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
|
@@ -542,6 +542,121 @@ describe('hasOutboundDeliveredSince', () => {
|
|
|
542
542
|
})
|
|
543
543
|
})
|
|
544
544
|
|
|
545
|
+
// Review finding H5 regression: the original PRIMARY KEY (chat_id, thread_id, message_id) does
|
|
546
|
+
// NOT dedupe thread-less rows because SQLite treats NULL as distinct from NULL
|
|
547
|
+
// in a UNIQUE/PK index. The documented at-least-once boot replay re-records an
|
|
548
|
+
// already-stored DM/non-topic message, so `INSERT OR REPLACE` appended a
|
|
549
|
+
// DUPLICATE row instead of replacing — over-counting the silence / over-ping
|
|
550
|
+
// detectors. The COALESCE(thread_id,'') unique index makes the upsert
|
|
551
|
+
// idempotent. These tests fail (duplicate rows) without the fix.
|
|
552
|
+
describe('idempotent upsert for null-thread rows (H5)', () => {
|
|
553
|
+
beforeEach(() => initHistory(stateDir, 30))
|
|
554
|
+
|
|
555
|
+
it('re-recording the same null-thread inbound yields exactly ONE row', () => {
|
|
556
|
+
const msg = {
|
|
557
|
+
chat_id: '-100',
|
|
558
|
+
thread_id: null,
|
|
559
|
+
message_id: 7,
|
|
560
|
+
user: 'alice',
|
|
561
|
+
user_id: '111',
|
|
562
|
+
ts: 1000,
|
|
563
|
+
text: 'hello',
|
|
564
|
+
}
|
|
565
|
+
// Simulate the at-least-once replay: record the same message twice.
|
|
566
|
+
recordInbound(msg)
|
|
567
|
+
recordInbound(msg)
|
|
568
|
+
const rows = query({ chat_id: '-100' })
|
|
569
|
+
expect(rows).toHaveLength(1)
|
|
570
|
+
expect(rows[0]).toMatchObject({ message_id: 7, role: 'user', text: 'hello' })
|
|
571
|
+
// The over-count surface the finding calls out must stay accurate.
|
|
572
|
+
expect(getRecentOutboundCount('-100', 999_999)).toBe(0)
|
|
573
|
+
})
|
|
574
|
+
|
|
575
|
+
it('re-recording the same null-thread outbound yields exactly ONE row', () => {
|
|
576
|
+
const now = Math.floor(Date.now() / 1000)
|
|
577
|
+
const send = {
|
|
578
|
+
chat_id: '-100',
|
|
579
|
+
thread_id: null,
|
|
580
|
+
message_ids: [42],
|
|
581
|
+
texts: ['the answer'],
|
|
582
|
+
ts: now,
|
|
583
|
+
}
|
|
584
|
+
recordOutbound(send)
|
|
585
|
+
recordOutbound(send)
|
|
586
|
+
const rows = query({ chat_id: '-100' })
|
|
587
|
+
expect(rows).toHaveLength(1)
|
|
588
|
+
// hasOutboundDeliveredSince / getRecentOutboundCount read this table; a
|
|
589
|
+
// duplicate here would double-count and over-ping.
|
|
590
|
+
expect(getRecentOutboundCount('-100', 60)).toBe(1)
|
|
591
|
+
})
|
|
592
|
+
|
|
593
|
+
it('a re-record REPLACES rather than appends (newest text wins)', () => {
|
|
594
|
+
recordInbound({ chat_id: '-100', thread_id: null, message_id: 7, user: 'a', user_id: '1', ts: 1000, text: 'first version' })
|
|
595
|
+
recordInbound({ chat_id: '-100', thread_id: null, message_id: 7, user: 'a', user_id: '1', ts: 1000, text: 'second version' })
|
|
596
|
+
const rows = query({ chat_id: '-100' })
|
|
597
|
+
expect(rows).toHaveLength(1)
|
|
598
|
+
expect(rows[0]?.text).toBe('second version')
|
|
599
|
+
})
|
|
600
|
+
|
|
601
|
+
it('a topic row and a general row with the SAME message_id stay DISTINCT', () => {
|
|
602
|
+
// Same chat_id + message_id, but one is in a forum topic (thread 5) and one
|
|
603
|
+
// is the general/DM row (null thread). These are different logical messages
|
|
604
|
+
// and must both survive — the COALESCE sentinel keeps them separate.
|
|
605
|
+
recordInbound({ chat_id: '-100', thread_id: 5, message_id: 9, user: 'a', user_id: '1', ts: 100, text: 'in topic 5' })
|
|
606
|
+
recordInbound({ chat_id: '-100', thread_id: null, message_id: 9, user: 'a', user_id: '1', ts: 100, text: 'in general' })
|
|
607
|
+
expect(query({ chat_id: '-100' })).toHaveLength(2)
|
|
608
|
+
expect(query({ chat_id: '-100', thread_id: 5 }).map(r => r.text)).toEqual(['in topic 5'])
|
|
609
|
+
expect(query({ chat_id: '-100', thread_id: null }).map(r => r.text)).toEqual(['in general'])
|
|
610
|
+
})
|
|
611
|
+
|
|
612
|
+
it('two distinct forum topics with the same message_id stay DISTINCT', () => {
|
|
613
|
+
recordInbound({ chat_id: '-100', thread_id: 5, message_id: 9, user: 'a', user_id: '1', ts: 100, text: 'topic5' })
|
|
614
|
+
recordInbound({ chat_id: '-100', thread_id: 6, message_id: 9, user: 'a', user_id: '1', ts: 100, text: 'topic6' })
|
|
615
|
+
expect(query({ chat_id: '-100' })).toHaveLength(2)
|
|
616
|
+
expect(query({ chat_id: '-100', thread_id: 5 }).map(r => r.text)).toEqual(['topic5'])
|
|
617
|
+
expect(query({ chat_id: '-100', thread_id: 6 }).map(r => r.text)).toEqual(['topic6'])
|
|
618
|
+
})
|
|
619
|
+
|
|
620
|
+
it('migration de-dupes pre-existing null-thread duplicates from a legacy DB', async () => {
|
|
621
|
+
// Simulate a DB written by the pre-fix build: insert duplicate null-thread
|
|
622
|
+
// rows DIRECTLY, bypassing the (now-fixed) upsert, to reproduce the exact
|
|
623
|
+
// corruption the finding describes. Then re-init to fire the migration.
|
|
624
|
+
const { Database } = await import('bun:sqlite')
|
|
625
|
+
const dbPath = join(stateDir, 'history.db')
|
|
626
|
+
const now = Math.floor(Date.now() / 1000) // recent ts so the retention sweep keeps them
|
|
627
|
+
_resetForTests()
|
|
628
|
+
const raw = new Database(dbPath)
|
|
629
|
+
// Drop the logical-key index so we're back to the legacy, dupe-permitting
|
|
630
|
+
// schema, then append two rows sharing the same (chat, null-thread, msg).
|
|
631
|
+
raw.exec('DROP INDEX IF EXISTS idx_messages_logical_key')
|
|
632
|
+
const insert = raw.prepare(
|
|
633
|
+
`INSERT INTO messages (chat_id, thread_id, message_id, role, user, user_id, ts, text, group_id)
|
|
634
|
+
VALUES (?, NULL, ?, 'user', 'a', '1', ?, ?, NULL)`,
|
|
635
|
+
)
|
|
636
|
+
insert.run('-100', 7, now, 'stale copy')
|
|
637
|
+
insert.run('-100', 7, now + 1, 'newest copy')
|
|
638
|
+
// A distinct topic row with the same message_id must survive the migration.
|
|
639
|
+
raw.prepare(
|
|
640
|
+
`INSERT INTO messages (chat_id, thread_id, message_id, role, user, user_id, ts, text, group_id)
|
|
641
|
+
VALUES (?, 5, ?, 'user', 'a', '1', ?, ?, NULL)`,
|
|
642
|
+
).run('-100', 7, now, 'topic row')
|
|
643
|
+
raw.close()
|
|
644
|
+
|
|
645
|
+
// Re-open through initHistory → runs the H5 migration (dedupe + index).
|
|
646
|
+
initHistory(stateDir, 30)
|
|
647
|
+
const general = query({ chat_id: '-100', thread_id: null })
|
|
648
|
+
expect(general).toHaveLength(1)
|
|
649
|
+
expect(general[0]?.text).toBe('newest copy') // kept the newest (highest ts/rowid)
|
|
650
|
+
// The topic row with the same message_id is untouched.
|
|
651
|
+
expect(query({ chat_id: '-100', thread_id: 5 }).map(r => r.text)).toEqual(['topic row'])
|
|
652
|
+
expect(query({ chat_id: '-100' })).toHaveLength(2)
|
|
653
|
+
|
|
654
|
+
// And the upsert is idempotent going forward.
|
|
655
|
+
recordInbound({ chat_id: '-100', thread_id: null, message_id: 7, user: 'a', user_id: '1', ts: now + 2, text: 'replay' })
|
|
656
|
+
expect(query({ chat_id: '-100', thread_id: null })).toHaveLength(1)
|
|
657
|
+
})
|
|
658
|
+
})
|
|
659
|
+
|
|
545
660
|
describe('secret redaction at persistence (both directions)', () => {
|
|
546
661
|
beforeEach(() => initHistory(stateDir, 30))
|
|
547
662
|
|
|
@@ -206,7 +206,11 @@ describe('inbound message-type helpers: handleAckOnly + handleRefusal', () => {
|
|
|
206
206
|
const fnStart = SRC.indexOf('async function handleAckOnly(')
|
|
207
207
|
const fnSlice = SRC.slice(fnStart, fnStart + 2000)
|
|
208
208
|
expect(fnSlice).toContain('gate(ctx)')
|
|
209
|
-
|
|
209
|
+
// #3155: the reaction now routes through the gated `sendReaction` helper
|
|
210
|
+
// (send gate + flood breaker, cosmetic) instead of a raw
|
|
211
|
+
// `bot.api.setMessageReaction`. The invariant is unchanged: it gates, THEN
|
|
212
|
+
// reacts.
|
|
213
|
+
expect(fnSlice).toContain('sendReaction(')
|
|
210
214
|
})
|
|
211
215
|
|
|
212
216
|
it('handleAckOnly drops non-allowlisted senders silently', () => {
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
AGENT_CALLBACK_PREFIX,
|
|
15
15
|
AGENT_CALLBACK_DATA_MAX,
|
|
16
16
|
wrapAgentCallbacks,
|
|
17
|
+
redactAgentKeyboard,
|
|
17
18
|
parseAgentCallback,
|
|
18
19
|
validateAndWrapAgentKeyboard,
|
|
19
20
|
extractAgentButtonMeta,
|
|
@@ -22,6 +23,7 @@ import {
|
|
|
22
23
|
escapeHtmlEntities,
|
|
23
24
|
applyTapAnnotationEdit,
|
|
24
25
|
} from '../inline-keyboard-callbacks.js'
|
|
26
|
+
import { redact } from '../secret-detect/redact.js'
|
|
25
27
|
|
|
26
28
|
describe('inline-keyboard-callbacks (#271)', () => {
|
|
27
29
|
describe('AGENT_CALLBACK_DATA_MAX', () => {
|
|
@@ -431,4 +433,166 @@ describe('inline-keyboard-callbacks (#271)', () => {
|
|
|
431
433
|
expect(outcome).toBe('failed')
|
|
432
434
|
})
|
|
433
435
|
})
|
|
436
|
+
|
|
437
|
+
// ─── #3148 fast-follow: agent-authored keyboard secret scrub ──────────────
|
|
438
|
+
//
|
|
439
|
+
// wrapAgentCallbacks rewrites ONLY callback_data; the visible `text` label,
|
|
440
|
+
// the `ack_text` toast, and any `copy_text.text` clipboard payload passed
|
|
441
|
+
// through VERBATIM before this fix — so a secret an agent placed in any of
|
|
442
|
+
// them transmitted to Telegram unmasked and resurfaced on tap (echo, toast,
|
|
443
|
+
// "✅ You chose" annotation). redactAgentKeyboard masks those three fields at
|
|
444
|
+
// the outbound boundary using the SAME redact() the reply body uses, while
|
|
445
|
+
// leaving the routing key (callback_data) exact.
|
|
446
|
+
describe('redactAgentKeyboard (#3148 fast-follow)', () => {
|
|
447
|
+
// Assemble the fixture at runtime so this source file never carries a
|
|
448
|
+
// contiguous token that trips push-protection / secretlint (CLAUDE.md).
|
|
449
|
+
const GITHUB_PAT = 'ghp' + '_' + '16C7e42F292c6912E7710c838347Ae178B4a'
|
|
450
|
+
|
|
451
|
+
it('masks a secret in a button `text` label in the sent payload (real redact)', () => {
|
|
452
|
+
const raw = [
|
|
453
|
+
[{ text: `Use ${GITHUB_PAT}`, callback_data: 'use_token' }],
|
|
454
|
+
]
|
|
455
|
+
// Mirror the gateway pipeline: redact BEFORE wrap.
|
|
456
|
+
const wrapped = wrapAgentCallbacks(redactAgentKeyboard(raw, redact))
|
|
457
|
+
const btn = wrapped[0]![0]!
|
|
458
|
+
// OUTCOME: the label bytes actually shipped to Telegram carry no secret.
|
|
459
|
+
expect(btn.text as string).not.toContain(GITHUB_PAT)
|
|
460
|
+
expect(btn.text as string).toContain('[REDACTED')
|
|
461
|
+
expect((btn.text as string).length).toBeGreaterThan(0)
|
|
462
|
+
// Routing key untouched (only the agent: namespace prefix was added).
|
|
463
|
+
expect(btn.callback_data).toBe(`${AGENT_CALLBACK_PREFIX}use_token`)
|
|
464
|
+
})
|
|
465
|
+
|
|
466
|
+
it('masks a secret in `ack_text` in the extracted per-button meta (real redact)', () => {
|
|
467
|
+
const raw = [
|
|
468
|
+
[{ text: 'Deploy', callback_data: 'deploy', ack_text: `token ${GITHUB_PAT}` }],
|
|
469
|
+
]
|
|
470
|
+
// Mirror the gateway: extract meta from the REDACTED keyboard so the
|
|
471
|
+
// stashed toast (shown via answerCallbackQuery on tap) is masked.
|
|
472
|
+
const meta = extractAgentButtonMeta(redactAgentKeyboard(raw, redact))
|
|
473
|
+
const ack = meta.get('deploy')!.ack_text!
|
|
474
|
+
expect(ack).not.toContain(GITHUB_PAT)
|
|
475
|
+
expect(ack).toContain('[REDACTED')
|
|
476
|
+
})
|
|
477
|
+
|
|
478
|
+
it('masks a secret in `copy_text.text` clipboard payload (real redact)', () => {
|
|
479
|
+
const raw = [
|
|
480
|
+
[{ text: 'Copy token', callback_data: 'copy', copy_text: { text: GITHUB_PAT } }],
|
|
481
|
+
]
|
|
482
|
+
const [[btn]] = redactAgentKeyboard(raw, redact)
|
|
483
|
+
const ct = (btn as { copy_text: { text: string } }).copy_text.text
|
|
484
|
+
expect(ct).not.toContain(GITHUB_PAT)
|
|
485
|
+
expect(ct).toContain('[REDACTED')
|
|
486
|
+
})
|
|
487
|
+
|
|
488
|
+
it('never empties a label that is ENTIRELY a secret (non-empty invariant)', () => {
|
|
489
|
+
const raw = [[{ text: GITHUB_PAT, callback_data: 'x' }]]
|
|
490
|
+
const [[btn]] = redactAgentKeyboard(raw, redact)
|
|
491
|
+
expect((btn.text as string).length).toBeGreaterThan(0)
|
|
492
|
+
expect(btn.text as string).not.toContain(GITHUB_PAT)
|
|
493
|
+
})
|
|
494
|
+
|
|
495
|
+
it('leaves a legit (secret-free) label unchanged', () => {
|
|
496
|
+
const raw = [
|
|
497
|
+
[{ text: 'Approve PR #547', callback_data: 'approve', ack_text: 'Approved ✓' }],
|
|
498
|
+
[{ text: 'Hold', callback_data: 'hold' }],
|
|
499
|
+
]
|
|
500
|
+
const out = redactAgentKeyboard(raw, redact)
|
|
501
|
+
expect(out[0]![0]!.text).toBe('Approve PR #547')
|
|
502
|
+
expect(out[0]![0]!.ack_text).toBe('Approved ✓')
|
|
503
|
+
expect(out[1]![0]!.text).toBe('Hold')
|
|
504
|
+
})
|
|
505
|
+
|
|
506
|
+
it('NEVER passes callback_data through the redactor (routing key stays exact)', () => {
|
|
507
|
+
// A fake redactor that mangles everything it sees; anything the routing
|
|
508
|
+
// key touched would be corrupted. Proves copy_text/url/callback_data are
|
|
509
|
+
// handled correctly by field, not blanket-scrubbed.
|
|
510
|
+
const seen: string[] = []
|
|
511
|
+
const mangle = (s: string) => {
|
|
512
|
+
seen.push(s)
|
|
513
|
+
return `X${s}X`
|
|
514
|
+
}
|
|
515
|
+
const raw = [
|
|
516
|
+
[
|
|
517
|
+
{ text: 'Label', callback_data: 'route_key_do_not_touch', ack_text: 'toast' },
|
|
518
|
+
{ text: 'Link', url: 'https://example.com/keep' },
|
|
519
|
+
{ text: 'Clip', callback_data: 'c2', copy_text: { text: 'secret-clip' } },
|
|
520
|
+
],
|
|
521
|
+
]
|
|
522
|
+
const out = redactAgentKeyboard(raw, mangle)
|
|
523
|
+
// callback_data + url are byte-exact.
|
|
524
|
+
expect(out[0]![0]!.callback_data).toBe('route_key_do_not_touch')
|
|
525
|
+
expect(out[0]![1]!.url).toBe('https://example.com/keep')
|
|
526
|
+
expect(out[0]![2]!.callback_data).toBe('c2')
|
|
527
|
+
// Only free-text fields were routed through the redactor.
|
|
528
|
+
expect(out[0]![0]!.text).toBe('XLabelX')
|
|
529
|
+
expect(out[0]![0]!.ack_text).toBe('XtoastX')
|
|
530
|
+
expect((out[0]![2]! as { copy_text: { text: string } }).copy_text.text).toBe('Xsecret-clipX')
|
|
531
|
+
expect(seen).toEqual(['Label', 'toast', 'Link', 'Clip', 'secret-clip'])
|
|
532
|
+
expect(seen).not.toContain('route_key_do_not_touch')
|
|
533
|
+
expect(seen).not.toContain('https://example.com/keep')
|
|
534
|
+
expect(seen).not.toContain('c2')
|
|
535
|
+
})
|
|
536
|
+
|
|
537
|
+
it('masks a secret in switch_inline_query* (pasted into the chat input on tap)', () => {
|
|
538
|
+
const raw = [
|
|
539
|
+
[{ text: 'Share', callback_data: 's', switch_inline_query: `key ${GITHUB_PAT}` }],
|
|
540
|
+
[{
|
|
541
|
+
text: 'Fill here',
|
|
542
|
+
callback_data: 'f',
|
|
543
|
+
switch_inline_query_current_chat: `key ${GITHUB_PAT}`,
|
|
544
|
+
}],
|
|
545
|
+
]
|
|
546
|
+
const out = redactAgentKeyboard(raw, redact)
|
|
547
|
+
const siq = (out[0]![0]! as { switch_inline_query: string }).switch_inline_query
|
|
548
|
+
const siqc = (out[1]![0]! as { switch_inline_query_current_chat: string })
|
|
549
|
+
.switch_inline_query_current_chat
|
|
550
|
+
expect(siq).not.toContain(GITHUB_PAT)
|
|
551
|
+
expect(siq).toContain('[REDACTED')
|
|
552
|
+
expect(siqc).not.toContain(GITHUB_PAT)
|
|
553
|
+
expect(siqc).toContain('[REDACTED')
|
|
554
|
+
})
|
|
555
|
+
|
|
556
|
+
it('masks a secret in switch_inline_query_chosen_chat.query', () => {
|
|
557
|
+
const raw = [[{
|
|
558
|
+
text: 'Share to…',
|
|
559
|
+
callback_data: 'cc',
|
|
560
|
+
switch_inline_query_chosen_chat: { query: `key ${GITHUB_PAT}`, allow_user_chats: true },
|
|
561
|
+
}]]
|
|
562
|
+
const [[btn]] = redactAgentKeyboard(raw, redact)
|
|
563
|
+
const cc = (btn as { switch_inline_query_chosen_chat: { query: string; allow_user_chats?: boolean } })
|
|
564
|
+
.switch_inline_query_chosen_chat
|
|
565
|
+
expect(cc.query).not.toContain(GITHUB_PAT)
|
|
566
|
+
expect(cc.query).toContain('[REDACTED')
|
|
567
|
+
// sibling sub-fields are preserved
|
|
568
|
+
expect(cc.allow_user_chats).toBe(true)
|
|
569
|
+
})
|
|
570
|
+
|
|
571
|
+
it('clamps a masked field that the marker pushed over the Telegram cap (reply not dropped)', () => {
|
|
572
|
+
// A 60-char label (< 64 cap) whose short secret expands under the marker
|
|
573
|
+
// would exceed 64 and 400-drop the whole reply; the clamp keeps it ≤ cap.
|
|
574
|
+
const shortSecret = 'AKIAIOSFODNN7EXAMPLE' // 20 chars, AWS-key shaped
|
|
575
|
+
const label = `${shortSecret} ${'x'.repeat(43)}` // 64 chars total, at the cap
|
|
576
|
+
const raw = [[{ text: label, callback_data: 'x' }]]
|
|
577
|
+
const [[btn]] = redactAgentKeyboard(raw, redact)
|
|
578
|
+
const text = btn.text as string
|
|
579
|
+
expect(text.length).toBeLessThanOrEqual(64)
|
|
580
|
+
expect(text).not.toContain(shortSecret)
|
|
581
|
+
})
|
|
582
|
+
|
|
583
|
+
it('preserves keyboard structure, row/column order, and does not mutate input', () => {
|
|
584
|
+
const raw = [
|
|
585
|
+
[{ text: 'A', callback_data: 'a' }, { text: 'B', callback_data: 'b' }],
|
|
586
|
+
[{ text: 'C', callback_data: 'c' }],
|
|
587
|
+
]
|
|
588
|
+
const out = redactAgentKeyboard(raw, (s) => s)
|
|
589
|
+
expect(out.map((r) => r.map((b) => b.callback_data))).toEqual([
|
|
590
|
+
['a', 'b'],
|
|
591
|
+
['c'],
|
|
592
|
+
])
|
|
593
|
+
// Fresh objects — input untouched.
|
|
594
|
+
expect(out[0]![0]).not.toBe(raw[0]![0])
|
|
595
|
+
expect(raw[0]![0]!.text).toBe('A')
|
|
596
|
+
})
|
|
597
|
+
})
|
|
434
598
|
})
|
|
@@ -9,6 +9,7 @@ import { tmpdir } from 'os'
|
|
|
9
9
|
import { join } from 'path'
|
|
10
10
|
import { detectErrorInTranscriptLine, startSessionTail } from '../session-tail.js'
|
|
11
11
|
import { resetAllCooldowns } from '../operator-events.js'
|
|
12
|
+
import { resolveModelUnavailableFromOperatorEvent } from '../model-unavailable.js'
|
|
12
13
|
|
|
13
14
|
// ─── detectErrorInTranscriptLine unit tests ───────────────────────────────────
|
|
14
15
|
|
|
@@ -155,6 +156,79 @@ describe('detectErrorInTranscriptLine — error detection', () => {
|
|
|
155
156
|
expect(result!.detail).toContain('hit your limit')
|
|
156
157
|
})
|
|
157
158
|
|
|
159
|
+
// Regression — the carrie incident (2026-07-12). Anthropic also emits a
|
|
160
|
+
// 429 for a TRANSIENT per-account burst / RPM throttle whose wording
|
|
161
|
+
// explicitly negates the account-quota reading ("would exceed your
|
|
162
|
+
// account's rate limit … not your usage limit"). That is a self-healing
|
|
163
|
+
// few-second throttle Claude Code retries internally — it must NOT be
|
|
164
|
+
// labeled quota-exhausted (which always shows the scary "model
|
|
165
|
+
// unavailable" card + drives failover). It must take the calm
|
|
166
|
+
// rate-limited path.
|
|
167
|
+
it('classifies a TRANSIENT burst 429 (explicit negation) as rate-limited, NOT quota-exhausted', () => {
|
|
168
|
+
const line = JSON.stringify({
|
|
169
|
+
type: 'assistant',
|
|
170
|
+
message: {
|
|
171
|
+
role: 'assistant',
|
|
172
|
+
model: '<synthetic>',
|
|
173
|
+
content: [
|
|
174
|
+
{
|
|
175
|
+
type: 'text',
|
|
176
|
+
text:
|
|
177
|
+
'API Error: 429 rate_limit_error This request would exceed ' +
|
|
178
|
+
"your account's rate limit. Please try again later. This is a " +
|
|
179
|
+
'short-term burst limit, not your usage limit.',
|
|
180
|
+
},
|
|
181
|
+
],
|
|
182
|
+
},
|
|
183
|
+
error: 'rate_limit',
|
|
184
|
+
isApiErrorMessage: true,
|
|
185
|
+
apiErrorStatus: 429,
|
|
186
|
+
})
|
|
187
|
+
const result = detectErrorInTranscriptLine(line)
|
|
188
|
+
expect(result).not.toBeNull()
|
|
189
|
+
// Wording-based classification: explicit transient negation → calm path.
|
|
190
|
+
expect(result!.kind).toBe('rate-limited')
|
|
191
|
+
expect(result!.transient).toBe(true)
|
|
192
|
+
// End-to-end: the resolver must NOT produce a model-unavailable card for
|
|
193
|
+
// this kind (the calm rate-limited branch returns null on a bare burst).
|
|
194
|
+
const detection = resolveModelUnavailableFromOperatorEvent({
|
|
195
|
+
kind: result!.kind,
|
|
196
|
+
detail: result!.detail,
|
|
197
|
+
})
|
|
198
|
+
expect(detection).toBeNull()
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
// Guard against over-correcting: a GENUINE quota wall (no transient marker)
|
|
202
|
+
// must STILL be quota-exhausted AND still resolve to a card.
|
|
203
|
+
it('a genuine quota-wall 429 still produces the quota-exhausted card', () => {
|
|
204
|
+
const line = JSON.stringify({
|
|
205
|
+
type: 'assistant',
|
|
206
|
+
message: {
|
|
207
|
+
role: 'assistant',
|
|
208
|
+
model: '<synthetic>',
|
|
209
|
+
content: [
|
|
210
|
+
{
|
|
211
|
+
type: 'text',
|
|
212
|
+
text: "You've hit your limit · resets 8:50am (Australia/Melbourne)",
|
|
213
|
+
},
|
|
214
|
+
],
|
|
215
|
+
},
|
|
216
|
+
error: 'rate_limit',
|
|
217
|
+
isApiErrorMessage: true,
|
|
218
|
+
apiErrorStatus: 429,
|
|
219
|
+
})
|
|
220
|
+
const result = detectErrorInTranscriptLine(line)
|
|
221
|
+
expect(result).not.toBeNull()
|
|
222
|
+
expect(result!.kind).toBe('quota-exhausted')
|
|
223
|
+
// The quota-exhausted branch ALWAYS returns a detection (the card).
|
|
224
|
+
const detection = resolveModelUnavailableFromOperatorEvent({
|
|
225
|
+
kind: result!.kind,
|
|
226
|
+
detail: result!.detail,
|
|
227
|
+
})
|
|
228
|
+
expect(detection).not.toBeNull()
|
|
229
|
+
expect(detection!.kind).toBe('quota_exhausted')
|
|
230
|
+
})
|
|
231
|
+
|
|
158
232
|
it('still returns null for a normal (non-error) assistant message', () => {
|
|
159
233
|
// No isApiErrorMessage flag → must NOT be treated as an error.
|
|
160
234
|
const line = JSON.stringify({
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
redactAskUserFields,
|
|
4
|
+
redactChecklistFields,
|
|
5
|
+
} from '../outbound-field-redact.js'
|
|
6
|
+
import { redact } from '../secret-detect/redact.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* OUTCOME tests for the #2044 structured-payload outbound scrub (F1/F2).
|
|
10
|
+
*
|
|
11
|
+
* These exercise the exact helpers the gateway calls (redactAskUserFields
|
|
12
|
+
* for ask_user, redactChecklistFields for send_checklist / update_checklist),
|
|
13
|
+
* injecting the SAME real `redact()` the gateway's redactOutboundText wraps.
|
|
14
|
+
* The assertion is on the SENT PAYLOAD the helper returns — a secret echoed
|
|
15
|
+
* into a question, an option/button label, a checklist title, or a task's
|
|
16
|
+
* text must be masked (marker present, raw token absent) — not merely that a
|
|
17
|
+
* redactor was invoked.
|
|
18
|
+
*
|
|
19
|
+
* Secrets are assembled at runtime (per CLAUDE.md "Secrets in tests") so the
|
|
20
|
+
* source file never contains a contiguous token that trips push-protection.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
// A realistic GitHub classic PAT shape (ghp_ + 36 chars) — high-confidence,
|
|
24
|
+
// masked by redact() to a [REDACTED:github_pat_classic] marker.
|
|
25
|
+
const SECRET = 'ghp' + '_' + 'AbCdEfGhIj0123456789KlMnOpQrStUvWxYz'
|
|
26
|
+
|
|
27
|
+
describe('redactAskUserFields — ask_user outbound scrub (F1)', () => {
|
|
28
|
+
it('masks a secret echoed into the QUESTION text', () => {
|
|
29
|
+
const out = redactAskUserFields(
|
|
30
|
+
`Deploy with token ${SECRET}?`,
|
|
31
|
+
['Yes', 'No'],
|
|
32
|
+
redact,
|
|
33
|
+
)
|
|
34
|
+
expect(out.question).not.toContain(SECRET)
|
|
35
|
+
expect(out.question).toContain('[REDACTED')
|
|
36
|
+
// Non-secret prose is preserved.
|
|
37
|
+
expect(out.question).toContain('Deploy with token')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('masks a secret echoed into an OPTION / button label', () => {
|
|
41
|
+
const out = redactAskUserFields(
|
|
42
|
+
'Which key?',
|
|
43
|
+
['Cancel', `Use ${SECRET}`],
|
|
44
|
+
redact,
|
|
45
|
+
)
|
|
46
|
+
expect(out.options[0]).toBe('Cancel')
|
|
47
|
+
expect(out.options[1]).not.toContain(SECRET)
|
|
48
|
+
expect(out.options[1]).toContain('[REDACTED')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('leaves clean question + options untouched', () => {
|
|
52
|
+
const out = redactAskUserFields('Proceed?', ['Yes', 'No'], redact)
|
|
53
|
+
expect(out.question).toBe('Proceed?')
|
|
54
|
+
expect(out.options).toEqual(['Yes', 'No'])
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('does not mutate the caller-supplied options array', () => {
|
|
58
|
+
const options = ['Cancel', `Use ${SECRET}`]
|
|
59
|
+
redactAskUserFields('q', options, redact)
|
|
60
|
+
expect(options[1]).toBe(`Use ${SECRET}`) // original array untouched
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
describe('redactChecklistFields — checklist outbound scrub (F2)', () => {
|
|
65
|
+
it('masks a secret in the checklist TITLE', () => {
|
|
66
|
+
const out = redactChecklistFields(
|
|
67
|
+
`Rotate ${SECRET}`,
|
|
68
|
+
[{ text: 'step one' }],
|
|
69
|
+
redact,
|
|
70
|
+
)
|
|
71
|
+
expect(out.title).not.toContain(SECRET)
|
|
72
|
+
expect(out.title).toContain('[REDACTED')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('masks a secret in a TASK text and preserves other task fields', () => {
|
|
76
|
+
const out = redactChecklistFields(
|
|
77
|
+
'Onboarding',
|
|
78
|
+
[
|
|
79
|
+
{ text: 'read the docs', done: true },
|
|
80
|
+
{ text: `save ${SECRET} to vault`, id: '7' },
|
|
81
|
+
],
|
|
82
|
+
redact,
|
|
83
|
+
)
|
|
84
|
+
expect(out.tasks![0]).toEqual({ text: 'read the docs', done: true })
|
|
85
|
+
expect(out.tasks![1]!.text).not.toContain(SECRET)
|
|
86
|
+
expect(out.tasks![1]!.text).toContain('[REDACTED')
|
|
87
|
+
// Sibling fields on the redacted task are carried through untouched.
|
|
88
|
+
expect(out.tasks![1]!.id).toBe('7')
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('passes undefined title / tasks through (update_checklist partial patch)', () => {
|
|
92
|
+
const out = redactChecklistFields(undefined, undefined, redact)
|
|
93
|
+
expect(out.title).toBeUndefined()
|
|
94
|
+
expect(out.tasks).toBeUndefined()
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('leaves an id-only task (no text) untouched', () => {
|
|
98
|
+
const out = redactChecklistFields(undefined, [{ id: '3', done: true }], redact)
|
|
99
|
+
expect(out.tasks![0]).toEqual({ id: '3', done: true })
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('does not mutate the caller-supplied task objects', () => {
|
|
103
|
+
const tasks = [{ text: `save ${SECRET}` }]
|
|
104
|
+
redactChecklistFields('t', tasks, redact)
|
|
105
|
+
expect(tasks[0]!.text).toBe(`save ${SECRET}`) // original untouched
|
|
106
|
+
})
|
|
107
|
+
})
|