switchroom 0.11.1 → 0.12.1

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 (77) hide show
  1. package/README.md +32 -16
  2. package/dist/agent-scheduler/index.js +216 -97
  3. package/dist/auth-broker/index.js +176 -97
  4. package/dist/cli/drive-write-pretool.mjs +26 -11
  5. package/dist/cli/skill-validate-pretool.mjs +7209 -0
  6. package/dist/cli/switchroom.js +45571 -42642
  7. package/dist/cli/ui/index.html +1281 -0
  8. package/dist/host-control/main.js +3628 -309
  9. package/dist/vault/approvals/kernel-server.js +207 -98
  10. package/dist/vault/broker/server.js +249 -119
  11. package/examples/personal-google-workspace-mcp/README.md +8 -3
  12. package/examples/switchroom.yaml +91 -42
  13. package/package.json +4 -3
  14. package/profiles/_base/start.sh.hbs +76 -36
  15. package/profiles/_shared/agent-self-service.md.hbs +1 -1
  16. package/profiles/default/CLAUDE.md.hbs +4 -2
  17. package/skills/file-bug/SKILL.md +6 -4
  18. package/skills/skill-creator/SKILL.md +52 -0
  19. package/skills/switchroom-cli/SKILL.md +20 -4
  20. package/skills/switchroom-install/SKILL.md +3 -3
  21. package/telegram-plugin/auth-snapshot-format.ts +9 -9
  22. package/telegram-plugin/card-format.ts +3 -3
  23. package/telegram-plugin/dist/bridge/bridge.js +112 -112
  24. package/telegram-plugin/dist/gateway/gateway.js +853 -414
  25. package/telegram-plugin/dist/server.js +162 -161
  26. package/telegram-plugin/format.ts +71 -0
  27. package/telegram-plugin/gateway/access-validator.test.ts +8 -8
  28. package/telegram-plugin/gateway/access-validator.ts +1 -1
  29. package/telegram-plugin/gateway/approval-card.test.ts +18 -18
  30. package/telegram-plugin/gateway/approval-card.ts +1 -1
  31. package/telegram-plugin/gateway/auth-command.ts +2 -2
  32. package/telegram-plugin/gateway/boot-card.ts +40 -3
  33. package/telegram-plugin/gateway/boot-probes.ts +114 -30
  34. package/telegram-plugin/gateway/diff-preview-card.test.ts +15 -15
  35. package/telegram-plugin/gateway/diff-preview-card.ts +1 -1
  36. package/telegram-plugin/gateway/drive-write-approval.test.ts +2 -2
  37. package/telegram-plugin/gateway/gateway.ts +265 -22
  38. package/telegram-plugin/gateway/update-announce.ts +167 -0
  39. package/telegram-plugin/quota-check.ts +0 -195
  40. package/telegram-plugin/recent-outbound-dedup.ts +1 -1
  41. package/telegram-plugin/registry/turns-schema.ts +1 -1
  42. package/telegram-plugin/retry-api-call.ts +24 -0
  43. package/telegram-plugin/server.ts +8 -5
  44. package/telegram-plugin/tests/auth-add-flow.test.ts +32 -3
  45. package/telegram-plugin/tests/auth-command-format2.test.ts +4 -4
  46. package/telegram-plugin/tests/auth-snapshot-format.test.ts +17 -17
  47. package/telegram-plugin/tests/auto-fallback-fleet.test.ts +10 -10
  48. package/telegram-plugin/tests/boot-probes.test.ts +90 -2
  49. package/telegram-plugin/tests/bot-runtime.test.ts +23 -1
  50. package/telegram-plugin/tests/fixtures/service-log-current-claude-code.bin +1 -1
  51. package/telegram-plugin/tests/fleet-state.test.ts +3 -2
  52. package/telegram-plugin/tests/quota-check.test.ts +0 -409
  53. package/telegram-plugin/tests/retry-api-call.test.ts +76 -0
  54. package/telegram-plugin/tests/secret-detect-audit.test.ts +1 -1
  55. package/telegram-plugin/tests/secret-detect-pipeline.test.ts +7 -6
  56. package/telegram-plugin/tests/secret-detect-suppressor-no-silent-allow.test.ts +6 -5
  57. package/telegram-plugin/tests/secret-detect.test.ts +8 -8
  58. package/telegram-plugin/tests/telegram-format.test.ts +84 -1
  59. package/telegram-plugin/tests/update-announce.test.ts +154 -0
  60. package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +8 -8
  61. package/telegram-plugin/tests/vault-request-access-tool.test.ts +51 -0
  62. package/telegram-plugin/welcome-text.ts +1 -8
  63. package/profiles/default/CLAUDE.md +0 -192
  64. package/skills/docx/scripts/office/validators/__pycache__/__init__.cpython-313.pyc +0 -0
  65. package/skills/docx/scripts/office/validators/__pycache__/base.cpython-313.pyc +0 -0
  66. package/skills/skill-creator/scripts/__pycache__/__init__.cpython-313.pyc +0 -0
  67. package/skills/skill-creator/scripts/__pycache__/generate_report.cpython-313.pyc +0 -0
  68. package/skills/skill-creator/scripts/__pycache__/improve_description.cpython-313.pyc +0 -0
  69. package/skills/skill-creator/scripts/__pycache__/run_eval.cpython-313.pyc +0 -0
  70. package/skills/skill-creator/scripts/__pycache__/run_loop.cpython-313.pyc +0 -0
  71. package/skills/skill-creator/scripts/__pycache__/utils.cpython-313.pyc +0 -0
  72. package/telegram-plugin/first-paint.ts +0 -225
  73. package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +0 -1
  74. package/telegram-plugin/server.js +0 -41795
  75. package/telegram-plugin/tests/html-balanced.ts +0 -63
  76. package/telegram-plugin/tests/snapshot-serializer.ts +0 -79
  77. package/telegram-plugin/tool-error-filter.ts +0 -89
@@ -14,6 +14,7 @@ import {
14
14
  createRetryApiCall,
15
15
  createSwallowingRetryApiCall,
16
16
  retryWithThreadFallback,
17
+ isHtmlParseRejectError,
17
18
  type RetryObserver,
18
19
  } from '../retry-api-call.js'
19
20
  import { errors, makeGrammyError } from './fake-bot-api.js'
@@ -436,3 +437,78 @@ describe('retryWithThreadFallback (#1075)', () => {
436
437
  expect(result).toBe(true)
437
438
  })
438
439
  })
440
+
441
+ describe('isHtmlParseRejectError', () => {
442
+ it('matches the real Telegram parse-failure 400 descriptions', () => {
443
+ const descriptions = [
444
+ "can't parse entities: Unsupported start tag \"h2\" at byte offset 12",
445
+ 'Bad Request: unsupported start tag "span"',
446
+ "can't find end of the entity starting at byte offset 40",
447
+ 'Bad Request: unclosed start tag at byte offset 5',
448
+ 'Bad Request: unexpected end tag at byte offset 9',
449
+ "Bad Request: can't parse entities: expected end tag",
450
+ ]
451
+ for (const d of descriptions) {
452
+ expect(
453
+ isHtmlParseRejectError(
454
+ makeGrammyError({ error_code: 400, description: d, method: 'sendMessage' }),
455
+ ),
456
+ ).toBe(true)
457
+ }
458
+ })
459
+
460
+ it('does NOT match other 400s (those have their own handling)', () => {
461
+ for (const d of [
462
+ 'Bad Request: message is not modified',
463
+ 'Bad Request: message to edit not found',
464
+ 'Bad Request: message thread not found',
465
+ 'Bad Request: chat not found',
466
+ ]) {
467
+ expect(
468
+ isHtmlParseRejectError(
469
+ makeGrammyError({ error_code: 400, description: d, method: 'sendMessage' }),
470
+ ),
471
+ ).toBe(false)
472
+ }
473
+ })
474
+
475
+ it('does not match non-400 GrammyErrors', () => {
476
+ expect(
477
+ isHtmlParseRejectError(
478
+ makeGrammyError({
479
+ error_code: 429,
480
+ description: "can't parse entities",
481
+ method: 'sendMessage',
482
+ }),
483
+ ),
484
+ ).toBe(false)
485
+ expect(
486
+ isHtmlParseRejectError(
487
+ makeGrammyError({
488
+ error_code: 403,
489
+ description: 'Forbidden: bot was blocked',
490
+ method: 'sendMessage',
491
+ }),
492
+ ),
493
+ ).toBe(false)
494
+ })
495
+
496
+ it('does not match plain Errors or non-error values', () => {
497
+ expect(isHtmlParseRejectError(new Error("can't parse entities"))).toBe(false)
498
+ expect(isHtmlParseRejectError('string')).toBe(false)
499
+ expect(isHtmlParseRejectError(null)).toBe(false)
500
+ expect(isHtmlParseRejectError(undefined)).toBe(false)
501
+ })
502
+
503
+ it('matches the curly-apostrophe variant Telegram sometimes emits', () => {
504
+ expect(
505
+ isHtmlParseRejectError(
506
+ makeGrammyError({
507
+ error_code: 400,
508
+ description: 'Bad Request: can’t parse entities: bad tag',
509
+ method: 'sendMessage',
510
+ }),
511
+ ),
512
+ ).toBe(true)
513
+ })
514
+ })
@@ -12,7 +12,7 @@ describe('secret-detect audit log', () => {
12
12
  })
13
13
 
14
14
  it('emits a structured event with slug but never the raw value', () => {
15
- const raw = 'sk-ant-Apq13yqRnPzx4MxK0TfAbY98Qw22'
15
+ const raw = ['sk-ant-', 'Apq13yqRnPzx4MxK0TfAbY98Qw22'].join('')
16
16
  emitAudit({
17
17
  chat_id: '-100',
18
18
  message_id: 5,
@@ -27,7 +27,8 @@ describe('pipeline.runPipeline', () => {
27
27
 
28
28
  it('stores a high-confidence hit and rewrites the prompt', () => {
29
29
  const { write, list, store } = mkFakeVault()
30
- const text = 'hey here is my key: sk-ant-Apq13yqRnPzx4MxK0TfAbY98Qw22 thanks'
30
+ const tok = ['sk-ant-', 'Apq13yqRnPzx4MxK0TfAbY98Qw22'].join('')
31
+ const text = `hey here is my key: ${tok} thanks`
31
32
  const res = runPipeline({
32
33
  chat_id: '-100',
33
34
  message_id: 5,
@@ -38,9 +39,9 @@ describe('pipeline.runPipeline', () => {
38
39
  })
39
40
  expect(res.stored).toHaveLength(1)
40
41
  expect(res.rewritten_text).toContain('[secret stored as vault:')
41
- expect(res.rewritten_text).not.toContain('sk-ant-Apq13yqRnPzx4MxK0TfAbY98Qw22')
42
+ expect(res.rewritten_text).not.toContain(tok)
42
43
  // The raw secret made it to the vault under the generated slug.
43
- expect([...store.values()]).toContain('sk-ant-Apq13yqRnPzx4MxK0TfAbY98Qw22')
44
+ expect([...store.values()]).toContain(tok)
44
45
  // Audit emitted once with action=stored.
45
46
  const storedLogs = captured.filter((l) => l.includes('"action":"stored"'))
46
47
  expect(storedLogs).toHaveLength(1)
@@ -71,7 +72,7 @@ describe('pipeline.runPipeline', () => {
71
72
 
72
73
  it('treats suppressed high-confidence hits as ambiguous', () => {
73
74
  const { write, list, store } = mkFakeVault()
74
- const text = 'test sk-ant-Apq13yqRnPzx4MxK0TfAbY98Qw22'
75
+ const text = `test ${['sk-ant-', 'Apq13yqRnPzx4MxK0TfAbY98Qw22'].join('')}`
75
76
  const res = runPipeline({
76
77
  chat_id: 'c',
77
78
  message_id: 1,
@@ -89,7 +90,7 @@ describe('pipeline.runPipeline', () => {
89
90
  const { write, list, store } = mkFakeVault()
90
91
  store.set('anthropic_api_key_20260423', 'preexisting')
91
92
  const text =
92
- 'first: sk-ant-Apq13yqRnPzx4MxK0TfAbY98Qw22 second: sk-ant-BqZ13yqRnPzx4MxK0TfAbY98Qw22'
93
+ `first: ${['sk-ant-', 'Apq13yqRnPzx4MxK0TfAbY98Qw22'].join('')} second: ${['sk-ant-', 'BqZ13yqRnPzx4MxK0TfAbY98Qw22'].join('')}`
93
94
  const res = runPipeline({
94
95
  chat_id: 'c',
95
96
  message_id: 2,
@@ -111,7 +112,7 @@ describe('pipeline.runPipeline', () => {
111
112
  const res = runPipeline({
112
113
  chat_id: 'c',
113
114
  message_id: 3,
114
- text: 'key is sk-ant-Apq13yqRnPzx4MxK0TfAbY98Qw22',
115
+ text: `key is ${['sk-ant-', 'Apq13yqRnPzx4MxK0TfAbY98Qw22'].join('')}`,
115
116
  passphrase: 'pw',
116
117
  vaultWrite: failingWrite,
117
118
  vaultList: list,
@@ -20,12 +20,13 @@ import type { VaultWriteFn, VaultListFn } from '../secret-detect/vault-write.js'
20
20
  * this without breaking a test.
21
21
  */
22
22
  describe('suppressor: never silent-allows on structured matches', () => {
23
+ const tok = ['sk-ant-', 'Apq13yqRnPzx4MxK0TfAbY98Qw22'].join('')
23
24
  const phrasings = [
24
- 'this is a test, here is sk-ant-Apq13yqRnPzx4MxK0TfAbY98Qw22',
25
- 'mock token: sk-ant-Apq13yqRnPzx4MxK0TfAbY98Qw22',
26
- 'example: sk-ant-Apq13yqRnPzx4MxK0TfAbY98Qw22',
27
- 'dummy sk-ant-Apq13yqRnPzx4MxK0TfAbY98Qw22',
28
- 'fixture sk-ant-Apq13yqRnPzx4MxK0TfAbY98Qw22',
25
+ `this is a test, here is ${tok}`,
26
+ `mock token: ${tok}`,
27
+ `example: ${tok}`,
28
+ `dummy ${tok}`,
29
+ `fixture ${tok}`,
29
30
  ]
30
31
 
31
32
  for (const text of phrasings) {
@@ -9,7 +9,7 @@ import { rewritePrompt } from '../secret-detect/rewrite.js'
9
9
 
10
10
  describe('mask.maskToken', () => {
11
11
  it('reveals first 6 + last 4 when length ≥ 18', () => {
12
- const tok = 'sk-ant-abc123XYZdefGHI456789'
12
+ const tok = ['sk-ant-', 'abc123XYZdefGHI456789'].join('')
13
13
  expect(maskToken(tok)).toBe(`${tok.slice(0, 6)}...${tok.slice(-4)}`)
14
14
  })
15
15
  it('returns *** for short inputs', () => {
@@ -96,7 +96,7 @@ describe('chunker.chunk', () => {
96
96
 
97
97
  describe('suppressor.isSuppressed', () => {
98
98
  it('demotes hits near test/mock/example/fixture/dummy', () => {
99
- const text = 'test: sk-ant-abc123defgh456789'
99
+ const text = `test: ${['sk-ant-', 'abc123defgh456789'].join('')}`
100
100
  const start = text.indexOf('sk-ant-')
101
101
  const end = text.length
102
102
  expect(isSuppressed(text, start, end)).toBe(true)
@@ -104,13 +104,13 @@ describe('suppressor.isSuppressed', () => {
104
104
  it('ignores markers more than 40 chars away', () => {
105
105
  // 80 chars of filler between "test" and the secret
106
106
  const filler = ' '.repeat(80)
107
- const text = `test${filler}sk-ant-abc123defgh456789`
107
+ const text = `test${filler}${['sk-ant-', 'abc123defgh456789'].join('')}`
108
108
  const start = text.indexOf('sk-ant-')
109
109
  const end = text.length
110
110
  expect(isSuppressed(text, start, end)).toBe(false)
111
111
  })
112
112
  it('whole-word only — "tested" does not trigger', () => {
113
- const text = 'untested sk-ant-abc123defgh456789'
113
+ const text = `untested ${['sk-ant-', 'abc123defgh456789'].join('')}`
114
114
  const start = text.indexOf('sk-ant-')
115
115
  const end = text.length
116
116
  expect(isSuppressed(text, start, end)).toBe(false)
@@ -150,7 +150,7 @@ describe('rewrite.rewritePrompt', () => {
150
150
  expect(out).toContain('[secret stored as vault:TOKEN]')
151
151
  })
152
152
  it('preserves non-secret substrings verbatim', () => {
153
- const text = 'please stash api key ANTHROPIC_API_KEY=sk-ant-ABCDEFGHIJKLMNOP now'
153
+ const text = `please stash api key ANTHROPIC_API_KEY=${['sk-ant-', 'ABCDEFGHIJKLMNOP'].join('')} now`
154
154
  const detections = detectSecrets(text)
155
155
  expect(detections.length).toBeGreaterThan(0)
156
156
  const targets = detections.map((d) => ({ detection: d, actual_slug: 'ANTHROPIC_API_KEY' }))
@@ -161,7 +161,7 @@ describe('rewrite.rewritePrompt', () => {
161
161
 
162
162
  describe('detectSecrets — end-to-end', () => {
163
163
  it('finds an anthropic key', () => {
164
- const text = 'here you go: sk-ant-Apq13yqRnPzx4MxK0TfAbY98Qw22'
164
+ const text = `here you go: ${['sk-ant-', 'Apq13yqRnPzx4MxK0TfAbY98Qw22'].join('')}`
165
165
  const d = detectSecrets(text)
166
166
  expect(d).toHaveLength(1)
167
167
  expect(d[0]!.rule_id).toBe('anthropic_api_key')
@@ -175,7 +175,7 @@ describe('detectSecrets — end-to-end', () => {
175
175
  expect(d.some((h) => h.rule_id === 'github_pat_classic')).toBe(true)
176
176
  })
177
177
  it('captures only the value for KEY=VALUE patterns', () => {
178
- const text = 'ANTHROPIC_API_KEY=sk-ant-Apq13yqRnPzx4MxK0TfAbY98Qw22'
178
+ const text = `ANTHROPIC_API_KEY=${['sk-ant-', 'Apq13yqRnPzx4MxK0TfAbY98Qw22'].join('')}`
179
179
  const d = detectSecrets(text)
180
180
  const envHit = d.find((h) => h.rule_id === 'env_key_value' || h.rule_id === 'anthropic_api_key')
181
181
  expect(envHit).toBeDefined()
@@ -183,7 +183,7 @@ describe('detectSecrets — end-to-end', () => {
183
183
  expect(envHit!.matched_text.startsWith('sk-ant-')).toBe(true)
184
184
  })
185
185
  it('flags suppressed on nearby "test"', () => {
186
- const text = 'test token: sk-ant-Apq13yqRnPzx4MxK0TfAbY98Qw22'
186
+ const text = `test token: ${['sk-ant-', 'Apq13yqRnPzx4MxK0TfAbY98Qw22'].join('')}`
187
187
  const d = detectSecrets(text)
188
188
  expect(d.length).toBeGreaterThan(0)
189
189
  expect(d[0]!.suppressed).toBe(true)
@@ -6,7 +6,7 @@ import { describe, test, expect } from 'vitest'
6
6
 
7
7
  // Import from the side-effect-free format module so tests don't trigger
8
8
  // server.ts's startup (env load, token check, grammy init).
9
- import { markdownToHtml, splitHtmlChunks, isLikelyTelegramHtml, repairEscapedWhitespace, sanitizeForTelegram } from '../format.js'
9
+ import { markdownToHtml, splitHtmlChunks, isLikelyTelegramHtml, repairEscapedWhitespace, sanitizeForTelegram, telegramHtmlToPlainText } from '../format.js'
10
10
 
11
11
  // ---------------------------------------------------------------------------
12
12
  // markdownToHtml
@@ -1091,3 +1091,86 @@ describe('markdownToHtml — markdown table rendering', () => {
1091
1091
  expect(result).toContain('• <b>OR</b>')
1092
1092
  })
1093
1093
  })
1094
+
1095
+ describe('telegramHtmlToPlainText (HTML parse-reject fallback)', () => {
1096
+ test('strips supported formatting tags, keeps the text', () => {
1097
+ const out = telegramHtmlToPlainText('<b>Bold</b> and <i>italic</i> and <code>x=1</code>')
1098
+ expect(out).toBe('Bold and italic and x=1')
1099
+ })
1100
+
1101
+ test('anchors become "label (href)"', () => {
1102
+ const out = telegramHtmlToPlainText('see <a href="https://example.com/x">the docs</a> now')
1103
+ expect(out).toBe('see the docs (https://example.com/x) now')
1104
+ })
1105
+
1106
+ test('anchor with label equal to href collapses to the bare url', () => {
1107
+ const out = telegramHtmlToPlainText('<a href="https://example.com">https://example.com</a>')
1108
+ expect(out).toBe('https://example.com')
1109
+ })
1110
+
1111
+ test('anchor with empty label yields just the href', () => {
1112
+ expect(telegramHtmlToPlainText('<a href="https://e.com"></a>')).toBe('https://e.com')
1113
+ })
1114
+
1115
+ test('single-quoted and unquoted href forms are handled', () => {
1116
+ expect(telegramHtmlToPlainText("<a href='https://a.co'>A</a>")).toBe('A (https://a.co)')
1117
+ expect(telegramHtmlToPlainText('<a href=https://b.co>B</a>')).toBe('B (https://b.co)')
1118
+ })
1119
+
1120
+ test('decodes the standard HTML entities (no double-decode of the result)', () => {
1121
+ const out = telegramHtmlToPlainText('a &amp; b &lt;tag&gt; &quot;q&quot; &#39;s&#39; 5 &nbsp;€')
1122
+ expect(out).toBe('a & b <tag> "q" \'s\' 5 €')
1123
+ })
1124
+
1125
+ test('numeric + hex char references decode', () => {
1126
+ expect(telegramHtmlToPlainText('&#8594; &#x2192;')).toBe('→ →')
1127
+ })
1128
+
1129
+ test('out-of-range / malformed char refs are left literal', () => {
1130
+ expect(telegramHtmlToPlainText('&#0; &#1114112; &#xZZ;')).toBe('&#0; &#1114112; &#xZZ;')
1131
+ })
1132
+
1133
+ test('block/break boundaries become newlines', () => {
1134
+ const out = telegramHtmlToPlainText('one<br>two<br/>three</p>four</blockquote>five')
1135
+ expect(out).toBe('one\ntwo\nthree\nfour\nfive')
1136
+ })
1137
+
1138
+ test('unsupported / malformed tags (the actual reject cause) are stripped, not escaped', () => {
1139
+ // A markdown→HTML slip that emitted an unsupported tag is exactly
1140
+ // what triggers Telegram's 400; the fallback must yield clean text.
1141
+ const out = telegramHtmlToPlainText('<h2>Title</h2><span class=x>body </span><unknowntag>tail')
1142
+ expect(out).toBe('Title\nbody tail')
1143
+ })
1144
+
1145
+ test('result is literal (parse_mode unset) — no re-escaping of < > &', () => {
1146
+ // We resend with parse_mode UNSET, so the output must be the raw
1147
+ // characters, not HTML entities.
1148
+ const out = telegramHtmlToPlainText('a &lt; b &amp;&amp; c &gt; d')
1149
+ expect(out).toBe('a < b && c > d')
1150
+ expect(out).not.toContain('&lt;')
1151
+ expect(out).not.toContain('&amp;')
1152
+ })
1153
+
1154
+ test('collapses 3+ blank lines and trims trailing line whitespace', () => {
1155
+ const out = telegramHtmlToPlainText('a \n\n\n\n\nb')
1156
+ expect(out).toBe('a\n\nb')
1157
+ })
1158
+
1159
+ test('nested formatting inside an anchor label is flattened', () => {
1160
+ const out = telegramHtmlToPlainText('<a href="https://x.io"><b>Big</b> link</a>')
1161
+ expect(out).toBe('Big link (https://x.io)')
1162
+ })
1163
+
1164
+ test('empty / whitespace input is safe', () => {
1165
+ expect(telegramHtmlToPlainText('')).toBe('')
1166
+ expect(telegramHtmlToPlainText(' \n ')).toBe('')
1167
+ })
1168
+
1169
+ test('pure-markup chunk collapses to empty (gateway substitutes a placeholder)', () => {
1170
+ // Documents the trigger for the empty-string guard in
1171
+ // gateway.ts:sendChunkPlainText — a chunk with no text content
1172
+ // strips to '', so the send path must substitute rather than
1173
+ // post an empty message (Telegram 400 "message text is empty").
1174
+ expect(telegramHtmlToPlainText('<b></b><i></i><br><span></span>')).toBe('')
1175
+ })
1176
+ })
@@ -0,0 +1,154 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import {
6
+ readLastTerminalUpdateAudit,
7
+ renderUpdateOutcomeLine,
8
+ claimUpdateAnnouncement,
9
+ maybeRenderUpdateAnnouncement,
10
+ } from "../gateway/update-announce.js";
11
+
12
+ function makeRow(o: Record<string, unknown>): string {
13
+ return JSON.stringify(o) + "\n";
14
+ }
15
+
16
+ describe("update-announce — PR C boot-card surfacing", () => {
17
+ let tmp: string;
18
+ let auditPath: string;
19
+ let stateDir: string;
20
+
21
+ beforeEach(() => {
22
+ tmp = mkdtempSync(join(tmpdir(), "update-announce-"));
23
+ auditPath = join(tmp, "host-control-audit.log");
24
+ stateDir = join(tmp, "state");
25
+ });
26
+ afterEach(() => {
27
+ rmSync(tmp, { recursive: true, force: true });
28
+ });
29
+
30
+ it("returns null when audit log missing", () => {
31
+ expect(readLastTerminalUpdateAudit({ auditLogPath: auditPath })).toBeNull();
32
+ });
33
+
34
+ it("returns most recent terminal update_apply row within lookback", () => {
35
+ const now = Date.parse("2026-05-17T12:00:00.000Z");
36
+ const lines =
37
+ makeRow({
38
+ ts: "2026-05-17T11:59:00.000Z",
39
+ op: "update_apply",
40
+ caller: { kind: "operator" },
41
+ request_id: "req-1",
42
+ result: "ok",
43
+ exit_code: 0,
44
+ duration_ms: 1234,
45
+ phase: "terminal",
46
+ channel: "dev",
47
+ resolved_sha: { "switchroom-agent": "sha256:abcdef1234567890" },
48
+ install_context: { install_type: "binary", detected_at: "2026-05-17T11:00:00Z" },
49
+ }) +
50
+ makeRow({
51
+ ts: "2026-05-17T11:58:00.000Z",
52
+ op: "update_apply",
53
+ caller: { kind: "operator" },
54
+ request_id: "req-0",
55
+ result: "started",
56
+ exit_code: null,
57
+ duration_ms: 0,
58
+ });
59
+ writeFileSync(auditPath, lines, "utf-8");
60
+ const entry = readLastTerminalUpdateAudit({ auditLogPath: auditPath, now });
61
+ expect(entry).not.toBeNull();
62
+ expect(entry!.request_id).toBe("req-1");
63
+ });
64
+
65
+ it("skips entries outside lookback window", () => {
66
+ const now = Date.parse("2026-05-17T12:00:00.000Z");
67
+ writeFileSync(auditPath, makeRow({
68
+ ts: "2026-05-17T11:00:00.000Z", // 60 minutes ago
69
+ op: "update_apply",
70
+ caller: { kind: "operator" },
71
+ request_id: "stale",
72
+ result: "ok",
73
+ exit_code: 0,
74
+ duration_ms: 1,
75
+ phase: "terminal",
76
+ }), "utf-8");
77
+ expect(readLastTerminalUpdateAudit({ auditLogPath: auditPath, now, lookbackMs: 10 * 60 * 1000 })).toBeNull();
78
+ });
79
+
80
+ it("renders success line with channel + short sha", () => {
81
+ const line = renderUpdateOutcomeLine({
82
+ ts: "2026-05-17T11:59:00.000Z",
83
+ op: "update_apply",
84
+ caller: { kind: "operator" },
85
+ request_id: "req-1",
86
+ result: "ok",
87
+ exit_code: 0,
88
+ duration_ms: 100,
89
+ phase: "terminal",
90
+ channel: "dev",
91
+ resolved_sha: { "switchroom-agent": "sha256:abcdef1234567890aaaa" },
92
+ });
93
+ expect(line).toContain("✅ update completed");
94
+ expect(line).toContain("channel:dev");
95
+ expect(line).toContain("sha:abcdef123456");
96
+ });
97
+
98
+ it("renders failure line with stderr + recovery hint for binary install", () => {
99
+ const line = renderUpdateOutcomeLine({
100
+ ts: "2026-05-17T11:59:00.000Z",
101
+ op: "update_apply",
102
+ caller: { kind: "operator" },
103
+ request_id: "req-2",
104
+ result: "error",
105
+ exit_code: 1,
106
+ duration_ms: 100,
107
+ phase: "terminal",
108
+ stderr_tail: "compose pull failed: registry timeout",
109
+ install_context: { install_type: "binary", detected_at: "2026-05-17T11:00:00Z" },
110
+ });
111
+ expect(line).toContain("❌ update failed at update_apply");
112
+ expect(line).toContain("compose pull failed");
113
+ expect(line).toContain("Recovery:");
114
+ expect(line).toContain("install.sh");
115
+ });
116
+
117
+ it("renders unknown recovery hint when install_type missing", () => {
118
+ const line = renderUpdateOutcomeLine({
119
+ ts: "2026-05-17T11:59:00.000Z",
120
+ op: "update_apply",
121
+ caller: { kind: "operator" },
122
+ request_id: "req-3",
123
+ result: "error",
124
+ exit_code: 1,
125
+ duration_ms: 100,
126
+ phase: "terminal",
127
+ });
128
+ expect(line).toContain("Cannot auto-detect install type");
129
+ });
130
+
131
+ it("claimUpdateAnnouncement is atomic — second call returns false", () => {
132
+ expect(claimUpdateAnnouncement("req-abc", { stateDir })).toBe(true);
133
+ expect(claimUpdateAnnouncement("req-abc", { stateDir })).toBe(false);
134
+ });
135
+
136
+ it("maybeRenderUpdateAnnouncement dedupes on second call", () => {
137
+ const now = Date.parse("2026-05-17T12:00:00.000Z");
138
+ writeFileSync(auditPath, makeRow({
139
+ ts: "2026-05-17T11:59:00.000Z",
140
+ op: "update_apply",
141
+ caller: { kind: "operator" },
142
+ request_id: "req-dedup",
143
+ result: "ok",
144
+ exit_code: 0,
145
+ duration_ms: 1,
146
+ phase: "terminal",
147
+ channel: "dev",
148
+ }), "utf-8");
149
+ const first = maybeRenderUpdateAnnouncement({ auditLogPath: auditPath, now, stateDir });
150
+ expect(first).not.toBeNull();
151
+ const second = maybeRenderUpdateAnnouncement({ auditLogPath: auditPath, now, stateDir });
152
+ expect(second).toBeNull();
153
+ });
154
+ });
@@ -25,7 +25,7 @@ const CTX_READ: VaultGrantInboundContext = {
25
25
  agent: 'gymbro',
26
26
  key: 'fatsecret/credentials',
27
27
  scope: 'read',
28
- chat_id: '8248703757',
28
+ chat_id: '12345',
29
29
  ttl_seconds: 30 * 86400,
30
30
  }
31
31
 
@@ -42,11 +42,11 @@ describe('buildVaultGrantApprovedInbound', () => {
42
42
  ctx: CTX_READ,
43
43
  grantId: 'vg_a1b2c3',
44
44
  stageId: 'stage-001',
45
- operatorId: '8248703757',
45
+ operatorId: '12345',
46
46
  nowMs: FIXED_NOW,
47
47
  })
48
48
  expect(msg.type).toBe('inbound')
49
- expect(msg.chatId).toBe('8248703757')
49
+ expect(msg.chatId).toBe('12345')
50
50
  expect(msg.user).toBe('vault-broker')
51
51
  expect(msg.userId).toBe(0)
52
52
  expect(msg.ts).toBe(FIXED_NOW)
@@ -71,7 +71,7 @@ describe('buildVaultGrantApprovedInbound', () => {
71
71
  ctx: CTX_READ,
72
72
  grantId: 'vg_a1b2c3',
73
73
  stageId: 'stage-001',
74
- operatorId: '8248703757',
74
+ operatorId: '12345',
75
75
  })
76
76
  expect(msg.meta).toEqual({
77
77
  source: 'vault_grant_approved',
@@ -80,7 +80,7 @@ describe('buildVaultGrantApprovedInbound', () => {
80
80
  scope: 'read',
81
81
  grant_id: 'vg_a1b2c3',
82
82
  stage_id: 'stage-001',
83
- operator_id: '8248703757',
83
+ operator_id: '12345',
84
84
  })
85
85
  })
86
86
 
@@ -151,7 +151,7 @@ describe('buildVaultGrantDeniedInbound', () => {
151
151
  const msg = buildVaultGrantDeniedInbound({
152
152
  ctx: CTX_READ,
153
153
  stageId: 'stage-001',
154
- operatorId: '8248703757',
154
+ operatorId: '12345',
155
155
  })
156
156
  expect(msg.meta).toEqual({
157
157
  source: 'vault_grant_denied',
@@ -159,7 +159,7 @@ describe('buildVaultGrantDeniedInbound', () => {
159
159
  key: 'fatsecret/credentials',
160
160
  scope: 'read',
161
161
  stage_id: 'stage-001',
162
- operator_id: '8248703757',
162
+ operator_id: '12345',
163
163
  })
164
164
  expect((msg.meta as { grant_id?: string }).grant_id).toBeUndefined()
165
165
  })
@@ -189,7 +189,7 @@ describe('buildVaultGrantDeniedInbound', () => {
189
189
  expect(denied.type).toBe('inbound')
190
190
  expect(denied.user).toBe('vault-broker')
191
191
  expect(denied.userId).toBe(0)
192
- expect(denied.chatId).toBe('8248703757')
192
+ expect(denied.chatId).toBe('12345')
193
193
  expect(denied.ts).toBe(FIXED_NOW)
194
194
  expect(denied.messageId).toBe(FIXED_NOW)
195
195
  })
@@ -112,3 +112,54 @@ describe('vault_request_access (#1012)', () => {
112
112
  expect(handlerBlock).toMatch(/allowFrom\.includes/)
113
113
  })
114
114
  })
115
+
116
+ /**
117
+ * Fix B (#1487 follow-up): vault_request_access must NOT card/mint when
118
+ * the agent's STANDING ACL already covers the key — and must decide
119
+ * that by probing the BROKER as the agent (no-token listViaBroker over
120
+ * the per-agent socket — path-as-identity), never a gateway-side
121
+ * config/checkAclByAgent read (the gateway can see newer config than
122
+ * the broker has loaded → "covered here, denied there"). Read scope
123
+ * only; fail-open on probe error. Source-pattern assertions matching
124
+ * this file's established style (the flow has Telegram + module-state
125
+ * side effects that aren't behaviourally unit-testable here).
126
+ */
127
+ describe('Fix B: vault_request_access standing-ACL-aware (#1487 follow-up)', () => {
128
+ const execBlock =
129
+ gatewaySrc.split('async function executeVaultRequestAccess')[1]?.split('\nasync function ')[0] ?? ''
130
+ const approveBlock =
131
+ gatewaySrc.split('async function performVaultAccessApproval')[1]?.split('\nasync function ')[0] ?? ''
132
+
133
+ it('request path: read-scope broker-probe short-circuits BEFORE the card is staged/sent', () => {
134
+ expect(execBlock).toContain('listViaBroker(')
135
+ expect(execBlock).toMatch(/scopeRaw === 'read'/)
136
+ const probeIdx = execBlock.indexOf('listViaBroker(')
137
+ const stageIdx = execBlock.indexOf('pendingVaultRequestAccesses.set(stageId')
138
+ expect(probeIdx).toBeGreaterThan(-1)
139
+ expect(stageIdx).toBeGreaterThan(-1)
140
+ expect(probeIdx).toBeLessThan(stageIdx)
141
+ expect(execBlock).toMatch(/ALREADY covered[\s\S]*?return\s*{/)
142
+ })
143
+
144
+ it('request path: decides via the BROKER, not a gateway-side config/ACL read (B2 — no config drift)', () => {
145
+ expect(execBlock).not.toContain('checkAclByAgent(')
146
+ expect(execBlock).not.toContain('loadSwitchroomConfig(')
147
+ })
148
+
149
+ it('operator-approve path: parallel guard short-circuits BEFORE mintGrantViaBroker', () => {
150
+ expect(approveBlock).toContain('listViaBroker(')
151
+ expect(approveBlock).toMatch(/pending\.scope === 'read'/)
152
+ const probeIdx = approveBlock.indexOf('listViaBroker(')
153
+ const mintIdx = approveBlock.indexOf('mintGrantViaBroker(mintArgs)')
154
+ expect(probeIdx).toBeGreaterThan(-1)
155
+ expect(mintIdx).toBeGreaterThan(-1)
156
+ expect(probeIdx).toBeLessThan(mintIdx)
157
+ expect(approveBlock).toMatch(/listViaBroker\([\s\S]*?pendingVaultRequestAccesses\.delete\(stageId\)[\s\S]*?return/)
158
+ expect(approveBlock).not.toContain('checkAclByAgent(')
159
+ })
160
+
161
+ it('both guards are fail-open (probe error → normal card/mint flow)', () => {
162
+ expect(execBlock).toMatch(/try\s*{[\s\S]*?listViaBroker\([\s\S]*?}\s*catch/)
163
+ expect(approveBlock).toMatch(/try\s*{[\s\S]*?listViaBroker\([\s\S]*?}\s*catch/)
164
+ })
165
+ })
@@ -253,14 +253,7 @@ export const switchroomHelpCommandNames = [
253
253
  "new", "reset", "approve", "deny", "pending", "interrupt",
254
254
  // Agents
255
255
  "agents", "agentstart", "stop", "restart", "logs", "memory",
256
- // Auth & config. The auth surface consolidated onto the `/auth`
257
- // dashboard:
258
- // - /authfallback removed in v0.6.12 (Switch primary picker
259
- // handles the operator case; auto-fallback poller handles the
260
- // transparent on-quota-wall case)
261
- // - /reauth removed in v0.6.13 (dashboard's `🔄 Reauth` button
262
- // fires the same flow; paste-back of the OAuth code is caught
263
- // by the generic message intercept on `pendingReauthFlows`)
256
+ // Auth & config consolidated onto the `/auth` dashboard.
264
257
  "auth",
265
258
  "topics", "update", "version",
266
259
  "permissions", "grant", "dangerous", "vault", "doctor",