switchroom 0.18.7 → 0.18.9
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 +2 -2
- package/dist/cli/switchroom.js +905 -758
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +111 -34
- package/skills/switchroom-runtime/SKILL.md +2 -0
- package/telegram-plugin/dist/gateway/gateway.js +46273 -44324
- package/telegram-plugin/flood-circuit-breaker.ts +123 -0
- package/telegram-plugin/gateway/activity-card-store.ts +63 -18
- package/telegram-plugin/gateway/approval-card-stores.ts +99 -0
- package/telegram-plugin/gateway/boot-card.ts +27 -0
- package/telegram-plugin/gateway/bot-commands-ops-info.ts +194 -0
- package/telegram-plugin/gateway/busy-ack.ts +106 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +2660 -0
- package/telegram-plugin/gateway/gateway.ts +1169 -3043
- package/telegram-plugin/gateway/inbound-delivery-machine-dispatch.ts +181 -23
- package/telegram-plugin/gateway/inbound-delivery-machine.ts +8 -0
- package/telegram-plugin/gateway/mental-model-propose-diff.ts +61 -5
- package/telegram-plugin/gateway/model-command.ts +23 -11
- package/telegram-plugin/gateway/outbound-send-path.ts +375 -0
- package/telegram-plugin/gateway/pending-state-stores.ts +106 -0
- package/telegram-plugin/gateway/register-bot-commands.ts +30 -0
- package/telegram-plugin/gateway/session-model-file.ts +198 -0
- package/telegram-plugin/gateway/status-pin-store.ts +82 -22
- package/telegram-plugin/gateway/worker-pin-reaper.ts +114 -0
- package/telegram-plugin/hooks/hooks.json +10 -10
- package/telegram-plugin/hooks/run-hook.sh +84 -0
- package/telegram-plugin/model-unavailable.ts +26 -0
- package/telegram-plugin/pty-partial-handler.ts +39 -0
- package/telegram-plugin/render/rich-render.ts +79 -1
- package/telegram-plugin/retry-api-call.ts +62 -0
- package/telegram-plugin/shared/bot-runtime.ts +8 -1
- package/telegram-plugin/silence-poke.ts +14 -0
- package/telegram-plugin/stream-controller.ts +156 -38
- package/telegram-plugin/tests/activity-card-store.test.ts +47 -2
- package/telegram-plugin/tests/approval-card-restart-outcome.test.ts +218 -0
- package/telegram-plugin/tests/approval-card-stores.test.ts +124 -0
- package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +111 -0
- package/telegram-plugin/tests/busy-ack-wiring.test.ts +118 -0
- package/telegram-plugin/tests/busy-ack.test.ts +121 -0
- package/telegram-plugin/tests/callback-query-handlers.test.ts +701 -0
- package/telegram-plugin/tests/emission-determinism-wiring.test.ts +11 -4
- package/telegram-plugin/tests/fixtures/cutover-killswitch-probe.ts +75 -0
- package/telegram-plugin/tests/flood-circuit-breaker.test.ts +74 -0
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +5 -1
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +177 -25
- package/telegram-plugin/tests/inbound-delivery-cutover-flip.test.ts +418 -0
- package/telegram-plugin/tests/inbound-delivery-dispatch-equivalence.test.ts +348 -0
- package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +141 -52
- package/telegram-plugin/tests/mental-model-name-entity-corruption.test.ts +119 -0
- package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -1
- package/telegram-plugin/tests/model-command.test.ts +2 -2
- package/telegram-plugin/tests/model-unavailable.test.ts +41 -0
- package/telegram-plugin/tests/outbound-send-chunks.test.ts +304 -0
- package/telegram-plugin/tests/outbound-send-path.test.ts +222 -0
- package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +34 -15
- package/telegram-plugin/tests/pending-state-stores.test.ts +235 -0
- package/telegram-plugin/tests/pty-partial-handler.test.ts +56 -0
- package/telegram-plugin/tests/render/render-outbound-chunks.test.ts +98 -0
- package/telegram-plugin/tests/retry-api-call.test.ts +59 -0
- package/telegram-plugin/tests/run-hook-wrapper.test.ts +132 -0
- package/telegram-plugin/tests/session-model-file.test.ts +132 -0
- package/telegram-plugin/tests/slot-banner-boot-recovery.test.ts +3 -3
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +3 -3
- package/telegram-plugin/tests/status-pin-store.test.ts +62 -6
- package/telegram-plugin/tests/stream-controller-chunk-cap.test.ts +122 -0
- package/telegram-plugin/tests/turn-flush-safety.test.ts +18 -4
- package/telegram-plugin/tests/vault-approval-posture.test.ts +15 -7
- package/telegram-plugin/tests/vault-grant-auto-resume.test.ts +8 -4
- package/telegram-plugin/tests/vault-grant-union.test.ts +8 -4
- package/telegram-plugin/tests/vault-grant-wizard.test.ts +8 -1
- package/telegram-plugin/tests/vault-grants-revoke.test.ts +8 -1
- package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +8 -4
- package/telegram-plugin/tests/vault-request-access-tool.test.ts +8 -4
- package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +8 -4
- package/telegram-plugin/tests/voice-send.test.ts +308 -0
- package/telegram-plugin/tests/worker-pin-reaper.test.ts +132 -0
- package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +118 -0
- package/telegram-plugin/uat/scenarios/jtbd-midflight-busy-ack-dm.test.ts +201 -0
- package/telegram-plugin/uat/scenarios/jtbd-worker-pin-lifecycle-dm.test.ts +208 -0
- package/telegram-plugin/uat/scenarios/vault-card-survives-gateway-restart-dm.test.ts +140 -0
- package/telegram-plugin/uat/scenarios/vault-deny-resumes-turn-dm.test.ts +84 -0
- package/telegram-plugin/uat/scenarios/vault-timeout-wakes-agent-dm.test.ts +91 -0
- package/telegram-plugin/voice-ondemand.ts +25 -1
- package/telegram-plugin/voice-send.ts +154 -0
|
@@ -0,0 +1,2660 @@
|
|
|
1
|
+
// Callback-query handlers — the vault / skill / mental-model / operator-event /
|
|
2
|
+
// auth-dashboard callback families, extracted verbatim from gateway.ts
|
|
3
|
+
// (#2996 Phase 5, remaining item 2).
|
|
4
|
+
//
|
|
5
|
+
// This module owns the handler LOGIC for the `bot.on('callback_query:data')`
|
|
6
|
+
// families that were previously ~2,200 lines of free functions inside
|
|
7
|
+
// gateway.ts:
|
|
8
|
+
//
|
|
9
|
+
// - vrd:* — /vault audit one-tap "Always allow" (recent denials)
|
|
10
|
+
// - vra:* — vault_request_access approve/deny (+ performVaultAccessApproval,
|
|
11
|
+
// shared with the passphrase-capture resume flow)
|
|
12
|
+
// - vrs:* — vault_request_save save/discard/rename
|
|
13
|
+
// - vd:* — deferred-secret unlock/cancel (+ executeDeferredSecretSave,
|
|
14
|
+
// shared with the passphrase text-intercept)
|
|
15
|
+
// - vg:* — vault grant management + the /vault grant wizard (all steps)
|
|
16
|
+
// - mmp:* — mental-model proposal approve/deny
|
|
17
|
+
// - sp:* — skill-improvement proposal approve/dismiss
|
|
18
|
+
// - op:* — operator-event card actions (dismiss/restart/reauth/logs)
|
|
19
|
+
// - auth:* — auth dashboard (fleet account swap, snapshot refresh)
|
|
20
|
+
//
|
|
21
|
+
// Deliberately NOT moved (they stay in gateway.ts): the dispatcher itself
|
|
22
|
+
// (`bot.on('callback_query:data', …)` — it also routes agent:*, mdl:*, eff:*,
|
|
23
|
+
// cn:*, cfg:*, and permission-card callbacks whose state is entangled with
|
|
24
|
+
// the durability layer), the card RENDER/stage paths (vault_request_* MCP
|
|
25
|
+
// tool handlers), and the pending-store constructions (TTL sweeps + expiry
|
|
26
|
+
// thunks live with the reaper).
|
|
27
|
+
//
|
|
28
|
+
// Style: factory over a deps object, following outbound-send-path.ts /
|
|
29
|
+
// register-bot-commands.ts / pending-state-stores.ts (PRs #3007-#3011).
|
|
30
|
+
// Everything gateway-LOCAL (module state, stores, config-derived mutable
|
|
31
|
+
// flags, wrapped API callers) is injected; everything gateway itself imports
|
|
32
|
+
// from other modules is imported here directly. Function bodies are
|
|
33
|
+
// byte-identical to the pre-extraction gateway.ts text except for four
|
|
34
|
+
// mechanical reads of formerly-mutable module `let`s, which became injected
|
|
35
|
+
// getters (`getVaultApprovalAuthMode()`, `getAdminOnlyKeys()`) so config
|
|
36
|
+
// reloads keep being observed live.
|
|
37
|
+
|
|
38
|
+
import { execFileSync } from 'child_process'
|
|
39
|
+
import { mkdirSync, writeFileSync } from 'fs'
|
|
40
|
+
import { homedir } from 'os'
|
|
41
|
+
import { join } from 'path'
|
|
42
|
+
import { InlineKeyboard, type Context } from 'grammy'
|
|
43
|
+
import { richMessage } from '../rich-send.js'
|
|
44
|
+
import { finalizeCallback } from '../inline-keyboard-callbacks.js'
|
|
45
|
+
import { retryWithThreadFallback, type RetryCallOpts } from '../retry-api-call.js'
|
|
46
|
+
import {
|
|
47
|
+
mintGrantViaBroker,
|
|
48
|
+
listViaBroker,
|
|
49
|
+
listGrantsViaBroker,
|
|
50
|
+
revokeGrantViaBroker,
|
|
51
|
+
} from '../../src/vault/broker/client.js'
|
|
52
|
+
import {
|
|
53
|
+
buildVaultGrantApprovedInbound,
|
|
54
|
+
buildVaultGrantApprovedCardText,
|
|
55
|
+
buildVaultGrantDeniedInbound,
|
|
56
|
+
buildVaultSaveCompletedInbound,
|
|
57
|
+
buildVaultSaveFailedInbound,
|
|
58
|
+
buildVaultSaveDiscardedInbound,
|
|
59
|
+
} from './vault-grant-inbound-builders.js'
|
|
60
|
+
import {
|
|
61
|
+
resolveMentalModelProposal,
|
|
62
|
+
type MentalModelPendingProposal,
|
|
63
|
+
} from './mental-model-propose-resolve.js'
|
|
64
|
+
import {
|
|
65
|
+
parseSkillProposalCallback,
|
|
66
|
+
buildSkillProposalApplyInbound,
|
|
67
|
+
} from './skill-proposal-card.js'
|
|
68
|
+
import {
|
|
69
|
+
getProposal as getSkillProposal,
|
|
70
|
+
setProposalStatus as setSkillProposalStatus,
|
|
71
|
+
} from '../../src/self-improve/skill-proposals.js'
|
|
72
|
+
import { maskToken } from '../secret-detect/mask.js'
|
|
73
|
+
import {
|
|
74
|
+
defaultVaultWrite,
|
|
75
|
+
defaultVaultList,
|
|
76
|
+
defaultVaultWritePosture,
|
|
77
|
+
} from '../secret-detect/vault-write.js'
|
|
78
|
+
import { parseVaultCliError, renderVaultCliError } from '../secret-detect/vault-error.js'
|
|
79
|
+
import type { StagingMap } from '../secret-detect/staging.js'
|
|
80
|
+
import { zipProbeResults } from '../auth-snapshot-format.js'
|
|
81
|
+
import { matchesAdminOnlyKey } from '../../src/vault/admin-only-keys.js'
|
|
82
|
+
import { getAuthBrokerClient } from './auth-broker-client.js'
|
|
83
|
+
import { chatKey } from './chat-key.js'
|
|
84
|
+
import { tryHostdDispatch, hostdRequestId } from './hostd-dispatch.js'
|
|
85
|
+
import type { HostdRequest } from '../../src/host-control/protocol.js'
|
|
86
|
+
import type { InboundMessage } from './ipc-protocol.js'
|
|
87
|
+
import type { SweepableCardStore } from './approval-card-stores.js'
|
|
88
|
+
import type { SweepableStore } from './pending-state-stores.js'
|
|
89
|
+
|
|
90
|
+
// ─── Pending-state entry types (moved with the handlers; the stores that
|
|
91
|
+
// hold them are still constructed in gateway.ts, which imports these
|
|
92
|
+
// types back) ─────────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
export type PendingVaultOp =
|
|
95
|
+
| { kind: 'passphrase'; op: 'list' | 'get' | 'delete' | 'set'; key?: string; startedAt: number }
|
|
96
|
+
| { kind: 'value'; op: 'set'; key: string; passphrase: string; startedAt: number }
|
|
97
|
+
// Issue #44: passphrase entry triggered by tapping "🔓 Unlock vault & save"
|
|
98
|
+
// on a deferred-secret card. After the passphrase is cached we look up the
|
|
99
|
+
// held secret by deferKey and write it directly — no re-paste required.
|
|
100
|
+
| {
|
|
101
|
+
kind: 'passphrase-for-deferred'
|
|
102
|
+
deferKey: string
|
|
103
|
+
cardChatId: string
|
|
104
|
+
cardMessageId: number
|
|
105
|
+
startedAt: number
|
|
106
|
+
}
|
|
107
|
+
// Issue #158: passphrase collected for /vault unlock — sent directly to the
|
|
108
|
+
// broker unlock socket, never logged or cached beyond the op itself.
|
|
109
|
+
| { kind: 'unlock'; startedAt: number }
|
|
110
|
+
// Issue #227: inline-keyboard wizard for /vault grant
|
|
111
|
+
| {
|
|
112
|
+
kind: 'grant-wizard'
|
|
113
|
+
step: 'agent' | 'keys' | 'duration' | 'confirm'
|
|
114
|
+
wizardMsgId?: number // message to edit for each step
|
|
115
|
+
agent?: string
|
|
116
|
+
selectedKeys?: string[] // keys toggled on in step 2
|
|
117
|
+
availableKeys?: string[] // list fetched from broker
|
|
118
|
+
ttlSeconds?: number | null // null = never expires
|
|
119
|
+
expiresLabel?: string // human-readable label for confirmation
|
|
120
|
+
description?: string
|
|
121
|
+
awaitingCustomDuration?: boolean // true while waiting for text reply
|
|
122
|
+
/**
|
|
123
|
+
* Approval-kernel request_id minted at the wizard confirm step
|
|
124
|
+
* (MIGRATION.md §2, Phase 1 dual-dispatch — audit-only, advisory).
|
|
125
|
+
* When set, `vg:generate` ALSO consumes + records an `allow_once`
|
|
126
|
+
* decision on the kernel; `vg:cancel` records a `deny`. Cards in
|
|
127
|
+
* flight from before this PR landed have it `undefined` and the
|
|
128
|
+
* legacy `mintGrantViaBroker` runs alone — no kernel write. After
|
|
129
|
+
* 1-2 releases the legacy-only branch can be removed (#833 Phase 2
|
|
130
|
+
* is the enforcing flip).
|
|
131
|
+
*/
|
|
132
|
+
kernel_request_id?: string
|
|
133
|
+
startedAt: number
|
|
134
|
+
}
|
|
135
|
+
// Issue #228: waiting for confirmation before revoking a grant.
|
|
136
|
+
| { kind: 'revoke_confirm'; grantId: string; agent: string; keys: string[]; startedAt: number }
|
|
137
|
+
// Issue #969 P1a: user tapped "Rename" on a vault_request_save card;
|
|
138
|
+
// the next message becomes the new key name for the staged save.
|
|
139
|
+
| { kind: 'rename-vault-save'; stageId: string; startedAt: number }
|
|
140
|
+
// Issue #1012 Phase 2 follow-up: operator tapped Approve on a
|
|
141
|
+
// vault_request_access card without first unlocking the vault. The
|
|
142
|
+
// next message becomes the passphrase — we cache it, delete the
|
|
143
|
+
// passphrase message, and auto-resume the approval mint flow without
|
|
144
|
+
// making the operator tap Approve a second time. Mirrors the
|
|
145
|
+
// `passphrase-for-deferred` flow from #44.
|
|
146
|
+
//
|
|
147
|
+
// #1051: `items` is a queue so concurrent Approve taps (operator
|
|
148
|
+
// taps card 2 before typing passphrase for card 1) don't strand
|
|
149
|
+
// earlier stages. On passphrase reply we process all queued items
|
|
150
|
+
// sequentially. Each item carries its own stageId + card refs;
|
|
151
|
+
// they're all in the same chat by construction (pendingVaultOps
|
|
152
|
+
// map is keyed by chat_id).
|
|
153
|
+
| {
|
|
154
|
+
kind: 'passphrase-for-access-approve'
|
|
155
|
+
items: Array<{
|
|
156
|
+
stageId: string
|
|
157
|
+
cardChatId: string
|
|
158
|
+
cardMessageId: number
|
|
159
|
+
senderId: string
|
|
160
|
+
}>
|
|
161
|
+
startedAt: number
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export interface DeferredSecret {
|
|
165
|
+
chat_id: string
|
|
166
|
+
original_message_id: number
|
|
167
|
+
text: string
|
|
168
|
+
staged_at: number
|
|
169
|
+
/**
|
|
170
|
+
* Slug suggested by the detector at the time we deferred the secret.
|
|
171
|
+
* Captured up-front so the post-unlock auto-write doesn't have to re-run
|
|
172
|
+
* detection (which would have to handle the no-detection-fired case for
|
|
173
|
+
* Channel B context-rule defers — issue #44). Falls back to a generic
|
|
174
|
+
* slug if detection didn't fire.
|
|
175
|
+
*/
|
|
176
|
+
suggested_slug: string
|
|
177
|
+
/**
|
|
178
|
+
* Approval-kernel request_id minted alongside the bespoke deferred-secret
|
|
179
|
+
* card (MIGRATION.md §1, Phase 1 dual-dispatch). When set, the
|
|
180
|
+
* `vd:unlock` / `vd:cancel` callback handler ALSO records the user's
|
|
181
|
+
* decision on the kernel side via `approvalConsume` + `approvalRecord`,
|
|
182
|
+
* so the audit log captures the unlock event.
|
|
183
|
+
*
|
|
184
|
+
* `undefined` on cards built before this PR landed (in-flight at deploy
|
|
185
|
+
* time) — the legacy handler runs alone, no kernel record. After ~1-2
|
|
186
|
+
* releases the legacy-only branch can be removed (separate cleanup PR).
|
|
187
|
+
*/
|
|
188
|
+
kernel_request_id?: string
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Agent-initiated save staging (issue #969 P1a). When an agent calls the
|
|
193
|
+
* `vault_request_save` MCP tool, we stage the value here, render an
|
|
194
|
+
* approval card to the user, and write to vault only on tap. The value
|
|
195
|
+
* is held in gateway memory ONLY — never echoed back to the agent and
|
|
196
|
+
* never logged.
|
|
197
|
+
*/
|
|
198
|
+
export interface PendingVaultRequestSave {
|
|
199
|
+
/** Agent that requested the save (process.env.SWITCHROOM_AGENT_NAME). */
|
|
200
|
+
agent: string
|
|
201
|
+
/** Chat to edit when the user taps. */
|
|
202
|
+
chat_id: string
|
|
203
|
+
/** Card message id (filled in after we send the card). */
|
|
204
|
+
card_message_id?: number
|
|
205
|
+
/** Supergroup forum topic the agent was working in when it requested the
|
|
206
|
+
* save — carried into the save-outcome inbound so the resumed reply lands
|
|
207
|
+
* back in that topic, not General. */
|
|
208
|
+
threadId?: number
|
|
209
|
+
/** Currently-suggested slug; may be renamed by the user. */
|
|
210
|
+
key: string
|
|
211
|
+
/** Storage shape — 'string' (default) or 'binary'. */
|
|
212
|
+
kind: 'string' | 'binary'
|
|
213
|
+
/** The secret value, held in memory until the user approves/discards. */
|
|
214
|
+
value: string
|
|
215
|
+
/** Optional rationale shown on the card. */
|
|
216
|
+
why?: string
|
|
217
|
+
/** Unix-ms timestamp; entries are reaped after VAULT_REQUEST_SAVE_TTL_MS. */
|
|
218
|
+
staged_at: number
|
|
219
|
+
/** Set on entries RESTORED from disk after a gateway restart. The staged
|
|
220
|
+
* secret `value` is held in memory only (never persisted — secrets
|
|
221
|
+
* hygiene), so a restored entry has an empty value and cannot complete the
|
|
222
|
+
* write. A Save tap on such a card degrades gracefully: it tells the agent
|
|
223
|
+
* the value was lost to a restart instead of writing an empty secret. */
|
|
224
|
+
restoredWithoutValue?: boolean
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Issue #1012 — agent-initiated vault ACL request. The agent calls
|
|
229
|
+
* `vault_request_access` when it hits VAULT-BROKER-DENIED (or
|
|
230
|
+
* preemptively, when it knows it'll need a key it doesn't yet have).
|
|
231
|
+
* The card carries [Approve] / [Deny] inline buttons; only the
|
|
232
|
+
* operator can mint the grant (same authorization gate as the
|
|
233
|
+
* existing /vault audit one-tap allow flow). The agent never sees
|
|
234
|
+
* the grant token directly — `mintGrantViaBroker` writes it to the
|
|
235
|
+
* agent's `.vault-token` file, which the agent's CLI reads on the
|
|
236
|
+
* next vault request.
|
|
237
|
+
*
|
|
238
|
+
* Mirrors PendingVaultRequestSave above (#969 P1a). No secret
|
|
239
|
+
* material is staged here — only the request metadata.
|
|
240
|
+
*/
|
|
241
|
+
export interface PendingVaultRequestAccess {
|
|
242
|
+
/** Agent that initiated the request (process.env.SWITCHROOM_AGENT_NAME). */
|
|
243
|
+
agent: string
|
|
244
|
+
/** Chat the card was rendered into; edited on tap. */
|
|
245
|
+
chat_id: string
|
|
246
|
+
/** Card message id (filled in after we send the card). */
|
|
247
|
+
card_message_id?: number
|
|
248
|
+
/** Supergroup forum topic the agent was working in when it requested (the
|
|
249
|
+
* card's originating thread). Carried into the grant-outcome inbound so the
|
|
250
|
+
* resumed reply lands back in that topic, not General. */
|
|
251
|
+
threadId?: number
|
|
252
|
+
/** Vault key the agent wants to read. */
|
|
253
|
+
key: string
|
|
254
|
+
/** 'read' (default) or 'write'. */
|
|
255
|
+
scope: 'read' | 'write'
|
|
256
|
+
/** Optional rationale the agent supplied; rendered on the card. */
|
|
257
|
+
reason?: string
|
|
258
|
+
/** Grant TTL in seconds (max 90 days; null = never, refused). */
|
|
259
|
+
ttl_seconds: number
|
|
260
|
+
/** Unix-ms timestamp; entries are reaped after VAULT_REQUEST_ACCESS_TTL_MS. */
|
|
261
|
+
staged_at: number
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Staged agent-initiated MENTAL MODEL proposal (hindsight Phase 5). The agent
|
|
266
|
+
* calls `mental_model_propose`; the operator taps Approve/Deny on the card.
|
|
267
|
+
* Mirrors PendingVaultRequestAccess — no memory content is staged here, only
|
|
268
|
+
* the proposed DECLARATION (name + source_query + optional knobs). On Approve
|
|
269
|
+
* the model becomes a first-class declared model in memory.mental_models[] via
|
|
270
|
+
* the operator-approved config-edit path; on Deny nothing is written.
|
|
271
|
+
*/
|
|
272
|
+
export interface PendingMentalModelPropose {
|
|
273
|
+
agent: string
|
|
274
|
+
chat_id: string
|
|
275
|
+
card_message_id?: number
|
|
276
|
+
threadId?: number
|
|
277
|
+
/** Proposed declaration, snake_case (matches memory.mental_models[] schema). */
|
|
278
|
+
spec: {
|
|
279
|
+
name: string
|
|
280
|
+
source_query: string
|
|
281
|
+
refresh_after_consolidation?: boolean
|
|
282
|
+
max_tokens?: number
|
|
283
|
+
}
|
|
284
|
+
reason?: string
|
|
285
|
+
staged_at: number
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ─── Deps ────────────────────────────────────────────────────────────────
|
|
289
|
+
|
|
290
|
+
/** Minimal bot shape the handlers touch (grammy Bot / chat-locked wrapper). */
|
|
291
|
+
export interface CallbackBotApi {
|
|
292
|
+
api: {
|
|
293
|
+
editMessageText: (
|
|
294
|
+
chat_id: number | string,
|
|
295
|
+
message_id: number,
|
|
296
|
+
text: unknown,
|
|
297
|
+
other?: Record<string, unknown>,
|
|
298
|
+
) => Promise<unknown>
|
|
299
|
+
sendRichMessage: (
|
|
300
|
+
chat_id: number | string,
|
|
301
|
+
rich_message: unknown,
|
|
302
|
+
other?: Record<string, unknown>,
|
|
303
|
+
) => Promise<{ message_id: number }>
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Everything the handlers read from gateway module scope. Stores are the
|
|
309
|
+
* consolidated #3008/#3011 store surfaces — never raw Maps. Mutable config
|
|
310
|
+
* `let`s (`VAULT_APPROVAL_AUTH_MODE`, `ADMIN_ONLY_KEYS`) are injected as
|
|
311
|
+
* getters so late config assignment stays observable.
|
|
312
|
+
*/
|
|
313
|
+
export interface CallbackQueryHandlersDeps {
|
|
314
|
+
/** The gateway's grammy bot singleton (raw sends on fallback paths). */
|
|
315
|
+
bot: unknown
|
|
316
|
+
/** The chat-lock-wrapped bot (serialized sends; passphrase prompt path). */
|
|
317
|
+
lockedBot: unknown
|
|
318
|
+
/** Read the live access file (allowFrom gate on every mutating family). */
|
|
319
|
+
loadAccess: () => { allowFrom: string[] }
|
|
320
|
+
escapeHtmlForTg: (text: string) => string
|
|
321
|
+
switchroomReply: (
|
|
322
|
+
ctx: Context,
|
|
323
|
+
text: string,
|
|
324
|
+
options?: {
|
|
325
|
+
html?: boolean
|
|
326
|
+
reply_markup?:
|
|
327
|
+
| InlineKeyboard
|
|
328
|
+
| { force_reply: true; input_field_placeholder?: string; selective?: boolean }
|
|
329
|
+
classification?: 'query' | 'mutation' | 'heavy'
|
|
330
|
+
},
|
|
331
|
+
) => Promise<unknown>
|
|
332
|
+
resolveThreadId: (chat_id: string, explicit?: string | number | null) => number | undefined
|
|
333
|
+
deliverResumeSyntheticOrBuffer: (agent: string, inbound: InboundMessage) => boolean
|
|
334
|
+
expireMentalModelProposeCard: (stageId: string, v: PendingMentalModelPropose, now: number) => void
|
|
335
|
+
readLiveSwitchroomConfigText: () => string
|
|
336
|
+
mentalModelCorrelationKey: (agentName: string, unifiedDiff: string) => string
|
|
337
|
+
getMyAgentName: () => string
|
|
338
|
+
triggerSelfRestart: (targetAgent: string, reason: string, delayMs?: number) => boolean
|
|
339
|
+
runSwitchroomAuthCommand: (ctx: Context, args: string[], label: string) => Promise<void>
|
|
340
|
+
switchroomExecJson: <T = unknown>(args: string[]) => T | null
|
|
341
|
+
assertSafeAgentName: (name: string) => void
|
|
342
|
+
buildDeferredSecretKeyboard: (deferKey: string) => InlineKeyboard
|
|
343
|
+
recordDeferredSecretKernelDecision: (
|
|
344
|
+
request_id: string | undefined,
|
|
345
|
+
decision: 'allow_once' | 'deny',
|
|
346
|
+
granted_by_user_id: number,
|
|
347
|
+
approverSet: string[],
|
|
348
|
+
) => Promise<void>
|
|
349
|
+
mintGrantWizardKernelRequest: (
|
|
350
|
+
agentSlug: string,
|
|
351
|
+
approverSet: string[],
|
|
352
|
+
selectedKeys: string[],
|
|
353
|
+
ttlSeconds: number | null,
|
|
354
|
+
) => Promise<string | null>
|
|
355
|
+
recordGrantWizardKernelDecision: (
|
|
356
|
+
request_id: string | undefined,
|
|
357
|
+
decision: 'allow_once' | 'deny',
|
|
358
|
+
granted_by_user_id: number,
|
|
359
|
+
approverSet: string[],
|
|
360
|
+
) => Promise<void>
|
|
361
|
+
/** Flood-wait-aware retry wrapper (gateway's `robustApiCall`). */
|
|
362
|
+
robustApiCall: <T>(fn: () => Promise<T>, opts?: RetryCallOpts) => Promise<T>
|
|
363
|
+
/** Fire-and-forget retry wrapper (gateway's `swallowingApiCall`, #1075). */
|
|
364
|
+
swallowingApiCall: <T>(fn: () => Promise<T>, opts?: RetryCallOpts) => Promise<T | undefined>
|
|
365
|
+
// Pending-state stores (consolidated surfaces from #3008/#3011).
|
|
366
|
+
pendingVaultRequestAccesses: SweepableCardStore<PendingVaultRequestAccess>
|
|
367
|
+
pendingVaultRequestSaves: SweepableCardStore<PendingVaultRequestSave>
|
|
368
|
+
pendingMentalModelProposes: SweepableCardStore<PendingMentalModelPropose>
|
|
369
|
+
pendingCardStore: { remove(stageId: string): void }
|
|
370
|
+
pendingMentalModelCorrelations: SweepableStore<{
|
|
371
|
+
agentName: string
|
|
372
|
+
unifiedDiff: string
|
|
373
|
+
createdAt: number
|
|
374
|
+
}>
|
|
375
|
+
pendingVaultOps: SweepableStore<PendingVaultOp>
|
|
376
|
+
vaultPassphraseCache: SweepableStore<{ passphrase: string; expiresAt: number }>
|
|
377
|
+
deferredSecrets: SweepableStore<DeferredSecret>
|
|
378
|
+
pendingReauthFlows: SweepableStore<{ agent: string; startedAt: number }>
|
|
379
|
+
secretStaging: StagingMap
|
|
380
|
+
/** Refresh throttle timestamps for the /auth dashboard ↻ button (the
|
|
381
|
+
* 60s reaper for this map stays in gateway.ts). */
|
|
382
|
+
lastAuthRefreshAtMs: Map<string, number>
|
|
383
|
+
/** Live read of the mutable `VAULT_APPROVAL_AUTH_MODE` module let. */
|
|
384
|
+
getVaultApprovalAuthMode: () => 'passphrase' | 'telegram-id'
|
|
385
|
+
/** Live read of the mutable `ADMIN_ONLY_KEYS` module let. */
|
|
386
|
+
getAdminOnlyKeys: () => string[]
|
|
387
|
+
/** Gateway-side vault-key shape gate (VAULT_KEY_REGEX; UX gate, not a
|
|
388
|
+
* security boundary). */
|
|
389
|
+
vaultKeyRegex: RegExp
|
|
390
|
+
/** MENTAL_MODEL_PROPOSE_TTL_MS (config-driven approval-card lifetime). */
|
|
391
|
+
mentalModelProposeTtlMs: number
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Freshness throttle for the /auth dashboard ↻ refresh button — one live
|
|
395
|
+
// probe fan-out per (chat, message) per window. Moved with the handler.
|
|
396
|
+
const AUTH_REFRESH_THROTTLE_MS = 5_000
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Build the callback-query handler families over the injected gateway deps.
|
|
400
|
+
* Bodies are verbatim from gateway.ts — behavior-preserving (#2996).
|
|
401
|
+
*/
|
|
402
|
+
export function createCallbackQueryHandlers(deps: CallbackQueryHandlersDeps) {
|
|
403
|
+
const {
|
|
404
|
+
loadAccess,
|
|
405
|
+
escapeHtmlForTg,
|
|
406
|
+
switchroomReply,
|
|
407
|
+
resolveThreadId,
|
|
408
|
+
deliverResumeSyntheticOrBuffer,
|
|
409
|
+
expireMentalModelProposeCard,
|
|
410
|
+
readLiveSwitchroomConfigText,
|
|
411
|
+
mentalModelCorrelationKey,
|
|
412
|
+
getMyAgentName,
|
|
413
|
+
triggerSelfRestart,
|
|
414
|
+
runSwitchroomAuthCommand,
|
|
415
|
+
switchroomExecJson,
|
|
416
|
+
assertSafeAgentName,
|
|
417
|
+
buildDeferredSecretKeyboard,
|
|
418
|
+
recordDeferredSecretKernelDecision,
|
|
419
|
+
mintGrantWizardKernelRequest,
|
|
420
|
+
recordGrantWizardKernelDecision,
|
|
421
|
+
robustApiCall,
|
|
422
|
+
swallowingApiCall,
|
|
423
|
+
pendingVaultRequestAccesses,
|
|
424
|
+
pendingVaultRequestSaves,
|
|
425
|
+
pendingMentalModelProposes,
|
|
426
|
+
pendingCardStore,
|
|
427
|
+
pendingMentalModelCorrelations,
|
|
428
|
+
pendingVaultOps,
|
|
429
|
+
vaultPassphraseCache,
|
|
430
|
+
deferredSecrets,
|
|
431
|
+
pendingReauthFlows,
|
|
432
|
+
secretStaging,
|
|
433
|
+
lastAuthRefreshAtMs,
|
|
434
|
+
getVaultApprovalAuthMode,
|
|
435
|
+
getAdminOnlyKeys,
|
|
436
|
+
vaultKeyRegex: VAULT_KEY_REGEX,
|
|
437
|
+
mentalModelProposeTtlMs: MENTAL_MODEL_PROPOSE_TTL_MS,
|
|
438
|
+
} = deps
|
|
439
|
+
const bot = deps.bot as CallbackBotApi
|
|
440
|
+
const lockedBot = deps.lockedBot as CallbackBotApi
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Handle a callback_query from an auth dashboard button. Parses the
|
|
444
|
+
* callback_data, runs the matching action, acknowledges the tap with a
|
|
445
|
+
* toast, and refreshes the dashboard in-place via editMessageText.
|
|
446
|
+
*/
|
|
447
|
+
/**
|
|
448
|
+
* Handle op:<action>:<encoded-agent> callbacks from operator-events.ts
|
|
449
|
+
* renderOperatorEvent(). Phase 4b — closes the "buttons do nothing" gap.
|
|
450
|
+
*
|
|
451
|
+
* Actions:
|
|
452
|
+
* dismiss — clear keyboard + toast
|
|
453
|
+
* restart — systemctl --user restart switchroom-<agent>
|
|
454
|
+
* reauth — delegate to runSwitchroomAuthCommand (same flow as /auth reauth)
|
|
455
|
+
* logs — post last 30 lines of journalctl for the agent
|
|
456
|
+
* slot management buttons — removed (E5); use /auth use or /auth add instead.
|
|
457
|
+
*/
|
|
458
|
+
/**
|
|
459
|
+
* Issue #44: handle taps on the deferred-secret card's inline buttons.
|
|
460
|
+
*
|
|
461
|
+
* `vd:unlock:<deferKey>` — register a `passphrase-for-deferred` pending
|
|
462
|
+
* vault op and edit the card to ask the user for their passphrase.
|
|
463
|
+
* The text-handler picks the passphrase up via the existing
|
|
464
|
+
* pendingVaultOps intercept and calls `executeDeferredSecretSave`
|
|
465
|
+
* to write the held secret directly. No re-paste required.
|
|
466
|
+
*
|
|
467
|
+
* `vd:cancel:<deferKey>` — drop the deferred secret and clear the card.
|
|
468
|
+
* The held bytes are evicted from the in-memory `deferredSecrets`
|
|
469
|
+
* map (they were never written to disk) so the secret vanishes.
|
|
470
|
+
*
|
|
471
|
+
* Authorization mirrors the operator-event callback: only senders on the
|
|
472
|
+
* configured allowlist get to act on the buttons.
|
|
473
|
+
*/
|
|
474
|
+
/**
|
|
475
|
+
* Issue #969 P1a — handle the agent-initiated vault-save approval card
|
|
476
|
+
* (`vault_request_save` MCP tool).
|
|
477
|
+
*
|
|
478
|
+
* Callbacks:
|
|
479
|
+
* vrs:save:<stageId> — confirm save; write to vault using broker put
|
|
480
|
+
* with operator-passphrase attestation (#969 P1a)
|
|
481
|
+
* so even new keys go through in one tap.
|
|
482
|
+
* vrs:discard:<stageId> — drop the staged secret; never touches disk.
|
|
483
|
+
* vrs:rename:<stageId> — set up a pending-op intercept so the user's
|
|
484
|
+
* next message is taken as a new key name.
|
|
485
|
+
*/
|
|
486
|
+
/**
|
|
487
|
+
* Issue #969 P2b — handle a tap on the "🔓 Allow <key>" button posted by
|
|
488
|
+
* `/vault audit <agent>`'s Recent denials section. Mints a 30-day
|
|
489
|
+
* read-grant for the agent + key via the broker.
|
|
490
|
+
*
|
|
491
|
+
* The grant also unioning into the agent's existing token if one is
|
|
492
|
+
* already present is out of scope for this PR — the operator can
|
|
493
|
+
* re-mint with a wider key list if they want consolidation.
|
|
494
|
+
*/
|
|
495
|
+
async function handleVaultRecentDenialCallback(ctx: Context, data: string): Promise<void> {
|
|
496
|
+
const senderId = String(ctx.from?.id ?? '')
|
|
497
|
+
const access = loadAccess()
|
|
498
|
+
if (!access.allowFrom.includes(senderId)) {
|
|
499
|
+
await ctx.answerCallbackQuery({ text: 'Not authorized.' }).catch(() => {})
|
|
500
|
+
return
|
|
501
|
+
}
|
|
502
|
+
// vrd:<agent>:<key> — parse, validate both halves against the strict
|
|
503
|
+
// slug regex before doing anything else.
|
|
504
|
+
const parts = data.split(':')
|
|
505
|
+
if (parts.length !== 3) {
|
|
506
|
+
await ctx.answerCallbackQuery({ text: 'Bad request' }).catch(() => {})
|
|
507
|
+
return
|
|
508
|
+
}
|
|
509
|
+
const [, agentName, keyName] = parts
|
|
510
|
+
if (!/^[a-z][a-z0-9-]{0,62}$/i.test(agentName)) {
|
|
511
|
+
await ctx.answerCallbackQuery({ text: 'Invalid agent name' }).catch(() => {})
|
|
512
|
+
return
|
|
513
|
+
}
|
|
514
|
+
// #1047: same canonical key shape as vault_request_save /
|
|
515
|
+
// vault_request_access — namespaced keys like `fatsecret/client_id`
|
|
516
|
+
// must round-trip through the /vault audit one-tap Allow flow too,
|
|
517
|
+
// not just the agent-initiated approval cards.
|
|
518
|
+
if (!VAULT_KEY_REGEX.test(keyName)) {
|
|
519
|
+
await ctx.answerCallbackQuery({ text: 'Invalid key name' }).catch(() => {})
|
|
520
|
+
return
|
|
521
|
+
}
|
|
522
|
+
await ctx.answerCallbackQuery({ text: '⏳ Minting 30-day read grant…' }).catch(() => {})
|
|
523
|
+
|
|
524
|
+
const result = await mintGrantViaBroker({
|
|
525
|
+
agent: agentName,
|
|
526
|
+
keys: [keyName],
|
|
527
|
+
ttl_seconds: 30 * 24 * 60 * 60,
|
|
528
|
+
description: `auto-mint via /vault audit one-tap (#969 P2b)`,
|
|
529
|
+
})
|
|
530
|
+
|
|
531
|
+
if (result.kind === 'unreachable') {
|
|
532
|
+
await switchroomReply(ctx, `🔴 Broker unreachable: ${escapeHtmlForTg(result.msg)}`, { html: true })
|
|
533
|
+
return
|
|
534
|
+
}
|
|
535
|
+
if (result.kind === 'error') {
|
|
536
|
+
await switchroomReply(ctx, `**mint_grant failed:** ${escapeHtmlForTg(result.msg)}`, { html: true })
|
|
537
|
+
return
|
|
538
|
+
}
|
|
539
|
+
// Write the token to the agent's .vault-token file — same flow as the
|
|
540
|
+
// vault grant wizard. The agent restarts in the background pick up
|
|
541
|
+
// the new token via SWITCHROOM_AGENT_NAME on next CLI invocation.
|
|
542
|
+
const { token, id } = result
|
|
543
|
+
const tokenPath = join(homedir(), '.switchroom', 'agents', agentName, '.vault-token')
|
|
544
|
+
try {
|
|
545
|
+
mkdirSync(join(homedir(), '.switchroom', 'agents', agentName), { recursive: true })
|
|
546
|
+
writeFileSync(tokenPath, token, { mode: 0o600 })
|
|
547
|
+
} catch (err) {
|
|
548
|
+
await switchroomReply(
|
|
549
|
+
ctx,
|
|
550
|
+
`**Grant created (${escapeHtmlForTg(id)}) but token write failed:** ` +
|
|
551
|
+
`${escapeHtmlForTg(String(err))}\n` +
|
|
552
|
+
`_Recover with: \`switchroom vault grant ${escapeHtmlForTg(agentName)} ` +
|
|
553
|
+
`--keys ${escapeHtmlForTg(keyName)} --duration 30d\` on the host._`,
|
|
554
|
+
{ html: true },
|
|
555
|
+
)
|
|
556
|
+
return
|
|
557
|
+
}
|
|
558
|
+
// #1150 audit: P0 fix — pre-fix the audit-listing message kept its
|
|
559
|
+
// tappable [Always allow ...] buttons after a successful mint, so
|
|
560
|
+
// the operator could re-tap the same denial and re-mint the grant
|
|
561
|
+
// (broker idempotency saves us from a duplicate write but the
|
|
562
|
+
// operator experience was "did anything happen? let me tap again").
|
|
563
|
+
// Strip the entire audit-listing keyboard on first tap + append a
|
|
564
|
+
// status line so the action is visible. Operator re-runs `/vault
|
|
565
|
+
// audit` to act on remaining denials — that's the documented flow.
|
|
566
|
+
// HTML-escape the source text before concatenation. `ctx.callbackQuery.
|
|
567
|
+
// message.text` returns the body with entities STRIPPED (Telegram
|
|
568
|
+
// decodes the original HTML), so any raw `<`, `>`, `&` in agent/key
|
|
569
|
+
// names that survived the original audit-listing's escape pass would
|
|
570
|
+
// now break the HTML re-parse — and finalizeCallback's catch swallows
|
|
571
|
+
// the failure, leaving the keyboard tappable. Caught in PR #1158 review
|
|
572
|
+
// for the operator-event card; the same fix applies here.
|
|
573
|
+
const sourceMsg = ctx.callbackQuery?.message
|
|
574
|
+
const baseText = sourceMsg && 'text' in sourceMsg && sourceMsg.text
|
|
575
|
+
? escapeHtmlForTg(sourceMsg.text)
|
|
576
|
+
: ''
|
|
577
|
+
const statusLine =
|
|
578
|
+
`\n\n✅ **${escapeHtmlForTg(agentName)}** granted read access to ` +
|
|
579
|
+
`\`${keyName}\` for 30 days ` +
|
|
580
|
+
`(grant \`${id}\`). ` +
|
|
581
|
+
`Re-run /vault audit to act on remaining denials.`
|
|
582
|
+
await finalizeCallback(ctx, {
|
|
583
|
+
ackText: '✅ Grant minted',
|
|
584
|
+
newText: baseText ? `${baseText}${statusLine}` : statusLine,
|
|
585
|
+
// No synthInbound — operator-only flow. The granted agent picks
|
|
586
|
+
// up the token via .vault-token file on next CLI invocation; no
|
|
587
|
+
// turn-wake needed.
|
|
588
|
+
})
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* Issue #1012 — handle a tap on the vault_request_access approval card.
|
|
593
|
+
* vra:approve:<stageId> — mint a scoped grant token via the broker,
|
|
594
|
+
* write the token to the agent's
|
|
595
|
+
* `.vault-token` file, edit card to success.
|
|
596
|
+
* vra:deny:<stageId> — drop the staged request, edit card to denied.
|
|
597
|
+
*
|
|
598
|
+
* Same authorization gate as the recent-denials one-tap handler:
|
|
599
|
+
* sender must be on the gateway's allowFrom list.
|
|
600
|
+
*/
|
|
601
|
+
/**
|
|
602
|
+
* Mint the scoped grant + write the token file for an approved
|
|
603
|
+
* `vault_request_access` request. Factored out so both the direct
|
|
604
|
+
* approve-tap (passphrase already cached) and the
|
|
605
|
+
* `passphrase-for-access-approve` resume flow (passphrase captured
|
|
606
|
+
* via text-message intercept after tap-on-locked) drive identical
|
|
607
|
+
* minting behaviour. #1012 Phase 2 + follow-up.
|
|
608
|
+
*/
|
|
609
|
+
/**
|
|
610
|
+
* #1115 follow-up: caller-supplied attestation. Either a real operator
|
|
611
|
+
* passphrase (when the operator typed it in chat) or a posture flag
|
|
612
|
+
* that tells the broker to use its own retained passphrase under
|
|
613
|
+
* `vault.broker.approvalAuth: telegram-id`. The passphrase variant
|
|
614
|
+
* never crosses into telegram-id callsites; the posture variant
|
|
615
|
+
* never crosses into passphrase-mode callsites.
|
|
616
|
+
*/
|
|
617
|
+
type AccessApprovalAttestation =
|
|
618
|
+
| { kind: 'passphrase'; passphrase: string }
|
|
619
|
+
| { kind: 'posture' }
|
|
620
|
+
|
|
621
|
+
async function performVaultAccessApproval(
|
|
622
|
+
ctx: Context,
|
|
623
|
+
pending: PendingVaultRequestAccess,
|
|
624
|
+
stageId: string,
|
|
625
|
+
senderId: string,
|
|
626
|
+
attestation: AccessApprovalAttestation,
|
|
627
|
+
): Promise<void> {
|
|
628
|
+
const brokerAuthOpts =
|
|
629
|
+
attestation.kind === 'passphrase'
|
|
630
|
+
? { passphrase: attestation.passphrase }
|
|
631
|
+
: { attest_via_posture: true as const }
|
|
632
|
+
|
|
633
|
+
// Fix B (#1487 follow-up), operator-tap guard. Defense-in-depth for a
|
|
634
|
+
// card staged before the key became standing-ACL-covered (config edit
|
|
635
|
+
// / #1487 deploy / drift): if the agent's standing ACL ALREADY covers
|
|
636
|
+
// this read key, do NOT mint — minting writes a `.vault-token` that
|
|
637
|
+
// shadows the standing ACL and is redundant. Authoritative broker
|
|
638
|
+
// probe AS THIS AGENT (no-token list over the per-agent socket — same
|
|
639
|
+
// rationale as executeVaultRequestAccess; never a gateway-side config
|
|
640
|
+
// read). Read scope only. Fail-open on probe error (mint as before).
|
|
641
|
+
if (pending.scope === 'read') {
|
|
642
|
+
try {
|
|
643
|
+
const visible = await listViaBroker()
|
|
644
|
+
if (visible !== null && visible.includes(pending.key)) {
|
|
645
|
+
pendingVaultRequestAccesses.delete(stageId)
|
|
646
|
+
pendingCardStore.remove(stageId)
|
|
647
|
+
if (pending.card_message_id != null) {
|
|
648
|
+
await ctx.api
|
|
649
|
+
.editMessageText(
|
|
650
|
+
pending.chat_id,
|
|
651
|
+
pending.card_message_id,
|
|
652
|
+
`ℹ️ **${escapeHtmlForTg(pending.agent)}** already has standing-ACL access to ` +
|
|
653
|
+
`\`${pending.key}\` (schedule.secrets[]). ` +
|
|
654
|
+
`**No grant minted** — a token would shadow the standing ACL. ` +
|
|
655
|
+
richMessage(`The agent can read it directly.`),
|
|
656
|
+
{ reply_markup: { inline_keyboard: [] } },
|
|
657
|
+
)
|
|
658
|
+
.catch(() => {})
|
|
659
|
+
}
|
|
660
|
+
return
|
|
661
|
+
}
|
|
662
|
+
} catch {
|
|
663
|
+
// Probe failed: fall through and mint as before (fail-open).
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// #1051: union the new key with the agent's existing active grant
|
|
668
|
+
// before minting. Without this, each fresh Approve OVERWRITES the
|
|
669
|
+
// agent's `.vault-token` file with a single-key grant — the
|
|
670
|
+
// previous approval's grant is still in the broker DB but the
|
|
671
|
+
// agent can no longer authenticate against it (the CLI reads the
|
|
672
|
+
// file's current token, the broker validates it, sees the new key
|
|
673
|
+
// isn't in the OLD grant's key_allow, and denies).
|
|
674
|
+
//
|
|
675
|
+
// Solution: list the agent's existing non-expired grants
|
|
676
|
+
// (passphrase-attested per #1051's broker-side gate widening),
|
|
677
|
+
// find the active read-grant (most recent non-revoked,
|
|
678
|
+
// non-expired), and pass its keys ∪ new_key as `keys` to the
|
|
679
|
+
// mint call. Old grant ages out via TTL — no explicit revoke
|
|
680
|
+
// needed.
|
|
681
|
+
let existingReadKeys: string[] = [];
|
|
682
|
+
let existingWriteKeys: string[] = [];
|
|
683
|
+
if (pending.scope === 'read' || pending.scope === 'write') {
|
|
684
|
+
const list = await listGrantsViaBroker(pending.agent, brokerAuthOpts);
|
|
685
|
+
if (list.kind === 'ok') {
|
|
686
|
+
const now = Math.floor(Date.now() / 1000);
|
|
687
|
+
// Prefer the MOST RECENT non-revoked, non-expired grant. The
|
|
688
|
+
// broker's listGrants returns ALL non-revoked, but we still
|
|
689
|
+
// filter expires_at locally as defence-in-depth + sort by
|
|
690
|
+
// created_at desc for stability.
|
|
691
|
+
const active = list.grants
|
|
692
|
+
.filter((g) => g.expires_at === null || g.expires_at > now)
|
|
693
|
+
// Reviewer-flagged on #1058 (Q4): `created_at` is
|
|
694
|
+
// seconds-granularity, so two grants minted in the same
|
|
695
|
+
// wall-clock second tie. Secondary sort by `id` (vg_<6hex>)
|
|
696
|
+
// makes the ordering stable so item 2's drain reliably picks
|
|
697
|
+
// up item 1's just-minted grant rather than an unrelated
|
|
698
|
+
// same-second grant.
|
|
699
|
+
.sort((a, b) => {
|
|
700
|
+
const dt = (b.created_at ?? 0) - (a.created_at ?? 0);
|
|
701
|
+
if (dt !== 0) return dt;
|
|
702
|
+
return b.id.localeCompare(a.id);
|
|
703
|
+
});
|
|
704
|
+
if (active.length > 0) {
|
|
705
|
+
existingReadKeys = active[0]!.key_allow ?? [];
|
|
706
|
+
existingWriteKeys = active[0]!.write_allow ?? [];
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
// If list fails (broker unreachable / error), proceed without
|
|
710
|
+
// union — better to mint a single-key grant than fail closed
|
|
711
|
+
// entirely. The agent loses the prior coverage in that edge
|
|
712
|
+
// case, same as today, but the new key is granted.
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// Compute the unioned key sets. Use Set to dedupe.
|
|
716
|
+
const readKeys = new Set<string>(existingReadKeys);
|
|
717
|
+
const writeKeys = new Set<string>(existingWriteKeys);
|
|
718
|
+
if (pending.scope === 'read') readKeys.add(pending.key);
|
|
719
|
+
if (pending.scope === 'write') writeKeys.add(pending.key);
|
|
720
|
+
|
|
721
|
+
const mintArgs: Parameters<typeof mintGrantViaBroker>[0] = {
|
|
722
|
+
agent: pending.agent,
|
|
723
|
+
keys: Array.from(readKeys),
|
|
724
|
+
ttl_seconds: pending.ttl_seconds,
|
|
725
|
+
description:
|
|
726
|
+
`auto-mint via vault_request_access (#1012, scope=${pending.scope}, by op ${senderId}` +
|
|
727
|
+
(existingReadKeys.length + existingWriteKeys.length > 0
|
|
728
|
+
? `, unioned with prior grant`
|
|
729
|
+
: ``) +
|
|
730
|
+
`)`,
|
|
731
|
+
...(writeKeys.size > 0 ? { write_keys: Array.from(writeKeys) } : {}),
|
|
732
|
+
...brokerAuthOpts,
|
|
733
|
+
}
|
|
734
|
+
const result = await mintGrantViaBroker(mintArgs)
|
|
735
|
+
if (result.kind === 'unreachable') {
|
|
736
|
+
await switchroomReply(ctx, `🔴 Broker unreachable: ${escapeHtmlForTg(result.msg)}`, { html: true })
|
|
737
|
+
return
|
|
738
|
+
}
|
|
739
|
+
if (result.kind === 'error') {
|
|
740
|
+
// Mint refused (most likely wrong passphrase). Drop the staged
|
|
741
|
+
// request so a re-attempt starts cleanly. The operator can ask
|
|
742
|
+
// the agent to re-issue, or the broker error message will tell
|
|
743
|
+
// them the next step.
|
|
744
|
+
pendingVaultRequestAccesses.delete(stageId)
|
|
745
|
+
pendingCardStore.remove(stageId)
|
|
746
|
+
if (pending.card_message_id != null) {
|
|
747
|
+
await ctx.api
|
|
748
|
+
.editMessageText(
|
|
749
|
+
pending.chat_id,
|
|
750
|
+
pending.card_message_id,
|
|
751
|
+
richMessage(`**mint_grant failed:** ${escapeHtmlForTg(result.msg)}`),
|
|
752
|
+
{ reply_markup: { inline_keyboard: [] } },
|
|
753
|
+
)
|
|
754
|
+
.catch(() => {})
|
|
755
|
+
}
|
|
756
|
+
return
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
const { token, id } = result
|
|
760
|
+
const tokenPath = join(homedir(), '.switchroom', 'agents', pending.agent, '.vault-token')
|
|
761
|
+
try {
|
|
762
|
+
mkdirSync(join(homedir(), '.switchroom', 'agents', pending.agent), { recursive: true })
|
|
763
|
+
writeFileSync(tokenPath, token, { mode: 0o600 })
|
|
764
|
+
} catch (err) {
|
|
765
|
+
await switchroomReply(
|
|
766
|
+
ctx,
|
|
767
|
+
`**Grant created (${escapeHtmlForTg(id)}) but token write failed:** ` +
|
|
768
|
+
`${escapeHtmlForTg(String(err))}\n` +
|
|
769
|
+
`_Recover with: \`switchroom vault grant ${escapeHtmlForTg(pending.agent)} ` +
|
|
770
|
+
`--keys ${escapeHtmlForTg(pending.key)} --duration ${Math.round(pending.ttl_seconds / 86400)}d\` on the host._`,
|
|
771
|
+
{ html: true },
|
|
772
|
+
)
|
|
773
|
+
return
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
pendingVaultRequestAccesses.delete(stageId)
|
|
777
|
+
pendingCardStore.remove(stageId)
|
|
778
|
+
if (pending.card_message_id != null) {
|
|
779
|
+
const days = Math.round(pending.ttl_seconds / 86400)
|
|
780
|
+
const footer =
|
|
781
|
+
getVaultApprovalAuthMode() === 'telegram-id'
|
|
782
|
+
? `\n_Approver verified by Telegram identity — broker auto-unlocked at startup._`
|
|
783
|
+
: ''
|
|
784
|
+
await ctx.api
|
|
785
|
+
.editMessageText(
|
|
786
|
+
pending.chat_id,
|
|
787
|
+
pending.card_message_id,
|
|
788
|
+
richMessage(
|
|
789
|
+
buildVaultGrantApprovedCardText({
|
|
790
|
+
agentEscaped: escapeHtmlForTg(pending.agent),
|
|
791
|
+
scope: pending.scope,
|
|
792
|
+
key: pending.key,
|
|
793
|
+
days,
|
|
794
|
+
grantId: id,
|
|
795
|
+
footer,
|
|
796
|
+
}),
|
|
797
|
+
),
|
|
798
|
+
{ reply_markup: { inline_keyboard: [] } },
|
|
799
|
+
)
|
|
800
|
+
.catch(() => {})
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// #1052: deliver a synthetic inbound message back to the agent so
|
|
804
|
+
// the task that fired vault_request_access auto-resumes — without
|
|
805
|
+
// this, the agent's turn ended after the tool call ("waiting for
|
|
806
|
+
// approval") and the operator has to send a fresh message to kick
|
|
807
|
+
// it back into action.
|
|
808
|
+
//
|
|
809
|
+
// Uses the existing inject_inbound primitive (cron's pattern from
|
|
810
|
+
// dispatch.ts:180-206). The bridge sees a normal channel event,
|
|
811
|
+
// renders it as `<channel source="vault_grant_approved">`, and the
|
|
812
|
+
// agent starts a new turn with the context that the operator just
|
|
813
|
+
// approved.
|
|
814
|
+
//
|
|
815
|
+
// The synthetic message text is concise + actionable so the agent
|
|
816
|
+
// knows (a) which key was approved, (b) at what scope, (c) what to
|
|
817
|
+
// do next. Meta carries the structured fields for forensics + for
|
|
818
|
+
// future filters that want to suppress these in the chat tail.
|
|
819
|
+
const synthetic = buildVaultGrantApprovedInbound({
|
|
820
|
+
ctx: {
|
|
821
|
+
agent: pending.agent,
|
|
822
|
+
key: pending.key,
|
|
823
|
+
scope: pending.scope,
|
|
824
|
+
chat_id: pending.chat_id,
|
|
825
|
+
ttl_seconds: pending.ttl_seconds,
|
|
826
|
+
...(pending.threadId != null ? { threadId: pending.threadId } : {}),
|
|
827
|
+
},
|
|
828
|
+
grantId: id,
|
|
829
|
+
stageId,
|
|
830
|
+
operatorId: senderId,
|
|
831
|
+
})
|
|
832
|
+
// Turn-gated via deliverResumeSyntheticOrBuffer: mid-turn → buffer
|
|
833
|
+
// (flushed at turn-end) so the resume never strands in claude's
|
|
834
|
+
// composer (#1556); idle → deliver; bridge-down → buffer (#1150).
|
|
835
|
+
const delivered = deliverResumeSyntheticOrBuffer(pending.agent, synthetic)
|
|
836
|
+
process.stderr.write(
|
|
837
|
+
`telegram gateway: vault_grant_approved injection agent=${pending.agent} ` +
|
|
838
|
+
`key=${pending.key} stage=${stageId} delivered=${delivered}\n`,
|
|
839
|
+
)
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/**
|
|
843
|
+
* #2670 — handle an Approve / Dismiss tap on a one-tap skill-improvement
|
|
844
|
+
* proposal card.
|
|
845
|
+
*
|
|
846
|
+
* Approve → mark the proposal approved + inject a synthetic
|
|
847
|
+
* `skill_proposal_apply` turn instructing the live agent to
|
|
848
|
+
* write the stored draft through `skill_*_personal` (so the
|
|
849
|
+
* secret-scan pipeline runs; agent never self-applies).
|
|
850
|
+
* Dismiss → mark rejected + write a rejection fingerprint so the weekly
|
|
851
|
+
* synthesis cron doesn't re-propose it.
|
|
852
|
+
*/
|
|
853
|
+
async function handleSkillProposalCallback(ctx: Context, data: string): Promise<void> {
|
|
854
|
+
const senderId = String(ctx.from?.id ?? '')
|
|
855
|
+
const access = loadAccess()
|
|
856
|
+
if (!access.allowFrom.includes(senderId)) {
|
|
857
|
+
await ctx.answerCallbackQuery({ text: 'Not authorized.' }).catch(() => {})
|
|
858
|
+
return
|
|
859
|
+
}
|
|
860
|
+
const parsed = parseSkillProposalCallback(data)
|
|
861
|
+
if (parsed == null) {
|
|
862
|
+
await ctx.answerCallbackQuery({ text: 'Bad request' }).catch(() => {})
|
|
863
|
+
return
|
|
864
|
+
}
|
|
865
|
+
const stateDir = process.env.TELEGRAM_STATE_DIR
|
|
866
|
+
const agent = process.env.SWITCHROOM_AGENT_NAME ?? ''
|
|
867
|
+
if (stateDir == null || stateDir.length === 0) {
|
|
868
|
+
await ctx.answerCallbackQuery({ text: 'State dir unset — cannot apply.' }).catch(() => {})
|
|
869
|
+
return
|
|
870
|
+
}
|
|
871
|
+
const proposal = getSkillProposal(stateDir, parsed.id)
|
|
872
|
+
if (proposal == null) {
|
|
873
|
+
await ctx.answerCallbackQuery({ text: 'Proposal expired or already actioned.' }).catch(() => {})
|
|
874
|
+
if (ctx.callbackQuery?.message) {
|
|
875
|
+
await ctx.editMessageReplyMarkup({ reply_markup: { inline_keyboard: [] } }).catch(() => {})
|
|
876
|
+
}
|
|
877
|
+
return
|
|
878
|
+
}
|
|
879
|
+
if (proposal.status !== 'pending') {
|
|
880
|
+
await ctx.answerCallbackQuery({ text: `Already ${proposal.status}.` }).catch(() => {})
|
|
881
|
+
if (ctx.callbackQuery?.message) {
|
|
882
|
+
await ctx.editMessageReplyMarkup({ reply_markup: { inline_keyboard: [] } }).catch(() => {})
|
|
883
|
+
}
|
|
884
|
+
return
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
const cbChatId = String(ctx.chat?.id ?? ctx.from?.id ?? '')
|
|
888
|
+
const cbThreadId = resolveThreadId(cbChatId, ctx.callbackQuery?.message?.message_thread_id)
|
|
889
|
+
|
|
890
|
+
if (parsed.action === 'deny') {
|
|
891
|
+
setSkillProposalStatus(stateDir, parsed.id, 'rejected')
|
|
892
|
+
await ctx.answerCallbackQuery({ text: '🚫 Dismissed — won’t be proposed again.' }).catch(() => {})
|
|
893
|
+
if (ctx.callbackQuery?.message && 'text' in ctx.callbackQuery.message) {
|
|
894
|
+
await ctx
|
|
895
|
+
.editMessageText(
|
|
896
|
+
`${escapeHtmlForTg(ctx.callbackQuery.message.text ?? '')}\n\n🚫 <i>Dismissed.</i>`,
|
|
897
|
+
{ parse_mode: 'HTML', reply_markup: { inline_keyboard: [] } },
|
|
898
|
+
)
|
|
899
|
+
.catch(() => {})
|
|
900
|
+
}
|
|
901
|
+
return
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
// Approve.
|
|
905
|
+
setSkillProposalStatus(stateDir, parsed.id, 'approved')
|
|
906
|
+
const synthetic = buildSkillProposalApplyInbound({
|
|
907
|
+
ctx: {
|
|
908
|
+
agent,
|
|
909
|
+
chat_id: cbChatId,
|
|
910
|
+
...(cbThreadId != null ? { threadId: cbThreadId } : {}),
|
|
911
|
+
},
|
|
912
|
+
proposalId: proposal.id,
|
|
913
|
+
skillSlug: proposal.skill_slug,
|
|
914
|
+
isNew: proposal.is_new,
|
|
915
|
+
operatorId: senderId,
|
|
916
|
+
})
|
|
917
|
+
const delivered = deliverResumeSyntheticOrBuffer(agent, synthetic)
|
|
918
|
+
await ctx.answerCallbackQuery({ text: '✅ Applying the skill…' }).catch(() => {})
|
|
919
|
+
if (ctx.callbackQuery?.message && 'text' in ctx.callbackQuery.message) {
|
|
920
|
+
await ctx
|
|
921
|
+
.editMessageText(
|
|
922
|
+
`${escapeHtmlForTg(ctx.callbackQuery.message.text ?? '')}\n\n✅ <i>Approved — applying.</i>`,
|
|
923
|
+
{ parse_mode: 'HTML', reply_markup: { inline_keyboard: [] } },
|
|
924
|
+
)
|
|
925
|
+
.catch(() => {})
|
|
926
|
+
}
|
|
927
|
+
process.stderr.write(
|
|
928
|
+
`telegram gateway: skill_proposal_apply injection agent=${agent} ` +
|
|
929
|
+
`proposal=${proposal.id} slug=${proposal.skill_slug} delivered=${delivered}\n`,
|
|
930
|
+
)
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
/**
|
|
934
|
+
* hindsight Phase 5 — handle a tap on the mental-model PROPOSAL card.
|
|
935
|
+
* mmp:approve:<stageId> — declare the model: append it to the agent's
|
|
936
|
+
* memory.mental_models[] via the operator-approved
|
|
937
|
+
* config-edit path (reused config_propose_edit
|
|
938
|
+
* apply+reconcile; reconcile ensures it), then wake
|
|
939
|
+
* the agent with an "applied" inbound.
|
|
940
|
+
* mmp:deny:<stageId> — drop the proposal; NOTHING is written; wake the
|
|
941
|
+
* agent with a "denied" inbound.
|
|
942
|
+
*
|
|
943
|
+
* Authorization: the tapper MUST be on the gateway's allowFrom list — an agent
|
|
944
|
+
* can PROPOSE but can never self-approve (identical gate to the vault flow).
|
|
945
|
+
*/
|
|
946
|
+
async function handleMentalModelProposeCallback(ctx: Context, data: string): Promise<void> {
|
|
947
|
+
const senderId = String(ctx.from?.id ?? '')
|
|
948
|
+
const access = loadAccess()
|
|
949
|
+
if (!access.allowFrom.includes(senderId)) {
|
|
950
|
+
// Self-approve is impossible: only an allow-listed operator can resolve
|
|
951
|
+
// the card. A tap from anyone else (incl. a compromised agent identity) is
|
|
952
|
+
// refused here.
|
|
953
|
+
await ctx.answerCallbackQuery({ text: 'Not authorized.' }).catch(() => {})
|
|
954
|
+
return
|
|
955
|
+
}
|
|
956
|
+
const parts = data.split(':')
|
|
957
|
+
if (parts.length < 3) {
|
|
958
|
+
await ctx.answerCallbackQuery({ text: 'Bad request' }).catch(() => {})
|
|
959
|
+
return
|
|
960
|
+
}
|
|
961
|
+
const action = parts[1]
|
|
962
|
+
const stageId = parts.slice(2).join(':')
|
|
963
|
+
const pending = pendingMentalModelProposes.get(stageId)
|
|
964
|
+
if (!pending) {
|
|
965
|
+
await ctx.answerCallbackQuery({ text: 'Card expired — ask the agent to re-propose.' }).catch(() => {})
|
|
966
|
+
if (ctx.callbackQuery?.message) {
|
|
967
|
+
await ctx.api
|
|
968
|
+
.editMessageText(
|
|
969
|
+
ctx.callbackQuery.message.chat.id,
|
|
970
|
+
ctx.callbackQuery.message.message_id,
|
|
971
|
+
richMessage('⌛ _This mental-model proposal card expired before you tapped. Ask the agent to re-propose if it still stands._'),
|
|
972
|
+
{ reply_markup: { inline_keyboard: [] } },
|
|
973
|
+
)
|
|
974
|
+
.catch(() => {})
|
|
975
|
+
}
|
|
976
|
+
return
|
|
977
|
+
}
|
|
978
|
+
if (action !== 'approve' && action !== 'deny') {
|
|
979
|
+
await ctx.answerCallbackQuery({ text: 'Bad request' }).catch(() => {})
|
|
980
|
+
return
|
|
981
|
+
}
|
|
982
|
+
// Enforce the TTL at TAP time, not just on the next propose's sweep. Without
|
|
983
|
+
// this, a card left untapped past its TTL is still resolvable if no fresh
|
|
984
|
+
// proposal has run the sweep — an operator could approve a stale proposal.
|
|
985
|
+
if (Date.now() - pending.staged_at > MENTAL_MODEL_PROPOSE_TTL_MS) {
|
|
986
|
+
// Expired between post and tap: route through the shared expiry path so the
|
|
987
|
+
// parked agent is WOKEN (timeout synthetic + missed-approvals re-offer) and
|
|
988
|
+
// the durable store entry is cleared — not just a silent map delete.
|
|
989
|
+
expireMentalModelProposeCard(stageId, pending, Date.now())
|
|
990
|
+
await ctx.answerCallbackQuery({ text: 'Card expired — the agent was notified.' }).catch(() => {})
|
|
991
|
+
return
|
|
992
|
+
}
|
|
993
|
+
// Single-shot: remove the pending entry immediately so a double-tap can't
|
|
994
|
+
// resolve twice.
|
|
995
|
+
pendingMentalModelProposes.delete(stageId)
|
|
996
|
+
pendingCardStore.remove(stageId)
|
|
997
|
+
|
|
998
|
+
const proposal: MentalModelPendingProposal = {
|
|
999
|
+
agent: pending.agent,
|
|
1000
|
+
chat_id: pending.chat_id,
|
|
1001
|
+
...(pending.threadId != null ? { threadId: pending.threadId } : {}),
|
|
1002
|
+
spec: pending.spec,
|
|
1003
|
+
...(pending.reason ? { reason: pending.reason } : {}),
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
const resolveDeps = {
|
|
1007
|
+
readConfigText: () => readLiveSwitchroomConfigText(),
|
|
1008
|
+
registerPreApproval: (agent: string, diff: string) => {
|
|
1009
|
+
pendingMentalModelCorrelations.set(mentalModelCorrelationKey(agent, diff), {
|
|
1010
|
+
agentName: agent,
|
|
1011
|
+
unifiedDiff: diff,
|
|
1012
|
+
createdAt: Date.now(),
|
|
1013
|
+
})
|
|
1014
|
+
},
|
|
1015
|
+
clearPreApproval: (agent: string, diff: string) => {
|
|
1016
|
+
pendingMentalModelCorrelations.delete(mentalModelCorrelationKey(agent, diff))
|
|
1017
|
+
},
|
|
1018
|
+
dispatchConfigEdit: async (a: { agent: string; diff: string; reason: string }) => {
|
|
1019
|
+
const req: HostdRequest = {
|
|
1020
|
+
v: 1,
|
|
1021
|
+
op: 'config_propose_edit',
|
|
1022
|
+
request_id: hostdRequestId('gw-mental-model'),
|
|
1023
|
+
args: {
|
|
1024
|
+
unified_diff: a.diff,
|
|
1025
|
+
reason: a.reason,
|
|
1026
|
+
target_path: '/state/config/switchroom.yaml',
|
|
1027
|
+
},
|
|
1028
|
+
}
|
|
1029
|
+
// config_propose_edit blocks on validate→approve→apply→reconcile
|
|
1030
|
+
// (5-10 min on a busy host) — allow 12 min. The operator already
|
|
1031
|
+
// approved on the proposal card, so hostd's config-approval callback
|
|
1032
|
+
// auto-resolves via the pre-registered correlation (no second card).
|
|
1033
|
+
const resp = await tryHostdDispatch(a.agent, req, 720_000)
|
|
1034
|
+
if (resp === 'not-configured') {
|
|
1035
|
+
return { state: 'error' as const, reason: 'hostd config-edit is not configured (host_control disabled or socket absent)' }
|
|
1036
|
+
}
|
|
1037
|
+
if (resp.result === 'completed') return { state: 'applied' as const }
|
|
1038
|
+
if (resp.result === 'denied') return { state: 'denied' as const, reason: resp.error ?? 'operator/host denied the edit' }
|
|
1039
|
+
return { state: 'error' as const, reason: resp.error ?? `hostd returned '${resp.result}'` }
|
|
1040
|
+
},
|
|
1041
|
+
// Ensure is delegated to reconcile: config_propose_edit's apply triggers a
|
|
1042
|
+
// reconcile which runs ensureDeclaredMentalModels (#2874) for the newly
|
|
1043
|
+
// declared model — the authoritative, correctly-scoped ensure. We
|
|
1044
|
+
// deliberately do NOT add a redundant gateway-side ensure (it would need
|
|
1045
|
+
// the agent's bank id + a reachable Hindsight endpoint from the gateway).
|
|
1046
|
+
injectInbound: (inbound: InboundMessage) => {
|
|
1047
|
+
deliverResumeSyntheticOrBuffer(pending.agent, inbound)
|
|
1048
|
+
},
|
|
1049
|
+
log: (m: string) => process.stderr.write(`telegram gateway: ${m}\n`),
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
if (action === 'deny') {
|
|
1053
|
+
await ctx.answerCallbackQuery({ text: '🚫 Denied' }).catch(() => {})
|
|
1054
|
+
await resolveMentalModelProposal('deny', proposal, stageId, senderId, resolveDeps)
|
|
1055
|
+
if (pending.card_message_id != null) {
|
|
1056
|
+
await ctx.api
|
|
1057
|
+
.editMessageText(
|
|
1058
|
+
pending.chat_id,
|
|
1059
|
+
pending.card_message_id,
|
|
1060
|
+
richMessage(`🚫 _Denied. **${escapeHtmlForTg(pending.agent)}**'s mental model \`${pending.spec.name}\` was not declared._`),
|
|
1061
|
+
{ reply_markup: { inline_keyboard: [] } },
|
|
1062
|
+
)
|
|
1063
|
+
.catch(() => {})
|
|
1064
|
+
}
|
|
1065
|
+
return
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
// Approve. Ack immediately + show an interim state, then persist in the
|
|
1069
|
+
// background (config_propose_edit can take minutes), then edit the card with
|
|
1070
|
+
// the real outcome. The turn resumes via the synthetic inbound injected by
|
|
1071
|
+
// resolveMentalModelProposal — not by this card edit.
|
|
1072
|
+
await ctx.answerCallbackQuery({ text: '✅ Declaring the model…' }).catch(() => {})
|
|
1073
|
+
if (pending.card_message_id != null) {
|
|
1074
|
+
await ctx.api
|
|
1075
|
+
.editMessageText(
|
|
1076
|
+
pending.chat_id,
|
|
1077
|
+
pending.card_message_id,
|
|
1078
|
+
richMessage(`⏳ _Declaring **${escapeHtmlForTg(pending.agent)}**'s mental model \`${pending.spec.name}\` — appending to config + ensuring…_`),
|
|
1079
|
+
{ reply_markup: { inline_keyboard: [] } },
|
|
1080
|
+
)
|
|
1081
|
+
.catch(() => {})
|
|
1082
|
+
}
|
|
1083
|
+
void (async () => {
|
|
1084
|
+
let result
|
|
1085
|
+
try {
|
|
1086
|
+
result = await resolveMentalModelProposal('approve', proposal, stageId, senderId, resolveDeps)
|
|
1087
|
+
} catch (err) {
|
|
1088
|
+
process.stderr.write(`telegram gateway: mental_model_propose approve threw: ${(err as Error).message}\n`)
|
|
1089
|
+
result = { outcome: 'failed' as const, reason: (err as Error).message }
|
|
1090
|
+
}
|
|
1091
|
+
if (pending.card_message_id != null) {
|
|
1092
|
+
const label =
|
|
1093
|
+
result.outcome === 'applied'
|
|
1094
|
+
? `✅ **Declared** ${escapeHtmlForTg(pending.agent)}'s mental model \`${pending.spec.name}\` — appended to \`memory.mental_models[]\` and ensured. Restart the agent to load it if it isn't picked up automatically.`
|
|
1095
|
+
: `⚠️ **Did NOT declare** \`${pending.spec.name}\`${'reason' in result && result.reason ? ` — ${escapeHtmlForTg(result.reason)}` : ''}. Nothing was written.`
|
|
1096
|
+
await ctx.api
|
|
1097
|
+
.editMessageText(pending.chat_id, pending.card_message_id, richMessage(label), {
|
|
1098
|
+
reply_markup: { inline_keyboard: [] },
|
|
1099
|
+
link_preview_options: { is_disabled: true },
|
|
1100
|
+
})
|
|
1101
|
+
.catch(() => {})
|
|
1102
|
+
}
|
|
1103
|
+
})()
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
async function handleVaultRequestAccessCallback(ctx: Context, data: string): Promise<void> {
|
|
1107
|
+
const senderId = String(ctx.from?.id ?? '')
|
|
1108
|
+
const access = loadAccess()
|
|
1109
|
+
if (!access.allowFrom.includes(senderId)) {
|
|
1110
|
+
await ctx.answerCallbackQuery({ text: 'Not authorized.' }).catch(() => {})
|
|
1111
|
+
return
|
|
1112
|
+
}
|
|
1113
|
+
const parts = data.split(':')
|
|
1114
|
+
if (parts.length < 3) {
|
|
1115
|
+
await ctx.answerCallbackQuery({ text: 'Bad request' }).catch(() => {})
|
|
1116
|
+
return
|
|
1117
|
+
}
|
|
1118
|
+
const action = parts[1]
|
|
1119
|
+
const stageId = parts.slice(2).join(':')
|
|
1120
|
+
const pending = pendingVaultRequestAccesses.get(stageId)
|
|
1121
|
+
if (!pending) {
|
|
1122
|
+
await ctx.answerCallbackQuery({ text: 'Card expired — ask the agent to re-request.' }).catch(() => {})
|
|
1123
|
+
if (ctx.callbackQuery?.message) {
|
|
1124
|
+
await ctx.api
|
|
1125
|
+
.editMessageText(
|
|
1126
|
+
ctx.callbackQuery.message.chat.id,
|
|
1127
|
+
ctx.callbackQuery.message.message_id,
|
|
1128
|
+
richMessage('⌛ _This access-request card expired before you tapped. Ask the agent to re-issue if the need still stands._'),
|
|
1129
|
+
{ reply_markup: { inline_keyboard: [] } },
|
|
1130
|
+
)
|
|
1131
|
+
.catch(() => {})
|
|
1132
|
+
}
|
|
1133
|
+
return
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
if (action === 'deny') {
|
|
1137
|
+
pendingVaultRequestAccesses.delete(stageId)
|
|
1138
|
+
pendingCardStore.remove(stageId)
|
|
1139
|
+
await ctx.answerCallbackQuery({ text: '🚫 Denied' }).catch(() => {})
|
|
1140
|
+
if (pending.card_message_id != null) {
|
|
1141
|
+
await ctx.api
|
|
1142
|
+
.editMessageText(
|
|
1143
|
+
pending.chat_id,
|
|
1144
|
+
pending.card_message_id,
|
|
1145
|
+
richMessage(`🚫 _Denied. **${escapeHtmlForTg(pending.agent)}** will not get access to \`${pending.key}\`._`),
|
|
1146
|
+
{ reply_markup: { inline_keyboard: [] } },
|
|
1147
|
+
)
|
|
1148
|
+
.catch(() => {})
|
|
1149
|
+
}
|
|
1150
|
+
// #1150 sibling: invariant-3 was missing on the deny path too. The
|
|
1151
|
+
// agent originally ended its turn after `vault_request_access` and
|
|
1152
|
+
// waits for the gateway to wake it. On approve we already inject
|
|
1153
|
+
// `vault_grant_approved` (#1052); now we mirror that for deny so
|
|
1154
|
+
// the agent can pick the fallback path (apologise to the user,
|
|
1155
|
+
// try a different approach, skip the feature) instead of staying
|
|
1156
|
+
// wedged forever. Buffer-on-failure so a mid-reconnect bridge
|
|
1157
|
+
// still receives this on its next register.
|
|
1158
|
+
const denyInbound = buildVaultGrantDeniedInbound({
|
|
1159
|
+
ctx: {
|
|
1160
|
+
agent: pending.agent,
|
|
1161
|
+
key: pending.key,
|
|
1162
|
+
scope: pending.scope,
|
|
1163
|
+
chat_id: pending.chat_id,
|
|
1164
|
+
ttl_seconds: pending.ttl_seconds,
|
|
1165
|
+
...(pending.threadId != null ? { threadId: pending.threadId } : {}),
|
|
1166
|
+
},
|
|
1167
|
+
stageId,
|
|
1168
|
+
operatorId: senderId,
|
|
1169
|
+
})
|
|
1170
|
+
const denyDelivered = deliverResumeSyntheticOrBuffer(pending.agent, denyInbound)
|
|
1171
|
+
process.stderr.write(
|
|
1172
|
+
`telegram gateway: vault_grant_denied injection agent=${pending.agent} ` +
|
|
1173
|
+
`key=${pending.key} stage=${stageId} delivered=${denyDelivered}\n`,
|
|
1174
|
+
)
|
|
1175
|
+
return
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
if (action === 'approve') {
|
|
1179
|
+
// Admin-only credentials (`vault.broker.adminOnlyKeys`) are held to a
|
|
1180
|
+
// higher bar: ONLY the admin operator (allowFrom[0]) may approve, and
|
|
1181
|
+
// the grant must be minted with the operator passphrase — never
|
|
1182
|
+
// posture, even under telegram-id mode (the broker enforces the same
|
|
1183
|
+
// rule, so a posture mint would just be rejected). So for an
|
|
1184
|
+
// admin-only key we (a) reject taps from any non-admin allowFrom
|
|
1185
|
+
// member, and (b) skip the telegram-id posture branch below, falling
|
|
1186
|
+
// through to the passphrase-prompt path. The card + buttons stay
|
|
1187
|
+
// intact on a non-admin tap so the admin can still approve.
|
|
1188
|
+
const isAdminOnly = matchesAdminOnlyKey(pending.key, getAdminOnlyKeys())
|
|
1189
|
+
if (isAdminOnly && senderId !== access.allowFrom[0]) {
|
|
1190
|
+
await ctx
|
|
1191
|
+
.answerCallbackQuery({
|
|
1192
|
+
text: '🔒 Admin-only credential — only the owner can approve this.',
|
|
1193
|
+
})
|
|
1194
|
+
.catch(() => {})
|
|
1195
|
+
return
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
// Posture: telegram-id (opt-in single-factor). The broker is
|
|
1199
|
+
// auto-unlocked and we silently hold the passphrase in memory; skip
|
|
1200
|
+
// the passphrase-cache lookup + prompt entirely and mint directly.
|
|
1201
|
+
// Allowlist check above already attested the operator's Telegram ID.
|
|
1202
|
+
// Admin-only keys are excluded — they take the passphrase path below.
|
|
1203
|
+
if (!isAdminOnly && getVaultApprovalAuthMode() === 'telegram-id') {
|
|
1204
|
+
const username = ctx.from?.username ?? ctx.from?.first_name ?? `id=${senderId}`
|
|
1205
|
+
if (pending.card_message_id != null) {
|
|
1206
|
+
await ctx.api
|
|
1207
|
+
.editMessageText(
|
|
1208
|
+
pending.chat_id,
|
|
1209
|
+
pending.card_message_id,
|
|
1210
|
+
richMessage(`✅ Approved by @${escapeHtmlForTg(username)} — minting…`),
|
|
1211
|
+
{ reply_markup: { inline_keyboard: [] } },
|
|
1212
|
+
)
|
|
1213
|
+
.catch(() => {})
|
|
1214
|
+
}
|
|
1215
|
+
await ctx.answerCallbackQuery({ text: '⏳ Minting grant…' }).catch(() => {})
|
|
1216
|
+
await performVaultAccessApproval(ctx, pending, stageId, senderId, { kind: 'posture' })
|
|
1217
|
+
return
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
// Tap-to-unlock-and-approve: if the operator hasn't unlocked the
|
|
1221
|
+
// vault in this chat yet, capture the passphrase via a pending op
|
|
1222
|
+
// intercept and resume the approve flow automatically once it
|
|
1223
|
+
// arrives — no second tap, no separate /vault unlock detour.
|
|
1224
|
+
// Mirrors the `passphrase-for-deferred` flow from #44.
|
|
1225
|
+
const cached = vaultPassphraseCache.get(pending.chat_id)
|
|
1226
|
+
if (!cached || cached.expiresAt <= Date.now()) {
|
|
1227
|
+
if (pending.card_message_id == null) {
|
|
1228
|
+
await ctx
|
|
1229
|
+
.answerCallbackQuery({ text: 'Card missing — ask the agent to re-issue.' })
|
|
1230
|
+
.catch(() => {})
|
|
1231
|
+
return
|
|
1232
|
+
}
|
|
1233
|
+
// #1051: if there's ALREADY a passphrase-for-access-approve
|
|
1234
|
+
// pending op for this chat (operator tapped Approve on a
|
|
1235
|
+
// sibling card before typing the passphrase), APPEND this
|
|
1236
|
+
// stage to the existing queue instead of overwriting. When
|
|
1237
|
+
// the passphrase reply lands the text-handler drains every
|
|
1238
|
+
// queued stage — both cards get their grant minted off one
|
|
1239
|
+
// passphrase entry. Without this, the second Approve tap
|
|
1240
|
+
// orphans the first stage.
|
|
1241
|
+
const existing = pendingVaultOps.get(pending.chat_id)
|
|
1242
|
+
const newItem = {
|
|
1243
|
+
stageId,
|
|
1244
|
+
cardChatId: pending.chat_id,
|
|
1245
|
+
cardMessageId: pending.card_message_id,
|
|
1246
|
+
senderId,
|
|
1247
|
+
}
|
|
1248
|
+
const items =
|
|
1249
|
+
existing?.kind === 'passphrase-for-access-approve'
|
|
1250
|
+
? [...existing.items.filter((it) => it.stageId !== stageId), newItem]
|
|
1251
|
+
: [newItem]
|
|
1252
|
+
pendingVaultOps.set(pending.chat_id, {
|
|
1253
|
+
kind: 'passphrase-for-access-approve',
|
|
1254
|
+
items,
|
|
1255
|
+
startedAt: existing?.kind === 'passphrase-for-access-approve' ? existing.startedAt : Date.now(),
|
|
1256
|
+
})
|
|
1257
|
+
// Card text differs slightly when joining an existing batch so
|
|
1258
|
+
// the operator isn't confused by two "Reply with passphrase"
|
|
1259
|
+
// cards open at once.
|
|
1260
|
+
const joiningBatch = items.length > 1
|
|
1261
|
+
await ctx.answerCallbackQuery({ text: joiningBatch ? `🔐 Queued — one passphrase covers ${items.length} cards` : '🔐 Send your passphrase…' }).catch(() => {})
|
|
1262
|
+
|
|
1263
|
+
// Strip the buttons on the ORIGINAL card and mark it "waiting" so it
|
|
1264
|
+
// can't be re-tapped, but do NOT overload it as the passphrase prompt.
|
|
1265
|
+
// An in-place edit fires no notification and stays pinned to the card's
|
|
1266
|
+
// old position in the chat, so a busy topic buries it and the operator
|
|
1267
|
+
// never sees the passphrase ask — the exact admin-key miss this fixes
|
|
1268
|
+
// (v0.16.45: the prompt scrolled off, the passphrase never arrived, the
|
|
1269
|
+
// grant was never minted). The prompt goes out as a fresh message below.
|
|
1270
|
+
await ctx.api
|
|
1271
|
+
.editMessageText(
|
|
1272
|
+
pending.chat_id,
|
|
1273
|
+
pending.card_message_id,
|
|
1274
|
+
richMessage(`🔐 _Approved — waiting for your vault passphrase. See the prompt below._`),
|
|
1275
|
+
{ reply_markup: { inline_keyboard: [] } },
|
|
1276
|
+
)
|
|
1277
|
+
.catch(() => {})
|
|
1278
|
+
|
|
1279
|
+
// The passphrase prompt as a NEW rich message. Three fixes vs. the old
|
|
1280
|
+
// in-place edit, all of which the reported bug needed:
|
|
1281
|
+
// 1. Real bold/italic — rendered through the sanctioned `richMessage`
|
|
1282
|
+
// GFM path (`sendRichMessage`), never a raw string. The old admin
|
|
1283
|
+
// and joining-batch branches passed raw markdown to editMessageText
|
|
1284
|
+
// (parse_mode=none), so `**`/`_` rendered as literal characters;
|
|
1285
|
+
// the "locked" branch even concatenated a string with a
|
|
1286
|
+
// `richMessage()` object (→ `[object Object]`). All three are gone.
|
|
1287
|
+
// 2. It lands at the BOTTOM of the chat, not stapled to an old card
|
|
1288
|
+
// that later messages bury.
|
|
1289
|
+
// 3. It fires a notification — `disable_notification` is deliberately
|
|
1290
|
+
// NOT set — so the operator is actually pinged to act.
|
|
1291
|
+
// Attention-grabbing header, short lines, key in code formatting.
|
|
1292
|
+
const promptText = joiningBatch
|
|
1293
|
+
? `**⚠️🔐 ACTION NEEDED: passphrase required**\n\n` +
|
|
1294
|
+
`Type your vault passphrase as your **next message**.\n` +
|
|
1295
|
+
`One entry covers **${items.length}** pending approvals in this chat, no re-type per card.\n\n` +
|
|
1296
|
+
`_We delete the passphrase message the moment we read it._`
|
|
1297
|
+
: isAdminOnly
|
|
1298
|
+
? `**⚠️🔐 ACTION NEEDED: passphrase required**\n\n` +
|
|
1299
|
+
`\`${pending.key}\` is an **admin-only credential**.\n` +
|
|
1300
|
+
`Type your vault passphrase as your **next message** to mint the grant for **${escapeHtmlForTg(pending.agent)}**.\n\n` +
|
|
1301
|
+
`_The passphrase is what proves it's you. An agent can never mint this key on its own. We delete the passphrase message the moment we read it._`
|
|
1302
|
+
: `**⚠️🔐 ACTION NEEDED: passphrase required**\n\n` +
|
|
1303
|
+
`Your vault is locked.\n` +
|
|
1304
|
+
`Reply with your passphrase as your **next message** to unlock and mint the grant for **${escapeHtmlForTg(pending.agent)}**.\n\n` +
|
|
1305
|
+
`_Mint authority stays operator-only: the broker only accepts the grant when the passphrase matches. We delete the passphrase message the moment we read it._`
|
|
1306
|
+
|
|
1307
|
+
// #1075: deleted-topic safe — fall back to the main chat. Wrapped
|
|
1308
|
+
// through robustApiCall for flood-wait retries, mirroring the card send.
|
|
1309
|
+
await retryWithThreadFallback<{ message_id: number }>(
|
|
1310
|
+
robustApiCall,
|
|
1311
|
+
(tid) =>
|
|
1312
|
+
lockedBot.api.sendRichMessage(pending.chat_id, richMessage(promptText), {
|
|
1313
|
+
...(tid != null && Number.isFinite(tid) ? { message_thread_id: tid } : {}),
|
|
1314
|
+
}),
|
|
1315
|
+
{ threadId: pending.threadId, chat_id: pending.chat_id, verb: 'vault_request_access.passphrase_prompt' },
|
|
1316
|
+
).catch(() => {})
|
|
1317
|
+
return
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
await ctx.answerCallbackQuery({ text: '⏳ Minting grant…' }).catch(() => {})
|
|
1321
|
+
await performVaultAccessApproval(ctx, pending, stageId, senderId, { kind: 'passphrase', passphrase: cached.passphrase })
|
|
1322
|
+
return
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
await ctx.answerCallbackQuery({ text: 'Unknown action' }).catch(() => {})
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
async function handleVaultRequestSaveCallback(ctx: Context, data: string): Promise<void> {
|
|
1329
|
+
const senderId = String(ctx.from?.id ?? '')
|
|
1330
|
+
const access = loadAccess()
|
|
1331
|
+
if (!access.allowFrom.includes(senderId)) {
|
|
1332
|
+
await ctx.answerCallbackQuery({ text: 'Not authorized.' }).catch(() => {})
|
|
1333
|
+
return
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
const parts = data.split(':')
|
|
1337
|
+
if (parts.length < 3) {
|
|
1338
|
+
await ctx.answerCallbackQuery({ text: 'Bad request' }).catch(() => {})
|
|
1339
|
+
return
|
|
1340
|
+
}
|
|
1341
|
+
const action = parts[1]
|
|
1342
|
+
const stageId = parts.slice(2).join(':')
|
|
1343
|
+
const pending = pendingVaultRequestSaves.get(stageId)
|
|
1344
|
+
if (!pending) {
|
|
1345
|
+
await ctx.answerCallbackQuery({ text: 'Card expired — ask the agent to re-send.' }).catch(() => {})
|
|
1346
|
+
if (ctx.callbackQuery?.message) {
|
|
1347
|
+
await ctx.api
|
|
1348
|
+
.editMessageText(
|
|
1349
|
+
ctx.callbackQuery.message.chat.id,
|
|
1350
|
+
ctx.callbackQuery.message.message_id,
|
|
1351
|
+
richMessage('⌛ _This vault-save card expired before you tapped. Ask the agent to re-issue if you still want to save._'),
|
|
1352
|
+
{ reply_markup: { inline_keyboard: [] } },
|
|
1353
|
+
)
|
|
1354
|
+
.catch(() => {})
|
|
1355
|
+
}
|
|
1356
|
+
return
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
if (action === 'discard') {
|
|
1360
|
+
pendingVaultRequestSaves.delete(stageId)
|
|
1361
|
+
pendingCardStore.remove(stageId)
|
|
1362
|
+
await ctx.answerCallbackQuery({ text: '🚫 Discarded' }).catch(() => {})
|
|
1363
|
+
if (pending.card_message_id != null) {
|
|
1364
|
+
await ctx.api
|
|
1365
|
+
.editMessageText(
|
|
1366
|
+
pending.chat_id,
|
|
1367
|
+
pending.card_message_id,
|
|
1368
|
+
richMessage(`🚫 _Discarded. The secret was not written to the vault._`),
|
|
1369
|
+
{ reply_markup: { inline_keyboard: [] } },
|
|
1370
|
+
)
|
|
1371
|
+
.catch(() => {})
|
|
1372
|
+
}
|
|
1373
|
+
// Wake the agent that called vault_request_save — symmetric with
|
|
1374
|
+
// the vra: approve/deny path (#1052/#1150/#1156). Without this the
|
|
1375
|
+
// tool returned "waiting for operator", the turn ended, and a
|
|
1376
|
+
// Discard left the agent silently idle forever.
|
|
1377
|
+
const discardInbound = buildVaultSaveDiscardedInbound({
|
|
1378
|
+
ctx: {
|
|
1379
|
+
agent: pending.agent,
|
|
1380
|
+
key: pending.key,
|
|
1381
|
+
chat_id: pending.chat_id,
|
|
1382
|
+
...(pending.threadId != null ? { threadId: pending.threadId } : {}),
|
|
1383
|
+
},
|
|
1384
|
+
stageId,
|
|
1385
|
+
operatorId: senderId,
|
|
1386
|
+
})
|
|
1387
|
+
const dDelivered = deliverResumeSyntheticOrBuffer(pending.agent, discardInbound)
|
|
1388
|
+
process.stderr.write(
|
|
1389
|
+
`telegram gateway: vault_save_discarded injection agent=${pending.agent} ` +
|
|
1390
|
+
`key=${pending.key} stage=${stageId} delivered=${dDelivered}\n`,
|
|
1391
|
+
)
|
|
1392
|
+
return
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
if (action === 'rename') {
|
|
1396
|
+
// Set up a pending-op intercept so the user's next message is read
|
|
1397
|
+
// as the new key name. Same shape as the existing /vault set value
|
|
1398
|
+
// capture (gateway.ts uses pendingVaultOps for this).
|
|
1399
|
+
pendingVaultOps.set(pending.chat_id, {
|
|
1400
|
+
kind: 'rename-vault-save',
|
|
1401
|
+
stageId,
|
|
1402
|
+
startedAt: Date.now(),
|
|
1403
|
+
} as PendingVaultOp)
|
|
1404
|
+
// #1150 audit P0: pre-fix the [Save once][Discard][Rename] keyboard
|
|
1405
|
+
// stayed live after the rename tap so the operator could re-tap
|
|
1406
|
+
// Save with the old key name mid-rename — a Save tap fires the
|
|
1407
|
+
// write immediately, racing the rename intercept. Strip the
|
|
1408
|
+
// keyboard atomically with a status line that names the rename
|
|
1409
|
+
// mode + the proposed new-key prompt. No synthInbound — the
|
|
1410
|
+
// agent's `vault_request_save` tool already returned "waiting
|
|
1411
|
+
// for operator," and the eventual save success/failure flows
|
|
1412
|
+
// its own wake-up below.
|
|
1413
|
+
const sourceMsg = ctx.callbackQuery?.message
|
|
1414
|
+
const baseText = sourceMsg && 'text' in sourceMsg && sourceMsg.text
|
|
1415
|
+
? escapeHtmlForTg(sourceMsg.text)
|
|
1416
|
+
: ''
|
|
1417
|
+
const statusLine =
|
|
1418
|
+
`\n\n✏️ **Rename mode** — send the new key name as your next message. ` +
|
|
1419
|
+
`The current proposed key is \`${pending.key}\`.`
|
|
1420
|
+
await finalizeCallback(ctx, {
|
|
1421
|
+
ackText: 'Send the new key name as your next message.',
|
|
1422
|
+
newText: baseText ? `${baseText}${statusLine}` : statusLine,
|
|
1423
|
+
})
|
|
1424
|
+
return
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
if (action === 'save') {
|
|
1428
|
+
// Acknowledge the tap immediately so Telegram doesn't show a
|
|
1429
|
+
// stale "spinning" state on the button while we run the write.
|
|
1430
|
+
await ctx.answerCallbackQuery({ text: '⏳ Saving…' }).catch(() => {})
|
|
1431
|
+
|
|
1432
|
+
// Restored-after-restart guard: the staged secret VALUE is held in gateway
|
|
1433
|
+
// memory only and is never persisted (secrets hygiene). If this card was
|
|
1434
|
+
// restored from disk after a gateway restart, the value is gone — we CANNOT
|
|
1435
|
+
// complete the write. Degrade gracefully: strip the card, wake the agent
|
|
1436
|
+
// with a save-failed (value-lost) synthetic so it re-requests, and stop.
|
|
1437
|
+
if (pending.restoredWithoutValue || pending.value.length === 0) {
|
|
1438
|
+
pendingVaultRequestSaves.delete(stageId)
|
|
1439
|
+
pendingCardStore.remove(stageId)
|
|
1440
|
+
if (pending.card_message_id != null) {
|
|
1441
|
+
await ctx.api
|
|
1442
|
+
.editMessageText(
|
|
1443
|
+
pending.chat_id,
|
|
1444
|
+
pending.card_message_id,
|
|
1445
|
+
richMessage(`⚠️ _The staged value for \`${escapeHtmlForTg(pending.key)}\` was lost to a gateway restart — nothing was saved. Ask **${escapeHtmlForTg(pending.agent)}** to re-issue \`vault_request_save\` if you still want to store it._`),
|
|
1446
|
+
{ reply_markup: { inline_keyboard: [] } },
|
|
1447
|
+
)
|
|
1448
|
+
.catch(() => {})
|
|
1449
|
+
}
|
|
1450
|
+
const lostInbound = buildVaultSaveFailedInbound({
|
|
1451
|
+
ctx: {
|
|
1452
|
+
agent: pending.agent,
|
|
1453
|
+
key: pending.key,
|
|
1454
|
+
chat_id: pending.chat_id,
|
|
1455
|
+
...(pending.threadId != null ? { threadId: pending.threadId } : {}),
|
|
1456
|
+
},
|
|
1457
|
+
stageId,
|
|
1458
|
+
operatorId: senderId,
|
|
1459
|
+
reason: 'staged value lost to a gateway restart — re-request the save',
|
|
1460
|
+
})
|
|
1461
|
+
const lDelivered = deliverResumeSyntheticOrBuffer(pending.agent, lostInbound)
|
|
1462
|
+
process.stderr.write(
|
|
1463
|
+
`telegram gateway: vault_request_save value lost to restart — wake agent=${pending.agent} ` +
|
|
1464
|
+
`key=${pending.key} stage=${stageId} delivered=${lDelivered}\n`,
|
|
1465
|
+
)
|
|
1466
|
+
return
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
// #1115 follow-up: the save-approve flow now mirrors the access-
|
|
1470
|
+
// approve flow under telegram-id mode — broker `put` accepts
|
|
1471
|
+
// `attest_via_posture: true` (server.ts:1448-1500), so the
|
|
1472
|
+
// gateway can attest the write without a cached passphrase.
|
|
1473
|
+
// Closes the UX gap where tapping Save surfaced a misleading
|
|
1474
|
+
// "🔒 Vault is locked" message even when the broker had been
|
|
1475
|
+
// auto-unlocked at boot.
|
|
1476
|
+
//
|
|
1477
|
+
// Branch: under telegram-id mode use the posture-attested put;
|
|
1478
|
+
// under passphrase mode keep the existing cached-passphrase +
|
|
1479
|
+
// shell-to-CLI path (operator must `/vault unlock` once per
|
|
1480
|
+
// chat session to populate `vaultPassphraseCache`).
|
|
1481
|
+
let write: { ok: boolean; output: string }
|
|
1482
|
+
if (getVaultApprovalAuthMode() === 'telegram-id') {
|
|
1483
|
+
// Posture-attested broker put. No passphrase needed. The broker
|
|
1484
|
+
// verifies (a) telegram-id mode, (b) per-agent peer, (c) broker
|
|
1485
|
+
// unlocked — see server.ts:1448-1500.
|
|
1486
|
+
write = await defaultVaultWritePosture(pending.key, pending.value)
|
|
1487
|
+
} else {
|
|
1488
|
+
// Passphrase mode — fetch the cached passphrase for this chat.
|
|
1489
|
+
// If the gateway hasn't seen the user unlock the vault yet, we
|
|
1490
|
+
// can't attest the write — surface the unlock prompt.
|
|
1491
|
+
const cached = vaultPassphraseCache.get(pending.chat_id)
|
|
1492
|
+
if (!cached || cached.expiresAt <= Date.now()) {
|
|
1493
|
+
if (pending.card_message_id != null) {
|
|
1494
|
+
await ctx.api
|
|
1495
|
+
.editMessageText(
|
|
1496
|
+
pending.chat_id,
|
|
1497
|
+
pending.card_message_id,
|
|
1498
|
+
richMessage(`🔒 **Passphrase not cached for this chat.** Run \`/vault unlock\` (or any /vault command) to cache it, then tap Save again on the next card.`),
|
|
1499
|
+
{ reply_markup: { inline_keyboard: [] } },
|
|
1500
|
+
)
|
|
1501
|
+
.catch(() => {})
|
|
1502
|
+
}
|
|
1503
|
+
pendingVaultRequestSaves.delete(stageId)
|
|
1504
|
+
pendingCardStore.remove(stageId)
|
|
1505
|
+
return
|
|
1506
|
+
}
|
|
1507
|
+
// defaultVaultWrite spawns `switchroom vault set <key>` with the
|
|
1508
|
+
// passphrase env set; the CLI forwards the passphrase to the
|
|
1509
|
+
// broker put as operator-attestation (#969 P1a), which authorizes
|
|
1510
|
+
// new-key creation.
|
|
1511
|
+
write = defaultVaultWrite(pending.key, pending.value, cached.passphrase)
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
if (!write.ok) {
|
|
1515
|
+
// Route through the structured-error renderer from #969 P0b so
|
|
1516
|
+
// failures show the actionable host hint instead of a raw blob.
|
|
1517
|
+
const parsed = parseVaultCliError(write.output)
|
|
1518
|
+
const rendered = renderVaultCliError(parsed, { verb: 'save', key: pending.key })
|
|
1519
|
+
const body = rendered.suppressRaw
|
|
1520
|
+
? rendered.html
|
|
1521
|
+
: `⚠️ vault write failed:\n\`\`\`\n${write.output}\n\`\`\``
|
|
1522
|
+
if (pending.card_message_id != null) {
|
|
1523
|
+
await ctx.api
|
|
1524
|
+
.editMessageText(
|
|
1525
|
+
pending.chat_id,
|
|
1526
|
+
pending.card_message_id,
|
|
1527
|
+
richMessage(`${body}\n\n_Tap a fresh card after fixing the underlying issue._`),
|
|
1528
|
+
{ reply_markup: { inline_keyboard: [] } },
|
|
1529
|
+
)
|
|
1530
|
+
.catch(() => {})
|
|
1531
|
+
}
|
|
1532
|
+
// Leave the staged secret in memory until TTL — operator might
|
|
1533
|
+
// retry by re-invoking the same MCP tool, but the value will be
|
|
1534
|
+
// re-staged with a new ID. Drop the current stage.
|
|
1535
|
+
pendingVaultRequestSaves.delete(stageId)
|
|
1536
|
+
pendingCardStore.remove(stageId)
|
|
1537
|
+
// Wake the waiting agent with the failure (symmetric with the
|
|
1538
|
+
// success/discard paths) so it doesn't assume vault:<key> exists.
|
|
1539
|
+
const failReason =
|
|
1540
|
+
(write.output || 'vault write error').split('\n')[0]!.slice(0, 200)
|
|
1541
|
+
const failInbound = buildVaultSaveFailedInbound({
|
|
1542
|
+
ctx: {
|
|
1543
|
+
agent: pending.agent,
|
|
1544
|
+
key: pending.key,
|
|
1545
|
+
chat_id: pending.chat_id,
|
|
1546
|
+
...(pending.threadId != null ? { threadId: pending.threadId } : {}),
|
|
1547
|
+
},
|
|
1548
|
+
stageId,
|
|
1549
|
+
operatorId: senderId,
|
|
1550
|
+
reason: failReason,
|
|
1551
|
+
})
|
|
1552
|
+
const fDelivered = deliverResumeSyntheticOrBuffer(pending.agent, failInbound)
|
|
1553
|
+
process.stderr.write(
|
|
1554
|
+
`telegram gateway: vault_save_failed injection agent=${pending.agent} ` +
|
|
1555
|
+
`key=${pending.key} stage=${stageId} delivered=${fDelivered}\n`,
|
|
1556
|
+
)
|
|
1557
|
+
return
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
// Success — mask the value in the card for visual confirmation.
|
|
1561
|
+
pendingVaultRequestSaves.delete(stageId)
|
|
1562
|
+
pendingCardStore.remove(stageId)
|
|
1563
|
+
if (pending.card_message_id != null) {
|
|
1564
|
+
await ctx.api
|
|
1565
|
+
.editMessageText(
|
|
1566
|
+
pending.chat_id,
|
|
1567
|
+
pending.card_message_id,
|
|
1568
|
+
richMessage(`✅ saved as \`vault:${escapeHtmlForTg(pending.key)}\` (masked: \`${escapeHtmlForTg(maskToken(pending.value))}\`)\n_The agent can now reference this as \`vault:${escapeHtmlForTg(pending.key)}\`._`),
|
|
1569
|
+
{ reply_markup: { inline_keyboard: [] } },
|
|
1570
|
+
)
|
|
1571
|
+
.catch(() => {})
|
|
1572
|
+
}
|
|
1573
|
+
// Wake the agent that called vault_request_save so it resumes the
|
|
1574
|
+
// task that was blocked on this credential (symmetric with the
|
|
1575
|
+
// vra: approve path; buffered if the bridge is mid-reconnect).
|
|
1576
|
+
const okInbound = buildVaultSaveCompletedInbound({
|
|
1577
|
+
ctx: {
|
|
1578
|
+
agent: pending.agent,
|
|
1579
|
+
key: pending.key,
|
|
1580
|
+
chat_id: pending.chat_id,
|
|
1581
|
+
...(pending.threadId != null ? { threadId: pending.threadId } : {}),
|
|
1582
|
+
},
|
|
1583
|
+
stageId,
|
|
1584
|
+
operatorId: senderId,
|
|
1585
|
+
})
|
|
1586
|
+
const okDelivered = deliverResumeSyntheticOrBuffer(pending.agent, okInbound)
|
|
1587
|
+
process.stderr.write(
|
|
1588
|
+
`telegram gateway: vault_save_completed injection agent=${pending.agent} ` +
|
|
1589
|
+
`key=${pending.key} stage=${stageId} delivered=${okDelivered}\n`,
|
|
1590
|
+
)
|
|
1591
|
+
return
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
await ctx.answerCallbackQuery({ text: 'Unknown action' }).catch(() => {})
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
async function handleVaultDeferCallback(ctx: Context, data: string): Promise<void> {
|
|
1598
|
+
const senderId = String(ctx.from?.id ?? '')
|
|
1599
|
+
const access = loadAccess()
|
|
1600
|
+
if (!access.allowFrom.includes(senderId)) {
|
|
1601
|
+
await ctx.answerCallbackQuery({ text: 'Not authorized.' }).catch(() => {})
|
|
1602
|
+
return
|
|
1603
|
+
}
|
|
1604
|
+
// vd:<action>:<deferKey>. deferKey itself contains a colon (chat:msgId)
|
|
1605
|
+
// so we slice rather than split — only the first two segments are
|
|
1606
|
+
// structural; the rest is the deferKey verbatim.
|
|
1607
|
+
const rest = data.slice('vd:'.length)
|
|
1608
|
+
const colon = rest.indexOf(':')
|
|
1609
|
+
if (colon < 0) {
|
|
1610
|
+
await ctx.answerCallbackQuery({ text: 'Malformed callback.' }).catch(() => {})
|
|
1611
|
+
return
|
|
1612
|
+
}
|
|
1613
|
+
const action = rest.slice(0, colon)
|
|
1614
|
+
const deferKey = rest.slice(colon + 1)
|
|
1615
|
+
const deferred = deferredSecrets.get(deferKey)
|
|
1616
|
+
if (!deferred) {
|
|
1617
|
+
await ctx.answerCallbackQuery({ text: 'This card expired. Re-send the secret.' }).catch(() => {})
|
|
1618
|
+
await ctx.editMessageReplyMarkup({ reply_markup: { inline_keyboard: [] } }).catch(() => {})
|
|
1619
|
+
return
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
const cardChatId = String(ctx.chat?.id ?? '')
|
|
1623
|
+
const cardMessageId = ctx.callbackQuery?.message?.message_id
|
|
1624
|
+
|
|
1625
|
+
if (action === 'cancel') {
|
|
1626
|
+
// Kernel-side dual-dispatch (MIGRATION.md §1): record the deny decision
|
|
1627
|
+
// BEFORE the legacy handler clears state, so the audit log captures it
|
|
1628
|
+
// even if the editMessageText below races with another tap. Best-effort
|
|
1629
|
+
// — broker unreachable falls back to legacy-only.
|
|
1630
|
+
await recordDeferredSecretKernelDecision(
|
|
1631
|
+
deferred.kernel_request_id,
|
|
1632
|
+
'deny',
|
|
1633
|
+
ctx.from?.id ?? 0,
|
|
1634
|
+
access.allowFrom,
|
|
1635
|
+
)
|
|
1636
|
+
deferredSecrets.delete(deferKey)
|
|
1637
|
+
await ctx.answerCallbackQuery({ text: 'Discarded.' }).catch(() => {})
|
|
1638
|
+
if (cardMessageId != null) {
|
|
1639
|
+
await ctx
|
|
1640
|
+
.editMessageText('🗑 Discarded — secret was not saved.', {
|
|
1641
|
+
reply_markup: { inline_keyboard: [] },
|
|
1642
|
+
})
|
|
1643
|
+
.catch(() => {})
|
|
1644
|
+
}
|
|
1645
|
+
return
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
if (action === 'unlock') {
|
|
1649
|
+
// Kernel-side dual-dispatch (MIGRATION.md §1): record the allow_once
|
|
1650
|
+
// decision when the user taps unlock. The actual passphrase capture +
|
|
1651
|
+
// vault write still happens via the legacy path below — the kernel
|
|
1652
|
+
// decision is for audit/state, not secret material (per RFC B). We
|
|
1653
|
+
// record at tap-time rather than after passphrase entry so a kernel
|
|
1654
|
+
// record exists even if the user abandons the passphrase prompt.
|
|
1655
|
+
await recordDeferredSecretKernelDecision(
|
|
1656
|
+
deferred.kernel_request_id,
|
|
1657
|
+
'allow_once',
|
|
1658
|
+
ctx.from?.id ?? 0,
|
|
1659
|
+
access.allowFrom,
|
|
1660
|
+
)
|
|
1661
|
+
// #1115 follow-up: telegram-id mode silent-defer-save was withdrawn
|
|
1662
|
+
// (same reason as the save-callback above — the in-memory
|
|
1663
|
+
// passphrase short-circuit became a bypass surface). The
|
|
1664
|
+
// deferred-secret save falls through to the cached-passphrase
|
|
1665
|
+
// path under all postures. Routing executeDeferredSecretSave
|
|
1666
|
+
// through broker-IPC attest_via_posture is a tracked follow-up.
|
|
1667
|
+
|
|
1668
|
+
// If a passphrase is already cached we can skip straight to the write.
|
|
1669
|
+
// Covers the case where the user had unlocked separately between
|
|
1670
|
+
// detection and tap.
|
|
1671
|
+
const cached = vaultPassphraseCache.get(cardChatId)
|
|
1672
|
+
if (cached && cached.expiresAt > Date.now()) {
|
|
1673
|
+
await ctx.answerCallbackQuery({ text: 'Saving…' }).catch(() => {})
|
|
1674
|
+
await executeDeferredSecretSave(ctx, deferKey, cached.passphrase, cardMessageId)
|
|
1675
|
+
return
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
if (cardMessageId == null) {
|
|
1679
|
+
await ctx.answerCallbackQuery({ text: 'Missing card context.' }).catch(() => {})
|
|
1680
|
+
return
|
|
1681
|
+
}
|
|
1682
|
+
pendingVaultOps.set(cardChatId, {
|
|
1683
|
+
kind: 'passphrase-for-deferred',
|
|
1684
|
+
deferKey,
|
|
1685
|
+
cardChatId,
|
|
1686
|
+
cardMessageId,
|
|
1687
|
+
startedAt: Date.now(),
|
|
1688
|
+
})
|
|
1689
|
+
await ctx.answerCallbackQuery({ text: 'Send your passphrase…' }).catch(() => {})
|
|
1690
|
+
await ctx
|
|
1691
|
+
.editMessageText(
|
|
1692
|
+
richMessage('🔐 Send your vault passphrase as your next message — we\'ll save the held secret automatically and delete the passphrase message.'),
|
|
1693
|
+
{ reply_markup: { inline_keyboard: [] } },
|
|
1694
|
+
)
|
|
1695
|
+
.catch(() => {})
|
|
1696
|
+
return
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
await ctx.answerCallbackQuery({ text: 'Unknown action.' }).catch(() => {})
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
// ─── Grant wizard helpers (Issue #227) ──────────────────────────────────────
|
|
1703
|
+
// TODO: these helpers duplicate server.ts — extract to a shared module in a
|
|
1704
|
+
// future refactor once the two entrypoints are proven stable in production.
|
|
1705
|
+
|
|
1706
|
+
/** Parse a duration string like "30d", "7h", "365d" into seconds. */
|
|
1707
|
+
function parseGrantDuration(s: string): number | null {
|
|
1708
|
+
const m = /^(\d+)([dh])$/i.exec(s.trim())
|
|
1709
|
+
if (!m) return null
|
|
1710
|
+
const n = parseInt(m[1]!, 10)
|
|
1711
|
+
if (n <= 0) return null
|
|
1712
|
+
return m[2]!.toLowerCase() === 'd' ? n * 86400 : n * 3600
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
/** Format seconds as a human-readable expiry label. */
|
|
1716
|
+
function formatGrantExpiry(ttlSeconds: number | null, now: Date = new Date()): string {
|
|
1717
|
+
if (ttlSeconds === null) return 'Never'
|
|
1718
|
+
const exp = new Date(now.getTime() + ttlSeconds * 1000)
|
|
1719
|
+
return exp.toISOString().slice(0, 10)
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
/** Build the Step 1 keyboard: agent selection. */
|
|
1723
|
+
function buildGrantAgentKeyboard(agents: string[]): InlineKeyboard {
|
|
1724
|
+
const kb = new InlineKeyboard()
|
|
1725
|
+
// Max 3 per row to keep buttons readable on mobile
|
|
1726
|
+
for (let i = 0; i < agents.length; i++) {
|
|
1727
|
+
if (i > 0 && i % 3 === 0) kb.row()
|
|
1728
|
+
kb.text(agents[i]!, `vg:agent:${agents[i]!}`)
|
|
1729
|
+
}
|
|
1730
|
+
kb.row().text('Cancel', 'vg:cancel')
|
|
1731
|
+
return kb
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
/** Build the Step 2 keyboard: key multi-select toggle. */
|
|
1735
|
+
function buildGrantKeysKeyboard(keys: string[], selected: Set<string>): InlineKeyboard {
|
|
1736
|
+
const kb = new InlineKeyboard()
|
|
1737
|
+
for (const k of keys) {
|
|
1738
|
+
const check = selected.has(k) ? '☑' : '☐'
|
|
1739
|
+
kb.row().text(`${check} ${k}`, `vg:key:${k}`)
|
|
1740
|
+
}
|
|
1741
|
+
kb.row()
|
|
1742
|
+
.text('Continue', 'vg:keys-continue')
|
|
1743
|
+
.text('Cancel', 'vg:cancel')
|
|
1744
|
+
return kb
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
/** Build the Step 3 keyboard: duration selection. */
|
|
1748
|
+
function buildGrantDurationKeyboard(): InlineKeyboard {
|
|
1749
|
+
return new InlineKeyboard()
|
|
1750
|
+
.text('30 days', 'vg:dur:30d')
|
|
1751
|
+
.text('90 days', 'vg:dur:90d')
|
|
1752
|
+
.text('1 year', 'vg:dur:1y')
|
|
1753
|
+
.row()
|
|
1754
|
+
.text('Custom…', 'vg:dur:custom')
|
|
1755
|
+
.text('No expiry', 'vg:dur:never')
|
|
1756
|
+
.row()
|
|
1757
|
+
.text('Back', 'vg:back:duration')
|
|
1758
|
+
.text('Cancel', 'vg:cancel')
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
/** Build the Confirm keyboard. */
|
|
1762
|
+
function buildGrantConfirmKeyboard(): InlineKeyboard {
|
|
1763
|
+
return new InlineKeyboard()
|
|
1764
|
+
.text('Generate', 'vg:generate')
|
|
1765
|
+
.text('Cancel', 'vg:cancel')
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
/** Start the grant wizard (step 1: pick agent). */
|
|
1769
|
+
async function startGrantWizardStep1(ctx: Context, chatId: string): Promise<void> {
|
|
1770
|
+
type AgentListResp = { agents: Array<{ name: string }> }
|
|
1771
|
+
const data = switchroomExecJson<AgentListResp>(['agent', 'list'])
|
|
1772
|
+
const agents = data?.agents?.map(a => a.name).filter(Boolean) ?? []
|
|
1773
|
+
if (agents.length === 0) {
|
|
1774
|
+
await switchroomReply(ctx, '⚠️ No agents found in switchroom.yaml.', { html: true })
|
|
1775
|
+
return
|
|
1776
|
+
}
|
|
1777
|
+
const kb = buildGrantAgentKeyboard(agents)
|
|
1778
|
+
const sent = await switchroomReply(ctx, '**Grant capability token — Step 1/3**\n\nWhich agent?', { html: true, reply_markup: kb })
|
|
1779
|
+
const wizardMsgId = (sent as unknown as { message_id?: number })?.message_id
|
|
1780
|
+
pendingVaultOps.set(chatId, {
|
|
1781
|
+
kind: 'grant-wizard',
|
|
1782
|
+
step: 'agent',
|
|
1783
|
+
wizardMsgId,
|
|
1784
|
+
startedAt: Date.now(),
|
|
1785
|
+
})
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
/** Advance grant wizard to step 2 (pick keys). */
|
|
1789
|
+
async function grantWizardStep2(ctx: Context, chatId: string, agent: string, wizardMsgId: number | undefined): Promise<void> {
|
|
1790
|
+
const keys = await listViaBroker()
|
|
1791
|
+
if (!keys) {
|
|
1792
|
+
await switchroomReply(ctx, '🔴 Broker is not running (or unreachable). Cannot list vault keys.', { html: true })
|
|
1793
|
+
pendingVaultOps.delete(chatId)
|
|
1794
|
+
return
|
|
1795
|
+
}
|
|
1796
|
+
if (keys.length === 0) {
|
|
1797
|
+
await switchroomReply(ctx, '⚠️ No vault keys found. Add secrets first with \`/vault set\`.', { html: true })
|
|
1798
|
+
pendingVaultOps.delete(chatId)
|
|
1799
|
+
return
|
|
1800
|
+
}
|
|
1801
|
+
const selected = new Set<string>()
|
|
1802
|
+
const kb = buildGrantKeysKeyboard(keys, selected)
|
|
1803
|
+
const text = `**Grant capability token — Step 2/3**\n\nWhich keys for \`${agent}\`?\n_Tap to toggle; tap Continue when done._`
|
|
1804
|
+
if (wizardMsgId != null) {
|
|
1805
|
+
// allow-raw-bot-api: vault grant wizard step 2/3; already .catch-swallows, tap-driven UI re-renders on retry
|
|
1806
|
+
await ctx.api.editMessageText(chatId, wizardMsgId, richMessage(text), { reply_markup: kb }).catch(() => {})
|
|
1807
|
+
} else {
|
|
1808
|
+
const sent = await switchroomReply(ctx, text, { html: true, reply_markup: kb })
|
|
1809
|
+
wizardMsgId = (sent as unknown as { message_id?: number })?.message_id
|
|
1810
|
+
}
|
|
1811
|
+
pendingVaultOps.set(chatId, {
|
|
1812
|
+
kind: 'grant-wizard',
|
|
1813
|
+
step: 'keys',
|
|
1814
|
+
agent,
|
|
1815
|
+
selectedKeys: [],
|
|
1816
|
+
availableKeys: keys,
|
|
1817
|
+
wizardMsgId,
|
|
1818
|
+
startedAt: Date.now(),
|
|
1819
|
+
})
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
/** Advance grant wizard to step 3 (pick duration). */
|
|
1823
|
+
async function grantWizardStep3(ctx: Context, chatId: string, state: Extract<PendingVaultOp, { kind: 'grant-wizard' }>): Promise<void> {
|
|
1824
|
+
const kb = buildGrantDurationKeyboard()
|
|
1825
|
+
const keyList = state.selectedKeys!.map(k => `• \`${k}\``).join('\n')
|
|
1826
|
+
const text = `**Grant capability token — Step 3/3**\n\nKeys for \`${state.agent!}\`:\n${keyList}\n\nHow long should this grant be valid?`
|
|
1827
|
+
const msgId = state.wizardMsgId
|
|
1828
|
+
if (msgId != null) {
|
|
1829
|
+
// allow-raw-bot-api: vault grant wizard step 3/3 (TTL select); already .catch-swallows, tap-driven UI re-renders on retry
|
|
1830
|
+
await ctx.api.editMessageText(chatId, msgId, richMessage(text), { reply_markup: kb }).catch(() => {})
|
|
1831
|
+
} else {
|
|
1832
|
+
const sent = await switchroomReply(ctx, text, { html: true, reply_markup: kb })
|
|
1833
|
+
state.wizardMsgId = (sent as unknown as { message_id?: number })?.message_id
|
|
1834
|
+
}
|
|
1835
|
+
pendingVaultOps.set(chatId, { ...state, step: 'duration' })
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1838
|
+
/** Advance grant wizard to confirmation step. */
|
|
1839
|
+
async function grantWizardConfirm(ctx: Context, chatId: string, state: Extract<PendingVaultOp, { kind: 'grant-wizard' }>): Promise<void> {
|
|
1840
|
+
const kb = buildGrantConfirmKeyboard()
|
|
1841
|
+
const expiresLabel = formatGrantExpiry(state.ttlSeconds!)
|
|
1842
|
+
const keyList = state.selectedKeys!.map(k => `• \`${k}\``).join('\n')
|
|
1843
|
+
const text = [
|
|
1844
|
+
'**Confirm grant**',
|
|
1845
|
+
'',
|
|
1846
|
+
`Agent: \`${state.agent!}\``,
|
|
1847
|
+
`Keys:\n${keyList}`,
|
|
1848
|
+
`Expires: **${escapeHtmlForTg(expiresLabel)}**`,
|
|
1849
|
+
'',
|
|
1850
|
+
'Tap **Generate** to mint the token.',
|
|
1851
|
+
].join('\n')
|
|
1852
|
+
const msgId = state.wizardMsgId
|
|
1853
|
+
if (msgId != null) {
|
|
1854
|
+
// allow-raw-bot-api: vault grant wizard confirm step; already .catch-swallows, tap-driven UI re-renders on retry
|
|
1855
|
+
await ctx.api.editMessageText(chatId, msgId, richMessage(text), { reply_markup: kb }).catch(() => {})
|
|
1856
|
+
} else {
|
|
1857
|
+
const sent = await switchroomReply(ctx, text, { html: true, reply_markup: kb })
|
|
1858
|
+
state.wizardMsgId = (sent as unknown as { message_id?: number })?.message_id
|
|
1859
|
+
}
|
|
1860
|
+
// Mint kernel decision row at the confirm step (MIGRATION.md §2,
|
|
1861
|
+
// audit-only Phase 1). We do it here rather than at executeGrantWizard
|
|
1862
|
+
// so a kernel row exists even if the user taps Cancel from the confirm
|
|
1863
|
+
// card — the deny verdict on cancel is then recorded against the same
|
|
1864
|
+
// request_id. If the kernel/broker is unreachable, request_id stays
|
|
1865
|
+
// undefined and the wizard runs legacy-only (no behaviour change).
|
|
1866
|
+
const kernelRequestId = await mintGrantWizardKernelRequest(
|
|
1867
|
+
state.agent!,
|
|
1868
|
+
loadAccess().allowFrom,
|
|
1869
|
+
state.selectedKeys!,
|
|
1870
|
+
state.ttlSeconds ?? null,
|
|
1871
|
+
)
|
|
1872
|
+
pendingVaultOps.set(chatId, {
|
|
1873
|
+
...state,
|
|
1874
|
+
step: 'confirm',
|
|
1875
|
+
expiresLabel,
|
|
1876
|
+
kernel_request_id: kernelRequestId ?? state.kernel_request_id,
|
|
1877
|
+
})
|
|
1878
|
+
}
|
|
1879
|
+
|
|
1880
|
+
/** Execute the grant: call broker mint_grant, write token, reply. */
|
|
1881
|
+
async function executeGrantWizard(ctx: Context, chatId: string, state: Extract<PendingVaultOp, { kind: 'grant-wizard' }>): Promise<void> {
|
|
1882
|
+
pendingVaultOps.delete(chatId)
|
|
1883
|
+
// Kernel-side dual-dispatch (MIGRATION.md §2, audit-only Phase 1):
|
|
1884
|
+
// record the allow_once decision when the user taps Generate. The
|
|
1885
|
+
// legacy `mintGrantViaBroker` below still drives the actual grant
|
|
1886
|
+
// mint + token write — the kernel row is informational, not
|
|
1887
|
+
// enforcing, in Phase 1 (issue #833 will flip to enforcing).
|
|
1888
|
+
// We record at tap-time rather than after mint_grant succeeds so a
|
|
1889
|
+
// kernel row exists even if the legacy mint fails (audit captures
|
|
1890
|
+
// intent regardless of downstream outcome).
|
|
1891
|
+
await recordGrantWizardKernelDecision(
|
|
1892
|
+
state.kernel_request_id,
|
|
1893
|
+
'allow_once',
|
|
1894
|
+
ctx.from?.id ?? 0,
|
|
1895
|
+
loadAccess().allowFrom,
|
|
1896
|
+
)
|
|
1897
|
+
// Defence-in-depth: state.agent flows from callback_data into a path
|
|
1898
|
+
// join below. A crafted vg:agent:../../etc payload would produce a
|
|
1899
|
+
// path traversal. Validate against the same regex the rest of the
|
|
1900
|
+
// file uses; on failure, drop silently — the wizard message has
|
|
1901
|
+
// already been finalized.
|
|
1902
|
+
try { assertSafeAgentName(state.agent!) } catch { return }
|
|
1903
|
+
const result = await mintGrantViaBroker({
|
|
1904
|
+
agent: state.agent!,
|
|
1905
|
+
keys: state.selectedKeys!,
|
|
1906
|
+
ttl_seconds: state.ttlSeconds ?? null,
|
|
1907
|
+
description: state.description,
|
|
1908
|
+
})
|
|
1909
|
+
if (result.kind === 'unreachable') {
|
|
1910
|
+
await switchroomReply(ctx, `🔴 Broker unreachable: ${escapeHtmlForTg(result.msg)}`, { html: true })
|
|
1911
|
+
return
|
|
1912
|
+
}
|
|
1913
|
+
if (result.kind === 'error') {
|
|
1914
|
+
await switchroomReply(ctx, `**mint_grant failed:** ${escapeHtmlForTg(result.msg)}`, { html: true })
|
|
1915
|
+
return
|
|
1916
|
+
}
|
|
1917
|
+
// Write token to the agent's .vault-token file
|
|
1918
|
+
const { token, id } = result
|
|
1919
|
+
const tokenPath = join(homedir(), '.switchroom', 'agents', state.agent!, '.vault-token')
|
|
1920
|
+
try {
|
|
1921
|
+
mkdirSync(join(homedir(), '.switchroom', 'agents', state.agent!), { recursive: true })
|
|
1922
|
+
writeFileSync(tokenPath, token, { mode: 0o600 })
|
|
1923
|
+
} catch (err) {
|
|
1924
|
+
await switchroomReply(ctx, `**Grant created but token write failed:** ${escapeHtmlForTg(String(err))}`, { html: true })
|
|
1925
|
+
return
|
|
1926
|
+
}
|
|
1927
|
+
// Collapse wizard message to just the outcome.
|
|
1928
|
+
// #1150 audit: P0 fix — pre-fix this `editMessageText` call omitted
|
|
1929
|
+
// `reply_markup: { inline_keyboard: [] }` so the wizard's [Generate]
|
|
1930
|
+
// / [Cancel] buttons stayed tappable on the success card. Operator
|
|
1931
|
+
// could re-tap [Generate] and mint a second redundant grant.
|
|
1932
|
+
// Strip the keyboard atomically with the success text via the
|
|
1933
|
+
// finalizeCallback helper.
|
|
1934
|
+
const msgId = state.wizardMsgId
|
|
1935
|
+
const successText = `✅ Grant \`${id}\` created. Written to \`~/.switchroom/agents/${escapeHtmlForTg(state.agent!)}/.vault-token\``
|
|
1936
|
+
if (msgId != null) {
|
|
1937
|
+
await finalizeCallback(ctx, {
|
|
1938
|
+
ackText: '✅ Grant created',
|
|
1939
|
+
newText: successText,
|
|
1940
|
+
// No synthInbound — operator-only flow.
|
|
1941
|
+
})
|
|
1942
|
+
} else {
|
|
1943
|
+
// Fallback when wizard message id was lost (rare; e.g. operator
|
|
1944
|
+
// deleted the card). Send a fresh reply with the success text;
|
|
1945
|
+
// no keyboard to strip in this branch.
|
|
1946
|
+
await switchroomReply(ctx, successText, { html: true })
|
|
1947
|
+
}
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
/**
|
|
1951
|
+
* Issue #228: handle vault grant management callbacks.
|
|
1952
|
+
*
|
|
1953
|
+
* `vg:revoke:<grantId>` — fetch grant details and show confirmation card.
|
|
1954
|
+
* `vg:confirm:<grantId>` — call broker revoke_grant, reply with success.
|
|
1955
|
+
* `vg:cancel:<grantId>` — dismiss (clear keyboard, no broker call).
|
|
1956
|
+
*
|
|
1957
|
+
* Issue #227: also handles /vault grant wizard callbacks.
|
|
1958
|
+
*
|
|
1959
|
+
* `vg:cancel` — cancel wizard at any step.
|
|
1960
|
+
* `vg:agent:<name>` — step 1: select agent.
|
|
1961
|
+
* `vg:key:<name>` — step 2: toggle key selection.
|
|
1962
|
+
* `vg:keys-continue` — step 2 → 3.
|
|
1963
|
+
* `vg:dur:<value>` — step 3: duration selection.
|
|
1964
|
+
* `vg:back:duration` — step 3 → back to step 2.
|
|
1965
|
+
* `vg:generate` — confirm and mint token.
|
|
1966
|
+
*/
|
|
1967
|
+
async function handleVaultGrantCallback(ctx: Context, data: string): Promise<void> {
|
|
1968
|
+
const senderId = String(ctx.from?.id ?? '')
|
|
1969
|
+
const access = loadAccess()
|
|
1970
|
+
if (!access.allowFrom.includes(senderId)) {
|
|
1971
|
+
await ctx.answerCallbackQuery({ text: 'Not authorized.' }).catch(() => {})
|
|
1972
|
+
return
|
|
1973
|
+
}
|
|
1974
|
+
|
|
1975
|
+
const revokeMatch = /^vg:revoke:(.+)$/.exec(data)
|
|
1976
|
+
if (revokeMatch) {
|
|
1977
|
+
const grantId = revokeMatch[1]!
|
|
1978
|
+
const result = await listGrantsViaBroker(undefined)
|
|
1979
|
+
if (result.kind !== 'ok') {
|
|
1980
|
+
await ctx.answerCallbackQuery({ text: 'Broker unreachable.' }).catch(() => {})
|
|
1981
|
+
return
|
|
1982
|
+
}
|
|
1983
|
+
const grant = result.grants.find(g => g.id === grantId)
|
|
1984
|
+
if (!grant) {
|
|
1985
|
+
await ctx.answerCallbackQuery({ text: 'Grant not found (already revoked?).' }).catch(() => {})
|
|
1986
|
+
await ctx.editMessageReplyMarkup({ reply_markup: { inline_keyboard: [] } }).catch(() => {})
|
|
1987
|
+
return
|
|
1988
|
+
}
|
|
1989
|
+
const cardText =
|
|
1990
|
+
`🗑 Revoke \`${grantId}\`?\n` +
|
|
1991
|
+
`Agent: **${escapeHtmlForTg(grant.agent_slug)}**\n` +
|
|
1992
|
+
`Keys: \`${escapeHtmlForTg(grant.key_allow.join(', '))}\``
|
|
1993
|
+
const confirmKeyboard = new InlineKeyboard()
|
|
1994
|
+
.text('✅ Confirm Revoke', `vg:confirm:${grantId}`)
|
|
1995
|
+
.text('❌ Cancel', `vg:cancel:${grantId}`)
|
|
1996
|
+
await ctx.answerCallbackQuery().catch(() => {})
|
|
1997
|
+
await ctx.editMessageText(richMessage(cardText), {
|
|
1998
|
+
reply_markup: confirmKeyboard,
|
|
1999
|
+
}).catch(async () => {
|
|
2000
|
+
const chatId = String(ctx.chat?.id ?? ctx.from?.id ?? '')
|
|
2001
|
+
const threadId = ctx.callbackQuery?.message?.message_thread_id
|
|
2002
|
+
if (chatId) {
|
|
2003
|
+
// #1075: thread-id-bearing — swallow on THREAD_NOT_FOUND.
|
|
2004
|
+
await swallowingApiCall(
|
|
2005
|
+
() =>
|
|
2006
|
+
bot.api.sendRichMessage(chatId, richMessage(cardText), {
|
|
2007
|
+
reply_markup: confirmKeyboard,
|
|
2008
|
+
...(threadId != null ? { message_thread_id: threadId } : {}),
|
|
2009
|
+
}),
|
|
2010
|
+
{
|
|
2011
|
+
chat_id: chatId,
|
|
2012
|
+
verb: 'vault-revoke-confirm-fallback',
|
|
2013
|
+
...(threadId != null ? { threadId } : {}),
|
|
2014
|
+
},
|
|
2015
|
+
)
|
|
2016
|
+
}
|
|
2017
|
+
})
|
|
2018
|
+
return
|
|
2019
|
+
}
|
|
2020
|
+
|
|
2021
|
+
const confirmMatch = /^vg:confirm:(.+)$/.exec(data)
|
|
2022
|
+
if (confirmMatch) {
|
|
2023
|
+
const grantId = confirmMatch[1]!
|
|
2024
|
+
const revokeResult = await revokeGrantViaBroker(grantId)
|
|
2025
|
+
if (revokeResult.kind === 'unreachable') {
|
|
2026
|
+
await ctx.answerCallbackQuery({ text: 'Broker unreachable.' }).catch(() => {})
|
|
2027
|
+
return
|
|
2028
|
+
}
|
|
2029
|
+
if (revokeResult.kind === 'error') {
|
|
2030
|
+
await ctx.answerCallbackQuery({ text: `Revoke failed: ${revokeResult.msg}` }).catch(() => {})
|
|
2031
|
+
return
|
|
2032
|
+
}
|
|
2033
|
+
await ctx.answerCallbackQuery({ text: '✅ Revoked' }).catch(() => {})
|
|
2034
|
+
await ctx.editMessageText(
|
|
2035
|
+
richMessage(`✅ Grant \`${grantId}\` revoked. Token file removed.`),
|
|
2036
|
+
{ reply_markup: { inline_keyboard: [] } },
|
|
2037
|
+
).catch(() => {})
|
|
2038
|
+
return
|
|
2039
|
+
}
|
|
2040
|
+
|
|
2041
|
+
const cancelMatch = /^vg:cancel:(.+)$/.exec(data)
|
|
2042
|
+
if (cancelMatch) {
|
|
2043
|
+
await ctx.answerCallbackQuery({ text: 'Cancelled.' }).catch(() => {})
|
|
2044
|
+
await ctx.editMessageReplyMarkup({ reply_markup: { inline_keyboard: [] } }).catch(() => {})
|
|
2045
|
+
return
|
|
2046
|
+
}
|
|
2047
|
+
|
|
2048
|
+
// #227 grant wizard callbacks (vg:cancel bare, vg:agent:*, vg:key:*, vg:keys-continue,
|
|
2049
|
+
// vg:dur:*, vg:back:*, vg:generate). These come after the management callbacks above
|
|
2050
|
+
// because management uses vg:cancel:<id> (with trailing id) while the wizard uses
|
|
2051
|
+
// bare vg:cancel — the cancelMatch above only matches the id-suffixed form.
|
|
2052
|
+
//
|
|
2053
|
+
// Note: pre-#265 fix this function did `await ctx.answerCallbackQuery().catch(() => {})`
|
|
2054
|
+
// unconditionally up front. That meant the `vg:keys-continue` branch's
|
|
2055
|
+
// toast call (`Select at least one key.`) hit a Telegram error
|
|
2056
|
+
// ("query is too old or query ID is invalid") because the query was
|
|
2057
|
+
// already answered, and the toast never reached the user. Each branch
|
|
2058
|
+
// now owns its own ack.
|
|
2059
|
+
const chatId = String(ctx.chat?.id ?? ctx.from?.id ?? '')
|
|
2060
|
+
const ackSilently = () => ctx.answerCallbackQuery().catch(() => {})
|
|
2061
|
+
|
|
2062
|
+
// Cancel at any wizard step
|
|
2063
|
+
if (data === 'vg:cancel') {
|
|
2064
|
+
// Kernel-side dual-dispatch (MIGRATION.md §2, audit-only Phase 1):
|
|
2065
|
+
// if the user got as far as the confirm step, a kernel request_id
|
|
2066
|
+
// will be on the wizard state — record the deny decision so the
|
|
2067
|
+
// audit log captures the abandonment. No-op if the user cancelled
|
|
2068
|
+
// before the confirm step (or if the kernel was unreachable).
|
|
2069
|
+
const cancelState = pendingVaultOps.get(chatId)
|
|
2070
|
+
if (cancelState && cancelState.kind === 'grant-wizard') {
|
|
2071
|
+
await recordGrantWizardKernelDecision(
|
|
2072
|
+
cancelState.kernel_request_id,
|
|
2073
|
+
'deny',
|
|
2074
|
+
ctx.from?.id ?? 0,
|
|
2075
|
+
loadAccess().allowFrom,
|
|
2076
|
+
)
|
|
2077
|
+
}
|
|
2078
|
+
pendingVaultOps.delete(chatId)
|
|
2079
|
+
const msg = ctx.callbackQuery?.message
|
|
2080
|
+
if (msg && 'text' in msg) {
|
|
2081
|
+
await ctx.editMessageText('❌ Grant wizard cancelled.').catch(() => {})
|
|
2082
|
+
}
|
|
2083
|
+
await ackSilently()
|
|
2084
|
+
return
|
|
2085
|
+
}
|
|
2086
|
+
|
|
2087
|
+
const state = pendingVaultOps.get(chatId)
|
|
2088
|
+
if (!state || state.kind !== 'grant-wizard') {
|
|
2089
|
+
await ctx.editMessageText('⚠️ Wizard session expired. Run /vault grant to start again.').catch(() => {})
|
|
2090
|
+
await ackSilently()
|
|
2091
|
+
return
|
|
2092
|
+
}
|
|
2093
|
+
|
|
2094
|
+
// vg:agent:<name> — step 1 selection
|
|
2095
|
+
if (data.startsWith('vg:agent:')) {
|
|
2096
|
+
const agent = data.slice('vg:agent:'.length)
|
|
2097
|
+
const msgId = (ctx.callbackQuery?.message as { message_id?: number })?.message_id ?? state.wizardMsgId
|
|
2098
|
+
await grantWizardStep2(ctx, chatId, agent, msgId)
|
|
2099
|
+
await ackSilently()
|
|
2100
|
+
return
|
|
2101
|
+
}
|
|
2102
|
+
|
|
2103
|
+
// vg:key:<name> — step 2 toggle
|
|
2104
|
+
if (data.startsWith('vg:key:')) {
|
|
2105
|
+
const key = data.slice('vg:key:'.length)
|
|
2106
|
+
if (state.step !== 'keys') { await ackSilently(); return }
|
|
2107
|
+
const selectedSet = new Set(state.selectedKeys ?? [])
|
|
2108
|
+
if (selectedSet.has(key)) {
|
|
2109
|
+
selectedSet.delete(key)
|
|
2110
|
+
} else {
|
|
2111
|
+
selectedSet.add(key)
|
|
2112
|
+
}
|
|
2113
|
+
const updatedState = { ...state, selectedKeys: [...selectedSet] }
|
|
2114
|
+
pendingVaultOps.set(chatId, updatedState)
|
|
2115
|
+
const kb = buildGrantKeysKeyboard(state.availableKeys ?? [], selectedSet)
|
|
2116
|
+
await ctx.editMessageReplyMarkup({ reply_markup: kb }).catch(() => {})
|
|
2117
|
+
await ackSilently()
|
|
2118
|
+
return
|
|
2119
|
+
}
|
|
2120
|
+
|
|
2121
|
+
// vg:keys-continue — step 2 → 3
|
|
2122
|
+
if (data === 'vg:keys-continue') {
|
|
2123
|
+
if (state.step !== 'keys') { await ackSilently(); return }
|
|
2124
|
+
if (!state.selectedKeys || state.selectedKeys.length === 0) {
|
|
2125
|
+
// Toast-only ack: this is the branch the unconditional pre-ack
|
|
2126
|
+
// used to silently swallow. See #265.
|
|
2127
|
+
await ctx.answerCallbackQuery({ text: 'Select at least one key.' }).catch(() => {})
|
|
2128
|
+
return
|
|
2129
|
+
}
|
|
2130
|
+
await grantWizardStep3(ctx, chatId, state)
|
|
2131
|
+
await ackSilently()
|
|
2132
|
+
return
|
|
2133
|
+
}
|
|
2134
|
+
|
|
2135
|
+
// vg:dur:<value> — step 3 duration selection
|
|
2136
|
+
if (data.startsWith('vg:dur:')) {
|
|
2137
|
+
if (state.step !== 'duration') { await ackSilently(); return }
|
|
2138
|
+
const dur = data.slice('vg:dur:'.length)
|
|
2139
|
+
if (dur === 'custom') {
|
|
2140
|
+
// Ask for text reply with n d|h format
|
|
2141
|
+
pendingVaultOps.set(chatId, { ...state, awaitingCustomDuration: true })
|
|
2142
|
+
const msg = ctx.callbackQuery?.message
|
|
2143
|
+
if (msg && 'text' in msg && msg.text) {
|
|
2144
|
+
// Escape source text before re-rendering with HTML parse mode.
|
|
2145
|
+
// `msg.text` returns entities-stripped plain UTF-8; a raw
|
|
2146
|
+
// `<`/`>`/`&` in the wizard's prior-step body (e.g. a future
|
|
2147
|
+
// key or label) would crash the HTML re-parse and the bare
|
|
2148
|
+
// `.catch(() => {})` would swallow the failure silently — same
|
|
2149
|
+
// hazard PR #1158 caught on the operator-event card.
|
|
2150
|
+
await ctx.editMessageText(
|
|
2151
|
+
richMessage(escapeHtmlForTg(msg.text) + '\n\n_Send a duration like \`30d\` or \`12h\`:_'),
|
|
2152
|
+
{ reply_markup: buildGrantDurationKeyboard() },
|
|
2153
|
+
).catch(() => {})
|
|
2154
|
+
}
|
|
2155
|
+
await ackSilently()
|
|
2156
|
+
return
|
|
2157
|
+
}
|
|
2158
|
+
let ttlSeconds: number | null
|
|
2159
|
+
if (dur === 'never') {
|
|
2160
|
+
ttlSeconds = null
|
|
2161
|
+
} else if (dur === '1y') {
|
|
2162
|
+
ttlSeconds = 365 * 86400
|
|
2163
|
+
} else {
|
|
2164
|
+
ttlSeconds = parseGrantDuration(dur)
|
|
2165
|
+
if (ttlSeconds === null) { await ackSilently(); return }
|
|
2166
|
+
}
|
|
2167
|
+
const newState = { ...state, ttlSeconds, awaitingCustomDuration: false }
|
|
2168
|
+
await grantWizardConfirm(ctx, chatId, newState)
|
|
2169
|
+
await ackSilently()
|
|
2170
|
+
return
|
|
2171
|
+
}
|
|
2172
|
+
|
|
2173
|
+
// vg:back:duration — go back to step 2 (keys selection) from step 3
|
|
2174
|
+
if (data === 'vg:back:duration') {
|
|
2175
|
+
if (state.step !== 'duration') { await ackSilently(); return }
|
|
2176
|
+
const msgId = state.wizardMsgId
|
|
2177
|
+
await grantWizardStep2(ctx, chatId, state.agent!, msgId)
|
|
2178
|
+
await ackSilently()
|
|
2179
|
+
return
|
|
2180
|
+
}
|
|
2181
|
+
|
|
2182
|
+
// vg:generate — final step
|
|
2183
|
+
if (data === 'vg:generate') {
|
|
2184
|
+
if (state.step !== 'confirm') { await ackSilently(); return }
|
|
2185
|
+
await executeGrantWizard(ctx, chatId, state)
|
|
2186
|
+
await ackSilently()
|
|
2187
|
+
return
|
|
2188
|
+
}
|
|
2189
|
+
|
|
2190
|
+
// Unrecognised vg: sub-action
|
|
2191
|
+
await ackSilently()
|
|
2192
|
+
}
|
|
2193
|
+
|
|
2194
|
+
/**
|
|
2195
|
+
* Issue #44: write a deferred secret to the vault using the now-cached
|
|
2196
|
+
* passphrase. Confirms with a masked ref + slug; matches the "captured
|
|
2197
|
+
* N secret" UX of the cached-passphrase happy path so the user
|
|
2198
|
+
* experience is identical regardless of which path they came in on.
|
|
2199
|
+
*
|
|
2200
|
+
* Called from two places:
|
|
2201
|
+
* - The `passphrase-for-deferred` branch of the text-handler
|
|
2202
|
+
* pendingVaultOps intercept, after the passphrase is verified.
|
|
2203
|
+
* - The `vd:unlock` callback handler when a passphrase happens to
|
|
2204
|
+
* already be cached (rare but possible).
|
|
2205
|
+
*
|
|
2206
|
+
* If write fails, the deferred entry is preserved so the user can retry.
|
|
2207
|
+
*/
|
|
2208
|
+
async function executeDeferredSecretSave(
|
|
2209
|
+
ctx: Context,
|
|
2210
|
+
deferKey: string,
|
|
2211
|
+
passphrase: string,
|
|
2212
|
+
cardMessageId: number | undefined,
|
|
2213
|
+
): Promise<void> {
|
|
2214
|
+
const deferred = deferredSecrets.get(deferKey)
|
|
2215
|
+
if (!deferred) {
|
|
2216
|
+
if (cardMessageId != null) {
|
|
2217
|
+
await ctx.api
|
|
2218
|
+
.editMessageText(
|
|
2219
|
+
deferKey.split(':')[0]!,
|
|
2220
|
+
cardMessageId,
|
|
2221
|
+
'⚠️ This card expired before unlock — please re-send the secret.',
|
|
2222
|
+
{ reply_markup: { inline_keyboard: [] } },
|
|
2223
|
+
)
|
|
2224
|
+
.catch(() => {})
|
|
2225
|
+
}
|
|
2226
|
+
return
|
|
2227
|
+
}
|
|
2228
|
+
|
|
2229
|
+
// De-duplicate suggested_slug against existing vault keys by appending
|
|
2230
|
+
// _2 / _3 / … if needed. Same logic as the cached-passphrase happy
|
|
2231
|
+
// path uses (gateway.ts ~L2402 stash command).
|
|
2232
|
+
const slugBase = deferred.suggested_slug || 'secret'
|
|
2233
|
+
const listed = defaultVaultList(passphrase)
|
|
2234
|
+
const existing = new Set(listed.ok ? listed.keys : [])
|
|
2235
|
+
let slug = slugBase
|
|
2236
|
+
let n = 2
|
|
2237
|
+
while (existing.has(slug)) slug = `${slugBase}_${n++}`
|
|
2238
|
+
|
|
2239
|
+
const write = defaultVaultWrite(slug, deferred.text, passphrase)
|
|
2240
|
+
if (!write.ok) {
|
|
2241
|
+
// Classify the failure via the structured stderr markers emitted by
|
|
2242
|
+
// `switchroom vault` (issue #969 P0a). If it's a recognised marker,
|
|
2243
|
+
// render a clean actionable message instead of dumping the raw
|
|
2244
|
+
// "Vault file not found …" / "VAULT-NEEDS-APPROVAL …" blob the CLI
|
|
2245
|
+
// emits — that was the misleading-error half of #968.
|
|
2246
|
+
//
|
|
2247
|
+
// Keep the deferred entry so the user can retry by tapping again
|
|
2248
|
+
// once the underlying condition is fixed (broker started, host
|
|
2249
|
+
// approval granted, etc.).
|
|
2250
|
+
const parsed = parseVaultCliError(write.output)
|
|
2251
|
+
const rendered = renderVaultCliError(parsed, { verb: "save", key: slug })
|
|
2252
|
+
const body = rendered.suppressRaw
|
|
2253
|
+
? rendered.html
|
|
2254
|
+
: `⚠️ vault write failed:\n\`\`\`\n${write.output}\n\`\`\``
|
|
2255
|
+
if (cardMessageId != null) {
|
|
2256
|
+
await ctx.api
|
|
2257
|
+
.editMessageText(
|
|
2258
|
+
deferred.chat_id,
|
|
2259
|
+
cardMessageId,
|
|
2260
|
+
richMessage(`${body}\n\nRe-tap to retry.`),
|
|
2261
|
+
{
|
|
2262
|
+
reply_markup: buildDeferredSecretKeyboard(deferKey).inline_keyboard.length > 0
|
|
2263
|
+
? buildDeferredSecretKeyboard(deferKey)
|
|
2264
|
+
: undefined,
|
|
2265
|
+
},
|
|
2266
|
+
)
|
|
2267
|
+
.catch(() => {})
|
|
2268
|
+
}
|
|
2269
|
+
return
|
|
2270
|
+
}
|
|
2271
|
+
|
|
2272
|
+
deferredSecrets.delete(deferKey)
|
|
2273
|
+
const masked = maskToken(deferred.text)
|
|
2274
|
+
if (cardMessageId != null) {
|
|
2275
|
+
await ctx.api
|
|
2276
|
+
.editMessageText(
|
|
2277
|
+
deferred.chat_id,
|
|
2278
|
+
cardMessageId,
|
|
2279
|
+
richMessage(`✅ stored as \`vault:${slug}\` (masked: \`${masked}\`)\n\nReply \`rename NEW_NAME\` to relabel.`),
|
|
2280
|
+
{ reply_markup: { inline_keyboard: [] } },
|
|
2281
|
+
)
|
|
2282
|
+
.catch(() => {})
|
|
2283
|
+
}
|
|
2284
|
+
// Stage for follow-up rename, mirroring the cached-passphrase path.
|
|
2285
|
+
secretStaging.set({
|
|
2286
|
+
chat_id: deferred.chat_id,
|
|
2287
|
+
message_id: deferred.original_message_id,
|
|
2288
|
+
detection: {
|
|
2289
|
+
rule_id: 'deferred',
|
|
2290
|
+
matched_text: deferred.text,
|
|
2291
|
+
start: 0,
|
|
2292
|
+
end: deferred.text.length,
|
|
2293
|
+
confidence: 'high' as const,
|
|
2294
|
+
suppressed: false,
|
|
2295
|
+
suggested_slug: slug,
|
|
2296
|
+
},
|
|
2297
|
+
staged_at: Date.now(),
|
|
2298
|
+
})
|
|
2299
|
+
}
|
|
2300
|
+
|
|
2301
|
+
async function handleOperatorEventCallback(ctx: Context, data: string): Promise<void> {
|
|
2302
|
+
const senderId = String(ctx.from?.id ?? '')
|
|
2303
|
+
const access = loadAccess()
|
|
2304
|
+
if (!access.allowFrom.includes(senderId)) {
|
|
2305
|
+
await ctx.answerCallbackQuery({ text: 'Not authorized.' }).catch(() => {})
|
|
2306
|
+
return
|
|
2307
|
+
}
|
|
2308
|
+
|
|
2309
|
+
// Parse op:<action>:<encoded-agent>
|
|
2310
|
+
const parts = data.slice(3).split(':', 2) // drop "op:", then split action:agent
|
|
2311
|
+
if (parts.length !== 2) {
|
|
2312
|
+
await ctx.answerCallbackQuery({ text: 'Malformed operator-event callback.' }).catch(() => {})
|
|
2313
|
+
return
|
|
2314
|
+
}
|
|
2315
|
+
const [action, encodedAgent] = parts
|
|
2316
|
+
let agent: string
|
|
2317
|
+
try {
|
|
2318
|
+
agent = decodeURIComponent(encodedAgent)
|
|
2319
|
+
} catch {
|
|
2320
|
+
await ctx.answerCallbackQuery({ text: 'Bad agent name encoding.' }).catch(() => {})
|
|
2321
|
+
return
|
|
2322
|
+
}
|
|
2323
|
+
if (!/^[a-z0-9][a-z0-9_-]{0,50}$/.test(agent)) {
|
|
2324
|
+
await ctx.answerCallbackQuery({ text: 'Invalid agent name.' }).catch(() => {})
|
|
2325
|
+
return
|
|
2326
|
+
}
|
|
2327
|
+
|
|
2328
|
+
// #1150 audit P1: extract the source card text once so every branch
|
|
2329
|
+
// below can append a status line via finalizeCallback. Pre-fix `dismiss`
|
|
2330
|
+
// and `restart` stripped the keyboard but kept the original card body
|
|
2331
|
+
// verbatim — operator scrolling back couldn't see what they'd decided.
|
|
2332
|
+
// `reauth` didn't strip the keyboard at all → re-tappable mid-flow.
|
|
2333
|
+
//
|
|
2334
|
+
// HTML-escape the extracted text before concatenation. Telegram returns
|
|
2335
|
+
// `msg.text` as plain UTF-8 with entities stripped — any raw `<`, `>`,
|
|
2336
|
+
// or `&` characters in the original `detail` (operator-events.ts
|
|
2337
|
+
// `unknown-4xx`/`unknown-5xx` cards routinely carry API error bodies
|
|
2338
|
+
// with `<`/`>` in them) would be re-parsed as HTML tags when the
|
|
2339
|
+
// finalizeCallback edit fires with `parseMode: 'HTML'`. Telegram
|
|
2340
|
+
// rejects the edit, finalizeCallback's catch swallows it, the
|
|
2341
|
+
// keyboard never strips, and the operator re-taps → exact bug this
|
|
2342
|
+
// PR is meant to fix re-introduced. Escape once here so every branch
|
|
2343
|
+
// gets a safe-to-reparse value. We lose the original bold/italic
|
|
2344
|
+
// styling on the source body — acceptable, that styling was already
|
|
2345
|
+
// gone the moment `msg.text` was read instead of `msg.entities`.
|
|
2346
|
+
// (PR #1158 round 2 — review item F.)
|
|
2347
|
+
const sourceMsgText = (() => {
|
|
2348
|
+
const msg = ctx.callbackQuery?.message
|
|
2349
|
+
if (!msg || !('text' in msg) || !msg.text) return ''
|
|
2350
|
+
return escapeHtmlForTg(msg.text)
|
|
2351
|
+
})()
|
|
2352
|
+
|
|
2353
|
+
switch (action) {
|
|
2354
|
+
case 'dismiss': {
|
|
2355
|
+
// #1150 audit P1: was strip-only. Now appends a status line so
|
|
2356
|
+
// scrollback shows the dismissal.
|
|
2357
|
+
const status = `\n\n✗ _Dismissed by operator._`
|
|
2358
|
+
await finalizeCallback(ctx, {
|
|
2359
|
+
ackText: 'Dismissed',
|
|
2360
|
+
newText: sourceMsgText ? `${sourceMsgText}${status}` : status,
|
|
2361
|
+
// No synthInbound — dismiss is operator-only, no model in loop.
|
|
2362
|
+
})
|
|
2363
|
+
return
|
|
2364
|
+
}
|
|
2365
|
+
case 'restart': {
|
|
2366
|
+
const ok = triggerSelfRestart(agent, 'inline-button-restart')
|
|
2367
|
+
if (ok) {
|
|
2368
|
+
// #1150 audit P1: was reply + editMessageReplyMarkup (two
|
|
2369
|
+
// separate edits). Atomic via finalizeCallback now — the
|
|
2370
|
+
// status line is the announcement, no separate reply needed.
|
|
2371
|
+
const status = `\n\n🔄 _**${escapeHtmlForTg(agent)}** restart requested by operator._`
|
|
2372
|
+
await finalizeCallback(ctx, {
|
|
2373
|
+
ackText: `Restarting ${agent}…`,
|
|
2374
|
+
newText: sourceMsgText ? `${sourceMsgText}${status}` : status,
|
|
2375
|
+
})
|
|
2376
|
+
} else {
|
|
2377
|
+
// Failure-path: leave the keyboard tappable so the operator
|
|
2378
|
+
// can retry once they've followed the manual instructions
|
|
2379
|
+
// below. ack toast still fires.
|
|
2380
|
+
await ctx.answerCallbackQuery({ text: `Restart failed for ${agent}` }).catch(() => {})
|
|
2381
|
+
const isDocker = process.env.SWITCHROOM_RUNTIME === 'docker'
|
|
2382
|
+
const detail = isDocker
|
|
2383
|
+
? `cross-agent restart is not supported under docker. ` +
|
|
2384
|
+
`Restart from the host: \`docker compose -p switchroom restart agent-${agent}\`.`
|
|
2385
|
+
: 'restart trigger failed'
|
|
2386
|
+
await ctx.replyWithRichMessage(richMessage(`**Restart failed for ${agent}:** ${detail}`))
|
|
2387
|
+
}
|
|
2388
|
+
return
|
|
2389
|
+
}
|
|
2390
|
+
case 'reauth': {
|
|
2391
|
+
// #1150 audit P1: pre-fix the operator-event card's [Reauth] button
|
|
2392
|
+
// stayed tappable after the reauth flow started → operator could
|
|
2393
|
+
// re-tap and spawn a second concurrent flow that fights the first
|
|
2394
|
+
// for the login URL state. Strip the keyboard and append a status
|
|
2395
|
+
// line; the new reauth-flow's own messages appear below the
|
|
2396
|
+
// collapsed card.
|
|
2397
|
+
const status = `\n\n🔐 _Reauth started for **${escapeHtmlForTg(agent)}** — follow the login URL below._`
|
|
2398
|
+
await finalizeCallback(ctx, {
|
|
2399
|
+
ackText: `Starting reauth for ${agent}…`,
|
|
2400
|
+
newText: sourceMsgText ? `${sourceMsgText}${status}` : status,
|
|
2401
|
+
synthInbound: async () => {
|
|
2402
|
+
await runSwitchroomAuthCommand(ctx, ['auth', 'reauth', agent], `auth reauth ${agent}`)
|
|
2403
|
+
// PR3 supergroup-mode: key by (chat, thread) so an OAuth code
|
|
2404
|
+
// pasted into a different topic isn't mistakenly intercepted
|
|
2405
|
+
// as this flow's reauth code.
|
|
2406
|
+
const reauthThreadId = ctx.callbackQuery?.message?.message_thread_id
|
|
2407
|
+
pendingReauthFlows.set(
|
|
2408
|
+
chatKey(String(ctx.chat!.id), reauthThreadId ?? null) as string,
|
|
2409
|
+
{ agent, startedAt: Date.now() },
|
|
2410
|
+
)
|
|
2411
|
+
},
|
|
2412
|
+
})
|
|
2413
|
+
return
|
|
2414
|
+
}
|
|
2415
|
+
case 'logs': {
|
|
2416
|
+
await ctx.answerCallbackQuery({ text: 'Fetching logs…' }).catch(() => {})
|
|
2417
|
+
// Pick the right log source for the runtime. Under docker, the
|
|
2418
|
+
// gateway is INSIDE the agent container — calling `docker logs`
|
|
2419
|
+
// requires the host's docker socket which is deliberately not
|
|
2420
|
+
// mounted into agent containers. Under systemd, journalctl
|
|
2421
|
+
// works as before. v0.7.2 fixed `case 'restart'` but left this
|
|
2422
|
+
// path systemd-only.
|
|
2423
|
+
const isDocker = process.env.SWITCHROOM_RUNTIME === 'docker'
|
|
2424
|
+
if (isDocker) {
|
|
2425
|
+
await ctx.replyWithRichMessage(richMessage(
|
|
2426
|
+
`_Inline log fetch is not available under docker mode (no docker.sock in agent containers). ` +
|
|
2427
|
+
`Run from the host: \`docker logs --since 30m --tail 30 switchroom-${agent}\`_`,
|
|
2428
|
+
))
|
|
2429
|
+
return
|
|
2430
|
+
}
|
|
2431
|
+
try {
|
|
2432
|
+
const out = execFileSync(
|
|
2433
|
+
'journalctl',
|
|
2434
|
+
['--user', '-u', `switchroom-${agent}`, '-n', '30', '--no-pager', '--output=short-monotonic'],
|
|
2435
|
+
{ encoding: 'utf-8', timeout: 10000, stdio: ['ignore', 'pipe', 'pipe'] },
|
|
2436
|
+
) as string
|
|
2437
|
+
const trimmed = out.trim().slice(-3500)
|
|
2438
|
+
await ctx.replyWithRichMessage(richMessage(
|
|
2439
|
+
trimmed
|
|
2440
|
+
? `\`\`\`\n${trimmed.replace(/```/g, '```')}\n\`\`\``
|
|
2441
|
+
: `_No logs for ${agent}._`,
|
|
2442
|
+
))
|
|
2443
|
+
} catch (err) {
|
|
2444
|
+
await ctx.replyWithRichMessage(richMessage(
|
|
2445
|
+
`**logs failed:** ${escapeHtmlForTg((err as Error).message)}`,
|
|
2446
|
+
))
|
|
2447
|
+
}
|
|
2448
|
+
return
|
|
2449
|
+
}
|
|
2450
|
+
default: {
|
|
2451
|
+
await ctx.answerCallbackQuery({ text: `Unknown action: ${action}` }).catch(() => {})
|
|
2452
|
+
return
|
|
2453
|
+
}
|
|
2454
|
+
}
|
|
2455
|
+
}
|
|
2456
|
+
|
|
2457
|
+
// RFC H §7.3: the dashboard callback dispatcher is gone — there are
|
|
2458
|
+
// no auth: callback buttons in the new chat surface. We keep a no-op
|
|
2459
|
+
// stub so any stale pinned message that fires an `auth:*` tap is
|
|
2460
|
+
// silently dismissed instead of crashing the gateway.
|
|
2461
|
+
async function handleAuthDashboardCallback(ctx: Context): Promise<void> {
|
|
2462
|
+
const data = ctx.callbackQuery?.data ?? ''
|
|
2463
|
+
const currentAgent = getMyAgentName()
|
|
2464
|
+
|
|
2465
|
+
// auth:use:<label> — fleet-wide swap via broker.setActive (same path
|
|
2466
|
+
// /auth use takes from chat). Admin-gated via the broker's own
|
|
2467
|
+
// per-agent admin flag.
|
|
2468
|
+
if (data.startsWith('auth:use:')) {
|
|
2469
|
+
const label = data.slice('auth:use:'.length)
|
|
2470
|
+
if (!label) {
|
|
2471
|
+
try { await ctx.answerCallbackQuery({ text: 'Missing account label.', show_alert: false }) } catch { /* */ }
|
|
2472
|
+
return
|
|
2473
|
+
}
|
|
2474
|
+
try {
|
|
2475
|
+
const client = await getAuthBrokerClient(currentAgent)
|
|
2476
|
+
if (!client) {
|
|
2477
|
+
try { await ctx.answerCallbackQuery({ text: 'Broker unreachable.', show_alert: true }) } catch { /* */ }
|
|
2478
|
+
return
|
|
2479
|
+
}
|
|
2480
|
+
const result = await client.setActive(label)
|
|
2481
|
+
try {
|
|
2482
|
+
await ctx.answerCallbackQuery({
|
|
2483
|
+
text: `Switched fleet → ${result.active} (${result.fanned.length} agents)`,
|
|
2484
|
+
show_alert: false,
|
|
2485
|
+
})
|
|
2486
|
+
} catch { /* toast may fail on stale tap */ }
|
|
2487
|
+
// Edit the source message to reflect the new active. Leaving
|
|
2488
|
+
// the old keyboard intact would tempt a double-tap; we replace
|
|
2489
|
+
// the text + drop the keyboard so the user has to /auth again
|
|
2490
|
+
// to see fresh state.
|
|
2491
|
+
const msg = ctx.callbackQuery?.message
|
|
2492
|
+
if (msg) {
|
|
2493
|
+
// Wrap in swallowingApiCall per #1075 — stale callback-source
|
|
2494
|
+
// messages (deleted topic, expired) shouldn't crash the swap.
|
|
2495
|
+
await swallowingApiCall(
|
|
2496
|
+
() =>
|
|
2497
|
+
bot.api.editMessageText(
|
|
2498
|
+
msg.chat.id,
|
|
2499
|
+
msg.message_id,
|
|
2500
|
+
richMessage(
|
|
2501
|
+
`**Active account →** \`${result.active}\`\n` +
|
|
2502
|
+
`_Re-mirrored credentials for ${result.fanned.length} agent${result.fanned.length === 1 ? '' : 's'}._\n\n` +
|
|
2503
|
+
`_Tap /auth to see updated quota for the new active account._`,
|
|
2504
|
+
),
|
|
2505
|
+
{},
|
|
2506
|
+
),
|
|
2507
|
+
{ chat_id: String(msg.chat.id), verb: 'auth:use:edit' },
|
|
2508
|
+
)
|
|
2509
|
+
}
|
|
2510
|
+
} catch (err) {
|
|
2511
|
+
const msg = (err as Error)?.message ?? String(err)
|
|
2512
|
+
try {
|
|
2513
|
+
await ctx.answerCallbackQuery({
|
|
2514
|
+
text: `Switch failed: ${msg.slice(0, 180)}`,
|
|
2515
|
+
show_alert: true,
|
|
2516
|
+
})
|
|
2517
|
+
} catch { /* */ }
|
|
2518
|
+
}
|
|
2519
|
+
return
|
|
2520
|
+
}
|
|
2521
|
+
|
|
2522
|
+
// auth:refresh — re-render the /auth snapshot in-place with a fresh
|
|
2523
|
+
// live probe. Replaces the message body; keyboard stays. The `:demo`
|
|
2524
|
+
// variant re-renders with email masking intact (a ↻ tap on an
|
|
2525
|
+
// `/auth demo` / `/usage demo` card must not unmask mid-recording).
|
|
2526
|
+
if (data === 'auth:refresh' || data === 'auth:refresh:demo') {
|
|
2527
|
+
const refreshDemo = data === 'auth:refresh:demo'
|
|
2528
|
+
// Freshness throttle: each refresh fan-fires N live api.anthropic.com
|
|
2529
|
+
// probes (one per account — forceLive bypasses the broker's 45s
|
|
2530
|
+
// probe-on-open TTL, because an explicit ↻ tap is the user asking
|
|
2531
|
+
// for live-now data). Without this, a user double-tapping the ↻
|
|
2532
|
+
// button burns through their account's RPM budget on duplicate
|
|
2533
|
+
// work. Cap at one per AUTH_REFRESH_THROTTLE_MS per (chat, message)
|
|
2534
|
+
// pair.
|
|
2535
|
+
const refreshMsg = ctx.callbackQuery?.message
|
|
2536
|
+
if (refreshMsg) {
|
|
2537
|
+
const key = `${refreshMsg.chat.id}:${refreshMsg.message_id}`
|
|
2538
|
+
const lastAtMs = lastAuthRefreshAtMs.get(key) ?? 0
|
|
2539
|
+
const sinceLastMs = Date.now() - lastAtMs
|
|
2540
|
+
if (sinceLastMs < AUTH_REFRESH_THROTTLE_MS) {
|
|
2541
|
+
const waitS = Math.ceil((AUTH_REFRESH_THROTTLE_MS - sinceLastMs) / 1000)
|
|
2542
|
+
try {
|
|
2543
|
+
await ctx.answerCallbackQuery({
|
|
2544
|
+
text: `Just refreshed — try again in ${waitS}s`,
|
|
2545
|
+
show_alert: false,
|
|
2546
|
+
})
|
|
2547
|
+
} catch { /* */ }
|
|
2548
|
+
return
|
|
2549
|
+
}
|
|
2550
|
+
lastAuthRefreshAtMs.set(key, Date.now())
|
|
2551
|
+
}
|
|
2552
|
+
try {
|
|
2553
|
+
const client = await getAuthBrokerClient(currentAgent)
|
|
2554
|
+
if (!client) {
|
|
2555
|
+
try { await ctx.answerCallbackQuery({ text: 'Broker unreachable.', show_alert: true }) } catch { /* */ }
|
|
2556
|
+
return
|
|
2557
|
+
}
|
|
2558
|
+
const state = await client.listState()
|
|
2559
|
+
// Broker-routed probe (#1336) — see gateway.ts:8910 for diagnosis.
|
|
2560
|
+
// forceLive=true: an explicit ↻ tap must bypass the broker's
|
|
2561
|
+
// probe-on-open TTL — pre-fix, a tap inside the TTL window served
|
|
2562
|
+
// the cached snapshot while stamping "Live · refreshed 0s ago".
|
|
2563
|
+
const probeResp = state.accounts.length > 0
|
|
2564
|
+
? await client.probeQuota(state.accounts.map((a) => a.label), undefined, true).catch(() => ({ results: [] }))
|
|
2565
|
+
: { results: [] }
|
|
2566
|
+
// #2495 Change 2 — even under forceLive a failed upstream probe falls
|
|
2567
|
+
// back to the broker cache (served:"cache"); stamp "⚠ cached Nm ago"
|
|
2568
|
+
// instead of a false live stamp, same as the /auth and /usage paths.
|
|
2569
|
+
const { quotas, staleCachedAtMs } = zipProbeResults(
|
|
2570
|
+
state.accounts.map((a) => a.label),
|
|
2571
|
+
probeResp.results,
|
|
2572
|
+
)
|
|
2573
|
+
const tz = process.env.SWITCHROOM_TIMEZONE ?? process.env.TZ ?? 'UTC'
|
|
2574
|
+
const { renderAuthSnapshotFormat2, buildSnapshotsFromState, buildSnapshotKeyboard } = await import(
|
|
2575
|
+
'../auth-snapshot-format.js'
|
|
2576
|
+
)
|
|
2577
|
+
const snapshots = buildSnapshotsFromState(state, quotas)
|
|
2578
|
+
// Single clock for card body + keyboard so health classification
|
|
2579
|
+
// can't disagree between the two (#2495 folded nit A).
|
|
2580
|
+
const renderNow = new Date()
|
|
2581
|
+
const text = renderAuthSnapshotFormat2(snapshots, {
|
|
2582
|
+
tz,
|
|
2583
|
+
now: renderNow,
|
|
2584
|
+
demo: refreshDemo,
|
|
2585
|
+
// Honesty backstop (same as /usage): a TOTAL probe failure (zero
|
|
2586
|
+
// result rows, nothing served from cache) renders an explicit
|
|
2587
|
+
// "probe failed" marker instead of a false "Live" footer next to
|
|
2588
|
+
// no-data rows.
|
|
2589
|
+
...(staleCachedAtMs != null
|
|
2590
|
+
? { staleCachedAtMs }
|
|
2591
|
+
: probeResp.results.length > 0
|
|
2592
|
+
? { liveProbedAtMs: renderNow.getTime() }
|
|
2593
|
+
: { probeFailed: true }),
|
|
2594
|
+
})
|
|
2595
|
+
const kbRows = buildSnapshotKeyboard(snapshots, { now: renderNow, demo: refreshDemo })
|
|
2596
|
+
const inline_keyboard = kbRows.map((row) =>
|
|
2597
|
+
row.map((b) => {
|
|
2598
|
+
if (b.callbackData) return { text: b.text, callback_data: b.callbackData }
|
|
2599
|
+
if (b.insertText) return { text: b.text, switch_inline_query_current_chat: b.insertText }
|
|
2600
|
+
return { text: b.text, callback_data: 'auth:noop' }
|
|
2601
|
+
}),
|
|
2602
|
+
)
|
|
2603
|
+
const msg = ctx.callbackQuery?.message
|
|
2604
|
+
if (msg) {
|
|
2605
|
+
await swallowingApiCall(
|
|
2606
|
+
() =>
|
|
2607
|
+
bot.api.editMessageText(msg.chat.id, msg.message_id, richMessage(text), {
|
|
2608
|
+
reply_markup: { inline_keyboard },
|
|
2609
|
+
}),
|
|
2610
|
+
{ chat_id: String(msg.chat.id), verb: 'auth:refresh:edit' },
|
|
2611
|
+
)
|
|
2612
|
+
}
|
|
2613
|
+
try { await ctx.answerCallbackQuery({ text: 'Refreshed.', show_alert: false }) } catch { /* */ }
|
|
2614
|
+
} catch (err) {
|
|
2615
|
+
const msg = (err as Error)?.message ?? String(err)
|
|
2616
|
+
try {
|
|
2617
|
+
await ctx.answerCallbackQuery({
|
|
2618
|
+
text: `Refresh failed: ${msg.slice(0, 180)}`,
|
|
2619
|
+
show_alert: true,
|
|
2620
|
+
})
|
|
2621
|
+
} catch { /* */ }
|
|
2622
|
+
}
|
|
2623
|
+
return
|
|
2624
|
+
}
|
|
2625
|
+
|
|
2626
|
+
// Unknown auth:* — likely from a too-old message. Dismiss with a
|
|
2627
|
+
// hint pointing at the canonical re-render verb.
|
|
2628
|
+
try {
|
|
2629
|
+
await ctx.answerCallbackQuery({
|
|
2630
|
+
text: 'Unknown auth button. Send /auth for current state.',
|
|
2631
|
+
show_alert: false,
|
|
2632
|
+
})
|
|
2633
|
+
} catch { /* */ }
|
|
2634
|
+
}
|
|
2635
|
+
|
|
2636
|
+
return {
|
|
2637
|
+
handleVaultRecentDenialCallback,
|
|
2638
|
+
performVaultAccessApproval,
|
|
2639
|
+
handleSkillProposalCallback,
|
|
2640
|
+
handleMentalModelProposeCallback,
|
|
2641
|
+
handleVaultRequestAccessCallback,
|
|
2642
|
+
handleVaultRequestSaveCallback,
|
|
2643
|
+
handleVaultDeferCallback,
|
|
2644
|
+
parseGrantDuration,
|
|
2645
|
+
formatGrantExpiry,
|
|
2646
|
+
buildGrantAgentKeyboard,
|
|
2647
|
+
buildGrantKeysKeyboard,
|
|
2648
|
+
buildGrantDurationKeyboard,
|
|
2649
|
+
buildGrantConfirmKeyboard,
|
|
2650
|
+
startGrantWizardStep1,
|
|
2651
|
+
grantWizardStep2,
|
|
2652
|
+
grantWizardStep3,
|
|
2653
|
+
grantWizardConfirm,
|
|
2654
|
+
executeGrantWizard,
|
|
2655
|
+
handleVaultGrantCallback,
|
|
2656
|
+
executeDeferredSecretSave,
|
|
2657
|
+
handleOperatorEventCallback,
|
|
2658
|
+
handleAuthDashboardCallback,
|
|
2659
|
+
}
|
|
2660
|
+
}
|