switchroom 0.18.17 → 0.18.19
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 +13 -0
- package/dist/auth-broker/index.js +13 -0
- package/dist/cli/notion-write-pretool.mjs +13 -0
- package/dist/cli/switchroom.js +605 -479
- package/dist/host-control/main.js +17 -1
- package/dist/vault/approvals/kernel-server.js +13 -0
- package/dist/vault/broker/server.js +13 -0
- package/package.json +1 -1
- package/telegram-plugin/bridge/bridge.ts +7 -1
- package/telegram-plugin/dist/bridge/bridge.js +26 -1
- package/telegram-plugin/dist/gateway/gateway.js +1544 -619
- package/telegram-plugin/dist/server.js +32 -1
- package/telegram-plugin/fleet-fallback-resume.ts +26 -3
- package/telegram-plugin/format.ts +137 -213
- package/telegram-plugin/gateway/approval-hold.ts +49 -0
- package/telegram-plugin/gateway/bridge-dead-watchdog.ts +61 -18
- package/telegram-plugin/gateway/gateway.ts +399 -85
- package/telegram-plugin/gateway/linear-activity.ts +20 -4
- package/telegram-plugin/gateway/outbound-send-path.ts +9 -7
- package/telegram-plugin/gateway/premium-recovery-wiring.ts +122 -0
- package/telegram-plugin/gateway/session-model-file.ts +103 -0
- package/telegram-plugin/gateway/tier-downgrade-wiring.ts +121 -0
- package/telegram-plugin/gateway/unhandled-rejection-policy.ts +14 -1
- package/telegram-plugin/llm-error-present.ts +474 -0
- package/telegram-plugin/operator-events.ts +7 -1
- package/telegram-plugin/permission-title.ts +172 -10
- package/telegram-plugin/premium-recovery.ts +101 -0
- package/telegram-plugin/raw-error-scrub.ts +73 -0
- package/telegram-plugin/retry-api-call.ts +8 -2
- package/telegram-plugin/send-gate-degraded.test.ts +152 -1
- package/telegram-plugin/send-gate-observability.test.ts +140 -0
- package/telegram-plugin/send-gate-observability.ts +65 -20
- package/telegram-plugin/send-gate.test.ts +143 -1
- package/telegram-plugin/send-gate.ts +212 -19
- package/telegram-plugin/session-tail.ts +16 -0
- package/telegram-plugin/shared/local-time.ts +69 -0
- package/telegram-plugin/stream-reply-handler.ts +5 -14
- package/telegram-plugin/tests/approval-hold-harness.ts +6 -6
- package/telegram-plugin/tests/approval-hold-outcome.test.ts +10 -2
- package/telegram-plugin/tests/bridge-dead-watchdog.test.ts +61 -0
- package/telegram-plugin/tests/fleet-fallback-resume.test.ts +39 -0
- package/telegram-plugin/tests/flood-windows-persistence.test.ts +3 -2
- package/telegram-plugin/tests/format-consistency.test.ts +68 -53
- package/telegram-plugin/tests/formatting-parse-regression.test.ts +5 -6
- package/telegram-plugin/tests/formatting-torture-set.ts +1 -1
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +26 -0
- package/telegram-plugin/tests/linear-create-issue.test.ts +30 -2
- package/telegram-plugin/tests/llm-error-present.test.ts +481 -0
- package/telegram-plugin/tests/outbound-send-path.test.ts +4 -3
- package/telegram-plugin/tests/paragraph-normalizer.test.ts +42 -100
- package/telegram-plugin/tests/permission-title.test.ts +167 -4
- package/telegram-plugin/tests/premium-recovery-wiring.test.ts +150 -0
- package/telegram-plugin/tests/premium-recovery.test.ts +165 -0
- package/telegram-plugin/tests/reaction-gate-routing.test.ts +6 -1
- package/telegram-plugin/tests/retry-api-call.test.ts +21 -0
- package/telegram-plugin/tests/stream-reply-handler.test.ts +9 -12
- package/telegram-plugin/tests/telegram-format.test.ts +86 -31
- package/telegram-plugin/tests/tier-downgrade-wiring.test.ts +165 -0
- package/telegram-plugin/tests/tier-downgrade.test.ts +141 -0
- package/telegram-plugin/tests/turn-flush-safety.test.ts +17 -21
- package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +27 -1
- package/telegram-plugin/tests/worker-activity-feed.test.ts +5 -2
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +492 -0
- package/telegram-plugin/tier-downgrade.ts +198 -0
- package/telegram-plugin/tool-activity-summary.ts +99 -0
- package/telegram-plugin/turn-flush-safety.ts +4 -3
- package/telegram-plugin/worker-activity-feed.ts +509 -409
|
@@ -12,8 +12,6 @@ import { describe, test, expect } from 'vitest'
|
|
|
12
12
|
import {
|
|
13
13
|
normalizeParagraphBreaks,
|
|
14
14
|
splitCollapsedInlineBullets,
|
|
15
|
-
addParagraphSpacers,
|
|
16
|
-
PARAGRAPH_SPACER,
|
|
17
15
|
} from '../format.js'
|
|
18
16
|
|
|
19
17
|
describe('normalizeParagraphBreaks', () => {
|
|
@@ -433,96 +431,44 @@ describe('splitCollapsedInlineBullets', () => {
|
|
|
433
431
|
})
|
|
434
432
|
|
|
435
433
|
// ---------------------------------------------------------------------------
|
|
436
|
-
//
|
|
434
|
+
// Paragraph gap spacing — plain \n\n, no NBSP spacer (#2669 follow-up).
|
|
437
435
|
//
|
|
438
|
-
// The
|
|
439
|
-
//
|
|
440
|
-
//
|
|
441
|
-
//
|
|
442
|
-
// `\n\n`
|
|
443
|
-
//
|
|
436
|
+
// The NBSP paragraph-spacer (addParagraphSpacers / PARAGRAPH_SPACER) was
|
|
437
|
+
// REMOVED: its premise — that the Bot API 10.1 rich GFM renderer collapses a
|
|
438
|
+
// `\n\n` gap TIGHT — is false for the live renderer, which shows a `\n\n` gap
|
|
439
|
+
// as one normal blank line. The spacer therefore injected a spurious SECOND
|
|
440
|
+
// blank line (`\n\n \n\n`) between every paragraph fleet-wide. Paragraph
|
|
441
|
+
// spacing now relies on the plain `\n\n` that normalizeParagraphBreaks already
|
|
442
|
+
// guarantees. These tests pin: multi-paragraph output has exactly one blank
|
|
443
|
+
// line per gap, and no U+00A0 anywhere.
|
|
444
444
|
// ---------------------------------------------------------------------------
|
|
445
445
|
|
|
446
|
-
describe('
|
|
447
|
-
const
|
|
446
|
+
describe('paragraph gap spacing — single blank line, no NBSP', () => {
|
|
447
|
+
const NBSP = String.fromCharCode(0xa0)
|
|
448
448
|
|
|
449
|
-
test('
|
|
450
|
-
|
|
449
|
+
test('a two-paragraph prose body has exactly one blank-line gap and no NBSP', () => {
|
|
450
|
+
const out = normalizeParagraphBreaks('Paragraph one.\n\nParagraph two.')
|
|
451
|
+
expect(out).toBe('Paragraph one.\n\nParagraph two.')
|
|
452
|
+
expect(out).not.toContain(NBSP)
|
|
453
|
+
// Exactly one empty line between the two content lines.
|
|
454
|
+
expect(out.split('\n')).toEqual(['Paragraph one.', '', 'Paragraph two.'])
|
|
451
455
|
})
|
|
452
456
|
|
|
453
|
-
test('
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
)
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
test('spaces every gap in a three-paragraph body', () => {
|
|
460
|
-
expect(addParagraphSpacers('One.\n\nTwo.\n\nThree.')).toBe(
|
|
461
|
-
`One.${gap}Two.${gap}Three.`,
|
|
462
|
-
)
|
|
463
|
-
})
|
|
464
|
-
|
|
465
|
-
test('is idempotent — a spaced gap is not doubled on a second pass', () => {
|
|
466
|
-
const once = addParagraphSpacers('Alpha.\n\nBravo.')
|
|
467
|
-
expect(addParagraphSpacers(once)).toBe(once)
|
|
468
|
-
})
|
|
469
|
-
|
|
470
|
-
test('leaves a single-`\\n` separation alone (only `\\n\\n` gaps are spaced)', () => {
|
|
471
|
-
// normalizeParagraphBreaks owns lone-break promotion; this pass only adds a
|
|
472
|
-
// visible spacer where a genuine blank-line gap already exists.
|
|
473
|
-
const input = 'Line one.\nLine two.'
|
|
474
|
-
expect(addParagraphSpacers(input)).toBe(input)
|
|
457
|
+
test('a three-paragraph body keeps single-blank-line gaps, no NBSP', () => {
|
|
458
|
+
const out = normalizeParagraphBreaks('One.\n\nTwo.\n\nThree.')
|
|
459
|
+
expect(out).toBe('One.\n\nTwo.\n\nThree.')
|
|
460
|
+
expect(out).not.toContain(NBSP)
|
|
461
|
+
// No gap is a DOUBLE blank line (`\n\n\n`), which the old spacer produced.
|
|
462
|
+
expect(out).not.toMatch(/\n\n\n/)
|
|
475
463
|
})
|
|
476
464
|
|
|
477
|
-
test('
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
)
|
|
481
|
-
expect(addParagraphSpacers('- first\n- second\n\nClosing prose after the list.')).toBe(
|
|
482
|
-
`- first\n- second${gap}Closing prose after the list.`,
|
|
483
|
-
)
|
|
465
|
+
test('prose->list transition is a single `\n\n` boundary, list interior tight', () => {
|
|
466
|
+
const out = normalizeParagraphBreaks('Here are the steps.\n\n- first\n- second')
|
|
467
|
+
expect(out).toBe('Here are the steps.\n\n- first\n- second')
|
|
468
|
+
expect(out).not.toContain(NBSP)
|
|
484
469
|
})
|
|
485
470
|
|
|
486
|
-
test('
|
|
487
|
-
const table = '| a | b |\n| --- | --- |\n| 1 | 2 |'
|
|
488
|
-
expect(addParagraphSpacers(`Intro.\n\n${table}`)).toBe(`Intro.${gap}${table}`)
|
|
489
|
-
})
|
|
490
|
-
|
|
491
|
-
test('spaces prose↔fence boundaries; fence interior untouched', () => {
|
|
492
|
-
const input = 'Look here.\n\n```js\nconst a = 1;\n```\n\nDone.'
|
|
493
|
-
expect(addParagraphSpacers(input)).toBe(
|
|
494
|
-
`Look here.${gap}\`\`\`js\nconst a = 1;\n\`\`\`${gap}Done.`,
|
|
495
|
-
)
|
|
496
|
-
})
|
|
497
|
-
|
|
498
|
-
test('spaces heading→anything and blockquote boundaries', () => {
|
|
499
|
-
expect(addParagraphSpacers('Intro prose.\n\n## Section\n\nBody prose.')).toBe(
|
|
500
|
-
`Intro prose.${gap}## Section${gap}Body prose.`,
|
|
501
|
-
)
|
|
502
|
-
expect(addParagraphSpacers('Said.\n\n> a quote\n\nAfter.')).toBe(
|
|
503
|
-
`Said.${gap}> a quote${gap}After.`,
|
|
504
|
-
)
|
|
505
|
-
})
|
|
506
|
-
|
|
507
|
-
test('does NOT space between items of the same loose list / same-kind blocks', () => {
|
|
508
|
-
const looseList = '- first\n\n- second\n\n- third'
|
|
509
|
-
expect(addParagraphSpacers(looseList)).toBe(looseList)
|
|
510
|
-
const quotes = '> one\n\n> two'
|
|
511
|
-
expect(addParagraphSpacers(quotes)).toBe(quotes)
|
|
512
|
-
})
|
|
513
|
-
|
|
514
|
-
test('never reaches inside a fenced block (interior blank line untouched)', () => {
|
|
515
|
-
const input = 'Before.\n\n```\nline 1\n\nline 2\n```\n\nAfter.'
|
|
516
|
-
const out = addParagraphSpacers(input)
|
|
517
|
-
// The fence interior — including its own blank line — is byte-for-byte intact.
|
|
518
|
-
expect(out).toContain('```\nline 1\n\nline 2\n```')
|
|
519
|
-
})
|
|
520
|
-
|
|
521
|
-
test('a body with no paragraph gap is returned unchanged', () => {
|
|
522
|
-
expect(addParagraphSpacers('just one paragraph, no gap')).toBe('just one paragraph, no gap')
|
|
523
|
-
})
|
|
524
|
-
|
|
525
|
-
test('composes after normalizeParagraphBreaks: prose gap spaced, list left tight', () => {
|
|
471
|
+
test('composed prose+list body: one blank line per gap, no NBSP, no double gap', () => {
|
|
526
472
|
const input = [
|
|
527
473
|
'Summary of the change.',
|
|
528
474
|
'',
|
|
@@ -531,12 +477,11 @@ describe('addParagraphSpacers', () => {
|
|
|
531
477
|
'- adds a normalizer',
|
|
532
478
|
'- lifts the cap',
|
|
533
479
|
].join('\n')
|
|
534
|
-
const out =
|
|
535
|
-
|
|
536
|
-
expect(out).toContain(
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
expect(out).toContain(`It does two things.${gap}- adds a normalizer\n- lifts the cap`)
|
|
480
|
+
const out = normalizeParagraphBreaks(input)
|
|
481
|
+
expect(out).toContain('Summary of the change.\n\nIt does two things.')
|
|
482
|
+
expect(out).toContain('It does two things.\n\n- adds a normalizer\n- lifts the cap')
|
|
483
|
+
expect(out).not.toContain(NBSP)
|
|
484
|
+
expect(out).not.toMatch(/\n\n\n/)
|
|
540
485
|
})
|
|
541
486
|
})
|
|
542
487
|
|
|
@@ -577,12 +522,12 @@ describe('normalizeParagraphBreaks — inline bullet split integration', () => {
|
|
|
577
522
|
* ragged / oversized gap: CommonMark discards it so it buys no visible space,
|
|
578
523
|
* but it reads as noise in the raw text. Step 1 of normalizeParagraphBreaks now
|
|
579
524
|
* collapses any run of blank lines — including whitespace-only interior lines —
|
|
580
|
-
* to exactly one clean `\n\n
|
|
581
|
-
*
|
|
582
|
-
*
|
|
525
|
+
* to exactly one clean `\n\n`. The collapse is deliberately ASCII-only, so a
|
|
526
|
+
* genuine user-typed U+00A0 line survives (the conservative choice; there is no
|
|
527
|
+
* longer an NBSP paragraph spacer to protect — that pass was removed).
|
|
583
528
|
*/
|
|
584
529
|
describe('normalizeParagraphBreaks — whitespace-only blank-line collapse (Bug 2)', () => {
|
|
585
|
-
const NBSP =
|
|
530
|
+
const NBSP = String.fromCharCode(0xa0)
|
|
586
531
|
|
|
587
532
|
test('a lone-space blank line between paragraphs collapses to a clean `\\n\\n` (real string)', () => {
|
|
588
533
|
const input =
|
|
@@ -635,14 +580,11 @@ describe('normalizeParagraphBreaks — whitespace-only blank-line collapse (Bug
|
|
|
635
580
|
expect(normalizeParagraphBreaks(input)).toBe(input)
|
|
636
581
|
})
|
|
637
582
|
|
|
638
|
-
test('
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
expect(
|
|
643
|
-
expect(spaced.split('\n')).toContain(NBSP)
|
|
644
|
-
// And re-running the normalizer must NOT eat that intentional spacer.
|
|
645
|
-
expect(normalizeParagraphBreaks(spaced)).toBe(spaced)
|
|
583
|
+
test('a genuine user-typed U+00A0-only line survives (ASCII-only collapse)', () => {
|
|
584
|
+
// The blank-line collapse is deliberately ASCII-only, so a non-breaking
|
|
585
|
+
// space a user actually typed on its own line is not silently eaten.
|
|
586
|
+
const input = 'Alpha para.\n' + NBSP + '\nBravo para.'
|
|
587
|
+
expect(normalizeParagraphBreaks(input)).toBe(input)
|
|
646
588
|
})
|
|
647
589
|
|
|
648
590
|
test('idempotent: collapsing a stray gap twice is stable', () => {
|
|
@@ -156,25 +156,188 @@ describe('formatPermissionCardBody', () => {
|
|
|
156
156
|
expect(body).toContain('why: _listing temp files_')
|
|
157
157
|
})
|
|
158
158
|
|
|
159
|
-
|
|
159
|
+
// #3167: no caller reason → an honest synthesized `context:` line built
|
|
160
|
+
// from the tool's salient input, NEVER a bare "why: not provided" and never
|
|
161
|
+
// the static schema description. The distinct `context:` label keeps the
|
|
162
|
+
// agent's omission of a rationale visible.
|
|
163
|
+
test('synthesizes a context line when no caller reason is present (never "not provided" / the description)', () => {
|
|
160
164
|
const body = formatPermissionCardBody({
|
|
161
165
|
toolName: 'Bash',
|
|
162
166
|
inputPreview: JSON.stringify({ command: 'ls /tmp' }),
|
|
163
167
|
description: 'Run a shell command on the host.',
|
|
164
168
|
agentName: 'gymbro',
|
|
165
169
|
})
|
|
166
|
-
expect(body).toContain('
|
|
170
|
+
expect(body).not.toContain('not provided')
|
|
167
171
|
expect(body).not.toContain('Run a shell command')
|
|
172
|
+
expect(body).toContain('context: _command: ls /tmp_')
|
|
168
173
|
})
|
|
169
174
|
|
|
170
|
-
test('
|
|
175
|
+
test('synthesizes context when the caller reason is whitespace only', () => {
|
|
171
176
|
const body = formatPermissionCardBody({
|
|
172
177
|
toolName: 'Bash',
|
|
173
178
|
inputPreview: JSON.stringify({ command: 'ls /tmp', reason: ' \n ' }),
|
|
174
179
|
description: 'Run a shell command.',
|
|
175
180
|
agentName: 'gymbro',
|
|
176
181
|
})
|
|
177
|
-
expect(body).toContain('
|
|
182
|
+
expect(body).not.toContain('not provided')
|
|
183
|
+
expect(body).toContain('context: _command: ls /tmp_')
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
// #3167 root case: the `reply` tool (and react/edit_message/…) carries NO
|
|
187
|
+
// `reason` argument, so its cards used to render a contentless
|
|
188
|
+
// "🔐 Clerk wants to reply / why: not provided". Now the reply text is
|
|
189
|
+
// synthesized onto a `context:` line so the operator has something to judge.
|
|
190
|
+
test('reply (no reason arg) synthesizes the reply text as context, not "not provided"', () => {
|
|
191
|
+
const body = formatPermissionCardBody({
|
|
192
|
+
toolName: 'mcp__switchroom-telegram__reply',
|
|
193
|
+
inputPreview: JSON.stringify({
|
|
194
|
+
chat_id: '12345',
|
|
195
|
+
text: "On it — pulling yesterday's GitHub activity now.",
|
|
196
|
+
format: 'html',
|
|
197
|
+
disable_notification: true,
|
|
198
|
+
}),
|
|
199
|
+
description: 'Reply on Telegram.',
|
|
200
|
+
agentName: 'clerk',
|
|
201
|
+
})
|
|
202
|
+
const lines = body.split('\n')
|
|
203
|
+
expect(lines[0]).toBe('🔐 **Clerk** wants to reply')
|
|
204
|
+
expect(body).not.toContain('not provided')
|
|
205
|
+
// Salient field surfaced (truncated); id/routing/formatting noise stripped.
|
|
206
|
+
expect(body).toContain('context: _text: On it — pulling')
|
|
207
|
+
expect(body).toMatch(/context: _text: On it — pulling[^_]*…_/)
|
|
208
|
+
expect(body).not.toContain('chat_id')
|
|
209
|
+
expect(body).not.toContain('disable_notification')
|
|
210
|
+
expect(body).not.toContain('format')
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
test('a reason ON a reply card still renders as why:, not context: (#3167)', () => {
|
|
214
|
+
const body = formatPermissionCardBody({
|
|
215
|
+
toolName: 'mcp__switchroom-telegram__reply',
|
|
216
|
+
inputPreview: JSON.stringify({
|
|
217
|
+
chat_id: '12345',
|
|
218
|
+
text: 'done',
|
|
219
|
+
reason: 'answering the operator’s status question',
|
|
220
|
+
}),
|
|
221
|
+
description: 'Reply on Telegram.',
|
|
222
|
+
agentName: 'clerk',
|
|
223
|
+
})
|
|
224
|
+
expect(body).toContain('why: _answering the operator’s status question_')
|
|
225
|
+
expect(body).not.toContain('context:')
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
test('synthesized context redacts secrets in the salient input (#3167)', () => {
|
|
229
|
+
const fakeToken = 'sk-ant-' + 'api03-' + 'B'.repeat(48)
|
|
230
|
+
const body = formatPermissionCardBody({
|
|
231
|
+
toolName: 'mcp__switchroom-telegram__reply',
|
|
232
|
+
inputPreview: JSON.stringify({ chat_id: '1', text: `here is the key: ${fakeToken}` }),
|
|
233
|
+
description: 'Reply on Telegram.',
|
|
234
|
+
agentName: 'clerk',
|
|
235
|
+
})
|
|
236
|
+
expect(body).toContain('context:')
|
|
237
|
+
expect(body).not.toContain(fakeToken)
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
test('falls back to the natural action when the input exposes nothing salient (#3167)', () => {
|
|
241
|
+
const body = formatPermissionCardBody({
|
|
242
|
+
toolName: 'ExitPlanMode',
|
|
243
|
+
inputPreview: undefined,
|
|
244
|
+
description: 'Exit plan mode.',
|
|
245
|
+
agentName: 'clerk',
|
|
246
|
+
})
|
|
247
|
+
expect(body).not.toContain('not provided')
|
|
248
|
+
expect(body).toContain('context: _exit plan mode_')
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
// #3167 review (HIGH/MEDIUM): the salient-value redactor must catch secrets
|
|
252
|
+
// the bare-value path missed. redact() excludes generic_high_entropy and the
|
|
253
|
+
// contextual kv detectors need a `key=value` shape — so a prefixless token
|
|
254
|
+
// under a credential-shaped key leaked verbatim. These FAIL pre-fix.
|
|
255
|
+
test('hard-masks a prefixless high-entropy value under a `token` key (#3167)', () => {
|
|
256
|
+
// No sk-/ghp-/JWT prefix — shape detection alone cannot catch it; only the
|
|
257
|
+
// key-name signal ("token") does.
|
|
258
|
+
const bare = 'Zx8Kq2Lm9Rn4Tv6Wb1Yc3Hd5Jf7Ug0Pe'
|
|
259
|
+
const body = formatPermissionCardBody({
|
|
260
|
+
toolName: 'mcp__acme__do',
|
|
261
|
+
inputPreview: JSON.stringify({ token: bare, note: 'ping' }),
|
|
262
|
+
description: 'do a thing',
|
|
263
|
+
agentName: 'clerk',
|
|
264
|
+
})
|
|
265
|
+
expect(body).not.toContain(bare)
|
|
266
|
+
// `token` value is hard-masked; the benign `note` still surfaces.
|
|
267
|
+
expect(body).toContain('token:')
|
|
268
|
+
expect(body).toContain('REDACTED')
|
|
269
|
+
expect(body).toContain('note: ping')
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
test('hard-masks a 40-hex secret under a `secret`-shaped key (#3167)', () => {
|
|
273
|
+
const hex = 'a3f1'.repeat(10) // 40 hex chars, no prefix
|
|
274
|
+
const body = formatPermissionCardBody({
|
|
275
|
+
toolName: 'mcp__acme__do',
|
|
276
|
+
inputPreview: JSON.stringify({ webhook_secret: hex }),
|
|
277
|
+
description: 'do a thing',
|
|
278
|
+
agentName: 'clerk',
|
|
279
|
+
})
|
|
280
|
+
expect(body).not.toContain(hex)
|
|
281
|
+
expect(body).toContain('REDACTED')
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
test('masks a non-http DSN credential embedded in free text (redactUrls misses it) (#3167)', () => {
|
|
285
|
+
// Under a benign key (`text`) so the hard-mask key path does NOT fire —
|
|
286
|
+
// this exercises the NON_HTTP_DSN_RE scheme coverage specifically.
|
|
287
|
+
const dsn = 'postgres://user:S3cretPass99@db.host:5432/app'
|
|
288
|
+
const body = formatPermissionCardBody({
|
|
289
|
+
toolName: 'mcp__switchroom-telegram__reply',
|
|
290
|
+
inputPreview: JSON.stringify({ chat_id: '1', text: `connect via ${dsn} then run` }),
|
|
291
|
+
description: 'Reply on Telegram.',
|
|
292
|
+
agentName: 'clerk',
|
|
293
|
+
})
|
|
294
|
+
expect(body).not.toContain('S3cretPass99')
|
|
295
|
+
expect(body).not.toContain(dsn)
|
|
296
|
+
expect(body).toContain('REDACTED')
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
test('hard-masks a DATABASE_URL under a url-shaped key (#3167)', () => {
|
|
300
|
+
const dsn = 'postgres://admin:hunter2pass@10.0.0.5/prod'
|
|
301
|
+
const body = formatPermissionCardBody({
|
|
302
|
+
toolName: 'mcp__acme__migrate',
|
|
303
|
+
inputPreview: JSON.stringify({ database_url: dsn }),
|
|
304
|
+
description: 'run a migration',
|
|
305
|
+
agentName: 'clerk',
|
|
306
|
+
})
|
|
307
|
+
expect(body).not.toContain('hunter2pass')
|
|
308
|
+
expect(body).not.toContain(dsn)
|
|
309
|
+
expect(body).toContain('REDACTED')
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
// #3167 review (LOW-2): acceptance asks for "tool + summarized input +
|
|
313
|
+
// originating turn" — surface a compact origin-turn reference when present.
|
|
314
|
+
test('appends a compact originating-turn reference when origin_turn_id is present (#3167)', () => {
|
|
315
|
+
const body = formatPermissionCardBody({
|
|
316
|
+
toolName: 'mcp__switchroom-telegram__reply',
|
|
317
|
+
inputPreview: JSON.stringify({ chat_id: '1', text: 'hi', origin_turn_id: 'turn-abcdef123456' }),
|
|
318
|
+
description: 'Reply on Telegram.',
|
|
319
|
+
agentName: 'clerk',
|
|
320
|
+
})
|
|
321
|
+
expect(body).toContain('· turn …ef123456')
|
|
322
|
+
// The raw routing id is NOT dumped as its own kv pair.
|
|
323
|
+
expect(body).not.toContain('origin_turn_id:')
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
// #3167 review (LOW-3): raw commands/text now reach the context line, so a
|
|
327
|
+
// markdown metachar in the value must be escaped or it can spoof/break the
|
|
328
|
+
// card's own `_italic_` / `code` formatting.
|
|
329
|
+
test('escapes markdown metachars in the synthesized context value (#3167)', () => {
|
|
330
|
+
const body = formatPermissionCardBody({
|
|
331
|
+
toolName: 'Bash',
|
|
332
|
+
inputPreview: JSON.stringify({ command: 'echo _a_ *b* `c`' }),
|
|
333
|
+
description: 'run a shell command',
|
|
334
|
+
agentName: 'clerk',
|
|
335
|
+
})
|
|
336
|
+
// The metachars are backslash-escaped so they can't open emphasis/code.
|
|
337
|
+
expect(body).toContain('\\_a\\_')
|
|
338
|
+
expect(body).toContain('\\*b\\*')
|
|
339
|
+
expect(body).toContain('\\`c\\`')
|
|
340
|
+
expect(body).not.toContain('echo _a_ *b* `c`')
|
|
178
341
|
})
|
|
179
342
|
|
|
180
343
|
test('drops the agent prefix when agentName is null (early-boot edge)', () => {
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration test for the premium-recovery ping GLUE
|
|
3
|
+
* (`runPremiumRecoveryPing`, premium-recovery-wiring.ts) — the never-storm
|
|
4
|
+
* orchestration the pure `decidePremiumRecovery` / `renderPremiumRecoveryPing`
|
|
5
|
+
* tests do NOT cover. This is the P0 at-most-once guarantee (claim-gate → clear-
|
|
6
|
+
* marker-before-send → per-chat fan-out) that was inline in the gateway and so
|
|
7
|
+
* had ZERO coverage; the same untested-glue gap the tier-downgrade extraction
|
|
8
|
+
* closed for the downgrade path.
|
|
9
|
+
*
|
|
10
|
+
* It drives the real runner with injected seams and PINS:
|
|
11
|
+
* - clear-BEFORE-send ordering (never-storm) + exactly one send per chat;
|
|
12
|
+
* - claim !granted → marker cleared, NO send;
|
|
13
|
+
* - decision.fire === false → no clear, no claim, no send;
|
|
14
|
+
* - no marker / no agent dir → no-op;
|
|
15
|
+
* - the button `callback_data` is EXACTLY `mdl:alias:<premium>` (a future edit
|
|
16
|
+
* can't silently break the session-scoped switch-back path).
|
|
17
|
+
* These are outcome assertions that FAIL if the guard logic is reordered/dropped.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { describe, it, expect } from 'vitest'
|
|
21
|
+
import {
|
|
22
|
+
runPremiumRecoveryPing,
|
|
23
|
+
type PremiumRecoveryPingDeps,
|
|
24
|
+
type PremiumRecoveryMarkerView,
|
|
25
|
+
type PremiumRecoveryKeyboard,
|
|
26
|
+
} from '../gateway/premium-recovery-wiring.js'
|
|
27
|
+
import type { PremiumRecoveryPing } from '../premium-recovery.js'
|
|
28
|
+
|
|
29
|
+
interface Recorder {
|
|
30
|
+
/** Ordered event log — pins clear-before-send. */
|
|
31
|
+
events: string[]
|
|
32
|
+
clears: number
|
|
33
|
+
claims: string[]
|
|
34
|
+
sends: Array<{ chatId: string; ping: PremiumRecoveryPing; keyboard: PremiumRecoveryKeyboard }>
|
|
35
|
+
decides: number
|
|
36
|
+
logs: string[]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function makeDeps(
|
|
40
|
+
over: {
|
|
41
|
+
agentDir?: string | null
|
|
42
|
+
marker?: PremiumRecoveryMarkerView | null
|
|
43
|
+
fire?: boolean
|
|
44
|
+
granted?: boolean
|
|
45
|
+
fallbackChats?: string[]
|
|
46
|
+
} = {},
|
|
47
|
+
): { deps: PremiumRecoveryPingDeps; rec: Recorder } {
|
|
48
|
+
const rec: Recorder = { events: [], clears: 0, claims: [], sends: [], decides: 0, logs: [] }
|
|
49
|
+
const deps: PremiumRecoveryPingDeps = {
|
|
50
|
+
getAgentDir: () => (over.agentDir === undefined ? '/tmp/agent' : over.agentDir),
|
|
51
|
+
readMarker: () =>
|
|
52
|
+
over.marker === undefined
|
|
53
|
+
? { premiumModel: 'fable', chats: ['111', '222'] }
|
|
54
|
+
: over.marker,
|
|
55
|
+
clearMarker: () => {
|
|
56
|
+
rec.clears += 1
|
|
57
|
+
rec.events.push('clear')
|
|
58
|
+
},
|
|
59
|
+
getAgent: () => 'klanker',
|
|
60
|
+
decide: () => {
|
|
61
|
+
rec.decides += 1
|
|
62
|
+
return over.fire === undefined ? true : over.fire
|
|
63
|
+
},
|
|
64
|
+
claimNotification: async (key) => {
|
|
65
|
+
rec.claims.push(key)
|
|
66
|
+
return over.granted === undefined ? true : over.granted
|
|
67
|
+
},
|
|
68
|
+
fallbackChats: () => over.fallbackChats ?? ['999'],
|
|
69
|
+
sendToChat: (chatId, ping, keyboard) => {
|
|
70
|
+
rec.sends.push({ chatId, ping, keyboard })
|
|
71
|
+
rec.events.push(`send:${chatId}`)
|
|
72
|
+
},
|
|
73
|
+
log: (msg) => rec.logs.push(msg),
|
|
74
|
+
}
|
|
75
|
+
return { deps, rec }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
describe('runPremiumRecoveryPing — never-storm at-most-once ordering', () => {
|
|
79
|
+
it('fire + granted: marker cleared BEFORE any send, exactly one send per chat', async () => {
|
|
80
|
+
const { deps, rec } = makeDeps({ marker: { premiumModel: 'fable', chats: ['111', '222'] } })
|
|
81
|
+
await runPremiumRecoveryPing(deps)
|
|
82
|
+
expect(rec.clears).toBe(1)
|
|
83
|
+
// One send per recorded chat, in order, no duplicates.
|
|
84
|
+
expect(rec.sends.map((s) => s.chatId)).toEqual(['111', '222'])
|
|
85
|
+
// The clear MUST precede the first send (a double-send is unrecoverable).
|
|
86
|
+
const clearIdx = rec.events.indexOf('clear')
|
|
87
|
+
const firstSendIdx = rec.events.findIndex((e) => e.startsWith('send:'))
|
|
88
|
+
expect(clearIdx).toBeGreaterThanOrEqual(0)
|
|
89
|
+
expect(firstSendIdx).toBeGreaterThanOrEqual(0)
|
|
90
|
+
expect(clearIdx).toBeLessThan(firstSendIdx)
|
|
91
|
+
// The fleet claim was keyed on agent + the dropped premium token.
|
|
92
|
+
expect(rec.claims).toEqual(['premium-recovery:klanker:fable'])
|
|
93
|
+
expect(rec.logs).toHaveLength(1)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('claim NOT granted: marker cleared (no lingering re-attempt), NO send', async () => {
|
|
97
|
+
const { deps, rec } = makeDeps({ granted: false })
|
|
98
|
+
await runPremiumRecoveryPing(deps)
|
|
99
|
+
expect(rec.claims).toEqual(['premium-recovery:klanker:fable'])
|
|
100
|
+
expect(rec.clears).toBe(1)
|
|
101
|
+
expect(rec.sends).toHaveLength(0)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('decision.fire === false: NO clear, NO claim, NO send (marker survives for a later tick)', async () => {
|
|
105
|
+
const { deps, rec } = makeDeps({ fire: false })
|
|
106
|
+
await runPremiumRecoveryPing(deps)
|
|
107
|
+
expect(rec.decides).toBe(1)
|
|
108
|
+
expect(rec.clears).toBe(0)
|
|
109
|
+
expect(rec.claims).toHaveLength(0)
|
|
110
|
+
expect(rec.sends).toHaveLength(0)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('no marker present: no-op — no decide, no claim, no clear, no send', async () => {
|
|
114
|
+
const { deps, rec } = makeDeps({ marker: null })
|
|
115
|
+
await runPremiumRecoveryPing(deps)
|
|
116
|
+
expect(rec.decides).toBe(0)
|
|
117
|
+
expect(rec.clears).toBe(0)
|
|
118
|
+
expect(rec.claims).toHaveLength(0)
|
|
119
|
+
expect(rec.sends).toHaveLength(0)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('no agent dir: immediate no-op (marker is never even read)', async () => {
|
|
123
|
+
const { deps, rec } = makeDeps({ agentDir: null })
|
|
124
|
+
await runPremiumRecoveryPing(deps)
|
|
125
|
+
expect(rec.decides).toBe(0)
|
|
126
|
+
expect(rec.clears).toBe(0)
|
|
127
|
+
expect(rec.sends).toHaveLength(0)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('marker with no chats falls back to the access allowFrom chats', async () => {
|
|
131
|
+
const { deps, rec } = makeDeps({
|
|
132
|
+
marker: { premiumModel: 'fable', chats: [] },
|
|
133
|
+
fallbackChats: ['999'],
|
|
134
|
+
})
|
|
135
|
+
await runPremiumRecoveryPing(deps)
|
|
136
|
+
expect(rec.sends.map((s) => s.chatId)).toEqual(['999'])
|
|
137
|
+
})
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
describe('runPremiumRecoveryPing — session-scoped switch-back button', () => {
|
|
141
|
+
it('the button callback_data is EXACTLY mdl:alias:<premium> (routes the model-menu apply path)', async () => {
|
|
142
|
+
const { deps, rec } = makeDeps({ marker: { premiumModel: 'fable', chats: ['111'] } })
|
|
143
|
+
await runPremiumRecoveryPing(deps)
|
|
144
|
+
const button = rec.sends[0].keyboard.inline_keyboard[0][0]
|
|
145
|
+
// Pin the exact wire format — a change to MODEL_CALLBACK_ALIAS or the button
|
|
146
|
+
// construction that broke the session-scoped `/model` apply path must fail here.
|
|
147
|
+
expect(button.callback_data).toBe('mdl:alias:fable')
|
|
148
|
+
expect(button.text).toBe('Switch to fable')
|
|
149
|
+
})
|
|
150
|
+
})
|