switchroom 0.19.18 → 0.19.22
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/dist/agent-scheduler/index.js +2 -1
- package/dist/auth-broker/index.js +56 -1
- package/dist/cli/drive-write-pretool.mjs +48 -5
- package/dist/cli/ms-365-write-pretool.mjs +40 -2
- package/dist/cli/notion-write-pretool.mjs +2 -1
- package/dist/cli/switchroom.js +5242 -2239
- package/dist/host-control/main.js +12241 -11375
- package/dist/vault/approvals/kernel-server.js +113 -7
- package/dist/vault/broker/server.js +259 -76
- package/package.json +6 -3
- package/profiles/_base/start.sh.hbs +61 -1
- package/skills/switchroom-release/SKILL.md +103 -20
- package/telegram-plugin/bridge/bridge.ts +14 -0
- package/telegram-plugin/card-format.ts +92 -3
- package/telegram-plugin/dist/bridge/bridge.js +13 -0
- package/telegram-plugin/dist/gateway/gateway.js +2356 -1159
- package/telegram-plugin/dist/server.js +13 -0
- package/telegram-plugin/edit-flood-fuse.ts +477 -0
- package/telegram-plugin/format.ts +19 -7
- package/telegram-plugin/gateway/always-allow-persist-queue.ts +97 -11
- package/telegram-plugin/gateway/boot-sweep-gate.ts +164 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +454 -81
- package/telegram-plugin/gateway/gateway.ts +66 -56
- package/telegram-plugin/gateway/inbound-interceptors.ts +27 -4
- package/telegram-plugin/gateway/missed-approvals-store.ts +66 -17
- package/telegram-plugin/gateway/narrative-lane.ts +49 -3
- package/telegram-plugin/gateway/pending-card-store.ts +46 -16
- package/telegram-plugin/gateway/scoped-grant-store.ts +39 -14
- package/telegram-plugin/gateway/status-pin-api.ts +145 -0
- package/telegram-plugin/gateway/store-file.ts +244 -0
- package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +325 -45
- package/telegram-plugin/hooks/tool-label-pretool.mjs +88 -2
- package/telegram-plugin/retry-api-call.ts +15 -2
- package/telegram-plugin/send-gate.ts +1 -1
- package/telegram-plugin/status-no-truncate.ts +64 -1
- package/telegram-plugin/status-pin-driver.ts +50 -27
- package/telegram-plugin/status-pin.ts +43 -5
- package/telegram-plugin/tests/activity-card-send-gate.test.ts +275 -0
- package/telegram-plugin/tests/activity-card-wiring.test.ts +16 -7
- package/telegram-plugin/tests/boot-pin-sweep-wiring.test.ts +101 -0
- package/telegram-plugin/tests/boot-sweep-gate.test.ts +293 -0
- package/telegram-plugin/tests/boot-version-string.test.ts +0 -0
- package/telegram-plugin/tests/bridge-tool-parity.test.ts +95 -0
- package/telegram-plugin/tests/edit-flood-fuse.test.ts +431 -0
- package/telegram-plugin/tests/pinned-card-collapse.test.ts +356 -0
- package/telegram-plugin/tests/status-pin-api.test.ts +178 -0
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +94 -11
- package/telegram-plugin/tests/status-pin.test.ts +106 -5
- package/telegram-plugin/tests/store-atomic-write.test.ts +411 -0
- package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +631 -1
- package/telegram-plugin/tests/tool-activity-summary.test.ts +28 -12
- package/telegram-plugin/tests/tool-label-pretool.test.ts +94 -0
- package/telegram-plugin/tests/vault-approval-posture.test.ts +6 -1
- package/telegram-plugin/tests/vault-passphrase-retry.test.ts +666 -0
- package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +42 -21
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +233 -1
- package/telegram-plugin/tests/worker-feed-repeat-steps.test.ts +147 -0
- package/telegram-plugin/tool-activity-summary.ts +85 -13
- package/telegram-plugin/worker-activity-feed.ts +56 -2
- package/vendor/hindsight-memory/scripts/drain_pending.py +847 -67
- package/vendor/hindsight-memory/scripts/lib/client.py +124 -0
- package/vendor/hindsight-memory/scripts/lib/pending.py +944 -33
- package/vendor/hindsight-memory/scripts/lib/retain_split.py +460 -0
- package/vendor/hindsight-memory/scripts/recall.py +74 -5
- package/vendor/hindsight-memory/scripts/session_start.py +48 -0
- package/vendor/hindsight-memory/scripts/tests/test_client_document_exists.py +470 -0
- package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +2275 -0
- package/vendor/hindsight-memory/scripts/tests/test_pending_failure_class.py +105 -0
- package/vendor/hindsight-memory/scripts/tests/test_pending_wedge.py +300 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_degraded_notice.py +365 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +12 -4
- package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +27 -2
- package/vendor/hindsight-memory/scripts/tests/test_retain_split.py +438 -0
- package/vendor/hindsight-memory/scripts/tests/test_session_start_version_skew.py +204 -0
- package/vendor/hindsight-memory/tests/test_drain_pending.py +130 -8
- package/vendor/hindsight-memory/tests/test_pending.py +32 -7
|
@@ -0,0 +1,666 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
2
|
+
import { existsSync, mkdtempSync, readFileSync } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import type { Context } from 'grammy'
|
|
6
|
+
import { InlineKeyboard } from 'grammy'
|
|
7
|
+
import {
|
|
8
|
+
createCallbackQueryHandlers,
|
|
9
|
+
isPassphraseMismatchBrokerError,
|
|
10
|
+
buildAccessPassphrasePromptText,
|
|
11
|
+
MAX_VAULT_PASSPHRASE_ATTEMPTS,
|
|
12
|
+
type CallbackQueryHandlersDeps,
|
|
13
|
+
type PendingVaultRequestAccess,
|
|
14
|
+
type PendingVaultRequestSave,
|
|
15
|
+
type PendingSecretRequest,
|
|
16
|
+
type ArmedSecretCapture,
|
|
17
|
+
type PendingMentalModelPropose,
|
|
18
|
+
type DeferredSecret,
|
|
19
|
+
type PendingVaultOp,
|
|
20
|
+
} from '../gateway/callback-query-handlers.js'
|
|
21
|
+
import { interceptVault, type InboundInterceptorDeps } from '../gateway/inbound-interceptors.js'
|
|
22
|
+
import { createSweepableCardStore } from '../gateway/approval-card-stores.js'
|
|
23
|
+
import { createSweepableStore } from '../gateway/pending-state-stores.js'
|
|
24
|
+
import { StagingMap } from '../secret-detect/staging.js'
|
|
25
|
+
|
|
26
|
+
// The broker is the only seam faked, and it is INJECTED through
|
|
27
|
+
// CallbackQueryHandlersDeps (#3627) rather than module-mocked. `vi.mock` is
|
|
28
|
+
// not an option here: CI sweeps this whole directory with `bun test`, whose
|
|
29
|
+
// `mock.module` is process-global and irreversible, so a broker-client mock
|
|
30
|
+
// registered by this file would leak into every later file (it did — it broke
|
|
31
|
+
// tests/linear-create-issue.test.ts, which reads its token through the same
|
|
32
|
+
// module). Injection matches the house pattern in vault-write-posture.test.ts.
|
|
33
|
+
|
|
34
|
+
/** The broker's real wrong-passphrase wire message (server.ts:2166-2172). */
|
|
35
|
+
const MISMATCH_MSG = "supplied passphrase does not match the broker's unlocked passphrase"
|
|
36
|
+
|
|
37
|
+
const CHAT = '111'
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Queued mint outcomes + recorded calls for the injected broker fake. Reset per
|
|
41
|
+
* test; `makeHandlerDeps()` wires it into the handler families.
|
|
42
|
+
*/
|
|
43
|
+
const brokerState = {
|
|
44
|
+
mintResults: [] as Array<Record<string, unknown>>,
|
|
45
|
+
mintCalls: [] as Array<Record<string, unknown>>,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A grant success writes the agent's `.vault-token`. The token path is
|
|
50
|
+
* injected (`brokerVaultTokenFilePath`) at a scratch tmpdir so no test can
|
|
51
|
+
* touch real fleet state — a `process.env.HOME` override would NOT do it here:
|
|
52
|
+
* bun's `os.homedir()` snapshots the process's startup HOME and ignores a
|
|
53
|
+
* later assignment, so the usual HOME override is silently inert under the
|
|
54
|
+
* `bun test` sweep CI runs over this directory.
|
|
55
|
+
*/
|
|
56
|
+
let tokenDir = ''
|
|
57
|
+
|
|
58
|
+
beforeEach(() => {
|
|
59
|
+
tokenDir = mkdtempSync(join(tmpdir(), 'vault-retry-test-'))
|
|
60
|
+
brokerState.mintResults.length = 0
|
|
61
|
+
brokerState.mintCalls.length = 0
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
// ── fakes ────────────────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
function makeCtx(opts: { editThrows?: boolean } = {}) {
|
|
67
|
+
const edits: Array<{ chatId: string; messageId: number; text: string }> = []
|
|
68
|
+
const ctx = {
|
|
69
|
+
from: { id: 111, username: 'op', first_name: 'op' },
|
|
70
|
+
chat: { id: 111 },
|
|
71
|
+
api: {
|
|
72
|
+
editMessageText: vi.fn(
|
|
73
|
+
async (chatId: string, messageId: number, body: { markdown?: string } | string) => {
|
|
74
|
+
if (opts.editThrows) throw new Error('Bad Request: message to edit not found')
|
|
75
|
+
edits.push({
|
|
76
|
+
chatId,
|
|
77
|
+
messageId,
|
|
78
|
+
text: typeof body === 'string' ? body : (body.markdown ?? ''),
|
|
79
|
+
})
|
|
80
|
+
return true
|
|
81
|
+
},
|
|
82
|
+
),
|
|
83
|
+
},
|
|
84
|
+
answerCallbackQuery: vi.fn(async () => true),
|
|
85
|
+
}
|
|
86
|
+
return { ctx: ctx as unknown as Context, edits, raw: ctx }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function makeHandlerDeps() {
|
|
90
|
+
/** Fresh messages the locked bot sent (prompts + resolution fallbacks). */
|
|
91
|
+
const sent: Array<{ chatId: string; text: string; threadId?: number }> = []
|
|
92
|
+
const fakeBot = {
|
|
93
|
+
api: {
|
|
94
|
+
editMessageText: vi.fn(async () => true),
|
|
95
|
+
sendRichMessage: vi.fn(
|
|
96
|
+
async (chatId: string, body: { markdown: string }, other?: { message_thread_id?: number }) => {
|
|
97
|
+
sent.push({
|
|
98
|
+
chatId,
|
|
99
|
+
text: body.markdown,
|
|
100
|
+
...(other?.message_thread_id != null ? { threadId: other.message_thread_id } : {}),
|
|
101
|
+
})
|
|
102
|
+
return { message_id: 900 + sent.length }
|
|
103
|
+
},
|
|
104
|
+
),
|
|
105
|
+
},
|
|
106
|
+
}
|
|
107
|
+
const deps: CallbackQueryHandlersDeps = {
|
|
108
|
+
bot: fakeBot,
|
|
109
|
+
lockedBot: fakeBot,
|
|
110
|
+
loadAccess: () => ({ allowFrom: ['111'] }),
|
|
111
|
+
escapeHtmlForTg: (t) => t.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'),
|
|
112
|
+
switchroomReply: vi.fn(async () => undefined),
|
|
113
|
+
resolveThreadId: (_chatId, explicit) => (explicit == null ? undefined : Number(explicit)),
|
|
114
|
+
deliverResumeSyntheticOrBuffer: vi.fn(
|
|
115
|
+
() => true,
|
|
116
|
+
) as unknown as CallbackQueryHandlersDeps['deliverResumeSyntheticOrBuffer'],
|
|
117
|
+
expireMentalModelProposeCard: vi.fn(),
|
|
118
|
+
readLiveSwitchroomConfigText: () => 'agents: {}\n',
|
|
119
|
+
mentalModelCorrelationKey: (a, d) => `${a}::${d.length}`,
|
|
120
|
+
getMyAgentName: () => 'test-agent',
|
|
121
|
+
triggerSelfRestart: vi.fn(() => true),
|
|
122
|
+
runSwitchroomAuthCommand: vi.fn(async () => undefined),
|
|
123
|
+
switchroomExecJson: <T,>(_args: string[]): T | null => null,
|
|
124
|
+
assertSafeAgentName: () => {},
|
|
125
|
+
buildDeferredSecretKeyboard: () => new InlineKeyboard().text('x', 'vd:unlock:k'),
|
|
126
|
+
recordDeferredSecretKernelDecision: vi.fn(async () => undefined),
|
|
127
|
+
mintGrantWizardKernelRequest: vi.fn(async () => null),
|
|
128
|
+
recordGrantWizardKernelDecision: vi.fn(async () => undefined),
|
|
129
|
+
robustApiCall: (fn) => fn(),
|
|
130
|
+
swallowingApiCall: async (fn) => {
|
|
131
|
+
try {
|
|
132
|
+
return await fn()
|
|
133
|
+
} catch {
|
|
134
|
+
return undefined
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
pendingVaultRequestAccesses: createSweepableCardStore<PendingVaultRequestAccess>({
|
|
138
|
+
isExpired: () => false,
|
|
139
|
+
expire: () => () => {},
|
|
140
|
+
log: () => () => {},
|
|
141
|
+
}),
|
|
142
|
+
pendingVaultRequestSaves: createSweepableCardStore<PendingVaultRequestSave>({
|
|
143
|
+
isExpired: () => false,
|
|
144
|
+
expire: () => () => {},
|
|
145
|
+
log: () => () => {},
|
|
146
|
+
}),
|
|
147
|
+
pendingSecretRequests: createSweepableCardStore<PendingSecretRequest>({
|
|
148
|
+
isExpired: () => false,
|
|
149
|
+
expire: () => () => {},
|
|
150
|
+
log: () => () => {},
|
|
151
|
+
}),
|
|
152
|
+
armedSecretCaptures: createSweepableStore<ArmedSecretCapture>(() => false),
|
|
153
|
+
pendingMentalModelProposes: createSweepableCardStore<PendingMentalModelPropose>({
|
|
154
|
+
isExpired: () => false,
|
|
155
|
+
expire: () => () => {},
|
|
156
|
+
log: () => () => {},
|
|
157
|
+
}),
|
|
158
|
+
pendingCardStore: { remove: vi.fn() },
|
|
159
|
+
pendingMentalModelCorrelations: createSweepableStore(() => false),
|
|
160
|
+
pendingVaultOps: createSweepableStore<PendingVaultOp>(() => false),
|
|
161
|
+
vaultPassphraseCache: createSweepableStore(() => false),
|
|
162
|
+
deferredSecrets: createSweepableStore<DeferredSecret>(() => false),
|
|
163
|
+
pendingReauthFlows: createSweepableStore(() => false),
|
|
164
|
+
secretStaging: new StagingMap(),
|
|
165
|
+
lastAuthRefreshAtMs: new Map<string, number>(),
|
|
166
|
+
brokerMintGrant: (async (args: Record<string, unknown>) => {
|
|
167
|
+
brokerState.mintCalls.push(args)
|
|
168
|
+
return (
|
|
169
|
+
brokerState.mintResults.shift() ?? {
|
|
170
|
+
kind: 'ok',
|
|
171
|
+
token: 'vgt_test',
|
|
172
|
+
id: 'vg_abc123',
|
|
173
|
+
expires_at: null,
|
|
174
|
+
}
|
|
175
|
+
)
|
|
176
|
+
}) as unknown as CallbackQueryHandlersDeps['brokerMintGrant'],
|
|
177
|
+
// No standing-ACL coverage and no prior grants: keeps the union /
|
|
178
|
+
// short-circuit preamble inert so each test observes the mint outcome only.
|
|
179
|
+
brokerList: (async () => null) as unknown as CallbackQueryHandlersDeps['brokerList'],
|
|
180
|
+
brokerListGrants: (async () => ({
|
|
181
|
+
kind: 'error',
|
|
182
|
+
msg: 'unavailable in test',
|
|
183
|
+
})) as unknown as CallbackQueryHandlersDeps['brokerListGrants'],
|
|
184
|
+
brokerVaultTokenFilePath: (slug: string) => join(tokenDir, slug, '.vault-token'),
|
|
185
|
+
getVaultApprovalAuthMode: () => 'passphrase',
|
|
186
|
+
getAdminOnlyKeys: () => [],
|
|
187
|
+
vaultKeyRegex: /^[A-Za-z0-9_./-]{1,200}$/,
|
|
188
|
+
mentalModelProposeTtlMs: 60 * 60 * 1000,
|
|
189
|
+
emitOperatorEvent: vi.fn(),
|
|
190
|
+
}
|
|
191
|
+
return { deps, sent, fakeBot }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function stagedAccess(over: Partial<PendingVaultRequestAccess> = {}): PendingVaultRequestAccess {
|
|
195
|
+
return {
|
|
196
|
+
agent: 'worker',
|
|
197
|
+
chat_id: CHAT,
|
|
198
|
+
card_message_id: 42,
|
|
199
|
+
key: 'github/token',
|
|
200
|
+
scope: 'read',
|
|
201
|
+
ttl_seconds: 30 * 24 * 60 * 60,
|
|
202
|
+
staged_at: Date.now(),
|
|
203
|
+
...over,
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Wire the REAL interceptor deps over the real handler families, so a test
|
|
209
|
+
* drives the same code path a typed passphrase takes in production.
|
|
210
|
+
*/
|
|
211
|
+
function makeInterceptDeps(handlerDeps: CallbackQueryHandlersDeps) {
|
|
212
|
+
const handlers = createCallbackQueryHandlers(handlerDeps)
|
|
213
|
+
const deleted: number[] = []
|
|
214
|
+
const deps = {
|
|
215
|
+
pendingVaultOps: handlerDeps.pendingVaultOps,
|
|
216
|
+
vaultInputTtlMs: 10 * 60 * 1000,
|
|
217
|
+
vaultPassphraseTtlMs: 10 * 60 * 1000,
|
|
218
|
+
vaultPassphraseCache: handlerDeps.vaultPassphraseCache,
|
|
219
|
+
deleteSensitiveMessage: vi.fn(async (_c: string, msgId: number) => {
|
|
220
|
+
deleted.push(msgId)
|
|
221
|
+
}),
|
|
222
|
+
executeVaultOp: vi.fn(async () => {}),
|
|
223
|
+
unlockViaBroker: vi.fn(async () => ({ ok: true })),
|
|
224
|
+
callbackQueryHandlers: () => handlers,
|
|
225
|
+
pendingVaultRequestAccesses: handlerDeps.pendingVaultRequestAccesses,
|
|
226
|
+
pendingVaultRequestSaves: handlerDeps.pendingVaultRequestSaves,
|
|
227
|
+
switchroomReply: handlerDeps.switchroomReply,
|
|
228
|
+
escapeHtmlForTg: handlerDeps.escapeHtmlForTg,
|
|
229
|
+
vaultKeyRegex: handlerDeps.vaultKeyRegex,
|
|
230
|
+
vaultKeyRegexLabel: 'slug',
|
|
231
|
+
secretStaging: handlerDeps.secretStaging,
|
|
232
|
+
} as unknown as InboundInterceptorDeps
|
|
233
|
+
return { deps, handlers, deleted }
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Type the pending op down to the access-approve variant for assertions. */
|
|
237
|
+
function accessOp(op: PendingVaultOp | undefined) {
|
|
238
|
+
if (op?.kind !== 'passphrase-for-access-approve') return undefined
|
|
239
|
+
return op
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Arm the queue exactly as an Approve tap on a locked vault would. */
|
|
243
|
+
function armQueue(
|
|
244
|
+
handlerDeps: CallbackQueryHandlersDeps,
|
|
245
|
+
items: Array<{ stageId: string; cardMessageId: number }>,
|
|
246
|
+
attempts?: number,
|
|
247
|
+
) {
|
|
248
|
+
handlerDeps.pendingVaultOps.set(CHAT, {
|
|
249
|
+
kind: 'passphrase-for-access-approve',
|
|
250
|
+
items: items.map((it) => ({
|
|
251
|
+
stageId: it.stageId,
|
|
252
|
+
cardChatId: CHAT,
|
|
253
|
+
cardMessageId: it.cardMessageId,
|
|
254
|
+
senderId: '111',
|
|
255
|
+
})),
|
|
256
|
+
...(attempts != null ? { attempts } : {}),
|
|
257
|
+
startedAt: Date.now(),
|
|
258
|
+
})
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ── the mismatch classifier ──────────────────────────────────────────────
|
|
262
|
+
|
|
263
|
+
describe('isPassphraseMismatchBrokerError', () => {
|
|
264
|
+
it('recognises the broker wrong-passphrase denial', () => {
|
|
265
|
+
expect(isPassphraseMismatchBrokerError(MISMATCH_MSG)).toBe(true)
|
|
266
|
+
expect(isPassphraseMismatchBrokerError('denied:passphrase-mismatch')).toBe(true)
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
it('does NOT treat other refusals as retryable', () => {
|
|
270
|
+
// These must stay terminal — retrying burns the operator's attempts on
|
|
271
|
+
// something no passphrase can fix.
|
|
272
|
+
expect(isPassphraseMismatchBrokerError('agent not permitted to hold this key')).toBe(false)
|
|
273
|
+
expect(isPassphraseMismatchBrokerError('mint_grant: keys must be a non-empty array')).toBe(false)
|
|
274
|
+
expect(isPassphraseMismatchBrokerError('vault is locked')).toBe(false)
|
|
275
|
+
})
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
// ── prompt copy ──────────────────────────────────────────────────────────
|
|
279
|
+
|
|
280
|
+
describe('buildAccessPassphrasePromptText', () => {
|
|
281
|
+
it('leads with 🚨 on every variant (#3627 item 1)', () => {
|
|
282
|
+
for (const variant of ['batch', 'admin-only', 'locked'] as const) {
|
|
283
|
+
const text = buildAccessPassphrasePromptText({
|
|
284
|
+
kind: 'first',
|
|
285
|
+
variant,
|
|
286
|
+
itemCount: 2,
|
|
287
|
+
agentEscaped: 'worker',
|
|
288
|
+
key: 'github/token',
|
|
289
|
+
})
|
|
290
|
+
expect(text.startsWith('**🚨🔐 ACTION NEEDED: passphrase required**')).toBe(true)
|
|
291
|
+
expect(text).not.toContain('⚠️')
|
|
292
|
+
}
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
it('pluralises the remaining-attempts line', () => {
|
|
296
|
+
const two = buildAccessPassphrasePromptText({
|
|
297
|
+
kind: 'retry',
|
|
298
|
+
itemCount: 1,
|
|
299
|
+
retryRemaining: 2,
|
|
300
|
+
})
|
|
301
|
+
expect(two).toContain('Wrong passphrase. 2 attempts remaining.')
|
|
302
|
+
const one = buildAccessPassphrasePromptText({
|
|
303
|
+
kind: 'retry',
|
|
304
|
+
itemCount: 1,
|
|
305
|
+
retryRemaining: 1,
|
|
306
|
+
})
|
|
307
|
+
expect(one).toContain('Wrong passphrase. 1 attempt remaining.')
|
|
308
|
+
})
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
// ── the retry loop, driven through interceptVault ────────────────────────
|
|
312
|
+
|
|
313
|
+
describe('wrong-passphrase retry (#3627 item 3)', () => {
|
|
314
|
+
it('keeps the stage alive and re-prompts with the remaining count', async () => {
|
|
315
|
+
const { deps: hDeps, sent } = makeHandlerDeps()
|
|
316
|
+
hDeps.pendingVaultRequestAccesses.set('s1', stagedAccess())
|
|
317
|
+
armQueue(hDeps, [{ stageId: 's1', cardMessageId: 42 }])
|
|
318
|
+
const { deps } = makeInterceptDeps(hDeps)
|
|
319
|
+
const { ctx, edits } = makeCtx()
|
|
320
|
+
brokerState.mintResults.push({ kind: 'error', msg: MISMATCH_MSG })
|
|
321
|
+
|
|
322
|
+
const out = await interceptVault({ ctx, text: 'wrong-one', chat_id: CHAT, msgId: 7 }, deps)
|
|
323
|
+
|
|
324
|
+
expect(out.handled).toBe(true)
|
|
325
|
+
// Stage survives — the operator can still complete this approval.
|
|
326
|
+
expect(hDeps.pendingVaultRequestAccesses.has('s1')).toBe(true)
|
|
327
|
+
expect(hDeps.pendingCardStore.remove).not.toHaveBeenCalled()
|
|
328
|
+
// Counter advanced on the passphrase ENTRY, queue re-armed with the
|
|
329
|
+
// still-unresolved stage.
|
|
330
|
+
const op = accessOp(hDeps.pendingVaultOps.get(CHAT))
|
|
331
|
+
expect(op?.attempts).toBe(1)
|
|
332
|
+
expect(op?.items.map((i) => i.stageId)).toEqual(['s1'])
|
|
333
|
+
// Operator sees a fresh, loud re-prompt with the count.
|
|
334
|
+
expect(sent).toHaveLength(1)
|
|
335
|
+
expect(sent[0]!.text).toContain('🚨🔐 ACTION NEEDED: passphrase required')
|
|
336
|
+
expect(sent[0]!.text).toContain('Wrong passphrase. 2 attempts remaining.')
|
|
337
|
+
// The card is NOT edited to a terminal failure.
|
|
338
|
+
expect(edits.some((e) => e.text.includes('mint_grant failed'))).toBe(false)
|
|
339
|
+
// The wrong passphrase must not linger in the chat cache — otherwise the
|
|
340
|
+
// next Approve tap silently re-uses it.
|
|
341
|
+
expect(hDeps.vaultPassphraseCache.get(CHAT)).toBeUndefined()
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
it('counts down across attempts and locks out on the third', async () => {
|
|
345
|
+
const { deps: hDeps, sent } = makeHandlerDeps()
|
|
346
|
+
hDeps.pendingVaultRequestAccesses.set('s1', stagedAccess())
|
|
347
|
+
armQueue(hDeps, [{ stageId: 's1', cardMessageId: 42 }])
|
|
348
|
+
const { deps } = makeInterceptDeps(hDeps)
|
|
349
|
+
const { ctx, edits } = makeCtx()
|
|
350
|
+
|
|
351
|
+
brokerState.mintResults.push({ kind: 'error', msg: MISMATCH_MSG })
|
|
352
|
+
await interceptVault({ ctx, text: 'nope-1', chat_id: CHAT, msgId: 7 }, deps)
|
|
353
|
+
brokerState.mintResults.push({ kind: 'error', msg: MISMATCH_MSG })
|
|
354
|
+
await interceptVault({ ctx, text: 'nope-2', chat_id: CHAT, msgId: 8 }, deps)
|
|
355
|
+
|
|
356
|
+
expect(accessOp(hDeps.pendingVaultOps.get(CHAT))?.attempts).toBe(2)
|
|
357
|
+
expect(sent[1]!.text).toContain('Wrong passphrase. 1 attempt remaining.')
|
|
358
|
+
expect(hDeps.pendingVaultRequestAccesses.has('s1')).toBe(true)
|
|
359
|
+
|
|
360
|
+
brokerState.mintResults.push({ kind: 'error', msg: MISMATCH_MSG })
|
|
361
|
+
await interceptVault({ ctx, text: 'nope-3', chat_id: CHAT, msgId: 9 }, deps)
|
|
362
|
+
|
|
363
|
+
// Third failure is terminal: stage dropped, card store cleaned, no
|
|
364
|
+
// further prompt, and a card edit that names the lockout.
|
|
365
|
+
expect(MAX_VAULT_PASSPHRASE_ATTEMPTS).toBe(3)
|
|
366
|
+
expect(hDeps.pendingVaultRequestAccesses.has('s1')).toBe(false)
|
|
367
|
+
expect(hDeps.pendingCardStore.remove).toHaveBeenCalledWith('s1')
|
|
368
|
+
expect(hDeps.pendingVaultOps.get(CHAT)).toBeUndefined()
|
|
369
|
+
expect(sent).toHaveLength(2)
|
|
370
|
+
const lockout = edits.at(-1)!
|
|
371
|
+
expect(lockout.messageId).toBe(42)
|
|
372
|
+
expect(lockout.text).toContain('Too many wrong passphrase attempts')
|
|
373
|
+
expect(lockout.text).toContain('ask the agent to re-issue')
|
|
374
|
+
// The broker's own reason is carried through for forensics.
|
|
375
|
+
expect(lockout.text).toContain('does not match')
|
|
376
|
+
// Three attempts, three mints — never a silent extra retry.
|
|
377
|
+
expect(brokerState.mintCalls).toHaveLength(3)
|
|
378
|
+
})
|
|
379
|
+
|
|
380
|
+
it('counts one attempt for a batch, not one per card', async () => {
|
|
381
|
+
const { deps: hDeps, sent } = makeHandlerDeps()
|
|
382
|
+
hDeps.pendingVaultRequestAccesses.set('s1', stagedAccess())
|
|
383
|
+
hDeps.pendingVaultRequestAccesses.set(
|
|
384
|
+
's2',
|
|
385
|
+
stagedAccess({ key: 'coolify/api-token', card_message_id: 43 }),
|
|
386
|
+
)
|
|
387
|
+
armQueue(hDeps, [
|
|
388
|
+
{ stageId: 's1', cardMessageId: 42 },
|
|
389
|
+
{ stageId: 's2', cardMessageId: 43 },
|
|
390
|
+
])
|
|
391
|
+
const { deps } = makeInterceptDeps(hDeps)
|
|
392
|
+
const { ctx } = makeCtx()
|
|
393
|
+
brokerState.mintResults.push({ kind: 'error', msg: MISMATCH_MSG })
|
|
394
|
+
brokerState.mintResults.push({ kind: 'error', msg: MISMATCH_MSG })
|
|
395
|
+
|
|
396
|
+
await interceptVault({ ctx, text: 'wrong', chat_id: CHAT, msgId: 7 }, deps)
|
|
397
|
+
|
|
398
|
+
const op = accessOp(hDeps.pendingVaultOps.get(CHAT))
|
|
399
|
+
// One typo = one attempt, even though it failed two cards.
|
|
400
|
+
expect(op?.attempts).toBe(1)
|
|
401
|
+
expect(op?.items.map((i) => i.stageId)).toEqual(['s1', 's2'])
|
|
402
|
+
// And exactly ONE re-prompt for the whole batch.
|
|
403
|
+
expect(sent).toHaveLength(1)
|
|
404
|
+
expect(sent[0]!.text).toContain('Wrong passphrase. 2 attempts remaining.')
|
|
405
|
+
expect(sent[0]!.text).toContain('One entry covers **2** pending approvals')
|
|
406
|
+
})
|
|
407
|
+
|
|
408
|
+
it('a batch mixing a mint success with a non-retryable refusal stays terminal', async () => {
|
|
409
|
+
const { deps: hDeps } = makeHandlerDeps()
|
|
410
|
+
hDeps.pendingVaultRequestAccesses.set('s1', stagedAccess())
|
|
411
|
+
hDeps.pendingVaultRequestAccesses.set(
|
|
412
|
+
's2',
|
|
413
|
+
stagedAccess({ key: 'coolify/api-token', card_message_id: 43 }),
|
|
414
|
+
)
|
|
415
|
+
armQueue(hDeps, [
|
|
416
|
+
{ stageId: 's1', cardMessageId: 42 },
|
|
417
|
+
{ stageId: 's2', cardMessageId: 43 },
|
|
418
|
+
])
|
|
419
|
+
const { deps } = makeInterceptDeps(hDeps)
|
|
420
|
+
const { ctx } = makeCtx()
|
|
421
|
+
// s1 mints fine; s2 is refused for a NON-passphrase reason.
|
|
422
|
+
brokerState.mintResults.push({ kind: 'ok', token: 't', id: 'vg_1', expires_at: null })
|
|
423
|
+
brokerState.mintResults.push({ kind: 'error', msg: 'agent not permitted to hold this key' })
|
|
424
|
+
|
|
425
|
+
await interceptVault({ ctx, text: 'right', chat_id: CHAT, msgId: 7 }, deps)
|
|
426
|
+
|
|
427
|
+
// Neither is retryable → no re-prompt, no surviving queue.
|
|
428
|
+
expect(hDeps.pendingVaultOps.get(CHAT)).toBeUndefined()
|
|
429
|
+
expect(hDeps.pendingVaultRequestAccesses.has('s1')).toBe(false)
|
|
430
|
+
expect(hDeps.pendingVaultRequestAccesses.has('s2')).toBe(false)
|
|
431
|
+
})
|
|
432
|
+
|
|
433
|
+
it('a non-mismatch broker error stays terminal on the FIRST failure', async () => {
|
|
434
|
+
const { deps: hDeps, sent } = makeHandlerDeps()
|
|
435
|
+
hDeps.pendingVaultRequestAccesses.set('s1', stagedAccess())
|
|
436
|
+
armQueue(hDeps, [{ stageId: 's1', cardMessageId: 42 }])
|
|
437
|
+
const { deps } = makeInterceptDeps(hDeps)
|
|
438
|
+
const { ctx, edits } = makeCtx()
|
|
439
|
+
brokerState.mintResults.push({ kind: 'error', msg: 'agent not permitted to hold this key' })
|
|
440
|
+
|
|
441
|
+
await interceptVault({ ctx, text: 'correct-passphrase', chat_id: CHAT, msgId: 7 }, deps)
|
|
442
|
+
|
|
443
|
+
expect(hDeps.pendingVaultRequestAccesses.has('s1')).toBe(false)
|
|
444
|
+
expect(hDeps.pendingCardStore.remove).toHaveBeenCalledWith('s1')
|
|
445
|
+
expect(hDeps.pendingVaultOps.get(CHAT)).toBeUndefined()
|
|
446
|
+
// No re-prompt at all — the operator is told what failed, once.
|
|
447
|
+
expect(sent).toHaveLength(0)
|
|
448
|
+
expect(edits.at(-1)!.text).toContain('mint_grant failed')
|
|
449
|
+
expect(edits.at(-1)!.text).toContain('agent not permitted')
|
|
450
|
+
expect(brokerState.mintCalls).toHaveLength(1)
|
|
451
|
+
})
|
|
452
|
+
|
|
453
|
+
it('re-arming the queue never strands a card that was queued concurrently', async () => {
|
|
454
|
+
// The cached-passphrase Approve path can hit a mismatch for ONE card
|
|
455
|
+
// while another card is already queued for the same chat waiting on a
|
|
456
|
+
// passphrase entry. Overwriting the pending op with just the failed card
|
|
457
|
+
// would leave the other one staged, its card still saying "waiting", with
|
|
458
|
+
// no pending op left to route the next passphrase entry back to it.
|
|
459
|
+
const { deps: hDeps, sent } = makeHandlerDeps()
|
|
460
|
+
hDeps.pendingVaultRequestAccesses.set('s1', stagedAccess())
|
|
461
|
+
hDeps.pendingVaultRequestAccesses.set(
|
|
462
|
+
's2',
|
|
463
|
+
stagedAccess({ key: 'coolify/api-token', card_message_id: 43 }),
|
|
464
|
+
)
|
|
465
|
+
const handlers = createCallbackQueryHandlers(hDeps)
|
|
466
|
+
const { ctx } = makeCtx()
|
|
467
|
+
// s2 is sitting in an open queue (tapped, awaiting a passphrase entry).
|
|
468
|
+
armQueue(hDeps, [{ stageId: 's2', cardMessageId: 43 }])
|
|
469
|
+
|
|
470
|
+
await handlers.resolveAccessApprovalPassphraseMismatch(ctx, {
|
|
471
|
+
chat_id: CHAT,
|
|
472
|
+
failed: [{ stageId: 's1', cardChatId: CHAT, cardMessageId: 42, senderId: '111' }],
|
|
473
|
+
priorAttempts: 0,
|
|
474
|
+
brokerMsg: MISMATCH_MSG,
|
|
475
|
+
})
|
|
476
|
+
|
|
477
|
+
const op = accessOp(hDeps.pendingVaultOps.get(CHAT))
|
|
478
|
+
expect(op?.items.map((i) => i.stageId).sort()).toEqual(['s1', 's2'])
|
|
479
|
+
expect(op?.attempts).toBe(1)
|
|
480
|
+
// And the operator is told the entry covers both.
|
|
481
|
+
expect(sent).toHaveLength(1)
|
|
482
|
+
expect(sent[0]!.text).toContain('One entry covers **2** pending approvals')
|
|
483
|
+
})
|
|
484
|
+
|
|
485
|
+
it('a correct passphrase after a failed attempt still mints', async () => {
|
|
486
|
+
const { deps: hDeps } = makeHandlerDeps()
|
|
487
|
+
hDeps.pendingVaultRequestAccesses.set('s1', stagedAccess())
|
|
488
|
+
armQueue(hDeps, [{ stageId: 's1', cardMessageId: 42 }])
|
|
489
|
+
const { deps } = makeInterceptDeps(hDeps)
|
|
490
|
+
const { ctx, edits } = makeCtx()
|
|
491
|
+
|
|
492
|
+
brokerState.mintResults.push({ kind: 'error', msg: MISMATCH_MSG })
|
|
493
|
+
await interceptVault({ ctx, text: 'wrong', chat_id: CHAT, msgId: 7 }, deps)
|
|
494
|
+
brokerState.mintResults.push({ kind: 'ok', token: 't', id: 'vg_ok1', expires_at: null })
|
|
495
|
+
await interceptVault({ ctx, text: 'right', chat_id: CHAT, msgId: 8 }, deps)
|
|
496
|
+
|
|
497
|
+
expect(hDeps.pendingVaultRequestAccesses.has('s1')).toBe(false)
|
|
498
|
+
expect(hDeps.pendingVaultOps.get(CHAT)).toBeUndefined()
|
|
499
|
+
expect(edits.at(-1)!.text).toContain('vg_ok1')
|
|
500
|
+
})
|
|
501
|
+
})
|
|
502
|
+
|
|
503
|
+
// ── item 2: no silent failure when the resolution edit itself fails ───────
|
|
504
|
+
|
|
505
|
+
describe('resolution edit failure fallback (#3627 item 2)', () => {
|
|
506
|
+
it('sends a fresh message carrying the resolved state when the edit fails', async () => {
|
|
507
|
+
const { deps: hDeps, sent } = makeHandlerDeps()
|
|
508
|
+
hDeps.pendingVaultRequestAccesses.set('s1', stagedAccess())
|
|
509
|
+
armQueue(hDeps, [{ stageId: 's1', cardMessageId: 42 }])
|
|
510
|
+
const { deps } = makeInterceptDeps(hDeps)
|
|
511
|
+
const { ctx } = makeCtx({ editThrows: true })
|
|
512
|
+
brokerState.mintResults.push({ kind: 'ok', token: 't', id: 'vg_fallback', expires_at: null })
|
|
513
|
+
|
|
514
|
+
await interceptVault({ ctx, text: 'right', chat_id: CHAT, msgId: 7 }, deps)
|
|
515
|
+
|
|
516
|
+
// The card edit blew up, but the operator still learns the outcome.
|
|
517
|
+
expect(sent).toHaveLength(1)
|
|
518
|
+
expect(sent[0]!.text).toContain('vg_fallback')
|
|
519
|
+
})
|
|
520
|
+
|
|
521
|
+
it('does not throw into the caller when the fallback send ALSO fails', async () => {
|
|
522
|
+
const { deps: hDeps, fakeBot } = makeHandlerDeps()
|
|
523
|
+
fakeBot.api.sendRichMessage.mockImplementation(async () => {
|
|
524
|
+
throw new Error('chat not found')
|
|
525
|
+
})
|
|
526
|
+
hDeps.pendingVaultRequestAccesses.set('s1', stagedAccess())
|
|
527
|
+
armQueue(hDeps, [{ stageId: 's1', cardMessageId: 42 }])
|
|
528
|
+
const { deps } = makeInterceptDeps(hDeps)
|
|
529
|
+
const { ctx } = makeCtx({ editThrows: true })
|
|
530
|
+
brokerState.mintResults.push({ kind: 'ok', token: 't', id: 'vg_x', expires_at: null })
|
|
531
|
+
|
|
532
|
+
const out = await interceptVault({ ctx, text: 'right', chat_id: CHAT, msgId: 7 }, deps)
|
|
533
|
+
|
|
534
|
+
// The intercept still completes and still consumes the message — a
|
|
535
|
+
// Telegram-side failure must never leak into the drain loop.
|
|
536
|
+
expect(out.handled).toBe(true)
|
|
537
|
+
expect(hDeps.pendingVaultRequestAccesses.has('s1')).toBe(false)
|
|
538
|
+
})
|
|
539
|
+
})
|
|
540
|
+
|
|
541
|
+
// ── review follow-ups: the retry loop's edges ─────────────────────────────
|
|
542
|
+
|
|
543
|
+
describe('retry loop — attestation, topic routing and the token write', () => {
|
|
544
|
+
it('a POSTURE attestation never retries a mismatch-shaped error', async () => {
|
|
545
|
+
// `approvalAuth: telegram-id` mints with `{ kind: 'posture' }` — the
|
|
546
|
+
// gateway never sent a passphrase, so there is nothing for the operator
|
|
547
|
+
// to retype. A mismatch-shaped broker message on that path must stay
|
|
548
|
+
// TERMINAL; treating it as retryable would leave the stage alive with a
|
|
549
|
+
// re-prompt the operator can't satisfy.
|
|
550
|
+
const { deps: hDeps, sent } = makeHandlerDeps()
|
|
551
|
+
hDeps.pendingVaultRequestAccesses.set('s1', stagedAccess())
|
|
552
|
+
const handlers = createCallbackQueryHandlers(hDeps)
|
|
553
|
+
const { ctx, edits } = makeCtx()
|
|
554
|
+
brokerState.mintResults.push({ kind: 'error', msg: MISMATCH_MSG })
|
|
555
|
+
|
|
556
|
+
const outcome = await handlers.performVaultAccessApproval(
|
|
557
|
+
ctx,
|
|
558
|
+
stagedAccess(),
|
|
559
|
+
's1',
|
|
560
|
+
'111',
|
|
561
|
+
{ kind: 'posture' },
|
|
562
|
+
)
|
|
563
|
+
|
|
564
|
+
expect(outcome.kind).toBe('failed')
|
|
565
|
+
expect(hDeps.pendingVaultRequestAccesses.has('s1')).toBe(false)
|
|
566
|
+
expect(hDeps.pendingCardStore.remove).toHaveBeenCalledWith('s1')
|
|
567
|
+
expect(sent).toHaveLength(0)
|
|
568
|
+
expect(edits.at(-1)!.text).toContain('mint_grant failed')
|
|
569
|
+
})
|
|
570
|
+
|
|
571
|
+
it('a card tapped mid-retry INHERITS the burned attempts (no free reset)', async () => {
|
|
572
|
+
// Tapping a second Approve between attempts must not hand out a fresh
|
|
573
|
+
// budget of 3. Drives the real approve callback so the queue-join branch
|
|
574
|
+
// itself is under test, not a hand-built pending op.
|
|
575
|
+
const { deps: hDeps, sent } = makeHandlerDeps()
|
|
576
|
+
hDeps.pendingVaultRequestAccesses.set('s1', stagedAccess())
|
|
577
|
+
hDeps.pendingVaultRequestAccesses.set(
|
|
578
|
+
's2',
|
|
579
|
+
stagedAccess({ key: 'coolify/api-token', card_message_id: 43 }),
|
|
580
|
+
)
|
|
581
|
+
// Two wrong entries already burned on s1.
|
|
582
|
+
armQueue(hDeps, [{ stageId: 's1', cardMessageId: 42 }], 2)
|
|
583
|
+
const handlers = createCallbackQueryHandlers(hDeps)
|
|
584
|
+
const { ctx } = makeCtx()
|
|
585
|
+
|
|
586
|
+
await handlers.handleVaultRequestAccessCallback(ctx, 'vra:approve:s2')
|
|
587
|
+
|
|
588
|
+
const joined = accessOp(hDeps.pendingVaultOps.get(CHAT))
|
|
589
|
+
expect(joined?.attempts).toBe(2)
|
|
590
|
+
expect(joined?.items.map((i) => i.stageId)).toEqual(['s1', 's2'])
|
|
591
|
+
|
|
592
|
+
// The very next wrong passphrase is the third — terminal for BOTH cards.
|
|
593
|
+
const { deps } = makeInterceptDeps(hDeps)
|
|
594
|
+
brokerState.mintResults.push({ kind: 'error', msg: MISMATCH_MSG })
|
|
595
|
+
brokerState.mintResults.push({ kind: 'error', msg: MISMATCH_MSG })
|
|
596
|
+
await interceptVault({ ctx, text: 'nope-3', chat_id: CHAT, msgId: 9 }, deps)
|
|
597
|
+
|
|
598
|
+
expect(hDeps.pendingVaultRequestAccesses.has('s1')).toBe(false)
|
|
599
|
+
expect(hDeps.pendingVaultRequestAccesses.has('s2')).toBe(false)
|
|
600
|
+
expect(hDeps.pendingVaultOps.get(CHAT)).toBeUndefined()
|
|
601
|
+
// The join prompt is the only send — no retry prompt after the lockout.
|
|
602
|
+
expect(sent.filter((s) => s.text.includes('attempts remaining'))).toHaveLength(0)
|
|
603
|
+
})
|
|
604
|
+
|
|
605
|
+
it('routes the retry prompt into the card’s forum topic', async () => {
|
|
606
|
+
// A batch re-prompt posted to General while the card sits in a busy topic
|
|
607
|
+
// is the same "operator never sees it" failure #3627 item 1 closes.
|
|
608
|
+
const { deps: hDeps, sent } = makeHandlerDeps()
|
|
609
|
+
hDeps.pendingVaultRequestAccesses.set('s1', stagedAccess({ threadId: 77 }))
|
|
610
|
+
hDeps.pendingVaultOps.set(CHAT, {
|
|
611
|
+
kind: 'passphrase-for-access-approve',
|
|
612
|
+
items: [{ stageId: 's1', cardChatId: CHAT, cardMessageId: 42, senderId: '111', threadId: 77 }],
|
|
613
|
+
startedAt: Date.now(),
|
|
614
|
+
})
|
|
615
|
+
const { deps } = makeInterceptDeps(hDeps)
|
|
616
|
+
const { ctx } = makeCtx()
|
|
617
|
+
brokerState.mintResults.push({ kind: 'error', msg: MISMATCH_MSG })
|
|
618
|
+
|
|
619
|
+
await interceptVault({ ctx, text: 'wrong', chat_id: CHAT, msgId: 7 }, deps)
|
|
620
|
+
|
|
621
|
+
expect(sent).toHaveLength(1)
|
|
622
|
+
expect(sent[0]!.threadId).toBe(77)
|
|
623
|
+
})
|
|
624
|
+
|
|
625
|
+
it('the CACHED-passphrase tap re-prompts, and survives a card with no message id', async () => {
|
|
626
|
+
// The Approve tap with a live (but wrong) cached passphrase never reaches
|
|
627
|
+
// interceptVault, so it has its own mismatch wiring. Also exercises the
|
|
628
|
+
// `card_message_id == null` case: there is no card to edit, so the
|
|
629
|
+
// resolution has to reach the operator as a fresh message instead.
|
|
630
|
+
const { deps: hDeps, sent } = makeHandlerDeps()
|
|
631
|
+
hDeps.vaultPassphraseCache.set(CHAT, { passphrase: 'stale', expiresAt: Date.now() + 60_000 })
|
|
632
|
+
hDeps.pendingVaultRequestAccesses.set('s1', stagedAccess({ card_message_id: undefined }))
|
|
633
|
+
const handlers = createCallbackQueryHandlers(hDeps)
|
|
634
|
+
const { ctx, edits } = makeCtx()
|
|
635
|
+
brokerState.mintResults.push({ kind: 'error', msg: MISMATCH_MSG })
|
|
636
|
+
|
|
637
|
+
await handlers.handleVaultRequestAccessCallback(ctx, 'vra:approve:s1')
|
|
638
|
+
|
|
639
|
+
// Stage survives, the stale cache is dropped, and the operator is asked
|
|
640
|
+
// again with the count.
|
|
641
|
+
expect(hDeps.pendingVaultRequestAccesses.has('s1')).toBe(true)
|
|
642
|
+
expect(hDeps.vaultPassphraseCache.get(CHAT)).toBeUndefined()
|
|
643
|
+
expect(accessOp(hDeps.pendingVaultOps.get(CHAT))?.attempts).toBe(1)
|
|
644
|
+
expect(sent).toHaveLength(1)
|
|
645
|
+
expect(sent[0]!.text).toContain('Wrong passphrase. 2 attempts remaining.')
|
|
646
|
+
expect(edits).toHaveLength(0)
|
|
647
|
+
})
|
|
648
|
+
|
|
649
|
+
it('writes the minted token to the injected vaultTokenFilePath', async () => {
|
|
650
|
+
// Pins the #3627 switch from an inline `homedir()` path formula to the
|
|
651
|
+
// broker client's `vaultTokenFilePath` seam: the gateway must write where
|
|
652
|
+
// the reader looks, not where `homedir()` happens to point.
|
|
653
|
+
const { deps: hDeps } = makeHandlerDeps()
|
|
654
|
+
hDeps.pendingVaultRequestAccesses.set('s1', stagedAccess())
|
|
655
|
+
armQueue(hDeps, [{ stageId: 's1', cardMessageId: 42 }])
|
|
656
|
+
const { deps } = makeInterceptDeps(hDeps)
|
|
657
|
+
const { ctx } = makeCtx()
|
|
658
|
+
brokerState.mintResults.push({ kind: 'ok', token: 'vgt_fake', id: 'vg_tok', expires_at: null })
|
|
659
|
+
|
|
660
|
+
await interceptVault({ ctx, text: 'right', chat_id: CHAT, msgId: 7 }, deps)
|
|
661
|
+
|
|
662
|
+
const path = join(tokenDir, 'worker', '.vault-token')
|
|
663
|
+
expect(existsSync(path)).toBe(true)
|
|
664
|
+
expect(readFileSync(path, 'utf-8')).toBe('vgt_fake')
|
|
665
|
+
})
|
|
666
|
+
})
|