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.
- package/README.md +32 -16
- package/dist/agent-scheduler/index.js +216 -97
- package/dist/auth-broker/index.js +176 -97
- package/dist/cli/drive-write-pretool.mjs +26 -11
- package/dist/cli/skill-validate-pretool.mjs +7209 -0
- package/dist/cli/switchroom.js +45571 -42642
- package/dist/cli/ui/index.html +1281 -0
- package/dist/host-control/main.js +3628 -309
- package/dist/vault/approvals/kernel-server.js +207 -98
- package/dist/vault/broker/server.js +249 -119
- package/examples/personal-google-workspace-mcp/README.md +8 -3
- package/examples/switchroom.yaml +91 -42
- package/package.json +4 -3
- package/profiles/_base/start.sh.hbs +76 -36
- package/profiles/_shared/agent-self-service.md.hbs +1 -1
- package/profiles/default/CLAUDE.md.hbs +4 -2
- package/skills/file-bug/SKILL.md +6 -4
- package/skills/skill-creator/SKILL.md +52 -0
- package/skills/switchroom-cli/SKILL.md +20 -4
- package/skills/switchroom-install/SKILL.md +3 -3
- package/telegram-plugin/auth-snapshot-format.ts +9 -9
- package/telegram-plugin/card-format.ts +3 -3
- package/telegram-plugin/dist/bridge/bridge.js +112 -112
- package/telegram-plugin/dist/gateway/gateway.js +853 -414
- package/telegram-plugin/dist/server.js +162 -161
- package/telegram-plugin/format.ts +71 -0
- package/telegram-plugin/gateway/access-validator.test.ts +8 -8
- package/telegram-plugin/gateway/access-validator.ts +1 -1
- package/telegram-plugin/gateway/approval-card.test.ts +18 -18
- package/telegram-plugin/gateway/approval-card.ts +1 -1
- package/telegram-plugin/gateway/auth-command.ts +2 -2
- package/telegram-plugin/gateway/boot-card.ts +40 -3
- package/telegram-plugin/gateway/boot-probes.ts +114 -30
- package/telegram-plugin/gateway/diff-preview-card.test.ts +15 -15
- package/telegram-plugin/gateway/diff-preview-card.ts +1 -1
- package/telegram-plugin/gateway/drive-write-approval.test.ts +2 -2
- package/telegram-plugin/gateway/gateway.ts +265 -22
- package/telegram-plugin/gateway/update-announce.ts +167 -0
- package/telegram-plugin/quota-check.ts +0 -195
- package/telegram-plugin/recent-outbound-dedup.ts +1 -1
- package/telegram-plugin/registry/turns-schema.ts +1 -1
- package/telegram-plugin/retry-api-call.ts +24 -0
- package/telegram-plugin/server.ts +8 -5
- package/telegram-plugin/tests/auth-add-flow.test.ts +32 -3
- package/telegram-plugin/tests/auth-command-format2.test.ts +4 -4
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +17 -17
- package/telegram-plugin/tests/auto-fallback-fleet.test.ts +10 -10
- package/telegram-plugin/tests/boot-probes.test.ts +90 -2
- package/telegram-plugin/tests/bot-runtime.test.ts +23 -1
- package/telegram-plugin/tests/fixtures/service-log-current-claude-code.bin +1 -1
- package/telegram-plugin/tests/fleet-state.test.ts +3 -2
- package/telegram-plugin/tests/quota-check.test.ts +0 -409
- package/telegram-plugin/tests/retry-api-call.test.ts +76 -0
- package/telegram-plugin/tests/secret-detect-audit.test.ts +1 -1
- package/telegram-plugin/tests/secret-detect-pipeline.test.ts +7 -6
- package/telegram-plugin/tests/secret-detect-suppressor-no-silent-allow.test.ts +6 -5
- package/telegram-plugin/tests/secret-detect.test.ts +8 -8
- package/telegram-plugin/tests/telegram-format.test.ts +84 -1
- package/telegram-plugin/tests/update-announce.test.ts +154 -0
- package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +8 -8
- package/telegram-plugin/tests/vault-request-access-tool.test.ts +51 -0
- package/telegram-plugin/welcome-text.ts +1 -8
- package/profiles/default/CLAUDE.md +0 -192
- package/skills/docx/scripts/office/validators/__pycache__/__init__.cpython-313.pyc +0 -0
- package/skills/docx/scripts/office/validators/__pycache__/base.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/__init__.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/generate_report.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/improve_description.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/run_eval.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/run_loop.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/utils.cpython-313.pyc +0 -0
- package/telegram-plugin/first-paint.ts +0 -225
- package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +0 -1
- package/telegram-plugin/server.js +0 -41795
- package/telegram-plugin/tests/html-balanced.ts +0 -63
- package/telegram-plugin/tests/snapshot-serializer.ts +0 -79
- 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
|
|
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(
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 = '
|
|
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 =
|
|
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 & b <tag> "q" 's' 5 €')
|
|
1122
|
+
expect(out).toBe('a & b <tag> "q" \'s\' 5 €')
|
|
1123
|
+
})
|
|
1124
|
+
|
|
1125
|
+
test('numeric + hex char references decode', () => {
|
|
1126
|
+
expect(telegramHtmlToPlainText('→ →')).toBe('→ →')
|
|
1127
|
+
})
|
|
1128
|
+
|
|
1129
|
+
test('out-of-range / malformed char refs are left literal', () => {
|
|
1130
|
+
expect(telegramHtmlToPlainText('� � &#xZZ;')).toBe('� � &#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 < b && c > d')
|
|
1149
|
+
expect(out).toBe('a < b && c > d')
|
|
1150
|
+
expect(out).not.toContain('<')
|
|
1151
|
+
expect(out).not.toContain('&')
|
|
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: '
|
|
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: '
|
|
45
|
+
operatorId: '12345',
|
|
46
46
|
nowMs: FIXED_NOW,
|
|
47
47
|
})
|
|
48
48
|
expect(msg.type).toBe('inbound')
|
|
49
|
-
expect(msg.chatId).toBe('
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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('
|
|
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
|
|
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",
|