switchroom 0.18.12 → 0.18.13

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 (49) hide show
  1. package/dist/agent-scheduler/index.js +8 -0
  2. package/dist/auth-broker/index.js +63 -65
  3. package/dist/cli/ms-365-write-pretool.mjs +31 -8
  4. package/dist/cli/notion-write-pretool.mjs +9 -1
  5. package/dist/cli/skill-validate-pretool.mjs +144 -2847
  6. package/dist/cli/switchroom.js +952 -3126
  7. package/dist/host-control/main.js +216 -2862
  8. package/dist/vault/approvals/kernel-server.js +67 -0
  9. package/dist/vault/broker/server.js +98 -44
  10. package/package.json +1 -1
  11. package/telegram-plugin/dist/bridge/bridge.js +49 -3
  12. package/telegram-plugin/dist/gateway/gateway.js +656 -2326
  13. package/telegram-plugin/dist/server.js +65 -3
  14. package/telegram-plugin/format.ts +19 -0
  15. package/telegram-plugin/gateway/approval-hold.ts +21 -2
  16. package/telegram-plugin/gateway/callback-query-handlers.ts +12 -0
  17. package/telegram-plugin/gateway/gateway.ts +221 -73
  18. package/telegram-plugin/history.ts +51 -0
  19. package/telegram-plugin/inline-keyboard-callbacks.ts +94 -0
  20. package/telegram-plugin/model-unavailable.ts +41 -11
  21. package/telegram-plugin/outbound-field-redact.ts +69 -0
  22. package/telegram-plugin/render/render.ts +32 -14
  23. package/telegram-plugin/scoped-approval.ts +11 -2
  24. package/telegram-plugin/secret-detect/chunker.ts +18 -4
  25. package/telegram-plugin/secret-detect/index.ts +12 -56
  26. package/telegram-plugin/send-gate-degraded.test.ts +131 -0
  27. package/telegram-plugin/send-gate.test.ts +25 -6
  28. package/telegram-plugin/send-gate.ts +82 -8
  29. package/telegram-plugin/session-tail.ts +82 -7
  30. package/telegram-plugin/subagent-watcher.ts +71 -16
  31. package/telegram-plugin/tests/approval-hold-outcome.test.ts +36 -5
  32. package/telegram-plugin/tests/callback-query-handlers.test.ts +65 -0
  33. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +57 -0
  34. package/telegram-plugin/tests/history.test.ts +115 -0
  35. package/telegram-plugin/tests/inbound-message-types.test.ts +5 -1
  36. package/telegram-plugin/tests/inline-keyboard-callbacks.test.ts +164 -0
  37. package/telegram-plugin/tests/operator-events-session-tail.test.ts +74 -0
  38. package/telegram-plugin/tests/outbound-field-redact.test.ts +107 -0
  39. package/telegram-plugin/tests/reaction-gate-routing.test.ts +173 -0
  40. package/telegram-plugin/tests/render/render.test.ts +88 -0
  41. package/telegram-plugin/tests/scoped-approval.test.ts +27 -0
  42. package/telegram-plugin/tests/secret-detect-chunk-overlap.test.ts +65 -0
  43. package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +5 -4
  44. package/telegram-plugin/tests/session-tail-sidecar-reap.test.ts +268 -0
  45. package/telegram-plugin/tests/subagent-watcher-fd-leak.test.ts +275 -0
  46. package/telegram-plugin/tests/worktree-watch-cwds.test.ts +215 -1
  47. package/telegram-plugin/worktree-watch-cwds.ts +194 -5
  48. package/telegram-plugin/secret-detect/secretlint-source.ts +0 -95
  49. package/telegram-plugin/tests/secret-detect-secretlint.test.ts +0 -105
@@ -0,0 +1,173 @@
1
+ /**
2
+ * #3155 — `setMessageReaction` must route through the send gate + flood breaker.
3
+ *
4
+ * Before this change ~13 raw `bot.api.setMessageReaction(...)` /
5
+ * `lockedBot.api.setMessageReaction(...)` call sites (early-ack 👀, status
6
+ * reactions, stop/interrupt ⚡, permission verdict ✅/❌, refusal 🚫, ack-only,
7
+ * the MCP `react` tool, the crash-recovery sweeps, the auth-code 🔑 redact)
8
+ * fired reactions OUTSIDE both:
9
+ *
10
+ * (a) the send gate — so reaction churn was never paced/shed under flood
11
+ * pressure even with the gate ON; and
12
+ * (b) the flood circuit breaker — a 429 from a reaction never reached the
13
+ * retry module's `onFloodWait` hook, so the ban went unrecorded.
14
+ *
15
+ * Reactions were therefore a residual flood vector even when the gate is on.
16
+ * The gateway now funnels every reaction through ONE seam,
17
+ * `robustApiCall(() => lockedBot.api.setMessageReaction(...), { priorityClass:
18
+ * 'cosmetic', verb: 'set-message-reaction' })`.
19
+ *
20
+ * Two levels of coverage:
21
+ * 1. BEHAVIOURAL — compose the real `send-gate` + `retry-api-call` modules the
22
+ * same way `gateway.ts` wires `robustApiCall`, and prove a cosmetic
23
+ * reaction is (a) shed when a flood window is open and (b) visible to
24
+ * `onFloodWait` on a 429.
25
+ * 2. LOAD-BEARING SOURCE SCAN — assert the gateway has exactly ONE runtime
26
+ * `setMessageReaction` call and that it is the cosmetic seam. This FAILS if
27
+ * any future change re-introduces a raw (ungated, breaker-blind) reaction.
28
+ */
29
+ import { describe, it, expect, vi } from 'vitest'
30
+ import { readFileSync } from 'node:fs'
31
+ import { fileURLToPath } from 'node:url'
32
+ import { createSendGate, type Clock } from '../send-gate.js'
33
+ import { createRetryApiCall } from '../retry-api-call.js'
34
+ import { errors } from './fake-bot-api.js'
35
+
36
+ const GATEWAY_SRC = fileURLToPath(new URL('../gateway/gateway.ts', import.meta.url))
37
+
38
+ /** Fixed-time clock; `sleep` is a no-op so nothing actually waits under test. */
39
+ function fixedClock(now: number): Clock {
40
+ return { now: () => now, sleep: () => Promise.resolve() }
41
+ }
42
+
43
+ /**
44
+ * Reconstruct the gateway's outbound wiring for reactions: `robustApiCall =
45
+ * gate(rawRetry)`, and a `sendReaction` closure that tags the call `cosmetic`
46
+ * exactly like `gatedSetMessageReaction` in gateway.ts.
47
+ */
48
+ function wireReactionPath(opts: {
49
+ clock: Clock
50
+ enabled: boolean
51
+ windowUntilTs?: number
52
+ onFloodWait?: (retryAfterSec: number) => void
53
+ }) {
54
+ const setMessageReaction = vi.fn(async () => true as const)
55
+ const sendGate = createSendGate({
56
+ enabled: opts.enabled,
57
+ clock: opts.clock,
58
+ ...(opts.windowUntilTs != null
59
+ ? { initialWindows: [{ scopeKey: 'global', untilTs: opts.windowUntilTs }] }
60
+ : {}),
61
+ })
62
+ const rawRetry = createRetryApiCall({
63
+ maxRetries: 2,
64
+ sleep: async () => {},
65
+ ...(opts.onFloodWait ? { onFloodWait: opts.onFloodWait } : {}),
66
+ })
67
+ const robustApiCall = <T>(
68
+ fn: () => Promise<T>,
69
+ o?: Parameters<typeof rawRetry<T>>[1],
70
+ ): Promise<T> => sendGate.gate(() => rawRetry(fn, o), o)
71
+
72
+ // Mirrors gateway.ts `gatedSetMessageReaction` / `sendReaction`.
73
+ const sendReaction = (chatId: string, messageId: number, emoji: string): Promise<unknown> =>
74
+ robustApiCall(() => setMessageReaction(chatId, messageId, [{ type: 'emoji', emoji }]), {
75
+ chat_id: chatId,
76
+ verb: 'set-message-reaction',
77
+ priorityClass: 'cosmetic',
78
+ })
79
+
80
+ return { sendReaction, setMessageReaction, sendGate }
81
+ }
82
+
83
+ describe('#3155 reactions route through the send gate (cosmetic)', () => {
84
+ it('SHEDS a reaction while a flood window is open (paced/shed by the gate)', async () => {
85
+ const now = 1_000_000
86
+ const { sendReaction, setMessageReaction, sendGate } = wireReactionPath({
87
+ clock: fixedClock(now),
88
+ enabled: true,
89
+ windowUntilTs: now + 60_000, // an open global ban window
90
+ })
91
+
92
+ const result = await sendReaction('chatA', 42, '👀')
93
+
94
+ // Cosmetic + a covering window ⇒ shed: the API is NEVER hit, the promise
95
+ // resolves undefined (fire-and-forget callers .catch nothing), and the gate
96
+ // counts the shed.
97
+ expect(setMessageReaction).not.toHaveBeenCalled()
98
+ expect(result).toBeUndefined()
99
+ expect(sendGate.stats().global.shed).toBe(1)
100
+ })
101
+
102
+ it('does NOT shed when the gate is OFF (proves the gate is what sheds)', async () => {
103
+ const now = 1_000_000
104
+ const { sendReaction, setMessageReaction } = wireReactionPath({
105
+ clock: fixedClock(now),
106
+ enabled: false, // gate disabled ⇒ pure passthrough
107
+ windowUntilTs: now + 60_000,
108
+ })
109
+
110
+ await sendReaction('chatA', 42, '👀')
111
+
112
+ // Flag OFF ⇒ the reaction still fires (the window only bites when enabled).
113
+ expect(setMessageReaction).toHaveBeenCalledTimes(1)
114
+ })
115
+
116
+ it('records a 429 on a reaction to the flood breaker via onFloodWait', async () => {
117
+ const now = 1_000_000
118
+ const onFloodWait = vi.fn()
119
+ const { sendReaction, setMessageReaction } = wireReactionPath({
120
+ clock: fixedClock(now),
121
+ enabled: true, // gate on, no open window ⇒ the cosmetic call is admitted
122
+ onFloodWait,
123
+ })
124
+ // The reaction send hits a Telegram 429 flood-wait.
125
+ setMessageReaction.mockRejectedValue(errors.floodWait(7, 'setMessageReaction'))
126
+
127
+ // Fire-and-forget semantics: callers swallow. The point is the SIDE EFFECT.
128
+ await sendReaction('chatA', 42, '🤝').catch(() => {})
129
+
130
+ // The reaction actually reached the API (was admitted, not shed)...
131
+ expect(setMessageReaction).toHaveBeenCalled()
132
+ // ...and its 429 was recorded by the breaker — the whole gap this closes.
133
+ expect(onFloodWait).toHaveBeenCalledWith(7)
134
+ })
135
+ })
136
+
137
+ describe('#3155 load-bearing: gateway.ts sends no reaction raw', () => {
138
+ const src = readFileSync(GATEWAY_SRC, 'utf-8')
139
+ const lines = src.split('\n')
140
+
141
+ /** Non-comment source lines that call `(bot|lockedBot).api.setMessageReaction`. */
142
+ const rawReactionLines = lines
143
+ .map((text, i) => ({ text, n: i + 1 }))
144
+ .filter(({ text }) => {
145
+ const t = text.trim()
146
+ if (t.startsWith('*') || t.startsWith('//') || t.startsWith('/*')) return false
147
+ return /\b(bot|lockedBot)\.api\.setMessageReaction\b/.test(text)
148
+ })
149
+
150
+ it('has EXACTLY ONE runtime setMessageReaction callsite (the shared seam)', () => {
151
+ // If this fails with >1, a raw reaction was re-introduced somewhere —
152
+ // ungated and invisible to the flood breaker. Route it through
153
+ // `sendReaction` / `gatedSetMessageReaction` instead.
154
+ expect(rawReactionLines).toHaveLength(1)
155
+ })
156
+
157
+ it('the sole seam is tagged cosmetic and routed through robustApiCall', () => {
158
+ const seamLine = rawReactionLines[0]!.n
159
+ // Look at the seam line + a small window for its opts object.
160
+ const window = lines.slice(seamLine - 2, seamLine + 6).join('\n')
161
+ expect(window).toContain('robustApiCall(')
162
+ expect(window).toContain("priorityClass: 'cosmetic'")
163
+ expect(window).toContain("verb: 'set-message-reaction'")
164
+ })
165
+
166
+ it('no reaction is fired as a raw fire-and-forget bot.api call', () => {
167
+ // Belt-and-braces: the old `bot.api.setMessageReaction(...).catch(() => {})`
168
+ // shape must be gone entirely.
169
+ expect(src).not.toMatch(/bot\.api\.setMessageReaction\([^)]*\)\s*\.catch/)
170
+ // And the fire-and-forget callers now go through the helper.
171
+ expect(src).toContain('void sendReaction(')
172
+ })
173
+ })
@@ -48,6 +48,65 @@ describe("render: inline palette", () => {
48
48
  "[label](https://example.com)",
49
49
  );
50
50
  });
51
+ it("escapes a `)` in a link href so the URL is not truncated (F3)", () => {
52
+ // An angle-bracket destination is the only way to smuggle an UNBALANCED
53
+ // `)` into an href via the parser (a bare `)` would otherwise close the
54
+ // destination). CommonMark strips the angle brackets → href holds the `)`.
55
+ const doc = parse("[wiki](<https://example.com/a)b>)");
56
+ const link = (doc.blocks[0] as any).children.find((c: any) => c.type === "link");
57
+ expect(link.href).toBe("https://example.com/a)b"); // parser captured the full URL
58
+
59
+ const out = render(doc);
60
+ // The `)` is backslash-escaped so it can no longer terminate the `(...)`
61
+ // destination early — a valid Bot API 10.1 link, not a broken one.
62
+ expect(out).toContain("a\\)b");
63
+ // And it round-trips: re-parsing recovers the SAME href, not a truncated
64
+ // `https://example.com/a` with stray `b)` prose spilled after it.
65
+ const reparsed = parse(out);
66
+ const reLink = (reparsed.blocks[0] as any).children.find((c: any) => c.type === "link");
67
+ expect(reLink.href).toBe("https://example.com/a)b");
68
+ });
69
+ it("round-trips a balanced-paren href (Wikipedia disambiguation) without leaking a backslash", () => {
70
+ // A bare destination with BALANCED parens is legal CommonMark; the parser
71
+ // captures the whole URL including the inner `(...)`. Escaping only `)`
72
+ // (the old fix) would unbalance the parens and leak a literal backslash
73
+ // into the decoded href. Escaping both parens keeps it balanced.
74
+ const src = "[w](https://en.wikipedia.org/wiki/Foo_(disambiguation))";
75
+ const doc = parse(src);
76
+ const link = (doc.blocks[0] as any).children.find((c: any) => c.type === "link");
77
+ expect(link.href).toBe("https://en.wikipedia.org/wiki/Foo_(disambiguation)");
78
+
79
+ const out = render(doc);
80
+ const reparsed = parse(out);
81
+ const reLink = (reparsed.blocks[0] as any).children.find((c: any) => c.type === "link");
82
+ // Re-parsed href is byte-for-byte the original — no truncation, no stray `\`.
83
+ expect(reLink.href).toBe("https://en.wikipedia.org/wiki/Foo_(disambiguation)");
84
+ expect(reLink.href).not.toContain("\\");
85
+ });
86
+ it("round-trips an href with multiple balanced paren groups", () => {
87
+ const src = "[m](https://example.com/a(b)c(d))";
88
+ const doc = parse(src);
89
+ const link = (doc.blocks[0] as any).children.find((c: any) => c.type === "link");
90
+ expect(link.href).toBe("https://example.com/a(b)c(d)");
91
+
92
+ const out = render(doc);
93
+ const reparsed = parse(out);
94
+ const reLink = (reparsed.blocks[0] as any).children.find((c: any) => c.type === "link");
95
+ expect(reLink.href).toBe("https://example.com/a(b)c(d)");
96
+ expect(reLink.href).not.toContain("\\");
97
+ });
98
+ it("round-trips an href with a lone unbalanced `)` without leaking a backslash", () => {
99
+ // Angle-bracket destination smuggles a lone `)` past the parser.
100
+ const doc = parse("[x](<https://example.com/a)b>)");
101
+ const link = (doc.blocks[0] as any).children.find((c: any) => c.type === "link");
102
+ expect(link.href).toBe("https://example.com/a)b");
103
+
104
+ const out = render(doc);
105
+ const reparsed = parse(out);
106
+ const reLink = (reparsed.blocks[0] as any).children.find((c: any) => c.type === "link");
107
+ expect(reLink.href).toBe("https://example.com/a)b");
108
+ expect(reLink.href).not.toContain("\\");
109
+ });
51
110
  it("underline", () => {
52
111
  expect(render(parse("__hi__"))).toBe("__hi__");
53
112
  });
@@ -226,6 +285,35 @@ describe("render: block palette", () => {
226
285
  const out = render(parse(md));
227
286
  expect(out).toContain("**bold** cell");
228
287
  });
288
+ it("neutralizes a `|` inside a code span in a table cell so the row survives (F4)", () => {
289
+ // Source pipe inside the code span is `\|`-escaped so the INPUT is a valid
290
+ // GFM table; the parser folds it to a code node whose text is `a|b`.
291
+ const md = ["| Col |", "| --- |", "| `a\\|b` |"].join("\n");
292
+ const doc = parse(md);
293
+ const srcCell = (doc.blocks[0] as TableNode).rows[0].cells[0].children.find(
294
+ (c: any) => c.type === "code",
295
+ ) as any;
296
+ expect(srcCell.text).toBe("a|b"); // parser captured the literal pipe
297
+
298
+ const out = render(doc);
299
+ // The `|` is re-escaped inside the code span so it can't be read as a
300
+ // column separator and tear the row — valid Bot API 10.1 GFM output.
301
+ expect(out).toContain("`a\\|b`");
302
+ // Every rendered table line stays a structurally-valid row (`|`-delimited,
303
+ // balanced) — a torn row would start with `|` but not end with one.
304
+ for (const line of out.split("\n")) {
305
+ if (line.trimStart().startsWith("|")) {
306
+ expect(line.trimEnd().endsWith("|")).toBe(true);
307
+ }
308
+ }
309
+ // And it round-trips: the re-parsed table still has ONE body cell whose
310
+ // code text is `a|b`, not a torn row with an unterminated code span.
311
+ const reparsed = parse(out);
312
+ const reRow = (reparsed.blocks[0] as TableNode).rows[0];
313
+ expect(reRow.cells).toHaveLength(1);
314
+ const reCell = reRow.cells[0].children.find((c: any) => c.type === "code") as any;
315
+ expect(reCell.text).toBe("a|b");
316
+ });
229
317
  });
230
318
 
231
319
  describe("render: full document", () => {
@@ -270,6 +270,33 @@ describe('isDestructiveBashCommand — fail-closed denylist', () => {
270
270
  expect(timeBoxRule('Bash', bashInput('git status `rm -rf x`'))).toBeNull()
271
271
  })
272
272
 
273
+ it('flags destructive git checkout / stash forms that discard work (fail-closed)', () => {
274
+ for (const cmd of [
275
+ // checkout that discards uncommitted working-tree changes
276
+ 'git checkout .', 'git checkout -f', 'git checkout -f main', 'git checkout --force',
277
+ 'git checkout -- file.ts', 'git checkout HEAD -- .', 'git checkout HEAD~1 -- src/x.ts',
278
+ // `./` and `./<path>` are common spellings of `.` — same whole-tree /
279
+ // subtree discard; git refnames forbid a branch starting with `./`,
280
+ // so gating these carries no false-positive risk.
281
+ 'git checkout ./', 'git checkout ./src',
282
+ // stash forms that irreversibly remove stash state
283
+ 'git stash drop', 'git stash drop stash@{2}', 'git stash clear', 'git stash pop',
284
+ ]) {
285
+ expect(isDestructiveBashCommand(cmd), cmd).toBe(true)
286
+ }
287
+ })
288
+
289
+ it('does NOT flag safe git checkout / stash forms (no over-broadening)', () => {
290
+ for (const cmd of [
291
+ // branch switches / creation are reversible
292
+ 'git checkout main', 'git checkout -b feature', 'git checkout feature.branch',
293
+ // stash inspection / non-removing forms keep the stash
294
+ 'git stash', 'git stash list', 'git stash show', 'git stash apply',
295
+ ]) {
296
+ expect(isDestructiveBashCommand(cmd), cmd).toBe(false)
297
+ }
298
+ })
299
+
273
300
  it('does NOT flag ordinary safe commands', () => {
274
301
  for (const cmd of [
275
302
  'git status', 'git log --oneline -5', 'git diff', 'npm test', 'npm run build',
@@ -0,0 +1,65 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { detectSecrets } from '../secret-detect/index.js'
3
+ import { chunk, CHUNK_THRESHOLD, WINDOW_SIZE, OVERLAP } from '../secret-detect/chunker.js'
4
+
5
+ /**
6
+ * OUTCOME test for tp-support F2 — the sliding-window overlap must exceed a
7
+ * real PEM private key so a boundary-straddling key in a >32 KB payload is
8
+ * still fully contained in (and therefore detected by) at least one window.
9
+ *
10
+ * The old 1 KB overlap was smaller than a 4096-bit RSA PEM (~3.2 KB), so a
11
+ * ~3.2 KB key straddling a 16 KB window boundary fell into the gap between
12
+ * consecutive windows and was scanned by NEITHER — it slipped past the
13
+ * scrubber unmasked. This pins the fix behaviorally: the key is detected.
14
+ *
15
+ * Proof-of-bite: reverting OVERLAP to 1024 (its pre-fix value) makes the
16
+ * boundary-straddling assertion below fail — the key lands in the gap.
17
+ */
18
+
19
+ // A representative ~3.2 KB PEM private key (4096-bit RSA armor size). Body is
20
+ // obviously-fake filler; only the BEGIN/END markers + non-empty body matter
21
+ // for the `pem_private_key` regex. Assembled at runtime per CLAUDE.md.
22
+ const PEM_BEGIN = '-----BEGIN RSA PRIVATE KEY-----'
23
+ const PEM_END = '-----END RSA PRIVATE KEY-----'
24
+ const PEM_BODY = 'A'.repeat(3100)
25
+ const PEM = `${PEM_BEGIN}\n${PEM_BODY}\n${PEM_END}`
26
+
27
+ describe('chunker overlap ≥ real PEM key size (tp-support F2)', () => {
28
+ it('overlap safely exceeds a 4096-bit RSA PEM (~3.2 KB)', () => {
29
+ // Guarantee: a secret is only missed if its length EXCEEDS OVERLAP.
30
+ expect(OVERLAP).toBeGreaterThanOrEqual(4 * 1024)
31
+ expect(PEM.length).toBeGreaterThan(1024) // the finding's ">1KB" threshold
32
+ expect(OVERLAP).toBeGreaterThan(PEM.length) // key fits inside the overlap
33
+ })
34
+
35
+ it('detects a >1KB PEM key straddling a window boundary in a >32KB payload', () => {
36
+ // Place the PEM so it straddles the first window boundary (16 KB): it
37
+ // starts before the boundary and ends after it. With the fixed 8 KB
38
+ // overlap the whole key sits inside the second window [8K, 24K]; with the
39
+ // old 1 KB overlap it fell into the gap between [0,16K] and [15.36K,31.74K].
40
+ const pemStart = WINDOW_SIZE - 1384 // 15000: before the 16384 boundary
41
+ const leading = 'a'.repeat(pemStart)
42
+ const trailing = 'a'.repeat(20000)
43
+ const text = leading + PEM + trailing
44
+
45
+ // Sanity: the payload is over the chunk threshold (so chunking is active)
46
+ // and the PEM genuinely straddles a real window boundary.
47
+ expect(text.length).toBeGreaterThan(CHUNK_THRESHOLD)
48
+ const windows = chunk(text)
49
+ expect(windows.length).toBeGreaterThan(1)
50
+ const pemEnd = pemStart + PEM.length
51
+ // Straddles the first boundary: starts before it, ends after it.
52
+ expect(pemStart).toBeLessThan(WINDOW_SIZE)
53
+ expect(pemEnd).toBeGreaterThan(WINDOW_SIZE)
54
+ // No single window before the fix (1 KB overlap) would have contained it,
55
+ // yet with the current overlap at least one window does.
56
+ const containing = windows.filter(
57
+ (w) => pemStart >= w.offset && pemEnd <= w.offset + w.text.length,
58
+ )
59
+ expect(containing.length).toBeGreaterThan(0)
60
+
61
+ // The actual outcome: the detector flags the straddling PEM.
62
+ const hits = detectSecrets(text)
63
+ expect(hits.some((h) => h.rule_id === 'pem_private_key')).toBe(true)
64
+ })
65
+ })
@@ -210,10 +210,11 @@ describe('pendingReauthFlows intercept — deleteMessage sequencing (Blocker 1)'
210
210
  const window = src.slice(interceptIdx, interceptIdx + 2000)
211
211
  // Allow the optional 4th `log` argument added in #561 (diagnostic
212
212
  // sink for redaction failures) — required is the first three args.
213
- // `bot.api` may be cast (e.g. `bot.api as never`) for the local
214
- // BotApi-vs-grammy-Api type mismatch cleanup in #623; `msgId` may
215
- // be narrowed (`msgId ?? null`).
216
- expect(window).toMatch(/redactAuthCodeMessage\(bot\.api(?:\s+as\s+\w+)?,\s*chat_id,\s*msgId(?:\s*\?\?\s*null)?(?:,\s*[^)]+)?\)/)
213
+ // The first arg is the injected BotApi surface: historically `bot.api`
214
+ // (optionally cast, #623), now the gated `redactAuthCodeApi` adapter whose
215
+ // reaction routes through the send gate + flood breaker (#3155). `msgId`
216
+ // may be narrowed (`msgId ?? null`).
217
+ expect(window).toMatch(/redactAuthCodeMessage\((?:bot\.api|redactAuthCodeApi)(?:\s+as\s+\w+)?,\s*chat_id,\s*msgId(?:\s*\?\?\s*null)?(?:,\s*[^)]+)?\)/)
217
218
  })
218
219
 
219
220
  it('redaction lands AFTER the success/error reply renders', () => {
@@ -0,0 +1,268 @@
1
+ /**
2
+ * FD/timer-leak regression test for the session-tail PreToolUse sidecars
3
+ * (review finding M1).
4
+ *
5
+ * `ToolLabelSidecar`s (each holding a stat-poll timer + file handle) were only
6
+ * reaped in the global `stop()`. Every session rotation (`/clear`, compaction →
7
+ * new sessionId) and every finished sub-agent minted a fresh sidecar that then
8
+ * lived until process exit — hundreds of leaked timers/handles on a long-lived
9
+ * agent.
10
+ *
11
+ * We mock the sidecar factory so each instance is a cheap fake with a `stop`
12
+ * spy, then drive a real parent-session rotation and a real sub-agent reap and
13
+ * assert the ended session's sidecar was stopped. Both assertions FAIL on
14
+ * pre-fix code (stop only in `stop()`).
15
+ */
16
+
17
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
18
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync, utimesSync } from 'fs'
19
+ import { tmpdir } from 'os'
20
+ import { join } from 'path'
21
+ import { startSessionTail, getProjectsDirForCwd } from '../session-tail.js'
22
+ import type { SidecarOptions, ToolLabelSidecar } from '../tool-label-sidecar.js'
23
+
24
+ // ─── Sidecar factory injection: record every fake instance keyed by sessionId ─
25
+ // We do NOT `vi.mock('../tool-label-sidecar.js')`. Under bun's vitest-compat
26
+ // layer `vi.mock` is PROCESS-GLOBAL (not file-scoped like vitest), and CI's
27
+ // bun-test-run shard runs this whole `tests/` dir in ONE process — so a module
28
+ // mock here would leak into the real `tool-label-sidecar.test.ts` suite and
29
+ // replace the module under test, failing its 7 assertions. Instead we inject a
30
+ // fake sidecar factory through `startSessionTail`'s `createSidecar` seam (the
31
+ // repo's bun-safe DI precedent — see `vault-write-posture.test.ts`), so the
32
+ // shared module cache is never touched. Sibling `subagent-watcher-fd-leak.test.ts`
33
+ // uses the same injected-fake style.
34
+ interface FakeSidecar extends ToolLabelSidecar {
35
+ sessionId: string
36
+ stop: ReturnType<typeof vi.fn>
37
+ }
38
+ const created = { instances: [] as FakeSidecar[] }
39
+ const fakeSidecarFactory = (opts: SidecarOptions): ToolLabelSidecar => {
40
+ const inst: FakeSidecar = {
41
+ sessionId: opts.sessionId,
42
+ stop: vi.fn(),
43
+ getLabel: () => undefined,
44
+ onLabel: () => () => {},
45
+ poll: () => {},
46
+ }
47
+ created.instances.push(inst)
48
+ return inst
49
+ }
50
+
51
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
52
+ const tempDirs: string[] = []
53
+ let prevStateDir: string | undefined
54
+
55
+ beforeEach(() => {
56
+ created.instances.length = 0
57
+ prevStateDir = process.env.TELEGRAM_STATE_DIR
58
+ const stateDir = mkdtempSync(join(tmpdir(), 'sidecar-state-'))
59
+ tempDirs.push(stateDir)
60
+ process.env.TELEGRAM_STATE_DIR = stateDir
61
+ })
62
+
63
+ afterEach(() => {
64
+ if (prevStateDir === undefined) delete process.env.TELEGRAM_STATE_DIR
65
+ else process.env.TELEGRAM_STATE_DIR = prevStateDir
66
+ for (const d of tempDirs) {
67
+ try { rmSync(d, { recursive: true, force: true }) } catch { /* ignore */ }
68
+ }
69
+ tempDirs.length = 0
70
+ })
71
+
72
+ function mkProjectsDir(): { claudeHome: string; cwd: string; projectsDir: string } {
73
+ const base = mkdtempSync(join(tmpdir(), 'session-tail-sidecar-'))
74
+ tempDirs.push(base)
75
+ const cwd = join(base, 'agent')
76
+ const claudeHome = join(base, 'claude-home')
77
+ const projectsDir = getProjectsDirForCwd(cwd, claudeHome)
78
+ mkdirSync(projectsDir, { recursive: true })
79
+ return { claudeHome, cwd, projectsDir }
80
+ }
81
+
82
+ const setMtime = (path: string, seconds: number): void => utimesSync(path, seconds, seconds)
83
+ const wait = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms))
84
+ const assistantText = (text: string): string =>
85
+ JSON.stringify({ type: 'assistant', message: { content: [{ type: 'text', text }] } }) + '\n'
86
+ const toolUseLine = (name: string, id: string): string =>
87
+ JSON.stringify({ type: 'assistant', message: { content: [{ type: 'tool_use', name, id, input: {} }] } }) + '\n'
88
+
89
+ const findSidecar = (sessionId: string): FakeSidecar | undefined =>
90
+ created.instances.find((i) => i.sessionId === sessionId)
91
+
92
+ describe('session-tail sidecar reap (M1) — parent session rotation', () => {
93
+ it('stops the prior session sidecar when the active file rotates', async () => {
94
+ const { claudeHome, cwd, projectsDir } = mkProjectsDir()
95
+ const first = join(projectsDir, 'session-one.jsonl')
96
+ const second = join(projectsDir, 'session-two.jsonl')
97
+
98
+ writeFileSync(first, assistantText('parent one'))
99
+ setMtime(first, 1_000_000)
100
+
101
+ const handle = startSessionTail({ cwd, claudeHome, rescanIntervalMs: 30, onEvent: () => {}, createSidecar: fakeSidecarFactory })
102
+ try {
103
+ await wait(120) // initial attach → sidecar 'session-one' created
104
+ expect(findSidecar('session-one')).toBeDefined()
105
+ expect(findSidecar('session-one')!.stop).not.toHaveBeenCalled()
106
+
107
+ // A newer session file appears (compaction / clear rotation).
108
+ const nowSec = Math.floor(Date.now() / 1000)
109
+ writeFileSync(second, assistantText('parent two'))
110
+ setMtime(second, nowSec + 20)
111
+ setMtime(first, nowSec + 5)
112
+ await wait(200) // rescan flips to session-two → session-one must be reaped
113
+
114
+ expect(findSidecar('session-two')).toBeDefined()
115
+ // The rotated-away session's sidecar is stopped at runtime, not left for stop().
116
+ expect(findSidecar('session-one')!.stop).toHaveBeenCalledTimes(1)
117
+ // The now-active session's sidecar stays alive.
118
+ expect(findSidecar('session-two')!.stop).not.toHaveBeenCalled()
119
+ } finally {
120
+ handle.stop()
121
+ }
122
+ })
123
+ })
124
+
125
+ describe('session-tail sidecar reap (M1) — idle sub-agent', () => {
126
+ it('stops a sub-agent sidecar when its idle sub-tail is reaped', async () => {
127
+ const { claudeHome, cwd, projectsDir } = mkProjectsDir()
128
+ const parent = join(projectsDir, 'parent.jsonl')
129
+ writeFileSync(parent, assistantText('parent'))
130
+ setMtime(parent, Math.floor(Date.now() / 1000))
131
+
132
+ // Sub-agents live under <parentSessionId>/subagents/agent-<id>.jsonl.
133
+ const subDir = join(projectsDir, 'parent', 'subagents')
134
+ mkdirSync(subDir, { recursive: true })
135
+ const subFile = join(subDir, 'agent-sub123.jsonl')
136
+ // A tool_use line makes the sub-tail lazily create its sidecar (keyed by
137
+ // the file stem 'agent-sub123').
138
+ writeFileSync(subFile, toolUseLine('Bash', 'toolu_1'))
139
+
140
+ const handle = startSessionTail({
141
+ cwd,
142
+ claudeHome,
143
+ rescanIntervalMs: 30,
144
+ // Reap sub-tails idle for >1000ms so the test doesn't wait 5 minutes but
145
+ // still respects the L1 floor clamp (Math.max(1000, …) — a smaller value
146
+ // is silently lifted to 1s) and leaves a window to observe the
147
+ // not-yet-reaped state.
148
+ subTailIdleReapMs: 1000,
149
+ onEvent: () => {},
150
+ createSidecar: fakeSidecarFactory,
151
+ })
152
+ try {
153
+ await wait(100) // attach parent + sub, read tool_use → sidecar created
154
+ const sub = findSidecar('agent-sub123')
155
+ expect(sub).toBeDefined()
156
+ expect(sub!.stop).not.toHaveBeenCalled()
157
+
158
+ // Let the sub-tail go idle past the reap window; a rescan tick reaps it.
159
+ await wait(1300)
160
+ expect(sub!.stop).toHaveBeenCalledTimes(1)
161
+ } finally {
162
+ handle.stop()
163
+ }
164
+ })
165
+ })
166
+
167
+ describe('session-tail sidecar reap (L1) — reap window is floor-clamped', () => {
168
+ it('does NOT instant-reap a live sub-tail under a zero (or negative) idle window', async () => {
169
+ // A `subTailIdleReapMs` of 0 (or negative) would, without the
170
+ // Math.max(1000, …) clamp, make `reapIdleSubTails` compute
171
+ // `cutoff = Date.now() - 0`, which is ALWAYS ≥ a live sub-tail's slightly-
172
+ // earlier `lastActivityAt` — so every live sub-agent sidecar would be
173
+ // reaped on the very first rescan tick. The clamp lifts the effective
174
+ // window to the 1s floor, so a sub-tail active moments ago survives.
175
+ const { claudeHome, cwd, projectsDir } = mkProjectsDir()
176
+ const parent = join(projectsDir, 'parent.jsonl')
177
+ writeFileSync(parent, assistantText('parent'))
178
+ setMtime(parent, Math.floor(Date.now() / 1000))
179
+
180
+ const subDir = join(projectsDir, 'parent', 'subagents')
181
+ mkdirSync(subDir, { recursive: true })
182
+ const subFile = join(subDir, 'agent-subzero.jsonl')
183
+ writeFileSync(subFile, toolUseLine('Bash', 'toolu_z'))
184
+
185
+ const handle = startSessionTail({
186
+ cwd,
187
+ claudeHome,
188
+ rescanIntervalMs: 30,
189
+ subTailIdleReapMs: 0, // pathological: pre-clamp this instant-reaps
190
+ onEvent: () => {},
191
+ createSidecar: fakeSidecarFactory,
192
+ })
193
+ try {
194
+ await wait(100) // attach parent + sub → sidecar 'agent-subzero' created
195
+ const sub = findSidecar('agent-subzero')
196
+ expect(sub).toBeDefined()
197
+ expect(sub!.stop).not.toHaveBeenCalled()
198
+
199
+ // Several rescan ticks pass (well under the 1s clamp floor). A live
200
+ // sub-tail whose activity is <1s old must NOT be reaped — so its sidecar
201
+ // stays alive. Pre-clamp, the first tick reaps it (sub.stop called once).
202
+ await wait(200)
203
+ expect(sub!.stop).not.toHaveBeenCalled()
204
+ } finally {
205
+ handle.stop()
206
+ }
207
+ })
208
+ })
209
+
210
+ describe('session-tail sidecar reap (M1) — namespace safety', () => {
211
+ it('a parent-session rotation does NOT stop a concurrently-live sub-agent sidecar', async () => {
212
+ // The rotation reap in attachToFile stops the sidecar keyed by the
213
+ // rotated-away parent's JSONL stem (a UUID). A live sub-agent's sidecar is
214
+ // keyed by its OWN file stem ('agent-<id>'). Because the two key namespaces
215
+ // never collide, rotating the parent must leave the sub-agent's sidecar
216
+ // untouched — otherwise a `/clear` or compaction mid-worker would silently
217
+ // kill the live worker's label sidecar. Reviewer verified by code-reading
218
+ // only; this locks it in.
219
+ const { claudeHome, cwd, projectsDir } = mkProjectsDir()
220
+ const first = join(projectsDir, 'session-one.jsonl')
221
+ const second = join(projectsDir, 'session-two.jsonl')
222
+
223
+ writeFileSync(first, assistantText('parent one'))
224
+ setMtime(first, 1_000_000)
225
+
226
+ // A live sub-agent under the CURRENT (session-one) parent. Its tool_use
227
+ // line makes the sub-tail create a sidecar keyed 'agent-subns'.
228
+ const subDir = join(projectsDir, 'session-one', 'subagents')
229
+ mkdirSync(subDir, { recursive: true })
230
+ const subFile = join(subDir, 'agent-subns.jsonl')
231
+ writeFileSync(subFile, toolUseLine('Bash', 'toolu_ns'))
232
+
233
+ const handle = startSessionTail({
234
+ cwd,
235
+ claudeHome,
236
+ rescanIntervalMs: 30,
237
+ // Large window so the sub-tail is never idle-reaped during the test —
238
+ // we're isolating the rotation path, not the idle path.
239
+ subTailIdleReapMs: 60_000,
240
+ onEvent: () => {},
241
+ createSidecar: fakeSidecarFactory,
242
+ })
243
+ try {
244
+ await wait(120) // attach parent + sub → both sidecars created
245
+ const parentSidecar = findSidecar('session-one')
246
+ const subSidecar = findSidecar('agent-subns')
247
+ expect(parentSidecar).toBeDefined()
248
+ expect(subSidecar).toBeDefined()
249
+ expect(parentSidecar!.stop).not.toHaveBeenCalled()
250
+ expect(subSidecar!.stop).not.toHaveBeenCalled()
251
+
252
+ // Parent session rotates (compaction / clear → new sessionId).
253
+ const nowSec = Math.floor(Date.now() / 1000)
254
+ writeFileSync(second, assistantText('parent two'))
255
+ setMtime(second, nowSec + 20)
256
+ setMtime(first, nowSec + 5)
257
+ await wait(200) // rescan flips active file to session-two
258
+
259
+ // The rotated-away PARENT sidecar is reaped …
260
+ expect(parentSidecar!.stop).toHaveBeenCalledTimes(1)
261
+ // … but the concurrently-live SUB-agent sidecar is NOT — different stem
262
+ // namespace, so the rotation reap never targets it.
263
+ expect(subSidecar!.stop).not.toHaveBeenCalled()
264
+ } finally {
265
+ handle.stop()
266
+ }
267
+ })
268
+ })