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.
- package/README.md +107 -371
- package/dist/cli/switchroom.js +627 -386
- package/dist/vault/approvals/kernel-server.js +88 -1
- package/dist/vault/broker/server.js +132 -2
- package/package.json +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +289 -81
- package/telegram-plugin/gateway/approval-callback.test.ts +49 -1
- package/telegram-plugin/gateway/approval-callback.ts +85 -56
- package/telegram-plugin/gateway/gateway.ts +168 -19
- package/telegram-plugin/gateway/pending-inbound-buffer.ts +39 -0
- 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-inbound-buffer.test.ts +71 -1
- package/telegram-plugin/tests/pending-permission-decisions.test.ts +73 -0
- package/telegram-plugin/tests/vault-save-inbound-builders.test.ts +96 -0
|
@@ -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
|
+
})
|