switchroom 0.18.8 → 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 +2 -2
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +78648 -77445
- package/telegram-plugin/gateway/approval-card-stores.ts +99 -0
- package/telegram-plugin/gateway/bot-commands-ops-info.ts +194 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +2660 -0
- package/telegram-plugin/gateway/gateway.ts +527 -2880
- 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/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/tests/approval-card-stores.test.ts +124 -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/gateway-outbound-redact.test.ts +5 -1
- 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-propose-callback-gate.test.ts +8 -1
- 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/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
|
@@ -135,7 +135,17 @@ import {
|
|
|
135
135
|
buildSecretRequestTimeoutInbound,
|
|
136
136
|
buildMentalModelProposeTimeoutInbound,
|
|
137
137
|
} from './approval-timeout-inbound-builders.js'
|
|
138
|
-
import { expirePendingCard
|
|
138
|
+
import { expirePendingCard } from './pending-card-expiry.js'
|
|
139
|
+
import { createSweepableCardStore } from './approval-card-stores.js'
|
|
140
|
+
import {
|
|
141
|
+
createCallbackQueryHandlers,
|
|
142
|
+
type PendingVaultOp,
|
|
143
|
+
type DeferredSecret,
|
|
144
|
+
type PendingVaultRequestSave,
|
|
145
|
+
type PendingVaultRequestAccess,
|
|
146
|
+
type PendingMentalModelPropose,
|
|
147
|
+
} from './callback-query-handlers.js'
|
|
148
|
+
import { createSweepableStore, createPlainStore } from './pending-state-stores.js'
|
|
139
149
|
import {
|
|
140
150
|
isPermissionRearmEnabled,
|
|
141
151
|
permissionRearmGraceMs,
|
|
@@ -173,8 +183,6 @@ import {
|
|
|
173
183
|
createRetryApiCall,
|
|
174
184
|
createSwallowingRetryApiCall,
|
|
175
185
|
retryWithThreadFallback,
|
|
176
|
-
isHtmlParseRejectError,
|
|
177
|
-
isMessageTooLongError,
|
|
178
186
|
} from '../retry-api-call.js'
|
|
179
187
|
import { installTgPostLogger, withTgPostTags } from '../shared/bot-runtime.js'
|
|
180
188
|
import { floodStatePath, makeFloodWaitRecorder } from '../flood-circuit-breaker.js'
|
|
@@ -280,9 +288,16 @@ const REPLY_TO_TEXT_MAX = 200
|
|
|
280
288
|
// #1161 silent-end fallback text now lives in ../silent-end.ts
|
|
281
289
|
// (`silentEndFallbackText`, imported above) so the transport-boundary
|
|
282
290
|
// tests exercise the real string — see PR #2892.
|
|
283
|
-
import { splitMarkdownChunks,
|
|
291
|
+
import { splitMarkdownChunks, repairEscapedWhitespace, normalizeParagraphBreaks, addParagraphSpacers, normalizePunctuation, stripExcessBold, escapeMarkdown, hardenCardBreaks, RICH_MESSAGE_MAX_CHARS } from '../format.js'
|
|
284
292
|
import { richMessage } from '../rich-send.js'
|
|
285
293
|
import { scrubVoice } from '../text-voice-scrub.js'
|
|
294
|
+
import {
|
|
295
|
+
normalizeOutboundBody,
|
|
296
|
+
computeEffectiveText,
|
|
297
|
+
computeReplyChunks,
|
|
298
|
+
sendReplyChunks,
|
|
299
|
+
type ReplyChunkSendDeps,
|
|
300
|
+
} from './outbound-send-path.js'
|
|
286
301
|
import {
|
|
287
302
|
validateInlineKeyboard,
|
|
288
303
|
type AnyButton,
|
|
@@ -301,11 +316,8 @@ import {
|
|
|
301
316
|
statusPairedText as buildStatusPairedText,
|
|
302
317
|
statusPendingText as buildStatusPendingText,
|
|
303
318
|
statusUnpairedText as buildStatusUnpairedText,
|
|
304
|
-
switchroomHelpText as buildSwitchroomHelpText,
|
|
305
319
|
restartAckText as buildRestartAckText,
|
|
306
320
|
newSessionAckText as buildNewSessionAckText,
|
|
307
|
-
TELEGRAM_BASE_COMMANDS,
|
|
308
|
-
TELEGRAM_SWITCHROOM_COMMANDS,
|
|
309
321
|
type AgentMetadata, type AuthSummary, type StatusProbeRow,
|
|
310
322
|
} from '../welcome-text.js'
|
|
311
323
|
import {
|
|
@@ -405,6 +417,8 @@ import {
|
|
|
405
417
|
type EffortCommandDeps,
|
|
406
418
|
type EffortMenuReply,
|
|
407
419
|
} from './effort-command.js'
|
|
420
|
+
import { registerSwitchroomBotCommands } from './register-bot-commands.js'
|
|
421
|
+
import { registerOpsInfoCommands } from './bot-commands-ops-info.js'
|
|
408
422
|
import { applyEffort } from '../../src/agents/effort-picker.js'
|
|
409
423
|
import { type BannerState } from '../slot-banner.js'
|
|
410
424
|
import { refreshBanner } from '../slot-banner-driver.js'
|
|
@@ -514,29 +528,13 @@ import type { ChatKey as _ChatKey } from './inbound-delivery-machine.js'
|
|
|
514
528
|
import { dispatchEffects, isDispatchEnabled } from './inbound-delivery-machine-dispatch.js'
|
|
515
529
|
import { probeGateParity } from './gate-parity-probe.js'
|
|
516
530
|
import { maybeFireWarmup } from './prefix-warmup.js'
|
|
517
|
-
import {
|
|
518
|
-
buildVaultGrantApprovedInbound,
|
|
519
|
-
buildVaultGrantApprovedCardText,
|
|
520
|
-
buildVaultGrantDeniedInbound,
|
|
521
|
-
buildVaultSaveCompletedInbound,
|
|
522
|
-
buildVaultSaveFailedInbound,
|
|
523
|
-
buildVaultSaveDiscardedInbound,
|
|
524
|
-
} from './vault-grant-inbound-builders.js'
|
|
525
531
|
import { renderMentalModelProposeCard } from './mental-model-propose-card.js'
|
|
526
|
-
import {
|
|
527
|
-
resolveMentalModelProposal,
|
|
528
|
-
type MentalModelPendingProposal,
|
|
529
|
-
} from './mental-model-propose-resolve.js'
|
|
530
532
|
import { readDeclaredMentalModelNames } from './mental-model-propose-diff.js'
|
|
531
533
|
import {
|
|
532
|
-
parseSkillProposalCallback,
|
|
533
|
-
buildSkillProposalApplyInbound,
|
|
534
534
|
renderSkillProposalCard,
|
|
535
535
|
skillProposalKeyboard,
|
|
536
536
|
} from './skill-proposal-card.js'
|
|
537
537
|
import {
|
|
538
|
-
getProposal as getSkillProposal,
|
|
539
|
-
setProposalStatus as setSkillProposalStatus,
|
|
540
538
|
enqueueProposal as enqueueSkillProposal,
|
|
541
539
|
isSuppressed as isSkillProposalSuppressed,
|
|
542
540
|
} from '../../src/self-improve/skill-proposals.js'
|
|
@@ -661,7 +659,7 @@ import {
|
|
|
661
659
|
isLiveCorroboration,
|
|
662
660
|
} from '../quota-watch.js'
|
|
663
661
|
import { buildSnapshotsFromState, buildSnapshotsFromCachedState, zipProbeResults } from '../auth-snapshot-format.js'
|
|
664
|
-
import { maskUsername
|
|
662
|
+
import { maskUsername } from '../demo-mask.js'
|
|
665
663
|
import {
|
|
666
664
|
writeTurnActiveMarker,
|
|
667
665
|
touchTurnActiveMarker,
|
|
@@ -683,10 +681,8 @@ import {
|
|
|
683
681
|
statusViaBroker,
|
|
684
682
|
lockViaBroker,
|
|
685
683
|
unlockViaBroker,
|
|
686
|
-
mintGrantViaBroker,
|
|
687
684
|
listViaBroker,
|
|
688
685
|
listGrantsViaBroker,
|
|
689
|
-
revokeGrantViaBroker,
|
|
690
686
|
} from '../../src/vault/broker/client.js'
|
|
691
687
|
import { emitLinearAgentActivity, createLinearIssue, buildLinearAuthDeadMessage, brokerRefreshIO, type LinearAuthDeadReason } from './linear-activity.js'
|
|
692
688
|
import { runLinearAgentSetup } from './linear-setup.js'
|
|
@@ -698,7 +694,6 @@ import {
|
|
|
698
694
|
approvalRecord,
|
|
699
695
|
} from '../../src/vault/approvals/client.js'
|
|
700
696
|
import { resolveVaultApprovalPosture } from '../vault-approval-posture.js'
|
|
701
|
-
import { matchesAdminOnlyKey } from '../../src/vault/admin-only-keys.js'
|
|
702
697
|
import {
|
|
703
698
|
openTurnsDb,
|
|
704
699
|
markOrphanedWithTimeoutClassification,
|
|
@@ -4939,24 +4934,8 @@ function probeAvailableReactions(chatId: string): void {
|
|
|
4939
4934
|
// ─── Text chunking ────────────────────────────────────────────────────────
|
|
4940
4935
|
const PHOTO_EXTS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp'])
|
|
4941
4936
|
|
|
4942
|
-
|
|
4943
|
-
|
|
4944
|
-
const out: string[] = []
|
|
4945
|
-
let rest = text
|
|
4946
|
-
while (rest.length > limit) {
|
|
4947
|
-
let cut = limit
|
|
4948
|
-
if (mode === 'newline') {
|
|
4949
|
-
const para = rest.lastIndexOf('\n\n', limit)
|
|
4950
|
-
const line = rest.lastIndexOf('\n', limit)
|
|
4951
|
-
const space = rest.lastIndexOf(' ', limit)
|
|
4952
|
-
cut = para > limit / 2 ? para : line > limit / 2 ? line : space > 0 ? space : limit
|
|
4953
|
-
}
|
|
4954
|
-
out.push(rest.slice(0, cut))
|
|
4955
|
-
rest = rest.slice(cut).replace(/^\n+/, '')
|
|
4956
|
-
}
|
|
4957
|
-
if (rest) out.push(rest)
|
|
4958
|
-
return out
|
|
4959
|
-
}
|
|
4937
|
+
// The length/newline text splitter moved to outbound-send-path.ts (#2996) as
|
|
4938
|
+
// `chunkText`; the sole gateway caller now goes through `computeReplyChunks`.
|
|
4960
4939
|
|
|
4961
4940
|
// ─── Typing indicator ─────────────────────────────────────────────────────
|
|
4962
4941
|
// All four state maps re-keyed from `chat_id` to `chatKey(chat, thread)`
|
|
@@ -5409,14 +5388,15 @@ const PERMISSION_CARD_ORIGIN_MAX_AGE_MS = 30 * 60_000
|
|
|
5409
5388
|
// other field finds no entry and falls through to a real operator card.
|
|
5410
5389
|
// Single-shot (deleted on match) + 30s TTL sweep so a stale correlation
|
|
5411
5390
|
// can't be replayed.
|
|
5412
|
-
const pendingAlwaysAllowCorrelations = new Map<string, { agentName: string; rule: string; unifiedDiff: string; createdAt: number }>()
|
|
5413
5391
|
const ALWAYS_ALLOW_CORRELATION_TTL_MS = 30_000
|
|
5392
|
+
// Storage extracted to pending-state-stores.ts (#2996 Phase 3 step 2): the
|
|
5393
|
+
// store owns the Map + co-locates the delete-past-TTL sweep. The TTL and its
|
|
5394
|
+
// comparison direction stay here in the injected predicate (byte-identical).
|
|
5395
|
+
const pendingAlwaysAllowCorrelations = createSweepableStore<{ agentName: string; rule: string; unifiedDiff: string; createdAt: number }>(
|
|
5396
|
+
(entry, now) => now - entry.createdAt > ALWAYS_ALLOW_CORRELATION_TTL_MS,
|
|
5397
|
+
)
|
|
5414
5398
|
function sweepStaleAlwaysAllowCorrelations(now = Date.now()): void {
|
|
5415
|
-
|
|
5416
|
-
if (now - entry.createdAt > ALWAYS_ALLOW_CORRELATION_TTL_MS) {
|
|
5417
|
-
pendingAlwaysAllowCorrelations.delete(key)
|
|
5418
|
-
}
|
|
5419
|
-
}
|
|
5399
|
+
pendingAlwaysAllowCorrelations.sweep(now)
|
|
5420
5400
|
}
|
|
5421
5401
|
|
|
5422
5402
|
// Sibling of pendingAlwaysAllowCorrelations for the agent-proposes →
|
|
@@ -5439,16 +5419,17 @@ function sweepStaleAlwaysAllowCorrelations(now = Date.now()): void {
|
|
|
5439
5419
|
// slow-but-valid tap, dropping the auto-approve and surfacing a SECOND card
|
|
5440
5420
|
// for an edit the operator already approved. Size it to the hostd budget.
|
|
5441
5421
|
const MENTAL_MODEL_CORRELATION_TTL_MS = 720_000
|
|
5442
|
-
|
|
5422
|
+
// Storage extracted to pending-state-stores.ts (#2996 Phase 3 step 2). Same
|
|
5423
|
+
// pattern as pendingAlwaysAllowCorrelations, but the predicate closes over the
|
|
5424
|
+
// larger 720s TTL that must outlive the whole config-edit approval budget.
|
|
5425
|
+
const pendingMentalModelCorrelations = createSweepableStore<{ agentName: string; unifiedDiff: string; createdAt: number }>(
|
|
5426
|
+
(entry, now) => now - entry.createdAt > MENTAL_MODEL_CORRELATION_TTL_MS,
|
|
5427
|
+
)
|
|
5443
5428
|
function mentalModelCorrelationKey(agentName: string, unifiedDiff: string): string {
|
|
5444
5429
|
return `${agentName}::${createHash('sha256').update(unifiedDiff).digest('hex')}`
|
|
5445
5430
|
}
|
|
5446
5431
|
function sweepStaleMentalModelCorrelations(now = Date.now()): void {
|
|
5447
|
-
|
|
5448
|
-
if (now - entry.createdAt > MENTAL_MODEL_CORRELATION_TTL_MS) {
|
|
5449
|
-
pendingMentalModelCorrelations.delete(key)
|
|
5450
|
-
}
|
|
5451
|
-
}
|
|
5432
|
+
pendingMentalModelCorrelations.sweep(now)
|
|
5452
5433
|
}
|
|
5453
5434
|
|
|
5454
5435
|
// Scoped-approval store: the 30-min window that backs the "✅ Allow" tap for
|
|
@@ -5486,10 +5467,19 @@ interface PendingAskUser {
|
|
|
5486
5467
|
timer: ReturnType<typeof setTimeout>
|
|
5487
5468
|
startedAt: number
|
|
5488
5469
|
}
|
|
5489
|
-
|
|
5490
|
-
|
|
5491
|
-
//
|
|
5492
|
-
const
|
|
5470
|
+
// Storage extracted to pending-state-stores.ts (#2996 Phase 3 step 2). Plain
|
|
5471
|
+
// store (no TTL sweep): entries are bounded by per-entry timers and cleared on
|
|
5472
|
+
// resolution / shutdown / chat-close, not by the reaper.
|
|
5473
|
+
const pendingAskUser = createPlainStore<PendingAskUser>()
|
|
5474
|
+
|
|
5475
|
+
// Reauth flows. Storage extracted to pending-state-stores.ts (#2996 Phase 3
|
|
5476
|
+
// step 2): the store owns the Map + co-locates the delete-past-TTL sweep the
|
|
5477
|
+
// reaper drove inline. Direction preserved: now - startedAt > TTL. The sweep
|
|
5478
|
+
// call stays in the SAME reaper position (first, ahead of the OAuth-code
|
|
5479
|
+
// cluster) so the awaitingAuthCodeAt contiguity pin is unaffected.
|
|
5480
|
+
const pendingReauthFlows = createSweepableStore<{ agent: string; startedAt: number }>(
|
|
5481
|
+
(v, now) => now - v.startedAt > REAUTH_INTERCEPT_TTL_MS,
|
|
5482
|
+
)
|
|
5493
5483
|
const REAUTH_INTERCEPT_TTL_MS = 10 * 60_000
|
|
5494
5484
|
|
|
5495
5485
|
// #710: per-message agent-button metadata (ack_text / single_use). Keyed by
|
|
@@ -5501,7 +5491,11 @@ const REAUTH_INTERCEPT_TTL_MS = 10 * 60_000
|
|
|
5501
5491
|
// design: when this map is empty (e.g. fresh process) the defaults apply
|
|
5502
5492
|
// (`'✓ received'` toast + strip keyboard) — the agent only loses any
|
|
5503
5493
|
// custom ack_text override.
|
|
5504
|
-
|
|
5494
|
+
// Storage extracted to pending-state-stores.ts (#2996 Phase 3 step 2). Plain
|
|
5495
|
+
// store (no TTL sweep): bounded by the AGENT_BUTTON_META_MAX LRU cap enforced
|
|
5496
|
+
// in rememberAgentButtonMeta, not the reaper. keys().next().value gives the
|
|
5497
|
+
// oldest insertion for eviction — Map-surface parity preserved.
|
|
5498
|
+
const agentButtonMeta = createPlainStore<Map<string, AgentButtonMeta>>()
|
|
5505
5499
|
const AGENT_BUTTON_META_MAX = 1000
|
|
5506
5500
|
function rememberAgentButtonMeta(
|
|
5507
5501
|
chatId: string | number,
|
|
@@ -5519,7 +5513,11 @@ function rememberAgentButtonMeta(
|
|
|
5519
5513
|
}
|
|
5520
5514
|
|
|
5521
5515
|
// Vault
|
|
5522
|
-
|
|
5516
|
+
// Storage extracted to pending-state-stores.ts (#2996 Phase 3 step 2). Absolute
|
|
5517
|
+
// expiry: the sweep deletes when now > expiresAt (direction preserved verbatim).
|
|
5518
|
+
const vaultPassphraseCache = createSweepableStore<{ passphrase: string; expiresAt: number }>(
|
|
5519
|
+
(v, now) => now > v.expiresAt,
|
|
5520
|
+
)
|
|
5523
5521
|
const VAULT_PASSPHRASE_TTL_MS = 30 * 60 * 1000
|
|
5524
5522
|
|
|
5525
5523
|
/**
|
|
@@ -5599,249 +5597,70 @@ export function initVaultApprovalPosture(): void {
|
|
|
5599
5597
|
const VAULT_KEY_REGEX = /^[A-Za-z0-9_./-]{1,200}$/
|
|
5600
5598
|
/** Human-readable label embedded in error messages and rename prompts. */
|
|
5601
5599
|
const VAULT_KEY_REGEX_LABEL = "[A-Za-z0-9_./-]{1,200}"
|
|
5602
|
-
|
|
5603
|
-
| { kind: 'passphrase'; op: 'list' | 'get' | 'delete' | 'set'; key?: string; startedAt: number }
|
|
5604
|
-
| { kind: 'value'; op: 'set'; key: string; passphrase: string; startedAt: number }
|
|
5605
|
-
// Issue #44: passphrase entry triggered by tapping "🔓 Unlock vault & save"
|
|
5606
|
-
// on a deferred-secret card. After the passphrase is cached we look up the
|
|
5607
|
-
// held secret by deferKey and write it directly — no re-paste required.
|
|
5608
|
-
| {
|
|
5609
|
-
kind: 'passphrase-for-deferred'
|
|
5610
|
-
deferKey: string
|
|
5611
|
-
cardChatId: string
|
|
5612
|
-
cardMessageId: number
|
|
5613
|
-
startedAt: number
|
|
5614
|
-
}
|
|
5615
|
-
// Issue #158: passphrase collected for /vault unlock — sent directly to the
|
|
5616
|
-
// broker unlock socket, never logged or cached beyond the op itself.
|
|
5617
|
-
| { kind: 'unlock'; startedAt: number }
|
|
5618
|
-
// Issue #227: inline-keyboard wizard for /vault grant
|
|
5619
|
-
| {
|
|
5620
|
-
kind: 'grant-wizard'
|
|
5621
|
-
step: 'agent' | 'keys' | 'duration' | 'confirm'
|
|
5622
|
-
wizardMsgId?: number // message to edit for each step
|
|
5623
|
-
agent?: string
|
|
5624
|
-
selectedKeys?: string[] // keys toggled on in step 2
|
|
5625
|
-
availableKeys?: string[] // list fetched from broker
|
|
5626
|
-
ttlSeconds?: number | null // null = never expires
|
|
5627
|
-
expiresLabel?: string // human-readable label for confirmation
|
|
5628
|
-
description?: string
|
|
5629
|
-
awaitingCustomDuration?: boolean // true while waiting for text reply
|
|
5630
|
-
/**
|
|
5631
|
-
* Approval-kernel request_id minted at the wizard confirm step
|
|
5632
|
-
* (MIGRATION.md §2, Phase 1 dual-dispatch — audit-only, advisory).
|
|
5633
|
-
* When set, `vg:generate` ALSO consumes + records an `allow_once`
|
|
5634
|
-
* decision on the kernel; `vg:cancel` records a `deny`. Cards in
|
|
5635
|
-
* flight from before this PR landed have it `undefined` and the
|
|
5636
|
-
* legacy `mintGrantViaBroker` runs alone — no kernel write. After
|
|
5637
|
-
* 1-2 releases the legacy-only branch can be removed (#833 Phase 2
|
|
5638
|
-
* is the enforcing flip).
|
|
5639
|
-
*/
|
|
5640
|
-
kernel_request_id?: string
|
|
5641
|
-
startedAt: number
|
|
5642
|
-
}
|
|
5643
|
-
// Issue #228: waiting for confirmation before revoking a grant.
|
|
5644
|
-
| { kind: 'revoke_confirm'; grantId: string; agent: string; keys: string[]; startedAt: number }
|
|
5645
|
-
// Issue #969 P1a: user tapped "Rename" on a vault_request_save card;
|
|
5646
|
-
// the next message becomes the new key name for the staged save.
|
|
5647
|
-
| { kind: 'rename-vault-save'; stageId: string; startedAt: number }
|
|
5648
|
-
// Issue #1012 Phase 2 follow-up: operator tapped Approve on a
|
|
5649
|
-
// vault_request_access card without first unlocking the vault. The
|
|
5650
|
-
// next message becomes the passphrase — we cache it, delete the
|
|
5651
|
-
// passphrase message, and auto-resume the approval mint flow without
|
|
5652
|
-
// making the operator tap Approve a second time. Mirrors the
|
|
5653
|
-
// `passphrase-for-deferred` flow from #44.
|
|
5654
|
-
//
|
|
5655
|
-
// #1051: `items` is a queue so concurrent Approve taps (operator
|
|
5656
|
-
// taps card 2 before typing passphrase for card 1) don't strand
|
|
5657
|
-
// earlier stages. On passphrase reply we process all queued items
|
|
5658
|
-
// sequentially. Each item carries its own stageId + card refs;
|
|
5659
|
-
// they're all in the same chat by construction (pendingVaultOps
|
|
5660
|
-
// map is keyed by chat_id).
|
|
5661
|
-
| {
|
|
5662
|
-
kind: 'passphrase-for-access-approve'
|
|
5663
|
-
items: Array<{
|
|
5664
|
-
stageId: string
|
|
5665
|
-
cardChatId: string
|
|
5666
|
-
cardMessageId: number
|
|
5667
|
-
senderId: string
|
|
5668
|
-
}>
|
|
5669
|
-
startedAt: number
|
|
5670
|
-
}
|
|
5600
|
+
// PendingVaultOp moved to callback-query-handlers.ts (#2996 Phase 5).
|
|
5671
5601
|
const VAULT_INPUT_TTL_MS = 5 * 60 * 1000
|
|
5672
|
-
|
|
5602
|
+
// Storage extracted to pending-state-stores.ts (#2996 Phase 3 step 2): the
|
|
5603
|
+
// store owns the Map + co-locates the delete-past-TTL sweep the reaper drove
|
|
5604
|
+
// inline. TTL + direction stay here (byte-identical: now - startedAt > TTL).
|
|
5605
|
+
const pendingVaultOps = createSweepableStore<PendingVaultOp>(
|
|
5606
|
+
(v, now) => now - v.startedAt > VAULT_INPUT_TTL_MS,
|
|
5607
|
+
)
|
|
5673
5608
|
|
|
5674
5609
|
// Secret-detection staging: ambiguous hits the user must confirm before we
|
|
5675
5610
|
// store/delete. Also holds the deferred "we need a passphrase before we can
|
|
5676
5611
|
// store this high-confidence hit" cases so the re-run after passphrase entry
|
|
5677
5612
|
// is seamless.
|
|
5678
5613
|
const secretStaging = new StagingMap()
|
|
5679
|
-
|
|
5680
|
-
|
|
5681
|
-
|
|
5682
|
-
|
|
5683
|
-
|
|
5684
|
-
|
|
5685
|
-
|
|
5686
|
-
|
|
5687
|
-
* detection (which would have to handle the no-detection-fired case for
|
|
5688
|
-
* Channel B context-rule defers — issue #44). Falls back to a generic
|
|
5689
|
-
* slug if detection didn't fire.
|
|
5690
|
-
*/
|
|
5691
|
-
suggested_slug: string
|
|
5692
|
-
/**
|
|
5693
|
-
* Approval-kernel request_id minted alongside the bespoke deferred-secret
|
|
5694
|
-
* card (MIGRATION.md §1, Phase 1 dual-dispatch). When set, the
|
|
5695
|
-
* `vd:unlock` / `vd:cancel` callback handler ALSO records the user's
|
|
5696
|
-
* decision on the kernel side via `approvalConsume` + `approvalRecord`,
|
|
5697
|
-
* so the audit log captures the unlock event.
|
|
5698
|
-
*
|
|
5699
|
-
* `undefined` on cards built before this PR landed (in-flight at deploy
|
|
5700
|
-
* time) — the legacy handler runs alone, no kernel record. After ~1-2
|
|
5701
|
-
* releases the legacy-only branch can be removed (separate cleanup PR).
|
|
5702
|
-
*/
|
|
5703
|
-
kernel_request_id?: string
|
|
5704
|
-
}
|
|
5705
|
-
const deferredSecrets = new Map<string, DeferredSecret>()
|
|
5614
|
+
// DeferredSecret moved to callback-query-handlers.ts (#2996 Phase 5).
|
|
5615
|
+
// Storage extracted to pending-state-stores.ts (#2996 Phase 3 step 2). The
|
|
5616
|
+
// isExpired predicate reads DEFERRED_SECRET_TTL_MS lazily at sweep time (it is
|
|
5617
|
+
// declared below this const but only referenced when the reaper sweeps, so the
|
|
5618
|
+
// TDZ never bites). Direction preserved: now - staged_at > TTL.
|
|
5619
|
+
const deferredSecrets = createSweepableStore<DeferredSecret>(
|
|
5620
|
+
(v, now) => now - v.staged_at > DEFERRED_SECRET_TTL_MS,
|
|
5621
|
+
)
|
|
5706
5622
|
|
|
5707
|
-
|
|
5708
|
-
* Agent-initiated save staging (issue #969 P1a). When an agent calls the
|
|
5709
|
-
* `vault_request_save` MCP tool, we stage the value here, render an
|
|
5710
|
-
* approval card to the user, and write to vault only on tap. The value
|
|
5711
|
-
* is held in gateway memory ONLY — never echoed back to the agent and
|
|
5712
|
-
* never logged.
|
|
5713
|
-
*/
|
|
5714
|
-
interface PendingVaultRequestSave {
|
|
5715
|
-
/** Agent that requested the save (process.env.SWITCHROOM_AGENT_NAME). */
|
|
5716
|
-
agent: string
|
|
5717
|
-
/** Chat to edit when the user taps. */
|
|
5718
|
-
chat_id: string
|
|
5719
|
-
/** Card message id (filled in after we send the card). */
|
|
5720
|
-
card_message_id?: number
|
|
5721
|
-
/** Supergroup forum topic the agent was working in when it requested the
|
|
5722
|
-
* save — carried into the save-outcome inbound so the resumed reply lands
|
|
5723
|
-
* back in that topic, not General. */
|
|
5724
|
-
threadId?: number
|
|
5725
|
-
/** Currently-suggested slug; may be renamed by the user. */
|
|
5726
|
-
key: string
|
|
5727
|
-
/** Storage shape — 'string' (default) or 'binary'. */
|
|
5728
|
-
kind: 'string' | 'binary'
|
|
5729
|
-
/** The secret value, held in memory until the user approves/discards. */
|
|
5730
|
-
value: string
|
|
5731
|
-
/** Optional rationale shown on the card. */
|
|
5732
|
-
why?: string
|
|
5733
|
-
/** Unix-ms timestamp; entries are reaped after VAULT_REQUEST_SAVE_TTL_MS. */
|
|
5734
|
-
staged_at: number
|
|
5735
|
-
/** Set on entries RESTORED from disk after a gateway restart. The staged
|
|
5736
|
-
* secret `value` is held in memory only (never persisted — secrets
|
|
5737
|
-
* hygiene), so a restored entry has an empty value and cannot complete the
|
|
5738
|
-
* write. A Save tap on such a card degrades gracefully: it tells the agent
|
|
5739
|
-
* the value was lost to a restart instead of writing an empty secret. */
|
|
5740
|
-
restoredWithoutValue?: boolean
|
|
5741
|
-
}
|
|
5742
|
-
const pendingVaultRequestSaves = new Map<string, PendingVaultRequestSave>()
|
|
5623
|
+
// PendingVaultRequestSave moved to callback-query-handlers.ts (#2996 Phase 5).
|
|
5743
5624
|
// Gateway-side reap window for a staged vault-save card. Tracks the operator
|
|
5744
5625
|
// approval-card lifetime (config-driven, 60-min default) so the reap never
|
|
5745
5626
|
// races ahead of the card the operator is still looking at.
|
|
5746
5627
|
const VAULT_REQUEST_SAVE_TTL_MS = approvalTtlMs()
|
|
5747
|
-
|
|
5748
|
-
|
|
5749
|
-
|
|
5750
|
-
|
|
5751
|
-
|
|
5752
|
-
|
|
5753
|
-
|
|
5754
|
-
)
|
|
5755
|
-
|
|
5628
|
+
// Storage extracted to approval-card-stores.ts (#2996 Phase 3): the store owns
|
|
5629
|
+
// the Map + co-locates the TTL sweep (`.sweep(now)`); expiry LOGIC
|
|
5630
|
+
// (expireVaultSaveCard) is still gateway-side, injected as a thunk. Call sites
|
|
5631
|
+
// (get/set/delete/iteration/restore) are byte-identical — same variable, same
|
|
5632
|
+
// Map surface.
|
|
5633
|
+
const pendingVaultRequestSaves = createSweepableCardStore<PendingVaultRequestSave>({
|
|
5634
|
+
isExpired: (v, n) => v.staged_at < n - VAULT_REQUEST_SAVE_TTL_MS,
|
|
5635
|
+
expire: () => expireVaultSaveCard,
|
|
5636
|
+
log: () => cardExpiryLog,
|
|
5637
|
+
})
|
|
5756
5638
|
|
|
5757
|
-
|
|
5758
|
-
* Issue #1012 — agent-initiated vault ACL request. The agent calls
|
|
5759
|
-
* `vault_request_access` when it hits VAULT-BROKER-DENIED (or
|
|
5760
|
-
* preemptively, when it knows it'll need a key it doesn't yet have).
|
|
5761
|
-
* The card carries [Approve] / [Deny] inline buttons; only the
|
|
5762
|
-
* operator can mint the grant (same authorization gate as the
|
|
5763
|
-
* existing /vault audit one-tap allow flow). The agent never sees
|
|
5764
|
-
* the grant token directly — `mintGrantViaBroker` writes it to the
|
|
5765
|
-
* agent's `.vault-token` file, which the agent's CLI reads on the
|
|
5766
|
-
* next vault request.
|
|
5767
|
-
*
|
|
5768
|
-
* Mirrors PendingVaultRequestSave above (#969 P1a). No secret
|
|
5769
|
-
* material is staged here — only the request metadata.
|
|
5770
|
-
*/
|
|
5771
|
-
interface PendingVaultRequestAccess {
|
|
5772
|
-
/** Agent that initiated the request (process.env.SWITCHROOM_AGENT_NAME). */
|
|
5773
|
-
agent: string
|
|
5774
|
-
/** Chat the card was rendered into; edited on tap. */
|
|
5775
|
-
chat_id: string
|
|
5776
|
-
/** Card message id (filled in after we send the card). */
|
|
5777
|
-
card_message_id?: number
|
|
5778
|
-
/** Supergroup forum topic the agent was working in when it requested (the
|
|
5779
|
-
* card's originating thread). Carried into the grant-outcome inbound so the
|
|
5780
|
-
* resumed reply lands back in that topic, not General. */
|
|
5781
|
-
threadId?: number
|
|
5782
|
-
/** Vault key the agent wants to read. */
|
|
5783
|
-
key: string
|
|
5784
|
-
/** 'read' (default) or 'write'. */
|
|
5785
|
-
scope: 'read' | 'write'
|
|
5786
|
-
/** Optional rationale the agent supplied; rendered on the card. */
|
|
5787
|
-
reason?: string
|
|
5788
|
-
/** Grant TTL in seconds (max 90 days; null = never, refused). */
|
|
5789
|
-
ttl_seconds: number
|
|
5790
|
-
/** Unix-ms timestamp; entries are reaped after VAULT_REQUEST_ACCESS_TTL_MS. */
|
|
5791
|
-
staged_at: number
|
|
5792
|
-
}
|
|
5793
|
-
const pendingVaultRequestAccesses = new Map<string, PendingVaultRequestAccess>()
|
|
5639
|
+
// PendingVaultRequestAccess moved to callback-query-handlers.ts (#2996 Phase 5).
|
|
5794
5640
|
// Gateway-side reap window for a staged vault-access card. Tracks the operator
|
|
5795
5641
|
// approval-card lifetime (config-driven, 60-min default) — see approvalTtlMs.
|
|
5796
5642
|
const VAULT_REQUEST_ACCESS_TTL_MS = approvalTtlMs()
|
|
5797
|
-
|
|
5798
|
-
|
|
5799
|
-
|
|
5800
|
-
|
|
5801
|
-
|
|
5802
|
-
|
|
5803
|
-
|
|
5804
|
-
)
|
|
5805
|
-
}
|
|
5643
|
+
// Storage extracted to approval-card-stores.ts (#2996 Phase 3) — see the
|
|
5644
|
+
// vault-save store above for the pattern.
|
|
5645
|
+
const pendingVaultRequestAccesses = createSweepableCardStore<PendingVaultRequestAccess>({
|
|
5646
|
+
isExpired: (v, n) => v.staged_at < n - VAULT_REQUEST_ACCESS_TTL_MS,
|
|
5647
|
+
expire: () => expireVaultAccessCard,
|
|
5648
|
+
log: () => cardExpiryLog,
|
|
5649
|
+
})
|
|
5806
5650
|
|
|
5807
|
-
|
|
5808
|
-
* Staged agent-initiated MENTAL MODEL proposal (hindsight Phase 5). The agent
|
|
5809
|
-
* calls `mental_model_propose`; the operator taps Approve/Deny on the card.
|
|
5810
|
-
* Mirrors PendingVaultRequestAccess — no memory content is staged here, only
|
|
5811
|
-
* the proposed DECLARATION (name + source_query + optional knobs). On Approve
|
|
5812
|
-
* the model becomes a first-class declared model in memory.mental_models[] via
|
|
5813
|
-
* the operator-approved config-edit path; on Deny nothing is written.
|
|
5814
|
-
*/
|
|
5815
|
-
interface PendingMentalModelPropose {
|
|
5816
|
-
agent: string
|
|
5817
|
-
chat_id: string
|
|
5818
|
-
card_message_id?: number
|
|
5819
|
-
threadId?: number
|
|
5820
|
-
/** Proposed declaration, snake_case (matches memory.mental_models[] schema). */
|
|
5821
|
-
spec: {
|
|
5822
|
-
name: string
|
|
5823
|
-
source_query: string
|
|
5824
|
-
refresh_after_consolidation?: boolean
|
|
5825
|
-
max_tokens?: number
|
|
5826
|
-
}
|
|
5827
|
-
reason?: string
|
|
5828
|
-
staged_at: number
|
|
5829
|
-
}
|
|
5830
|
-
const pendingMentalModelProposes = new Map<string, PendingMentalModelPropose>()
|
|
5651
|
+
// PendingMentalModelPropose moved to callback-query-handlers.ts (#2996 Phase 5).
|
|
5831
5652
|
const MENTAL_MODEL_PROPOSE_TTL_MS = approvalTtlMs()
|
|
5832
|
-
//
|
|
5833
|
-
//
|
|
5834
|
-
//
|
|
5835
|
-
//
|
|
5836
|
-
|
|
5837
|
-
|
|
5838
|
-
|
|
5839
|
-
|
|
5840
|
-
|
|
5841
|
-
|
|
5842
|
-
|
|
5843
|
-
)
|
|
5844
|
-
}
|
|
5653
|
+
// Storage extracted to approval-card-stores.ts (#2996 Phase 3). The store's
|
|
5654
|
+
// `.sweep(now)` expires past-TTL proposals; for any expired entry the injected
|
|
5655
|
+
// expireMentalModelProposeCard ALSO edits the posted card's keyboard away, so a
|
|
5656
|
+
// stale card left in the chat can't be tapped into a "Card expired" answer — the
|
|
5657
|
+
// operator sees the ⌛ expiry inline instead. Best-effort: card edits are
|
|
5658
|
+
// fire-and-forget (the entry is removed regardless).
|
|
5659
|
+
const pendingMentalModelProposes = createSweepableCardStore<PendingMentalModelPropose>({
|
|
5660
|
+
isExpired: (v, n) => v.staged_at < n - MENTAL_MODEL_PROPOSE_TTL_MS,
|
|
5661
|
+
expire: () => expireMentalModelProposeCard,
|
|
5662
|
+
log: () => cardExpiryLog,
|
|
5663
|
+
})
|
|
5845
5664
|
|
|
5846
5665
|
// Sliding-window rate limit for mental-model proposals: at most
|
|
5847
5666
|
// MENTAL_MODEL_PROPOSE_MAX_PER_WINDOW cards per MENTAL_MODEL_PROPOSE_WINDOW_MS.
|
|
@@ -6026,9 +5845,10 @@ const DEFERRED_SECRET_TTL_MS = 24 * 60 * 60_000 // 24 h — ignored one-tap card
|
|
|
6026
5845
|
// Freshness throttle for `auth:refresh` taps. Keyed by `<chat_id>:<message_id>`
|
|
6027
5846
|
// so two different snapshot messages throttle independently. Each refresh
|
|
6028
5847
|
// fan-fires N live api.anthropic.com probes (one per account), so we cap
|
|
6029
|
-
// rapid re-taps to one per AUTH_REFRESH_THROTTLE_MS
|
|
5848
|
+
// rapid re-taps to one per AUTH_REFRESH_THROTTLE_MS (the constant moved to
|
|
5849
|
+
// callback-query-handlers.ts with the handler; this map's 60s reaper stays
|
|
5850
|
+
// gateway-side below).
|
|
6030
5851
|
const lastAuthRefreshAtMs = new Map<string, number>()
|
|
6031
|
-
const AUTH_REFRESH_THROTTLE_MS = 5_000
|
|
6032
5852
|
|
|
6033
5853
|
// ─── TTL reaper ───────────────────────────────────────────────────────────
|
|
6034
5854
|
// Pending state maps above all grow whenever a flow starts and only shrink
|
|
@@ -6285,9 +6105,9 @@ function expireMentalModelProposeCard(stageId: string, v: PendingMentalModelProp
|
|
|
6285
6105
|
// per-entry guarded via sweepExpiredEntries, so one throwing expiry (dead IPC
|
|
6286
6106
|
// socket, store IO error) can't skip the remaining entries or families.
|
|
6287
6107
|
function sweepExpiredApprovalCards(now: number): void {
|
|
6288
|
-
|
|
6289
|
-
|
|
6290
|
-
|
|
6108
|
+
pendingVaultRequestAccesses.sweep(now)
|
|
6109
|
+
pendingVaultRequestSaves.sweep(now)
|
|
6110
|
+
pendingMentalModelProposes.sweep(now)
|
|
6291
6111
|
sweepSecretRequests(now)
|
|
6292
6112
|
}
|
|
6293
6113
|
|
|
@@ -6371,9 +6191,7 @@ function restorePendingApprovalCards(): number {
|
|
|
6371
6191
|
const pendingStateReaper = setInterval(() => {
|
|
6372
6192
|
const now = Date.now()
|
|
6373
6193
|
// OAuth-code state grouped first (pinned by secret-detect-oauth-code.test.ts).
|
|
6374
|
-
|
|
6375
|
-
if (now - v.startedAt > REAUTH_INTERCEPT_TTL_MS) pendingReauthFlows.delete(k)
|
|
6376
|
-
}
|
|
6194
|
+
pendingReauthFlows.sweep(now)
|
|
6377
6195
|
for (const [k, v] of pendingAuthAddFlows) {
|
|
6378
6196
|
if (now - v.startedAt > REAUTH_INTERCEPT_TTL_MS) {
|
|
6379
6197
|
cancelAccountAuthSession(v)
|
|
@@ -6404,9 +6222,7 @@ const pendingStateReaper = setInterval(() => {
|
|
|
6404
6222
|
for (const [k, v] of pendingAuthRmFlows) {
|
|
6405
6223
|
if (now >= v.expiresAt) pendingAuthRmFlows.delete(k)
|
|
6406
6224
|
}
|
|
6407
|
-
|
|
6408
|
-
if (now - v.startedAt > VAULT_INPUT_TTL_MS) pendingVaultOps.delete(k)
|
|
6409
|
-
}
|
|
6225
|
+
pendingVaultOps.sweep(now)
|
|
6410
6226
|
for (const [k, v] of pendingPermissions) {
|
|
6411
6227
|
// hostd gated fleet-mutation verbs get a longer (30-min) human-scale
|
|
6412
6228
|
// decision window than the 10-min default (Bug 2 fix #2).
|
|
@@ -6485,9 +6301,7 @@ const pendingStateReaper = setInterval(() => {
|
|
|
6485
6301
|
for (const [sig, at] of permissionTimeoutSignatures) {
|
|
6486
6302
|
if (now - at > PERMISSION_DUPLICATE_WINDOW_MS) permissionTimeoutSignatures.delete(sig)
|
|
6487
6303
|
}
|
|
6488
|
-
|
|
6489
|
-
if (now > v.expiresAt) vaultPassphraseCache.delete(k)
|
|
6490
|
-
}
|
|
6304
|
+
vaultPassphraseCache.sweep(now)
|
|
6491
6305
|
// Drop expired "⏱ 30 min" scoped grants. (Lookup already fails closed on
|
|
6492
6306
|
// expiry; this just keeps the map from accumulating dead entries.) Persist
|
|
6493
6307
|
// the removal so a restart between sweeps can't resurrect a swept grant —
|
|
@@ -6497,9 +6311,7 @@ const pendingStateReaper = setInterval(() => {
|
|
|
6497
6311
|
if (countScopedGrants(scopedGrants) !== scopedGrantsBefore) {
|
|
6498
6312
|
scopedGrantStore.save(scopedGrants)
|
|
6499
6313
|
}
|
|
6500
|
-
|
|
6501
|
-
if (now - v.staged_at > DEFERRED_SECRET_TTL_MS) deferredSecrets.delete(k)
|
|
6502
|
-
}
|
|
6314
|
+
deferredSecrets.sweep(now)
|
|
6503
6315
|
// Agent-initiated approval cards (vault_request_access / vault_request_save /
|
|
6504
6316
|
// request_secret / mental_model_propose): expire past-TTL entries and WAKE
|
|
6505
6317
|
// the parked agent (timeout synthetic + missed-approvals re-offer). This is
|
|
@@ -10998,37 +10810,23 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
10998
10810
|
// Repair LLM JSON-escape bungles, then promote lone prose paragraph breaks
|
|
10999
10811
|
// into GFM hard breaks so the rich path doesn't collapse them (lists/tables/
|
|
11000
10812
|
// code are left untouched — see normalizeParagraphBreaks).
|
|
11001
|
-
|
|
11002
|
-
//
|
|
11003
|
-
//
|
|
11004
|
-
//
|
|
11005
|
-
//
|
|
11006
|
-
//
|
|
11007
|
-
//
|
|
11008
|
-
|
|
11009
|
-
|
|
11010
|
-
|
|
11011
|
-
|
|
11012
|
-
|
|
11013
|
-
|
|
11014
|
-
|
|
11015
|
-
|
|
11016
|
-
|
|
11017
|
-
|
|
11018
|
-
// dashes inside fences/inline code are preserved). Kill switch:
|
|
11019
|
-
// `SWITCHROOM_DISABLE_VOICE_SCRUB=1`.
|
|
11020
|
-
{
|
|
11021
|
-
const scrub = scrubVoice(text)
|
|
11022
|
-
if (scrub.replaced > 0) {
|
|
11023
|
-
text = scrub.scrubbed
|
|
11024
|
-
emitRuntimeMetric({
|
|
11025
|
-
kind: 'voice_scrub_applied',
|
|
11026
|
-
chatKey: statusKey(chat_id, args.message_thread_id != null
|
|
11027
|
-
? Number(args.message_thread_id) : undefined),
|
|
11028
|
-
replaced: scrub.replaced,
|
|
11029
|
-
site: 'reply',
|
|
11030
|
-
})
|
|
11031
|
-
}
|
|
10813
|
+
// Outbound text pipeline (#2996 §3B): normalize → redact → punctuation/bold
|
|
10814
|
+
// → voice-scrub, extracted verbatim into outbound-send-path.ts. The order is
|
|
10815
|
+
// load-bearing (secret scrub BEFORE the punctuation/bold normalizers so a
|
|
10816
|
+
// secret with an em-dash or `**` is matched literally; voice scrub last so
|
|
10817
|
+
// retries see the scrubbed dedup key). The metric side effect (fired on a
|
|
10818
|
+
// non-zero voice-scrub replacement) stays here — the pure module returns the
|
|
10819
|
+
// replacement count and the gateway emits.
|
|
10820
|
+
const _normalized = normalizeOutboundBody(rawText, 'reply', redactOutboundText)
|
|
10821
|
+
let text = _normalized.text
|
|
10822
|
+
if (_normalized.voiceReplaced > 0) {
|
|
10823
|
+
emitRuntimeMetric({
|
|
10824
|
+
kind: 'voice_scrub_applied',
|
|
10825
|
+
chatKey: statusKey(chat_id, args.message_thread_id != null
|
|
10826
|
+
? Number(args.message_thread_id) : undefined),
|
|
10827
|
+
replaced: _normalized.voiceReplaced,
|
|
10828
|
+
site: 'reply',
|
|
10829
|
+
})
|
|
11032
10830
|
}
|
|
11033
10831
|
process.stderr.write(`telegram channel: reply: invoked chatId=${chat_id} charCount=${text.length} preview=${JSON.stringify(text.slice(0, 80))}\n`)
|
|
11034
10832
|
// #2527: emit time_to_first_text_reply_ms on the FIRST text reply of each
|
|
@@ -11249,7 +11047,7 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
11249
11047
|
// jammed together — unlike the old HTML path. Inject a visible blank-line
|
|
11250
11048
|
// spacer into prose `\n\n` gaps on the rich path only. The literal
|
|
11251
11049
|
// (`format:'text'`) path must stay byte-exact, so it is left untouched.
|
|
11252
|
-
const effectiveText: string =
|
|
11050
|
+
const effectiveText: string = computeEffectiveText(text, literalText)
|
|
11253
11051
|
|
|
11254
11052
|
assertAllowedChat(chat_id)
|
|
11255
11053
|
|
|
@@ -11313,9 +11111,12 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
11313
11111
|
|
|
11314
11112
|
const limit = Math.max(1, Math.min(access.textChunkLimit ?? RICH_MESSAGE_MAX_CHARS, MAX_CHUNK_LIMIT))
|
|
11315
11113
|
const replyMode = access.replyToMode ?? 'first'
|
|
11316
|
-
const chunks =
|
|
11317
|
-
|
|
11318
|
-
|
|
11114
|
+
const chunks = computeReplyChunks({
|
|
11115
|
+
effectiveText,
|
|
11116
|
+
literalText,
|
|
11117
|
+
limit,
|
|
11118
|
+
chunkMode: access.chunkMode ?? 'length',
|
|
11119
|
+
})
|
|
11319
11120
|
const sentIds: number[] = []
|
|
11320
11121
|
|
|
11321
11122
|
// Outbound TTS synthesis (PR-C2). Done BEFORE the text send so a
|
|
@@ -11700,162 +11501,83 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
11700
11501
|
}
|
|
11701
11502
|
}
|
|
11702
11503
|
|
|
11504
|
+
// #2996 step 1 — the chunk-send loop (with its THREAD_NOT_FOUND / oversize
|
|
11505
|
+
// re-split / parse-reject fallback ladder and partial-failure contract) is
|
|
11506
|
+
// relocated verbatim to outbound-send-path.ts's `sendReplyChunks` so it is
|
|
11507
|
+
// unit-testable against a fake bot API (gateway.ts is not importable —
|
|
11508
|
+
// Bun.listen + boot logic run at import). The raw `bot.api.*` calls stay HERE
|
|
11509
|
+
// as thin injected adapters (retry wrapping + allow-raw-bot-api markers
|
|
11510
|
+
// preserved), so the module is bot-agnostic and the check-bot-api-wrapping
|
|
11511
|
+
// allowlist is unchanged. The caller still builds the per-chunk option shape
|
|
11512
|
+
// (byte-identical). `sentIds` is threaded by reference; `threadId` /
|
|
11513
|
+
// `previewMessageId` come back for the file-send + history code below.
|
|
11514
|
+
const chunkSendDeps: ReplyChunkSendDeps = {
|
|
11515
|
+
sendRich: (opts, body, tid) =>
|
|
11516
|
+
robustApiCall(
|
|
11517
|
+
// allow-raw-bot-api: injected chunk-loop adapter — sendRichMessage routed through robustApiCall; THREAD_NOT_FOUND handled by sendReplyChunks' fallback ladder
|
|
11518
|
+
() => lockedBot.api.sendRichMessage(chat_id, body as never, opts as never),
|
|
11519
|
+
{ threadId: tid, chat_id },
|
|
11520
|
+
),
|
|
11521
|
+
sendLiteral: (opts, txt, tid) =>
|
|
11522
|
+
robustApiCall(
|
|
11523
|
+
// allow-raw-bot-api: injected chunk-loop adapter — literal format:'text' send routed through robustApiCall; THREAD_NOT_FOUND handled by sendReplyChunks
|
|
11524
|
+
() => lockedBot.api.sendMessage(chat_id, txt, opts as never),
|
|
11525
|
+
{ threadId: tid, chat_id },
|
|
11526
|
+
),
|
|
11527
|
+
sendLiteralRaw: (opts, txt) =>
|
|
11528
|
+
// allow-raw-bot-api: literal last-resort fallback (plaintext parse-reject / length re-split); wrapping would re-enter the parse/length policy that just rejected the payload
|
|
11529
|
+
lockedBot.api.sendMessage(chat_id, txt, opts as never),
|
|
11530
|
+
sendRichRaw: (opts, body) =>
|
|
11531
|
+
// allow-raw-bot-api: rich length-error re-split last resort; wrapping would re-enter the chunk-loop's own classification on an already-classified length failure
|
|
11532
|
+
lockedBot.api.sendRichMessage(chat_id, body as never, opts as never),
|
|
11533
|
+
editPreview: (mid, body, opts, tid) =>
|
|
11534
|
+
robustApiCall(
|
|
11535
|
+
// allow-raw-bot-api: preview edit-in-place routed through robustApiCall; thread fallback handled by sendReplyChunks
|
|
11536
|
+
() => lockedBot.api.editMessageText(chat_id, mid, body as never, opts as never),
|
|
11537
|
+
{ threadId: tid, chat_id },
|
|
11538
|
+
),
|
|
11539
|
+
richMessage,
|
|
11540
|
+
logOutbound,
|
|
11541
|
+
deleteStalePreview,
|
|
11542
|
+
stderr: (s: string) => { process.stderr.write(s) },
|
|
11543
|
+
}
|
|
11703
11544
|
try {
|
|
11704
|
-
|
|
11705
|
-
|
|
11706
|
-
|
|
11707
|
-
|
|
11708
|
-
|
|
11709
|
-
|
|
11710
|
-
|
|
11711
|
-
|
|
11712
|
-
|
|
11713
|
-
|
|
11714
|
-
|
|
11715
|
-
|
|
11716
|
-
|
|
11717
|
-
|
|
11718
|
-
|
|
11719
|
-
|
|
11720
|
-
|
|
11721
|
-
|
|
11722
|
-
|
|
11723
|
-
|
|
11724
|
-
|
|
11725
|
-
|
|
11726
|
-
|
|
11727
|
-
|
|
11728
|
-
|
|
11729
|
-
|
|
11730
|
-
|
|
11731
|
-
|
|
11732
|
-
const richOrPlain = (s: string) => (literalText ? s : richMessage(s))
|
|
11733
|
-
|
|
11734
|
-
if (i === 0 && previewMessageId != null) {
|
|
11545
|
+
const _sendResult = await sendReplyChunks(chunkSendDeps, {
|
|
11546
|
+
chatId: chat_id,
|
|
11547
|
+
chunks,
|
|
11548
|
+
literalText,
|
|
11549
|
+
suppressText,
|
|
11550
|
+
threadId,
|
|
11551
|
+
previewMessageId,
|
|
11552
|
+
sentIds,
|
|
11553
|
+
buildSendOpts: (i, isLastChunk, tid) => {
|
|
11554
|
+
const shouldReplyTo =
|
|
11555
|
+
reply_to != null && replyMode !== 'off' && (replyMode === 'all' || i === 0)
|
|
11556
|
+
return {
|
|
11557
|
+
...(shouldReplyTo
|
|
11558
|
+
? {
|
|
11559
|
+
reply_parameters: {
|
|
11560
|
+
message_id: reply_to,
|
|
11561
|
+
...(quoteText != null ? { quote: { text: quoteText, position: 0 } } : {}),
|
|
11562
|
+
},
|
|
11563
|
+
}
|
|
11564
|
+
: {}),
|
|
11565
|
+
...(tid != null ? { message_thread_id: tid } : {}),
|
|
11566
|
+
...(disableLinkPreview ? { link_preview_options: { is_disabled: true } } : {}),
|
|
11567
|
+
...(replyMarkup != null && isLastChunk ? { reply_markup: replyMarkup } : {}),
|
|
11568
|
+
...(protectContent ? { protect_content: true } : {}),
|
|
11569
|
+
...(disableNotification ? { disable_notification: true } : {}),
|
|
11570
|
+
}
|
|
11571
|
+
},
|
|
11572
|
+
buildPreviewEditOpts: (isLastChunk) => {
|
|
11735
11573
|
const editOpts: Record<string, unknown> = {}
|
|
11736
11574
|
if (disableLinkPreview) editOpts.link_preview_options = { is_disabled: true }
|
|
11737
11575
|
if (replyMarkup != null && isLastChunk) editOpts.reply_markup = replyMarkup
|
|
11738
|
-
|
|
11739
|
-
|
|
11740
|
-
|
|
11741
|
-
|
|
11742
|
-
|
|
11743
|
-
sentIds.push(previewMessageId!)
|
|
11744
|
-
previewMessageId = null
|
|
11745
|
-
continue
|
|
11746
|
-
} catch (err) {
|
|
11747
|
-
const msg = err instanceof Error ? err.message : String(err)
|
|
11748
|
-
if (/not modified/i.test(msg)) {
|
|
11749
|
-
sentIds.push(previewMessageId!)
|
|
11750
|
-
previewMessageId = null
|
|
11751
|
-
continue
|
|
11752
|
-
}
|
|
11753
|
-
process.stderr.write(`telegram gateway: preview edit-in-place failed (${msg}), sending fresh\n`)
|
|
11754
|
-
await deleteStalePreview(previewMessageId!)
|
|
11755
|
-
previewMessageId = null
|
|
11756
|
-
}
|
|
11757
|
-
}
|
|
11758
|
-
|
|
11759
|
-
// Last-resort: resend this chunk as plain text (no rich wrapper, so
|
|
11760
|
-
// the markdown parser never runs). Keeps thread / reply / markup
|
|
11761
|
-
// params; only the formatting is sacrificed. Used when Telegram
|
|
11762
|
-
// rejects our markdown — better an unformatted answer than a
|
|
11763
|
-
// vanished one. The raw markdown source is itself readable prose, so
|
|
11764
|
-
// we send it verbatim rather than strip anything.
|
|
11765
|
-
const sendChunkPlainText = async (opts: Record<string, unknown>): Promise<void> => {
|
|
11766
|
-
const plain =
|
|
11767
|
-
chunks[i].length > 0
|
|
11768
|
-
? chunks[i]
|
|
11769
|
-
: '⚠️ (a fragment could not be rendered for Telegram)'
|
|
11770
|
-
// allow-raw-bot-api: plaintext last-resort fallback; wrapping would re-enter the parse policy that just rejected the payload
|
|
11771
|
-
const sent = await lockedBot.api.sendMessage(chat_id, plain, opts as never)
|
|
11772
|
-
sentIds.push(sent.message_id)
|
|
11773
|
-
logOutbound('reply', chat_id, sent.message_id, plain.length, `chunk=${i + 1}/${chunks.length} plaintext-fallback`)
|
|
11774
|
-
process.stderr.write(
|
|
11775
|
-
`telegram gateway: markdown parse-reject — resent chunk ${i + 1}/${chunks.length} as plain text\n`,
|
|
11776
|
-
)
|
|
11777
|
-
}
|
|
11778
|
-
|
|
11779
|
-
// Literal `format:'text'` sends bypass the rich parser entirely
|
|
11780
|
-
// (plain sendMessage, no markdown). The default path ships rich
|
|
11781
|
-
// markdown via sendRichMessage. Both resolve to a Message with a
|
|
11782
|
-
// message_id, which is all the caller reads.
|
|
11783
|
-
const sendChunk = (opts: Record<string, unknown>): Promise<{ message_id: number }> => {
|
|
11784
|
-
if (literalText) {
|
|
11785
|
-
// allow-raw-bot-api: literal format:'text' send; the chunk-loop's own THREAD_NOT_FOUND + parse-reject handling wraps this
|
|
11786
|
-
return lockedBot.api.sendMessage(chat_id, chunks[i], opts as never)
|
|
11787
|
-
}
|
|
11788
|
-
// sendRichMessage does NOT accept link_preview_options (rich messages
|
|
11789
|
-
// control previews via entity detection) — drop it for the rich path.
|
|
11790
|
-
const richOpts = { ...opts }
|
|
11791
|
-
delete (richOpts as { link_preview_options?: unknown }).link_preview_options
|
|
11792
|
-
// allow-raw-bot-api: sendRichMessage is not in the THREAD_NOT_FOUND blast pattern; thread fallback handled in the catch below
|
|
11793
|
-
return lockedBot.api.sendRichMessage(chat_id, richMessage(chunks[i]), richOpts as never)
|
|
11794
|
-
}
|
|
11795
|
-
|
|
11796
|
-
// Length-error recovery: a single pre-computed chunk can still exceed the
|
|
11797
|
-
// wire cap when splitMarkdownChunks hit an indivisible region and emitted
|
|
11798
|
-
// it whole (a giant fenced block, a no-boundary blob). Telegram answers
|
|
11799
|
-
// with RICH_MESSAGE_TEXT_TOO_LONG / MESSAGE_TOO_LONG. Re-split this chunk
|
|
11800
|
-
// at a harder boundary and send each piece, rather than misclassifying it
|
|
11801
|
-
// as a parse-reject (which would resend the same oversized payload as
|
|
11802
|
-
// plain text) or surfacing the raw 400.
|
|
11803
|
-
const sendChunkResplit = async (opts: Record<string, unknown>): Promise<void> => {
|
|
11804
|
-
// Re-split at the same cap; for a truly indivisible block this still
|
|
11805
|
-
// yields one oversized piece, but a hard character-cut on the rendered
|
|
11806
|
-
// markdown at least keeps each delivered piece under the wire cap.
|
|
11807
|
-
const subPieces = splitMarkdownChunks(chunks[i], RICH_MESSAGE_MAX_CHARS)
|
|
11808
|
-
const pieces =
|
|
11809
|
-
subPieces.length > 1
|
|
11810
|
-
? subPieces
|
|
11811
|
-
: hardSliceToCap(chunks[i], RICH_MESSAGE_MAX_CHARS)
|
|
11812
|
-
for (let p = 0; p < pieces.length; p++) {
|
|
11813
|
-
let sent: { message_id: number }
|
|
11814
|
-
if (literalText) {
|
|
11815
|
-
// allow-raw-bot-api: length-error re-split last resort (literal text); wrapping would re-enter the chunk-loop's own classification on an already-classified length failure.
|
|
11816
|
-
sent = await lockedBot.api.sendMessage(chat_id, pieces[p], opts as never)
|
|
11817
|
-
} else {
|
|
11818
|
-
const ro = { ...opts }
|
|
11819
|
-
delete (ro as { link_preview_options?: unknown }).link_preview_options
|
|
11820
|
-
// allow-raw-bot-api: length-error re-split last resort (rich); wrapping would re-enter the chunk-loop's own classification on an already-classified length failure.
|
|
11821
|
-
sent = await lockedBot.api.sendRichMessage(chat_id, richMessage(pieces[p]), ro as never)
|
|
11822
|
-
}
|
|
11823
|
-
sentIds.push(sent.message_id)
|
|
11824
|
-
logOutbound('reply', chat_id, sent.message_id, pieces[p].length, `chunk=${i + 1}/${chunks.length} resplit=${p + 1}/${pieces.length}`)
|
|
11825
|
-
}
|
|
11826
|
-
process.stderr.write(
|
|
11827
|
-
`telegram gateway: rich body too long — re-split chunk ${i + 1}/${chunks.length} into ${pieces.length} piece(s)\n`,
|
|
11828
|
-
)
|
|
11829
|
-
}
|
|
11830
|
-
|
|
11831
|
-
try {
|
|
11832
|
-
const sent = await robustApiCall(() => sendChunk(sendOpts), { threadId, chat_id })
|
|
11833
|
-
sentIds.push(sent.message_id)
|
|
11834
|
-
logOutbound('reply', chat_id, sent.message_id, chunks[i].length, `chunk=${i + 1}/${chunks.length}`)
|
|
11835
|
-
} catch (err) {
|
|
11836
|
-
if (err instanceof Error && err.message === 'THREAD_NOT_FOUND') {
|
|
11837
|
-
threadId = undefined
|
|
11838
|
-
const retryOpts = { ...sendOpts }
|
|
11839
|
-
delete (retryOpts as any).message_thread_id
|
|
11840
|
-
try {
|
|
11841
|
-
const sent = await sendChunk(retryOpts)
|
|
11842
|
-
sentIds.push(sent.message_id)
|
|
11843
|
-
} catch (retryErr) {
|
|
11844
|
-
// Thread dropped, AND another failure: length → re-split,
|
|
11845
|
-
// parse-reject → plain text, else propagate.
|
|
11846
|
-
if (isMessageTooLongError(retryErr)) await sendChunkResplit(retryOpts)
|
|
11847
|
-
else if (isHtmlParseRejectError(retryErr)) await sendChunkPlainText(retryOpts)
|
|
11848
|
-
else throw retryErr
|
|
11849
|
-
}
|
|
11850
|
-
} else if (isMessageTooLongError(err)) {
|
|
11851
|
-
await sendChunkResplit(sendOpts)
|
|
11852
|
-
} else if (isHtmlParseRejectError(err)) {
|
|
11853
|
-
await sendChunkPlainText(sendOpts)
|
|
11854
|
-
} else {
|
|
11855
|
-
throw err
|
|
11856
|
-
}
|
|
11857
|
-
}
|
|
11858
|
-
}
|
|
11576
|
+
return editOpts
|
|
11577
|
+
},
|
|
11578
|
+
})
|
|
11579
|
+
threadId = _sendResult.threadId
|
|
11580
|
+
previewMessageId = _sendResult.previewMessageId
|
|
11859
11581
|
} catch (err) {
|
|
11860
11582
|
const msg = err instanceof Error ? err.message : String(err)
|
|
11861
11583
|
throw new Error(`reply failed after ${sentIds.length} of ${chunks.length} chunk(s) sent: ${msg}`)
|
|
@@ -12699,7 +12421,7 @@ async function executeVaultRequestSave(args: Record<string, unknown>): Promise<{
|
|
|
12699
12421
|
staged_at: Date.now(),
|
|
12700
12422
|
}
|
|
12701
12423
|
pendingVaultRequestSaves.set(stageId, pending)
|
|
12702
|
-
|
|
12424
|
+
pendingVaultRequestSaves.sweep(Date.now())
|
|
12703
12425
|
|
|
12704
12426
|
// Send the approval card. #1075: route through retryWithThreadFallback
|
|
12705
12427
|
// so a deleted topic still lands the card on the main chat instead of
|
|
@@ -12767,29 +12489,32 @@ interface PendingSecretRequest {
|
|
|
12767
12489
|
* in that topic, not General. */
|
|
12768
12490
|
threadId?: number
|
|
12769
12491
|
}
|
|
12770
|
-
// stageId -> request (lives until tapped or TTL).
|
|
12771
|
-
const pendingSecretRequests = new Map<string, PendingSecretRequest>()
|
|
12772
12492
|
// chat_id -> the armed capture: the operator's NEXT message in this chat is
|
|
12773
12493
|
// the value for `key`. Set when [Provide securely] is tapped.
|
|
12774
12494
|
interface ArmedSecretCapture { key: string; agent: string; stageId: string; armed_at: number; threadId?: number }
|
|
12775
|
-
|
|
12495
|
+
// Storage extracted to pending-state-stores.ts (#2996 Phase 3 step 2). Swept by
|
|
12496
|
+
// sweepSecretRequests (below) — a plain delete-past-TTL, no wake (transient
|
|
12497
|
+
// post-tap window). Direction preserved: now - armed_at > TTL.
|
|
12498
|
+
const armedSecretCaptures = createSweepableStore<ArmedSecretCapture>(
|
|
12499
|
+
(v, now) => now - v.armed_at > ARMED_SECRET_CAPTURE_TTL_MS,
|
|
12500
|
+
)
|
|
12776
12501
|
const PENDING_SECRET_REQUEST_TTL_MS = 30 * 60_000 // card lifetime
|
|
12777
12502
|
const ARMED_SECRET_CAPTURE_TTL_MS = 10 * 60_000 // window to send the value after tapping
|
|
12503
|
+
// stageId -> request (lives until tapped or TTL). Storage extracted to
|
|
12504
|
+
// approval-card-stores.ts (#2996 Phase 3): the store owns the Map + co-locates
|
|
12505
|
+
// the TTL sweep; expireSecretRequestCard (the wake logic) stays gateway-side.
|
|
12506
|
+
const pendingSecretRequests = createSweepableCardStore<PendingSecretRequest>({
|
|
12507
|
+
isExpired: (v, n) => n - v.staged_at > PENDING_SECRET_REQUEST_TTL_MS,
|
|
12508
|
+
expire: () => expireSecretRequestCard,
|
|
12509
|
+
log: () => cardExpiryLog,
|
|
12510
|
+
})
|
|
12778
12511
|
|
|
12779
12512
|
function sweepSecretRequests(now = Date.now()): void {
|
|
12780
|
-
|
|
12781
|
-
pendingSecretRequests,
|
|
12782
|
-
(v, n) => n - v.staged_at > PENDING_SECRET_REQUEST_TTL_MS,
|
|
12783
|
-
expireSecretRequestCard,
|
|
12784
|
-
now,
|
|
12785
|
-
cardExpiryLog,
|
|
12786
|
-
)
|
|
12513
|
+
pendingSecretRequests.sweep(now)
|
|
12787
12514
|
// armedSecretCaptures is a TRANSIENT post-tap window (never persisted): it's
|
|
12788
12515
|
// only set after the operator taps [Provide securely], and the request is
|
|
12789
12516
|
// no longer parked-on-a-card. Just drop stale ones — no wake needed.
|
|
12790
|
-
|
|
12791
|
-
if (now - v.armed_at > ARMED_SECRET_CAPTURE_TTL_MS) armedSecretCaptures.delete(k)
|
|
12792
|
-
}
|
|
12517
|
+
armedSecretCaptures.sweep(now)
|
|
12793
12518
|
}
|
|
12794
12519
|
|
|
12795
12520
|
function buildSecretRequestKeyboard(stageId: string): { inline_keyboard: Array<Array<{ text: string; callback_data: string }>> } {
|
|
@@ -13189,7 +12914,7 @@ async function executeVaultRequestAccess(args: Record<string, unknown>): Promise
|
|
|
13189
12914
|
staged_at: Date.now(),
|
|
13190
12915
|
}
|
|
13191
12916
|
pendingVaultRequestAccesses.set(stageId, pending)
|
|
13192
|
-
|
|
12917
|
+
pendingVaultRequestAccesses.sweep(Date.now())
|
|
13193
12918
|
|
|
13194
12919
|
// renderVaultRequestAccessCard self-hardens its field line breaks (this card
|
|
13195
12920
|
// is sent direct, bypassing the switchroomReply chokepoint).
|
|
@@ -13371,7 +13096,7 @@ async function executeMentalModelPropose(args: Record<string, unknown>): Promise
|
|
|
13371
13096
|
staged_at: Date.now(),
|
|
13372
13097
|
}
|
|
13373
13098
|
pendingMentalModelProposes.set(stageId, pending)
|
|
13374
|
-
|
|
13099
|
+
pendingMentalModelProposes.sweep(Date.now())
|
|
13375
13100
|
|
|
13376
13101
|
const text = renderMentalModelProposeCard({
|
|
13377
13102
|
agent: agentSlug,
|
|
@@ -18039,13 +17764,20 @@ async function handleInbound(
|
|
|
18039
17764
|
// isSteering is now the real classification (computed at ~line 16289), so
|
|
18040
17765
|
// the machine correctly distinguishes a mid-turn steer (delivered, no new
|
|
18041
17766
|
// turn) from a fresh turn.
|
|
18042
|
-
|
|
17767
|
+
// PR3c cutover (#2794 / #2996): the payload now carries the REAL
|
|
17768
|
+
// ipc-protocol InboundMessage (previously null) so the machine's
|
|
17769
|
+
// deliverToBridge / bufferInbound / persistInbound effects are executable,
|
|
17770
|
+
// and the returned effects are CAPTURED — under the cutover they are
|
|
17771
|
+
// dispatched below and become the authoritative deliver-vs-buffer routing.
|
|
17772
|
+
// The machine treats the payload as opaque, so the kill-switch shadow
|
|
17773
|
+
// trace is unchanged.
|
|
17774
|
+
const machineInboundEffects = shadowEmit({
|
|
18043
17775
|
kind: 'inbound',
|
|
18044
17776
|
key: statusKey(chat_id, messageThreadId) as _ChatKey,
|
|
18045
17777
|
msg: {
|
|
18046
17778
|
msgId: msgId ?? 0,
|
|
18047
17779
|
isSteering,
|
|
18048
|
-
payload:
|
|
17780
|
+
payload: inboundMsg,
|
|
18049
17781
|
},
|
|
18050
17782
|
at: Date.now(),
|
|
18051
17783
|
})
|
|
@@ -18059,6 +17791,72 @@ async function handleInbound(
|
|
|
18059
17791
|
effectiveText,
|
|
18060
17792
|
})
|
|
18061
17793
|
|
|
17794
|
+
// ── PR3c inbound cutover (#2794 / #2996 item 1) ────────────────────────
|
|
17795
|
+
// The machine's effect list is now AUTHORITATIVE for the deliver-vs-buffer
|
|
17796
|
+
// routing when the cutover is on: the effects captured at the emit above
|
|
17797
|
+
// are executed through `dispatchEffects` (wired to real executors in
|
|
17798
|
+
// #3006) instead of the imperative twin below. The twin stays fully
|
|
17799
|
+
// intact as the kill-switch fallback (`SWITCHROOM_DELIVERY_MACHINE_CUTOVER=0`
|
|
17800
|
+
// restores the exact legacy path) and is deleted in PR4 after the 48h bake.
|
|
17801
|
+
//
|
|
17802
|
+
// Two carve-outs fall back to the imperative twin even with the cutover on
|
|
17803
|
+
// (both documented in the RFC as un-modeled by the machine; the twin's
|
|
17804
|
+
// behavior is the contract until the machine grows the events):
|
|
17805
|
+
// 1. `!`-interrupt while the machine reads in_turn — the twin's
|
|
17806
|
+
// interrupt carve-out delivers (the SIGINT'd turn may never emit
|
|
17807
|
+
// turn_complete, so buffering would strand the body; see
|
|
17808
|
+
// decideInboundDelivery). The machine has no interrupt concept yet
|
|
17809
|
+
// and would buffer.
|
|
17810
|
+
// 2. machine reads bridge_dead — the twin attempts the send and, on
|
|
17811
|
+
// miss, applies the shouldTrackDelivery carve-outs (steering /
|
|
17812
|
+
// interrupt / sourced bodies are dropped, not replayed orphaned
|
|
17813
|
+
// after restart) + posts the restart notice. The machine's
|
|
17814
|
+
// unconditional buffer+persist would replay a steer as a fresh turn.
|
|
17815
|
+
const machineDelivers = machineInboundEffects.some((e) => e.kind === 'deliverToBridge')
|
|
17816
|
+
const machineBuffers = machineInboundEffects.some((e) => e.kind === 'bufferInbound')
|
|
17817
|
+
// ANCHORED STRING — must match the machine's bridge-dead inbound trace
|
|
17818
|
+
// stage verbatim (see the anchor comment at its emit site in
|
|
17819
|
+
// inbound-delivery-machine.ts + the pin test in
|
|
17820
|
+
// inbound-delivery-cutover-flip.test.ts). Deleted in PR4 with the twin.
|
|
17821
|
+
const machineBridgeDead = machineInboundEffects.some(
|
|
17822
|
+
(e) => e.kind === 'logTrace' && e.stage === 'inbound_bridge_dead_buffer',
|
|
17823
|
+
)
|
|
17824
|
+
const machineAuthoritative =
|
|
17825
|
+
isDeliveryCutoverEnabled() &&
|
|
17826
|
+
isDispatchEnabled() &&
|
|
17827
|
+
!machineBridgeDead &&
|
|
17828
|
+
(machineDelivers || (machineBuffers && interrupt.isInterrupt !== true))
|
|
17829
|
+
if (machineAuthoritative && machineBuffers) {
|
|
17830
|
+
// Machine says: mid-turn non-steering inbound → buffer until idle (the
|
|
17831
|
+
// #1556 contract). dispatchEffects executes bufferInbound (+ the
|
|
17832
|
+
// idempotent persistInbound — push() already spools when a spool is
|
|
17833
|
+
// attached) + the logTrace. The queued-status UX below is presentation,
|
|
17834
|
+
// not routing — it stays imperative on both paths until PR4.
|
|
17835
|
+
dispatchEffects(machineInboundEffects, {
|
|
17836
|
+
selfAgent,
|
|
17837
|
+
ipcServer,
|
|
17838
|
+
pendingInboundBuffer,
|
|
17839
|
+
inboundSpool: inboundSpool ?? null,
|
|
17840
|
+
pendingPermissionBuffer,
|
|
17841
|
+
})
|
|
17842
|
+
process.stderr.write(
|
|
17843
|
+
`telegram gateway: inbound held mid-turn (machine) agent=${selfAgent} ` +
|
|
17844
|
+
`chat=${chat_id} msg=${msgId ?? '-'} — will flush on turn-complete\n`,
|
|
17845
|
+
)
|
|
17846
|
+
const inFlightThreadM = currentTurn?.sessionThreadId
|
|
17847
|
+
const crossTopicQueuedCardM =
|
|
17848
|
+
QUEUED_STATUS_UX_ENABLED &&
|
|
17849
|
+
!isDmChatId(chat_id) &&
|
|
17850
|
+
messageThreadId != null &&
|
|
17851
|
+
messageThreadId !== inFlightThreadM
|
|
17852
|
+
if (crossTopicQueuedCardM) {
|
|
17853
|
+
postQueuedStatus(chat_id, messageThreadId, inFlightThreadM)
|
|
17854
|
+
} else {
|
|
17855
|
+
maybePostBusyAck('buffer-until-idle', chat_id, messageThreadId ?? undefined)
|
|
17856
|
+
}
|
|
17857
|
+
return
|
|
17858
|
+
}
|
|
17859
|
+
|
|
18062
17860
|
// #2917: read the gate LIVE (not the receipt snapshot) on the default path
|
|
18063
17861
|
// so a sibling same-chat inbound delivered during THIS handler's async
|
|
18064
17862
|
// lead-in is observed here. Reading `claudeBusyKeys` live is self-block-safe:
|
|
@@ -18084,7 +17882,10 @@ async function handleInbound(
|
|
|
18084
17882
|
// the freshly-killed bridge.
|
|
18085
17883
|
isInterrupt: interrupt.isInterrupt,
|
|
18086
17884
|
})
|
|
18087
|
-
|
|
17885
|
+
// PR3c: the legacy buffer branch runs ONLY when the machine is not
|
|
17886
|
+
// authoritative (kill switch / carve-out fallback) — the machine buffer
|
|
17887
|
+
// path above already returned for machine-routed buffering.
|
|
17888
|
+
if (!machineAuthoritative && deliveryGate.decision === 'buffer-until-idle') {
|
|
18088
17889
|
pendingInboundBuffer.push(selfAgent, inboundMsg)
|
|
18089
17890
|
process.stderr.write(
|
|
18090
17891
|
`telegram gateway: inbound held mid-turn agent=${selfAgent} ` +
|
|
@@ -18125,8 +17926,12 @@ async function handleInbound(
|
|
|
18125
17926
|
// other's async lead-in and race to the bridge, reordering the replies.
|
|
18126
17927
|
// Only fresh-turn deliveries reserve (steering/interrupt amend a running
|
|
18127
17928
|
// turn and must not). Released below if the send misses (bridge offline).
|
|
17929
|
+
// PR3c: skipped when the machine is authoritative — the inbound event at
|
|
17930
|
+
// DEFERRED_INBOUND_EMIT advanced the machine SYNCHRONOUSLY (idle→in_turn),
|
|
17931
|
+
// so the machine itself is the pre-await reservation; the busy-key mirror
|
|
17932
|
+
// is stamped by the setTurnStarted effect in the dispatch below.
|
|
18128
17933
|
let reservedBusyKey: string | null = null
|
|
18129
|
-
if (deliveryGate.reserve && SERIALIZE_INBOUND_DELIVERY_ENABLED && machineInTurnAtReceipt == null) {
|
|
17934
|
+
if (!machineAuthoritative && deliveryGate.reserve && SERIALIZE_INBOUND_DELIVERY_ENABLED && machineInTurnAtReceipt == null) {
|
|
18130
17935
|
reservedBusyKey = markClaudeBusyForInbound(inboundMsg)
|
|
18131
17936
|
}
|
|
18132
17937
|
|
|
@@ -18157,6 +17962,104 @@ async function handleInbound(
|
|
|
18157
17962
|
}
|
|
18158
17963
|
}
|
|
18159
17964
|
|
|
17965
|
+
// ── PR3c machine deliver path (#2794) ──────────────────────────────────
|
|
17966
|
+
// The machine said deliver (fresh turn: setTurnStarted + deliverToBridge;
|
|
17967
|
+
// steer: deliverToBridge only). dispatchEffects executes the send; the
|
|
17968
|
+
// post-send branches below mirror the imperative twin bit-for-bit:
|
|
17969
|
+
// delivered → steer ack + busy-mark (idempotent, same lifecycle the twin
|
|
17970
|
+
// stamps) + delivery-confirm tracking; miss → release the busy mirror in
|
|
17971
|
+
// lockstep, durable-buffer per the shouldTrackDelivery carve-outs, and
|
|
17972
|
+
// post the restart notice. NOTE the machine already advanced to in_turn
|
|
17973
|
+
// at the emit; a send-miss therefore leaves the machine in_turn until the
|
|
17974
|
+
// TTL tick clears it — identical to today's cutover behavior (the emit
|
|
17975
|
+
// has always preceded the send attempt), not a regression of this flip.
|
|
17976
|
+
if (machineAuthoritative) {
|
|
17977
|
+
let machineDelivered = false
|
|
17978
|
+
// Key stamped by THIS dispatch's setTurnStarted effect (fresh turn only —
|
|
17979
|
+
// a mid-turn steer emits deliverToBridge WITHOUT setTurnStarted). The
|
|
17980
|
+
// miss branch below may only release a key THIS dispatch stamped: on a
|
|
17981
|
+
// steer-miss the chat's busy key belongs to the ORIGINAL in-flight turn
|
|
17982
|
+
// (mirroring the twin, which only ever releases reservedBusyKey and never
|
|
17983
|
+
// reserves for steers). An unconditional delete would wipe the running
|
|
17984
|
+
// turn's key → false machine_over_holds parity drift during the bake +
|
|
17985
|
+
// the pending-restart gate (claudeBusyKeys.size) could green-light a
|
|
17986
|
+
// mid-turn restart.
|
|
17987
|
+
let machineStampedKey: string | null = null
|
|
17988
|
+
dispatchEffects(machineInboundEffects, {
|
|
17989
|
+
selfAgent,
|
|
17990
|
+
ipcServer,
|
|
17991
|
+
pendingInboundBuffer,
|
|
17992
|
+
inboundSpool: inboundSpool ?? null,
|
|
17993
|
+
pendingPermissionBuffer,
|
|
17994
|
+
// Busy-key mirror: same lockstep add markClaudeBusyForInbound does —
|
|
17995
|
+
// keeps the kill-switch fallback + gate-parity-probe + pending-restart
|
|
17996
|
+
// gate (claudeBusyKeys.size) coherent while the twins remain live.
|
|
17997
|
+
onSetTurnStarted: (k, at) => {
|
|
17998
|
+
machineStampedKey = k
|
|
17999
|
+
markBusyKeyLockstep(claudeBusyKeys, claudeBusyKeySince, k, at)
|
|
18000
|
+
},
|
|
18001
|
+
onDeliverResult: (_k, ok) => {
|
|
18002
|
+
machineDelivered = ok
|
|
18003
|
+
},
|
|
18004
|
+
})
|
|
18005
|
+
if (machineDelivered) {
|
|
18006
|
+
if (isSteering) {
|
|
18007
|
+
maybePostBusyAck('steer', chat_id, messageThreadId ?? undefined)
|
|
18008
|
+
}
|
|
18009
|
+
// Mirror the twin: EVERY delivered inbound stamps the busy key
|
|
18010
|
+
// (steer/interrupt included — idempotent lockstep re-stamp), and the
|
|
18011
|
+
// returned key is what the delivery-confirm tracker matches acks on.
|
|
18012
|
+
const busyKey = markClaudeBusyForInbound(inboundMsg)
|
|
18013
|
+
if (
|
|
18014
|
+
DELIVERY_CONFIRM_ENABLED &&
|
|
18015
|
+
shouldTrackDelivery({
|
|
18016
|
+
isSteering,
|
|
18017
|
+
isInterrupt: interrupt.isInterrupt,
|
|
18018
|
+
hasSource: inboundMsg.meta?.source != null,
|
|
18019
|
+
effectiveText,
|
|
18020
|
+
})
|
|
18021
|
+
) {
|
|
18022
|
+
trackDelivery(deliveryQueue, busyKey, inboundMsg, Date.now(), String(inboundMsg.messageId))
|
|
18023
|
+
}
|
|
18024
|
+
} else {
|
|
18025
|
+
// Send missed while the machine read bridge-alive. Release ONLY a busy
|
|
18026
|
+
// mirror THIS dispatch's setTurnStarted stamped (lockstep with the
|
|
18027
|
+
// twin's reservedBusyKey release). A steer-miss stamped nothing — the
|
|
18028
|
+
// chat key belongs to the original in-flight turn and must survive
|
|
18029
|
+
// (see machineStampedKey above). Then buffer per the carve-outs, notify.
|
|
18030
|
+
//
|
|
18031
|
+
// Known behavior delta (documented, accepted for the bake): the
|
|
18032
|
+
// machine already advanced to in_turn at the emit, so until the TTL
|
|
18033
|
+
// tick / bridge-flap clears it, SUBSEQUENT inbounds are machine-
|
|
18034
|
+
// buffered ("queued" busy-ack) instead of the legacy per-message send
|
|
18035
|
+
// attempt + restart notice. See the RFC carve-out row + PR body.
|
|
18036
|
+
if (machineStampedKey != null) {
|
|
18037
|
+
claudeBusyKeys.delete(machineStampedKey)
|
|
18038
|
+
claudeBusyKeySince.delete(machineStampedKey)
|
|
18039
|
+
}
|
|
18040
|
+
if (
|
|
18041
|
+
shouldTrackDelivery({
|
|
18042
|
+
isSteering,
|
|
18043
|
+
isInterrupt: interrupt.isInterrupt,
|
|
18044
|
+
hasSource: inboundMsg.meta?.source != null,
|
|
18045
|
+
effectiveText,
|
|
18046
|
+
})
|
|
18047
|
+
) {
|
|
18048
|
+
pendingInboundBuffer.push(selfAgent, inboundMsg)
|
|
18049
|
+
}
|
|
18050
|
+
const threadOptsM = messageThreadId != null ? { message_thread_id: messageThreadId } : {}
|
|
18051
|
+
void swallowingApiCall(
|
|
18052
|
+
() => bot.api.sendMessage(chat_id, '⏳ Agent is restarting — your message is queued and will be processed when it reconnects.', { ...threadOptsM }),
|
|
18053
|
+
{
|
|
18054
|
+
chat_id,
|
|
18055
|
+
verb: 'agent-restarting-notice',
|
|
18056
|
+
...(messageThreadId != null ? { threadId: messageThreadId } : {}),
|
|
18057
|
+
},
|
|
18058
|
+
)
|
|
18059
|
+
}
|
|
18060
|
+
return
|
|
18061
|
+
}
|
|
18062
|
+
|
|
18160
18063
|
const delivered = ipcServer.sendToAgent(selfAgent, inboundMsg)
|
|
18161
18064
|
if (delivered) {
|
|
18162
18065
|
// #2995 — a steer delivered mid-turn while the turn is stuck inside one
|
|
@@ -22123,2233 +22026,100 @@ function resolveAgentDirForName(agent: string): string | null {
|
|
|
22123
22026
|
return null
|
|
22124
22027
|
}
|
|
22125
22028
|
|
|
22126
|
-
|
|
22127
|
-
|
|
22128
|
-
|
|
22129
|
-
|
|
22130
|
-
|
|
22131
|
-
|
|
22132
|
-
|
|
22133
|
-
|
|
22134
|
-
|
|
22135
|
-
|
|
22136
|
-
|
|
22137
|
-
|
|
22138
|
-
|
|
22139
|
-
|
|
22140
|
-
|
|
22141
|
-
|
|
22142
|
-
|
|
22143
|
-
|
|
22144
|
-
|
|
22145
|
-
|
|
22146
|
-
|
|
22147
|
-
|
|
22148
|
-
|
|
22149
|
-
|
|
22150
|
-
|
|
22151
|
-
|
|
22152
|
-
|
|
22153
|
-
|
|
22154
|
-
|
|
22155
|
-
|
|
22156
|
-
|
|
22157
|
-
|
|
22158
|
-
|
|
22159
|
-
|
|
22160
|
-
|
|
22161
|
-
|
|
22162
|
-
|
|
22163
|
-
|
|
22164
|
-
|
|
22165
|
-
|
|
22166
|
-
|
|
22167
|
-
|
|
22168
|
-
|
|
22169
|
-
|
|
22170
|
-
|
|
22171
|
-
|
|
22172
|
-
|
|
22173
|
-
|
|
22174
|
-
|
|
22175
|
-
|
|
22176
|
-
|
|
22177
|
-
|
|
22178
|
-
|
|
22179
|
-
|
|
22180
|
-
|
|
22181
|
-
|
|
22182
|
-
|
|
22183
|
-
|
|
22184
|
-
|
|
22185
|
-
|
|
22186
|
-
// vrd:<agent>:<key> — parse, validate both halves against the strict
|
|
22187
|
-
// slug regex before doing anything else.
|
|
22188
|
-
const parts = data.split(':')
|
|
22189
|
-
if (parts.length !== 3) {
|
|
22190
|
-
await ctx.answerCallbackQuery({ text: 'Bad request' }).catch(() => {})
|
|
22191
|
-
return
|
|
22192
|
-
}
|
|
22193
|
-
const [, agentName, keyName] = parts
|
|
22194
|
-
if (!/^[a-z][a-z0-9-]{0,62}$/i.test(agentName)) {
|
|
22195
|
-
await ctx.answerCallbackQuery({ text: 'Invalid agent name' }).catch(() => {})
|
|
22196
|
-
return
|
|
22197
|
-
}
|
|
22198
|
-
// #1047: same canonical key shape as vault_request_save /
|
|
22199
|
-
// vault_request_access — namespaced keys like `fatsecret/client_id`
|
|
22200
|
-
// must round-trip through the /vault audit one-tap Allow flow too,
|
|
22201
|
-
// not just the agent-initiated approval cards.
|
|
22202
|
-
if (!VAULT_KEY_REGEX.test(keyName)) {
|
|
22203
|
-
await ctx.answerCallbackQuery({ text: 'Invalid key name' }).catch(() => {})
|
|
22204
|
-
return
|
|
22205
|
-
}
|
|
22206
|
-
await ctx.answerCallbackQuery({ text: '⏳ Minting 30-day read grant…' }).catch(() => {})
|
|
22029
|
+
// ─── Callback-query handler families (#2996 Phase 5, remaining item 2) ─────
|
|
22030
|
+
// Extracted verbatim to callback-query-handlers.ts behind an injected deps
|
|
22031
|
+
// object (see that module's header for what moved and what deliberately
|
|
22032
|
+
// stayed). Destructuring keeps every call site below byte-identical.
|
|
22033
|
+
const callbackQueryHandlers = createCallbackQueryHandlers({
|
|
22034
|
+
bot,
|
|
22035
|
+
lockedBot,
|
|
22036
|
+
loadAccess,
|
|
22037
|
+
escapeHtmlForTg,
|
|
22038
|
+
switchroomReply,
|
|
22039
|
+
resolveThreadId,
|
|
22040
|
+
deliverResumeSyntheticOrBuffer,
|
|
22041
|
+
expireMentalModelProposeCard,
|
|
22042
|
+
readLiveSwitchroomConfigText,
|
|
22043
|
+
mentalModelCorrelationKey,
|
|
22044
|
+
getMyAgentName,
|
|
22045
|
+
triggerSelfRestart,
|
|
22046
|
+
runSwitchroomAuthCommand,
|
|
22047
|
+
switchroomExecJson,
|
|
22048
|
+
assertSafeAgentName,
|
|
22049
|
+
buildDeferredSecretKeyboard,
|
|
22050
|
+
recordDeferredSecretKernelDecision,
|
|
22051
|
+
mintGrantWizardKernelRequest,
|
|
22052
|
+
recordGrantWizardKernelDecision,
|
|
22053
|
+
robustApiCall,
|
|
22054
|
+
swallowingApiCall,
|
|
22055
|
+
pendingVaultRequestAccesses,
|
|
22056
|
+
pendingVaultRequestSaves,
|
|
22057
|
+
pendingMentalModelProposes,
|
|
22058
|
+
pendingCardStore,
|
|
22059
|
+
pendingMentalModelCorrelations,
|
|
22060
|
+
pendingVaultOps,
|
|
22061
|
+
vaultPassphraseCache,
|
|
22062
|
+
deferredSecrets,
|
|
22063
|
+
pendingReauthFlows,
|
|
22064
|
+
secretStaging,
|
|
22065
|
+
lastAuthRefreshAtMs,
|
|
22066
|
+
// Mutable module `let`s — injected as getters so the startup config
|
|
22067
|
+
// assignment (and any later reload) stays observable from the module.
|
|
22068
|
+
getVaultApprovalAuthMode: () => VAULT_APPROVAL_AUTH_MODE,
|
|
22069
|
+
getAdminOnlyKeys: () => ADMIN_ONLY_KEYS,
|
|
22070
|
+
vaultKeyRegex: VAULT_KEY_REGEX,
|
|
22071
|
+
mentalModelProposeTtlMs: MENTAL_MODEL_PROPOSE_TTL_MS,
|
|
22072
|
+
})
|
|
22073
|
+
const {
|
|
22074
|
+
handleVaultRecentDenialCallback,
|
|
22075
|
+
performVaultAccessApproval,
|
|
22076
|
+
handleSkillProposalCallback,
|
|
22077
|
+
handleMentalModelProposeCallback,
|
|
22078
|
+
handleVaultRequestAccessCallback,
|
|
22079
|
+
handleVaultRequestSaveCallback,
|
|
22080
|
+
handleVaultDeferCallback,
|
|
22081
|
+
parseGrantDuration,
|
|
22082
|
+
startGrantWizardStep1,
|
|
22083
|
+
grantWizardConfirm,
|
|
22084
|
+
handleVaultGrantCallback,
|
|
22085
|
+
executeDeferredSecretSave,
|
|
22086
|
+
handleOperatorEventCallback,
|
|
22087
|
+
handleAuthDashboardCallback,
|
|
22088
|
+
} = callbackQueryHandlers
|
|
22207
22089
|
|
|
22208
|
-
|
|
22209
|
-
|
|
22210
|
-
|
|
22211
|
-
|
|
22212
|
-
|
|
22213
|
-
|
|
22090
|
+
// /reauth was removed in v0.6.13 — the `/auth` dashboard's
|
|
22091
|
+
// `🔄 Reauth default` button fires the same flow (the `case 'reauth':`
|
|
22092
|
+
// callback dispatch calls `runSwitchroomAuthCommand` and seeds
|
|
22093
|
+
// `pendingReauthFlows`). The OAuth code paste-back is caught by the
|
|
22094
|
+
// generic message intercept that watches `pendingReauthFlows` —
|
|
22095
|
+
// pasting the code into chat now Just Works without a typed entry
|
|
22096
|
+
// point. Removed surfaces in this PR:
|
|
22097
|
+
// - bot.command('reauth', ...) → use /auth → 🔄 Reauth
|
|
22098
|
+
// - /reauth <code|url> paste-back → paste into chat
|
|
22099
|
+
// - /reauth <other-agent> targeting → use that agent's /auth
|
|
22214
22100
|
|
|
22215
|
-
|
|
22216
|
-
|
|
22217
|
-
|
|
22218
|
-
|
|
22219
|
-
|
|
22220
|
-
|
|
22221
|
-
|
|
22222
|
-
|
|
22223
|
-
|
|
22224
|
-
|
|
22225
|
-
|
|
22226
|
-
|
|
22227
|
-
|
|
22228
|
-
|
|
22229
|
-
|
|
22230
|
-
|
|
22231
|
-
|
|
22232
|
-
|
|
22233
|
-
|
|
22234
|
-
|
|
22235
|
-
|
|
22236
|
-
|
|
22237
|
-
`--keys ${escapeHtmlForTg(keyName)} --duration 30d\` on the host._`,
|
|
22238
|
-
{ html: true },
|
|
22239
|
-
)
|
|
22240
|
-
return
|
|
22241
|
-
}
|
|
22242
|
-
// #1150 audit: P0 fix — pre-fix the audit-listing message kept its
|
|
22243
|
-
// tappable [Always allow ...] buttons after a successful mint, so
|
|
22244
|
-
// the operator could re-tap the same denial and re-mint the grant
|
|
22245
|
-
// (broker idempotency saves us from a duplicate write but the
|
|
22246
|
-
// operator experience was "did anything happen? let me tap again").
|
|
22247
|
-
// Strip the entire audit-listing keyboard on first tap + append a
|
|
22248
|
-
// status line so the action is visible. Operator re-runs `/vault
|
|
22249
|
-
// audit` to act on remaining denials — that's the documented flow.
|
|
22250
|
-
// HTML-escape the source text before concatenation. `ctx.callbackQuery.
|
|
22251
|
-
// message.text` returns the body with entities STRIPPED (Telegram
|
|
22252
|
-
// decodes the original HTML), so any raw `<`, `>`, `&` in agent/key
|
|
22253
|
-
// names that survived the original audit-listing's escape pass would
|
|
22254
|
-
// now break the HTML re-parse — and finalizeCallback's catch swallows
|
|
22255
|
-
// the failure, leaving the keyboard tappable. Caught in PR #1158 review
|
|
22256
|
-
// for the operator-event card; the same fix applies here.
|
|
22257
|
-
const sourceMsg = ctx.callbackQuery?.message
|
|
22258
|
-
const baseText = sourceMsg && 'text' in sourceMsg && sourceMsg.text
|
|
22259
|
-
? escapeHtmlForTg(sourceMsg.text)
|
|
22260
|
-
: ''
|
|
22261
|
-
const statusLine =
|
|
22262
|
-
`\n\n✅ **${escapeHtmlForTg(agentName)}** granted read access to ` +
|
|
22263
|
-
`\`${keyName}\` for 30 days ` +
|
|
22264
|
-
`(grant \`${id}\`). ` +
|
|
22265
|
-
`Re-run /vault audit to act on remaining denials.`
|
|
22266
|
-
await finalizeCallback(ctx, {
|
|
22267
|
-
ackText: '✅ Grant minted',
|
|
22268
|
-
newText: baseText ? `${baseText}${statusLine}` : statusLine,
|
|
22269
|
-
// No synthInbound — operator-only flow. The granted agent picks
|
|
22270
|
-
// up the token via .vault-token file on next CLI invocation; no
|
|
22271
|
-
// turn-wake needed.
|
|
22272
|
-
})
|
|
22273
|
-
}
|
|
22274
|
-
|
|
22275
|
-
/**
|
|
22276
|
-
* Issue #1012 — handle a tap on the vault_request_access approval card.
|
|
22277
|
-
* vra:approve:<stageId> — mint a scoped grant token via the broker,
|
|
22278
|
-
* write the token to the agent's
|
|
22279
|
-
* `.vault-token` file, edit card to success.
|
|
22280
|
-
* vra:deny:<stageId> — drop the staged request, edit card to denied.
|
|
22281
|
-
*
|
|
22282
|
-
* Same authorization gate as the recent-denials one-tap handler:
|
|
22283
|
-
* sender must be on the gateway's allowFrom list.
|
|
22284
|
-
*/
|
|
22285
|
-
/**
|
|
22286
|
-
* Mint the scoped grant + write the token file for an approved
|
|
22287
|
-
* `vault_request_access` request. Factored out so both the direct
|
|
22288
|
-
* approve-tap (passphrase already cached) and the
|
|
22289
|
-
* `passphrase-for-access-approve` resume flow (passphrase captured
|
|
22290
|
-
* via text-message intercept after tap-on-locked) drive identical
|
|
22291
|
-
* minting behaviour. #1012 Phase 2 + follow-up.
|
|
22292
|
-
*/
|
|
22293
|
-
/**
|
|
22294
|
-
* #1115 follow-up: caller-supplied attestation. Either a real operator
|
|
22295
|
-
* passphrase (when the operator typed it in chat) or a posture flag
|
|
22296
|
-
* that tells the broker to use its own retained passphrase under
|
|
22297
|
-
* `vault.broker.approvalAuth: telegram-id`. The passphrase variant
|
|
22298
|
-
* never crosses into telegram-id callsites; the posture variant
|
|
22299
|
-
* never crosses into passphrase-mode callsites.
|
|
22300
|
-
*/
|
|
22301
|
-
type AccessApprovalAttestation =
|
|
22302
|
-
| { kind: 'passphrase'; passphrase: string }
|
|
22303
|
-
| { kind: 'posture' }
|
|
22304
|
-
|
|
22305
|
-
async function performVaultAccessApproval(
|
|
22306
|
-
ctx: Context,
|
|
22307
|
-
pending: PendingVaultRequestAccess,
|
|
22308
|
-
stageId: string,
|
|
22309
|
-
senderId: string,
|
|
22310
|
-
attestation: AccessApprovalAttestation,
|
|
22311
|
-
): Promise<void> {
|
|
22312
|
-
const brokerAuthOpts =
|
|
22313
|
-
attestation.kind === 'passphrase'
|
|
22314
|
-
? { passphrase: attestation.passphrase }
|
|
22315
|
-
: { attest_via_posture: true as const }
|
|
22316
|
-
|
|
22317
|
-
// Fix B (#1487 follow-up), operator-tap guard. Defense-in-depth for a
|
|
22318
|
-
// card staged before the key became standing-ACL-covered (config edit
|
|
22319
|
-
// / #1487 deploy / drift): if the agent's standing ACL ALREADY covers
|
|
22320
|
-
// this read key, do NOT mint — minting writes a `.vault-token` that
|
|
22321
|
-
// shadows the standing ACL and is redundant. Authoritative broker
|
|
22322
|
-
// probe AS THIS AGENT (no-token list over the per-agent socket — same
|
|
22323
|
-
// rationale as executeVaultRequestAccess; never a gateway-side config
|
|
22324
|
-
// read). Read scope only. Fail-open on probe error (mint as before).
|
|
22325
|
-
if (pending.scope === 'read') {
|
|
22326
|
-
try {
|
|
22327
|
-
const visible = await listViaBroker()
|
|
22328
|
-
if (visible !== null && visible.includes(pending.key)) {
|
|
22329
|
-
pendingVaultRequestAccesses.delete(stageId)
|
|
22330
|
-
pendingCardStore.remove(stageId)
|
|
22331
|
-
if (pending.card_message_id != null) {
|
|
22332
|
-
await ctx.api
|
|
22333
|
-
.editMessageText(
|
|
22334
|
-
pending.chat_id,
|
|
22335
|
-
pending.card_message_id,
|
|
22336
|
-
`ℹ️ **${escapeHtmlForTg(pending.agent)}** already has standing-ACL access to ` +
|
|
22337
|
-
`\`${pending.key}\` (schedule.secrets[]). ` +
|
|
22338
|
-
`**No grant minted** — a token would shadow the standing ACL. ` +
|
|
22339
|
-
richMessage(`The agent can read it directly.`),
|
|
22340
|
-
{ reply_markup: { inline_keyboard: [] } },
|
|
22341
|
-
)
|
|
22342
|
-
.catch(() => {})
|
|
22343
|
-
}
|
|
22344
|
-
return
|
|
22345
|
-
}
|
|
22346
|
-
} catch {
|
|
22347
|
-
// Probe failed: fall through and mint as before (fail-open).
|
|
22348
|
-
}
|
|
22349
|
-
}
|
|
22350
|
-
|
|
22351
|
-
// #1051: union the new key with the agent's existing active grant
|
|
22352
|
-
// before minting. Without this, each fresh Approve OVERWRITES the
|
|
22353
|
-
// agent's `.vault-token` file with a single-key grant — the
|
|
22354
|
-
// previous approval's grant is still in the broker DB but the
|
|
22355
|
-
// agent can no longer authenticate against it (the CLI reads the
|
|
22356
|
-
// file's current token, the broker validates it, sees the new key
|
|
22357
|
-
// isn't in the OLD grant's key_allow, and denies).
|
|
22358
|
-
//
|
|
22359
|
-
// Solution: list the agent's existing non-expired grants
|
|
22360
|
-
// (passphrase-attested per #1051's broker-side gate widening),
|
|
22361
|
-
// find the active read-grant (most recent non-revoked,
|
|
22362
|
-
// non-expired), and pass its keys ∪ new_key as `keys` to the
|
|
22363
|
-
// mint call. Old grant ages out via TTL — no explicit revoke
|
|
22364
|
-
// needed.
|
|
22365
|
-
let existingReadKeys: string[] = [];
|
|
22366
|
-
let existingWriteKeys: string[] = [];
|
|
22367
|
-
if (pending.scope === 'read' || pending.scope === 'write') {
|
|
22368
|
-
const list = await listGrantsViaBroker(pending.agent, brokerAuthOpts);
|
|
22369
|
-
if (list.kind === 'ok') {
|
|
22370
|
-
const now = Math.floor(Date.now() / 1000);
|
|
22371
|
-
// Prefer the MOST RECENT non-revoked, non-expired grant. The
|
|
22372
|
-
// broker's listGrants returns ALL non-revoked, but we still
|
|
22373
|
-
// filter expires_at locally as defence-in-depth + sort by
|
|
22374
|
-
// created_at desc for stability.
|
|
22375
|
-
const active = list.grants
|
|
22376
|
-
.filter((g) => g.expires_at === null || g.expires_at > now)
|
|
22377
|
-
// Reviewer-flagged on #1058 (Q4): `created_at` is
|
|
22378
|
-
// seconds-granularity, so two grants minted in the same
|
|
22379
|
-
// wall-clock second tie. Secondary sort by `id` (vg_<6hex>)
|
|
22380
|
-
// makes the ordering stable so item 2's drain reliably picks
|
|
22381
|
-
// up item 1's just-minted grant rather than an unrelated
|
|
22382
|
-
// same-second grant.
|
|
22383
|
-
.sort((a, b) => {
|
|
22384
|
-
const dt = (b.created_at ?? 0) - (a.created_at ?? 0);
|
|
22385
|
-
if (dt !== 0) return dt;
|
|
22386
|
-
return b.id.localeCompare(a.id);
|
|
22387
|
-
});
|
|
22388
|
-
if (active.length > 0) {
|
|
22389
|
-
existingReadKeys = active[0]!.key_allow ?? [];
|
|
22390
|
-
existingWriteKeys = active[0]!.write_allow ?? [];
|
|
22391
|
-
}
|
|
22392
|
-
}
|
|
22393
|
-
// If list fails (broker unreachable / error), proceed without
|
|
22394
|
-
// union — better to mint a single-key grant than fail closed
|
|
22395
|
-
// entirely. The agent loses the prior coverage in that edge
|
|
22396
|
-
// case, same as today, but the new key is granted.
|
|
22397
|
-
}
|
|
22398
|
-
|
|
22399
|
-
// Compute the unioned key sets. Use Set to dedupe.
|
|
22400
|
-
const readKeys = new Set<string>(existingReadKeys);
|
|
22401
|
-
const writeKeys = new Set<string>(existingWriteKeys);
|
|
22402
|
-
if (pending.scope === 'read') readKeys.add(pending.key);
|
|
22403
|
-
if (pending.scope === 'write') writeKeys.add(pending.key);
|
|
22404
|
-
|
|
22405
|
-
const mintArgs: Parameters<typeof mintGrantViaBroker>[0] = {
|
|
22406
|
-
agent: pending.agent,
|
|
22407
|
-
keys: Array.from(readKeys),
|
|
22408
|
-
ttl_seconds: pending.ttl_seconds,
|
|
22409
|
-
description:
|
|
22410
|
-
`auto-mint via vault_request_access (#1012, scope=${pending.scope}, by op ${senderId}` +
|
|
22411
|
-
(existingReadKeys.length + existingWriteKeys.length > 0
|
|
22412
|
-
? `, unioned with prior grant`
|
|
22413
|
-
: ``) +
|
|
22414
|
-
`)`,
|
|
22415
|
-
...(writeKeys.size > 0 ? { write_keys: Array.from(writeKeys) } : {}),
|
|
22416
|
-
...brokerAuthOpts,
|
|
22417
|
-
}
|
|
22418
|
-
const result = await mintGrantViaBroker(mintArgs)
|
|
22419
|
-
if (result.kind === 'unreachable') {
|
|
22420
|
-
await switchroomReply(ctx, `🔴 Broker unreachable: ${escapeHtmlForTg(result.msg)}`, { html: true })
|
|
22421
|
-
return
|
|
22422
|
-
}
|
|
22423
|
-
if (result.kind === 'error') {
|
|
22424
|
-
// Mint refused (most likely wrong passphrase). Drop the staged
|
|
22425
|
-
// request so a re-attempt starts cleanly. The operator can ask
|
|
22426
|
-
// the agent to re-issue, or the broker error message will tell
|
|
22427
|
-
// them the next step.
|
|
22428
|
-
pendingVaultRequestAccesses.delete(stageId)
|
|
22429
|
-
pendingCardStore.remove(stageId)
|
|
22430
|
-
if (pending.card_message_id != null) {
|
|
22431
|
-
await ctx.api
|
|
22432
|
-
.editMessageText(
|
|
22433
|
-
pending.chat_id,
|
|
22434
|
-
pending.card_message_id,
|
|
22435
|
-
richMessage(`**mint_grant failed:** ${escapeHtmlForTg(result.msg)}`),
|
|
22436
|
-
{ reply_markup: { inline_keyboard: [] } },
|
|
22437
|
-
)
|
|
22438
|
-
.catch(() => {})
|
|
22439
|
-
}
|
|
22440
|
-
return
|
|
22441
|
-
}
|
|
22442
|
-
|
|
22443
|
-
const { token, id } = result
|
|
22444
|
-
const tokenPath = join(homedir(), '.switchroom', 'agents', pending.agent, '.vault-token')
|
|
22445
|
-
try {
|
|
22446
|
-
mkdirSync(join(homedir(), '.switchroom', 'agents', pending.agent), { recursive: true })
|
|
22447
|
-
writeFileSync(tokenPath, token, { mode: 0o600 })
|
|
22448
|
-
} catch (err) {
|
|
22449
|
-
await switchroomReply(
|
|
22450
|
-
ctx,
|
|
22451
|
-
`**Grant created (${escapeHtmlForTg(id)}) but token write failed:** ` +
|
|
22452
|
-
`${escapeHtmlForTg(String(err))}\n` +
|
|
22453
|
-
`_Recover with: \`switchroom vault grant ${escapeHtmlForTg(pending.agent)} ` +
|
|
22454
|
-
`--keys ${escapeHtmlForTg(pending.key)} --duration ${Math.round(pending.ttl_seconds / 86400)}d\` on the host._`,
|
|
22455
|
-
{ html: true },
|
|
22456
|
-
)
|
|
22457
|
-
return
|
|
22458
|
-
}
|
|
22459
|
-
|
|
22460
|
-
pendingVaultRequestAccesses.delete(stageId)
|
|
22461
|
-
pendingCardStore.remove(stageId)
|
|
22462
|
-
if (pending.card_message_id != null) {
|
|
22463
|
-
const days = Math.round(pending.ttl_seconds / 86400)
|
|
22464
|
-
const footer =
|
|
22465
|
-
VAULT_APPROVAL_AUTH_MODE === 'telegram-id'
|
|
22466
|
-
? `\n_Approver verified by Telegram identity — broker auto-unlocked at startup._`
|
|
22467
|
-
: ''
|
|
22468
|
-
await ctx.api
|
|
22469
|
-
.editMessageText(
|
|
22470
|
-
pending.chat_id,
|
|
22471
|
-
pending.card_message_id,
|
|
22472
|
-
richMessage(
|
|
22473
|
-
buildVaultGrantApprovedCardText({
|
|
22474
|
-
agentEscaped: escapeHtmlForTg(pending.agent),
|
|
22475
|
-
scope: pending.scope,
|
|
22476
|
-
key: pending.key,
|
|
22477
|
-
days,
|
|
22478
|
-
grantId: id,
|
|
22479
|
-
footer,
|
|
22480
|
-
}),
|
|
22481
|
-
),
|
|
22482
|
-
{ reply_markup: { inline_keyboard: [] } },
|
|
22483
|
-
)
|
|
22484
|
-
.catch(() => {})
|
|
22485
|
-
}
|
|
22486
|
-
|
|
22487
|
-
// #1052: deliver a synthetic inbound message back to the agent so
|
|
22488
|
-
// the task that fired vault_request_access auto-resumes — without
|
|
22489
|
-
// this, the agent's turn ended after the tool call ("waiting for
|
|
22490
|
-
// approval") and the operator has to send a fresh message to kick
|
|
22491
|
-
// it back into action.
|
|
22492
|
-
//
|
|
22493
|
-
// Uses the existing inject_inbound primitive (cron's pattern from
|
|
22494
|
-
// dispatch.ts:180-206). The bridge sees a normal channel event,
|
|
22495
|
-
// renders it as `<channel source="vault_grant_approved">`, and the
|
|
22496
|
-
// agent starts a new turn with the context that the operator just
|
|
22497
|
-
// approved.
|
|
22498
|
-
//
|
|
22499
|
-
// The synthetic message text is concise + actionable so the agent
|
|
22500
|
-
// knows (a) which key was approved, (b) at what scope, (c) what to
|
|
22501
|
-
// do next. Meta carries the structured fields for forensics + for
|
|
22502
|
-
// future filters that want to suppress these in the chat tail.
|
|
22503
|
-
const synthetic = buildVaultGrantApprovedInbound({
|
|
22504
|
-
ctx: {
|
|
22505
|
-
agent: pending.agent,
|
|
22506
|
-
key: pending.key,
|
|
22507
|
-
scope: pending.scope,
|
|
22508
|
-
chat_id: pending.chat_id,
|
|
22509
|
-
ttl_seconds: pending.ttl_seconds,
|
|
22510
|
-
...(pending.threadId != null ? { threadId: pending.threadId } : {}),
|
|
22511
|
-
},
|
|
22512
|
-
grantId: id,
|
|
22513
|
-
stageId,
|
|
22514
|
-
operatorId: senderId,
|
|
22515
|
-
})
|
|
22516
|
-
// Turn-gated via deliverResumeSyntheticOrBuffer: mid-turn → buffer
|
|
22517
|
-
// (flushed at turn-end) so the resume never strands in claude's
|
|
22518
|
-
// composer (#1556); idle → deliver; bridge-down → buffer (#1150).
|
|
22519
|
-
const delivered = deliverResumeSyntheticOrBuffer(pending.agent, synthetic)
|
|
22520
|
-
process.stderr.write(
|
|
22521
|
-
`telegram gateway: vault_grant_approved injection agent=${pending.agent} ` +
|
|
22522
|
-
`key=${pending.key} stage=${stageId} delivered=${delivered}\n`,
|
|
22523
|
-
)
|
|
22524
|
-
}
|
|
22525
|
-
|
|
22526
|
-
/**
|
|
22527
|
-
* #2670 — handle an Approve / Dismiss tap on a one-tap skill-improvement
|
|
22528
|
-
* proposal card.
|
|
22529
|
-
*
|
|
22530
|
-
* Approve → mark the proposal approved + inject a synthetic
|
|
22531
|
-
* `skill_proposal_apply` turn instructing the live agent to
|
|
22532
|
-
* write the stored draft through `skill_*_personal` (so the
|
|
22533
|
-
* secret-scan pipeline runs; agent never self-applies).
|
|
22534
|
-
* Dismiss → mark rejected + write a rejection fingerprint so the weekly
|
|
22535
|
-
* synthesis cron doesn't re-propose it.
|
|
22536
|
-
*/
|
|
22537
|
-
async function handleSkillProposalCallback(ctx: Context, data: string): Promise<void> {
|
|
22538
|
-
const senderId = String(ctx.from?.id ?? '')
|
|
22539
|
-
const access = loadAccess()
|
|
22540
|
-
if (!access.allowFrom.includes(senderId)) {
|
|
22541
|
-
await ctx.answerCallbackQuery({ text: 'Not authorized.' }).catch(() => {})
|
|
22542
|
-
return
|
|
22543
|
-
}
|
|
22544
|
-
const parsed = parseSkillProposalCallback(data)
|
|
22545
|
-
if (parsed == null) {
|
|
22546
|
-
await ctx.answerCallbackQuery({ text: 'Bad request' }).catch(() => {})
|
|
22547
|
-
return
|
|
22548
|
-
}
|
|
22549
|
-
const stateDir = process.env.TELEGRAM_STATE_DIR
|
|
22550
|
-
const agent = process.env.SWITCHROOM_AGENT_NAME ?? ''
|
|
22551
|
-
if (stateDir == null || stateDir.length === 0) {
|
|
22552
|
-
await ctx.answerCallbackQuery({ text: 'State dir unset — cannot apply.' }).catch(() => {})
|
|
22553
|
-
return
|
|
22554
|
-
}
|
|
22555
|
-
const proposal = getSkillProposal(stateDir, parsed.id)
|
|
22556
|
-
if (proposal == null) {
|
|
22557
|
-
await ctx.answerCallbackQuery({ text: 'Proposal expired or already actioned.' }).catch(() => {})
|
|
22558
|
-
if (ctx.callbackQuery?.message) {
|
|
22559
|
-
await ctx.editMessageReplyMarkup({ reply_markup: { inline_keyboard: [] } }).catch(() => {})
|
|
22560
|
-
}
|
|
22561
|
-
return
|
|
22562
|
-
}
|
|
22563
|
-
if (proposal.status !== 'pending') {
|
|
22564
|
-
await ctx.answerCallbackQuery({ text: `Already ${proposal.status}.` }).catch(() => {})
|
|
22565
|
-
if (ctx.callbackQuery?.message) {
|
|
22566
|
-
await ctx.editMessageReplyMarkup({ reply_markup: { inline_keyboard: [] } }).catch(() => {})
|
|
22567
|
-
}
|
|
22568
|
-
return
|
|
22569
|
-
}
|
|
22570
|
-
|
|
22571
|
-
const cbChatId = String(ctx.chat?.id ?? ctx.from?.id ?? '')
|
|
22572
|
-
const cbThreadId = resolveThreadId(cbChatId, ctx.callbackQuery?.message?.message_thread_id)
|
|
22573
|
-
|
|
22574
|
-
if (parsed.action === 'deny') {
|
|
22575
|
-
setSkillProposalStatus(stateDir, parsed.id, 'rejected')
|
|
22576
|
-
await ctx.answerCallbackQuery({ text: '🚫 Dismissed — won’t be proposed again.' }).catch(() => {})
|
|
22577
|
-
if (ctx.callbackQuery?.message && 'text' in ctx.callbackQuery.message) {
|
|
22578
|
-
await ctx
|
|
22579
|
-
.editMessageText(
|
|
22580
|
-
`${escapeHtmlForTg(ctx.callbackQuery.message.text ?? '')}\n\n🚫 <i>Dismissed.</i>`,
|
|
22581
|
-
{ parse_mode: 'HTML', reply_markup: { inline_keyboard: [] } },
|
|
22582
|
-
)
|
|
22583
|
-
.catch(() => {})
|
|
22584
|
-
}
|
|
22585
|
-
return
|
|
22586
|
-
}
|
|
22587
|
-
|
|
22588
|
-
// Approve.
|
|
22589
|
-
setSkillProposalStatus(stateDir, parsed.id, 'approved')
|
|
22590
|
-
const synthetic = buildSkillProposalApplyInbound({
|
|
22591
|
-
ctx: {
|
|
22592
|
-
agent,
|
|
22593
|
-
chat_id: cbChatId,
|
|
22594
|
-
...(cbThreadId != null ? { threadId: cbThreadId } : {}),
|
|
22595
|
-
},
|
|
22596
|
-
proposalId: proposal.id,
|
|
22597
|
-
skillSlug: proposal.skill_slug,
|
|
22598
|
-
isNew: proposal.is_new,
|
|
22599
|
-
operatorId: senderId,
|
|
22600
|
-
})
|
|
22601
|
-
const delivered = deliverResumeSyntheticOrBuffer(agent, synthetic)
|
|
22602
|
-
await ctx.answerCallbackQuery({ text: '✅ Applying the skill…' }).catch(() => {})
|
|
22603
|
-
if (ctx.callbackQuery?.message && 'text' in ctx.callbackQuery.message) {
|
|
22604
|
-
await ctx
|
|
22605
|
-
.editMessageText(
|
|
22606
|
-
`${escapeHtmlForTg(ctx.callbackQuery.message.text ?? '')}\n\n✅ <i>Approved — applying.</i>`,
|
|
22607
|
-
{ parse_mode: 'HTML', reply_markup: { inline_keyboard: [] } },
|
|
22608
|
-
)
|
|
22609
|
-
.catch(() => {})
|
|
22610
|
-
}
|
|
22611
|
-
process.stderr.write(
|
|
22612
|
-
`telegram gateway: skill_proposal_apply injection agent=${agent} ` +
|
|
22613
|
-
`proposal=${proposal.id} slug=${proposal.skill_slug} delivered=${delivered}\n`,
|
|
22614
|
-
)
|
|
22615
|
-
}
|
|
22616
|
-
|
|
22617
|
-
/**
|
|
22618
|
-
* hindsight Phase 5 — handle a tap on the mental-model PROPOSAL card.
|
|
22619
|
-
* mmp:approve:<stageId> — declare the model: append it to the agent's
|
|
22620
|
-
* memory.mental_models[] via the operator-approved
|
|
22621
|
-
* config-edit path (reused config_propose_edit
|
|
22622
|
-
* apply+reconcile; reconcile ensures it), then wake
|
|
22623
|
-
* the agent with an "applied" inbound.
|
|
22624
|
-
* mmp:deny:<stageId> — drop the proposal; NOTHING is written; wake the
|
|
22625
|
-
* agent with a "denied" inbound.
|
|
22626
|
-
*
|
|
22627
|
-
* Authorization: the tapper MUST be on the gateway's allowFrom list — an agent
|
|
22628
|
-
* can PROPOSE but can never self-approve (identical gate to the vault flow).
|
|
22629
|
-
*/
|
|
22630
|
-
async function handleMentalModelProposeCallback(ctx: Context, data: string): Promise<void> {
|
|
22631
|
-
const senderId = String(ctx.from?.id ?? '')
|
|
22632
|
-
const access = loadAccess()
|
|
22633
|
-
if (!access.allowFrom.includes(senderId)) {
|
|
22634
|
-
// Self-approve is impossible: only an allow-listed operator can resolve
|
|
22635
|
-
// the card. A tap from anyone else (incl. a compromised agent identity) is
|
|
22636
|
-
// refused here.
|
|
22637
|
-
await ctx.answerCallbackQuery({ text: 'Not authorized.' }).catch(() => {})
|
|
22638
|
-
return
|
|
22639
|
-
}
|
|
22640
|
-
const parts = data.split(':')
|
|
22641
|
-
if (parts.length < 3) {
|
|
22642
|
-
await ctx.answerCallbackQuery({ text: 'Bad request' }).catch(() => {})
|
|
22643
|
-
return
|
|
22644
|
-
}
|
|
22645
|
-
const action = parts[1]
|
|
22646
|
-
const stageId = parts.slice(2).join(':')
|
|
22647
|
-
const pending = pendingMentalModelProposes.get(stageId)
|
|
22648
|
-
if (!pending) {
|
|
22649
|
-
await ctx.answerCallbackQuery({ text: 'Card expired — ask the agent to re-propose.' }).catch(() => {})
|
|
22650
|
-
if (ctx.callbackQuery?.message) {
|
|
22651
|
-
await ctx.api
|
|
22652
|
-
.editMessageText(
|
|
22653
|
-
ctx.callbackQuery.message.chat.id,
|
|
22654
|
-
ctx.callbackQuery.message.message_id,
|
|
22655
|
-
richMessage('⌛ _This mental-model proposal card expired before you tapped. Ask the agent to re-propose if it still stands._'),
|
|
22656
|
-
{ reply_markup: { inline_keyboard: [] } },
|
|
22657
|
-
)
|
|
22658
|
-
.catch(() => {})
|
|
22659
|
-
}
|
|
22660
|
-
return
|
|
22661
|
-
}
|
|
22662
|
-
if (action !== 'approve' && action !== 'deny') {
|
|
22663
|
-
await ctx.answerCallbackQuery({ text: 'Bad request' }).catch(() => {})
|
|
22664
|
-
return
|
|
22665
|
-
}
|
|
22666
|
-
// Enforce the TTL at TAP time, not just on the next propose's sweep. Without
|
|
22667
|
-
// this, a card left untapped past its TTL is still resolvable if no fresh
|
|
22668
|
-
// proposal has run the sweep — an operator could approve a stale proposal.
|
|
22669
|
-
if (Date.now() - pending.staged_at > MENTAL_MODEL_PROPOSE_TTL_MS) {
|
|
22670
|
-
// Expired between post and tap: route through the shared expiry path so the
|
|
22671
|
-
// parked agent is WOKEN (timeout synthetic + missed-approvals re-offer) and
|
|
22672
|
-
// the durable store entry is cleared — not just a silent map delete.
|
|
22673
|
-
expireMentalModelProposeCard(stageId, pending, Date.now())
|
|
22674
|
-
await ctx.answerCallbackQuery({ text: 'Card expired — the agent was notified.' }).catch(() => {})
|
|
22675
|
-
return
|
|
22676
|
-
}
|
|
22677
|
-
// Single-shot: remove the pending entry immediately so a double-tap can't
|
|
22678
|
-
// resolve twice.
|
|
22679
|
-
pendingMentalModelProposes.delete(stageId)
|
|
22680
|
-
pendingCardStore.remove(stageId)
|
|
22681
|
-
|
|
22682
|
-
const proposal: MentalModelPendingProposal = {
|
|
22683
|
-
agent: pending.agent,
|
|
22684
|
-
chat_id: pending.chat_id,
|
|
22685
|
-
...(pending.threadId != null ? { threadId: pending.threadId } : {}),
|
|
22686
|
-
spec: pending.spec,
|
|
22687
|
-
...(pending.reason ? { reason: pending.reason } : {}),
|
|
22688
|
-
}
|
|
22689
|
-
|
|
22690
|
-
const resolveDeps = {
|
|
22691
|
-
readConfigText: () => readLiveSwitchroomConfigText(),
|
|
22692
|
-
registerPreApproval: (agent: string, diff: string) => {
|
|
22693
|
-
pendingMentalModelCorrelations.set(mentalModelCorrelationKey(agent, diff), {
|
|
22694
|
-
agentName: agent,
|
|
22695
|
-
unifiedDiff: diff,
|
|
22696
|
-
createdAt: Date.now(),
|
|
22697
|
-
})
|
|
22698
|
-
},
|
|
22699
|
-
clearPreApproval: (agent: string, diff: string) => {
|
|
22700
|
-
pendingMentalModelCorrelations.delete(mentalModelCorrelationKey(agent, diff))
|
|
22701
|
-
},
|
|
22702
|
-
dispatchConfigEdit: async (a: { agent: string; diff: string; reason: string }) => {
|
|
22703
|
-
const req: HostdRequest = {
|
|
22704
|
-
v: 1,
|
|
22705
|
-
op: 'config_propose_edit',
|
|
22706
|
-
request_id: hostdRequestId('gw-mental-model'),
|
|
22707
|
-
args: {
|
|
22708
|
-
unified_diff: a.diff,
|
|
22709
|
-
reason: a.reason,
|
|
22710
|
-
target_path: '/state/config/switchroom.yaml',
|
|
22711
|
-
},
|
|
22712
|
-
}
|
|
22713
|
-
// config_propose_edit blocks on validate→approve→apply→reconcile
|
|
22714
|
-
// (5-10 min on a busy host) — allow 12 min. The operator already
|
|
22715
|
-
// approved on the proposal card, so hostd's config-approval callback
|
|
22716
|
-
// auto-resolves via the pre-registered correlation (no second card).
|
|
22717
|
-
const resp = await tryHostdDispatch(a.agent, req, 720_000)
|
|
22718
|
-
if (resp === 'not-configured') {
|
|
22719
|
-
return { state: 'error' as const, reason: 'hostd config-edit is not configured (host_control disabled or socket absent)' }
|
|
22720
|
-
}
|
|
22721
|
-
if (resp.result === 'completed') return { state: 'applied' as const }
|
|
22722
|
-
if (resp.result === 'denied') return { state: 'denied' as const, reason: resp.error ?? 'operator/host denied the edit' }
|
|
22723
|
-
return { state: 'error' as const, reason: resp.error ?? `hostd returned '${resp.result}'` }
|
|
22724
|
-
},
|
|
22725
|
-
// Ensure is delegated to reconcile: config_propose_edit's apply triggers a
|
|
22726
|
-
// reconcile which runs ensureDeclaredMentalModels (#2874) for the newly
|
|
22727
|
-
// declared model — the authoritative, correctly-scoped ensure. We
|
|
22728
|
-
// deliberately do NOT add a redundant gateway-side ensure (it would need
|
|
22729
|
-
// the agent's bank id + a reachable Hindsight endpoint from the gateway).
|
|
22730
|
-
injectInbound: (inbound: InboundMessage) => {
|
|
22731
|
-
deliverResumeSyntheticOrBuffer(pending.agent, inbound)
|
|
22732
|
-
},
|
|
22733
|
-
log: (m: string) => process.stderr.write(`telegram gateway: ${m}\n`),
|
|
22734
|
-
}
|
|
22735
|
-
|
|
22736
|
-
if (action === 'deny') {
|
|
22737
|
-
await ctx.answerCallbackQuery({ text: '🚫 Denied' }).catch(() => {})
|
|
22738
|
-
await resolveMentalModelProposal('deny', proposal, stageId, senderId, resolveDeps)
|
|
22739
|
-
if (pending.card_message_id != null) {
|
|
22740
|
-
await ctx.api
|
|
22741
|
-
.editMessageText(
|
|
22742
|
-
pending.chat_id,
|
|
22743
|
-
pending.card_message_id,
|
|
22744
|
-
richMessage(`🚫 _Denied. **${escapeHtmlForTg(pending.agent)}**'s mental model \`${pending.spec.name}\` was not declared._`),
|
|
22745
|
-
{ reply_markup: { inline_keyboard: [] } },
|
|
22746
|
-
)
|
|
22747
|
-
.catch(() => {})
|
|
22748
|
-
}
|
|
22749
|
-
return
|
|
22750
|
-
}
|
|
22751
|
-
|
|
22752
|
-
// Approve. Ack immediately + show an interim state, then persist in the
|
|
22753
|
-
// background (config_propose_edit can take minutes), then edit the card with
|
|
22754
|
-
// the real outcome. The turn resumes via the synthetic inbound injected by
|
|
22755
|
-
// resolveMentalModelProposal — not by this card edit.
|
|
22756
|
-
await ctx.answerCallbackQuery({ text: '✅ Declaring the model…' }).catch(() => {})
|
|
22757
|
-
if (pending.card_message_id != null) {
|
|
22758
|
-
await ctx.api
|
|
22759
|
-
.editMessageText(
|
|
22760
|
-
pending.chat_id,
|
|
22761
|
-
pending.card_message_id,
|
|
22762
|
-
richMessage(`⏳ _Declaring **${escapeHtmlForTg(pending.agent)}**'s mental model \`${pending.spec.name}\` — appending to config + ensuring…_`),
|
|
22763
|
-
{ reply_markup: { inline_keyboard: [] } },
|
|
22764
|
-
)
|
|
22765
|
-
.catch(() => {})
|
|
22766
|
-
}
|
|
22767
|
-
void (async () => {
|
|
22768
|
-
let result
|
|
22769
|
-
try {
|
|
22770
|
-
result = await resolveMentalModelProposal('approve', proposal, stageId, senderId, resolveDeps)
|
|
22771
|
-
} catch (err) {
|
|
22772
|
-
process.stderr.write(`telegram gateway: mental_model_propose approve threw: ${(err as Error).message}\n`)
|
|
22773
|
-
result = { outcome: 'failed' as const, reason: (err as Error).message }
|
|
22774
|
-
}
|
|
22775
|
-
if (pending.card_message_id != null) {
|
|
22776
|
-
const label =
|
|
22777
|
-
result.outcome === 'applied'
|
|
22778
|
-
? `✅ **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.`
|
|
22779
|
-
: `⚠️ **Did NOT declare** \`${pending.spec.name}\`${'reason' in result && result.reason ? ` — ${escapeHtmlForTg(result.reason)}` : ''}. Nothing was written.`
|
|
22780
|
-
await ctx.api
|
|
22781
|
-
.editMessageText(pending.chat_id, pending.card_message_id, richMessage(label), {
|
|
22782
|
-
reply_markup: { inline_keyboard: [] },
|
|
22783
|
-
link_preview_options: { is_disabled: true },
|
|
22784
|
-
})
|
|
22785
|
-
.catch(() => {})
|
|
22786
|
-
}
|
|
22787
|
-
})()
|
|
22788
|
-
}
|
|
22789
|
-
|
|
22790
|
-
async function handleVaultRequestAccessCallback(ctx: Context, data: string): Promise<void> {
|
|
22791
|
-
const senderId = String(ctx.from?.id ?? '')
|
|
22792
|
-
const access = loadAccess()
|
|
22793
|
-
if (!access.allowFrom.includes(senderId)) {
|
|
22794
|
-
await ctx.answerCallbackQuery({ text: 'Not authorized.' }).catch(() => {})
|
|
22795
|
-
return
|
|
22796
|
-
}
|
|
22797
|
-
const parts = data.split(':')
|
|
22798
|
-
if (parts.length < 3) {
|
|
22799
|
-
await ctx.answerCallbackQuery({ text: 'Bad request' }).catch(() => {})
|
|
22800
|
-
return
|
|
22801
|
-
}
|
|
22802
|
-
const action = parts[1]
|
|
22803
|
-
const stageId = parts.slice(2).join(':')
|
|
22804
|
-
const pending = pendingVaultRequestAccesses.get(stageId)
|
|
22805
|
-
if (!pending) {
|
|
22806
|
-
await ctx.answerCallbackQuery({ text: 'Card expired — ask the agent to re-request.' }).catch(() => {})
|
|
22807
|
-
if (ctx.callbackQuery?.message) {
|
|
22808
|
-
await ctx.api
|
|
22809
|
-
.editMessageText(
|
|
22810
|
-
ctx.callbackQuery.message.chat.id,
|
|
22811
|
-
ctx.callbackQuery.message.message_id,
|
|
22812
|
-
richMessage('⌛ _This access-request card expired before you tapped. Ask the agent to re-issue if the need still stands._'),
|
|
22813
|
-
{ reply_markup: { inline_keyboard: [] } },
|
|
22814
|
-
)
|
|
22815
|
-
.catch(() => {})
|
|
22816
|
-
}
|
|
22817
|
-
return
|
|
22818
|
-
}
|
|
22819
|
-
|
|
22820
|
-
if (action === 'deny') {
|
|
22821
|
-
pendingVaultRequestAccesses.delete(stageId)
|
|
22822
|
-
pendingCardStore.remove(stageId)
|
|
22823
|
-
await ctx.answerCallbackQuery({ text: '🚫 Denied' }).catch(() => {})
|
|
22824
|
-
if (pending.card_message_id != null) {
|
|
22825
|
-
await ctx.api
|
|
22826
|
-
.editMessageText(
|
|
22827
|
-
pending.chat_id,
|
|
22828
|
-
pending.card_message_id,
|
|
22829
|
-
richMessage(`🚫 _Denied. **${escapeHtmlForTg(pending.agent)}** will not get access to \`${pending.key}\`._`),
|
|
22830
|
-
{ reply_markup: { inline_keyboard: [] } },
|
|
22831
|
-
)
|
|
22832
|
-
.catch(() => {})
|
|
22833
|
-
}
|
|
22834
|
-
// #1150 sibling: invariant-3 was missing on the deny path too. The
|
|
22835
|
-
// agent originally ended its turn after `vault_request_access` and
|
|
22836
|
-
// waits for the gateway to wake it. On approve we already inject
|
|
22837
|
-
// `vault_grant_approved` (#1052); now we mirror that for deny so
|
|
22838
|
-
// the agent can pick the fallback path (apologise to the user,
|
|
22839
|
-
// try a different approach, skip the feature) instead of staying
|
|
22840
|
-
// wedged forever. Buffer-on-failure so a mid-reconnect bridge
|
|
22841
|
-
// still receives this on its next register.
|
|
22842
|
-
const denyInbound = buildVaultGrantDeniedInbound({
|
|
22843
|
-
ctx: {
|
|
22844
|
-
agent: pending.agent,
|
|
22845
|
-
key: pending.key,
|
|
22846
|
-
scope: pending.scope,
|
|
22847
|
-
chat_id: pending.chat_id,
|
|
22848
|
-
ttl_seconds: pending.ttl_seconds,
|
|
22849
|
-
...(pending.threadId != null ? { threadId: pending.threadId } : {}),
|
|
22850
|
-
},
|
|
22851
|
-
stageId,
|
|
22852
|
-
operatorId: senderId,
|
|
22853
|
-
})
|
|
22854
|
-
const denyDelivered = deliverResumeSyntheticOrBuffer(pending.agent, denyInbound)
|
|
22855
|
-
process.stderr.write(
|
|
22856
|
-
`telegram gateway: vault_grant_denied injection agent=${pending.agent} ` +
|
|
22857
|
-
`key=${pending.key} stage=${stageId} delivered=${denyDelivered}\n`,
|
|
22858
|
-
)
|
|
22859
|
-
return
|
|
22860
|
-
}
|
|
22861
|
-
|
|
22862
|
-
if (action === 'approve') {
|
|
22863
|
-
// Admin-only credentials (`vault.broker.adminOnlyKeys`) are held to a
|
|
22864
|
-
// higher bar: ONLY the admin operator (allowFrom[0]) may approve, and
|
|
22865
|
-
// the grant must be minted with the operator passphrase — never
|
|
22866
|
-
// posture, even under telegram-id mode (the broker enforces the same
|
|
22867
|
-
// rule, so a posture mint would just be rejected). So for an
|
|
22868
|
-
// admin-only key we (a) reject taps from any non-admin allowFrom
|
|
22869
|
-
// member, and (b) skip the telegram-id posture branch below, falling
|
|
22870
|
-
// through to the passphrase-prompt path. The card + buttons stay
|
|
22871
|
-
// intact on a non-admin tap so the admin can still approve.
|
|
22872
|
-
const isAdminOnly = matchesAdminOnlyKey(pending.key, ADMIN_ONLY_KEYS)
|
|
22873
|
-
if (isAdminOnly && senderId !== access.allowFrom[0]) {
|
|
22874
|
-
await ctx
|
|
22875
|
-
.answerCallbackQuery({
|
|
22876
|
-
text: '🔒 Admin-only credential — only the owner can approve this.',
|
|
22877
|
-
})
|
|
22878
|
-
.catch(() => {})
|
|
22879
|
-
return
|
|
22880
|
-
}
|
|
22881
|
-
|
|
22882
|
-
// Posture: telegram-id (opt-in single-factor). The broker is
|
|
22883
|
-
// auto-unlocked and we silently hold the passphrase in memory; skip
|
|
22884
|
-
// the passphrase-cache lookup + prompt entirely and mint directly.
|
|
22885
|
-
// Allowlist check above already attested the operator's Telegram ID.
|
|
22886
|
-
// Admin-only keys are excluded — they take the passphrase path below.
|
|
22887
|
-
if (!isAdminOnly && VAULT_APPROVAL_AUTH_MODE === 'telegram-id') {
|
|
22888
|
-
const username = ctx.from?.username ?? ctx.from?.first_name ?? `id=${senderId}`
|
|
22889
|
-
if (pending.card_message_id != null) {
|
|
22890
|
-
await ctx.api
|
|
22891
|
-
.editMessageText(
|
|
22892
|
-
pending.chat_id,
|
|
22893
|
-
pending.card_message_id,
|
|
22894
|
-
richMessage(`✅ Approved by @${escapeHtmlForTg(username)} — minting…`),
|
|
22895
|
-
{ reply_markup: { inline_keyboard: [] } },
|
|
22896
|
-
)
|
|
22897
|
-
.catch(() => {})
|
|
22898
|
-
}
|
|
22899
|
-
await ctx.answerCallbackQuery({ text: '⏳ Minting grant…' }).catch(() => {})
|
|
22900
|
-
await performVaultAccessApproval(ctx, pending, stageId, senderId, { kind: 'posture' })
|
|
22901
|
-
return
|
|
22902
|
-
}
|
|
22903
|
-
|
|
22904
|
-
// Tap-to-unlock-and-approve: if the operator hasn't unlocked the
|
|
22905
|
-
// vault in this chat yet, capture the passphrase via a pending op
|
|
22906
|
-
// intercept and resume the approve flow automatically once it
|
|
22907
|
-
// arrives — no second tap, no separate /vault unlock detour.
|
|
22908
|
-
// Mirrors the `passphrase-for-deferred` flow from #44.
|
|
22909
|
-
const cached = vaultPassphraseCache.get(pending.chat_id)
|
|
22910
|
-
if (!cached || cached.expiresAt <= Date.now()) {
|
|
22911
|
-
if (pending.card_message_id == null) {
|
|
22912
|
-
await ctx
|
|
22913
|
-
.answerCallbackQuery({ text: 'Card missing — ask the agent to re-issue.' })
|
|
22914
|
-
.catch(() => {})
|
|
22915
|
-
return
|
|
22916
|
-
}
|
|
22917
|
-
// #1051: if there's ALREADY a passphrase-for-access-approve
|
|
22918
|
-
// pending op for this chat (operator tapped Approve on a
|
|
22919
|
-
// sibling card before typing the passphrase), APPEND this
|
|
22920
|
-
// stage to the existing queue instead of overwriting. When
|
|
22921
|
-
// the passphrase reply lands the text-handler drains every
|
|
22922
|
-
// queued stage — both cards get their grant minted off one
|
|
22923
|
-
// passphrase entry. Without this, the second Approve tap
|
|
22924
|
-
// orphans the first stage.
|
|
22925
|
-
const existing = pendingVaultOps.get(pending.chat_id)
|
|
22926
|
-
const newItem = {
|
|
22927
|
-
stageId,
|
|
22928
|
-
cardChatId: pending.chat_id,
|
|
22929
|
-
cardMessageId: pending.card_message_id,
|
|
22930
|
-
senderId,
|
|
22931
|
-
}
|
|
22932
|
-
const items =
|
|
22933
|
-
existing?.kind === 'passphrase-for-access-approve'
|
|
22934
|
-
? [...existing.items.filter((it) => it.stageId !== stageId), newItem]
|
|
22935
|
-
: [newItem]
|
|
22936
|
-
pendingVaultOps.set(pending.chat_id, {
|
|
22937
|
-
kind: 'passphrase-for-access-approve',
|
|
22938
|
-
items,
|
|
22939
|
-
startedAt: existing?.kind === 'passphrase-for-access-approve' ? existing.startedAt : Date.now(),
|
|
22940
|
-
})
|
|
22941
|
-
// Card text differs slightly when joining an existing batch so
|
|
22942
|
-
// the operator isn't confused by two "Reply with passphrase"
|
|
22943
|
-
// cards open at once.
|
|
22944
|
-
const joiningBatch = items.length > 1
|
|
22945
|
-
await ctx.answerCallbackQuery({ text: joiningBatch ? `🔐 Queued — one passphrase covers ${items.length} cards` : '🔐 Send your passphrase…' }).catch(() => {})
|
|
22946
|
-
|
|
22947
|
-
// Strip the buttons on the ORIGINAL card and mark it "waiting" so it
|
|
22948
|
-
// can't be re-tapped, but do NOT overload it as the passphrase prompt.
|
|
22949
|
-
// An in-place edit fires no notification and stays pinned to the card's
|
|
22950
|
-
// old position in the chat, so a busy topic buries it and the operator
|
|
22951
|
-
// never sees the passphrase ask — the exact admin-key miss this fixes
|
|
22952
|
-
// (v0.16.45: the prompt scrolled off, the passphrase never arrived, the
|
|
22953
|
-
// grant was never minted). The prompt goes out as a fresh message below.
|
|
22954
|
-
await ctx.api
|
|
22955
|
-
.editMessageText(
|
|
22956
|
-
pending.chat_id,
|
|
22957
|
-
pending.card_message_id,
|
|
22958
|
-
richMessage(`🔐 _Approved — waiting for your vault passphrase. See the prompt below._`),
|
|
22959
|
-
{ reply_markup: { inline_keyboard: [] } },
|
|
22960
|
-
)
|
|
22961
|
-
.catch(() => {})
|
|
22962
|
-
|
|
22963
|
-
// The passphrase prompt as a NEW rich message. Three fixes vs. the old
|
|
22964
|
-
// in-place edit, all of which the reported bug needed:
|
|
22965
|
-
// 1. Real bold/italic — rendered through the sanctioned `richMessage`
|
|
22966
|
-
// GFM path (`sendRichMessage`), never a raw string. The old admin
|
|
22967
|
-
// and joining-batch branches passed raw markdown to editMessageText
|
|
22968
|
-
// (parse_mode=none), so `**`/`_` rendered as literal characters;
|
|
22969
|
-
// the "locked" branch even concatenated a string with a
|
|
22970
|
-
// `richMessage()` object (→ `[object Object]`). All three are gone.
|
|
22971
|
-
// 2. It lands at the BOTTOM of the chat, not stapled to an old card
|
|
22972
|
-
// that later messages bury.
|
|
22973
|
-
// 3. It fires a notification — `disable_notification` is deliberately
|
|
22974
|
-
// NOT set — so the operator is actually pinged to act.
|
|
22975
|
-
// Attention-grabbing header, short lines, key in code formatting.
|
|
22976
|
-
const promptText = joiningBatch
|
|
22977
|
-
? `**⚠️🔐 ACTION NEEDED: passphrase required**\n\n` +
|
|
22978
|
-
`Type your vault passphrase as your **next message**.\n` +
|
|
22979
|
-
`One entry covers **${items.length}** pending approvals in this chat, no re-type per card.\n\n` +
|
|
22980
|
-
`_We delete the passphrase message the moment we read it._`
|
|
22981
|
-
: isAdminOnly
|
|
22982
|
-
? `**⚠️🔐 ACTION NEEDED: passphrase required**\n\n` +
|
|
22983
|
-
`\`${pending.key}\` is an **admin-only credential**.\n` +
|
|
22984
|
-
`Type your vault passphrase as your **next message** to mint the grant for **${escapeHtmlForTg(pending.agent)}**.\n\n` +
|
|
22985
|
-
`_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._`
|
|
22986
|
-
: `**⚠️🔐 ACTION NEEDED: passphrase required**\n\n` +
|
|
22987
|
-
`Your vault is locked.\n` +
|
|
22988
|
-
`Reply with your passphrase as your **next message** to unlock and mint the grant for **${escapeHtmlForTg(pending.agent)}**.\n\n` +
|
|
22989
|
-
`_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._`
|
|
22990
|
-
|
|
22991
|
-
// #1075: deleted-topic safe — fall back to the main chat. Wrapped
|
|
22992
|
-
// through robustApiCall for flood-wait retries, mirroring the card send.
|
|
22993
|
-
await retryWithThreadFallback<{ message_id: number }>(
|
|
22994
|
-
robustApiCall,
|
|
22995
|
-
(tid) =>
|
|
22996
|
-
lockedBot.api.sendRichMessage(pending.chat_id, richMessage(promptText), {
|
|
22997
|
-
...(tid != null && Number.isFinite(tid) ? { message_thread_id: tid } : {}),
|
|
22998
|
-
}),
|
|
22999
|
-
{ threadId: pending.threadId, chat_id: pending.chat_id, verb: 'vault_request_access.passphrase_prompt' },
|
|
23000
|
-
).catch(() => {})
|
|
23001
|
-
return
|
|
23002
|
-
}
|
|
23003
|
-
|
|
23004
|
-
await ctx.answerCallbackQuery({ text: '⏳ Minting grant…' }).catch(() => {})
|
|
23005
|
-
await performVaultAccessApproval(ctx, pending, stageId, senderId, { kind: 'passphrase', passphrase: cached.passphrase })
|
|
23006
|
-
return
|
|
23007
|
-
}
|
|
23008
|
-
|
|
23009
|
-
await ctx.answerCallbackQuery({ text: 'Unknown action' }).catch(() => {})
|
|
23010
|
-
}
|
|
23011
|
-
|
|
23012
|
-
async function handleVaultRequestSaveCallback(ctx: Context, data: string): Promise<void> {
|
|
23013
|
-
const senderId = String(ctx.from?.id ?? '')
|
|
23014
|
-
const access = loadAccess()
|
|
23015
|
-
if (!access.allowFrom.includes(senderId)) {
|
|
23016
|
-
await ctx.answerCallbackQuery({ text: 'Not authorized.' }).catch(() => {})
|
|
23017
|
-
return
|
|
23018
|
-
}
|
|
23019
|
-
|
|
23020
|
-
const parts = data.split(':')
|
|
23021
|
-
if (parts.length < 3) {
|
|
23022
|
-
await ctx.answerCallbackQuery({ text: 'Bad request' }).catch(() => {})
|
|
23023
|
-
return
|
|
23024
|
-
}
|
|
23025
|
-
const action = parts[1]
|
|
23026
|
-
const stageId = parts.slice(2).join(':')
|
|
23027
|
-
const pending = pendingVaultRequestSaves.get(stageId)
|
|
23028
|
-
if (!pending) {
|
|
23029
|
-
await ctx.answerCallbackQuery({ text: 'Card expired — ask the agent to re-send.' }).catch(() => {})
|
|
23030
|
-
if (ctx.callbackQuery?.message) {
|
|
23031
|
-
await ctx.api
|
|
23032
|
-
.editMessageText(
|
|
23033
|
-
ctx.callbackQuery.message.chat.id,
|
|
23034
|
-
ctx.callbackQuery.message.message_id,
|
|
23035
|
-
richMessage('⌛ _This vault-save card expired before you tapped. Ask the agent to re-issue if you still want to save._'),
|
|
23036
|
-
{ reply_markup: { inline_keyboard: [] } },
|
|
23037
|
-
)
|
|
23038
|
-
.catch(() => {})
|
|
23039
|
-
}
|
|
23040
|
-
return
|
|
23041
|
-
}
|
|
23042
|
-
|
|
23043
|
-
if (action === 'discard') {
|
|
23044
|
-
pendingVaultRequestSaves.delete(stageId)
|
|
23045
|
-
pendingCardStore.remove(stageId)
|
|
23046
|
-
await ctx.answerCallbackQuery({ text: '🚫 Discarded' }).catch(() => {})
|
|
23047
|
-
if (pending.card_message_id != null) {
|
|
23048
|
-
await ctx.api
|
|
23049
|
-
.editMessageText(
|
|
23050
|
-
pending.chat_id,
|
|
23051
|
-
pending.card_message_id,
|
|
23052
|
-
richMessage(`🚫 _Discarded. The secret was not written to the vault._`),
|
|
23053
|
-
{ reply_markup: { inline_keyboard: [] } },
|
|
23054
|
-
)
|
|
23055
|
-
.catch(() => {})
|
|
23056
|
-
}
|
|
23057
|
-
// Wake the agent that called vault_request_save — symmetric with
|
|
23058
|
-
// the vra: approve/deny path (#1052/#1150/#1156). Without this the
|
|
23059
|
-
// tool returned "waiting for operator", the turn ended, and a
|
|
23060
|
-
// Discard left the agent silently idle forever.
|
|
23061
|
-
const discardInbound = buildVaultSaveDiscardedInbound({
|
|
23062
|
-
ctx: {
|
|
23063
|
-
agent: pending.agent,
|
|
23064
|
-
key: pending.key,
|
|
23065
|
-
chat_id: pending.chat_id,
|
|
23066
|
-
...(pending.threadId != null ? { threadId: pending.threadId } : {}),
|
|
23067
|
-
},
|
|
23068
|
-
stageId,
|
|
23069
|
-
operatorId: senderId,
|
|
23070
|
-
})
|
|
23071
|
-
const dDelivered = deliverResumeSyntheticOrBuffer(pending.agent, discardInbound)
|
|
23072
|
-
process.stderr.write(
|
|
23073
|
-
`telegram gateway: vault_save_discarded injection agent=${pending.agent} ` +
|
|
23074
|
-
`key=${pending.key} stage=${stageId} delivered=${dDelivered}\n`,
|
|
23075
|
-
)
|
|
23076
|
-
return
|
|
23077
|
-
}
|
|
23078
|
-
|
|
23079
|
-
if (action === 'rename') {
|
|
23080
|
-
// Set up a pending-op intercept so the user's next message is read
|
|
23081
|
-
// as the new key name. Same shape as the existing /vault set value
|
|
23082
|
-
// capture (gateway.ts uses pendingVaultOps for this).
|
|
23083
|
-
pendingVaultOps.set(pending.chat_id, {
|
|
23084
|
-
kind: 'rename-vault-save',
|
|
23085
|
-
stageId,
|
|
23086
|
-
startedAt: Date.now(),
|
|
23087
|
-
} as PendingVaultOp)
|
|
23088
|
-
// #1150 audit P0: pre-fix the [Save once][Discard][Rename] keyboard
|
|
23089
|
-
// stayed live after the rename tap so the operator could re-tap
|
|
23090
|
-
// Save with the old key name mid-rename — a Save tap fires the
|
|
23091
|
-
// write immediately, racing the rename intercept. Strip the
|
|
23092
|
-
// keyboard atomically with a status line that names the rename
|
|
23093
|
-
// mode + the proposed new-key prompt. No synthInbound — the
|
|
23094
|
-
// agent's `vault_request_save` tool already returned "waiting
|
|
23095
|
-
// for operator," and the eventual save success/failure flows
|
|
23096
|
-
// its own wake-up below.
|
|
23097
|
-
const sourceMsg = ctx.callbackQuery?.message
|
|
23098
|
-
const baseText = sourceMsg && 'text' in sourceMsg && sourceMsg.text
|
|
23099
|
-
? escapeHtmlForTg(sourceMsg.text)
|
|
23100
|
-
: ''
|
|
23101
|
-
const statusLine =
|
|
23102
|
-
`\n\n✏️ **Rename mode** — send the new key name as your next message. ` +
|
|
23103
|
-
`The current proposed key is \`${pending.key}\`.`
|
|
23104
|
-
await finalizeCallback(ctx, {
|
|
23105
|
-
ackText: 'Send the new key name as your next message.',
|
|
23106
|
-
newText: baseText ? `${baseText}${statusLine}` : statusLine,
|
|
23107
|
-
})
|
|
23108
|
-
return
|
|
23109
|
-
}
|
|
23110
|
-
|
|
23111
|
-
if (action === 'save') {
|
|
23112
|
-
// Acknowledge the tap immediately so Telegram doesn't show a
|
|
23113
|
-
// stale "spinning" state on the button while we run the write.
|
|
23114
|
-
await ctx.answerCallbackQuery({ text: '⏳ Saving…' }).catch(() => {})
|
|
23115
|
-
|
|
23116
|
-
// Restored-after-restart guard: the staged secret VALUE is held in gateway
|
|
23117
|
-
// memory only and is never persisted (secrets hygiene). If this card was
|
|
23118
|
-
// restored from disk after a gateway restart, the value is gone — we CANNOT
|
|
23119
|
-
// complete the write. Degrade gracefully: strip the card, wake the agent
|
|
23120
|
-
// with a save-failed (value-lost) synthetic so it re-requests, and stop.
|
|
23121
|
-
if (pending.restoredWithoutValue || pending.value.length === 0) {
|
|
23122
|
-
pendingVaultRequestSaves.delete(stageId)
|
|
23123
|
-
pendingCardStore.remove(stageId)
|
|
23124
|
-
if (pending.card_message_id != null) {
|
|
23125
|
-
await ctx.api
|
|
23126
|
-
.editMessageText(
|
|
23127
|
-
pending.chat_id,
|
|
23128
|
-
pending.card_message_id,
|
|
23129
|
-
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._`),
|
|
23130
|
-
{ reply_markup: { inline_keyboard: [] } },
|
|
23131
|
-
)
|
|
23132
|
-
.catch(() => {})
|
|
23133
|
-
}
|
|
23134
|
-
const lostInbound = buildVaultSaveFailedInbound({
|
|
23135
|
-
ctx: {
|
|
23136
|
-
agent: pending.agent,
|
|
23137
|
-
key: pending.key,
|
|
23138
|
-
chat_id: pending.chat_id,
|
|
23139
|
-
...(pending.threadId != null ? { threadId: pending.threadId } : {}),
|
|
23140
|
-
},
|
|
23141
|
-
stageId,
|
|
23142
|
-
operatorId: senderId,
|
|
23143
|
-
reason: 'staged value lost to a gateway restart — re-request the save',
|
|
23144
|
-
})
|
|
23145
|
-
const lDelivered = deliverResumeSyntheticOrBuffer(pending.agent, lostInbound)
|
|
23146
|
-
process.stderr.write(
|
|
23147
|
-
`telegram gateway: vault_request_save value lost to restart — wake agent=${pending.agent} ` +
|
|
23148
|
-
`key=${pending.key} stage=${stageId} delivered=${lDelivered}\n`,
|
|
23149
|
-
)
|
|
23150
|
-
return
|
|
23151
|
-
}
|
|
23152
|
-
|
|
23153
|
-
// #1115 follow-up: the save-approve flow now mirrors the access-
|
|
23154
|
-
// approve flow under telegram-id mode — broker `put` accepts
|
|
23155
|
-
// `attest_via_posture: true` (server.ts:1448-1500), so the
|
|
23156
|
-
// gateway can attest the write without a cached passphrase.
|
|
23157
|
-
// Closes the UX gap where tapping Save surfaced a misleading
|
|
23158
|
-
// "🔒 Vault is locked" message even when the broker had been
|
|
23159
|
-
// auto-unlocked at boot.
|
|
23160
|
-
//
|
|
23161
|
-
// Branch: under telegram-id mode use the posture-attested put;
|
|
23162
|
-
// under passphrase mode keep the existing cached-passphrase +
|
|
23163
|
-
// shell-to-CLI path (operator must `/vault unlock` once per
|
|
23164
|
-
// chat session to populate `vaultPassphraseCache`).
|
|
23165
|
-
let write: { ok: boolean; output: string }
|
|
23166
|
-
if (VAULT_APPROVAL_AUTH_MODE === 'telegram-id') {
|
|
23167
|
-
// Posture-attested broker put. No passphrase needed. The broker
|
|
23168
|
-
// verifies (a) telegram-id mode, (b) per-agent peer, (c) broker
|
|
23169
|
-
// unlocked — see server.ts:1448-1500.
|
|
23170
|
-
write = await defaultVaultWritePosture(pending.key, pending.value)
|
|
23171
|
-
} else {
|
|
23172
|
-
// Passphrase mode — fetch the cached passphrase for this chat.
|
|
23173
|
-
// If the gateway hasn't seen the user unlock the vault yet, we
|
|
23174
|
-
// can't attest the write — surface the unlock prompt.
|
|
23175
|
-
const cached = vaultPassphraseCache.get(pending.chat_id)
|
|
23176
|
-
if (!cached || cached.expiresAt <= Date.now()) {
|
|
23177
|
-
if (pending.card_message_id != null) {
|
|
23178
|
-
await ctx.api
|
|
23179
|
-
.editMessageText(
|
|
23180
|
-
pending.chat_id,
|
|
23181
|
-
pending.card_message_id,
|
|
23182
|
-
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.`),
|
|
23183
|
-
{ reply_markup: { inline_keyboard: [] } },
|
|
23184
|
-
)
|
|
23185
|
-
.catch(() => {})
|
|
23186
|
-
}
|
|
23187
|
-
pendingVaultRequestSaves.delete(stageId)
|
|
23188
|
-
pendingCardStore.remove(stageId)
|
|
23189
|
-
return
|
|
23190
|
-
}
|
|
23191
|
-
// defaultVaultWrite spawns `switchroom vault set <key>` with the
|
|
23192
|
-
// passphrase env set; the CLI forwards the passphrase to the
|
|
23193
|
-
// broker put as operator-attestation (#969 P1a), which authorizes
|
|
23194
|
-
// new-key creation.
|
|
23195
|
-
write = defaultVaultWrite(pending.key, pending.value, cached.passphrase)
|
|
23196
|
-
}
|
|
23197
|
-
|
|
23198
|
-
if (!write.ok) {
|
|
23199
|
-
// Route through the structured-error renderer from #969 P0b so
|
|
23200
|
-
// failures show the actionable host hint instead of a raw blob.
|
|
23201
|
-
const parsed = parseVaultCliError(write.output)
|
|
23202
|
-
const rendered = renderVaultCliError(parsed, { verb: 'save', key: pending.key })
|
|
23203
|
-
const body = rendered.suppressRaw
|
|
23204
|
-
? rendered.html
|
|
23205
|
-
: `⚠️ vault write failed:\n\`\`\`\n${write.output}\n\`\`\``
|
|
23206
|
-
if (pending.card_message_id != null) {
|
|
23207
|
-
await ctx.api
|
|
23208
|
-
.editMessageText(
|
|
23209
|
-
pending.chat_id,
|
|
23210
|
-
pending.card_message_id,
|
|
23211
|
-
richMessage(`${body}\n\n_Tap a fresh card after fixing the underlying issue._`),
|
|
23212
|
-
{ reply_markup: { inline_keyboard: [] } },
|
|
23213
|
-
)
|
|
23214
|
-
.catch(() => {})
|
|
23215
|
-
}
|
|
23216
|
-
// Leave the staged secret in memory until TTL — operator might
|
|
23217
|
-
// retry by re-invoking the same MCP tool, but the value will be
|
|
23218
|
-
// re-staged with a new ID. Drop the current stage.
|
|
23219
|
-
pendingVaultRequestSaves.delete(stageId)
|
|
23220
|
-
pendingCardStore.remove(stageId)
|
|
23221
|
-
// Wake the waiting agent with the failure (symmetric with the
|
|
23222
|
-
// success/discard paths) so it doesn't assume vault:<key> exists.
|
|
23223
|
-
const failReason =
|
|
23224
|
-
(write.output || 'vault write error').split('\n')[0]!.slice(0, 200)
|
|
23225
|
-
const failInbound = buildVaultSaveFailedInbound({
|
|
23226
|
-
ctx: {
|
|
23227
|
-
agent: pending.agent,
|
|
23228
|
-
key: pending.key,
|
|
23229
|
-
chat_id: pending.chat_id,
|
|
23230
|
-
...(pending.threadId != null ? { threadId: pending.threadId } : {}),
|
|
23231
|
-
},
|
|
23232
|
-
stageId,
|
|
23233
|
-
operatorId: senderId,
|
|
23234
|
-
reason: failReason,
|
|
23235
|
-
})
|
|
23236
|
-
const fDelivered = deliverResumeSyntheticOrBuffer(pending.agent, failInbound)
|
|
23237
|
-
process.stderr.write(
|
|
23238
|
-
`telegram gateway: vault_save_failed injection agent=${pending.agent} ` +
|
|
23239
|
-
`key=${pending.key} stage=${stageId} delivered=${fDelivered}\n`,
|
|
23240
|
-
)
|
|
23241
|
-
return
|
|
23242
|
-
}
|
|
23243
|
-
|
|
23244
|
-
// Success — mask the value in the card for visual confirmation.
|
|
23245
|
-
pendingVaultRequestSaves.delete(stageId)
|
|
23246
|
-
pendingCardStore.remove(stageId)
|
|
23247
|
-
if (pending.card_message_id != null) {
|
|
23248
|
-
await ctx.api
|
|
23249
|
-
.editMessageText(
|
|
23250
|
-
pending.chat_id,
|
|
23251
|
-
pending.card_message_id,
|
|
23252
|
-
richMessage(`✅ saved as \`vault:${escapeHtmlForTg(pending.key)}\` (masked: \`${escapeHtmlForTg(maskToken(pending.value))}\`)\n_The agent can now reference this as \`vault:${escapeHtmlForTg(pending.key)}\`._`),
|
|
23253
|
-
{ reply_markup: { inline_keyboard: [] } },
|
|
23254
|
-
)
|
|
23255
|
-
.catch(() => {})
|
|
23256
|
-
}
|
|
23257
|
-
// Wake the agent that called vault_request_save so it resumes the
|
|
23258
|
-
// task that was blocked on this credential (symmetric with the
|
|
23259
|
-
// vra: approve path; buffered if the bridge is mid-reconnect).
|
|
23260
|
-
const okInbound = buildVaultSaveCompletedInbound({
|
|
23261
|
-
ctx: {
|
|
23262
|
-
agent: pending.agent,
|
|
23263
|
-
key: pending.key,
|
|
23264
|
-
chat_id: pending.chat_id,
|
|
23265
|
-
...(pending.threadId != null ? { threadId: pending.threadId } : {}),
|
|
23266
|
-
},
|
|
23267
|
-
stageId,
|
|
23268
|
-
operatorId: senderId,
|
|
23269
|
-
})
|
|
23270
|
-
const okDelivered = deliverResumeSyntheticOrBuffer(pending.agent, okInbound)
|
|
23271
|
-
process.stderr.write(
|
|
23272
|
-
`telegram gateway: vault_save_completed injection agent=${pending.agent} ` +
|
|
23273
|
-
`key=${pending.key} stage=${stageId} delivered=${okDelivered}\n`,
|
|
23274
|
-
)
|
|
23275
|
-
return
|
|
23276
|
-
}
|
|
23277
|
-
|
|
23278
|
-
await ctx.answerCallbackQuery({ text: 'Unknown action' }).catch(() => {})
|
|
23279
|
-
}
|
|
23280
|
-
|
|
23281
|
-
async function handleVaultDeferCallback(ctx: Context, data: string): Promise<void> {
|
|
23282
|
-
const senderId = String(ctx.from?.id ?? '')
|
|
23283
|
-
const access = loadAccess()
|
|
23284
|
-
if (!access.allowFrom.includes(senderId)) {
|
|
23285
|
-
await ctx.answerCallbackQuery({ text: 'Not authorized.' }).catch(() => {})
|
|
23286
|
-
return
|
|
23287
|
-
}
|
|
23288
|
-
// vd:<action>:<deferKey>. deferKey itself contains a colon (chat:msgId)
|
|
23289
|
-
// so we slice rather than split — only the first two segments are
|
|
23290
|
-
// structural; the rest is the deferKey verbatim.
|
|
23291
|
-
const rest = data.slice('vd:'.length)
|
|
23292
|
-
const colon = rest.indexOf(':')
|
|
23293
|
-
if (colon < 0) {
|
|
23294
|
-
await ctx.answerCallbackQuery({ text: 'Malformed callback.' }).catch(() => {})
|
|
23295
|
-
return
|
|
23296
|
-
}
|
|
23297
|
-
const action = rest.slice(0, colon)
|
|
23298
|
-
const deferKey = rest.slice(colon + 1)
|
|
23299
|
-
const deferred = deferredSecrets.get(deferKey)
|
|
23300
|
-
if (!deferred) {
|
|
23301
|
-
await ctx.answerCallbackQuery({ text: 'This card expired. Re-send the secret.' }).catch(() => {})
|
|
23302
|
-
await ctx.editMessageReplyMarkup({ reply_markup: { inline_keyboard: [] } }).catch(() => {})
|
|
23303
|
-
return
|
|
23304
|
-
}
|
|
23305
|
-
|
|
23306
|
-
const cardChatId = String(ctx.chat?.id ?? '')
|
|
23307
|
-
const cardMessageId = ctx.callbackQuery?.message?.message_id
|
|
23308
|
-
|
|
23309
|
-
if (action === 'cancel') {
|
|
23310
|
-
// Kernel-side dual-dispatch (MIGRATION.md §1): record the deny decision
|
|
23311
|
-
// BEFORE the legacy handler clears state, so the audit log captures it
|
|
23312
|
-
// even if the editMessageText below races with another tap. Best-effort
|
|
23313
|
-
// — broker unreachable falls back to legacy-only.
|
|
23314
|
-
await recordDeferredSecretKernelDecision(
|
|
23315
|
-
deferred.kernel_request_id,
|
|
23316
|
-
'deny',
|
|
23317
|
-
ctx.from?.id ?? 0,
|
|
23318
|
-
access.allowFrom,
|
|
23319
|
-
)
|
|
23320
|
-
deferredSecrets.delete(deferKey)
|
|
23321
|
-
await ctx.answerCallbackQuery({ text: 'Discarded.' }).catch(() => {})
|
|
23322
|
-
if (cardMessageId != null) {
|
|
23323
|
-
await ctx
|
|
23324
|
-
.editMessageText('🗑 Discarded — secret was not saved.', {
|
|
23325
|
-
reply_markup: { inline_keyboard: [] },
|
|
23326
|
-
})
|
|
23327
|
-
.catch(() => {})
|
|
23328
|
-
}
|
|
23329
|
-
return
|
|
23330
|
-
}
|
|
23331
|
-
|
|
23332
|
-
if (action === 'unlock') {
|
|
23333
|
-
// Kernel-side dual-dispatch (MIGRATION.md §1): record the allow_once
|
|
23334
|
-
// decision when the user taps unlock. The actual passphrase capture +
|
|
23335
|
-
// vault write still happens via the legacy path below — the kernel
|
|
23336
|
-
// decision is for audit/state, not secret material (per RFC B). We
|
|
23337
|
-
// record at tap-time rather than after passphrase entry so a kernel
|
|
23338
|
-
// record exists even if the user abandons the passphrase prompt.
|
|
23339
|
-
await recordDeferredSecretKernelDecision(
|
|
23340
|
-
deferred.kernel_request_id,
|
|
23341
|
-
'allow_once',
|
|
23342
|
-
ctx.from?.id ?? 0,
|
|
23343
|
-
access.allowFrom,
|
|
23344
|
-
)
|
|
23345
|
-
// #1115 follow-up: telegram-id mode silent-defer-save was withdrawn
|
|
23346
|
-
// (same reason as the save-callback above — the in-memory
|
|
23347
|
-
// passphrase short-circuit became a bypass surface). The
|
|
23348
|
-
// deferred-secret save falls through to the cached-passphrase
|
|
23349
|
-
// path under all postures. Routing executeDeferredSecretSave
|
|
23350
|
-
// through broker-IPC attest_via_posture is a tracked follow-up.
|
|
23351
|
-
|
|
23352
|
-
// If a passphrase is already cached we can skip straight to the write.
|
|
23353
|
-
// Covers the case where the user had unlocked separately between
|
|
23354
|
-
// detection and tap.
|
|
23355
|
-
const cached = vaultPassphraseCache.get(cardChatId)
|
|
23356
|
-
if (cached && cached.expiresAt > Date.now()) {
|
|
23357
|
-
await ctx.answerCallbackQuery({ text: 'Saving…' }).catch(() => {})
|
|
23358
|
-
await executeDeferredSecretSave(ctx, deferKey, cached.passphrase, cardMessageId)
|
|
23359
|
-
return
|
|
23360
|
-
}
|
|
23361
|
-
|
|
23362
|
-
if (cardMessageId == null) {
|
|
23363
|
-
await ctx.answerCallbackQuery({ text: 'Missing card context.' }).catch(() => {})
|
|
23364
|
-
return
|
|
23365
|
-
}
|
|
23366
|
-
pendingVaultOps.set(cardChatId, {
|
|
23367
|
-
kind: 'passphrase-for-deferred',
|
|
23368
|
-
deferKey,
|
|
23369
|
-
cardChatId,
|
|
23370
|
-
cardMessageId,
|
|
23371
|
-
startedAt: Date.now(),
|
|
23372
|
-
})
|
|
23373
|
-
await ctx.answerCallbackQuery({ text: 'Send your passphrase…' }).catch(() => {})
|
|
23374
|
-
await ctx
|
|
23375
|
-
.editMessageText(
|
|
23376
|
-
richMessage('🔐 Send your vault passphrase as your next message — we\'ll save the held secret automatically and delete the passphrase message.'),
|
|
23377
|
-
{ reply_markup: { inline_keyboard: [] } },
|
|
23378
|
-
)
|
|
23379
|
-
.catch(() => {})
|
|
23380
|
-
return
|
|
23381
|
-
}
|
|
23382
|
-
|
|
23383
|
-
await ctx.answerCallbackQuery({ text: 'Unknown action.' }).catch(() => {})
|
|
23384
|
-
}
|
|
23385
|
-
|
|
23386
|
-
// ─── Grant wizard helpers (Issue #227) ──────────────────────────────────────
|
|
23387
|
-
// TODO: these helpers duplicate server.ts — extract to a shared module in a
|
|
23388
|
-
// future refactor once the two entrypoints are proven stable in production.
|
|
23389
|
-
|
|
23390
|
-
/** Parse a duration string like "30d", "7h", "365d" into seconds. */
|
|
23391
|
-
function parseGrantDuration(s: string): number | null {
|
|
23392
|
-
const m = /^(\d+)([dh])$/i.exec(s.trim())
|
|
23393
|
-
if (!m) return null
|
|
23394
|
-
const n = parseInt(m[1]!, 10)
|
|
23395
|
-
if (n <= 0) return null
|
|
23396
|
-
return m[2]!.toLowerCase() === 'd' ? n * 86400 : n * 3600
|
|
23397
|
-
}
|
|
23398
|
-
|
|
23399
|
-
/** Format seconds as a human-readable expiry label. */
|
|
23400
|
-
function formatGrantExpiry(ttlSeconds: number | null, now: Date = new Date()): string {
|
|
23401
|
-
if (ttlSeconds === null) return 'Never'
|
|
23402
|
-
const exp = new Date(now.getTime() + ttlSeconds * 1000)
|
|
23403
|
-
return exp.toISOString().slice(0, 10)
|
|
23404
|
-
}
|
|
23405
|
-
|
|
23406
|
-
/** Build the Step 1 keyboard: agent selection. */
|
|
23407
|
-
function buildGrantAgentKeyboard(agents: string[]): InlineKeyboard {
|
|
23408
|
-
const kb = new InlineKeyboard()
|
|
23409
|
-
// Max 3 per row to keep buttons readable on mobile
|
|
23410
|
-
for (let i = 0; i < agents.length; i++) {
|
|
23411
|
-
if (i > 0 && i % 3 === 0) kb.row()
|
|
23412
|
-
kb.text(agents[i]!, `vg:agent:${agents[i]!}`)
|
|
23413
|
-
}
|
|
23414
|
-
kb.row().text('Cancel', 'vg:cancel')
|
|
23415
|
-
return kb
|
|
23416
|
-
}
|
|
23417
|
-
|
|
23418
|
-
/** Build the Step 2 keyboard: key multi-select toggle. */
|
|
23419
|
-
function buildGrantKeysKeyboard(keys: string[], selected: Set<string>): InlineKeyboard {
|
|
23420
|
-
const kb = new InlineKeyboard()
|
|
23421
|
-
for (const k of keys) {
|
|
23422
|
-
const check = selected.has(k) ? '☑' : '☐'
|
|
23423
|
-
kb.row().text(`${check} ${k}`, `vg:key:${k}`)
|
|
23424
|
-
}
|
|
23425
|
-
kb.row()
|
|
23426
|
-
.text('Continue', 'vg:keys-continue')
|
|
23427
|
-
.text('Cancel', 'vg:cancel')
|
|
23428
|
-
return kb
|
|
23429
|
-
}
|
|
23430
|
-
|
|
23431
|
-
/** Build the Step 3 keyboard: duration selection. */
|
|
23432
|
-
function buildGrantDurationKeyboard(): InlineKeyboard {
|
|
23433
|
-
return new InlineKeyboard()
|
|
23434
|
-
.text('30 days', 'vg:dur:30d')
|
|
23435
|
-
.text('90 days', 'vg:dur:90d')
|
|
23436
|
-
.text('1 year', 'vg:dur:1y')
|
|
23437
|
-
.row()
|
|
23438
|
-
.text('Custom…', 'vg:dur:custom')
|
|
23439
|
-
.text('No expiry', 'vg:dur:never')
|
|
23440
|
-
.row()
|
|
23441
|
-
.text('Back', 'vg:back:duration')
|
|
23442
|
-
.text('Cancel', 'vg:cancel')
|
|
23443
|
-
}
|
|
23444
|
-
|
|
23445
|
-
/** Build the Confirm keyboard. */
|
|
23446
|
-
function buildGrantConfirmKeyboard(): InlineKeyboard {
|
|
23447
|
-
return new InlineKeyboard()
|
|
23448
|
-
.text('Generate', 'vg:generate')
|
|
23449
|
-
.text('Cancel', 'vg:cancel')
|
|
23450
|
-
}
|
|
23451
|
-
|
|
23452
|
-
/** Start the grant wizard (step 1: pick agent). */
|
|
23453
|
-
async function startGrantWizardStep1(ctx: Context, chatId: string): Promise<void> {
|
|
23454
|
-
type AgentListResp = { agents: Array<{ name: string }> }
|
|
23455
|
-
const data = switchroomExecJson<AgentListResp>(['agent', 'list'])
|
|
23456
|
-
const agents = data?.agents?.map(a => a.name).filter(Boolean) ?? []
|
|
23457
|
-
if (agents.length === 0) {
|
|
23458
|
-
await switchroomReply(ctx, '⚠️ No agents found in switchroom.yaml.', { html: true })
|
|
23459
|
-
return
|
|
23460
|
-
}
|
|
23461
|
-
const kb = buildGrantAgentKeyboard(agents)
|
|
23462
|
-
const sent = await switchroomReply(ctx, '**Grant capability token — Step 1/3**\n\nWhich agent?', { html: true, reply_markup: kb })
|
|
23463
|
-
const wizardMsgId = (sent as unknown as { message_id?: number })?.message_id
|
|
23464
|
-
pendingVaultOps.set(chatId, {
|
|
23465
|
-
kind: 'grant-wizard',
|
|
23466
|
-
step: 'agent',
|
|
23467
|
-
wizardMsgId,
|
|
23468
|
-
startedAt: Date.now(),
|
|
23469
|
-
})
|
|
23470
|
-
}
|
|
23471
|
-
|
|
23472
|
-
/** Advance grant wizard to step 2 (pick keys). */
|
|
23473
|
-
async function grantWizardStep2(ctx: Context, chatId: string, agent: string, wizardMsgId: number | undefined): Promise<void> {
|
|
23474
|
-
const keys = await listViaBroker()
|
|
23475
|
-
if (!keys) {
|
|
23476
|
-
await switchroomReply(ctx, '🔴 Broker is not running (or unreachable). Cannot list vault keys.', { html: true })
|
|
23477
|
-
pendingVaultOps.delete(chatId)
|
|
23478
|
-
return
|
|
23479
|
-
}
|
|
23480
|
-
if (keys.length === 0) {
|
|
23481
|
-
await switchroomReply(ctx, '⚠️ No vault keys found. Add secrets first with \`/vault set\`.', { html: true })
|
|
23482
|
-
pendingVaultOps.delete(chatId)
|
|
23483
|
-
return
|
|
23484
|
-
}
|
|
23485
|
-
const selected = new Set<string>()
|
|
23486
|
-
const kb = buildGrantKeysKeyboard(keys, selected)
|
|
23487
|
-
const text = `**Grant capability token — Step 2/3**\n\nWhich keys for \`${agent}\`?\n_Tap to toggle; tap Continue when done._`
|
|
23488
|
-
if (wizardMsgId != null) {
|
|
23489
|
-
// allow-raw-bot-api: vault grant wizard step 2/3; already .catch-swallows, tap-driven UI re-renders on retry
|
|
23490
|
-
await ctx.api.editMessageText(chatId, wizardMsgId, richMessage(text), { reply_markup: kb }).catch(() => {})
|
|
23491
|
-
} else {
|
|
23492
|
-
const sent = await switchroomReply(ctx, text, { html: true, reply_markup: kb })
|
|
23493
|
-
wizardMsgId = (sent as unknown as { message_id?: number })?.message_id
|
|
23494
|
-
}
|
|
23495
|
-
pendingVaultOps.set(chatId, {
|
|
23496
|
-
kind: 'grant-wizard',
|
|
23497
|
-
step: 'keys',
|
|
23498
|
-
agent,
|
|
23499
|
-
selectedKeys: [],
|
|
23500
|
-
availableKeys: keys,
|
|
23501
|
-
wizardMsgId,
|
|
23502
|
-
startedAt: Date.now(),
|
|
23503
|
-
})
|
|
23504
|
-
}
|
|
23505
|
-
|
|
23506
|
-
/** Advance grant wizard to step 3 (pick duration). */
|
|
23507
|
-
async function grantWizardStep3(ctx: Context, chatId: string, state: Extract<PendingVaultOp, { kind: 'grant-wizard' }>): Promise<void> {
|
|
23508
|
-
const kb = buildGrantDurationKeyboard()
|
|
23509
|
-
const keyList = state.selectedKeys!.map(k => `• \`${k}\``).join('\n')
|
|
23510
|
-
const text = `**Grant capability token — Step 3/3**\n\nKeys for \`${state.agent!}\`:\n${keyList}\n\nHow long should this grant be valid?`
|
|
23511
|
-
const msgId = state.wizardMsgId
|
|
23512
|
-
if (msgId != null) {
|
|
23513
|
-
// allow-raw-bot-api: vault grant wizard step 3/3 (TTL select); already .catch-swallows, tap-driven UI re-renders on retry
|
|
23514
|
-
await ctx.api.editMessageText(chatId, msgId, richMessage(text), { reply_markup: kb }).catch(() => {})
|
|
23515
|
-
} else {
|
|
23516
|
-
const sent = await switchroomReply(ctx, text, { html: true, reply_markup: kb })
|
|
23517
|
-
state.wizardMsgId = (sent as unknown as { message_id?: number })?.message_id
|
|
23518
|
-
}
|
|
23519
|
-
pendingVaultOps.set(chatId, { ...state, step: 'duration' })
|
|
23520
|
-
}
|
|
23521
|
-
|
|
23522
|
-
/** Advance grant wizard to confirmation step. */
|
|
23523
|
-
async function grantWizardConfirm(ctx: Context, chatId: string, state: Extract<PendingVaultOp, { kind: 'grant-wizard' }>): Promise<void> {
|
|
23524
|
-
const kb = buildGrantConfirmKeyboard()
|
|
23525
|
-
const expiresLabel = formatGrantExpiry(state.ttlSeconds!)
|
|
23526
|
-
const keyList = state.selectedKeys!.map(k => `• \`${k}\``).join('\n')
|
|
23527
|
-
const text = [
|
|
23528
|
-
'**Confirm grant**',
|
|
23529
|
-
'',
|
|
23530
|
-
`Agent: \`${state.agent!}\``,
|
|
23531
|
-
`Keys:\n${keyList}`,
|
|
23532
|
-
`Expires: **${escapeHtmlForTg(expiresLabel)}**`,
|
|
23533
|
-
'',
|
|
23534
|
-
'Tap **Generate** to mint the token.',
|
|
23535
|
-
].join('\n')
|
|
23536
|
-
const msgId = state.wizardMsgId
|
|
23537
|
-
if (msgId != null) {
|
|
23538
|
-
// allow-raw-bot-api: vault grant wizard confirm step; already .catch-swallows, tap-driven UI re-renders on retry
|
|
23539
|
-
await ctx.api.editMessageText(chatId, msgId, richMessage(text), { reply_markup: kb }).catch(() => {})
|
|
23540
|
-
} else {
|
|
23541
|
-
const sent = await switchroomReply(ctx, text, { html: true, reply_markup: kb })
|
|
23542
|
-
state.wizardMsgId = (sent as unknown as { message_id?: number })?.message_id
|
|
23543
|
-
}
|
|
23544
|
-
// Mint kernel decision row at the confirm step (MIGRATION.md §2,
|
|
23545
|
-
// audit-only Phase 1). We do it here rather than at executeGrantWizard
|
|
23546
|
-
// so a kernel row exists even if the user taps Cancel from the confirm
|
|
23547
|
-
// card — the deny verdict on cancel is then recorded against the same
|
|
23548
|
-
// request_id. If the kernel/broker is unreachable, request_id stays
|
|
23549
|
-
// undefined and the wizard runs legacy-only (no behaviour change).
|
|
23550
|
-
const kernelRequestId = await mintGrantWizardKernelRequest(
|
|
23551
|
-
state.agent!,
|
|
23552
|
-
loadAccess().allowFrom,
|
|
23553
|
-
state.selectedKeys!,
|
|
23554
|
-
state.ttlSeconds ?? null,
|
|
23555
|
-
)
|
|
23556
|
-
pendingVaultOps.set(chatId, {
|
|
23557
|
-
...state,
|
|
23558
|
-
step: 'confirm',
|
|
23559
|
-
expiresLabel,
|
|
23560
|
-
kernel_request_id: kernelRequestId ?? state.kernel_request_id,
|
|
23561
|
-
})
|
|
23562
|
-
}
|
|
23563
|
-
|
|
23564
|
-
/** Execute the grant: call broker mint_grant, write token, reply. */
|
|
23565
|
-
async function executeGrantWizard(ctx: Context, chatId: string, state: Extract<PendingVaultOp, { kind: 'grant-wizard' }>): Promise<void> {
|
|
23566
|
-
pendingVaultOps.delete(chatId)
|
|
23567
|
-
// Kernel-side dual-dispatch (MIGRATION.md §2, audit-only Phase 1):
|
|
23568
|
-
// record the allow_once decision when the user taps Generate. The
|
|
23569
|
-
// legacy `mintGrantViaBroker` below still drives the actual grant
|
|
23570
|
-
// mint + token write — the kernel row is informational, not
|
|
23571
|
-
// enforcing, in Phase 1 (issue #833 will flip to enforcing).
|
|
23572
|
-
// We record at tap-time rather than after mint_grant succeeds so a
|
|
23573
|
-
// kernel row exists even if the legacy mint fails (audit captures
|
|
23574
|
-
// intent regardless of downstream outcome).
|
|
23575
|
-
await recordGrantWizardKernelDecision(
|
|
23576
|
-
state.kernel_request_id,
|
|
23577
|
-
'allow_once',
|
|
23578
|
-
ctx.from?.id ?? 0,
|
|
23579
|
-
loadAccess().allowFrom,
|
|
23580
|
-
)
|
|
23581
|
-
// Defence-in-depth: state.agent flows from callback_data into a path
|
|
23582
|
-
// join below. A crafted vg:agent:../../etc payload would produce a
|
|
23583
|
-
// path traversal. Validate against the same regex the rest of the
|
|
23584
|
-
// file uses; on failure, drop silently — the wizard message has
|
|
23585
|
-
// already been finalized.
|
|
23586
|
-
try { assertSafeAgentName(state.agent!) } catch { return }
|
|
23587
|
-
const result = await mintGrantViaBroker({
|
|
23588
|
-
agent: state.agent!,
|
|
23589
|
-
keys: state.selectedKeys!,
|
|
23590
|
-
ttl_seconds: state.ttlSeconds ?? null,
|
|
23591
|
-
description: state.description,
|
|
23592
|
-
})
|
|
23593
|
-
if (result.kind === 'unreachable') {
|
|
23594
|
-
await switchroomReply(ctx, `🔴 Broker unreachable: ${escapeHtmlForTg(result.msg)}`, { html: true })
|
|
23595
|
-
return
|
|
23596
|
-
}
|
|
23597
|
-
if (result.kind === 'error') {
|
|
23598
|
-
await switchroomReply(ctx, `**mint_grant failed:** ${escapeHtmlForTg(result.msg)}`, { html: true })
|
|
23599
|
-
return
|
|
23600
|
-
}
|
|
23601
|
-
// Write token to the agent's .vault-token file
|
|
23602
|
-
const { token, id } = result
|
|
23603
|
-
const tokenPath = join(homedir(), '.switchroom', 'agents', state.agent!, '.vault-token')
|
|
23604
|
-
try {
|
|
23605
|
-
mkdirSync(join(homedir(), '.switchroom', 'agents', state.agent!), { recursive: true })
|
|
23606
|
-
writeFileSync(tokenPath, token, { mode: 0o600 })
|
|
23607
|
-
} catch (err) {
|
|
23608
|
-
await switchroomReply(ctx, `**Grant created but token write failed:** ${escapeHtmlForTg(String(err))}`, { html: true })
|
|
23609
|
-
return
|
|
23610
|
-
}
|
|
23611
|
-
// Collapse wizard message to just the outcome.
|
|
23612
|
-
// #1150 audit: P0 fix — pre-fix this `editMessageText` call omitted
|
|
23613
|
-
// `reply_markup: { inline_keyboard: [] }` so the wizard's [Generate]
|
|
23614
|
-
// / [Cancel] buttons stayed tappable on the success card. Operator
|
|
23615
|
-
// could re-tap [Generate] and mint a second redundant grant.
|
|
23616
|
-
// Strip the keyboard atomically with the success text via the
|
|
23617
|
-
// finalizeCallback helper.
|
|
23618
|
-
const msgId = state.wizardMsgId
|
|
23619
|
-
const successText = `✅ Grant \`${id}\` created. Written to \`~/.switchroom/agents/${escapeHtmlForTg(state.agent!)}/.vault-token\``
|
|
23620
|
-
if (msgId != null) {
|
|
23621
|
-
await finalizeCallback(ctx, {
|
|
23622
|
-
ackText: '✅ Grant created',
|
|
23623
|
-
newText: successText,
|
|
23624
|
-
// No synthInbound — operator-only flow.
|
|
23625
|
-
})
|
|
23626
|
-
} else {
|
|
23627
|
-
// Fallback when wizard message id was lost (rare; e.g. operator
|
|
23628
|
-
// deleted the card). Send a fresh reply with the success text;
|
|
23629
|
-
// no keyboard to strip in this branch.
|
|
23630
|
-
await switchroomReply(ctx, successText, { html: true })
|
|
23631
|
-
}
|
|
23632
|
-
}
|
|
23633
|
-
|
|
23634
|
-
/**
|
|
23635
|
-
* Issue #228: handle vault grant management callbacks.
|
|
23636
|
-
*
|
|
23637
|
-
* `vg:revoke:<grantId>` — fetch grant details and show confirmation card.
|
|
23638
|
-
* `vg:confirm:<grantId>` — call broker revoke_grant, reply with success.
|
|
23639
|
-
* `vg:cancel:<grantId>` — dismiss (clear keyboard, no broker call).
|
|
23640
|
-
*
|
|
23641
|
-
* Issue #227: also handles /vault grant wizard callbacks.
|
|
23642
|
-
*
|
|
23643
|
-
* `vg:cancel` — cancel wizard at any step.
|
|
23644
|
-
* `vg:agent:<name>` — step 1: select agent.
|
|
23645
|
-
* `vg:key:<name>` — step 2: toggle key selection.
|
|
23646
|
-
* `vg:keys-continue` — step 2 → 3.
|
|
23647
|
-
* `vg:dur:<value>` — step 3: duration selection.
|
|
23648
|
-
* `vg:back:duration` — step 3 → back to step 2.
|
|
23649
|
-
* `vg:generate` — confirm and mint token.
|
|
23650
|
-
*/
|
|
23651
|
-
async function handleVaultGrantCallback(ctx: Context, data: string): Promise<void> {
|
|
23652
|
-
const senderId = String(ctx.from?.id ?? '')
|
|
23653
|
-
const access = loadAccess()
|
|
23654
|
-
if (!access.allowFrom.includes(senderId)) {
|
|
23655
|
-
await ctx.answerCallbackQuery({ text: 'Not authorized.' }).catch(() => {})
|
|
23656
|
-
return
|
|
23657
|
-
}
|
|
23658
|
-
|
|
23659
|
-
const revokeMatch = /^vg:revoke:(.+)$/.exec(data)
|
|
23660
|
-
if (revokeMatch) {
|
|
23661
|
-
const grantId = revokeMatch[1]!
|
|
23662
|
-
const result = await listGrantsViaBroker(undefined)
|
|
23663
|
-
if (result.kind !== 'ok') {
|
|
23664
|
-
await ctx.answerCallbackQuery({ text: 'Broker unreachable.' }).catch(() => {})
|
|
23665
|
-
return
|
|
23666
|
-
}
|
|
23667
|
-
const grant = result.grants.find(g => g.id === grantId)
|
|
23668
|
-
if (!grant) {
|
|
23669
|
-
await ctx.answerCallbackQuery({ text: 'Grant not found (already revoked?).' }).catch(() => {})
|
|
23670
|
-
await ctx.editMessageReplyMarkup({ reply_markup: { inline_keyboard: [] } }).catch(() => {})
|
|
23671
|
-
return
|
|
23672
|
-
}
|
|
23673
|
-
const cardText =
|
|
23674
|
-
`🗑 Revoke \`${grantId}\`?\n` +
|
|
23675
|
-
`Agent: **${escapeHtmlForTg(grant.agent_slug)}**\n` +
|
|
23676
|
-
`Keys: \`${escapeHtmlForTg(grant.key_allow.join(', '))}\``
|
|
23677
|
-
const confirmKeyboard = new InlineKeyboard()
|
|
23678
|
-
.text('✅ Confirm Revoke', `vg:confirm:${grantId}`)
|
|
23679
|
-
.text('❌ Cancel', `vg:cancel:${grantId}`)
|
|
23680
|
-
await ctx.answerCallbackQuery().catch(() => {})
|
|
23681
|
-
await ctx.editMessageText(richMessage(cardText), {
|
|
23682
|
-
reply_markup: confirmKeyboard,
|
|
23683
|
-
}).catch(async () => {
|
|
23684
|
-
const chatId = String(ctx.chat?.id ?? ctx.from?.id ?? '')
|
|
23685
|
-
const threadId = ctx.callbackQuery?.message?.message_thread_id
|
|
23686
|
-
if (chatId) {
|
|
23687
|
-
// #1075: thread-id-bearing — swallow on THREAD_NOT_FOUND.
|
|
23688
|
-
await swallowingApiCall(
|
|
23689
|
-
() =>
|
|
23690
|
-
bot.api.sendRichMessage(chatId, richMessage(cardText), {
|
|
23691
|
-
reply_markup: confirmKeyboard,
|
|
23692
|
-
...(threadId != null ? { message_thread_id: threadId } : {}),
|
|
23693
|
-
}),
|
|
23694
|
-
{
|
|
23695
|
-
chat_id: chatId,
|
|
23696
|
-
verb: 'vault-revoke-confirm-fallback',
|
|
23697
|
-
...(threadId != null ? { threadId } : {}),
|
|
23698
|
-
},
|
|
23699
|
-
)
|
|
23700
|
-
}
|
|
23701
|
-
})
|
|
23702
|
-
return
|
|
23703
|
-
}
|
|
23704
|
-
|
|
23705
|
-
const confirmMatch = /^vg:confirm:(.+)$/.exec(data)
|
|
23706
|
-
if (confirmMatch) {
|
|
23707
|
-
const grantId = confirmMatch[1]!
|
|
23708
|
-
const revokeResult = await revokeGrantViaBroker(grantId)
|
|
23709
|
-
if (revokeResult.kind === 'unreachable') {
|
|
23710
|
-
await ctx.answerCallbackQuery({ text: 'Broker unreachable.' }).catch(() => {})
|
|
23711
|
-
return
|
|
23712
|
-
}
|
|
23713
|
-
if (revokeResult.kind === 'error') {
|
|
23714
|
-
await ctx.answerCallbackQuery({ text: `Revoke failed: ${revokeResult.msg}` }).catch(() => {})
|
|
23715
|
-
return
|
|
23716
|
-
}
|
|
23717
|
-
await ctx.answerCallbackQuery({ text: '✅ Revoked' }).catch(() => {})
|
|
23718
|
-
await ctx.editMessageText(
|
|
23719
|
-
richMessage(`✅ Grant \`${grantId}\` revoked. Token file removed.`),
|
|
23720
|
-
{ reply_markup: { inline_keyboard: [] } },
|
|
23721
|
-
).catch(() => {})
|
|
23722
|
-
return
|
|
23723
|
-
}
|
|
23724
|
-
|
|
23725
|
-
const cancelMatch = /^vg:cancel:(.+)$/.exec(data)
|
|
23726
|
-
if (cancelMatch) {
|
|
23727
|
-
await ctx.answerCallbackQuery({ text: 'Cancelled.' }).catch(() => {})
|
|
23728
|
-
await ctx.editMessageReplyMarkup({ reply_markup: { inline_keyboard: [] } }).catch(() => {})
|
|
23729
|
-
return
|
|
23730
|
-
}
|
|
23731
|
-
|
|
23732
|
-
// #227 grant wizard callbacks (vg:cancel bare, vg:agent:*, vg:key:*, vg:keys-continue,
|
|
23733
|
-
// vg:dur:*, vg:back:*, vg:generate). These come after the management callbacks above
|
|
23734
|
-
// because management uses vg:cancel:<id> (with trailing id) while the wizard uses
|
|
23735
|
-
// bare vg:cancel — the cancelMatch above only matches the id-suffixed form.
|
|
23736
|
-
//
|
|
23737
|
-
// Note: pre-#265 fix this function did `await ctx.answerCallbackQuery().catch(() => {})`
|
|
23738
|
-
// unconditionally up front. That meant the `vg:keys-continue` branch's
|
|
23739
|
-
// toast call (`Select at least one key.`) hit a Telegram error
|
|
23740
|
-
// ("query is too old or query ID is invalid") because the query was
|
|
23741
|
-
// already answered, and the toast never reached the user. Each branch
|
|
23742
|
-
// now owns its own ack.
|
|
23743
|
-
const chatId = String(ctx.chat?.id ?? ctx.from?.id ?? '')
|
|
23744
|
-
const ackSilently = () => ctx.answerCallbackQuery().catch(() => {})
|
|
23745
|
-
|
|
23746
|
-
// Cancel at any wizard step
|
|
23747
|
-
if (data === 'vg:cancel') {
|
|
23748
|
-
// Kernel-side dual-dispatch (MIGRATION.md §2, audit-only Phase 1):
|
|
23749
|
-
// if the user got as far as the confirm step, a kernel request_id
|
|
23750
|
-
// will be on the wizard state — record the deny decision so the
|
|
23751
|
-
// audit log captures the abandonment. No-op if the user cancelled
|
|
23752
|
-
// before the confirm step (or if the kernel was unreachable).
|
|
23753
|
-
const cancelState = pendingVaultOps.get(chatId)
|
|
23754
|
-
if (cancelState && cancelState.kind === 'grant-wizard') {
|
|
23755
|
-
await recordGrantWizardKernelDecision(
|
|
23756
|
-
cancelState.kernel_request_id,
|
|
23757
|
-
'deny',
|
|
23758
|
-
ctx.from?.id ?? 0,
|
|
23759
|
-
loadAccess().allowFrom,
|
|
23760
|
-
)
|
|
23761
|
-
}
|
|
23762
|
-
pendingVaultOps.delete(chatId)
|
|
23763
|
-
const msg = ctx.callbackQuery?.message
|
|
23764
|
-
if (msg && 'text' in msg) {
|
|
23765
|
-
await ctx.editMessageText('❌ Grant wizard cancelled.').catch(() => {})
|
|
23766
|
-
}
|
|
23767
|
-
await ackSilently()
|
|
23768
|
-
return
|
|
23769
|
-
}
|
|
23770
|
-
|
|
23771
|
-
const state = pendingVaultOps.get(chatId)
|
|
23772
|
-
if (!state || state.kind !== 'grant-wizard') {
|
|
23773
|
-
await ctx.editMessageText('⚠️ Wizard session expired. Run /vault grant to start again.').catch(() => {})
|
|
23774
|
-
await ackSilently()
|
|
23775
|
-
return
|
|
23776
|
-
}
|
|
23777
|
-
|
|
23778
|
-
// vg:agent:<name> — step 1 selection
|
|
23779
|
-
if (data.startsWith('vg:agent:')) {
|
|
23780
|
-
const agent = data.slice('vg:agent:'.length)
|
|
23781
|
-
const msgId = (ctx.callbackQuery?.message as { message_id?: number })?.message_id ?? state.wizardMsgId
|
|
23782
|
-
await grantWizardStep2(ctx, chatId, agent, msgId)
|
|
23783
|
-
await ackSilently()
|
|
23784
|
-
return
|
|
23785
|
-
}
|
|
23786
|
-
|
|
23787
|
-
// vg:key:<name> — step 2 toggle
|
|
23788
|
-
if (data.startsWith('vg:key:')) {
|
|
23789
|
-
const key = data.slice('vg:key:'.length)
|
|
23790
|
-
if (state.step !== 'keys') { await ackSilently(); return }
|
|
23791
|
-
const selectedSet = new Set(state.selectedKeys ?? [])
|
|
23792
|
-
if (selectedSet.has(key)) {
|
|
23793
|
-
selectedSet.delete(key)
|
|
23794
|
-
} else {
|
|
23795
|
-
selectedSet.add(key)
|
|
23796
|
-
}
|
|
23797
|
-
const updatedState = { ...state, selectedKeys: [...selectedSet] }
|
|
23798
|
-
pendingVaultOps.set(chatId, updatedState)
|
|
23799
|
-
const kb = buildGrantKeysKeyboard(state.availableKeys ?? [], selectedSet)
|
|
23800
|
-
await ctx.editMessageReplyMarkup({ reply_markup: kb }).catch(() => {})
|
|
23801
|
-
await ackSilently()
|
|
23802
|
-
return
|
|
23803
|
-
}
|
|
23804
|
-
|
|
23805
|
-
// vg:keys-continue — step 2 → 3
|
|
23806
|
-
if (data === 'vg:keys-continue') {
|
|
23807
|
-
if (state.step !== 'keys') { await ackSilently(); return }
|
|
23808
|
-
if (!state.selectedKeys || state.selectedKeys.length === 0) {
|
|
23809
|
-
// Toast-only ack: this is the branch the unconditional pre-ack
|
|
23810
|
-
// used to silently swallow. See #265.
|
|
23811
|
-
await ctx.answerCallbackQuery({ text: 'Select at least one key.' }).catch(() => {})
|
|
23812
|
-
return
|
|
23813
|
-
}
|
|
23814
|
-
await grantWizardStep3(ctx, chatId, state)
|
|
23815
|
-
await ackSilently()
|
|
23816
|
-
return
|
|
23817
|
-
}
|
|
23818
|
-
|
|
23819
|
-
// vg:dur:<value> — step 3 duration selection
|
|
23820
|
-
if (data.startsWith('vg:dur:')) {
|
|
23821
|
-
if (state.step !== 'duration') { await ackSilently(); return }
|
|
23822
|
-
const dur = data.slice('vg:dur:'.length)
|
|
23823
|
-
if (dur === 'custom') {
|
|
23824
|
-
// Ask for text reply with n d|h format
|
|
23825
|
-
pendingVaultOps.set(chatId, { ...state, awaitingCustomDuration: true })
|
|
23826
|
-
const msg = ctx.callbackQuery?.message
|
|
23827
|
-
if (msg && 'text' in msg && msg.text) {
|
|
23828
|
-
// Escape source text before re-rendering with HTML parse mode.
|
|
23829
|
-
// `msg.text` returns entities-stripped plain UTF-8; a raw
|
|
23830
|
-
// `<`/`>`/`&` in the wizard's prior-step body (e.g. a future
|
|
23831
|
-
// key or label) would crash the HTML re-parse and the bare
|
|
23832
|
-
// `.catch(() => {})` would swallow the failure silently — same
|
|
23833
|
-
// hazard PR #1158 caught on the operator-event card.
|
|
23834
|
-
await ctx.editMessageText(
|
|
23835
|
-
richMessage(escapeHtmlForTg(msg.text) + '\n\n_Send a duration like \`30d\` or \`12h\`:_'),
|
|
23836
|
-
{ reply_markup: buildGrantDurationKeyboard() },
|
|
23837
|
-
).catch(() => {})
|
|
23838
|
-
}
|
|
23839
|
-
await ackSilently()
|
|
23840
|
-
return
|
|
23841
|
-
}
|
|
23842
|
-
let ttlSeconds: number | null
|
|
23843
|
-
if (dur === 'never') {
|
|
23844
|
-
ttlSeconds = null
|
|
23845
|
-
} else if (dur === '1y') {
|
|
23846
|
-
ttlSeconds = 365 * 86400
|
|
23847
|
-
} else {
|
|
23848
|
-
ttlSeconds = parseGrantDuration(dur)
|
|
23849
|
-
if (ttlSeconds === null) { await ackSilently(); return }
|
|
23850
|
-
}
|
|
23851
|
-
const newState = { ...state, ttlSeconds, awaitingCustomDuration: false }
|
|
23852
|
-
await grantWizardConfirm(ctx, chatId, newState)
|
|
23853
|
-
await ackSilently()
|
|
23854
|
-
return
|
|
23855
|
-
}
|
|
23856
|
-
|
|
23857
|
-
// vg:back:duration — go back to step 2 (keys selection) from step 3
|
|
23858
|
-
if (data === 'vg:back:duration') {
|
|
23859
|
-
if (state.step !== 'duration') { await ackSilently(); return }
|
|
23860
|
-
const msgId = state.wizardMsgId
|
|
23861
|
-
await grantWizardStep2(ctx, chatId, state.agent!, msgId)
|
|
23862
|
-
await ackSilently()
|
|
23863
|
-
return
|
|
23864
|
-
}
|
|
23865
|
-
|
|
23866
|
-
// vg:generate — final step
|
|
23867
|
-
if (data === 'vg:generate') {
|
|
23868
|
-
if (state.step !== 'confirm') { await ackSilently(); return }
|
|
23869
|
-
await executeGrantWizard(ctx, chatId, state)
|
|
23870
|
-
await ackSilently()
|
|
23871
|
-
return
|
|
23872
|
-
}
|
|
23873
|
-
|
|
23874
|
-
// Unrecognised vg: sub-action
|
|
23875
|
-
await ackSilently()
|
|
23876
|
-
}
|
|
23877
|
-
|
|
23878
|
-
/**
|
|
23879
|
-
* Issue #44: write a deferred secret to the vault using the now-cached
|
|
23880
|
-
* passphrase. Confirms with a masked ref + slug; matches the "captured
|
|
23881
|
-
* N secret" UX of the cached-passphrase happy path so the user
|
|
23882
|
-
* experience is identical regardless of which path they came in on.
|
|
23883
|
-
*
|
|
23884
|
-
* Called from two places:
|
|
23885
|
-
* - The `passphrase-for-deferred` branch of the text-handler
|
|
23886
|
-
* pendingVaultOps intercept, after the passphrase is verified.
|
|
23887
|
-
* - The `vd:unlock` callback handler when a passphrase happens to
|
|
23888
|
-
* already be cached (rare but possible).
|
|
23889
|
-
*
|
|
23890
|
-
* If write fails, the deferred entry is preserved so the user can retry.
|
|
23891
|
-
*/
|
|
23892
|
-
async function executeDeferredSecretSave(
|
|
23893
|
-
ctx: Context,
|
|
23894
|
-
deferKey: string,
|
|
23895
|
-
passphrase: string,
|
|
23896
|
-
cardMessageId: number | undefined,
|
|
23897
|
-
): Promise<void> {
|
|
23898
|
-
const deferred = deferredSecrets.get(deferKey)
|
|
23899
|
-
if (!deferred) {
|
|
23900
|
-
if (cardMessageId != null) {
|
|
23901
|
-
await ctx.api
|
|
23902
|
-
.editMessageText(
|
|
23903
|
-
deferKey.split(':')[0]!,
|
|
23904
|
-
cardMessageId,
|
|
23905
|
-
'⚠️ This card expired before unlock — please re-send the secret.',
|
|
23906
|
-
{ reply_markup: { inline_keyboard: [] } },
|
|
23907
|
-
)
|
|
23908
|
-
.catch(() => {})
|
|
23909
|
-
}
|
|
23910
|
-
return
|
|
23911
|
-
}
|
|
23912
|
-
|
|
23913
|
-
// De-duplicate suggested_slug against existing vault keys by appending
|
|
23914
|
-
// _2 / _3 / … if needed. Same logic as the cached-passphrase happy
|
|
23915
|
-
// path uses (gateway.ts ~L2402 stash command).
|
|
23916
|
-
const slugBase = deferred.suggested_slug || 'secret'
|
|
23917
|
-
const listed = defaultVaultList(passphrase)
|
|
23918
|
-
const existing = new Set(listed.ok ? listed.keys : [])
|
|
23919
|
-
let slug = slugBase
|
|
23920
|
-
let n = 2
|
|
23921
|
-
while (existing.has(slug)) slug = `${slugBase}_${n++}`
|
|
23922
|
-
|
|
23923
|
-
const write = defaultVaultWrite(slug, deferred.text, passphrase)
|
|
23924
|
-
if (!write.ok) {
|
|
23925
|
-
// Classify the failure via the structured stderr markers emitted by
|
|
23926
|
-
// `switchroom vault` (issue #969 P0a). If it's a recognised marker,
|
|
23927
|
-
// render a clean actionable message instead of dumping the raw
|
|
23928
|
-
// "Vault file not found …" / "VAULT-NEEDS-APPROVAL …" blob the CLI
|
|
23929
|
-
// emits — that was the misleading-error half of #968.
|
|
23930
|
-
//
|
|
23931
|
-
// Keep the deferred entry so the user can retry by tapping again
|
|
23932
|
-
// once the underlying condition is fixed (broker started, host
|
|
23933
|
-
// approval granted, etc.).
|
|
23934
|
-
const parsed = parseVaultCliError(write.output)
|
|
23935
|
-
const rendered = renderVaultCliError(parsed, { verb: "save", key: slug })
|
|
23936
|
-
const body = rendered.suppressRaw
|
|
23937
|
-
? rendered.html
|
|
23938
|
-
: `⚠️ vault write failed:\n\`\`\`\n${write.output}\n\`\`\``
|
|
23939
|
-
if (cardMessageId != null) {
|
|
23940
|
-
await ctx.api
|
|
23941
|
-
.editMessageText(
|
|
23942
|
-
deferred.chat_id,
|
|
23943
|
-
cardMessageId,
|
|
23944
|
-
richMessage(`${body}\n\nRe-tap to retry.`),
|
|
23945
|
-
{
|
|
23946
|
-
reply_markup: buildDeferredSecretKeyboard(deferKey).inline_keyboard.length > 0
|
|
23947
|
-
? buildDeferredSecretKeyboard(deferKey)
|
|
23948
|
-
: undefined,
|
|
23949
|
-
},
|
|
23950
|
-
)
|
|
23951
|
-
.catch(() => {})
|
|
23952
|
-
}
|
|
23953
|
-
return
|
|
23954
|
-
}
|
|
23955
|
-
|
|
23956
|
-
deferredSecrets.delete(deferKey)
|
|
23957
|
-
const masked = maskToken(deferred.text)
|
|
23958
|
-
if (cardMessageId != null) {
|
|
23959
|
-
await ctx.api
|
|
23960
|
-
.editMessageText(
|
|
23961
|
-
deferred.chat_id,
|
|
23962
|
-
cardMessageId,
|
|
23963
|
-
richMessage(`✅ stored as \`vault:${slug}\` (masked: \`${masked}\`)\n\nReply \`rename NEW_NAME\` to relabel.`),
|
|
23964
|
-
{ reply_markup: { inline_keyboard: [] } },
|
|
23965
|
-
)
|
|
23966
|
-
.catch(() => {})
|
|
23967
|
-
}
|
|
23968
|
-
// Stage for follow-up rename, mirroring the cached-passphrase path.
|
|
23969
|
-
secretStaging.set({
|
|
23970
|
-
chat_id: deferred.chat_id,
|
|
23971
|
-
message_id: deferred.original_message_id,
|
|
23972
|
-
detection: {
|
|
23973
|
-
rule_id: 'deferred',
|
|
23974
|
-
matched_text: deferred.text,
|
|
23975
|
-
start: 0,
|
|
23976
|
-
end: deferred.text.length,
|
|
23977
|
-
confidence: 'high' as const,
|
|
23978
|
-
suppressed: false,
|
|
23979
|
-
suggested_slug: slug,
|
|
23980
|
-
},
|
|
23981
|
-
staged_at: Date.now(),
|
|
23982
|
-
})
|
|
23983
|
-
}
|
|
23984
|
-
|
|
23985
|
-
async function handleOperatorEventCallback(ctx: Context, data: string): Promise<void> {
|
|
23986
|
-
const senderId = String(ctx.from?.id ?? '')
|
|
23987
|
-
const access = loadAccess()
|
|
23988
|
-
if (!access.allowFrom.includes(senderId)) {
|
|
23989
|
-
await ctx.answerCallbackQuery({ text: 'Not authorized.' }).catch(() => {})
|
|
23990
|
-
return
|
|
23991
|
-
}
|
|
23992
|
-
|
|
23993
|
-
// Parse op:<action>:<encoded-agent>
|
|
23994
|
-
const parts = data.slice(3).split(':', 2) // drop "op:", then split action:agent
|
|
23995
|
-
if (parts.length !== 2) {
|
|
23996
|
-
await ctx.answerCallbackQuery({ text: 'Malformed operator-event callback.' }).catch(() => {})
|
|
23997
|
-
return
|
|
23998
|
-
}
|
|
23999
|
-
const [action, encodedAgent] = parts
|
|
24000
|
-
let agent: string
|
|
24001
|
-
try {
|
|
24002
|
-
agent = decodeURIComponent(encodedAgent)
|
|
24003
|
-
} catch {
|
|
24004
|
-
await ctx.answerCallbackQuery({ text: 'Bad agent name encoding.' }).catch(() => {})
|
|
24005
|
-
return
|
|
24006
|
-
}
|
|
24007
|
-
if (!/^[a-z0-9][a-z0-9_-]{0,50}$/.test(agent)) {
|
|
24008
|
-
await ctx.answerCallbackQuery({ text: 'Invalid agent name.' }).catch(() => {})
|
|
24009
|
-
return
|
|
24010
|
-
}
|
|
24011
|
-
|
|
24012
|
-
// #1150 audit P1: extract the source card text once so every branch
|
|
24013
|
-
// below can append a status line via finalizeCallback. Pre-fix `dismiss`
|
|
24014
|
-
// and `restart` stripped the keyboard but kept the original card body
|
|
24015
|
-
// verbatim — operator scrolling back couldn't see what they'd decided.
|
|
24016
|
-
// `reauth` didn't strip the keyboard at all → re-tappable mid-flow.
|
|
24017
|
-
//
|
|
24018
|
-
// HTML-escape the extracted text before concatenation. Telegram returns
|
|
24019
|
-
// `msg.text` as plain UTF-8 with entities stripped — any raw `<`, `>`,
|
|
24020
|
-
// or `&` characters in the original `detail` (operator-events.ts
|
|
24021
|
-
// `unknown-4xx`/`unknown-5xx` cards routinely carry API error bodies
|
|
24022
|
-
// with `<`/`>` in them) would be re-parsed as HTML tags when the
|
|
24023
|
-
// finalizeCallback edit fires with `parseMode: 'HTML'`. Telegram
|
|
24024
|
-
// rejects the edit, finalizeCallback's catch swallows it, the
|
|
24025
|
-
// keyboard never strips, and the operator re-taps → exact bug this
|
|
24026
|
-
// PR is meant to fix re-introduced. Escape once here so every branch
|
|
24027
|
-
// gets a safe-to-reparse value. We lose the original bold/italic
|
|
24028
|
-
// styling on the source body — acceptable, that styling was already
|
|
24029
|
-
// gone the moment `msg.text` was read instead of `msg.entities`.
|
|
24030
|
-
// (PR #1158 round 2 — review item F.)
|
|
24031
|
-
const sourceMsgText = (() => {
|
|
24032
|
-
const msg = ctx.callbackQuery?.message
|
|
24033
|
-
if (!msg || !('text' in msg) || !msg.text) return ''
|
|
24034
|
-
return escapeHtmlForTg(msg.text)
|
|
24035
|
-
})()
|
|
24036
|
-
|
|
24037
|
-
switch (action) {
|
|
24038
|
-
case 'dismiss': {
|
|
24039
|
-
// #1150 audit P1: was strip-only. Now appends a status line so
|
|
24040
|
-
// scrollback shows the dismissal.
|
|
24041
|
-
const status = `\n\n✗ _Dismissed by operator._`
|
|
24042
|
-
await finalizeCallback(ctx, {
|
|
24043
|
-
ackText: 'Dismissed',
|
|
24044
|
-
newText: sourceMsgText ? `${sourceMsgText}${status}` : status,
|
|
24045
|
-
// No synthInbound — dismiss is operator-only, no model in loop.
|
|
24046
|
-
})
|
|
24047
|
-
return
|
|
24048
|
-
}
|
|
24049
|
-
case 'restart': {
|
|
24050
|
-
const ok = triggerSelfRestart(agent, 'inline-button-restart')
|
|
24051
|
-
if (ok) {
|
|
24052
|
-
// #1150 audit P1: was reply + editMessageReplyMarkup (two
|
|
24053
|
-
// separate edits). Atomic via finalizeCallback now — the
|
|
24054
|
-
// status line is the announcement, no separate reply needed.
|
|
24055
|
-
const status = `\n\n🔄 _**${escapeHtmlForTg(agent)}** restart requested by operator._`
|
|
24056
|
-
await finalizeCallback(ctx, {
|
|
24057
|
-
ackText: `Restarting ${agent}…`,
|
|
24058
|
-
newText: sourceMsgText ? `${sourceMsgText}${status}` : status,
|
|
24059
|
-
})
|
|
24060
|
-
} else {
|
|
24061
|
-
// Failure-path: leave the keyboard tappable so the operator
|
|
24062
|
-
// can retry once they've followed the manual instructions
|
|
24063
|
-
// below. ack toast still fires.
|
|
24064
|
-
await ctx.answerCallbackQuery({ text: `Restart failed for ${agent}` }).catch(() => {})
|
|
24065
|
-
const isDocker = process.env.SWITCHROOM_RUNTIME === 'docker'
|
|
24066
|
-
const detail = isDocker
|
|
24067
|
-
? `cross-agent restart is not supported under docker. ` +
|
|
24068
|
-
`Restart from the host: \`docker compose -p switchroom restart agent-${agent}\`.`
|
|
24069
|
-
: 'restart trigger failed'
|
|
24070
|
-
await ctx.replyWithRichMessage(richMessage(`**Restart failed for ${agent}:** ${detail}`))
|
|
24071
|
-
}
|
|
24072
|
-
return
|
|
24073
|
-
}
|
|
24074
|
-
case 'reauth': {
|
|
24075
|
-
// #1150 audit P1: pre-fix the operator-event card's [Reauth] button
|
|
24076
|
-
// stayed tappable after the reauth flow started → operator could
|
|
24077
|
-
// re-tap and spawn a second concurrent flow that fights the first
|
|
24078
|
-
// for the login URL state. Strip the keyboard and append a status
|
|
24079
|
-
// line; the new reauth-flow's own messages appear below the
|
|
24080
|
-
// collapsed card.
|
|
24081
|
-
const status = `\n\n🔐 _Reauth started for **${escapeHtmlForTg(agent)}** — follow the login URL below._`
|
|
24082
|
-
await finalizeCallback(ctx, {
|
|
24083
|
-
ackText: `Starting reauth for ${agent}…`,
|
|
24084
|
-
newText: sourceMsgText ? `${sourceMsgText}${status}` : status,
|
|
24085
|
-
synthInbound: async () => {
|
|
24086
|
-
await runSwitchroomAuthCommand(ctx, ['auth', 'reauth', agent], `auth reauth ${agent}`)
|
|
24087
|
-
// PR3 supergroup-mode: key by (chat, thread) so an OAuth code
|
|
24088
|
-
// pasted into a different topic isn't mistakenly intercepted
|
|
24089
|
-
// as this flow's reauth code.
|
|
24090
|
-
const reauthThreadId = ctx.callbackQuery?.message?.message_thread_id
|
|
24091
|
-
pendingReauthFlows.set(
|
|
24092
|
-
chatKey(String(ctx.chat!.id), reauthThreadId ?? null) as string,
|
|
24093
|
-
{ agent, startedAt: Date.now() },
|
|
24094
|
-
)
|
|
24095
|
-
},
|
|
24096
|
-
})
|
|
24097
|
-
return
|
|
24098
|
-
}
|
|
24099
|
-
case 'logs': {
|
|
24100
|
-
await ctx.answerCallbackQuery({ text: 'Fetching logs…' }).catch(() => {})
|
|
24101
|
-
// Pick the right log source for the runtime. Under docker, the
|
|
24102
|
-
// gateway is INSIDE the agent container — calling `docker logs`
|
|
24103
|
-
// requires the host's docker socket which is deliberately not
|
|
24104
|
-
// mounted into agent containers. Under systemd, journalctl
|
|
24105
|
-
// works as before. v0.7.2 fixed `case 'restart'` but left this
|
|
24106
|
-
// path systemd-only.
|
|
24107
|
-
const isDocker = process.env.SWITCHROOM_RUNTIME === 'docker'
|
|
24108
|
-
if (isDocker) {
|
|
24109
|
-
await ctx.replyWithRichMessage(richMessage(
|
|
24110
|
-
`_Inline log fetch is not available under docker mode (no docker.sock in agent containers). ` +
|
|
24111
|
-
`Run from the host: \`docker logs --since 30m --tail 30 switchroom-${agent}\`_`,
|
|
24112
|
-
))
|
|
24113
|
-
return
|
|
24114
|
-
}
|
|
24115
|
-
try {
|
|
24116
|
-
const out = execFileSync(
|
|
24117
|
-
'journalctl',
|
|
24118
|
-
['--user', '-u', `switchroom-${agent}`, '-n', '30', '--no-pager', '--output=short-monotonic'],
|
|
24119
|
-
{ encoding: 'utf-8', timeout: 10000, stdio: ['ignore', 'pipe', 'pipe'] },
|
|
24120
|
-
) as string
|
|
24121
|
-
const trimmed = out.trim().slice(-3500)
|
|
24122
|
-
await ctx.replyWithRichMessage(richMessage(
|
|
24123
|
-
trimmed
|
|
24124
|
-
? `\`\`\`\n${trimmed.replace(/```/g, '```')}\n\`\`\``
|
|
24125
|
-
: `_No logs for ${agent}._`,
|
|
24126
|
-
))
|
|
24127
|
-
} catch (err) {
|
|
24128
|
-
await ctx.replyWithRichMessage(richMessage(
|
|
24129
|
-
`**logs failed:** ${escapeHtmlForTg((err as Error).message)}`,
|
|
24130
|
-
))
|
|
24131
|
-
}
|
|
24132
|
-
return
|
|
24133
|
-
}
|
|
24134
|
-
default: {
|
|
24135
|
-
await ctx.answerCallbackQuery({ text: `Unknown action: ${action}` }).catch(() => {})
|
|
24136
|
-
return
|
|
24137
|
-
}
|
|
24138
|
-
}
|
|
24139
|
-
}
|
|
24140
|
-
|
|
24141
|
-
// RFC H §7.3: the dashboard callback dispatcher is gone — there are
|
|
24142
|
-
// no auth: callback buttons in the new chat surface. We keep a no-op
|
|
24143
|
-
// stub so any stale pinned message that fires an `auth:*` tap is
|
|
24144
|
-
// silently dismissed instead of crashing the gateway.
|
|
24145
|
-
async function handleAuthDashboardCallback(ctx: Context): Promise<void> {
|
|
24146
|
-
const data = ctx.callbackQuery?.data ?? ''
|
|
24147
|
-
const currentAgent = getMyAgentName()
|
|
24148
|
-
|
|
24149
|
-
// auth:use:<label> — fleet-wide swap via broker.setActive (same path
|
|
24150
|
-
// /auth use takes from chat). Admin-gated via the broker's own
|
|
24151
|
-
// per-agent admin flag.
|
|
24152
|
-
if (data.startsWith('auth:use:')) {
|
|
24153
|
-
const label = data.slice('auth:use:'.length)
|
|
24154
|
-
if (!label) {
|
|
24155
|
-
try { await ctx.answerCallbackQuery({ text: 'Missing account label.', show_alert: false }) } catch { /* */ }
|
|
24156
|
-
return
|
|
24157
|
-
}
|
|
24158
|
-
try {
|
|
24159
|
-
const client = await getAuthBrokerClient(currentAgent)
|
|
24160
|
-
if (!client) {
|
|
24161
|
-
try { await ctx.answerCallbackQuery({ text: 'Broker unreachable.', show_alert: true }) } catch { /* */ }
|
|
24162
|
-
return
|
|
24163
|
-
}
|
|
24164
|
-
const result = await client.setActive(label)
|
|
24165
|
-
try {
|
|
24166
|
-
await ctx.answerCallbackQuery({
|
|
24167
|
-
text: `Switched fleet → ${result.active} (${result.fanned.length} agents)`,
|
|
24168
|
-
show_alert: false,
|
|
24169
|
-
})
|
|
24170
|
-
} catch { /* toast may fail on stale tap */ }
|
|
24171
|
-
// Edit the source message to reflect the new active. Leaving
|
|
24172
|
-
// the old keyboard intact would tempt a double-tap; we replace
|
|
24173
|
-
// the text + drop the keyboard so the user has to /auth again
|
|
24174
|
-
// to see fresh state.
|
|
24175
|
-
const msg = ctx.callbackQuery?.message
|
|
24176
|
-
if (msg) {
|
|
24177
|
-
// Wrap in swallowingApiCall per #1075 — stale callback-source
|
|
24178
|
-
// messages (deleted topic, expired) shouldn't crash the swap.
|
|
24179
|
-
await swallowingApiCall(
|
|
24180
|
-
() =>
|
|
24181
|
-
bot.api.editMessageText(
|
|
24182
|
-
msg.chat.id,
|
|
24183
|
-
msg.message_id,
|
|
24184
|
-
richMessage(
|
|
24185
|
-
`**Active account →** \`${result.active}\`\n` +
|
|
24186
|
-
`_Re-mirrored credentials for ${result.fanned.length} agent${result.fanned.length === 1 ? '' : 's'}._\n\n` +
|
|
24187
|
-
`_Tap /auth to see updated quota for the new active account._`,
|
|
24188
|
-
),
|
|
24189
|
-
{},
|
|
24190
|
-
),
|
|
24191
|
-
{ chat_id: String(msg.chat.id), verb: 'auth:use:edit' },
|
|
24192
|
-
)
|
|
24193
|
-
}
|
|
24194
|
-
} catch (err) {
|
|
24195
|
-
const msg = (err as Error)?.message ?? String(err)
|
|
24196
|
-
try {
|
|
24197
|
-
await ctx.answerCallbackQuery({
|
|
24198
|
-
text: `Switch failed: ${msg.slice(0, 180)}`,
|
|
24199
|
-
show_alert: true,
|
|
24200
|
-
})
|
|
24201
|
-
} catch { /* */ }
|
|
24202
|
-
}
|
|
24203
|
-
return
|
|
24204
|
-
}
|
|
24205
|
-
|
|
24206
|
-
// auth:refresh — re-render the /auth snapshot in-place with a fresh
|
|
24207
|
-
// live probe. Replaces the message body; keyboard stays. The `:demo`
|
|
24208
|
-
// variant re-renders with email masking intact (a ↻ tap on an
|
|
24209
|
-
// `/auth demo` / `/usage demo` card must not unmask mid-recording).
|
|
24210
|
-
if (data === 'auth:refresh' || data === 'auth:refresh:demo') {
|
|
24211
|
-
const refreshDemo = data === 'auth:refresh:demo'
|
|
24212
|
-
// Freshness throttle: each refresh fan-fires N live api.anthropic.com
|
|
24213
|
-
// probes (one per account — forceLive bypasses the broker's 45s
|
|
24214
|
-
// probe-on-open TTL, because an explicit ↻ tap is the user asking
|
|
24215
|
-
// for live-now data). Without this, a user double-tapping the ↻
|
|
24216
|
-
// button burns through their account's RPM budget on duplicate
|
|
24217
|
-
// work. Cap at one per AUTH_REFRESH_THROTTLE_MS per (chat, message)
|
|
24218
|
-
// pair.
|
|
24219
|
-
const refreshMsg = ctx.callbackQuery?.message
|
|
24220
|
-
if (refreshMsg) {
|
|
24221
|
-
const key = `${refreshMsg.chat.id}:${refreshMsg.message_id}`
|
|
24222
|
-
const lastAtMs = lastAuthRefreshAtMs.get(key) ?? 0
|
|
24223
|
-
const sinceLastMs = Date.now() - lastAtMs
|
|
24224
|
-
if (sinceLastMs < AUTH_REFRESH_THROTTLE_MS) {
|
|
24225
|
-
const waitS = Math.ceil((AUTH_REFRESH_THROTTLE_MS - sinceLastMs) / 1000)
|
|
24226
|
-
try {
|
|
24227
|
-
await ctx.answerCallbackQuery({
|
|
24228
|
-
text: `Just refreshed — try again in ${waitS}s`,
|
|
24229
|
-
show_alert: false,
|
|
24230
|
-
})
|
|
24231
|
-
} catch { /* */ }
|
|
24232
|
-
return
|
|
24233
|
-
}
|
|
24234
|
-
lastAuthRefreshAtMs.set(key, Date.now())
|
|
24235
|
-
}
|
|
24236
|
-
try {
|
|
24237
|
-
const client = await getAuthBrokerClient(currentAgent)
|
|
24238
|
-
if (!client) {
|
|
24239
|
-
try { await ctx.answerCallbackQuery({ text: 'Broker unreachable.', show_alert: true }) } catch { /* */ }
|
|
24240
|
-
return
|
|
24241
|
-
}
|
|
24242
|
-
const state = await client.listState()
|
|
24243
|
-
// Broker-routed probe (#1336) — see gateway.ts:8910 for diagnosis.
|
|
24244
|
-
// forceLive=true: an explicit ↻ tap must bypass the broker's
|
|
24245
|
-
// probe-on-open TTL — pre-fix, a tap inside the TTL window served
|
|
24246
|
-
// the cached snapshot while stamping "Live · refreshed 0s ago".
|
|
24247
|
-
const probeResp = state.accounts.length > 0
|
|
24248
|
-
? await client.probeQuota(state.accounts.map((a) => a.label), undefined, true).catch(() => ({ results: [] }))
|
|
24249
|
-
: { results: [] }
|
|
24250
|
-
// #2495 Change 2 — even under forceLive a failed upstream probe falls
|
|
24251
|
-
// back to the broker cache (served:"cache"); stamp "⚠ cached Nm ago"
|
|
24252
|
-
// instead of a false live stamp, same as the /auth and /usage paths.
|
|
24253
|
-
const { quotas, staleCachedAtMs } = zipProbeResults(
|
|
24254
|
-
state.accounts.map((a) => a.label),
|
|
24255
|
-
probeResp.results,
|
|
24256
|
-
)
|
|
24257
|
-
const tz = process.env.SWITCHROOM_TIMEZONE ?? process.env.TZ ?? 'UTC'
|
|
24258
|
-
const { renderAuthSnapshotFormat2, buildSnapshotsFromState, buildSnapshotKeyboard } = await import(
|
|
24259
|
-
'../auth-snapshot-format.js'
|
|
24260
|
-
)
|
|
24261
|
-
const snapshots = buildSnapshotsFromState(state, quotas)
|
|
24262
|
-
// Single clock for card body + keyboard so health classification
|
|
24263
|
-
// can't disagree between the two (#2495 folded nit A).
|
|
24264
|
-
const renderNow = new Date()
|
|
24265
|
-
const text = renderAuthSnapshotFormat2(snapshots, {
|
|
24266
|
-
tz,
|
|
24267
|
-
now: renderNow,
|
|
24268
|
-
demo: refreshDemo,
|
|
24269
|
-
// Honesty backstop (same as /usage): a TOTAL probe failure (zero
|
|
24270
|
-
// result rows, nothing served from cache) renders an explicit
|
|
24271
|
-
// "probe failed" marker instead of a false "Live" footer next to
|
|
24272
|
-
// no-data rows.
|
|
24273
|
-
...(staleCachedAtMs != null
|
|
24274
|
-
? { staleCachedAtMs }
|
|
24275
|
-
: probeResp.results.length > 0
|
|
24276
|
-
? { liveProbedAtMs: renderNow.getTime() }
|
|
24277
|
-
: { probeFailed: true }),
|
|
24278
|
-
})
|
|
24279
|
-
const kbRows = buildSnapshotKeyboard(snapshots, { now: renderNow, demo: refreshDemo })
|
|
24280
|
-
const inline_keyboard = kbRows.map((row) =>
|
|
24281
|
-
row.map((b) => {
|
|
24282
|
-
if (b.callbackData) return { text: b.text, callback_data: b.callbackData }
|
|
24283
|
-
if (b.insertText) return { text: b.text, switch_inline_query_current_chat: b.insertText }
|
|
24284
|
-
return { text: b.text, callback_data: 'auth:noop' }
|
|
24285
|
-
}),
|
|
24286
|
-
)
|
|
24287
|
-
const msg = ctx.callbackQuery?.message
|
|
24288
|
-
if (msg) {
|
|
24289
|
-
await swallowingApiCall(
|
|
24290
|
-
() =>
|
|
24291
|
-
bot.api.editMessageText(msg.chat.id, msg.message_id, richMessage(text), {
|
|
24292
|
-
reply_markup: { inline_keyboard },
|
|
24293
|
-
}),
|
|
24294
|
-
{ chat_id: String(msg.chat.id), verb: 'auth:refresh:edit' },
|
|
24295
|
-
)
|
|
24296
|
-
}
|
|
24297
|
-
try { await ctx.answerCallbackQuery({ text: 'Refreshed.', show_alert: false }) } catch { /* */ }
|
|
24298
|
-
} catch (err) {
|
|
24299
|
-
const msg = (err as Error)?.message ?? String(err)
|
|
24300
|
-
try {
|
|
24301
|
-
await ctx.answerCallbackQuery({
|
|
24302
|
-
text: `Refresh failed: ${msg.slice(0, 180)}`,
|
|
24303
|
-
show_alert: true,
|
|
24304
|
-
})
|
|
24305
|
-
} catch { /* */ }
|
|
24306
|
-
}
|
|
24307
|
-
return
|
|
24308
|
-
}
|
|
24309
|
-
|
|
24310
|
-
// Unknown auth:* — likely from a too-old message. Dismiss with a
|
|
24311
|
-
// hint pointing at the canonical re-render verb.
|
|
24312
|
-
try {
|
|
24313
|
-
await ctx.answerCallbackQuery({
|
|
24314
|
-
text: 'Unknown auth button. Send /auth for current state.',
|
|
24315
|
-
show_alert: false,
|
|
24316
|
-
})
|
|
24317
|
-
} catch { /* */ }
|
|
24318
|
-
}
|
|
24319
|
-
|
|
24320
|
-
// /reauth was removed in v0.6.13 — the `/auth` dashboard's
|
|
24321
|
-
// `🔄 Reauth default` button fires the same flow (the `case 'reauth':`
|
|
24322
|
-
// callback dispatch calls `runSwitchroomAuthCommand` and seeds
|
|
24323
|
-
// `pendingReauthFlows`). The OAuth code paste-back is caught by the
|
|
24324
|
-
// generic message intercept that watches `pendingReauthFlows` —
|
|
24325
|
-
// pasting the code into chat now Just Works without a typed entry
|
|
24326
|
-
// point. Removed surfaces in this PR:
|
|
24327
|
-
// - bot.command('reauth', ...) → use /auth → 🔄 Reauth
|
|
24328
|
-
// - /reauth <code|url> paste-back → paste into chat
|
|
24329
|
-
// - /reauth <other-agent> targeting → use that agent's /auth
|
|
24330
|
-
|
|
24331
|
-
bot.command('vault', async ctx => {
|
|
24332
|
-
if (!isAuthorizedSender(ctx)) return
|
|
24333
|
-
const chatId = String(ctx.chat!.id)
|
|
24334
|
-
const args = (typeof ctx.match === "string" ? ctx.match : "").trim().split(/\s+/).filter(Boolean)
|
|
24335
|
-
const sub = args[0]?.toLowerCase()
|
|
24336
|
-
const key = args[1]
|
|
24337
|
-
if (!sub || sub === 'help') {
|
|
24338
|
-
await switchroomReply(ctx, [
|
|
24339
|
-
'**Vault commands**',
|
|
24340
|
-
'/vault list — list all secret keys',
|
|
24341
|
-
'/vault get <key> — read a secret value',
|
|
24342
|
-
'/vault set <key> — set a secret',
|
|
24343
|
-
'/vault delete <key> — remove a secret',
|
|
24344
|
-
'/vault status — show broker state',
|
|
24345
|
-
'/vault unlock — unlock the broker (prompts for passphrase)',
|
|
24346
|
-
'/vault lock — lock the broker',
|
|
24347
|
-
'/vault grant — mint a capability token (inline wizard)',
|
|
24348
|
-
'/vault grants [agent] — list active capability grants (tap to revoke)',
|
|
24349
|
-
'/vault audit <agent> — unified view of an agent\'s vault access',
|
|
24350
|
-
'',
|
|
24351
|
-
'Your passphrase is cached in memory for 30 min after first use.',
|
|
24352
|
-
].join('\n'), { html: true })
|
|
22101
|
+
bot.command('vault', async ctx => {
|
|
22102
|
+
if (!isAuthorizedSender(ctx)) return
|
|
22103
|
+
const chatId = String(ctx.chat!.id)
|
|
22104
|
+
const args = (typeof ctx.match === "string" ? ctx.match : "").trim().split(/\s+/).filter(Boolean)
|
|
22105
|
+
const sub = args[0]?.toLowerCase()
|
|
22106
|
+
const key = args[1]
|
|
22107
|
+
if (!sub || sub === 'help') {
|
|
22108
|
+
await switchroomReply(ctx, [
|
|
22109
|
+
'**Vault commands**',
|
|
22110
|
+
'/vault list — list all secret keys',
|
|
22111
|
+
'/vault get <key> — read a secret value',
|
|
22112
|
+
'/vault set <key> — set a secret',
|
|
22113
|
+
'/vault delete <key> — remove a secret',
|
|
22114
|
+
'/vault status — show broker state',
|
|
22115
|
+
'/vault unlock — unlock the broker (prompts for passphrase)',
|
|
22116
|
+
'/vault lock — lock the broker',
|
|
22117
|
+
'/vault grant — mint a capability token (inline wizard)',
|
|
22118
|
+
'/vault grants [agent] — list active capability grants (tap to revoke)',
|
|
22119
|
+
'/vault audit <agent> — unified view of an agent\'s vault access',
|
|
22120
|
+
'',
|
|
22121
|
+
'Your passphrase is cached in memory for 30 min after first use.',
|
|
22122
|
+
].join('\n'), { html: true })
|
|
24353
22123
|
return
|
|
24354
22124
|
}
|
|
24355
22125
|
|
|
@@ -24887,150 +22657,27 @@ async function renderFleetDoctor(ctx: Context): Promise<void> {
|
|
|
24887
22657
|
await switchroomReply(ctx, formatDoctorReport(body), { html: true })
|
|
24888
22658
|
}
|
|
24889
22659
|
|
|
24890
|
-
|
|
24891
|
-
|
|
24892
|
-
|
|
24893
|
-
|
|
24894
|
-
|
|
24895
|
-
|
|
24896
|
-
|
|
24897
|
-
|
|
24898
|
-
|
|
24899
|
-
|
|
24900
|
-
|
|
24901
|
-
|
|
24902
|
-
|
|
24903
|
-
|
|
24904
|
-
|
|
24905
|
-
|
|
24906
|
-
|
|
24907
|
-
|
|
24908
|
-
|
|
24909
|
-
bot.command('grant', async ctx => {
|
|
24910
|
-
if (!isAuthorizedSender(ctx)) return
|
|
24911
|
-
const parts = getCommandArgs(ctx).split(/\s+/).filter(Boolean)
|
|
24912
|
-
if (parts.length === 0) { await switchroomReply(ctx, 'Usage: /grant <tool> or /grant <agent> <tool>'); return }
|
|
24913
|
-
let agentName: string; let tool: string
|
|
24914
|
-
if (parts.length === 1) { agentName = getMyAgentName(); tool = parts[0] }
|
|
24915
|
-
else { agentName = parts[0]; tool = parts.slice(1).join(' ') }
|
|
24916
|
-
try { assertSafeAgentName(agentName) } catch { await switchroomReply(ctx, 'Invalid agent name.'); return }
|
|
24917
|
-
await runSwitchroomCommand(ctx, ['agent', 'grant', agentName, tool], `grant ${agentName} ${tool}`)
|
|
24918
|
-
})
|
|
24919
|
-
|
|
24920
|
-
bot.command('dangerous', async ctx => {
|
|
24921
|
-
if (!isAuthorizedSender(ctx)) return
|
|
24922
|
-
const parts = getCommandArgs(ctx).split(/\s+/).filter(Boolean)
|
|
24923
|
-
let agentName: string; let off = false
|
|
24924
|
-
if (parts.length === 0) { agentName = getMyAgentName() }
|
|
24925
|
-
else if (parts.length === 1 && parts[0] === 'off') { agentName = getMyAgentName(); off = true }
|
|
24926
|
-
else { agentName = parts[0]; if (parts[1] === 'off') off = true }
|
|
24927
|
-
try { assertSafeAgentName(agentName) } catch { await switchroomReply(ctx, 'Invalid agent name.'); return }
|
|
24928
|
-
const args = ['agent', 'dangerous', agentName]; if (off) args.push('--off')
|
|
24929
|
-
await runSwitchroomCommand(ctx, args, `dangerous ${agentName}${off ? ' off' : ''}`)
|
|
24930
|
-
})
|
|
24931
|
-
|
|
24932
|
-
bot.command('permissions', async ctx => {
|
|
24933
|
-
if (!isAuthorizedSender(ctx)) return
|
|
24934
|
-
const agentName = (typeof ctx.match === "string" ? ctx.match : "").trim() || getMyAgentName()
|
|
24935
|
-
try { assertSafeAgentName(agentName) } catch { await switchroomReply(ctx, 'Invalid agent name.'); return }
|
|
24936
|
-
await runSwitchroomCommand(ctx, ['agent', 'permissions', agentName], `permissions ${agentName}`)
|
|
24937
|
-
})
|
|
24938
|
-
|
|
24939
|
-
// Drive-by cleanup (#927): the dead /update handler that lived here
|
|
24940
|
-
// was a pre-#919 stub. Grammy registers in order so the comprehensive
|
|
24941
|
-
// /update handler at line ~6516 (added in #919, hardened in #924,
|
|
24942
|
-
// docker-guarded in #934) fired first and this one never ran.
|
|
24943
|
-
// Removed to avoid future confusion.
|
|
24944
|
-
|
|
24945
|
-
bot.command('version', async ctx => {
|
|
24946
|
-
if (!isAuthorizedSender(ctx)) return
|
|
24947
|
-
try {
|
|
24948
|
-
let output: string
|
|
24949
|
-
try { output = switchroomExecCombined(['version'], 10000) }
|
|
24950
|
-
catch (err: unknown) { output = (err as any).stdout ?? (err as any).message ?? 'version failed' }
|
|
24951
|
-
const trimmed = stripAnsi(output).trim()
|
|
24952
|
-
if (!trimmed) { await switchroomReply(ctx, 'version: no output'); return }
|
|
24953
|
-
await switchroomReply(ctx, preBlock(formatSwitchroomOutput(trimmed)), { html: true })
|
|
24954
|
-
} catch (err: unknown) {
|
|
24955
|
-
await switchroomReply(ctx, `**version failed:**\n${preBlock(formatSwitchroomOutput((err as any).message ?? 'unknown error'))}`, { html: true })
|
|
24956
|
-
}
|
|
24957
|
-
})
|
|
24958
|
-
|
|
24959
|
-
|
|
24960
|
-
// /whoami — the operator's view of THIS agent's sandbox (the same
|
|
24961
|
-
// `config whoami` the agent itself can call as an MCP tool, and the host CLI
|
|
24962
|
-
// exposes). Read-only, isAuthorizedSender-gated like /version — surfaces
|
|
24963
|
-
// tools / MCP / vault key-NAMES (never values) / powers so the operator can
|
|
24964
|
-
// see at a glance what this agent is authorized for.
|
|
24965
|
-
bot.command('whoami', async ctx => {
|
|
24966
|
-
if (!isAuthorizedSender(ctx)) return
|
|
24967
|
-
const demo = hasDemoFlag(getCommandArgs(ctx))
|
|
24968
|
-
try {
|
|
24969
|
-
let raw: string
|
|
24970
|
-
try { raw = switchroomExecCombined(['config', 'whoami'], 10000) }
|
|
24971
|
-
catch (err: unknown) { raw = (err as any).stdout ?? (err as any).message ?? 'whoami failed' }
|
|
24972
|
-
const trimmed = stripAnsi(raw).trim()
|
|
24973
|
-
let card: string
|
|
24974
|
-
try { card = formatWhoamiCard(JSON.parse(trimmed.split('\n').pop() ?? trimmed), demo) }
|
|
24975
|
-
catch { card = preBlock(formatSwitchroomOutput(trimmed || 'whoami: no output')) }
|
|
24976
|
-
await switchroomReply(ctx, card, { html: true })
|
|
24977
|
-
} catch (err: unknown) {
|
|
24978
|
-
await switchroomReply(ctx, `**whoami failed:**\n${preBlock(formatSwitchroomOutput((err as any).message ?? 'unknown error'))}`, { html: true })
|
|
24979
|
-
}
|
|
24980
|
-
})
|
|
24981
|
-
|
|
24982
|
-
/** Compact HTML card from the `config whoami` JSON view. Names/booleans only.
|
|
24983
|
-
* `demo` (the `/whoami demo` suffix) masks the vault key NAMES via maskVaultKey
|
|
24984
|
-
* for screen recordings — agent/MCP/model/skills topology is left untouched
|
|
24985
|
-
* (out of scope). Off by default. */
|
|
24986
|
-
function formatWhoamiCard(v: {
|
|
24987
|
-
name?: string; persona?: string | null; model?: string | null; tier?: string;
|
|
24988
|
-
tools?: { allow?: string[]; deny?: string[] }; mcpServers?: string[]; skills?: string[];
|
|
24989
|
-
vault?: { key: string; readable: boolean }[];
|
|
24990
|
-
powers?: { admin?: boolean; root?: boolean; configEdit?: boolean; crossAgentHostVerbs?: boolean };
|
|
24991
|
-
scheduleCount?: number; memoryBackend?: string | null;
|
|
24992
|
-
}, demo = false): string {
|
|
24993
|
-
const esc = escapeHtmlForTg
|
|
24994
|
-
const yn = (b?: boolean) => (b ? '✓' : '✗')
|
|
24995
|
-
const lines: string[] = []
|
|
24996
|
-
lines.push(`👤 **${esc(v.name ?? '?')}** · ${esc(v.tier ?? 'standard')}`)
|
|
24997
|
-
if (v.persona) lines.push(esc(v.persona))
|
|
24998
|
-
if (v.model) lines.push(`Model: ${esc(v.model)}`)
|
|
24999
|
-
const allow = v.tools?.allow ?? []
|
|
25000
|
-
lines.push(`Tools: ${allow.length ? esc(allow.slice(0, 8).join(', ')) + (allow.length > 8 ? ` …(+${allow.length - 8})` : '') : '—'}`)
|
|
25001
|
-
if ((v.tools?.deny ?? []).length) lines.push(`Denied: ${esc((v.tools!.deny!).join(', '))}`)
|
|
25002
|
-
if ((v.mcpServers ?? []).length) lines.push(`MCP: ${esc(v.mcpServers!.join(', '))}`)
|
|
25003
|
-
if ((v.skills ?? []).length) lines.push(`Skills: ${esc(v.skills!.join(', '))}`)
|
|
25004
|
-
if ((v.vault ?? []).length) {
|
|
25005
|
-
lines.push(`Vault keys (names only): ${v.vault!.map(k => `${esc(demo ? maskVaultKey(k.key) : k.key)} ${yn(k.readable)}`).join(', ')}`)
|
|
25006
|
-
}
|
|
25007
|
-
const p = v.powers ?? {}
|
|
25008
|
-
lines.push(`Powers: admin ${yn(p.admin)} · root ${yn(p.root)} · config-edit ${yn(p.configEdit)} · cross-agent verbs ${yn(p.crossAgentHostVerbs)}`)
|
|
25009
|
-
lines.push(`Schedule: ${v.scheduleCount ?? 0} cron · Memory: ${esc(v.memoryBackend ?? 'none')}`)
|
|
25010
|
-
return lines.join('\n')
|
|
25011
|
-
}
|
|
25012
|
-
|
|
25013
|
-
bot.command('commands', async ctx => {
|
|
25014
|
-
if (!isAuthorizedSender(ctx)) return
|
|
25015
|
-
await switchroomReply(ctx, buildSwitchroomHelpText(getMyAgentName()), { html: true })
|
|
22660
|
+
// Ops/info slash commands (/doctor /grant /dangerous /permissions /version
|
|
22661
|
+
// /whoami /commands) extracted verbatim to bot-commands-ops-info.ts (#2996
|
|
22662
|
+
// Phase 5). Registered here to preserve grammy's in-order registration.
|
|
22663
|
+
registerOpsInfoCommands(bot, {
|
|
22664
|
+
AGENT_ADMIN,
|
|
22665
|
+
isAuthorizedSender,
|
|
22666
|
+
getMyAgentName,
|
|
22667
|
+
switchroomReply,
|
|
22668
|
+
buildDoctorScopeKeyboard,
|
|
22669
|
+
renderSelfDoctor,
|
|
22670
|
+
preBlock,
|
|
22671
|
+
formatSwitchroomOutput,
|
|
22672
|
+
getCommandArgs,
|
|
22673
|
+
assertSafeAgentName,
|
|
22674
|
+
runSwitchroomCommand,
|
|
22675
|
+
switchroomExecCombined,
|
|
22676
|
+
stripAnsi,
|
|
22677
|
+
hasDemoFlag,
|
|
22678
|
+
escapeHtmlForTg,
|
|
25016
22679
|
})
|
|
25017
22680
|
|
|
25018
|
-
async function registerSwitchroomBotCommands(): Promise<void> {
|
|
25019
|
-
// Slash-menu is deliberately trimmed from the full command catalogue.
|
|
25020
|
-
// See telegram-plugin/welcome-text.ts TELEGRAM_MENU_COMMANDS for the
|
|
25021
|
-
// rationale (mobile UX focus; ops primitives stay typable but out of
|
|
25022
|
-
// the autocomplete clutter). /commands surfaces the full list.
|
|
25023
|
-
await bot.api.setMyCommands(
|
|
25024
|
-
[...TELEGRAM_BASE_COMMANDS, ...TELEGRAM_SWITCHROOM_COMMANDS],
|
|
25025
|
-
{ scope: { type: 'all_private_chats' } },
|
|
25026
|
-
)
|
|
25027
|
-
// Group chats don't support /start pairing, so only the switchroom
|
|
25028
|
-
// commands are registered there.
|
|
25029
|
-
await bot.api.setMyCommands(
|
|
25030
|
-
TELEGRAM_SWITCHROOM_COMMANDS,
|
|
25031
|
-
{ scope: { type: 'all_group_chats' } },
|
|
25032
|
-
)
|
|
25033
|
-
}
|
|
25034
22681
|
|
|
25035
22682
|
// ─── Inline-button handler (permissions) ──────────────────────────────────
|
|
25036
22683
|
// Handles `perm:(allow|deny|always|asn|asb|back):<id>` — permission request buttons
|
|
@@ -27991,7 +25638,7 @@ void (async () => {
|
|
|
27991
25638
|
// lifetime.
|
|
27992
25639
|
scheduleAlwaysAllowPersistDrain()
|
|
27993
25640
|
|
|
27994
|
-
void registerSwitchroomBotCommands().catch(() => {})
|
|
25641
|
+
void registerSwitchroomBotCommands(bot).catch(() => {})
|
|
27995
25642
|
|
|
27996
25643
|
// #613 fix: pre-warm the chatAvailableReactions cache for every
|
|
27997
25644
|
// chat in access.allowFrom. Without this, the FIRST inbound
|