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.
- package/dist/agent-scheduler/index.js +8 -0
- package/dist/auth-broker/index.js +63 -65
- package/dist/cli/ms-365-write-pretool.mjs +31 -8
- package/dist/cli/notion-write-pretool.mjs +9 -1
- package/dist/cli/skill-validate-pretool.mjs +144 -2847
- package/dist/cli/switchroom.js +952 -3126
- package/dist/host-control/main.js +216 -2862
- package/dist/vault/approvals/kernel-server.js +67 -0
- package/dist/vault/broker/server.js +98 -44
- package/package.json +1 -1
- package/telegram-plugin/dist/bridge/bridge.js +49 -3
- package/telegram-plugin/dist/gateway/gateway.js +656 -2326
- package/telegram-plugin/dist/server.js +65 -3
- package/telegram-plugin/format.ts +19 -0
- package/telegram-plugin/gateway/approval-hold.ts +21 -2
- package/telegram-plugin/gateway/callback-query-handlers.ts +12 -0
- package/telegram-plugin/gateway/gateway.ts +221 -73
- package/telegram-plugin/history.ts +51 -0
- package/telegram-plugin/inline-keyboard-callbacks.ts +94 -0
- package/telegram-plugin/model-unavailable.ts +41 -11
- package/telegram-plugin/outbound-field-redact.ts +69 -0
- package/telegram-plugin/render/render.ts +32 -14
- 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/subagent-watcher.ts +71 -16
- package/telegram-plugin/tests/approval-hold-outcome.test.ts +36 -5
- package/telegram-plugin/tests/callback-query-handlers.test.ts +65 -0
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +57 -0
- package/telegram-plugin/tests/history.test.ts +115 -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.test.ts +88 -0
- 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/subagent-watcher-fd-leak.test.ts +275 -0
- package/telegram-plugin/tests/worktree-watch-cwds.test.ts +215 -1
- 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
|
@@ -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
|
-
//
|
|
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
|
+
})
|