switchroom 0.14.45 → 0.14.47

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.
@@ -87,6 +87,35 @@ describe('naturalAction — MCP tools', () => {
87
87
  'list files (Google Workspace)',
88
88
  )
89
89
  })
90
+
91
+ // Clarity fix: REST-wrapper MCP tools (brevo/meta/postiz via rest-server.mjs)
92
+ // take a `path` — surface it so "post (Brevo)" becomes "POST /smtp/email
93
+ // (Brevo)" and the operator can see WHICH endpoint is being written.
94
+ test('REST-wrapper write names the endpoint with an uppercased HTTP verb', () => {
95
+ expect(
96
+ naturalAction('mcp__brevo__post', JSON.stringify({ path: '/smtp/email', body: { to: 'x' } })),
97
+ ).toBe('POST /smtp/email (Brevo)')
98
+ expect(
99
+ naturalAction('mcp__brevo__put', JSON.stringify({ path: '/contacts/123', body: {} })),
100
+ ).toBe('PUT /contacts/123 (Brevo)')
101
+ })
102
+
103
+ test('REST-wrapper read surfaces the path too', () => {
104
+ expect(
105
+ naturalAction('mcp__brevo__get', JSON.stringify({ path: '/contacts', query: { limit: 10 } })),
106
+ ).toBe('GET /contacts (Brevo)')
107
+ })
108
+
109
+ test('falls back to the plain verb phrase when there is no resource key', () => {
110
+ // No path → today's behavior, unchanged (defensive for unknown shapes).
111
+ expect(naturalAction('mcp__brevo__post', undefined)).toBe('post (Brevo)')
112
+ expect(naturalAction('mcp__brevo__post', JSON.stringify({ foo: 1 }))).toBe('post (Brevo)')
113
+ })
114
+
115
+ test('internal REST-ish tool is NOT endpoint-enriched (stays a bare verb)', () => {
116
+ // hostd is internal → no "(Server)" tag, no path enrichment.
117
+ expect(naturalAction('mcp__hostd__do_thing', JSON.stringify({ path: '/x' }))).toBe('do thing')
118
+ })
90
119
  })
91
120
 
92
121
  describe('formatPermissionCardBody', () => {
@@ -156,6 +185,56 @@ describe('formatPermissionCardBody', () => {
156
185
  })
157
186
  expect(body).toContain('why: <i>first second paragraph</i>')
158
187
  })
188
+
189
+ // Clarity fix: the card gains a third "↳" line summarizing the REST
190
+ // payload so the operator can see WHAT is being written, not just the
191
+ // endpoint. Values are redaction-passed + truncated; nested objects show
192
+ // as a bare key name.
193
+ test('REST write card: endpoint in the title + a payload summary line', () => {
194
+ const body = formatPermissionCardBody({
195
+ toolName: 'mcp__brevo__post',
196
+ inputPreview: JSON.stringify({
197
+ path: '/smtp/email',
198
+ body: { subject: 'Priority access', templateId: 12, to: [{ email: 'lisa@example.com' }] },
199
+ }),
200
+ description: 'HIGH RISK: write to the brevo API (POST).',
201
+ agentName: 'marko',
202
+ })
203
+ const lines = body.split('\n')
204
+ expect(lines[0]).toBe('🔐 <b>Marko</b> wants to POST /smtp/email (Brevo)')
205
+ expect(lines[1]).toBe('why: <i>HIGH RISK: write to the brevo API (POST).</i>')
206
+ // Third line: scalar keys show value; the nested `to` array shows key-only.
207
+ expect(lines[2]).toContain('↳')
208
+ expect(lines[2]).toContain('subject: Priority access')
209
+ expect(lines[2]).toContain('templateId: 12')
210
+ expect(lines[2]).toContain('to') // key-only, not the email object dumped
211
+ expect(lines[2]).not.toContain('lisa@example.com')
212
+ })
213
+
214
+ test('no payload → no third line (DM / non-REST cards unchanged)', () => {
215
+ const body = formatPermissionCardBody({
216
+ toolName: 'Edit',
217
+ inputPreview: JSON.stringify({ file_path: '/a/b.md' }),
218
+ description: 'edit it',
219
+ agentName: 'clerk',
220
+ })
221
+ expect(body.split('\n')).toHaveLength(2)
222
+ expect(body).not.toContain('↳')
223
+ })
224
+
225
+ test('redaction is load-bearing: a token in the payload is masked, never shown', () => {
226
+ // Build the fake token at runtime so the source file never holds a
227
+ // contiguous token literal (repo push-protection rule).
228
+ const fakeToken = 'sk-ant-' + 'api03-' + 'A'.repeat(48)
229
+ const body = formatPermissionCardBody({
230
+ toolName: 'mcp__brevo__post',
231
+ inputPreview: JSON.stringify({ path: '/contacts', body: { apiKey: fakeToken, name: 'Lisa' } }),
232
+ description: 'create a contact',
233
+ agentName: 'marko',
234
+ })
235
+ expect(body).not.toContain(fakeToken)
236
+ expect(body).toContain('name: Lisa') // benign value still surfaces
237
+ })
159
238
  })
160
239
 
161
240
  describe('describeGrant — phrased from the chosen scope', () => {
@@ -16,7 +16,11 @@ import { describe, it, expect } from 'vitest'
16
16
  import {
17
17
  buildVaultGrantApprovedInbound,
18
18
  buildVaultGrantDeniedInbound,
19
+ buildVaultSaveCompletedInbound,
20
+ buildVaultSaveFailedInbound,
21
+ buildVaultSaveDiscardedInbound,
19
22
  type VaultGrantInboundContext,
23
+ type VaultSaveInboundContext,
20
24
  } from '../gateway/vault-grant-inbound-builders.js'
21
25
 
22
26
  const FIXED_NOW = 1_700_000_000_000
@@ -224,3 +228,83 @@ describe('approve vs deny shape invariants', () => {
224
228
  expect(String(deny.meta?.source)).toMatch(/^vault_grant_denied$/)
225
229
  })
226
230
  })
231
+
232
+ // ── Supergroup topic routing (gap #2) ──────────────────────────────────────
233
+ //
234
+ // When the agent requested the credential from inside a forum topic, the
235
+ // grant/save-outcome inbound must carry that topic so the resumed turn's
236
+ // reply lands back in it — not General. The carrier is two-fold and BOTH
237
+ // halves are load-bearing:
238
+ // - top-level `threadId` → the gateway's per-topic busy-key / deliver-
239
+ // until-acked keying (markClaudeBusyForInbound reads it).
240
+ // - `meta.message_thread_id` (stringified) → rendered into the
241
+ // `<channel message_thread_id="…">` XML, which session-tail's
242
+ // parseChannelMeta re-extracts to set currentTurn.sessionThreadId, which
243
+ // the reply tool defaults to. Drop either and the reply mis-routes.
244
+ //
245
+ // DM / non-topic requests must leave BOTH absent (an empty-string or 0
246
+ // thread is a Telegram 400 "message thread not found").
247
+ describe('grant/save outcome topic routing', () => {
248
+ const SAVE_CTX: VaultSaveInboundContext = {
249
+ agent: 'marko',
250
+ key: 'brevo/api-key',
251
+ chat_id: '-1001234567890',
252
+ }
253
+ const THREAD = 4242
254
+
255
+ const grantBuilders = [
256
+ {
257
+ name: 'approved',
258
+ build: (ctx: VaultGrantInboundContext) =>
259
+ buildVaultGrantApprovedInbound({ ctx, grantId: 'vg_x', stageId: 's', operatorId: '1' }),
260
+ },
261
+ {
262
+ name: 'denied',
263
+ build: (ctx: VaultGrantInboundContext) =>
264
+ buildVaultGrantDeniedInbound({ ctx, stageId: 's', operatorId: '1' }),
265
+ },
266
+ ]
267
+ const saveBuilders = [
268
+ {
269
+ name: 'save-completed',
270
+ build: (ctx: VaultSaveInboundContext) =>
271
+ buildVaultSaveCompletedInbound({ ctx, stageId: 's', operatorId: '1' }),
272
+ },
273
+ {
274
+ name: 'save-failed',
275
+ build: (ctx: VaultSaveInboundContext) =>
276
+ buildVaultSaveFailedInbound({ ctx, stageId: 's', operatorId: '1', reason: 'disk full' }),
277
+ },
278
+ {
279
+ name: 'save-discarded',
280
+ build: (ctx: VaultSaveInboundContext) =>
281
+ buildVaultSaveDiscardedInbound({ ctx, stageId: 's', operatorId: '1' }),
282
+ },
283
+ ]
284
+
285
+ for (const { name, build } of grantBuilders) {
286
+ it(`${name}: threadId set → top-level threadId + meta.message_thread_id (stringified)`, () => {
287
+ const msg = build({ ...CTX_READ, threadId: THREAD })
288
+ expect(msg.threadId).toBe(THREAD)
289
+ expect(msg.meta?.message_thread_id).toBe(String(THREAD))
290
+ })
291
+ it(`${name}: threadId absent → both omitted (DM stays thread-less)`, () => {
292
+ const msg = build(CTX_READ)
293
+ expect(msg.threadId).toBeUndefined()
294
+ expect(msg.meta?.message_thread_id).toBeUndefined()
295
+ })
296
+ }
297
+
298
+ for (const { name, build } of saveBuilders) {
299
+ it(`${name}: threadId set → top-level threadId + meta.message_thread_id (stringified)`, () => {
300
+ const msg = build({ ...SAVE_CTX, threadId: THREAD })
301
+ expect(msg.threadId).toBe(THREAD)
302
+ expect(msg.meta?.message_thread_id).toBe(String(THREAD))
303
+ })
304
+ it(`${name}: threadId absent → both omitted (DM stays thread-less)`, () => {
305
+ const msg = build(SAVE_CTX)
306
+ expect(msg.threadId).toBeUndefined()
307
+ expect(msg.meta?.message_thread_id).toBeUndefined()
308
+ })
309
+ }
310
+ })