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.
- package/README.md +107 -371
- package/dist/cli/switchroom.js +397 -245
- package/dist/host-control/main.js +1 -1
- package/dist/vault/approvals/kernel-server.js +20 -0
- package/dist/vault/broker/server.js +111 -1
- package/package.json +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +208 -26
- package/telegram-plugin/gateway/approval-callback.ts +24 -13
- package/telegram-plugin/gateway/gateway.ts +149 -17
- package/telegram-plugin/gateway/pending-permission-decisions.ts +112 -0
- package/telegram-plugin/gateway/vault-grant-inbound-builders.ts +117 -0
- package/telegram-plugin/tests/pending-permission-decisions.test.ts +73 -0
- package/telegram-plugin/tests/vault-save-inbound-builders.test.ts +96 -0
|
@@ -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)
|
|
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
|
-
|
|
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
|
-
//
|
|
6985
|
-
|
|
6986
|
-
|
|
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
|
|
6992
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
12088
|
-
|
|
12089
|
-
|
|
12090
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
})
|