switchroom 0.12.12 → 0.12.14

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.
@@ -246,9 +246,13 @@ import { createIpcServer, type IpcClient, type IpcServer } from './ipc-server.js
246
246
  import { handleRequestDriveApproval } from './drive-write-approval.js'
247
247
  import { buildDiffPreviewCard } from './diff-preview-card.js'
248
248
  import { createPendingInboundBuffer } from './pending-inbound-buffer.js'
249
+ import { createPendingPermissionBuffer } from './pending-permission-decisions.js'
249
250
  import {
250
251
  buildVaultGrantApprovedInbound,
251
252
  buildVaultGrantDeniedInbound,
253
+ buildVaultSaveCompletedInbound,
254
+ buildVaultSaveFailedInbound,
255
+ buildVaultSaveDiscardedInbound,
252
256
  } from './vault-grant-inbound-builders.js'
253
257
  import { createPollHealthCheck, type PollHealthCheckHandle } from './poll-health.js'
254
258
  import type {
@@ -262,6 +266,7 @@ import type {
262
266
  PtyPartialForward,
263
267
  InboundMessage,
264
268
  InjectInboundMessage,
269
+ PermissionEvent,
265
270
  } from './ipc-protocol.js'
266
271
  import { DebounceBuffer, HourCap, buildReactionInboundMeta, buildReactionInboundText, evaluateTriggerCandidate, isGroupChat, resolveReactionsConfig, truncatePreview, type PendingReaction, type ReactionBatch, type ReactionsResolvedConfig } from './reaction-trigger.js'
267
272
  import { writePidFile, clearPidFile } from './pid-file.js'
@@ -2093,7 +2098,23 @@ const pendingStateReaper = setInterval(() => {
2093
2098
  if (now - v.startedAt > VAULT_INPUT_TTL_MS) pendingVaultOps.delete(k)
2094
2099
  }
2095
2100
  for (const [k, v] of pendingPermissions) {
2096
- if (now - v.startedAt > PERMISSION_TTL_MS) pendingPermissions.delete(k)
2101
+ if (now - v.startedAt > PERMISSION_TTL_MS) {
2102
+ // Don't just drop it: the claude turn is suspended INSIDE the MCP
2103
+ // permission call waiting for a verdict. A silent delete left it
2104
+ // wedged forever when the operator never tapped — permanent
2105
+ // silence, the exact symptom this series fixes. Auto-deny so the
2106
+ // call unblocks; claude then tells the user it couldn't get
2107
+ // permission (or takes a fallback). Routed through
2108
+ // dispatchPermissionVerdict so it's buffered+redelivered too if
2109
+ // the bridge is also offline at sweep time.
2110
+ dispatchPermissionVerdict({ type: 'permission', requestId: k, behavior: 'deny' })
2111
+ process.stderr.write(
2112
+ `telegram gateway: permission TTL expired — auto-deny request=${k} ` +
2113
+ `tool=${v.tool_name} (no operator response in ` +
2114
+ `${Math.round(PERMISSION_TTL_MS / 60000)}m)\n`,
2115
+ )
2116
+ pendingPermissions.delete(k)
2117
+ }
2097
2118
  }
2098
2119
  for (const [k, v] of vaultPassphraseCache) {
2099
2120
  if (now > v.expiresAt) vaultPassphraseCache.delete(k)
@@ -2738,6 +2759,36 @@ silencePoke.startTimer({
2738
2759
  // would mint the grant but silently drop the `vault_grant_approved`
2739
2760
  // inbound, leaving the agent stuck waiting for a manual poke.
2740
2761
  const pendingInboundBuffer = createPendingInboundBuffer()
2762
+ const pendingPermissionBuffer = createPendingPermissionBuffer()
2763
+
2764
+ /**
2765
+ * Deliver a permission verdict to this agent's bridge, buffering on a
2766
+ * miss so it's redelivered when the bridge reconnects. Replaces the
2767
+ * bare `ipcServer.broadcast({type:'permission',...})` at every verdict
2768
+ * site (and the TTL-sweep auto-deny). broadcast was fire-and-forget:
2769
+ * a verdict produced while the bridge was mid-reconnect was dropped
2770
+ * and the claude turn stayed suspended INSIDE the MCP permission call
2771
+ * forever — the user tapped Approve/Deny and nothing happened, no
2772
+ * further output, permanent silence. sendToAgent is registered-keyed
2773
+ * and returns a real delivered bool; on a miss we buffer and
2774
+ * onClientRegistered re-sends so the reconnecting bridge relays the
2775
+ * verdict to the still-suspended call. A late verdict for a dead
2776
+ * request_id is harmless — the bridge relays it and Claude Code
2777
+ * ignores an unknown request_id. (Function declaration so the
2778
+ * pre-2747 TTL sweep can reference it; ipcServer/pendingPermissionBuffer
2779
+ * are resolved at call-time, after module init.)
2780
+ */
2781
+ function dispatchPermissionVerdict(ev: PermissionEvent): void {
2782
+ const selfAgent = process.env.SWITCHROOM_AGENT_NAME ?? ''
2783
+ const delivered = ipcServer.sendToAgent(selfAgent, ev)
2784
+ if (!delivered) {
2785
+ pendingPermissionBuffer.push(selfAgent, ev)
2786
+ process.stderr.write(
2787
+ `telegram gateway: permission verdict buffered (bridge offline) ` +
2788
+ `request=${ev.requestId} behavior=${ev.behavior}\n`,
2789
+ )
2790
+ }
2791
+ }
2741
2792
 
2742
2793
  const ipcServer: IpcServer = createIpcServer({
2743
2794
  socketPath: SOCKET_PATH,
@@ -2765,6 +2816,22 @@ const ipcServer: IpcServer = createIpcServer({
2765
2816
  )
2766
2817
  }
2767
2818
  }
2819
+ // PR-3: drain permission verdicts missed while the bridge was
2820
+ // offline. A claude turn suspended inside the MCP permission call
2821
+ // is unblocked the moment the reconnecting bridge relays the
2822
+ // verdict; without this the verdict (incl. the TTL auto-deny) was
2823
+ // lost and the turn stayed silent forever.
2824
+ const pendingVerdicts = pendingPermissionBuffer.drain(client.agentName)
2825
+ for (const ev of pendingVerdicts) {
2826
+ try {
2827
+ client.send(ev)
2828
+ } catch (err) {
2829
+ process.stderr.write(
2830
+ `telegram gateway: pending-permission drain failed agent=${client.agentName} ` +
2831
+ `request=${ev.requestId} behavior=${ev.behavior}: ${(err as Error).message}\n`,
2832
+ )
2833
+ }
2834
+ }
2768
2835
  }
2769
2836
 
2770
2837
  // If the agent reconnected after a /restart (or any restart), post a boot
@@ -6251,7 +6318,7 @@ async function handleInbound(
6251
6318
  // Forward permission reply to connected bridge
6252
6319
  const behavior = permMatch[1]!.toLowerCase().startsWith('y') ? 'allow' : 'deny'
6253
6320
  const request_id = permMatch[2]!.toLowerCase()
6254
- ipcServer.broadcast({
6321
+ dispatchPermissionVerdict({
6255
6322
  type: 'permission',
6256
6323
  requestId: request_id,
6257
6324
  behavior,
@@ -6981,17 +7048,28 @@ async function handleInbound(
6981
7048
  },
6982
7049
  }
6983
7050
 
6984
- // Try to send to a connected bridge. If no bridge connected, tell the user.
6985
- ipcServer.broadcast(inboundMsg)
6986
- const delivered = ipcServer.clientCount() > 0
6987
-
7051
+ // Deliver to THIS agent's registered bridge, buffering on miss.
7052
+ // broadcast()/clientCount() were the wrong primitives: broadcast is
7053
+ // not registered-keyed (writes to any alive socket incl. an
7054
+ // unregistered pre-handshake one) and yields no delivered signal,
7055
+ // and clientCount() counts unregistered sockets — so a bridge
7056
+ // mid-reconnect made clientCount()>0, the message was broadcast into
7057
+ // a non-registered socket, the "restarting" notice was suppressed,
7058
+ // and the user's message was silently lost. The old "queued either
7059
+ // way" comment was false: broadcast does not queue. sendToAgent is
7060
+ // registered-keyed + returns a real delivered bool; on a miss we
7061
+ // push to pendingInboundBuffer, which onClientRegistered drains on
7062
+ // the next bridge register — so the notice below is now truthful.
7063
+ const selfAgent = process.env.SWITCHROOM_AGENT_NAME ?? ''
7064
+ const delivered = ipcServer.sendToAgent(selfAgent, inboundMsg)
6988
7065
  if (!delivered) {
7066
+ pendingInboundBuffer.push(selfAgent, inboundMsg)
6989
7067
  const threadOpts = messageThreadId != null ? { message_thread_id: messageThreadId } : {}
6990
7068
  // #1075: thread-id-bearing — swallow via robustApiCall so a deleted
6991
- // topic doesn't crash the gateway. Fire-and-forget; the user-visible
6992
- // hint is non-critical (the inbound is queued either way).
7069
+ // topic doesn't crash the gateway. Fire-and-forget; the inbound is
7070
+ // genuinely buffered now, so the hint is accurate, not a guess.
6993
7071
  void swallowingApiCall(
6994
- () => bot.api.sendMessage(chat_id, '⏳ Agent is restarting, please wait…', { ...threadOpts }),
7072
+ () => bot.api.sendMessage(chat_id, '⏳ Agent is restarting your message is queued and will be processed when it reconnects.', { ...threadOpts }),
6995
7073
  {
6996
7074
  chat_id,
6997
7075
  verb: 'agent-restarting-notice',
@@ -8847,7 +8925,7 @@ async function handlePermissionSlash(ctx: Context, behavior: 'allow' | 'deny'):
8847
8925
  return
8848
8926
  }
8849
8927
  // Forward to connected bridges — same IPC the button handler uses.
8850
- ipcServer.broadcast({ type: 'permission', requestId: request_id, behavior })
8928
+ dispatchPermissionVerdict({ type: 'permission', requestId: request_id, behavior })
8851
8929
  pendingPermissions.delete(request_id)
8852
8930
  process.stderr.write(
8853
8931
  `[telegram gateway] slash-${behavior} request_id=${request_id} tool=${details.tool_name} by=${senderId}\n`,
@@ -10039,6 +10117,21 @@ async function handleVaultRequestSaveCallback(ctx: Context, data: string): Promi
10039
10117
  )
10040
10118
  .catch(() => {})
10041
10119
  }
10120
+ // Wake the agent that called vault_request_save — symmetric with
10121
+ // the vra: approve/deny path (#1052/#1150/#1156). Without this the
10122
+ // tool returned "waiting for operator", the turn ended, and a
10123
+ // Discard left the agent silently idle forever.
10124
+ const discardInbound = buildVaultSaveDiscardedInbound({
10125
+ ctx: { agent: pending.agent, key: pending.key, chat_id: pending.chat_id },
10126
+ stageId,
10127
+ operatorId: senderId,
10128
+ })
10129
+ const dDelivered = ipcServer.sendToAgent(pending.agent, discardInbound)
10130
+ process.stderr.write(
10131
+ `telegram gateway: vault_save_discarded injection agent=${pending.agent} ` +
10132
+ `key=${pending.key} stage=${stageId} delivered=${dDelivered}\n`,
10133
+ )
10134
+ if (!dDelivered) pendingInboundBuffer.push(pending.agent, discardInbound)
10042
10135
  return
10043
10136
  }
10044
10137
 
@@ -10143,6 +10236,22 @@ async function handleVaultRequestSaveCallback(ctx: Context, data: string): Promi
10143
10236
  // retry by re-invoking the same MCP tool, but the value will be
10144
10237
  // re-staged with a new ID. Drop the current stage.
10145
10238
  pendingVaultRequestSaves.delete(stageId)
10239
+ // Wake the waiting agent with the failure (symmetric with the
10240
+ // success/discard paths) so it doesn't assume vault:<key> exists.
10241
+ const failReason =
10242
+ (write.output || 'vault write error').split('\n')[0]!.slice(0, 200)
10243
+ const failInbound = buildVaultSaveFailedInbound({
10244
+ ctx: { agent: pending.agent, key: pending.key, chat_id: pending.chat_id },
10245
+ stageId,
10246
+ operatorId: senderId,
10247
+ reason: failReason,
10248
+ })
10249
+ const fDelivered = ipcServer.sendToAgent(pending.agent, failInbound)
10250
+ process.stderr.write(
10251
+ `telegram gateway: vault_save_failed injection agent=${pending.agent} ` +
10252
+ `key=${pending.key} stage=${stageId} delivered=${fDelivered}\n`,
10253
+ )
10254
+ if (!fDelivered) pendingInboundBuffer.push(pending.agent, failInbound)
10146
10255
  return
10147
10256
  }
10148
10257
 
@@ -10158,6 +10267,20 @@ async function handleVaultRequestSaveCallback(ctx: Context, data: string): Promi
10158
10267
  )
10159
10268
  .catch(() => {})
10160
10269
  }
10270
+ // Wake the agent that called vault_request_save so it resumes the
10271
+ // task that was blocked on this credential (symmetric with the
10272
+ // vra: approve path; buffered if the bridge is mid-reconnect).
10273
+ const okInbound = buildVaultSaveCompletedInbound({
10274
+ ctx: { agent: pending.agent, key: pending.key, chat_id: pending.chat_id },
10275
+ stageId,
10276
+ operatorId: senderId,
10277
+ })
10278
+ const okDelivered = ipcServer.sendToAgent(pending.agent, okInbound)
10279
+ process.stderr.write(
10280
+ `telegram gateway: vault_save_completed injection agent=${pending.agent} ` +
10281
+ `key=${pending.key} stage=${stageId} delivered=${okDelivered}\n`,
10282
+ )
10283
+ if (!okDelivered) pendingInboundBuffer.push(pending.agent, okInbound)
10161
10284
  return
10162
10285
  }
10163
10286
 
@@ -12084,16 +12207,25 @@ bot.on('callback_query:data', async ctx => {
12084
12207
  process.stderr.write(
12085
12208
  `telegram gateway: button_callback chatId=${cbChatId} user=${ctx.from.id} data=${JSON.stringify(agentCb.raw)} btnText=${JSON.stringify(buttonText ?? null)}\n`,
12086
12209
  )
12087
- ipcServer.broadcast(inboundMsg)
12088
- if (ipcServer.clientCount() === 0) {
12089
- // No bridge connected — the agent's gone. Tell the user so they
12090
- // don't think the button silently swallowed their tap.
12210
+ // Registered-keyed delivery + buffer-on-miss (same fix as the
12211
+ // normal-inbound path above): broadcast()/clientCount() lost the
12212
+ // tap whenever the bridge was mid-reconnect (clientCount() counts
12213
+ // unregistered sockets, so the notice was suppressed AND nothing
12214
+ // was actually queued). sendToAgent → pendingInboundBuffer (drained
12215
+ // by onClientRegistered) makes the "queued" promise real.
12216
+ const selfAgentBtn = process.env.SWITCHROOM_AGENT_NAME ?? ''
12217
+ const btnDelivered = ipcServer.sendToAgent(selfAgentBtn, inboundMsg)
12218
+ if (!btnDelivered) {
12219
+ pendingInboundBuffer.push(selfAgentBtn, inboundMsg)
12220
+ // No registered bridge — the agent's mid-restart. Tell the user
12221
+ // so they don't think the button silently swallowed their tap;
12222
+ // the tap is genuinely buffered now and replays on reconnect.
12091
12223
  // #1075: thread-id-bearing — swallow on THREAD_NOT_FOUND.
12092
12224
  void swallowingApiCall(
12093
12225
  () =>
12094
12226
  bot.api.sendMessage(
12095
12227
  cbChatId,
12096
- '⏳ Agent is restarting — your button tap was queued but won\'t be processed until it comes back.',
12228
+ '⏳ Agent is restarting — your button tap is queued and will be processed when it comes back.',
12097
12229
  cbThreadId != null ? { message_thread_id: cbThreadId } : {},
12098
12230
  ),
12099
12231
  {
@@ -12222,7 +12354,7 @@ bot.on('callback_query:data', async ctx => {
12222
12354
  // otherwise the rule may be unsafe to honour at scale and we
12223
12355
  // fall back to single-use allow.
12224
12356
  synthInbound: () => {
12225
- ipcServer.broadcast({
12357
+ dispatchPermissionVerdict({
12226
12358
  type: 'permission',
12227
12359
  requestId: request_id,
12228
12360
  behavior: 'allow',
@@ -12260,7 +12392,7 @@ bot.on('callback_query:data', async ctx => {
12260
12392
  newText: baseText ? `${baseText}\n\n${label}` : label,
12261
12393
  parseMode: 'HTML',
12262
12394
  synthInbound: () => {
12263
- ipcServer.broadcast({
12395
+ dispatchPermissionVerdict({
12264
12396
  type: 'permission',
12265
12397
  requestId: request_id,
12266
12398
  behavior: behavior as 'allow' | 'deny',
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Per-agent buffer for permission verdicts the gateway couldn't deliver
3
+ * because no live IPC client was registered for the agent at send-time.
4
+ *
5
+ * Background (PR-3 of the callback→model-continuation series): a
6
+ * tool/skill/MCP permission request suspends the claude turn *inside*
7
+ * the MCP permission call until the gateway relays the operator's
8
+ * Approve/Deny verdict back (`{type:'permission'}` → bridge
9
+ * `onPermission` → Claude Code). The verdict sites previously used
10
+ * `ipcServer.broadcast(...)`, which is fire-and-forget: if the bridge
11
+ * was mid-reconnect at the exact moment the operator tapped (every
12
+ * agent/gateway restart, claude session bounce), the verdict was
13
+ * dropped and the model stayed wedged forever — the user's tap did
14
+ * nothing and they were left silent.
15
+ *
16
+ * This is the permission-verdict analog of `pending-inbound-buffer.ts`:
17
+ * the verdict sites now `sendToAgent` (registered-keyed, real delivered
18
+ * bool) and on a miss `push()` here; `onClientRegistered` `drain()`s
19
+ * and re-sends so a reconnecting bridge relays the missed verdict to
20
+ * the still-suspended permission call.
21
+ *
22
+ * Contract mirrors pending-inbound-buffer:
23
+ * - `push(agent, ev)` best-effort, synchronous, bounded.
24
+ * - `drain(agent)` returns ALL pending verdicts in insertion order
25
+ * and clears them; called from `onClientRegistered`.
26
+ * - In-memory only; survives reconnect within one gateway lifetime,
27
+ * not a gateway restart. A late-redelivered verdict for a
28
+ * request_id claude no longer has is harmless — the bridge relays
29
+ * it and Claude Code ignores an unknown request_id. The TTL-sweep
30
+ * auto-deny is the independent backstop for "operator never tapped".
31
+ *
32
+ * Per-agent cap prevents a never-reconnecting bridge from leaking
33
+ * memory; on overflow the OLDEST verdict is dropped (freshest is most
34
+ * relevant) and logged.
35
+ */
36
+
37
+ import type { PermissionEvent } from './ipc-protocol.js'
38
+
39
+ /** Default cap per agent — a reasonable backlog of permission cards
40
+ * stacked while the bridge is offline, no more. */
41
+ export const DEFAULT_PENDING_PERMISSION_CAP = 32
42
+
43
+ export interface PendingPermissionBuffer {
44
+ /** Append `ev` to `agent`'s queue. Returns true if accepted without
45
+ * eviction, false if the cap forced dropping the oldest (the new
46
+ * entry is STILL accepted). */
47
+ push: (agent: string, ev: PermissionEvent) => boolean
48
+ /** Pop and return all pending verdicts for `agent` (insertion order).
49
+ * Empty array when none. Idempotent. */
50
+ drain: (agent: string) => PermissionEvent[]
51
+ /** Test-only: current depth for `agent`. */
52
+ depth: (agent: string) => number
53
+ /** Test-only: total depth across all agents. */
54
+ totalDepth: () => number
55
+ }
56
+
57
+ export interface PendingPermissionBufferOptions {
58
+ capPerAgent?: number
59
+ log?: (line: string) => void
60
+ }
61
+
62
+ export function createPendingPermissionBuffer(
63
+ opts: PendingPermissionBufferOptions = {},
64
+ ): PendingPermissionBuffer {
65
+ const cap = opts.capPerAgent ?? DEFAULT_PENDING_PERMISSION_CAP
66
+ const log = opts.log ?? ((line: string) => process.stderr.write(line))
67
+ const queues = new Map<string, PermissionEvent[]>()
68
+
69
+ return {
70
+ push(agent, ev) {
71
+ let q = queues.get(agent)
72
+ if (q == null) {
73
+ q = []
74
+ queues.set(agent, q)
75
+ }
76
+ let evicted = false
77
+ if (q.length >= cap) {
78
+ const dropped = q.shift()
79
+ evicted = true
80
+ log(
81
+ `pending-permission-buffer: agent=${agent} cap=${cap} reached — ` +
82
+ `dropped oldest verdict request=${dropped?.requestId ?? '-'} ` +
83
+ `behavior=${dropped?.behavior ?? '-'}\n`,
84
+ )
85
+ }
86
+ q.push(ev)
87
+ log(
88
+ `pending-permission-buffer: agent=${agent} buffered request=${ev.requestId} ` +
89
+ `behavior=${ev.behavior} depth_after=${q.length} evicted=${evicted}\n`,
90
+ )
91
+ return !evicted
92
+ },
93
+ drain(agent) {
94
+ const q = queues.get(agent)
95
+ if (q == null || q.length === 0) return []
96
+ queues.delete(agent)
97
+ log(
98
+ `pending-permission-buffer: drained agent=${agent} count=${q.length} ` +
99
+ `requests=[${q.map((e) => e.requestId).join(',')}]\n`,
100
+ )
101
+ return q
102
+ },
103
+ depth(agent) {
104
+ return queues.get(agent)?.length ?? 0
105
+ },
106
+ totalDepth() {
107
+ let n = 0
108
+ for (const q of queues.values()) n += q.length
109
+ return n
110
+ },
111
+ }
112
+ }
@@ -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
+ }
@@ -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
+ })