switchroom 0.15.12 → 0.15.14
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/dist/agent-scheduler/index.js +324 -14
- package/dist/auth-broker/index.js +61 -4
- package/dist/cli/notion-write-pretool.mjs +61 -4
- package/dist/cli/switchroom.js +402 -113
- package/dist/host-control/main.js +61 -4
- package/dist/vault/approvals/kernel-server.js +62 -5
- package/dist/vault/broker/server.js +62 -5
- package/package.json +1 -1
- package/profiles/_base/cron-session.sh.hbs +30 -13
- package/profiles/_shared/agent-self-service.md.hbs +37 -0
- package/telegram-plugin/bridge/bridge.ts +38 -1
- package/telegram-plugin/dist/bridge/bridge.js +31 -1
- package/telegram-plugin/dist/gateway/gateway.js +536 -53
- package/telegram-plugin/dist/server.js +31 -1
- package/telegram-plugin/gateway/gateway.ts +169 -6
- package/telegram-plugin/gateway/ipc-protocol.ts +31 -1
- package/telegram-plugin/gateway/ipc-server.ts +29 -0
- package/telegram-plugin/gateway/linear-activity.ts +145 -0
- package/telegram-plugin/runtime-metrics.ts +14 -0
- package/telegram-plugin/scoped-approval.ts +253 -0
- package/telegram-plugin/tests/bridge-liveness-override.test.ts +21 -0
- package/telegram-plugin/tests/ipc-server-validate-send-outbound.test.ts +54 -0
- package/telegram-plugin/tests/linear-agent-activity.test.ts +1 -1
- package/telegram-plugin/tests/linear-create-issue.test.ts +211 -0
- package/telegram-plugin/tests/permission-verdict-resume-guard.test.ts +13 -0
- package/telegram-plugin/tests/runtime-metrics.test.ts +9 -0
- package/telegram-plugin/tests/scoped-approval.test.ts +254 -0
- package/telegram-plugin/tests/send-outbound-wiring.test.ts +63 -0
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scoped, time-boxed approval — the "⏱ 30 min" tier, the middle rung
|
|
3
|
+
* between "✅ Allow once" (re-prompts on the very next call) and
|
|
4
|
+
* "🔁 Always…" (a durable `tools.allow` write that lasts forever). After
|
|
5
|
+
* the operator taps "⏱ 30 min" on a permission card, byte-identical
|
|
6
|
+
* in-scope requests auto-allow for a fixed window without re-carding.
|
|
7
|
+
*
|
|
8
|
+
* Design contract (reference/access-model.md — "you hold the leash"):
|
|
9
|
+
*
|
|
10
|
+
* - **Operator-authored only.** Every cache entry is created by an
|
|
11
|
+
* `allowFrom`-authenticated Telegram tap. No tool call can seed an
|
|
12
|
+
* entry (first contact always cards) or extend one (the window is a
|
|
13
|
+
* FIXED box — `recordScopedGrant` sets `expiresAt` once; a matching
|
|
14
|
+
* request never moves it). So an agent can never author its own
|
|
15
|
+
* authorization.
|
|
16
|
+
* - **Gateway-side only.** This store lives in the gateway process. It
|
|
17
|
+
* must NOT be pushed down to the bridge's `sessionAllowRules`
|
|
18
|
+
* (`bridge.ts`) — that cache is agent-uid, untimed, and would silently
|
|
19
|
+
* promote a 30-min grant to session-forever. The gateway dispatches
|
|
20
|
+
* the in-flight allow WITHOUT the `rule` field for exactly this reason.
|
|
21
|
+
* - **Conservative scope (this tier, v1).** Only the *narrow* scope is
|
|
22
|
+
* ever time-boxed: an exact file path (`Edit(/x.ts)`) or a Bash
|
|
23
|
+
* command-family (`Bash(git:*)`). Broad scopes ("any file", resource-
|
|
24
|
+
* blind MCP, "any command") are NOT offered the ⏱ button — they stay
|
|
25
|
+
* once / always. This covers the real fatigue (re-editing the same
|
|
26
|
+
* file, re-running a safe command) without fanning one tap across an
|
|
27
|
+
* unbounded action set.
|
|
28
|
+
* - **Fail-closed on irreversible.** A Bash family grant (`Bash(git:*)`)
|
|
29
|
+
* must never auto-allow a destructive member of that family
|
|
30
|
+
* (`git push --force`, `git reset --hard`). `isDestructiveBashCommand`
|
|
31
|
+
* is re-checked at BOTH grant time (don't offer ⏱) and match time
|
|
32
|
+
* (a cached family grant fails closed → re-cards) so per-call consent
|
|
33
|
+
* for irreversible actions is preserved.
|
|
34
|
+
*
|
|
35
|
+
* Pure + side-effect-free so it unit-tests under vitest AND bun without a
|
|
36
|
+
* Grammy context (mirrors permission-rule.ts). Callers pass `now`/`ttlMs`
|
|
37
|
+
* explicitly; nothing reads the clock here.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import { basename } from "node:path";
|
|
41
|
+
import { matchesAllowRule, type ScopedAllowChoices } from "./permission-rule.js";
|
|
42
|
+
|
|
43
|
+
/** Default time-box window: 30 minutes. */
|
|
44
|
+
export const SCOPED_APPROVAL_DEFAULT_TTL_MS = 30 * 60 * 1000;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Resolve the configured window from the environment. `0` (or negative)
|
|
48
|
+
* disables the tier — the gateway hides the ⏱ button and never
|
|
49
|
+
* short-circuits. A blank/garbage value falls back to the 30-min default.
|
|
50
|
+
* Kill-switch: `SWITCHROOM_SCOPED_APPROVAL_TTL_MS=0`.
|
|
51
|
+
*/
|
|
52
|
+
export function scopedApprovalTtlMs(
|
|
53
|
+
env: Record<string, string | undefined> = process.env,
|
|
54
|
+
): number {
|
|
55
|
+
const raw = env.SWITCHROOM_SCOPED_APPROVAL_TTL_MS;
|
|
56
|
+
if (raw === undefined || raw.trim() === "") return SCOPED_APPROVAL_DEFAULT_TTL_MS;
|
|
57
|
+
const n = Number(raw);
|
|
58
|
+
if (!Number.isFinite(n) || n < 0) return SCOPED_APPROVAL_DEFAULT_TTL_MS;
|
|
59
|
+
return Math.floor(n);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** A single operator-tapped, time-boxed grant. */
|
|
63
|
+
export interface ScopedGrant {
|
|
64
|
+
/** Narrow allow-rule, e.g. `Edit(/state/x.ts)` or `Bash(git:*)`. */
|
|
65
|
+
readonly rule: string;
|
|
66
|
+
/** Unix-ms when this grant stops auto-allowing. Fixed at grant time. */
|
|
67
|
+
readonly expiresAt: number;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Per-agent store of live time-boxed grants. Keyed by agent name. */
|
|
71
|
+
export type ScopedGrantStore = Map<string, ScopedGrant[]>;
|
|
72
|
+
|
|
73
|
+
/** The narrow rule + an honest operator-facing breadth phrase for the card. */
|
|
74
|
+
export interface TimeBoxDecision {
|
|
75
|
+
readonly rule: string;
|
|
76
|
+
/** e.g. "edits to x.ts" / "any `git` command" — states the real breadth. */
|
|
77
|
+
readonly breadth: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const FILE_RULE = /^(Edit|Write|MultiEdit|NotebookEdit|Read)\((.+)\)$/;
|
|
81
|
+
const BASH_FAMILY_RULE = /^Bash\(([^:]+):\*\)$/;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Conservative time-box eligibility. Given the already-resolved scope
|
|
85
|
+
* choices for a permission request, return the NARROW rule to time-box
|
|
86
|
+
* plus an honest breadth phrase — or `null` when this request must not
|
|
87
|
+
* get a ⏱ button (broad-only tools, MCP, Skill, a destructive Bash
|
|
88
|
+
* command, or any tool with no narrow sub-scope).
|
|
89
|
+
*
|
|
90
|
+
* - File tools with an exact path → time-boxable (bounded to the one
|
|
91
|
+
* operator-seen file).
|
|
92
|
+
* - Bash with a first-token family → time-boxable ONLY when the
|
|
93
|
+
* triggering command itself is non-destructive. The family grant
|
|
94
|
+
* still covers the whole `<tok>` family for matching, but match-time
|
|
95
|
+
* re-checks each later command (see `lookupScopedGrant`).
|
|
96
|
+
*/
|
|
97
|
+
export function resolveTimeBox(
|
|
98
|
+
toolName: string,
|
|
99
|
+
inputPreview: string | undefined,
|
|
100
|
+
choices: ScopedAllowChoices | null,
|
|
101
|
+
): TimeBoxDecision | null {
|
|
102
|
+
// Only ever time-box the narrow scope, and only when one exists.
|
|
103
|
+
const specific = choices?.specific;
|
|
104
|
+
if (!specific || specific.broad) return null;
|
|
105
|
+
const rule = specific.rule;
|
|
106
|
+
|
|
107
|
+
const fileMatch = FILE_RULE.exec(rule);
|
|
108
|
+
if (fileMatch) {
|
|
109
|
+
const verb = fileMatch[1] === "Read" ? "reads of" : "edits to";
|
|
110
|
+
return { rule, breadth: `${verb} ${basename(fileMatch[2]!)}` };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const bashMatch = BASH_FAMILY_RULE.exec(rule);
|
|
114
|
+
if (bashMatch) {
|
|
115
|
+
const cmd = readBashCommand(inputPreview);
|
|
116
|
+
// No command text to vet, or a destructive triggering command → don't
|
|
117
|
+
// offer the time-box at all (fall through to once / always).
|
|
118
|
+
if (!cmd || isDestructiveBashCommand(cmd)) return null;
|
|
119
|
+
return { rule, breadth: `any \`${bashMatch[1]}\` command` };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// MCP (resource-blind), Skill, broad-only tools → not time-boxed in the
|
|
123
|
+
// conservative tier.
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Record an operator-tapped 30-min grant. The window is a FIXED box: a
|
|
129
|
+
* re-tap on the same rule resets it (the operator just re-approved), but
|
|
130
|
+
* nothing else ever moves `expiresAt`. Re-tapping the same rule replaces
|
|
131
|
+
* the prior entry rather than accumulating duplicates. A non-positive
|
|
132
|
+
* `ttlMs` is a no-op (tier disabled).
|
|
133
|
+
*/
|
|
134
|
+
export function recordScopedGrant(
|
|
135
|
+
store: ScopedGrantStore,
|
|
136
|
+
agent: string,
|
|
137
|
+
rule: string,
|
|
138
|
+
now: number,
|
|
139
|
+
ttlMs: number,
|
|
140
|
+
): void {
|
|
141
|
+
if (ttlMs <= 0) return;
|
|
142
|
+
const list = store.get(agent) ?? [];
|
|
143
|
+
const others = list.filter((g) => g.rule !== rule);
|
|
144
|
+
others.push({ rule, expiresAt: now + ttlMs });
|
|
145
|
+
store.set(agent, others);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Is there a LIVE operator-tapped grant covering this request? Returns the
|
|
150
|
+
* matching rule (for the audit log line) or `null`.
|
|
151
|
+
*
|
|
152
|
+
* FAIL-CLOSED in two ways:
|
|
153
|
+
* 1. an expired grant never matches (→ re-card);
|
|
154
|
+
* 2. a destructive Bash command never auto-allows even when its family
|
|
155
|
+
* grant (`Bash(git:*)`) matches — `git push --force` re-cards even
|
|
156
|
+
* after `git status` was time-boxed.
|
|
157
|
+
*
|
|
158
|
+
* Never mutates the store (no window-sliding). Pure read.
|
|
159
|
+
*/
|
|
160
|
+
export function lookupScopedGrant(
|
|
161
|
+
store: ScopedGrantStore,
|
|
162
|
+
agent: string,
|
|
163
|
+
toolName: string,
|
|
164
|
+
inputPreview: string | undefined,
|
|
165
|
+
now: number,
|
|
166
|
+
): string | null {
|
|
167
|
+
const list = store.get(agent);
|
|
168
|
+
if (!list || list.length === 0) return null;
|
|
169
|
+
for (const g of list) {
|
|
170
|
+
if (g.expiresAt <= now) continue; // expired → fail closed
|
|
171
|
+
if (!matchesAllowRule(g.rule, toolName, inputPreview)) continue;
|
|
172
|
+
if (toolName === "Bash") {
|
|
173
|
+
const cmd = readBashCommand(inputPreview);
|
|
174
|
+
// Family grant matched, but re-vet THIS command: destructive or
|
|
175
|
+
// un-vettable → fail closed, re-card.
|
|
176
|
+
if (!cmd || isDestructiveBashCommand(cmd)) return null;
|
|
177
|
+
}
|
|
178
|
+
return g.rule;
|
|
179
|
+
}
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Drop expired grants. Call from the gateway's periodic sweep. */
|
|
184
|
+
export function sweepScopedGrants(store: ScopedGrantStore, now: number): void {
|
|
185
|
+
for (const [agent, list] of store) {
|
|
186
|
+
const live = list.filter((g) => g.expiresAt > now);
|
|
187
|
+
if (live.length === 0) store.delete(agent);
|
|
188
|
+
else if (live.length !== list.length) store.set(agent, live);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Heuristic destructive/irreversible-command detector. FAIL-CLOSED: when
|
|
194
|
+
* a command can't be read or looks risky, return `true` so it is never
|
|
195
|
+
* time-boxed (it re-prompts instead). Better to re-card a safe command
|
|
196
|
+
* than auto-allow a destructive one. Covers the access-model crown-jewel
|
|
197
|
+
* cases that ride the tool-permission channel: pipe-to-shell, privilege
|
|
198
|
+
* escalation, destructive coreutils/disk ops, recursive perms, device
|
|
199
|
+
* redirects, destructive git, power/process control, and bulk
|
|
200
|
+
* docker/package teardown.
|
|
201
|
+
*/
|
|
202
|
+
export function isDestructiveBashCommand(command: string): boolean {
|
|
203
|
+
if (!command || !command.trim()) return true;
|
|
204
|
+
const c = command.toLowerCase();
|
|
205
|
+
|
|
206
|
+
// Backtick command substitution is un-vettable: the destructive op can
|
|
207
|
+
// hide inside `…` where the command-position anchors below (which include
|
|
208
|
+
// `$(` but not the backtick) would miss it — e.g. `git status \`rm -rf x\``
|
|
209
|
+
// matches the `Bash(git:*)` family but its first token is the harmless
|
|
210
|
+
// `git`. There is no need to time-box a backtick-substituted command, so
|
|
211
|
+
// fail closed (re-card). `$(…)` substitution stays handled by the `(`
|
|
212
|
+
// anchor in the rules below.
|
|
213
|
+
if (c.includes("`")) return true;
|
|
214
|
+
|
|
215
|
+
// download-and-execute: ... | sh|bash|python|node
|
|
216
|
+
if (/\|\s*(sudo\s+)?(sh|bash|zsh|fish|python\d?|perl|ruby|node)\b/.test(c)) return true;
|
|
217
|
+
// privilege escalation
|
|
218
|
+
if (/(^|\s|;|&&|\|\||\()sudo\b/.test(c)) return true;
|
|
219
|
+
if (/(^|\s|;|&&|\|\||\()(su|doas)\s/.test(c)) return true;
|
|
220
|
+
// destructive coreutils / disk
|
|
221
|
+
if (/(^|\s|;|&&|\|\||\()(rm|rmdir|dd|shred|truncate|fdisk|mkfs\S*|wipefs|blkdiscard|fallocate)\b/.test(c)) return true;
|
|
222
|
+
// recursive permission / ownership changes (-R / --recursive)
|
|
223
|
+
if (/\b(chmod|chown|chgrp)\b[^|;&]*(\s-(-recursive|[a-z]*r[a-z]*)\b)/.test(c)) return true;
|
|
224
|
+
// redirection clobbering devices or system dirs
|
|
225
|
+
if (/>\s*\/(dev|etc|boot|sys|proc)\b/.test(c)) return true;
|
|
226
|
+
// destructive git
|
|
227
|
+
if (/\bgit\b/.test(c) &&
|
|
228
|
+
/(push\b[^|;&]*(--force|-f\b|--force-with-lease)|push\s+[^\s]*\s+\+|reset\s+--hard|clean\s+-[a-z]*[fd]|filter-branch|reflog\s+expire|update-ref\s+-d|branch\s+-d{1,2}\b|checkout\s+--\s|restore\b)/.test(c)) return true;
|
|
229
|
+
// power / process control
|
|
230
|
+
if (/(^|\s|;|&&|\|\||\()(shutdown|reboot|halt|poweroff|kill|killall|pkill)\b/.test(c)) return true;
|
|
231
|
+
if (/(^|\s)init\s+0\b/.test(c)) return true;
|
|
232
|
+
// bulk docker teardown / package removal
|
|
233
|
+
if (/\bdocker\b[^|;&]*\b(rm|prune)\b/.test(c)) return true;
|
|
234
|
+
if (/(^|\s)(apt|apt-get|yum|dnf|brew|pacman|npm|pnpm|yarn|pip\d?)\b[^|;&]*\b(remove|uninstall|purge|prune)\b/.test(c)) return true;
|
|
235
|
+
// fork bomb
|
|
236
|
+
if (/:\s*\(\s*\)\s*\{/.test(c)) return true;
|
|
237
|
+
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Extract the `command` string from a permission_request input preview. */
|
|
242
|
+
function readBashCommand(inputPreview: string | undefined): string | null {
|
|
243
|
+
if (!inputPreview || typeof inputPreview !== "string") return null;
|
|
244
|
+
const trimmed = inputPreview.trim();
|
|
245
|
+
if (!trimmed.startsWith("{")) return null;
|
|
246
|
+
try {
|
|
247
|
+
const parsed = JSON.parse(trimmed) as Record<string, unknown>;
|
|
248
|
+
const cmd = parsed?.command;
|
|
249
|
+
return typeof cmd === "string" && cmd.length > 0 ? cmd : null;
|
|
250
|
+
} catch {
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #2307 Tier-1 — the cron-session bridge writes its liveness file to a DISTINCT
|
|
3
|
+
* path so a live `<agent>-cron` bridge can't mask a dead MAIN bridge in the
|
|
4
|
+
* dashboard/doctor probe (RISK #2). The bridge is an IIFE (can't instantiate
|
|
5
|
+
* in-process), so this pins the wiring by source-read: the liveness file path
|
|
6
|
+
* honours `SWITCHROOM_BRIDGE_ALIVE_PATH`, falling back to the canonical
|
|
7
|
+
* STATE_DIR/.bridge-alive (unchanged for the main bridge).
|
|
8
|
+
*/
|
|
9
|
+
import { describe, it, expect } from "vitest";
|
|
10
|
+
import { readFileSync } from "node:fs";
|
|
11
|
+
import { resolve } from "node:path";
|
|
12
|
+
|
|
13
|
+
const bridgeSrc = readFileSync(resolve(__dirname, "..", "bridge", "bridge.ts"), "utf-8");
|
|
14
|
+
|
|
15
|
+
describe("bridge liveness path override (#2307 Tier-1)", () => {
|
|
16
|
+
it("honours SWITCHROOM_BRIDGE_ALIVE_PATH, falling back to STATE_DIR/.bridge-alive", () => {
|
|
17
|
+
expect(bridgeSrc).toMatch(
|
|
18
|
+
/livenessFilePath:\s*process\.env\.SWITCHROOM_BRIDGE_ALIVE_PATH\s*\?\?\s*join\(STATE_DIR,\s*["']\.bridge-alive["']\)/,
|
|
19
|
+
);
|
|
20
|
+
});
|
|
21
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validation contract for the `send_outbound` IPC verb (#2307 Tier-0 action
|
|
3
|
+
* tier) — the MODEL-FREE outbound the agent-scheduler sends for a `kind: action`
|
|
4
|
+
* telegram-message. A rogue process on the same UDS must not inject a malformed
|
|
5
|
+
* payload: agentName required + name-shaped, chatId + text required non-empty,
|
|
6
|
+
* threadId (optional) an integer, parseMode (optional) html|text.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, it, expect } from "vitest";
|
|
9
|
+
import { validateClientMessage } from "../gateway/ipc-server.js";
|
|
10
|
+
|
|
11
|
+
const base = { type: "send_outbound", agentName: "clerk", chatId: "12345", text: "Daily heartbeat" };
|
|
12
|
+
|
|
13
|
+
describe("validateClientMessage — send_outbound", () => {
|
|
14
|
+
it("accepts a well-formed minimal message", () => {
|
|
15
|
+
expect(validateClientMessage(base)).toBe(true);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("accepts threadId + parseMode", () => {
|
|
19
|
+
expect(validateClientMessage({ ...base, threadId: 7, parseMode: "html" })).toBe(true);
|
|
20
|
+
expect(validateClientMessage({ ...base, threadId: 1, parseMode: "text" })).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("rejects a missing / non-string / malformed agentName", () => {
|
|
24
|
+
expect(validateClientMessage({ ...base, agentName: undefined })).toBe(false);
|
|
25
|
+
expect(validateClientMessage({ ...base, agentName: 123 })).toBe(false);
|
|
26
|
+
expect(validateClientMessage({ ...base, agentName: "" })).toBe(false);
|
|
27
|
+
expect(validateClientMessage({ ...base, agentName: "../etc" })).toBe(false);
|
|
28
|
+
expect(validateClientMessage({ ...base, agentName: "Clerk UPPER" })).toBe(false);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("rejects a missing / empty chatId", () => {
|
|
32
|
+
expect(validateClientMessage({ ...base, chatId: undefined })).toBe(false);
|
|
33
|
+
expect(validateClientMessage({ ...base, chatId: "" })).toBe(false);
|
|
34
|
+
expect(validateClientMessage({ ...base, chatId: 123 })).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("rejects a missing / empty / over-long text", () => {
|
|
38
|
+
expect(validateClientMessage({ ...base, text: undefined })).toBe(false);
|
|
39
|
+
expect(validateClientMessage({ ...base, text: "" })).toBe(false);
|
|
40
|
+
expect(validateClientMessage({ ...base, text: 5 })).toBe(false);
|
|
41
|
+
expect(validateClientMessage({ ...base, text: "x".repeat(4096) })).toBe(true); // at the cap
|
|
42
|
+
expect(validateClientMessage({ ...base, text: "x".repeat(4097) })).toBe(false); // over Telegram's limit
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("rejects a non-integer threadId", () => {
|
|
46
|
+
expect(validateClientMessage({ ...base, threadId: 1.5 })).toBe(false);
|
|
47
|
+
expect(validateClientMessage({ ...base, threadId: "1" })).toBe(false);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("rejects an unknown parseMode", () => {
|
|
51
|
+
expect(validateClientMessage({ ...base, parseMode: "markdown" })).toBe(false);
|
|
52
|
+
expect(validateClientMessage({ ...base, parseMode: 1 })).toBe(false);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -51,7 +51,7 @@ describe('linear_agent_activity — gateway wiring (#2298)', () => {
|
|
|
51
51
|
})
|
|
52
52
|
|
|
53
53
|
it('is allow-listed and dispatched', () => {
|
|
54
|
-
expect(gw).toMatch(/'linear_agent_activity'
|
|
54
|
+
expect(gw).toMatch(/'linear_agent_activity',/)
|
|
55
55
|
expect(gw).toMatch(/case 'linear_agent_activity':\s*\n\s*return executeLinearAgentActivity\(args\)/)
|
|
56
56
|
})
|
|
57
57
|
})
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { readFileSync } from 'node:fs'
|
|
3
|
+
import {
|
|
4
|
+
createLinearIssue,
|
|
5
|
+
captureDedupMarker,
|
|
6
|
+
type LinearTokenResult,
|
|
7
|
+
} from '../gateway/linear-activity.js'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Tests for the `linear_create_issue` MCP tool (#2312 — capture-on-reaction
|
|
11
|
+
* → Linear).
|
|
12
|
+
*
|
|
13
|
+
* Structural part: assert the tool is declared in bridge/bridge.ts and
|
|
14
|
+
* allow-listed + dispatched in gateway/gateway.ts (the gateway IIFE can't be
|
|
15
|
+
* imported in a test, so wiring is verified by reading the source — same
|
|
16
|
+
* constraint as linear-agent-activity.test.ts).
|
|
17
|
+
*
|
|
18
|
+
* Behavioural part: the create logic lives in gateway/linear-activity.ts with
|
|
19
|
+
* an injectable token-resolver + fetch, so team auto-resolve, dedup, priority,
|
|
20
|
+
* and the vault-denied path are exercised without a broker or the network.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const okToken = (token: string) => async (): Promise<LinearTokenResult> => ({ ok: true, token })
|
|
24
|
+
|
|
25
|
+
/** A fake fetch that routes by GraphQL operation so a single test can answer
|
|
26
|
+
* the teams query, the searchIssues query, and the issueCreate mutation
|
|
27
|
+
* independently. Each handler returns the `data` payload. */
|
|
28
|
+
function routingFetch(handlers: {
|
|
29
|
+
teams?: () => unknown
|
|
30
|
+
searchIssues?: () => unknown
|
|
31
|
+
issueCreate?: () => unknown
|
|
32
|
+
status?: number
|
|
33
|
+
}): {
|
|
34
|
+
fetchImpl: typeof fetch
|
|
35
|
+
calls: Array<{ url: string; query: string; variables: Record<string, unknown>; headers: Record<string, string> }>
|
|
36
|
+
} {
|
|
37
|
+
const calls: Array<{ url: string; query: string; variables: Record<string, unknown>; headers: Record<string, string> }> = []
|
|
38
|
+
const status = handlers.status ?? 200
|
|
39
|
+
const fetchImpl = (async (url: string, init?: RequestInit) => {
|
|
40
|
+
const parsed = JSON.parse((init?.body as string) ?? '{}') as { query: string; variables: Record<string, unknown> }
|
|
41
|
+
calls.push({
|
|
42
|
+
url,
|
|
43
|
+
query: parsed.query,
|
|
44
|
+
variables: parsed.variables,
|
|
45
|
+
headers: (init?.headers as Record<string, string>) ?? {},
|
|
46
|
+
})
|
|
47
|
+
let data: unknown = {}
|
|
48
|
+
if (parsed.query.includes('teams')) data = handlers.teams?.() ?? {}
|
|
49
|
+
else if (parsed.query.includes('searchIssues')) data = handlers.searchIssues?.() ?? {}
|
|
50
|
+
else if (parsed.query.includes('issueCreate')) data = handlers.issueCreate?.() ?? {}
|
|
51
|
+
return {
|
|
52
|
+
ok: status >= 200 && status < 300,
|
|
53
|
+
status,
|
|
54
|
+
json: async () => ({ data }),
|
|
55
|
+
text: async () => JSON.stringify({ data }),
|
|
56
|
+
} as unknown as Response
|
|
57
|
+
}) as unknown as typeof fetch
|
|
58
|
+
return { fetchImpl, calls }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const oneTeam = () => ({ teams: { nodes: [{ id: 'team_1', key: 'ENG', name: 'Engineering' }] } })
|
|
62
|
+
const issueOk = () => ({ issueCreate: { success: true, issue: { id: 'i1', identifier: 'ENG-42', url: 'https://linear.app/acme/issue/ENG-42' } } })
|
|
63
|
+
|
|
64
|
+
describe('linear_create_issue — gateway wiring (#2312)', () => {
|
|
65
|
+
const gw = readFileSync(new URL('../gateway/gateway.ts', import.meta.url), 'utf8')
|
|
66
|
+
const bridge = readFileSync(new URL('../bridge/bridge.ts', import.meta.url), 'utf8')
|
|
67
|
+
|
|
68
|
+
it('declares the MCP tool with required {title,body}', () => {
|
|
69
|
+
const idx = bridge.indexOf(`name: 'linear_create_issue'`)
|
|
70
|
+
expect(idx).toBeGreaterThan(0)
|
|
71
|
+
const schema = bridge.slice(idx, idx + 2500)
|
|
72
|
+
expect(schema).toMatch(/required: \['title', 'body'\]/)
|
|
73
|
+
expect(schema).toMatch(/dedup_key/)
|
|
74
|
+
expect(schema).toMatch(/team_id/)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('is allow-listed and dispatched', () => {
|
|
78
|
+
expect(gw).toMatch(/'linear_create_issue',\n\]\)/)
|
|
79
|
+
expect(gw).toMatch(/case 'linear_create_issue':\s*\n\s*return executeLinearCreateIssue\(args\)/)
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
describe('createLinearIssue — behaviour (#2312)', () => {
|
|
84
|
+
it('auto-resolves the single team and POSTs issueCreate (zero-config)', async () => {
|
|
85
|
+
const { fetchImpl, calls } = routingFetch({ teams: oneTeam, issueCreate: issueOk })
|
|
86
|
+
const r = await createLinearIssue(
|
|
87
|
+
{ title: 'Fix Brevo retries', body: 'They double-fire on sync.' },
|
|
88
|
+
{ agent: 'clerk', resolveToken: okToken('lin_tok'), fetchImpl, log: () => {} },
|
|
89
|
+
)
|
|
90
|
+
expect(r.content[0].text).toBe('Filed: Fix Brevo retries → https://linear.app/acme/issue/ENG-42')
|
|
91
|
+
// teams query first, then issueCreate.
|
|
92
|
+
expect(calls).toHaveLength(2)
|
|
93
|
+
expect(calls[0].query).toMatch(/teams/)
|
|
94
|
+
expect(calls[1].query).toMatch(/issueCreate/)
|
|
95
|
+
expect(calls[1].variables.input).toMatchObject({ teamId: 'team_1', title: 'Fix Brevo retries' })
|
|
96
|
+
// raw token, no Bearer prefix (Linear convention).
|
|
97
|
+
expect(calls[1].headers.Authorization).toBe('lin_tok')
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('skips team resolution when team_id is passed', async () => {
|
|
101
|
+
const { fetchImpl, calls } = routingFetch({ issueCreate: issueOk })
|
|
102
|
+
const r = await createLinearIssue(
|
|
103
|
+
{ title: 'X', body: 'Y', team_id: 'team_explicit' },
|
|
104
|
+
{ agent: 'clerk', resolveToken: okToken('t'), fetchImpl, log: () => {} },
|
|
105
|
+
)
|
|
106
|
+
expect(r.content[0].text).toMatch(/Filed:/)
|
|
107
|
+
expect(calls).toHaveLength(1)
|
|
108
|
+
expect(calls[0].query).toMatch(/issueCreate/)
|
|
109
|
+
expect(calls[0].variables.input).toMatchObject({ teamId: 'team_explicit' })
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('uses deps.defaultTeamId when no team_id is passed (multi-team default)', async () => {
|
|
113
|
+
const { fetchImpl, calls } = routingFetch({ issueCreate: issueOk })
|
|
114
|
+
const r = await createLinearIssue(
|
|
115
|
+
{ title: 'X', body: 'Y' },
|
|
116
|
+
{ resolveToken: okToken('t'), fetchImpl, defaultTeamId: 'team_default', log: () => {} },
|
|
117
|
+
)
|
|
118
|
+
expect(r.content[0].text).toMatch(/Filed:/)
|
|
119
|
+
// default team skips the teams() resolution entirely.
|
|
120
|
+
expect(calls).toHaveLength(1)
|
|
121
|
+
expect(calls[0].query).toMatch(/issueCreate/)
|
|
122
|
+
expect(calls[0].variables.input).toMatchObject({ teamId: 'team_default' })
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('explicit team_id overrides deps.defaultTeamId', async () => {
|
|
126
|
+
const { fetchImpl, calls } = routingFetch({ issueCreate: issueOk })
|
|
127
|
+
await createLinearIssue(
|
|
128
|
+
{ title: 'X', body: 'Y', team_id: 'team_explicit' },
|
|
129
|
+
{ resolveToken: okToken('t'), fetchImpl, defaultTeamId: 'team_default', log: () => {} },
|
|
130
|
+
)
|
|
131
|
+
expect(calls[0].variables.input).toMatchObject({ teamId: 'team_explicit' })
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('passes priority through when provided', async () => {
|
|
135
|
+
const { fetchImpl, calls } = routingFetch({ issueCreate: issueOk })
|
|
136
|
+
await createLinearIssue(
|
|
137
|
+
{ title: 'X', body: 'Y', team_id: 't', priority: 1 },
|
|
138
|
+
{ resolveToken: okToken('t'), fetchImpl, log: () => {} },
|
|
139
|
+
)
|
|
140
|
+
expect((calls[0].variables.input as Record<string, unknown>).priority).toBe(1)
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('errors actionably when the workspace has multiple teams and none is given', async () => {
|
|
144
|
+
const { fetchImpl } = routingFetch({
|
|
145
|
+
teams: () => ({ teams: { nodes: [{ id: 'a', key: 'ENG', name: 'Engineering' }, { id: 'b', key: 'OPS', name: 'Operations' }] } }),
|
|
146
|
+
})
|
|
147
|
+
const r = await createLinearIssue(
|
|
148
|
+
{ title: 'X', body: 'Y' },
|
|
149
|
+
{ resolveToken: okToken('t'), fetchImpl, log: () => {} },
|
|
150
|
+
)
|
|
151
|
+
expect(r.content[0].text).toMatch(/multiple teams/)
|
|
152
|
+
expect(r.content[0].text).toMatch(/ENG \(Engineering\)/)
|
|
153
|
+
expect(r.content[0].text).toMatch(/default_team_id/)
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('short-circuits to "Already filed" on a dedup hit', async () => {
|
|
157
|
+
const { fetchImpl, calls } = routingFetch({
|
|
158
|
+
searchIssues: () => ({ searchIssues: { nodes: [{ id: 'old', url: 'https://linear.app/acme/issue/ENG-7', title: 'prior' }] } }),
|
|
159
|
+
teams: oneTeam,
|
|
160
|
+
issueCreate: issueOk,
|
|
161
|
+
})
|
|
162
|
+
const r = await createLinearIssue(
|
|
163
|
+
{ title: 'X', body: 'Y', dedup_key: 'chat:99' },
|
|
164
|
+
{ resolveToken: okToken('t'), fetchImpl, log: () => {} },
|
|
165
|
+
)
|
|
166
|
+
expect(r.content[0].text).toBe('Already filed: https://linear.app/acme/issue/ENG-7')
|
|
167
|
+
// only the search ran — no team resolve, no create.
|
|
168
|
+
expect(calls).toHaveLength(1)
|
|
169
|
+
expect(calls[0].query).toMatch(/searchIssues/)
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it('falls through to create when the dedup search misses, and embeds the marker', async () => {
|
|
173
|
+
const { fetchImpl, calls } = routingFetch({
|
|
174
|
+
searchIssues: () => ({ searchIssues: { nodes: [] } }),
|
|
175
|
+
teams: oneTeam,
|
|
176
|
+
issueCreate: issueOk,
|
|
177
|
+
})
|
|
178
|
+
const r = await createLinearIssue(
|
|
179
|
+
{ title: 'X', body: 'Y', dedup_key: 'chat:99' },
|
|
180
|
+
{ resolveToken: okToken('t'), fetchImpl, log: () => {} },
|
|
181
|
+
)
|
|
182
|
+
expect(r.content[0].text).toMatch(/Filed:/)
|
|
183
|
+
const create = calls.find((c) => c.query.includes('issueCreate'))!
|
|
184
|
+
expect((create.variables.input as Record<string, unknown>).description).toContain(captureDedupMarker('chat:99'))
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('returns vault_request_access guidance when the token is denied', async () => {
|
|
188
|
+
const r = await createLinearIssue(
|
|
189
|
+
{ title: 'X', body: 'Y' },
|
|
190
|
+
{ agent: 'clerk', resolveToken: async () => ({ ok: false, reason: 'denied' }), log: () => {} },
|
|
191
|
+
)
|
|
192
|
+
expect(r.content[0].text).toMatch(/Couldn't file to Linear: no token/)
|
|
193
|
+
expect(r.content[0].text).toMatch(/vault_request_access/)
|
|
194
|
+
expect(r.content[0].text).toMatch(/linear\/clerk\/token/)
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it('requires a title', async () => {
|
|
198
|
+
await expect(
|
|
199
|
+
createLinearIssue({ body: 'Y' }, { resolveToken: okToken('t'), log: () => {} }),
|
|
200
|
+
).rejects.toThrow(/title is required/)
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
it('surfaces a Linear API error status', async () => {
|
|
204
|
+
const { fetchImpl } = routingFetch({ teams: oneTeam, issueCreate: issueOk, status: 401 })
|
|
205
|
+
const r = await createLinearIssue(
|
|
206
|
+
{ title: 'X', body: 'Y' },
|
|
207
|
+
{ resolveToken: okToken('t'), fetchImpl, log: () => {} },
|
|
208
|
+
)
|
|
209
|
+
expect(r.content[0].text).toMatch(/Linear API 401/)
|
|
210
|
+
})
|
|
211
|
+
})
|
|
@@ -54,6 +54,17 @@ const dispatchCallsites = LINES.flatMap((line, i) =>
|
|
|
54
54
|
: [],
|
|
55
55
|
)
|
|
56
56
|
|
|
57
|
+
// A SILENT auto-allow path (the "⏱ 30 min" scoped-approval short-circuit in
|
|
58
|
+
// onPermissionRequest) posts NO card: the turn was never parked on 🙏, so it
|
|
59
|
+
// must NOT call resumeReactionAfterVerdict() / postPermissionResumeMessage()
|
|
60
|
+
// — doing so on every auto-allowed call is the exact noise that tier removes.
|
|
61
|
+
// Such callsites carry the `no-card-verdict` sentinel within the 3 lines above
|
|
62
|
+
// the dispatch and are exempt from the resume/post pairing. The invariant the
|
|
63
|
+
// guard protects (a verdict that un-parks a CARD must visibly resume it) still
|
|
64
|
+
// holds for every card-bearing path.
|
|
65
|
+
const isSilentNoCardVerdict = (idx: number): boolean =>
|
|
66
|
+
LINES.slice(Math.max(0, idx - 3), idx + 1).some((l) => /no-card-verdict/.test(l))
|
|
67
|
+
|
|
57
68
|
// How far below the dispatch the resume call is allowed to live. The
|
|
58
69
|
// widest real gap today is ~9 lines (the slash-command path); 15 gives
|
|
59
70
|
// refactor headroom without letting an unrelated resume "cover" a
|
|
@@ -68,6 +79,7 @@ describe('permission verdict → resume reaction wiring', () => {
|
|
|
68
79
|
it('every dispatchPermissionVerdict() callsite flips the awaiting glyph back via resumeReactionAfterVerdict()', () => {
|
|
69
80
|
const unpaired: number[] = []
|
|
70
81
|
for (const idx of dispatchCallsites) {
|
|
82
|
+
if (isSilentNoCardVerdict(idx)) continue
|
|
71
83
|
const window = LINES.slice(idx, idx + RESUME_WINDOW + 1).join('\n')
|
|
72
84
|
if (!/\bresumeReactionAfterVerdict\s*\(\s*\)/.test(window)) {
|
|
73
85
|
// 1-based line number for a human-readable failure.
|
|
@@ -98,6 +110,7 @@ describe('permission verdict → resume reaction wiring', () => {
|
|
|
98
110
|
it('every dispatchPermissionVerdict() callsite posts the agent-voiced resume message via postPermissionResumeMessage()', () => {
|
|
99
111
|
const unpaired: number[] = []
|
|
100
112
|
for (const idx of dispatchCallsites) {
|
|
113
|
+
if (isSilentNoCardVerdict(idx)) continue
|
|
101
114
|
const window = LINES.slice(idx, idx + POST_WINDOW + 1).join('\n')
|
|
102
115
|
if (!/\bpostPermissionResumeMessage\s*\(/.test(window)) {
|
|
103
116
|
unpaired.push(idx + 1)
|
|
@@ -82,6 +82,15 @@ describe('runtime-metrics — JSONL sink', () => {
|
|
|
82
82
|
expect(parsed.ended_via).toBe('reply')
|
|
83
83
|
})
|
|
84
84
|
|
|
85
|
+
it('cron_fell_back_to_main carries agent + prompt_key (#2307 Tier-1)', () => {
|
|
86
|
+
emitRuntimeMetric({ kind: 'cron_fell_back_to_main', agent: 'clerk', prompt_key: 'abc123' })
|
|
87
|
+
const parsed = JSON.parse(readFileSync(metricsPath, 'utf-8').trim())
|
|
88
|
+
expect(parsed.kind).toBe('cron_fell_back_to_main')
|
|
89
|
+
expect(parsed.agent).toBe('clerk')
|
|
90
|
+
expect(parsed.prompt_key).toBe('abc123')
|
|
91
|
+
expect(typeof parsed.ts).toBe('number')
|
|
92
|
+
})
|
|
93
|
+
|
|
85
94
|
it('appends — does not overwrite — across calls', () => {
|
|
86
95
|
for (let i = 0; i < 5; i++) {
|
|
87
96
|
emitRuntimeMetric({
|