switchroom 0.16.28 → 0.16.38

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 (93) hide show
  1. package/dist/agent-scheduler/index.js +13 -2
  2. package/dist/auth-broker/index.js +13 -2
  3. package/dist/cli/notion-write-pretool.mjs +13 -2
  4. package/dist/cli/switchroom.js +60648 -59800
  5. package/dist/host-control/main.js +730 -18
  6. package/dist/vault/approvals/kernel-server.js +51 -37
  7. package/dist/vault/broker/server.js +72 -58
  8. package/package.json +2 -1
  9. package/profiles/_shared/agent-self-service.md.hbs +7 -2
  10. package/telegram-plugin/auth-snapshot-format.ts +173 -67
  11. package/telegram-plugin/auto-fallback-fleet.ts +3 -6
  12. package/telegram-plugin/card-format.ts +52 -2
  13. package/telegram-plugin/credits-watch.ts +4 -7
  14. package/telegram-plugin/dist/gateway/gateway.js +2855 -1034
  15. package/telegram-plugin/dist/server.js +0 -1
  16. package/telegram-plugin/format.ts +264 -7
  17. package/telegram-plugin/gateway/approval-card.ts +7 -14
  18. package/telegram-plugin/gateway/approvals-commands.ts +6 -9
  19. package/telegram-plugin/gateway/auth-command.ts +35 -38
  20. package/telegram-plugin/gateway/boot-card.ts +7 -2
  21. package/telegram-plugin/gateway/chat-id-fallback.ts +46 -0
  22. package/telegram-plugin/gateway/config-approval-handler.ts +6 -9
  23. package/telegram-plugin/gateway/diff-preview-card.ts +3 -6
  24. package/telegram-plugin/gateway/gateway.ts +1293 -97
  25. package/telegram-plugin/gateway/ipc-protocol.ts +66 -2
  26. package/telegram-plugin/gateway/ipc-server.ts +91 -1
  27. package/telegram-plugin/gateway/linear-activity.ts +2 -5
  28. package/telegram-plugin/gateway/obligation-turn-end.ts +27 -0
  29. package/telegram-plugin/gateway/permission-card-store.ts +104 -0
  30. package/telegram-plugin/gateway/permission-timeout.ts +25 -6
  31. package/telegram-plugin/gateway/status-pin-store.ts +302 -0
  32. package/telegram-plugin/gateway/turns-jsonl-rotate.ts +30 -0
  33. package/telegram-plugin/gateway/unhandled-rejection-policy.ts +12 -1
  34. package/telegram-plugin/gateway/vault-grant-inbound-builders.ts +35 -0
  35. package/telegram-plugin/gateway/vault-request-access-card.ts +61 -0
  36. package/telegram-plugin/history.ts +17 -7
  37. package/telegram-plugin/idle-footer.ts +2 -2
  38. package/telegram-plugin/issues-card.ts +12 -7
  39. package/telegram-plugin/model-unavailable.ts +3 -6
  40. package/telegram-plugin/operator-events.ts +4 -6
  41. package/telegram-plugin/quota-check.ts +2 -2
  42. package/telegram-plugin/quota-watch.ts +7 -10
  43. package/telegram-plugin/server.ts +3 -1
  44. package/telegram-plugin/status-pin-driver.ts +102 -0
  45. package/telegram-plugin/status-pin.ts +76 -0
  46. package/telegram-plugin/stream-reply-handler.ts +14 -1
  47. package/telegram-plugin/subagent-watcher.ts +6 -3
  48. package/telegram-plugin/tests/auth-command-format2.test.ts +6 -2
  49. package/telegram-plugin/tests/auth-command-vernacular.test.ts +18 -0
  50. package/telegram-plugin/tests/auth-snapshot-format.test.ts +204 -45
  51. package/telegram-plugin/tests/card-format.test.ts +51 -0
  52. package/telegram-plugin/tests/chat-id-fallback.test.ts +74 -0
  53. package/telegram-plugin/tests/codespan-escaping-golden.test.ts +166 -0
  54. package/telegram-plugin/tests/credits-watch.test.ts +18 -0
  55. package/telegram-plugin/tests/history.test.ts +38 -2
  56. package/telegram-plugin/tests/idle-footer.test.ts +53 -9
  57. package/telegram-plugin/tests/ipc-server-validate-rollout-status.test.ts +66 -0
  58. package/telegram-plugin/tests/issues-card.test.ts +24 -0
  59. package/telegram-plugin/tests/obligation-turn-end.test.ts +76 -0
  60. package/telegram-plugin/tests/paragraph-normalizer.test.ts +200 -1
  61. package/telegram-plugin/tests/permission-card-single-edit.test.ts +66 -0
  62. package/telegram-plugin/tests/permission-card-store.test.ts +83 -0
  63. package/telegram-plugin/tests/permission-timeout.test.ts +37 -6
  64. package/telegram-plugin/tests/permission-verdict-resume-guard.test.ts +23 -6
  65. package/telegram-plugin/tests/quota-check.test.ts +9 -2
  66. package/telegram-plugin/tests/quota-watch.test.ts +7 -7
  67. package/telegram-plugin/tests/rollout-status-wiring.test.ts +81 -0
  68. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +294 -0
  69. package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +210 -0
  70. package/telegram-plugin/tests/status-pin-store.test.ts +450 -0
  71. package/telegram-plugin/tests/status-pin.test.ts +202 -0
  72. package/telegram-plugin/tests/stream-reply-handler.test.ts +39 -0
  73. package/telegram-plugin/tests/telegram-format.test.ts +88 -0
  74. package/telegram-plugin/tests/tool-activity-summary.test.ts +82 -24
  75. package/telegram-plugin/tests/turns-jsonl-rotate.test.ts +39 -0
  76. package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +12 -0
  77. package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +54 -0
  78. package/telegram-plugin/tests/vault-request-access-card.test.ts +94 -0
  79. package/telegram-plugin/tests/voice-normalize-text.test.ts +256 -0
  80. package/telegram-plugin/tests/voice-ondemand.test.ts +202 -0
  81. package/telegram-plugin/tests/voice-out-one-send.test.ts +153 -0
  82. package/telegram-plugin/tests/voice-synthesize-sidecar.test.ts +352 -0
  83. package/telegram-plugin/tests/voice-transcribe-sidecar.test.ts +332 -0
  84. package/telegram-plugin/tests/voice-transcribe.test.ts +188 -0
  85. package/telegram-plugin/tests/worker-activity-feed.test.ts +28 -10
  86. package/telegram-plugin/tool-activity-summary.ts +11 -8
  87. package/telegram-plugin/uat/driver.ts +8 -1
  88. package/telegram-plugin/voice-normalize-text.ts +340 -0
  89. package/telegram-plugin/voice-ondemand.ts +138 -0
  90. package/telegram-plugin/voice-synthesize-sidecar.ts +259 -0
  91. package/telegram-plugin/voice-synthesize.ts +128 -0
  92. package/telegram-plugin/voice-transcribe-sidecar.ts +176 -0
  93. package/telegram-plugin/worker-activity-feed.ts +13 -4
@@ -0,0 +1,332 @@
1
+ /**
2
+ * Unit tests for the LOCAL STT sidecar transcription path (voice PR-B2):
3
+ * - transcribeViaSidecar (HTTP POST to /stt, mocked fetch)
4
+ * - materializeSidecarToken (vault resolution, mocked broker)
5
+ *
6
+ * Mirrors the cloud twin's test (voice-transcribe.test.ts): every error
7
+ * reason is pinned so a refactor can't silently change the contract the
8
+ * gateway depends on (any non-ok → null → "(voice message)" fallback).
9
+ */
10
+
11
+ import { describe, it, expect } from 'bun:test'
12
+ import {
13
+ transcribeViaSidecar,
14
+ SIDECAR_MAX_BYTES,
15
+ DEFAULT_SIDECAR_BASE_URL,
16
+ } from '../voice-transcribe-sidecar.js'
17
+ import {
18
+ materializeSidecarToken,
19
+ DEFAULT_VOICE_SIDECAR_TOKEN_REF,
20
+ VOICE_SIDECAR_TOKEN_KEY,
21
+ } from '../../src/telegram/materialize-sidecar-token.js'
22
+ import type { resolveVaultReferencesViaBroker } from '../../src/vault/resolver.js'
23
+ import type { SwitchroomConfig } from '../../src/config/schema.js'
24
+
25
+ const SMALL_AUDIO = new Uint8Array([0x4f, 0x67, 0x67, 0x53]) // OggS magic
26
+
27
+ function makeFetch(status: number, body: unknown): typeof fetch {
28
+ return (async () => ({
29
+ ok: status >= 200 && status < 300,
30
+ status,
31
+ json: async () => body,
32
+ text: async () => (typeof body === 'string' ? body : JSON.stringify(body)),
33
+ headers: new Headers(),
34
+ })) as unknown as typeof fetch
35
+ }
36
+
37
+ describe('transcribeViaSidecar — pre-flight checks', () => {
38
+ it('returns no-token when token is empty', async () => {
39
+ const r = await transcribeViaSidecar({
40
+ token: '',
41
+ audio: SMALL_AUDIO,
42
+ filename: 'voice.ogg',
43
+ fetchImpl: makeFetch(200, { ok: true, text: 'x' }),
44
+ })
45
+ expect(r).toMatchObject({ ok: false, reason: 'no-token' })
46
+ })
47
+
48
+ it('returns audio-too-short on empty audio', async () => {
49
+ const r = await transcribeViaSidecar({
50
+ token: 't',
51
+ audio: new Uint8Array(0),
52
+ filename: 'voice.ogg',
53
+ })
54
+ expect(r).toMatchObject({ ok: false, reason: 'audio-too-short' })
55
+ })
56
+
57
+ it('returns audio-too-large above the cap', async () => {
58
+ const tooBig = new Uint8Array(SIDECAR_MAX_BYTES + 1)
59
+ const r = await transcribeViaSidecar({
60
+ token: 't',
61
+ audio: tooBig,
62
+ filename: 'voice.ogg',
63
+ })
64
+ expect(r).toMatchObject({ ok: false, reason: 'audio-too-large' })
65
+ })
66
+ })
67
+
68
+ describe('transcribeViaSidecar — happy path', () => {
69
+ it('returns transcript on 200 + valid JSON', async () => {
70
+ const r = await transcribeViaSidecar({
71
+ token: 't',
72
+ audio: SMALL_AUDIO,
73
+ filename: 'voice.ogg',
74
+ fetchImpl: makeFetch(200, {
75
+ ok: true,
76
+ text: 'Hello world',
77
+ language: 'en',
78
+ durationMs: 1234,
79
+ audioSeconds: 3.2,
80
+ }),
81
+ })
82
+ expect(r.ok).toBe(true)
83
+ if (r.ok) {
84
+ expect(r.text).toBe('Hello world')
85
+ expect(r.language).toBe('en')
86
+ expect(r.durationMs).toBe(1234)
87
+ expect(r.audioSeconds).toBe(3.2)
88
+ }
89
+ })
90
+
91
+ it('sends the X-Voice-Token header, audio + language fields, to <base>/stt', async () => {
92
+ let receivedUrl = ''
93
+ let receivedToken: string | null = null
94
+ let receivedForm: FormData | null = null
95
+ const fakeFetch: typeof fetch = (async (url: string, init?: RequestInit) => {
96
+ receivedUrl = url
97
+ receivedToken = (init?.headers as Record<string, string>)['X-Voice-Token']
98
+ receivedForm = init?.body as FormData
99
+ return {
100
+ ok: true,
101
+ status: 200,
102
+ json: async () => ({ ok: true, text: 'bonjour' }),
103
+ text: async () => '',
104
+ headers: new Headers(),
105
+ }
106
+ }) as unknown as typeof fetch
107
+
108
+ const r = await transcribeViaSidecar({
109
+ token: 'secret-token',
110
+ audio: SMALL_AUDIO,
111
+ filename: 'voice.ogg',
112
+ language: 'fr',
113
+ fetchImpl: fakeFetch,
114
+ })
115
+ expect(r.ok).toBe(true)
116
+ expect(receivedUrl).toBe(`${DEFAULT_SIDECAR_BASE_URL}/stt`)
117
+ expect(receivedToken).toBe('secret-token')
118
+ expect(receivedForm).not.toBeNull()
119
+ expect(receivedForm!.get('language')).toBe('fr')
120
+ expect(receivedForm!.get('audio')).not.toBeNull()
121
+ })
122
+
123
+ it('falls back to wall-clock durationMs when the sidecar omits it', async () => {
124
+ const r = await transcribeViaSidecar({
125
+ token: 't',
126
+ audio: SMALL_AUDIO,
127
+ filename: 'voice.ogg',
128
+ fetchImpl: makeFetch(200, { ok: true, text: 'hi' }),
129
+ })
130
+ expect(r.ok).toBe(true)
131
+ if (r.ok) {
132
+ expect(r.durationMs).toBeGreaterThanOrEqual(0)
133
+ expect(r.audioSeconds).toBeNull()
134
+ }
135
+ })
136
+ })
137
+
138
+ describe('transcribeViaSidecar — error paths', () => {
139
+ it('returns http-401 on a wrong/missing token (sidecar rejects)', async () => {
140
+ const r = await transcribeViaSidecar({
141
+ token: 't',
142
+ audio: SMALL_AUDIO,
143
+ filename: 'voice.ogg',
144
+ fetchImpl: makeFetch(401, { ok: false, reason: 'unauthorized' }),
145
+ })
146
+ expect(r).toMatchObject({ ok: false, reason: 'http-401' })
147
+ })
148
+
149
+ it('surfaces the sidecar reason on a 2xx { ok: false } body', async () => {
150
+ const r = await transcribeViaSidecar({
151
+ token: 't',
152
+ audio: SMALL_AUDIO,
153
+ filename: 'voice.ogg',
154
+ fetchImpl: makeFetch(200, { ok: false, reason: 'gpu-timeout', detail: 'busy' }),
155
+ })
156
+ expect(r).toMatchObject({ ok: false, reason: 'gpu-timeout' })
157
+ })
158
+
159
+ it('returns malformed-response when 200 body has no text field', async () => {
160
+ const r = await transcribeViaSidecar({
161
+ token: 't',
162
+ audio: SMALL_AUDIO,
163
+ filename: 'voice.ogg',
164
+ fetchImpl: makeFetch(200, { ok: true, unexpected: 'shape' }),
165
+ })
166
+ expect(r).toMatchObject({ ok: false, reason: 'malformed-response' })
167
+ })
168
+
169
+ it('returns fetch-failed on a thrown network error', async () => {
170
+ const fakeFetch: typeof fetch = (async () => {
171
+ throw new Error('ECONNREFUSED')
172
+ }) as unknown as typeof fetch
173
+ const r = await transcribeViaSidecar({
174
+ token: 't',
175
+ audio: SMALL_AUDIO,
176
+ filename: 'voice.ogg',
177
+ fetchImpl: fakeFetch,
178
+ })
179
+ expect(r).toMatchObject({ ok: false, reason: 'fetch-failed' })
180
+ expect(r.detail).toContain('ECONNREFUSED')
181
+ })
182
+
183
+ it('returns timeout when fetch is aborted', async () => {
184
+ const fakeFetch: typeof fetch = (async () => {
185
+ throw new Error('The operation was aborted')
186
+ }) as unknown as typeof fetch
187
+ const r = await transcribeViaSidecar({
188
+ token: 't',
189
+ audio: SMALL_AUDIO,
190
+ filename: 'voice.ogg',
191
+ timeoutMs: 5,
192
+ fetchImpl: fakeFetch,
193
+ })
194
+ expect(r).toMatchObject({ ok: false, reason: 'timeout' })
195
+ })
196
+ })
197
+
198
+ // ── materializeSidecarToken — vault resolution (mirrors the key helper) ──
199
+
200
+ const EMPTY_CONFIG = {} as unknown as SwitchroomConfig
201
+
202
+ function makeBrokerResolver(
203
+ resolvedRefs: Record<string, string>,
204
+ ): typeof resolveVaultReferencesViaBroker {
205
+ return (async (config: SwitchroomConfig) => {
206
+ const ref = config.telegram?.bot_token
207
+ if (ref && resolvedRefs[ref] !== undefined) {
208
+ return {
209
+ ok: true,
210
+ config: {
211
+ ...config,
212
+ telegram: { ...config.telegram, bot_token: resolvedRefs[ref] },
213
+ },
214
+ }
215
+ }
216
+ return { ok: false, reason: 'not_found' as const }
217
+ }) as unknown as typeof resolveVaultReferencesViaBroker
218
+ }
219
+
220
+ describe('materializeSidecarToken — vault resolution', () => {
221
+ it('defaults to vault:voice/sidecar-token', () => {
222
+ expect(VOICE_SIDECAR_TOKEN_KEY).toBe('voice/sidecar-token')
223
+ expect(DEFAULT_VOICE_SIDECAR_TOKEN_REF).toBe('vault:voice/sidecar-token')
224
+ })
225
+
226
+ it('resolves the configured vault ref via the broker', async () => {
227
+ const tok = await materializeSidecarToken({
228
+ config: EMPTY_CONFIG,
229
+ env: {},
230
+ resolveViaBroker: makeBrokerResolver({
231
+ 'vault:voice/sidecar-token': 'deadbeef' + 'cafe',
232
+ }),
233
+ })
234
+ expect(tok).toBe('deadbeef' + 'cafe')
235
+ })
236
+
237
+ it('returns a literal (non-vault) token directly without the broker', async () => {
238
+ let brokerCalled = false
239
+ const tok = await materializeSidecarToken({
240
+ tokenRef: 'raw' + 'token',
241
+ config: EMPTY_CONFIG,
242
+ env: {},
243
+ resolveViaBroker: (async () => {
244
+ brokerCalled = true
245
+ return { ok: false, reason: 'not_found' as const }
246
+ }) as unknown as typeof resolveVaultReferencesViaBroker,
247
+ })
248
+ expect(tok).toBe('raw' + 'token')
249
+ expect(brokerCalled).toBe(false)
250
+ })
251
+
252
+ it('returns null (graceful fallback, no throw) when unresolvable', async () => {
253
+ const tok = await materializeSidecarToken(
254
+ {
255
+ config: EMPTY_CONFIG,
256
+ env: {},
257
+ resolveViaBroker: makeBrokerResolver({}),
258
+ },
259
+ () => {},
260
+ )
261
+ expect(tok).toBeNull()
262
+ })
263
+
264
+ // ── Regression: single-key isolation (voice token bug) ──
265
+ //
266
+ // resolveVaultReferencesViaBroker fails the WHOLE batch unless EVERY vault:
267
+ // ref resolves. Admin-only refs (litellm/master-key,
268
+ // google/client-secret) are correctly DENIED to a normal agent — so if the
269
+ // materializer passes the full config, those denials sink the resolution
270
+ // even though voice/sidecar-token itself is granted. The fix passes a
271
+ // MINIMAL config carrying only the one ref. These tests fail against the
272
+ // old `...config` spread.
273
+
274
+ function collectRefs(v: unknown, out: Set<string>): void {
275
+ if (typeof v === 'string' && v.startsWith('vault:')) out.add(v.slice('vault:'.length).split('#')[0])
276
+ else if (Array.isArray(v)) for (const i of v) collectRefs(i, out)
277
+ else if (v !== null && typeof v === 'object') for (const val of Object.values(v as Record<string, unknown>)) collectRefs(val, out)
278
+ }
279
+
280
+ /** Resolver stub with the real batch semantics: denies unless every collected ref is granted. */
281
+ function makeBatchResolver(
282
+ granted: Record<string, string>,
283
+ onCall?: (refs: Set<string>) => void,
284
+ ): typeof resolveVaultReferencesViaBroker {
285
+ return (async (config: SwitchroomConfig) => {
286
+ const refs = new Set<string>()
287
+ collectRefs(config, refs)
288
+ onCall?.(refs)
289
+ for (const r of refs) {
290
+ if (granted[`vault:${r}`] === undefined) {
291
+ return { ok: false, reason: 'denied' as const }
292
+ }
293
+ }
294
+ const resolved = granted[config.telegram?.bot_token ?? '']
295
+ return {
296
+ ok: true,
297
+ config: { ...config, telegram: { ...config.telegram, bot_token: resolved } },
298
+ }
299
+ }) as unknown as typeof resolveVaultReferencesViaBroker
300
+ }
301
+
302
+ const CONFIG_WITH_ADMIN_REFS = {
303
+ telegram: { bot_token: 'vault:tg/bot' },
304
+ litellm: { admin_key: 'vault:litellm/master-key' },
305
+ google_workspace: { google_client_secret: 'vault:google/client-secret' },
306
+ agents: { klanker: { some_key: 'vault:per-agent/secret' } },
307
+ vault: { path: '~/.switchroom/vault.enc' },
308
+ } as unknown as SwitchroomConfig
309
+
310
+ it('resolves the sidecar token even when the config carries admin-only vault refs the broker DENIES', async () => {
311
+ const tok = await materializeSidecarToken({
312
+ config: CONFIG_WITH_ADMIN_REFS,
313
+ env: {},
314
+ resolveViaBroker: makeBatchResolver({ 'vault:voice/sidecar-token': 'deadbeefcafe' }),
315
+ })
316
+ expect(tok).toBe('deadbeefcafe')
317
+ })
318
+
319
+ it('invokes the resolver with a config containing exactly ONE vault ref (the sidecar token)', async () => {
320
+ let seen: Set<string> | null = null
321
+ await materializeSidecarToken({
322
+ config: CONFIG_WITH_ADMIN_REFS,
323
+ env: {},
324
+ resolveViaBroker: makeBatchResolver(
325
+ { 'vault:voice/sidecar-token': 'deadbeefcafe' },
326
+ (refs) => { seen = refs },
327
+ ),
328
+ })
329
+ expect(seen).not.toBeNull()
330
+ expect([...seen!]).toEqual(['voice/sidecar-token'])
331
+ })
332
+ })
@@ -10,10 +10,18 @@
10
10
  */
11
11
 
12
12
  import { describe, it, expect } from 'bun:test'
13
+ import { readFileSync } from 'node:fs'
14
+ import { fileURLToPath } from 'node:url'
13
15
  import {
14
16
  transcribeViaWhisper,
15
17
  OPENAI_WHISPER_MAX_BYTES,
16
18
  } from '../voice-transcribe.js'
19
+ import {
20
+ materializeVoiceKey,
21
+ DEFAULT_VOICE_API_KEY_REF,
22
+ } from '../../src/telegram/materialize-voice-key.js'
23
+ import type { resolveVaultReferencesViaBroker } from '../../src/vault/resolver.js'
24
+ import type { SwitchroomConfig } from '../../src/config/schema.js'
17
25
 
18
26
  const SMALL_AUDIO = new Uint8Array([0x4f, 0x67, 0x67, 0x53]) // OggS magic; not real audio
19
27
 
@@ -194,3 +202,183 @@ describe('transcribeViaWhisper — error paths', () => {
194
202
  expect(r).toMatchObject({ ok: false, reason: 'timeout' })
195
203
  })
196
204
  })
205
+
206
+ // ── PR-A: STT key resolves via the vault broker, not a plaintext file ──
207
+
208
+ const EMPTY_CONFIG = {} as unknown as SwitchroomConfig
209
+
210
+ /** A fake broker resolver that returns the value mapped to bot_token (the
211
+ * key materializeVoiceKey narrows the reference onto). */
212
+ function makeBrokerResolver(
213
+ resolvedRefs: Record<string, string>,
214
+ ): typeof resolveVaultReferencesViaBroker {
215
+ return (async (config: SwitchroomConfig) => {
216
+ const ref = config.telegram?.bot_token
217
+ if (ref && resolvedRefs[ref] !== undefined) {
218
+ return {
219
+ ok: true,
220
+ config: {
221
+ ...config,
222
+ telegram: { ...config.telegram, bot_token: resolvedRefs[ref] },
223
+ },
224
+ }
225
+ }
226
+ return { ok: false, reason: 'not_found' as const }
227
+ }) as unknown as typeof resolveVaultReferencesViaBroker
228
+ }
229
+
230
+ describe('materializeVoiceKey — vault resolution', () => {
231
+ it('resolves the configured vault ref via the broker', async () => {
232
+ const key = await materializeVoiceKey({
233
+ apiKeyRef: 'vault:openai/api-key',
234
+ config: EMPTY_CONFIG,
235
+ env: {},
236
+ resolveViaBroker: makeBrokerResolver({
237
+ 'vault:openai/api-key': 'sk-' + 'resolved' + '-key',
238
+ }),
239
+ })
240
+ expect(key).toBe('sk-' + 'resolved' + '-key')
241
+ })
242
+
243
+ it('defaults to vault:openai/api-key when no ref is configured', async () => {
244
+ expect(DEFAULT_VOICE_API_KEY_REF).toBe('vault:openai/api-key')
245
+ const key = await materializeVoiceKey({
246
+ apiKeyRef: undefined,
247
+ config: EMPTY_CONFIG,
248
+ env: {},
249
+ resolveViaBroker: makeBrokerResolver({
250
+ 'vault:openai/api-key': 'sk-' + 'default' + '-key',
251
+ }),
252
+ })
253
+ expect(key).toBe('sk-' + 'default' + '-key')
254
+ })
255
+
256
+ it('returns a literal (non-vault) key directly without touching the broker', async () => {
257
+ let brokerCalled = false
258
+ const key = await materializeVoiceKey({
259
+ apiKeyRef: 'sk-' + 'literal',
260
+ config: EMPTY_CONFIG,
261
+ env: {},
262
+ resolveViaBroker: (async () => {
263
+ brokerCalled = true
264
+ return { ok: false, reason: 'not_found' as const }
265
+ }) as unknown as typeof resolveVaultReferencesViaBroker,
266
+ })
267
+ expect(key).toBe('sk-' + 'literal')
268
+ expect(brokerCalled).toBe(false)
269
+ })
270
+
271
+ it('returns null (graceful fallback, no throw) when the key is unresolvable', async () => {
272
+ const key = await materializeVoiceKey(
273
+ {
274
+ apiKeyRef: 'vault:openai/api-key',
275
+ config: EMPTY_CONFIG,
276
+ env: {}, // no SWITCHROOM_VAULT_PASSPHRASE → no direct-decrypt fallback
277
+ resolveViaBroker: makeBrokerResolver({}), // broker resolves nothing
278
+ },
279
+ () => {}, // swallow the diagnostic log
280
+ )
281
+ expect(key).toBeNull()
282
+ })
283
+
284
+ // ── Regression: single-key isolation (voice token bug) ──
285
+ //
286
+ // The batch resolver (resolveVaultReferencesViaBroker) fails the WHOLE
287
+ // resolution unless EVERY vault: ref it finds resolves. Admin-only refs
288
+ // like litellm.admin_key / google_workspace.google_client_secret are
289
+ // (correctly) DENIED to a normal agent, so if the materializer passes the
290
+ // full config those denials sink the resolution even though openai/api-key
291
+ // itself is granted. The fix passes a MINIMAL config carrying only the one
292
+ // ref. These tests would fail against the old `...config` spread.
293
+
294
+ /** Regex of every vault: ref the resolver sees; denies if it sees any admin-only key. */
295
+ function collectRefs(v: unknown, out: Set<string>): void {
296
+ if (typeof v === 'string' && v.startsWith('vault:')) out.add(v.slice('vault:'.length).split('#')[0])
297
+ else if (Array.isArray(v)) for (const i of v) collectRefs(i, out)
298
+ else if (v !== null && typeof v === 'object') for (const val of Object.values(v as Record<string, unknown>)) collectRefs(val, out)
299
+ }
300
+
301
+ /**
302
+ * A resolver stub with the SAME batch semantics as the real one: it fails
303
+ * (denied) unless every ref it collects is in `granted`. Mirrors how a real
304
+ * broker denies admin-only keys to a normal agent.
305
+ */
306
+ function makeBatchResolver(
307
+ granted: Record<string, string>,
308
+ onCall?: (refs: Set<string>) => void,
309
+ ): typeof resolveVaultReferencesViaBroker {
310
+ return (async (config: SwitchroomConfig) => {
311
+ const refs = new Set<string>()
312
+ collectRefs(config, refs)
313
+ onCall?.(refs)
314
+ for (const r of refs) {
315
+ if (granted[`vault:${r}`] === undefined) {
316
+ return { ok: false, reason: 'denied' as const }
317
+ }
318
+ }
319
+ const resolvedToken = granted[config.telegram?.bot_token ?? '']
320
+ return {
321
+ ok: true,
322
+ config: { ...config, telegram: { ...config.telegram, bot_token: resolvedToken } },
323
+ }
324
+ }) as unknown as typeof resolveVaultReferencesViaBroker
325
+ }
326
+
327
+ const CONFIG_WITH_ADMIN_REFS = {
328
+ telegram: { bot_token: 'vault:tg/bot' },
329
+ litellm: { admin_key: 'vault:litellm/master-key' },
330
+ google_workspace: { google_client_secret: 'vault:google/client-secret' },
331
+ agents: { klanker: { some_key: 'vault:per-agent/secret' } },
332
+ vault: { path: '~/.switchroom/vault.enc' },
333
+ } as unknown as SwitchroomConfig
334
+
335
+ it('resolves the STT key even when the config carries admin-only vault refs the broker DENIES', async () => {
336
+ const key = await materializeVoiceKey({
337
+ apiKeyRef: 'vault:openai/api-key',
338
+ config: CONFIG_WITH_ADMIN_REFS,
339
+ env: {},
340
+ // ONLY openai/api-key is granted; litellm/google/per-agent all DENY.
341
+ resolveViaBroker: makeBatchResolver({ 'vault:openai/api-key': 'sk-granted-key' }),
342
+ })
343
+ expect(key).toBe('sk-granted-key')
344
+ })
345
+
346
+ it('invokes the resolver with a config containing exactly ONE vault ref (the STT key)', async () => {
347
+ let seen: Set<string> | null = null
348
+ await materializeVoiceKey({
349
+ apiKeyRef: 'vault:openai/api-key',
350
+ config: CONFIG_WITH_ADMIN_REFS,
351
+ env: {},
352
+ resolveViaBroker: makeBatchResolver(
353
+ { 'vault:openai/api-key': 'sk-granted-key' },
354
+ (refs) => { seen = refs },
355
+ ),
356
+ })
357
+ expect(seen).not.toBeNull()
358
+ expect([...seen!]).toEqual(['openai/api-key'])
359
+ })
360
+ })
361
+
362
+ describe('voice-in — no plaintext key file is read', () => {
363
+ it('the gateway never reads ~/.switchroom/openai-api-key as a path', () => {
364
+ const gatewayPath = fileURLToPath(
365
+ new URL('../gateway/gateway.ts', import.meta.url),
366
+ )
367
+ const src = readFileSync(gatewayPath, 'utf8')
368
+ // The plaintext file basename must not appear as a path segment in
369
+ // any source string (it lived in a join(..., 'openai-api-key')).
370
+ expect(src).not.toContain("'openai-api-key'")
371
+ expect(src).not.toContain('"openai-api-key"')
372
+ })
373
+
374
+ it('the voice-key materializer reads no openai-api-key path', () => {
375
+ const helperPath = fileURLToPath(
376
+ new URL('../../src/telegram/materialize-voice-key.ts', import.meta.url),
377
+ )
378
+ const src = readFileSync(helperPath, 'utf8')
379
+ // The basename appears only inside a code-comment sentence (prose),
380
+ // never quoted as a path literal that fs would read.
381
+ expect(src).not.toContain("'openai-api-key'")
382
+ expect(src).not.toContain('"openai-api-key"')
383
+ })
384
+ })
@@ -115,7 +115,7 @@ describe('renderWorkerActivity', () => {
115
115
  expect(out).toContain('─────')
116
116
  expect(out).toContain('✅ _PR #21 opened_')
117
117
  // latestSummary is the RESULT on the finished path, never also a step.
118
- expect(out).not.toContain('_✓ PR #21 opened_')
118
+ expect(out).not.toContain('~~_✓ PR #21 opened_~~')
119
119
  })
120
120
 
121
121
  it('renders a failed terminal recap', () => {
@@ -155,8 +155,8 @@ describe('renderWorkerActivity', () => {
155
155
  narrativeLines: ['read the brief', 'scanned vendor A', 'scanned vendor B'],
156
156
  }),
157
157
  )
158
- expect(out).toContain('_✓ read the brief_')
159
- expect(out).toContain('_✓ scanned vendor A_')
158
+ expect(out).toContain('~~_✓ read the brief_~~')
159
+ expect(out).toContain('~~_✓ scanned vendor A_~~')
160
160
  expect(out).toContain('**→ scanned vendor B**')
161
161
  // The single-line latestSummary fallback is NOT used when a block is present.
162
162
  expect(out).not.toContain('newest only')
@@ -171,7 +171,7 @@ describe('renderWorkerActivity', () => {
171
171
 
172
172
  it('drops blank narrative lines from the feed', () => {
173
173
  const out = renderWorkerActivity(view({ narrativeLines: ['kept', ' ', 'also kept'] }))
174
- expect(out).toContain('_✓ kept_')
174
+ expect(out).toContain('~~_✓ kept_~~')
175
175
  expect(out).toContain('**→ also kept**')
176
176
  expect(stepCount(out)).toBe(2)
177
177
  })
@@ -183,7 +183,7 @@ describe('renderWorkerActivity', () => {
183
183
  expect(out).toContain(`_✓ +${total - STATUS_ROLLING_LINES} earlier…_`)
184
184
  expect(out).not.toContain('step 1<')
185
185
  const firstVisible = total - STATUS_ROLLING_LINES + 1
186
- expect(out).toContain(`_✓ step ${firstVisible}_`)
186
+ expect(out).toContain(`~~_✓ step ${firstVisible}_~~`)
187
187
  expect(out).toContain(`**→ step ${total}**`)
188
188
  // STATUS_ROLLING_LINES visible step lines (the overflow header isn't a step).
189
189
  expect(out.match(/step \d/g) ?? []).toHaveLength(STATUS_ROLLING_LINES)
@@ -191,7 +191,7 @@ describe('renderWorkerActivity', () => {
191
191
 
192
192
  it('strips the CONTENT Markdown markup (the card keeps its own ** / _ wrappers, #2669)', () => {
193
193
  // Post-#2669 the card itself is rich markdown — the header is **Worker**
194
- // and steps are _✓ …_ / **→ …**. The per-line pipeline still runs
194
+ // and steps are ~~_✓ …_~~ / **→ …**. The per-line pipeline still runs
195
195
  // stripMarkdown over the user-supplied CONTENT so a model-authored
196
196
  // `**full**` / `` `git push` `` doesn't inject extra emphasis, but the
197
197
  // card's own wrapper markup is expected.
@@ -276,6 +276,24 @@ describe('createWorkerActivityFeed', () => {
276
276
  expect(feed.has('w1')).toBe(true)
277
277
  })
278
278
 
279
+ it('messageIdOf exposes the posted message id (for status-pin) and is null before paint / after finish', async () => {
280
+ const bot = makeFakeBot()
281
+ let clock = 0
282
+ const feed = createWorkerActivityFeed({ bot, now: () => clock, firstPaintMinMs: 8000 })
283
+ // Before paint: no message, no id to pin.
284
+ clock = 5000
285
+ await feed.update('w1', 'chat', view({ elapsedMs: 5000 }))
286
+ expect(feed.messageIdOf('w1')).toBeNull()
287
+ // After paint: id is the posted message the gateway pins.
288
+ clock = 9000
289
+ await feed.update('w1', 'chat', view({ elapsedMs: 9000 }))
290
+ // The fake mints message ids starting at 1000; first paint → 1000.
291
+ expect(feed.messageIdOf('w1')).toBe(1000)
292
+ // After finish: handle is dropped → null (gateway unpins independently).
293
+ await feed.finish('w1', view({ state: 'done' }))
294
+ expect(feed.messageIdOf('w1')).toBeNull()
295
+ })
296
+
279
297
  it('dedups an identical body (no edit)', async () => {
280
298
  const bot = makeFakeBot()
281
299
  let clock = 10_000
@@ -406,8 +424,8 @@ describe('createWorkerActivityFeed', () => {
406
424
  await feed.update('w1', 'chat', view({ toolCount: 3, latestSummary: 'scanned vendor B' }))
407
425
 
408
426
  const last = bot.edits.at(-1)!
409
- expect(last.text).toContain('_✓ read the brief_')
410
- expect(last.text).toContain('_✓ scanned vendor A_')
427
+ expect(last.text).toContain('~~_✓ read the brief_~~')
428
+ expect(last.text).toContain('~~_✓ scanned vendor A_~~')
411
429
  expect(last.text).toContain('**→ scanned vendor B**')
412
430
  expect(last.text.match(/[✓→]/g) ?? []).toHaveLength(3)
413
431
  })
@@ -462,8 +480,8 @@ describe('createWorkerActivityFeed', () => {
462
480
  clock = 13_000
463
481
  await feed.update('w1', 'chat', view({ toolCount: 3, latestSummary: 'line C' }))
464
482
  const last = bot.edits.at(-1)!
465
- expect(last.text).toContain('_✓ line A_')
466
- expect(last.text).toContain('_✓ line B_')
483
+ expect(last.text).toContain('~~_✓ line A_~~')
484
+ expect(last.text).toContain('~~_✓ line B_~~')
467
485
  expect(last.text).toContain('**→ line C**')
468
486
  })
469
487