switchroom 0.18.12 → 0.18.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 +57 -9
- package/dist/auth-broker/index.js +174 -72
- package/dist/cli/autoaccept-poll.js +23 -0
- package/dist/cli/drive-write-pretool.mjs +24 -1
- package/dist/cli/foreground-hog-pretool.mjs +264 -0
- package/dist/cli/ms-365-write-pretool.mjs +31 -8
- package/dist/cli/notion-write-pretool.mjs +9 -2
- package/dist/cli/skill-validate-pretool.mjs +144 -2847
- package/dist/cli/switchroom.js +986 -3131
- package/dist/host-control/main.js +216 -2863
- package/dist/vault/approvals/kernel-server.js +67 -1
- package/dist/vault/broker/server.js +98 -45
- package/package.json +1 -1
- package/profiles/coding/CLAUDE.md.hbs +2 -0
- package/profiles/default/CLAUDE.md.hbs +2 -0
- package/skills/switchroom-architecture/telegram.md +0 -1
- package/telegram-plugin/auth-snapshot-format.ts +37 -5
- package/telegram-plugin/auto-fallback-fleet.ts +29 -1
- package/telegram-plugin/bridge/bridge.ts +2 -0
- package/telegram-plugin/dist/bridge/bridge.js +51 -3
- package/telegram-plugin/dist/gateway/gateway.js +1251 -2368
- package/telegram-plugin/dist/server.js +67 -3
- package/telegram-plugin/format.ts +19 -0
- package/telegram-plugin/gateway/approval-hold.ts +21 -2
- package/telegram-plugin/gateway/auth-broker-client.ts +1 -0
- package/telegram-plugin/gateway/auth-command.ts +14 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +12 -0
- package/telegram-plugin/gateway/forward-origin.ts +235 -0
- package/telegram-plugin/gateway/gateway.ts +445 -83
- package/telegram-plugin/gateway/throttle-tier-wiring.ts +268 -0
- package/telegram-plugin/history.ts +106 -6
- package/telegram-plugin/inline-keyboard-callbacks.ts +94 -0
- package/telegram-plugin/model-unavailable.ts +61 -13
- package/telegram-plugin/outbound-field-redact.ts +69 -0
- package/telegram-plugin/render/render.ts +32 -14
- package/telegram-plugin/render/rich-render.ts +40 -32
- package/telegram-plugin/scoped-approval.ts +11 -2
- package/telegram-plugin/secret-detect/chunker.ts +18 -4
- package/telegram-plugin/secret-detect/index.ts +12 -56
- package/telegram-plugin/send-gate-degraded.test.ts +131 -0
- package/telegram-plugin/send-gate.test.ts +25 -6
- package/telegram-plugin/send-gate.ts +82 -8
- package/telegram-plugin/session-tail.ts +82 -7
- package/telegram-plugin/stream-controller.ts +3 -2
- package/telegram-plugin/subagent-watcher.ts +71 -16
- package/telegram-plugin/tests/approval-hold-outcome.test.ts +36 -5
- package/telegram-plugin/tests/auto-fallback-fleet.test.ts +72 -0
- package/telegram-plugin/tests/callback-query-handlers.test.ts +65 -0
- package/telegram-plugin/tests/forward-origin.test.ts +309 -0
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +57 -0
- package/telegram-plugin/tests/history.test.ts +272 -0
- package/telegram-plugin/tests/inbound-message-types.test.ts +5 -1
- package/telegram-plugin/tests/inline-keyboard-callbacks.test.ts +164 -0
- package/telegram-plugin/tests/operator-events-session-tail.test.ts +74 -0
- package/telegram-plugin/tests/outbound-field-redact.test.ts +107 -0
- package/telegram-plugin/tests/reaction-gate-routing.test.ts +173 -0
- package/telegram-plugin/tests/render/render-outbound-chunks.test.ts +6 -4
- package/telegram-plugin/tests/render/render.test.ts +88 -0
- package/telegram-plugin/tests/render/rich-render.test.ts +41 -22
- package/telegram-plugin/tests/scoped-approval.test.ts +27 -0
- package/telegram-plugin/tests/secret-detect-chunk-overlap.test.ts +65 -0
- package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +5 -4
- package/telegram-plugin/tests/session-tail-sidecar-reap.test.ts +268 -0
- package/telegram-plugin/tests/single-mode-stream-reply.test.ts +5 -3
- package/telegram-plugin/tests/status-accent.test.ts +5 -3
- package/telegram-plugin/tests/stream-controller-chunk-cap.test.ts +20 -20
- package/telegram-plugin/tests/stream-reply-handler.test.ts +5 -2
- package/telegram-plugin/tests/subagent-watcher-fd-leak.test.ts +275 -0
- package/telegram-plugin/tests/throttle-tier-wiring.test.ts +290 -0
- package/telegram-plugin/tests/throttle-tier.test.ts +278 -0
- package/telegram-plugin/tests/worktree-watch-cwds.test.ts +215 -1
- package/telegram-plugin/throttle-tier.ts +226 -0
- package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +8 -7
- package/telegram-plugin/worktree-watch-cwds.ts +194 -5
- package/telegram-plugin/secret-detect/secretlint-source.ts +0 -95
- package/telegram-plugin/tests/secret-detect-secretlint.test.ts +0 -105
|
@@ -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", () => {
|
|
@@ -7,24 +7,32 @@ import {
|
|
|
7
7
|
} from "../../render/rich-render.js";
|
|
8
8
|
|
|
9
9
|
describe("parseRichRenderEnabled", () => {
|
|
10
|
-
it("defaults
|
|
11
|
-
expect(parseRichRenderEnabled(undefined)).toBe(
|
|
10
|
+
it("defaults ON when unset (escape hatch, not opt-in)", () => {
|
|
11
|
+
expect(parseRichRenderEnabled(undefined)).toBe(true);
|
|
12
12
|
});
|
|
13
|
-
it("
|
|
13
|
+
it("disabled only by the explicit off tokens (case-insensitive, trimmed)", () => {
|
|
14
|
+
for (const v of ["0", "false", "off", "no", "FALSE", " Off ", "NO"]) {
|
|
15
|
+
expect(parseRichRenderEnabled(v)).toBe(false);
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
it("truthy tokens stay ON", () => {
|
|
14
19
|
for (const v of ["1", "true", "on", "yes", "TRUE", " On "]) {
|
|
15
20
|
expect(parseRichRenderEnabled(v)).toBe(true);
|
|
16
21
|
}
|
|
17
22
|
});
|
|
18
|
-
it("
|
|
19
|
-
for (const v of ["
|
|
20
|
-
expect(parseRichRenderEnabled(v)).toBe(
|
|
23
|
+
it("empty / unrecognised values stay ON (fail-open to the default)", () => {
|
|
24
|
+
for (const v of ["", " ", "maybe", "2", "disable"]) {
|
|
25
|
+
expect(parseRichRenderEnabled(v)).toBe(true);
|
|
21
26
|
}
|
|
22
27
|
});
|
|
23
28
|
});
|
|
24
29
|
|
|
25
30
|
describe("richRenderEnabled", () => {
|
|
26
|
-
it("reads SWITCHROOM_RICH_RENDER, default
|
|
27
|
-
expect(richRenderEnabled({} as NodeJS.ProcessEnv)).toBe(
|
|
31
|
+
it("reads SWITCHROOM_RICH_RENDER, default ON", () => {
|
|
32
|
+
expect(richRenderEnabled({} as NodeJS.ProcessEnv)).toBe(true);
|
|
33
|
+
expect(
|
|
34
|
+
richRenderEnabled({ SWITCHROOM_RICH_RENDER: "0" } as NodeJS.ProcessEnv),
|
|
35
|
+
).toBe(false);
|
|
28
36
|
expect(
|
|
29
37
|
richRenderEnabled({ SWITCHROOM_RICH_RENDER: "1" } as NodeJS.ProcessEnv),
|
|
30
38
|
).toBe(true);
|
|
@@ -32,41 +40,52 @@ describe("richRenderEnabled", () => {
|
|
|
32
40
|
});
|
|
33
41
|
|
|
34
42
|
describe("maybeRenderOutbound", () => {
|
|
35
|
-
it("
|
|
43
|
+
it("disabled (=0) is a byte-for-byte passthrough (the escape hatch)", () => {
|
|
36
44
|
const raw = "**bold** and a\n\n**> collapsible quote\n> second line";
|
|
37
|
-
const r = maybeRenderOutbound(raw, {
|
|
45
|
+
const r = maybeRenderOutbound(raw, {
|
|
46
|
+
SWITCHROOM_RICH_RENDER: "0",
|
|
47
|
+
} as NodeJS.ProcessEnv);
|
|
38
48
|
expect(r.mode).toBe("markdown");
|
|
39
49
|
expect(r.text).toBe(raw);
|
|
40
50
|
});
|
|
41
51
|
|
|
42
|
-
it("
|
|
43
|
-
const r = maybeRenderOutbound("**> collapsible", {
|
|
44
|
-
SWITCHROOM_RICH_RENDER: "1",
|
|
45
|
-
} as NodeJS.ProcessEnv);
|
|
52
|
+
it("default (env unset) routes through parse -> renderSafe", () => {
|
|
53
|
+
const r = maybeRenderOutbound("**> collapsible", {} as NodeJS.ProcessEnv);
|
|
46
54
|
expect(r.mode).toBe("markdown");
|
|
47
55
|
// The expandable blockquote round-trips back to the `**> ` marker.
|
|
48
56
|
expect(r.text).toContain("**> ");
|
|
49
57
|
});
|
|
50
58
|
|
|
51
|
-
it("
|
|
52
|
-
const r = maybeRenderOutbound(
|
|
53
|
-
|
|
54
|
-
|
|
59
|
+
it("default preserves plain prose through the round-trip", () => {
|
|
60
|
+
const r = maybeRenderOutbound(
|
|
61
|
+
"just some plain text",
|
|
62
|
+
{} as NodeJS.ProcessEnv,
|
|
63
|
+
);
|
|
55
64
|
expect(r.text).toContain("just some plain text");
|
|
56
65
|
});
|
|
57
66
|
|
|
58
|
-
it("
|
|
59
|
-
const on = {
|
|
67
|
+
it("default round-trips underline / spoiler / highlight", () => {
|
|
68
|
+
const on = {} as NodeJS.ProcessEnv;
|
|
60
69
|
expect(maybeRenderOutbound("__u__", on).text).toBe("__u__");
|
|
61
70
|
expect(maybeRenderOutbound("a ||s|| b", on).text).toBe("a ||s|| b");
|
|
62
71
|
expect(maybeRenderOutbound("a ==m== b", on).text).toBe("a ==m== b");
|
|
63
72
|
});
|
|
64
73
|
|
|
65
|
-
it("
|
|
74
|
+
it("disabled (=0) passes new constructs through untouched too", () => {
|
|
66
75
|
const raw = "__u__ and ||s|| and ==m==";
|
|
67
|
-
const r = maybeRenderOutbound(raw, {
|
|
76
|
+
const r = maybeRenderOutbound(raw, {
|
|
77
|
+
SWITCHROOM_RICH_RENDER: "0",
|
|
78
|
+
} as NodeJS.ProcessEnv);
|
|
68
79
|
expect(r.text).toBe(raw);
|
|
69
80
|
});
|
|
81
|
+
|
|
82
|
+
it("a junk env value still renders (fail-open to the default)", () => {
|
|
83
|
+
const r = maybeRenderOutbound("**> collapsible", {
|
|
84
|
+
SWITCHROOM_RICH_RENDER: "maybe",
|
|
85
|
+
} as NodeJS.ProcessEnv);
|
|
86
|
+
expect(r.mode).toBe("markdown");
|
|
87
|
+
expect(r.text).toContain("**> ");
|
|
88
|
+
});
|
|
70
89
|
});
|
|
71
90
|
|
|
72
91
|
describe("renderOutbound (flag-independent)", () => {
|
|
@@ -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
|
-
//
|
|
214
|
-
//
|
|
215
|
-
//
|
|
216
|
-
|
|
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
|
+
})
|
|
@@ -69,7 +69,7 @@ describe('stream-reply single-mode reuse (#2669)', () => {
|
|
|
69
69
|
expect(bot.api.editMessageText).toHaveBeenCalled()
|
|
70
70
|
})
|
|
71
71
|
|
|
72
|
-
it('the rich path ships
|
|
72
|
+
it('the rich path ships GFM markdown unescaped via sendRichMessage', async () => {
|
|
73
73
|
const state = makeState()
|
|
74
74
|
const deps = makeDeps(bot)
|
|
75
75
|
|
|
@@ -77,8 +77,10 @@ describe('stream-reply single-mode reuse (#2669)', () => {
|
|
|
77
77
|
|
|
78
78
|
expect(bot.state.sent).toHaveLength(1)
|
|
79
79
|
expect(bot.state.sent[0].rich).toBe(true)
|
|
80
|
-
//
|
|
81
|
-
|
|
80
|
+
// GFM markdown is the wire payload — no HTML, no MarkdownV2 escaping.
|
|
81
|
+
// The default-on rich renderer normalises the italic marker
|
|
82
|
+
// (`_italic_` -> `*italic*`, same wire entity).
|
|
83
|
+
expect(bot.state.sent[0].text).toBe('**bold** and *italic*')
|
|
82
84
|
expect(bot.state.sent[0].parse_mode).toBeUndefined()
|
|
83
85
|
})
|
|
84
86
|
|
|
@@ -91,8 +91,10 @@ describe('handleStreamReply accent integration', () => {
|
|
|
91
91
|
await pending
|
|
92
92
|
|
|
93
93
|
const sent = sentMarkdown(bot)
|
|
94
|
-
|
|
95
|
-
|
|
94
|
+
// The default-on rich renderer normalises the italic marker
|
|
95
|
+
// (`_In progress…_` -> `*In progress…*`, same wire entity).
|
|
96
|
+
expect(sent).toMatch(/^🔵 \*In progress…\*\n\n/)
|
|
97
|
+
expect(sent).toBe('🔵 *In progress…*\n\nStill working...')
|
|
96
98
|
})
|
|
97
99
|
|
|
98
100
|
it("accent='done' prepends the checkmark markdown header before the body", async () => {
|
|
@@ -176,6 +178,6 @@ describe('handleStreamReply accent integration', () => {
|
|
|
176
178
|
await microtaskFlush()
|
|
177
179
|
await p2
|
|
178
180
|
|
|
179
|
-
expect(editedMarkdown(bot)).toBe('🔵
|
|
181
|
+
expect(editedMarkdown(bot)).toBe('🔵 *In progress…*\n\nPart one Part two')
|
|
180
182
|
})
|
|
181
183
|
})
|