switchroom 0.12.13 → 0.12.15

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.
@@ -123,3 +123,120 @@ export function buildVaultGrantDeniedInbound(opts: {
123
123
  },
124
124
  }
125
125
  }
126
+
127
+ /** Subset of PendingVaultRequestSave the save-outcome builders need.
128
+ * The `vault_request_save` flow has no scope/ttl (it stores a value,
129
+ * it doesn't mint a scoped grant). */
130
+ export interface VaultSaveInboundContext {
131
+ agent: string
132
+ key: string
133
+ /** Telegram chat the save card lived in — keeps the synthesized
134
+ * resume-turn associated with the originating conversation. */
135
+ chat_id: string
136
+ }
137
+
138
+ /**
139
+ * Synthetic inbound for a successful operator Save tap on a
140
+ * `vault_request_save` card. Symmetric with
141
+ * `buildVaultGrantApprovedInbound`: the `vault_request_save` tool
142
+ * returns "waiting for operator" and the agent ends its turn, so
143
+ * without this inbound the secret is stored but the agent is never
144
+ * told and never resumes (the exact silence bug this fixes — the
145
+ * `vrs:` handler had no wake-up despite a comment claiming one).
146
+ */
147
+ export function buildVaultSaveCompletedInbound(opts: {
148
+ ctx: VaultSaveInboundContext
149
+ stageId: string
150
+ operatorId: string
151
+ nowMs?: number
152
+ }): InboundMessage {
153
+ const ts = opts.nowMs ?? Date.now()
154
+ return {
155
+ type: 'inbound',
156
+ chatId: opts.ctx.chat_id,
157
+ messageId: ts,
158
+ user: 'vault-broker',
159
+ userId: 0,
160
+ ts,
161
+ text:
162
+ `✅ Operator saved your secret as \`vault:${opts.ctx.key}\`. ` +
163
+ `Please resume the task that was waiting on it — reference the ` +
164
+ `value via the usual \`vault:${opts.ctx.key}\` path.`,
165
+ meta: {
166
+ source: 'vault_save_completed',
167
+ agent: opts.ctx.agent,
168
+ key: opts.ctx.key,
169
+ stage_id: opts.stageId,
170
+ operator_id: opts.operatorId,
171
+ },
172
+ }
173
+ }
174
+
175
+ /**
176
+ * Synthetic inbound for a Save tap whose vault write FAILED. Steers
177
+ * the model away from assuming the secret exists.
178
+ */
179
+ export function buildVaultSaveFailedInbound(opts: {
180
+ ctx: VaultSaveInboundContext
181
+ stageId: string
182
+ operatorId: string
183
+ reason: string
184
+ nowMs?: number
185
+ }): InboundMessage {
186
+ const ts = opts.nowMs ?? Date.now()
187
+ return {
188
+ type: 'inbound',
189
+ chatId: opts.ctx.chat_id,
190
+ messageId: ts,
191
+ user: 'vault-broker',
192
+ userId: 0,
193
+ ts,
194
+ text:
195
+ `⚠️ The operator tapped Save but the vault write for ` +
196
+ `\`vault:${opts.ctx.key}\` FAILED (${opts.reason}). The secret was ` +
197
+ `NOT stored — do NOT assume \`vault:${opts.ctx.key}\` resolves. ` +
198
+ `Either retry the save (the operator may need to fix the underlying ` +
199
+ `issue first) or pick a fallback for the original task.`,
200
+ meta: {
201
+ source: 'vault_save_failed',
202
+ agent: opts.ctx.agent,
203
+ key: opts.ctx.key,
204
+ stage_id: opts.stageId,
205
+ operator_id: opts.operatorId,
206
+ },
207
+ }
208
+ }
209
+
210
+ /**
211
+ * Synthetic inbound for an operator Discard tap. The secret was never
212
+ * written; steer the model to a fallback rather than silent idle.
213
+ */
214
+ export function buildVaultSaveDiscardedInbound(opts: {
215
+ ctx: VaultSaveInboundContext
216
+ stageId: string
217
+ operatorId: string
218
+ nowMs?: number
219
+ }): InboundMessage {
220
+ const ts = opts.nowMs ?? Date.now()
221
+ return {
222
+ type: 'inbound',
223
+ chatId: opts.ctx.chat_id,
224
+ messageId: ts,
225
+ user: 'vault-broker',
226
+ userId: 0,
227
+ ts,
228
+ text:
229
+ `🚫 Operator discarded your \`vault_request_save\` for ` +
230
+ `\`${opts.ctx.key}\` — the secret was NOT stored and ` +
231
+ `\`vault:${opts.ctx.key}\` will not resolve. Pick a fallback for ` +
232
+ `the original task (ask the user, try another approach, or skip ` +
233
+ `the feature). Do NOT re-request the save without asking the user.`,
234
+ meta: {
235
+ source: 'vault_save_discarded',
236
+ agent: opts.ctx.agent,
237
+ key: opts.ctx.key,
238
+ stage_id: opts.stageId,
239
+ operator_id: opts.operatorId,
240
+ },
241
+ }
242
+ }
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  import { describe, it, expect } from 'vitest'
10
- import { createPendingInboundBuffer, DEFAULT_PENDING_INBOUND_CAP } from '../gateway/pending-inbound-buffer.js'
10
+ import { createPendingInboundBuffer, redeliverBufferedInbound, DEFAULT_PENDING_INBOUND_CAP } from '../gateway/pending-inbound-buffer.js'
11
11
  import type { InboundMessage } from '../gateway/ipc-protocol.js'
12
12
 
13
13
  function inbound(source: string, ts = Date.now()): InboundMessage {
@@ -130,3 +130,73 @@ describe('pending-inbound-buffer', () => {
130
130
  expect(buf.totalDepth()).toBe(1)
131
131
  })
132
132
  })
133
+
134
+ describe('redeliverBufferedInbound — wedge-clear self-heal (fleet-update incident 2026-05-19)', () => {
135
+ it('delivers every buffered message and empties the buffer when send succeeds', () => {
136
+ const buf = createPendingInboundBuffer({ log: () => {} })
137
+ buf.push('klanker', inbound('user', 1))
138
+ buf.push('klanker', inbound('user', 2))
139
+ const seen: number[] = []
140
+ const r = redeliverBufferedInbound(buf, 'klanker', (m) => {
141
+ seen.push(m.messageId as number)
142
+ return true
143
+ })
144
+ expect(r).toEqual({ drained: 2, redelivered: 2, rebuffered: 0 })
145
+ expect(seen).toEqual([1, 2]) // FIFO preserved
146
+ expect(buf.depth('klanker')).toBe(0)
147
+ })
148
+
149
+ it('re-buffers (loses nothing) when the bridge is still offline — send returns false', () => {
150
+ const buf = createPendingInboundBuffer({ log: () => {} })
151
+ buf.push('klanker', inbound('user', 1))
152
+ buf.push('klanker', inbound('cron', 2))
153
+ const r = redeliverBufferedInbound(buf, 'klanker', () => false)
154
+ expect(r).toEqual({ drained: 2, redelivered: 0, rebuffered: 2 })
155
+ expect(buf.depth('klanker')).toBe(2) // still there, nothing lost
156
+ expect(buf.drain('klanker').map((m) => m.meta?.source)).toEqual(['user', 'cron'])
157
+ })
158
+
159
+ it('treats a throwing send as not-delivered and re-buffers', () => {
160
+ const buf = createPendingInboundBuffer({ log: () => {} })
161
+ buf.push('klanker', inbound('user', 1))
162
+ const r = redeliverBufferedInbound(buf, 'klanker', () => {
163
+ throw new Error('bridge write failed')
164
+ })
165
+ expect(r).toEqual({ drained: 1, redelivered: 0, rebuffered: 1 })
166
+ expect(buf.depth('klanker')).toBe(1)
167
+ })
168
+
169
+ it('mixed: delivers what it can, re-buffers only the misses', () => {
170
+ const buf = createPendingInboundBuffer({ log: () => {} })
171
+ buf.push('klanker', inbound('a', 1))
172
+ buf.push('klanker', inbound('b', 2))
173
+ buf.push('klanker', inbound('c', 3))
174
+ let n = 0
175
+ const r = redeliverBufferedInbound(buf, 'klanker', () => {
176
+ n++
177
+ return n !== 2 // 2nd send fails
178
+ })
179
+ expect(r).toEqual({ drained: 3, redelivered: 2, rebuffered: 1 })
180
+ expect(buf.drain('klanker').map((m) => m.meta?.source)).toEqual(['b'])
181
+ })
182
+
183
+ it('is a no-op on an empty buffer (no send calls)', () => {
184
+ const buf = createPendingInboundBuffer({ log: () => {} })
185
+ let calls = 0
186
+ const r = redeliverBufferedInbound(buf, 'klanker', () => {
187
+ calls++
188
+ return true
189
+ })
190
+ expect(r).toEqual({ drained: 0, redelivered: 0, rebuffered: 0 })
191
+ expect(calls).toBe(0)
192
+ })
193
+
194
+ it('only touches the named agent', () => {
195
+ const buf = createPendingInboundBuffer({ log: () => {} })
196
+ buf.push('klanker', inbound('user', 1))
197
+ buf.push('clerk', inbound('user', 2))
198
+ redeliverBufferedInbound(buf, 'klanker', () => true)
199
+ expect(buf.depth('klanker')).toBe(0)
200
+ expect(buf.depth('clerk')).toBe(1) // untouched
201
+ })
202
+ })
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Pin the per-agent permission-verdict buffer (PR-3 of the callback→
3
+ * model-continuation series). A tool/skill/MCP permission request
4
+ * suspends the claude turn inside the MCP permission call until the
5
+ * operator's Approve/Deny verdict is relayed back. The verdict sites
6
+ * previously `broadcast(...)` fire-and-forget, so a verdict produced
7
+ * while the bridge was mid-reconnect was dropped and the model stayed
8
+ * wedged forever (user tapped, nothing happened, silence). This buffer
9
+ * — the permission analog of pending-inbound-buffer — holds the missed
10
+ * verdict and is drained on the next bridge register.
11
+ */
12
+
13
+ import { describe, it, expect } from 'vitest'
14
+ import {
15
+ createPendingPermissionBuffer,
16
+ DEFAULT_PENDING_PERMISSION_CAP,
17
+ } from '../gateway/pending-permission-decisions.js'
18
+ import type { PermissionEvent } from '../gateway/ipc-protocol.js'
19
+
20
+ function verdict(
21
+ requestId: string,
22
+ behavior: 'allow' | 'deny' = 'allow',
23
+ rule?: string,
24
+ ): PermissionEvent {
25
+ return { type: 'permission', requestId, behavior, ...(rule ? { rule } : {}) }
26
+ }
27
+
28
+ describe('pending-permission-decisions', () => {
29
+ it('push + drain — FIFO order per agent, idempotent drain', () => {
30
+ const buf = createPendingPermissionBuffer({ log: () => {} })
31
+ buf.push('a', verdict('r1', 'allow'))
32
+ buf.push('a', verdict('r2', 'deny'))
33
+ buf.push('b', verdict('r3', 'allow'))
34
+ const a = buf.drain('a')
35
+ expect(a.map((e) => e.requestId)).toEqual(['r1', 'r2'])
36
+ expect(a[1]!.behavior).toBe('deny')
37
+ expect(buf.drain('a')).toEqual([]) // idempotent
38
+ expect(buf.drain('b').map((e) => e.requestId)).toEqual(['r3'])
39
+ expect(buf.totalDepth()).toBe(0)
40
+ })
41
+
42
+ it('preserves the always-allow rule field through buffering', () => {
43
+ const buf = createPendingPermissionBuffer({ log: () => {} })
44
+ buf.push('a', verdict('r1', 'allow', 'Bash(ls:*)'))
45
+ const [ev] = buf.drain('a')
46
+ expect(ev!.rule).toBe('Bash(ls:*)')
47
+ expect(ev!.behavior).toBe('allow')
48
+ })
49
+
50
+ it('per-agent cap drops the OLDEST verdict and reports eviction', () => {
51
+ const buf = createPendingPermissionBuffer({ capPerAgent: 3, log: () => {} })
52
+ expect(buf.push('a', verdict('r1'))).toBe(true)
53
+ expect(buf.push('a', verdict('r2'))).toBe(true)
54
+ expect(buf.push('a', verdict('r3'))).toBe(true)
55
+ expect(buf.push('a', verdict('r4'))).toBe(false) // evicted oldest
56
+ const drained = buf.drain('a')
57
+ expect(drained.map((e) => e.requestId)).toEqual(['r2', 'r3', 'r4'])
58
+ })
59
+
60
+ it('depth/totalDepth track buffered verdicts', () => {
61
+ const buf = createPendingPermissionBuffer({ log: () => {} })
62
+ expect(buf.depth('a')).toBe(0)
63
+ buf.push('a', verdict('r1'))
64
+ buf.push('a', verdict('r2'))
65
+ buf.push('b', verdict('r3'))
66
+ expect(buf.depth('a')).toBe(2)
67
+ expect(buf.totalDepth()).toBe(3)
68
+ })
69
+
70
+ it('exports a sane default cap', () => {
71
+ expect(DEFAULT_PENDING_PERMISSION_CAP).toBeGreaterThanOrEqual(8)
72
+ })
73
+ })
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Pin the InboundMessage shapes the gateway synthesizes when the
3
+ * operator taps Save / Discard (or the write fails) on a
4
+ * `vault_request_save` card. Before this, `handleVaultRequestSaveCallback`
5
+ * edited the card on every terminal outcome but NEVER woke the agent
6
+ * (no sendToAgent / no pendingInboundBuffer push) — so the secret was
7
+ * stored/discarded but the agent that called `vault_request_save`
8
+ * stayed silently idle. These builders + the gateway wiring close that
9
+ * gap symmetrically with the vra: approve/deny path (#1052/#1150/#1156).
10
+ *
11
+ * The wire shape is load-bearing; a dropped `meta.source` / field would
12
+ * silently regress the wake-up. Cheap regression guard.
13
+ */
14
+
15
+ import { describe, it, expect } from 'vitest'
16
+ import {
17
+ buildVaultSaveCompletedInbound,
18
+ buildVaultSaveFailedInbound,
19
+ buildVaultSaveDiscardedInbound,
20
+ type VaultSaveInboundContext,
21
+ } from '../gateway/vault-grant-inbound-builders.js'
22
+
23
+ const FIXED_NOW = 1_700_000_000_000
24
+
25
+ const CTX: VaultSaveInboundContext = {
26
+ agent: 'gymbro',
27
+ key: 'garmin/credentials',
28
+ chat_id: '12345',
29
+ }
30
+
31
+ describe('buildVaultSaveCompletedInbound', () => {
32
+ it('emits the canonical vault-broker envelope', () => {
33
+ const msg = buildVaultSaveCompletedInbound({
34
+ ctx: CTX,
35
+ stageId: 'stage-001',
36
+ operatorId: '999',
37
+ nowMs: FIXED_NOW,
38
+ })
39
+ expect(msg.type).toBe('inbound')
40
+ expect(msg.chatId).toBe('12345')
41
+ expect(msg.user).toBe('vault-broker')
42
+ expect(msg.userId).toBe(0)
43
+ expect(msg.ts).toBe(FIXED_NOW)
44
+ expect(msg.messageId).toBe(FIXED_NOW)
45
+ })
46
+
47
+ it('carries the load-bearing meta + an actionable resume instruction', () => {
48
+ const msg = buildVaultSaveCompletedInbound({
49
+ ctx: CTX,
50
+ stageId: 'stage-001',
51
+ operatorId: '999',
52
+ nowMs: FIXED_NOW,
53
+ })
54
+ expect(msg.meta).toEqual({
55
+ source: 'vault_save_completed',
56
+ agent: 'gymbro',
57
+ key: 'garmin/credentials',
58
+ stage_id: 'stage-001',
59
+ operator_id: '999',
60
+ })
61
+ expect(msg.text).toContain('vault:garmin/credentials')
62
+ expect(msg.text.toLowerCase()).toContain('resume')
63
+ })
64
+ })
65
+
66
+ describe('buildVaultSaveFailedInbound', () => {
67
+ it('flags NOT stored, carries the reason + failed source', () => {
68
+ const msg = buildVaultSaveFailedInbound({
69
+ ctx: CTX,
70
+ stageId: 'stage-002',
71
+ operatorId: '999',
72
+ reason: 'VAULT-BROKER-DENIED: no operator attestation',
73
+ nowMs: FIXED_NOW,
74
+ })
75
+ expect(msg.meta?.source).toBe('vault_save_failed')
76
+ expect(msg.meta?.key).toBe('garmin/credentials')
77
+ expect(msg.text).toContain('FAILED')
78
+ expect(msg.text).toContain('VAULT-BROKER-DENIED')
79
+ expect(msg.text).toContain('NOT stored')
80
+ })
81
+ })
82
+
83
+ describe('buildVaultSaveDiscardedInbound', () => {
84
+ it('flags NOT stored + steers to a fallback, discarded source', () => {
85
+ const msg = buildVaultSaveDiscardedInbound({
86
+ ctx: CTX,
87
+ stageId: 'stage-003',
88
+ operatorId: '999',
89
+ nowMs: FIXED_NOW,
90
+ })
91
+ expect(msg.meta?.source).toBe('vault_save_discarded')
92
+ expect(msg.meta?.agent).toBe('gymbro')
93
+ expect(msg.text).toContain('discarded')
94
+ expect(msg.text).toContain('NOT stored')
95
+ })
96
+ })