switchroom 0.19.35 → 0.19.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/dist/cli/skill-validate-pretool.mjs +15 -2
  2. package/dist/cli/switchroom.js +678 -444
  3. package/dist/host-control/main.js +149 -3
  4. package/package.json +4 -2
  5. package/profiles/_base/start.sh.hbs +37 -12
  6. package/telegram-plugin/bridge/bridge.ts +4 -4
  7. package/telegram-plugin/dist/bridge/bridge.js +4 -4
  8. package/telegram-plugin/dist/gateway/gateway.js +317 -95
  9. package/telegram-plugin/dist/server.js +4 -4
  10. package/telegram-plugin/format.ts +100 -29
  11. package/telegram-plugin/gateway/gateway.ts +22 -29
  12. package/telegram-plugin/gateway/ipc-server.ts +18 -15
  13. package/telegram-plugin/gateway/model-command.ts +34 -0
  14. package/telegram-plugin/gateway/outbound-send-path.ts +12 -1
  15. package/telegram-plugin/operator-events.ts +40 -16
  16. package/telegram-plugin/render/unsupported-token-guard.ts +10 -6
  17. package/telegram-plugin/secret-detect/db-uri.ts +90 -0
  18. package/telegram-plugin/secret-detect/index.ts +24 -1
  19. package/telegram-plugin/secret-detect/inert-values.ts +147 -0
  20. package/telegram-plugin/secret-detect/kv-scanner.ts +108 -0
  21. package/telegram-plugin/secret-detect/patterns.ts +24 -4
  22. package/telegram-plugin/tests/format-consistency.test.ts +111 -0
  23. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +40 -2
  24. package/telegram-plugin/tests/ipc-server-validate-operator.test.ts +20 -11
  25. package/telegram-plugin/tests/mcp-instructions-budget.test.ts +8 -1
  26. package/telegram-plugin/tests/outbound-send-path.test.ts +1 -1
  27. package/telegram-plugin/tests/render/unsupported-token-guard.test.ts +36 -5
  28. package/telegram-plugin/tests/secret-detect-cross-engine.test.ts +263 -0
  29. package/telegram-plugin/tests/secret-detect-write-path.test.ts +403 -0
  30. package/telegram-plugin/tests/turn-flush-safety.test.ts +2 -2
  31. package/vendor/hindsight-memory/scripts/lib/client.py +58 -0
  32. package/vendor/hindsight-memory/scripts/lib/secret_patterns.json +431 -0
  33. package/vendor/hindsight-memory/scripts/lib/secret_redact.py +563 -0
  34. package/vendor/hindsight-memory/scripts/lib/secret_redaction_vectors.json +397 -0
  35. package/vendor/hindsight-memory/scripts/tests/test_secret_redact.py +522 -0
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Inert VALUES — placeholders, variable references and already-masked
3
+ * text that occupy a credential-shaped slot without being a credential.
4
+ *
5
+ * Masking one of these buys nothing and costs real information:
6
+ *
7
+ * - `POSTGRES_PASSWORD: vault:pg/password` — the vault KEY NAME is
8
+ * precisely what an agent is supposed to remember. Redacting it
9
+ * deletes the pointer and keeps nothing.
10
+ * - `JWT_SECRET=<generate-with-openssl-rand>` — an instruction.
11
+ * - `const API_KEY = process.env.ANTHROPIC_API_KEY` — a reference.
12
+ *
13
+ * Before #3982's review this list existed only inside the new
14
+ * `memorable_password` rule, so letter case decided the outcome:
15
+ * `password: ${DB_PASSWORD}` survived while `PASSWORD: ${DB_PASSWORD}`
16
+ * (matched by the ALL-CAPS `env_key_value` rule) was destroyed. Every
17
+ * rule whose capture is a LABELLED slot now shares the list —
18
+ * `env_key_value`, `json_secret_field`, `cli_flag` and
19
+ * `memorable_password`.
20
+ *
21
+ * Mirrored in `vendor/hindsight-memory/scripts/lib/secret_redact.py`
22
+ * (`_INERT_VALUE_RES` / `_INERT_GATED_RULES`) and pinned across both
23
+ * engines by `secret_redaction_vectors.json`.
24
+ */
25
+
26
+ export const INERT_VALUE_RE = [
27
+ // Our own marker (idempotence). `maskToken` only ever emits
28
+ // `[REDACTED]` or `[REDACTED:<rule_id>]`, so the closing bracket is
29
+ // part of the shape.
30
+ /^\[REDACTED(?::[A-Za-z0-9_]+)?\]$/i,
31
+ /^\$\{?[A-Za-z_][A-Za-z0-9_]*\}?$/, // $PASSWORD, ${DB_PASSWORD}
32
+ // `.` is spelled out: JS `.` and Python `.` exclude different line
33
+ // terminators, and the two engines must agree byte for byte.
34
+ /^\{\{[^\n\r\u2028\u2029]*\}\}$/, // {{ handlebars }}
35
+ /^<[^<>\n\r\u2028\u2029]*>$/, // <your-password-here>
36
+ /^%[A-Za-z_][A-Za-z0-9_]*%$/, // %PASSWORD% (Windows)
37
+ /^vault:[A-Za-z0-9_./-]*$/i, // switchroom vault reference
38
+ /^[*x•.]+$/i, // ***, xxxx, ••••
39
+ // Code reference — `process.env.FOO`, `import.meta.env.VITE_X`,
40
+ // `os.environ["FOO"]`. The member path is PART OF THE SHAPE: a bare
41
+ // prefix followed by anything else is not a reference.
42
+ /^(?:process\.env|import\.meta\.env|os\.environ)(?:\.[A-Za-z_$][A-Za-z0-9_$]*|\[["']?[A-Za-z_$][A-Za-z0-9_$]*["']?\])*$/,
43
+ // Well-known "fill this in" values. A live credential never begins
44
+ // with the word telling you to replace it.
45
+ /^(?:changeme|change-me|replaceme|replace-me|placeholder|todo|tbd|yourkey|your-key|yourpassword|your-password|yoursecret|your-secret|yourtoken|your-token)(?:[-_][A-Za-z0-9-]+)?$/i,
46
+ // A lowercase English phrase — a help string or a JSON doc value such
47
+ // as `{"token": "the bearer token to use"}`, never a credential.
48
+ /^[a-z]+(?: [a-z]+){2,}$/,
49
+ ]
50
+
51
+ /**
52
+ * An unbroken run of `[A-Za-z0-9]` long enough to BE a credential.
53
+ *
54
+ * Placeholder text is words joined by separators (`your-password-here`,
55
+ * `generate-with-openssl-rand`, `pg/password`, `ANTHROPIC_API_KEY`) — the
56
+ * longest alphanumeric run across every value this list protects is 10
57
+ * (`production`). A credential is the opposite shape: one dense run.
58
+ */
59
+ const CREDENTIAL_RUN_RE = /[A-Za-z0-9]{12,}/g
60
+
61
+ /**
62
+ * Mixed-class floor — `ENV_KV_MIN_LEN`, the length below which the env
63
+ * scanner already declines to call something a secret. Must not exceed
64
+ * `CREDENTIAL_RUN_RE`'s own floor.
65
+ */
66
+ const MIXED_CLASS_RUN_MIN = 12
67
+
68
+ /**
69
+ * Single-class floor. A run of only letters or only digits is far more
70
+ * likely to be an English word than a credential (`authentication`,
71
+ * `configuration`), so it gets more rope — but not unlimited rope:
72
+ * `abcdefghijklmnop` and a 40-`x` filler are values `main` masked, and no
73
+ * word in the protected corpus reaches 16.
74
+ */
75
+ const SINGLE_CLASS_RUN_MIN = 16
76
+
77
+ function runIsCredentialShaped(run: string): boolean {
78
+ // Classes present among {lower, upper, digit}. Hex, base62 and
79
+ // CamelCase-with-digits tokens are two or three; `yourtokenhere` and
80
+ // the segments of `YOUR_TOKEN_HERE` are one.
81
+ let classes = 0
82
+ if (/[a-z]/.test(run)) classes++
83
+ if (/[A-Z]/.test(run)) classes++
84
+ if (/[0-9]/.test(run)) classes++
85
+ return (
86
+ run.length >= (classes >= 2 ? MIXED_CLASS_RUN_MIN : SINGLE_CLASS_RUN_MIN)
87
+ )
88
+ }
89
+
90
+ /**
91
+ * True when `value` carries a credential-shaped run anywhere inside it.
92
+ *
93
+ * #3982 review, MAJOR 7. The inert SHAPES above are necessary but not
94
+ * sufficient: `<a8f3…40 hex…>` and `{{tok_…}}` are legal instances of the
95
+ * `<…>` / `{{…}}` shapes, and a wrapper left behind after someone filled
96
+ * the value in (`vault:pg/password<secret>`, `[REDACTED]<secret>`,
97
+ * `changeme-<secret>`) turned a false-positive fix into a bypass of
98
+ * already-shipped coverage. "Inert shape AND carries no credential" is
99
+ * the property actually wanted; end-anchoring alone is not enough,
100
+ * because `<secret>` is a well-formed instance of the shape.
101
+ */
102
+ export function hasCredentialShapedRun(value: string): boolean {
103
+ for (const run of value.match(CREDENTIAL_RUN_RE) ?? []) {
104
+ if (runIsCredentialShaped(run)) return true
105
+ }
106
+ return false
107
+ }
108
+
109
+ /** True when `value` is a placeholder / reference rather than a secret. */
110
+ export function isInertValue(value: string): boolean {
111
+ if (!INERT_VALUE_RE.some((re) => re.test(value))) return false
112
+ return !hasCredentialShapedRun(value)
113
+ }
114
+
115
+ /**
116
+ * Rule ids whose captured value is a labelled slot, and which therefore
117
+ * honour `isInertValue`. Keep in sync with `_INERT_GATED_RULES` in
118
+ * `secret_redact.py`.
119
+ */
120
+ export const INERT_GATED_RULES = new Set([
121
+ 'env_key_value',
122
+ 'json_secret_field',
123
+ 'cli_flag',
124
+ ])
125
+
126
+ /**
127
+ * The heuristic KEY=VALUE scanner honours the same list — it is the rule
128
+ * that ate `ANTHROPIC_API_KEY: vault:anthropic/api_key` and
129
+ * `const API_KEY = process.env.ANTHROPIC_API_KEY`. Kept as its own
130
+ * export because `scanKeyValue` applies it directly rather than through
131
+ * the `ALL_PATTERNS` loop.
132
+ */
133
+ export const KV_ENTROPY_RULE_ID = 'kv_entropy'
134
+
135
+ /**
136
+ * Trailing punctuation an English sentence puts AFTER a value. The value
137
+ * character classes are `[^\s"']`-shaped, so a sentence-final `.` or a
138
+ * list comma is swallowed INTO the value — and a `.` alone supplies a
139
+ * second character class, which is what made
140
+ * "The password is required." redact as a credential (#3982 review,
141
+ * BLOCKER 2).
142
+ */
143
+ const TRAILING_PUNCT_RE = /[.,;:!?)\]}]+$/
144
+
145
+ export function stripTrailingPunctuation(value: string): string {
146
+ return value.replace(TRAILING_PUNCT_RE, '')
147
+ }
@@ -13,6 +13,7 @@
13
13
  * `key=value` match), so the rewriter can preserve the `key=` prefix.
14
14
  */
15
15
  import { shannonEntropy } from './entropy.js'
16
+ import { isInertValue, stripTrailingPunctuation } from './inert-values.js'
16
17
 
17
18
  export interface RawHit {
18
19
  rule_id: string
@@ -31,6 +32,107 @@ const KV_RE = /\b([A-Za-z_][A-Za-z0-9_-]*(?:password|passwd|token|secret|key|api
31
32
 
32
33
  export const KV_ENTROPY_THRESHOLD = 4.0
33
34
 
35
+ // ─── Human-memorable passwords ────────────────────────────────────────
36
+ //
37
+ // Gap closed (2026-07 hindsight write-path audit): a password a human can
38
+ // remember — family names + a year, a two-word phrase with a digit — has
39
+ // Shannon entropy well under KV_ENTROPY_THRESHOLD, so `scanKeyValue` above
40
+ // skips it and it was stored in agent memory verbatim.
41
+ //
42
+ // Lowering KV_ENTROPY_THRESHOLD is NOT the fix: that threshold guards a
43
+ // wide LHS set (`key`, `token`, `secret`, `api_key`, and any identifier
44
+ // ending in them), where dropping the entropy floor would mask ordinary
45
+ // prose and config chatter wholesale.
46
+ //
47
+ // Instead this is a SEPARATE, much narrower rule:
48
+ //
49
+ // * the LHS must be the password family specifically — `password`,
50
+ // `passwd`, `passphrase`, `pwd` (optionally prefixed, e.g.
51
+ // `db_password`). Not `key`/`token`/`secret`.
52
+ // * the connector is `:`/`=` or the literal word `is` (so "the wifi
53
+ // password is <value>" is covered, which is how a human actually
54
+ // writes one down).
55
+ // * the value must LOOK like a chosen credential rather than a word:
56
+ // 8..64 bytes, no whitespace, and at least two distinct character
57
+ // classes of {lower, upper, digit, symbol}.
58
+ //
59
+ // The two-class gate is what keeps ordinary prose out — but ONLY once
60
+ // trailing punctuation has been stripped off the candidate value.
61
+ //
62
+ // #3982 review, BLOCKER 2: the value class is `[^\s"']`, so a sentence
63
+ // swallows its own terminator into the match. "The password is
64
+ // required." captured `required.` — lowercase letters PLUS a `.`, which
65
+ // is two character classes — and redacted as a credential. Same for
66
+ // `incorrect.`, `unchanged.`, and for a list comma in
67
+ // "password: required, minimum twelve characters". Sentence-final is the
68
+ // single most common position for those words, so the original comment
69
+ // here ("single-class lowercase and never match") was not just wrong, it
70
+ // was wrong about the common case: "Ken confirmed the password is
71
+ // unchanged." stored as "…the password is [REDACTED:memorable_password]",
72
+ // which INVERTS the meaning of the sentence it corrupts.
73
+ //
74
+ // `looksLikeMemorablePassword` therefore runs its length / distinct-char
75
+ // / character-class gates against the punctuation-stripped core, while
76
+ // the MASK still covers the whole captured value (a trailing `!` is more
77
+ // likely the last byte of the password than the end of a sentence).
78
+ //
79
+ // The deliberate trade that remains: a single-class credential such as an
80
+ // all-lowercase passphrase still slips through. That is the conservative
81
+ // side of the line — over-redacting prose corrupts stored conversation and
82
+ // is unrecoverable, whereas this rule's misses are the pre-existing
83
+ // behaviour, not a regression.
84
+ const MEMORABLE_PW_RE =
85
+ /\b([A-Za-z0-9_-]*(?:password|passwd|passphrase|pwd))\b\s*(?:[:=]\s*|\s+is\s+)(["']?)([^\s"']{8,64})\2/gi
86
+
87
+ export const MEMORABLE_PW_RULE_ID = 'memorable_password'
88
+
89
+ /** Minimum distinct character classes for a value to look chosen-by-a-human. */
90
+ export const MEMORABLE_PW_MIN_CLASSES = 2
91
+
92
+ function charClassCount(value: string): number {
93
+ let n = 0
94
+ if (/[a-z]/.test(value)) n++
95
+ if (/[A-Z]/.test(value)) n++
96
+ if (/[0-9]/.test(value)) n++
97
+ if (/[^A-Za-z0-9]/.test(value)) n++
98
+ return n
99
+ }
100
+
101
+ /** True when `value` looks like a human-chosen password, not prose. */
102
+ export function looksLikeMemorablePassword(value: string): boolean {
103
+ if (isInertValue(value)) return false
104
+ // Gate on the value WITHOUT the sentence punctuation it swallowed.
105
+ const core = stripTrailingPunctuation(value)
106
+ if (core.length < 8 || core.length > 64) return false
107
+ if (isInertValue(core)) return false
108
+ // A single repeated character is a mask, not a password.
109
+ if (new Set(core).size < 4) return false
110
+ return charClassCount(core) >= MEMORABLE_PW_MIN_CLASSES
111
+ }
112
+
113
+ export function scanMemorablePasswords(text: string): RawHit[] {
114
+ const hits: RawHit[] = []
115
+ MEMORABLE_PW_RE.lastIndex = 0
116
+ let m: RegExpExecArray | null
117
+ while ((m = MEMORABLE_PW_RE.exec(text)) !== null) {
118
+ const keyName = m[1]!
119
+ const value = m[3]
120
+ if (!value || !looksLikeMemorablePassword(value)) continue
121
+ const valueOffsetInMatch = m[0].indexOf(value, keyName.length)
122
+ if (valueOffsetInMatch < 0) continue
123
+ const start = m.index + valueOffsetInMatch
124
+ hits.push({
125
+ rule_id: MEMORABLE_PW_RULE_ID,
126
+ start,
127
+ end: start + value.length,
128
+ matched_text: value,
129
+ key_name: keyName,
130
+ confidence: 'ambiguous',
131
+ })
132
+ }
133
+ return hits
134
+ }
135
+
34
136
  export function scanKeyValue(text: string): RawHit[] {
35
137
  const hits: RawHit[] = []
36
138
  KV_RE.lastIndex = 0
@@ -38,6 +140,12 @@ export function scanKeyValue(text: string): RawHit[] {
38
140
  while ((m = KV_RE.exec(text)) !== null) {
39
141
  const [, keyName, value] = m
40
142
  if (!value) continue
143
+ // Placeholders / references are not credentials — see inert-values.ts.
144
+ // `ANTHROPIC_API_KEY: vault:anthropic/api_key` and
145
+ // `const API_KEY = process.env.ANTHROPIC_API_KEY` both land HERE, not
146
+ // on the ALL_CAPS `env_key_value` pattern, and both were being
147
+ // destroyed (#3982 review, MAJOR 5).
148
+ if (isInertValue(value)) continue
41
149
  // Shannon entropy gate — only flag values that actually look random.
42
150
  const h = shannonEntropy(value)
43
151
  if (h < KV_ENTROPY_THRESHOLD) continue
@@ -94,20 +94,40 @@ export const STRUCTURED_PATTERNS: PatternDef[] = [
94
94
  captureIndex: 2,
95
95
  slugHint: 'cli_flag',
96
96
  },
97
- // Authorization: Bearer token (form 1 — explicit Authorization header)
97
+ // Authorization: Bearer token (form 1 — explicit Authorization header).
98
+ //
99
+ // CASE-INSENSITIVE since #3982's review: HTTP/2 and HTTP/3 lowercase
100
+ // every header name on the wire, so `authorization: bearer <token>` is
101
+ // what an agent actually pastes out of a curl trace or a proxy log —
102
+ // and it sailed through both engines unmasked. RFC 9110 makes the
103
+ // header name case-insensitive and the auth SCHEME token
104
+ // case-insensitive too, so matching case-sensitively was simply wrong.
98
105
  {
99
106
  rule_id: 'bearer_auth_header',
100
- regex: /Authorization\s*[:=]\s*Bearer\s+([A-Za-z0-9._\-+=]+)/g,
107
+ regex: /Authorization\s*[:=]\s*Bearer\s+([A-Za-z0-9._\-+=]+)/gi,
101
108
  captureIndex: 1,
102
109
  slugHint: 'bearer_token',
103
110
  },
104
- // Bare "Bearer XYZ" (length-gated to cut false positives on the word "Bearer")
111
+ // Bare "Bearer XYZ" (length-gated to cut false positives on the word
112
+ // "Bearer"). The 18-char floor is what keeps the now case-insensitive
113
+ // match off prose like "the bearer token to use".
105
114
  {
106
115
  rule_id: 'bearer_loose',
107
- regex: /\bBearer\s+([A-Za-z0-9._\-+=]{18,})\b/g,
116
+ regex: /\bBearer\s+([A-Za-z0-9._\-+=]{18,})\b/gi,
108
117
  captureIndex: 1,
109
118
  slugHint: 'bearer_token',
110
119
  },
120
+ // Authorization: Basic <base64(user:password)>. Base64 is an encoding,
121
+ // not a cipher — a Basic header is a plaintext credential with extra
122
+ // steps, and it reached agent memory unmasked (#3982 review, MAJOR 6).
123
+ // Anchored on the header + scheme, so a bare base64 blob elsewhere in
124
+ // the text is untouched (that stays a documented gap).
125
+ {
126
+ rule_id: 'basic_auth_header',
127
+ regex: /Authorization\s*[:=]\s*Basic\s+([A-Za-z0-9+/=]{8,})/gi,
128
+ captureIndex: 1,
129
+ slugHint: 'basic_auth',
130
+ },
111
131
  // PEM private key block — single greedy capture, non-overlapping.
112
132
  {
113
133
  rule_id: 'pem_private_key',
@@ -245,6 +245,24 @@ describe('normalizePunctuation', () => {
245
245
  expect(normalizePunctuation('<not—a—url>')).toBe('<not, a, url>')
246
246
  })
247
247
 
248
+ test('does NOT rewrite a dash inside a `>` blockquote (verbatim quote)', () => {
249
+ // `>` blockquotes hold quoted text the author is reproducing verbatim;
250
+ // rewriting their em-dash to `, ` corrupts the quotation.
251
+ expect(normalizePunctuation('> she said — plainly — no')).toBe(
252
+ '> she said — plainly — no',
253
+ )
254
+ // Indented blockquote line is still a blockquote.
255
+ expect(normalizePunctuation(' > quoted — text')).toBe(' > quoted — text')
256
+ // The expandable-blockquote opener `**>` is a blockquote line too.
257
+ expect(normalizePunctuation('**> first — line\n> continues — here')).toBe(
258
+ '**> first — line\n> continues — here',
259
+ )
260
+ // Prose OUTSIDE the quote still normalizes; only the quoted line is spared.
261
+ expect(normalizePunctuation('intro — here\n> quote — verbatim\nafter — end')).toBe(
262
+ 'intro, here\n> quote — verbatim\nafter, end',
263
+ )
264
+ })
265
+
248
266
  test('idempotent', () => {
249
267
  const once = normalizePunctuation('a — b\n• c\nd—e\n1–2')
250
268
  expect(normalizePunctuation(once)).toBe(once)
@@ -334,4 +352,97 @@ describe('stripExcessBold', () => {
334
352
  const once = stripExcessBold(input)
335
353
  expect(stripExcessBold(once)).toBe(once)
336
354
  })
355
+
356
+ // ── Heading exemption in the GLOBAL (>30%) rule ──────────────────────────
357
+ // A bold-dense digest tripped the global ratio and lost EVERY bold marker,
358
+ // including its section headings, with no signal. Short standalone
359
+ // pseudo-heading blocks must now survive the global strip.
360
+
361
+ const boldDenseBody =
362
+ '**alpha** **bravo** **charlie** **delta** **echo** **foxtrot** **golf** ' +
363
+ '**hotel** **india** **juliet** **kilo** **lima** plus a short plain tail here.'
364
+
365
+ test('global strip keeps a short standalone bold heading, strips the rest', () => {
366
+ const input = `**Section One**\n\n${boldDenseBody}`
367
+ const out = stripExcessBold(input)
368
+ // Heading survives.
369
+ expect(out).toContain('**Section One**')
370
+ // Non-heading inline bold is flattened.
371
+ expect(out).toContain('alpha')
372
+ expect(out).not.toContain('**alpha**')
373
+ expect(out).not.toContain('**golf**')
374
+ })
375
+
376
+ test('global strip preserves EVERY heading in a multi-section digest', () => {
377
+ const input =
378
+ `**Overview**\n\n${boldDenseBody}\n\n` +
379
+ `**Next steps:**\n\n**one** **two** **three** **four** **five** **six** ` +
380
+ 'plus a plain closing clause long enough to matter here.'
381
+ const out = stripExcessBold(input)
382
+ expect(out).toContain('**Overview**')
383
+ expect(out).toContain('**Next steps:**')
384
+ expect(out).not.toContain('**one**')
385
+ expect(out).not.toContain('**six**')
386
+ })
387
+
388
+ test('regression: global strip with NO headings still fully strips', () => {
389
+ const input = `${boldDenseBody}`
390
+ const out = stripExcessBold(input)
391
+ expect(out).not.toContain('**')
392
+ expect(out).toContain('alpha')
393
+ })
394
+
395
+ test('regression: under-threshold message keeps all bold (incl. headings)', () => {
396
+ const input = `**Summary**\n\n${filler} The key fact is **42**.`
397
+ expect(stripExcessBold(input)).toBe(input)
398
+ })
399
+
400
+ test('48/49-char pseudo-heading boundary honoured under global strip', () => {
401
+ // Heading length is measured WITH the `**` markers. 44 inner chars → 48
402
+ // total (exempt); 45 inner chars → 49 total (stripped).
403
+ const heading48 = '**' + 'H'.repeat(44) + '**' // length 48
404
+ const heading49 = '**' + 'H'.repeat(45) + '**' // length 49
405
+ expect(heading48.length).toBe(48)
406
+ expect(heading49.length).toBe(49)
407
+
408
+ const out48 = stripExcessBold(`${heading48}\n\n${boldDenseBody}`)
409
+ expect(out48).toContain(heading48)
410
+
411
+ const out49 = stripExcessBold(`${heading49}\n\n${boldDenseBody}`)
412
+ expect(out49).not.toContain(heading49)
413
+ expect(out49).toContain('H'.repeat(45))
414
+ })
415
+
416
+ test('multi-line fully-bolded block is NOT mislabelled a heading (global)', () => {
417
+ // Two bolded lines in one block must be flattened, not exempted — the
418
+ // heading exemption is single-line only.
419
+ const input = `**First bold line here**\n**Second bold line here**\n\n${boldDenseBody}`
420
+ const out = stripExcessBold(input)
421
+ expect(out).not.toContain('**First bold line here**')
422
+ expect(out).toContain('First bold line here')
423
+ })
424
+
425
+ // ── Observability ────────────────────────────────────────────────────────
426
+ test('onStrip fires with rule=global + ratio when global rule strips', () => {
427
+ const calls: Array<{ rule: string; ratio: number }> = []
428
+ stripExcessBold(`**Section One**\n\n${boldDenseBody}`, (d) => calls.push(d))
429
+ expect(calls).toHaveLength(1)
430
+ expect(calls[0].rule).toBe('global')
431
+ expect(calls[0].ratio).toBeGreaterThan(0.3)
432
+ })
433
+
434
+ test('onStrip fires with rule=per-block when only a block is flattened', () => {
435
+ const calls: Array<{ rule: string; ratio: number }> = []
436
+ const input = `${filler}\n\n**This whole paragraph is bold.**\n**Every single line of it.**`
437
+ stripExcessBold(input, (d) => calls.push(d))
438
+ expect(calls).toHaveLength(1)
439
+ expect(calls[0].rule).toBe('per-block')
440
+ expect(calls[0].ratio).toBeLessThanOrEqual(0.3)
441
+ })
442
+
443
+ test('onStrip does NOT fire when nothing is stripped', () => {
444
+ const calls: Array<{ rule: string; ratio: number }> = []
445
+ stripExcessBold(`**Summary**\n\n${filler} The key fact is **42**.`, (d) => calls.push(d))
446
+ expect(calls).toHaveLength(0)
447
+ })
337
448
  })
@@ -21,6 +21,8 @@ import {
21
21
  classifyModelSwitchConfirmation,
22
22
  formatModelRelaunchDiagLog,
23
23
  resolveModelSwitchBootNotice,
24
+ resolveSessionModelResolutionTimeoutMs,
25
+ waitForSessionModelResolution,
24
26
  } from '../gateway/model-command.js'
25
27
 
26
28
  const __dirname = dirname(fileURLToPath(import.meta.url))
@@ -147,6 +149,39 @@ describe('gateway: the live callback dispatcher routes every switch tap to the h
147
149
  })
148
150
 
149
151
  describe('gateway boot: session-model re-hydration + confirmation + alert relay', () => {
152
+ it('does not classify stale previous-boot state before the resolution barrier', async () => {
153
+ const staleLaunched = 'claude-opus-4-8'
154
+ const configured = 'claude-opus-4-8'
155
+ const reason = 'user: /model fable (session-only relaunch, menu)'
156
+ const resolved = await waitForSessionModelResolution({
157
+ barrierExists: () => false,
158
+ timeoutMs: 0,
159
+ })
160
+ const confirmation = resolved
161
+ ? classifyModelSwitchConfirmation({ reason, launched: staleLaunched, configured })
162
+ : null
163
+ expect(resolved).toBe(false)
164
+ expect(confirmation).toBeNull()
165
+ })
166
+
167
+ it('waits asynchronously until the resolution barrier appears', async () => {
168
+ let checks = 0
169
+ const resolved = await waitForSessionModelResolution({
170
+ barrierExists: () => ++checks >= 2,
171
+ timeoutMs: 1_000,
172
+ sleep: async () => {},
173
+ })
174
+ expect(resolved).toBe(true)
175
+ expect(checks).toBe(2)
176
+ })
177
+
178
+ it('uses a safe default for absent or invalid barrier timeout overrides', () => {
179
+ expect(resolveSessionModelResolutionTimeoutMs(undefined)).toBe(180_000)
180
+ expect(resolveSessionModelResolutionTimeoutMs('not-a-number')).toBe(180_000)
181
+ expect(resolveSessionModelResolutionTimeoutMs('-1')).toBe(180_000)
182
+ expect(resolveSessionModelResolutionTimeoutMs('2500')).toBe(2_500)
183
+ })
184
+
150
185
  it('re-hydrates the override from .active-session-model (launched !== configured)', () => {
151
186
  const idx = GATEWAY_SRC.indexOf("join(smAgentDir, '.active-session-model')")
152
187
  expect(idx).toBeGreaterThan(0)
@@ -295,9 +330,12 @@ describe('gateway boot: session-model re-hydration + confirmation + alert relay'
295
330
  })
296
331
 
297
332
  it('wires the pipeline into the boot rehydration (gateway calls classify → diag log → notice)', () => {
298
- const idx = GATEWAY_SRC.indexOf('const isApplyBoot = launched.length > 0')
333
+ const idx = GATEWAY_SRC.indexOf('const resolutionTimeoutMs = resolveSessionModelResolutionTimeoutMs(')
299
334
  expect(idx).toBeGreaterThan(0)
300
- const win = GATEWAY_SRC.slice(idx, idx + 4200)
335
+ const win = GATEWAY_SRC.slice(idx, idx + 7000)
336
+ expect(win).toContain('waitForSessionModelResolution({')
337
+ expect(win).toContain('if (!resolved) {')
338
+ expect(win).toContain('gw /model relaunch UNRESOLVED')
301
339
  expect(win).toContain('classifyModelSwitchConfirmation({')
302
340
  expect(win).toContain('formatModelRelaunchDiagLog')
303
341
  expect(win).toContain('if (confirmation != null)')
@@ -13,18 +13,13 @@
13
13
 
14
14
  import { describe, it, expect } from 'vitest'
15
15
  import { validateClientMessage } from '../gateway/ipc-server.js'
16
+ import { OPERATOR_EVENT_KINDS } from '../operator-events.js'
16
17
 
17
- const VALID_KINDS = [
18
- 'credentials-expired',
19
- 'credentials-invalid',
20
- 'credit-exhausted',
21
- 'quota-exhausted',
22
- 'rate-limited',
23
- 'agent-crashed',
24
- 'agent-restarted-unexpectedly',
25
- 'unknown-4xx',
26
- 'unknown-5xx',
27
- ]
18
+ // Drift-proof: iterate the CANONICAL taxonomy the validator now derives its
19
+ // allowlist from, not a hand-copied literal. A kind added to
20
+ // OPERATOR_EVENT_KINDS is automatically covered here; a validator that fails
21
+ // to accept any canonical kind fails this test.
22
+ const VALID_KINDS = OPERATOR_EVENT_KINDS
28
23
 
29
24
  function base() {
30
25
  return {
@@ -43,6 +38,20 @@ describe('validateClientMessage — operator_event', () => {
43
38
  }
44
39
  })
45
40
 
41
+ // Regression for the 2026-07-30 silent-out-of-credits incident: these three
42
+ // kinds were added to the OperatorEventKind union but NOT to the gateway IPC
43
+ // validator's hand-maintained allowlist. The bridge forwarded a
44
+ // `provider-credit-exhausted` operator_event; the validator rejected it as an
45
+ // "invalid IPC message shape" and dropped it, so an OpenRouter/LiteLLM 402
46
+ // credit wall produced NO loud Telegram card. Named explicitly (not just via
47
+ // the canonical-list loop above) so the failure message points straight at
48
+ // the incident if the allowlist ever regresses to a hand-maintained literal.
49
+ it('accepts the operator-actionable kinds forwarded by the bridge over IPC', () => {
50
+ for (const kind of ['provider-credit-exhausted', 'mcp-dependency-blocked', 'proxy-misconfig']) {
51
+ expect(validateClientMessage({ ...base(), kind }), kind).toBe(true)
52
+ }
53
+ })
54
+
46
55
  it('rejects unknown kinds', () => {
47
56
  expect(validateClientMessage({ ...base(), kind: 'something-else' })).toBe(false)
48
57
  expect(validateClientMessage({ ...base(), kind: '' })).toBe(false)
@@ -162,8 +162,15 @@ describe("moved instruction content landed in tool descriptions", () => {
162
162
 
163
163
  it("format modes landed in the reply description", () => {
164
164
  expect(bridgeCode).toContain("FORMAT:");
165
- expect(bridgeCode).toMatch(/default format is "html"/);
165
+ // The description must describe the ONE real render path (rich GFM), and
166
+ // still name the legacy aliases so the enum values stay documented. It must
167
+ // NOT resurrect the deleted-engine claims (html→HTML conversion, MarkdownV2
168
+ // auto-escaping) — those are false at HEAD (single GFM path, no escaping).
169
+ expect(bridgeCode).toMatch(/rich GFM markdown/);
166
170
  expect(bridgeCode).toContain("markdownv2");
171
+ expect(bridgeCode).not.toMatch(/default format is "html"/);
172
+ expect(bridgeCode).not.toMatch(/auto-converted to Telegram HTML/);
173
+ expect(bridgeCode).not.toMatch(/MarkdownV2 with auto-escaping/);
167
174
  });
168
175
 
169
176
  it("history-buffer rationale landed in get_recent_messages", () => {
@@ -379,7 +379,7 @@ describe('outbound-send-path — temporal pass wiring (#3501)', () => {
379
379
  it('temporal runs AFTER punctuation/bold and BEFORE scrubVoice (structural pin)', () => {
380
380
  const src = readFileSync(new URL('../gateway/outbound-send-path.ts', import.meta.url), 'utf8')
381
381
  const start = src.indexOf('export function normalizeOutboundBody(')
382
- const boldIdx = src.indexOf('stripExcessBold(normalizePunctuation(text))', start)
382
+ const boldIdx = src.indexOf('stripExcessBold(normalizePunctuation(text),', start)
383
383
  const temporalIdx = src.indexOf('normalizeTemporal(text, tz, nowMs)', start)
384
384
  const scrubIdx = src.indexOf('scrubVoice(text)', start)
385
385
  expect(boldIdx).toBeGreaterThan(start)
@@ -71,15 +71,46 @@ describe("guardUnsupportedTokens — deterministic send-time repair", () => {
71
71
  expect(guardUnsupportedTokens("x^2^ metres")).toBe("x2 metres");
72
72
  });
73
73
 
74
- it("leaves in-prose bracket literals intact, repairs real numeric footnotes", () => {
75
- // `array[^index]` is a negated-char-class / index literal, not a footnote.
76
- expect(guardUnsupportedTokens("array[^index] lookup")).toBe(
77
- "array[^index] lookup",
74
+ it("repairs alphanumeric footnote markers, not just numeric", () => {
75
+ // POLISH-4: widened from digits-only so `[^note]`/`[^ref]` no longer ship
76
+ // as literal bracket noise. Each would survive UNTOUCHED on origin/main.
77
+ expect(guardUnsupportedTokens("see the note[^note] here")).toBe(
78
+ "see the note here",
79
+ );
80
+ expect(guardUnsupportedTokens("as shown[^ref] above")).toBe(
81
+ "as shown above",
82
+ );
83
+ expect(guardUnsupportedTokens("point[^fn1] made")).toBe("point made");
84
+ // A single-letter id is a footnote too.
85
+ expect(guardUnsupportedTokens("claim[^a] holds")).toBe("claim holds");
86
+ // Definition lines with an alphanumeric id are still left intact (`(?!:)`).
87
+ expect(guardUnsupportedTokens("[^note]: the definition")).toBe(
88
+ "[^note]: the definition",
78
89
  );
90
+ });
91
+
92
+ it("leaves regex-literal negated char classes intact", () => {
93
+ // Real negated char classes contain punctuation / ranges / escapes, so the
94
+ // alphanumeric-only id requirement excludes them — these stay verbatim.
79
95
  expect(guardUnsupportedTokens("use [^/] to match")).toBe(
80
96
  "use [^/] to match",
81
97
  );
82
- // A real numeric footnote marker is still stripped.
98
+ expect(guardUnsupportedTokens("strip [^a-z] chars")).toBe(
99
+ "strip [^a-z] chars",
100
+ );
101
+ expect(guardUnsupportedTokens('match [^"] here')).toBe('match [^"] here');
102
+ // Accepted tradeoff (task POLISH-4): a PURE-alphanumeric negated class in
103
+ // BARE prose like `array[^index]` is now treated as a footnote and stripped.
104
+ // This is rare — regex/index literals in real prose live in code spans,
105
+ // which splitProtectedSegments masks (see code-span test below).
106
+ expect(guardUnsupportedTokens("array[^index] lookup")).toBe("array lookup");
107
+ // …but inside a code span the same literal is untouched.
108
+ expect(guardUnsupportedTokens("`array[^index]` lookup")).toBe(
109
+ "`array[^index]` lookup",
110
+ );
111
+ });
112
+
113
+ it("repairs a real numeric footnote marker", () => {
83
114
  expect(guardUnsupportedTokens("see the note[^1] here")).toBe(
84
115
  "see the note here",
85
116
  );