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.
Files changed (28) hide show
  1. package/dist/agent-scheduler/index.js +324 -14
  2. package/dist/auth-broker/index.js +61 -4
  3. package/dist/cli/notion-write-pretool.mjs +61 -4
  4. package/dist/cli/switchroom.js +402 -113
  5. package/dist/host-control/main.js +61 -4
  6. package/dist/vault/approvals/kernel-server.js +62 -5
  7. package/dist/vault/broker/server.js +62 -5
  8. package/package.json +1 -1
  9. package/profiles/_base/cron-session.sh.hbs +30 -13
  10. package/profiles/_shared/agent-self-service.md.hbs +37 -0
  11. package/telegram-plugin/bridge/bridge.ts +38 -1
  12. package/telegram-plugin/dist/bridge/bridge.js +31 -1
  13. package/telegram-plugin/dist/gateway/gateway.js +536 -53
  14. package/telegram-plugin/dist/server.js +31 -1
  15. package/telegram-plugin/gateway/gateway.ts +169 -6
  16. package/telegram-plugin/gateway/ipc-protocol.ts +31 -1
  17. package/telegram-plugin/gateway/ipc-server.ts +29 -0
  18. package/telegram-plugin/gateway/linear-activity.ts +145 -0
  19. package/telegram-plugin/runtime-metrics.ts +14 -0
  20. package/telegram-plugin/scoped-approval.ts +253 -0
  21. package/telegram-plugin/tests/bridge-liveness-override.test.ts +21 -0
  22. package/telegram-plugin/tests/ipc-server-validate-send-outbound.test.ts +54 -0
  23. package/telegram-plugin/tests/linear-agent-activity.test.ts +1 -1
  24. package/telegram-plugin/tests/linear-create-issue.test.ts +211 -0
  25. package/telegram-plugin/tests/permission-verdict-resume-guard.test.ts +13 -0
  26. package/telegram-plugin/tests/runtime-metrics.test.ts +9 -0
  27. package/telegram-plugin/tests/scoped-approval.test.ts +254 -0
  28. 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',\n\]\)/)
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({