switchroom 0.19.19 → 0.19.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/dist/auth-broker/index.js +53 -0
  2. package/dist/cli/switchroom.js +2444 -1264
  3. package/dist/host-control/main.js +54 -1
  4. package/dist/vault/approvals/kernel-server.js +53 -0
  5. package/dist/vault/broker/server.js +53 -0
  6. package/package.json +4 -2
  7. package/skills/switchroom-release/SKILL.md +103 -20
  8. package/telegram-plugin/card-format.ts +92 -3
  9. package/telegram-plugin/dist/gateway/gateway.js +769 -172
  10. package/telegram-plugin/edit-flood-fuse.ts +477 -0
  11. package/telegram-plugin/format.ts +19 -7
  12. package/telegram-plugin/gateway/boot-sweep-gate.ts +164 -0
  13. package/telegram-plugin/gateway/callback-query-handlers.ts +454 -81
  14. package/telegram-plugin/gateway/gateway.ts +66 -56
  15. package/telegram-plugin/gateway/inbound-interceptors.ts +27 -4
  16. package/telegram-plugin/gateway/narrative-lane.ts +49 -3
  17. package/telegram-plugin/gateway/status-pin-api.ts +145 -0
  18. package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +325 -45
  19. package/telegram-plugin/retry-api-call.ts +15 -2
  20. package/telegram-plugin/send-gate.ts +1 -1
  21. package/telegram-plugin/status-no-truncate.ts +64 -1
  22. package/telegram-plugin/status-pin-driver.ts +50 -27
  23. package/telegram-plugin/status-pin.ts +43 -5
  24. package/telegram-plugin/tests/activity-card-send-gate.test.ts +275 -0
  25. package/telegram-plugin/tests/activity-card-wiring.test.ts +16 -7
  26. package/telegram-plugin/tests/boot-pin-sweep-wiring.test.ts +101 -0
  27. package/telegram-plugin/tests/boot-sweep-gate.test.ts +293 -0
  28. package/telegram-plugin/tests/boot-version-string.test.ts +0 -0
  29. package/telegram-plugin/tests/edit-flood-fuse.test.ts +431 -0
  30. package/telegram-plugin/tests/pinned-card-collapse.test.ts +356 -0
  31. package/telegram-plugin/tests/status-pin-api.test.ts +178 -0
  32. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +94 -11
  33. package/telegram-plugin/tests/status-pin.test.ts +106 -5
  34. package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +631 -1
  35. package/telegram-plugin/tests/tool-activity-summary.test.ts +19 -10
  36. package/telegram-plugin/tests/vault-approval-posture.test.ts +6 -1
  37. package/telegram-plugin/tests/vault-passphrase-retry.test.ts +666 -0
  38. package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +42 -21
  39. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +233 -1
  40. package/telegram-plugin/tool-activity-summary.ts +85 -13
  41. package/telegram-plugin/worker-activity-feed.ts +5 -1
  42. package/vendor/hindsight-memory/scripts/drain_pending.py +193 -25
  43. package/vendor/hindsight-memory/scripts/lib/pending.py +84 -5
  44. package/vendor/hindsight-memory/scripts/lib/retain_split.py +21 -10
  45. package/vendor/hindsight-memory/scripts/recall.py +74 -5
  46. package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +158 -4
  47. package/vendor/hindsight-memory/scripts/tests/test_pending_failure_class.py +105 -0
  48. package/vendor/hindsight-memory/scripts/tests/test_pending_wedge.py +300 -0
  49. package/vendor/hindsight-memory/scripts/tests/test_recall_degraded_notice.py +365 -0
  50. package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +12 -4
  51. package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +27 -2
  52. package/vendor/hindsight-memory/scripts/tests/test_retain_split.py +19 -11
  53. package/vendor/hindsight-memory/tests/test_drain_pending.py +28 -2
@@ -37,16 +37,16 @@
37
37
 
38
38
  import { execFileSync } from 'child_process'
39
39
  import { mkdirSync, writeFileSync } from 'fs'
40
- import { homedir } from 'os'
41
- import { join } from 'path'
40
+ import { dirname, join } from 'path'
42
41
  import { InlineKeyboard, type Context } from 'grammy'
43
42
  import { richMessage } from '../rich-send.js'
44
43
  import { finalizeCallback } from '../inline-keyboard-callbacks.js'
45
44
  import { retryWithThreadFallback, type RetryCallOpts } from '../retry-api-call.js'
46
45
  import {
47
- mintGrantViaBroker,
48
- listViaBroker,
49
- listGrantsViaBroker,
46
+ mintGrantViaBroker as realMintGrantViaBroker,
47
+ listViaBroker as realListViaBroker,
48
+ listGrantsViaBroker as realListGrantsViaBroker,
49
+ vaultTokenFilePath as realVaultTokenFilePath,
50
50
  revokeGrantViaBroker,
51
51
  } from '../../src/vault/broker/client.js'
52
52
  import {
@@ -152,6 +152,12 @@ export type PendingVaultOp =
152
152
  // sequentially. Each item carries its own stageId + card refs;
153
153
  // they're all in the same chat by construction (pendingVaultOps
154
154
  // map is keyed by chat_id).
155
+ //
156
+ // #3627: `attempts` counts WRONG passphrase entries so far for this
157
+ // queue. It lives on the pending-op (per passphrase ENTRY), not on the
158
+ // individual staged cards, because one entry drains the whole batch —
159
+ // a per-card counter would show confusing "2 attempts remaining" per
160
+ // card for what the operator experienced as a single typo.
155
161
  | {
156
162
  kind: 'passphrase-for-access-approve'
157
163
  items: Array<{
@@ -159,7 +165,11 @@ export type PendingVaultOp =
159
165
  cardChatId: string
160
166
  cardMessageId: number
161
167
  senderId: string
168
+ /** Forum topic the card lives in, so a retry prompt lands beside it. */
169
+ threadId?: number
162
170
  }>
171
+ /** Wrong-passphrase entries so far (0 on the first prompt). */
172
+ attempts?: number
163
173
  startedAt: number
164
174
  }
165
175
 
@@ -434,12 +444,129 @@ export interface CallbackQueryHandlersDeps {
434
444
  * whose rate-window retry also failed.
435
445
  */
436
446
  emitOperatorEvent: (event: OperatorEvent) => void
447
+
448
+ /**
449
+ * Broker seams, injectable for tests (#3627). Production leaves these unset
450
+ * and gets the real `src/vault/broker/client.js` functions.
451
+ *
452
+ * Why DI and not a module mock: CI sweeps this whole test directory with
453
+ * `bun test`, whose `mock.module` is PROCESS-GLOBAL and irreversible — a
454
+ * broker-client mock registered by one file leaks into every file that runs
455
+ * after it. Injection keeps the seam per-suite, matching the house pattern
456
+ * (telegram-plugin/tests/vault-write-posture.test.ts).
457
+ */
458
+ brokerMintGrant?: typeof realMintGrantViaBroker
459
+ brokerList?: typeof realListViaBroker
460
+ brokerListGrants?: typeof realListGrantsViaBroker
461
+ brokerVaultTokenFilePath?: typeof realVaultTokenFilePath
437
462
  }
438
463
 
439
464
  // Freshness throttle for the /auth dashboard ↻ refresh button — one live
440
465
  // probe fan-out per (chat, message) per window. Moved with the handler.
441
466
  const AUTH_REFRESH_THROTTLE_MS = 5_000
442
467
 
468
+ /**
469
+ * #3627 — how many passphrase entries the operator gets on a
470
+ * `vault_request_access` approval before the staged cards fail terminally.
471
+ * Counted per PASSPHRASE ENTRY (the `passphrase-for-access-approve` pending
472
+ * op), not per card: one entry drains the whole queued batch.
473
+ */
474
+ export const MAX_VAULT_PASSPHRASE_ATTEMPTS = 3
475
+
476
+ /**
477
+ * Result of one `performVaultAccessApproval` run. `passphrase-mismatch` is the
478
+ * ONLY retryable outcome (#3627): the stage is deliberately left alive and the
479
+ * card left in its "waiting for passphrase" state so the caller can re-prompt.
480
+ * Every other broker failure stays terminal and has already surfaced its own
481
+ * card edit / operator reply by the time it returns.
482
+ */
483
+ export type VaultAccessApprovalOutcome =
484
+ | { kind: 'ok' }
485
+ | { kind: 'passphrase-mismatch'; msg: string }
486
+ | { kind: 'failed'; msg: string }
487
+
488
+ /**
489
+ * True when a broker `mint_grant` error is the wrong-passphrase denial
490
+ * (`denied:passphrase-mismatch`, src/vault/broker/server.ts:2166) rather than
491
+ * an ACL / bad-request / internal failure. The broker's wire error drops the
492
+ * audit result code and carries only the human message ("supplied passphrase
493
+ * does not match the broker's unlocked passphrase"), so this matches on the
494
+ * message shape — deliberately narrow: an unrelated DENIED must NOT be
495
+ * retryable, or a genuinely refused mint would re-prompt three times.
496
+ */
497
+ export function isPassphraseMismatchBrokerError(msg: string): boolean {
498
+ const m = msg.toLowerCase()
499
+ if (!m.includes('passphrase')) return false
500
+ return m.includes('does not match') || m.includes('mismatch')
501
+ }
502
+
503
+ /**
504
+ * The `ACTION NEEDED: passphrase required` prompt body (#3627).
505
+ *
506
+ * Shared by the first prompt (after an Approve tap on a locked vault) and the
507
+ * wrong-passphrase re-prompt, so the header, the delete-on-read promise, and
508
+ * the 🚨 urgency icon can never drift apart between the two. 🚨 (not ⚠️):
509
+ * this message BLOCKS an approval the operator already tapped, so it outranks
510
+ * the generic-warning glyph the gateway uses everywhere else.
511
+ *
512
+ * A discriminated union, not one bag of optionals: the retry prompt has no
513
+ * agent/key to render (a batch spans several), and the first prompt has no
514
+ * attempt count — modelling them as one optional-heavy shape invites a caller
515
+ * to pass `''` for fields the other branch needs.
516
+ */
517
+ export type AccessPassphrasePromptSpec =
518
+ | {
519
+ kind: 'retry'
520
+ /** Attempts left AFTER the failure being reported. */
521
+ retryRemaining: number
522
+ itemCount: number
523
+ }
524
+ | {
525
+ kind: 'first'
526
+ /** `batch` = one entry covers several cards; `admin-only` = admin key. */
527
+ variant: 'batch' | 'admin-only' | 'locked'
528
+ itemCount: number
529
+ agentEscaped: string
530
+ key: string
531
+ }
532
+ export function buildAccessPassphrasePromptText(opts: AccessPassphrasePromptSpec): string {
533
+ const header = `**🚨🔐 ACTION NEEDED: passphrase required**`
534
+ if (opts.kind === 'retry') {
535
+ const plural = opts.retryRemaining === 1 ? 'attempt' : 'attempts'
536
+ return (
537
+ `${header}\n\n` +
538
+ `Wrong passphrase. ${opts.retryRemaining} ${plural} remaining.\n` +
539
+ `Type your vault passphrase again as your **next message**.\n` +
540
+ (opts.itemCount > 1
541
+ ? `One entry covers **${opts.itemCount}** pending approvals in this chat.\n`
542
+ : ``) +
543
+ `\n_We delete the passphrase message the moment we read it._`
544
+ )
545
+ }
546
+ if (opts.variant === 'batch') {
547
+ return (
548
+ `${header}\n\n` +
549
+ `Type your vault passphrase as your **next message**.\n` +
550
+ `One entry covers **${opts.itemCount}** pending approvals in this chat, no re-type per card.\n\n` +
551
+ `_We delete the passphrase message the moment we read it._`
552
+ )
553
+ }
554
+ if (opts.variant === 'admin-only') {
555
+ return (
556
+ `${header}\n\n` +
557
+ `\`${opts.key}\` is an **admin-only credential**.\n` +
558
+ `Type your vault passphrase as your **next message** to mint the grant for **${opts.agentEscaped}**.\n\n` +
559
+ `_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._`
560
+ )
561
+ }
562
+ return (
563
+ `${header}\n\n` +
564
+ `Your vault is locked.\n` +
565
+ `Reply with your passphrase as your **next message** to unlock and mint the grant for **${opts.agentEscaped}**.\n\n` +
566
+ `_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._`
567
+ )
568
+ }
569
+
443
570
  /**
444
571
  * Build the callback-query handler families over the injected gateway deps.
445
572
  * Bodies are verbatim from gateway.ts — behavior-preserving (#2996).
@@ -486,6 +613,13 @@ export function createCallbackQueryHandlers(deps: CallbackQueryHandlersDeps) {
486
613
  } = deps
487
614
  const bot = deps.bot as CallbackBotApi
488
615
  const lockedBot = deps.lockedBot as CallbackBotApi
616
+ // #3627: broker seams — the real client unless a test injects a fake. Bound
617
+ // to the ORIGINAL names so every call site below reads as a direct broker
618
+ // call (and the structural pins that anchor on those names keep holding).
619
+ const mintGrantViaBroker = deps.brokerMintGrant ?? realMintGrantViaBroker
620
+ const listViaBroker = deps.brokerList ?? realListViaBroker
621
+ const listGrantsViaBroker = deps.brokerListGrants ?? realListGrantsViaBroker
622
+ const vaultTokenFilePath = deps.brokerVaultTokenFilePath ?? realVaultTokenFilePath
489
623
 
490
624
  /**
491
625
  * Handle a callback_query from an auth dashboard button. Parses the
@@ -588,9 +722,13 @@ async function handleVaultRecentDenialCallback(ctx: Context, data: string): Prom
588
722
  // vault grant wizard. The agent restarts in the background pick up
589
723
  // the new token via SWITCHROOM_AGENT_NAME on next CLI invocation.
590
724
  const { token, id } = result
591
- const tokenPath = join(homedir(), '.switchroom', 'agents', agentName, '.vault-token')
725
+ // #3627: the path formula lives in the broker client (the module that
726
+ // MINTS the token and later reads it back), so the gateway can't drift from
727
+ // it — the inline `homedir()` copy this replaces silently ignored
728
+ // SWITCHROOM_AGENTS_DIR, writing tokens where the reader wouldn't look.
729
+ const tokenPath = vaultTokenFilePath(agentName)
592
730
  try {
593
- mkdirSync(join(homedir(), '.switchroom', 'agents', agentName), { recursive: true })
731
+ mkdirSync(dirname(tokenPath), { recursive: true })
594
732
  writeFileSync(tokenPath, token, { mode: 0o600 })
595
733
  } catch (err) {
596
734
  await switchroomReply(
@@ -666,13 +804,201 @@ type AccessApprovalAttestation =
666
804
  | { kind: 'passphrase'; passphrase: string }
667
805
  | { kind: 'posture' }
668
806
 
807
+ /**
808
+ * #3627 item 2 — edit a `vault_request_access` card to its RESOLVED state
809
+ * (granted / failed / already-covered), with a guaranteed operator-visible
810
+ * outcome.
811
+ *
812
+ * Every resolution edit used to be `.catch(() => {})`: when the edit itself
813
+ * failed (message deleted, flood wait, topic gone) the card stayed frozen on
814
+ * "waiting for your vault passphrase" and the operator had NO signal that the
815
+ * request had in fact resolved. Now the failure is logged and the same
816
+ * resolved text is re-sent as a fresh message, so the outcome is never
817
+ * silently swallowed.
818
+ *
819
+ * Never throws: both the edit and the fallback send are contained, because
820
+ * every caller runs it after the grant has already been minted/refused and
821
+ * must not have its own control flow broken by a Telegram-side failure.
822
+ */
823
+ async function editResolvedCard(
824
+ ctx: Context,
825
+ target: { chat_id: string; threadId?: number },
826
+ messageId: number,
827
+ markdown: string,
828
+ label: string,
829
+ ): Promise<void> {
830
+ // messageId <= 0 means "no card to edit" (a stage whose card id was never
831
+ // recorded) — go straight to the fresh-message path so the outcome still
832
+ // reaches the operator.
833
+ if (messageId > 0) {
834
+ try {
835
+ // Through robustApiCall (not raw): a flood-wait must be RETRIED, not
836
+ // treated as an edit failure — falling back to a fresh message on a
837
+ // 429 would double-post the resolution.
838
+ await robustApiCall(
839
+ () =>
840
+ ctx.api.editMessageText(target.chat_id, messageId, richMessage(markdown), {
841
+ reply_markup: { inline_keyboard: [] },
842
+ }),
843
+ { chat_id: target.chat_id, verb: `vault_request_access.${label}_edit` },
844
+ )
845
+ return
846
+ } catch (err) {
847
+ process.stderr.write(
848
+ `telegram gateway: vault card resolution edit FAILED (${label}) ` +
849
+ `chat=${target.chat_id} msg=${messageId}: ${String(err)} — sending fallback message\n`,
850
+ )
851
+ }
852
+ }
853
+ try {
854
+ await retryWithThreadFallback<{ message_id: number }>(
855
+ robustApiCall,
856
+ (tid) =>
857
+ lockedBot.api.sendRichMessage(target.chat_id, richMessage(markdown), {
858
+ ...(tid != null && Number.isFinite(tid) ? { message_thread_id: tid } : {}),
859
+ }),
860
+ {
861
+ threadId: target.threadId,
862
+ chat_id: target.chat_id,
863
+ verb: `vault_request_access.${label}_fallback`,
864
+ },
865
+ )
866
+ } catch (err) {
867
+ process.stderr.write(
868
+ `telegram gateway: vault card resolution FALLBACK SEND failed (${label}) ` +
869
+ `chat=${target.chat_id}: ${String(err)}\n`,
870
+ )
871
+ }
872
+ }
873
+
874
+ /**
875
+ * #3627 item 1/3 — send the `ACTION NEEDED: passphrase required` prompt as a
876
+ * fresh message (never an in-place edit: see the long rationale at the first
877
+ * prompt call site). Shared by the initial prompt and the wrong-passphrase
878
+ * re-prompt so both land at the bottom of the chat WITH a notification.
879
+ */
880
+ async function sendAccessPassphrasePrompt(
881
+ target: { chat_id: string; threadId?: number },
882
+ spec: AccessPassphrasePromptSpec,
883
+ ): Promise<void> {
884
+ const promptText = buildAccessPassphrasePromptText(spec)
885
+ // #1075: deleted-topic safe — fall back to the main chat. Wrapped
886
+ // through robustApiCall for flood-wait retries, mirroring the card send.
887
+ await retryWithThreadFallback<{ message_id: number }>(
888
+ robustApiCall,
889
+ (tid) =>
890
+ lockedBot.api.sendRichMessage(target.chat_id, richMessage(promptText), {
891
+ ...(tid != null && Number.isFinite(tid) ? { message_thread_id: tid } : {}),
892
+ }),
893
+ {
894
+ threadId: target.threadId,
895
+ chat_id: target.chat_id,
896
+ verb: 'vault_request_access.passphrase_prompt',
897
+ },
898
+ ).catch((err: unknown) => {
899
+ // #3627: never silent. A prompt that failed to send leaves the operator
900
+ // staring at a card that says "waiting for your vault passphrase" with
901
+ // nothing to reply to — the same silent-failure class item 2 closes on
902
+ // the resolution edits.
903
+ process.stderr.write(
904
+ `telegram gateway: vault passphrase prompt send FAILED chat=${target.chat_id} ` +
905
+ `kind=${spec.kind}: ${String(err)}\n`,
906
+ )
907
+ })
908
+ }
909
+
910
+ /**
911
+ * #3627 item 3 — decide what happens after a passphrase entry that the broker
912
+ * refused as a mismatch for one or more staged cards.
913
+ *
914
+ * Attempts are counted on the PASSPHRASE ENTRY (`attempts` on the pending op),
915
+ * not per card: one entry drains the whole queued batch, so a per-card counter
916
+ * would report "2 attempts remaining" N times for a single typo.
917
+ *
918
+ * - Under the cap → re-arm the pending op with ONLY the still-unresolved
919
+ * stages and re-prompt with the remaining count. The stages and their cards
920
+ * stay alive, so the next entry resumes exactly where this one failed.
921
+ * - At the cap → today's terminal behaviour: drop each stage, strip its card
922
+ * and say plainly that the agent must re-issue.
923
+ *
924
+ * The (wrong) passphrase is dropped from the chat cache either way, so a
925
+ * later Approve tap can never silently re-use it.
926
+ */
927
+ async function resolveAccessApprovalPassphraseMismatch(
928
+ ctx: Context,
929
+ args: {
930
+ chat_id: string
931
+ failed: Array<{
932
+ stageId: string
933
+ cardChatId: string
934
+ cardMessageId: number
935
+ senderId: string
936
+ threadId?: number
937
+ }>
938
+ priorAttempts: number
939
+ brokerMsg: string
940
+ },
941
+ ): Promise<void> {
942
+ const { chat_id, failed, priorAttempts, brokerMsg } = args
943
+ if (failed.length === 0) return
944
+ vaultPassphraseCache.delete(chat_id)
945
+ const attempts = priorAttempts + 1
946
+ const remaining = MAX_VAULT_PASSPHRASE_ATTEMPTS - attempts
947
+ const threadId = failed.find((it) => it.threadId != null)?.threadId
948
+ process.stderr.write(
949
+ `telegram gateway: vault_request_access passphrase mismatch chat=${chat_id} ` +
950
+ `stages=${failed.map((f) => f.stageId).join(',')} attempts=${attempts}/${MAX_VAULT_PASSPHRASE_ATTEMPTS}\n`,
951
+ )
952
+ if (remaining > 0) {
953
+ // Union with any queue still open for this chat instead of overwriting it.
954
+ // The batch drain deletes the op before it runs, so this is normally just
955
+ // `failed`; but the cached-passphrase tap path can hit a mismatch while
956
+ // OTHER cards sit queued for the same chat, and clobbering that queue
957
+ // would strand them (staged, card still saying "waiting", no pending op
958
+ // to route the next passphrase entry back to them).
959
+ const open = pendingVaultOps.get(chat_id)
960
+ const carried =
961
+ open?.kind === 'passphrase-for-access-approve'
962
+ ? open.items.filter((it) => !failed.some((f) => f.stageId === it.stageId))
963
+ : []
964
+ const requeued = [...failed, ...carried]
965
+ pendingVaultOps.set(chat_id, {
966
+ kind: 'passphrase-for-access-approve',
967
+ items: requeued,
968
+ attempts,
969
+ // Restart the input TTL: the operator is being asked again NOW, so the
970
+ // clock for their reply starts now too.
971
+ startedAt: Date.now(),
972
+ })
973
+ await sendAccessPassphrasePrompt(
974
+ { chat_id, ...(threadId != null ? { threadId } : {}) },
975
+ { kind: 'retry', retryRemaining: remaining, itemCount: requeued.length },
976
+ )
977
+ return
978
+ }
979
+ // Cap reached — terminal, as before #3627.
980
+ for (const item of failed) {
981
+ pendingVaultRequestAccesses.delete(item.stageId)
982
+ pendingCardStore.remove(item.stageId)
983
+ await editResolvedCard(
984
+ ctx,
985
+ { chat_id: item.cardChatId, ...(item.threadId != null ? { threadId: item.threadId } : {}) },
986
+ item.cardMessageId,
987
+ `❌ **Too many wrong passphrase attempts** (${MAX_VAULT_PASSPHRASE_ATTEMPTS}). ` +
988
+ `This request was cancelled — ask the agent to re-issue it.\n` +
989
+ `_Broker: ${escapeHtmlForTg(brokerMsg)}_`,
990
+ 'passphrase_lockout',
991
+ )
992
+ }
993
+ }
994
+
669
995
  async function performVaultAccessApproval(
670
996
  ctx: Context,
671
997
  pending: PendingVaultRequestAccess,
672
998
  stageId: string,
673
999
  senderId: string,
674
1000
  attestation: AccessApprovalAttestation,
675
- ): Promise<void> {
1001
+ ): Promise<VaultAccessApprovalOutcome> {
676
1002
  const brokerAuthOpts =
677
1003
  attestation.kind === 'passphrase'
678
1004
  ? { passphrase: attestation.passphrase }
@@ -693,19 +1019,23 @@ async function performVaultAccessApproval(
693
1019
  pendingVaultRequestAccesses.delete(stageId)
694
1020
  pendingCardStore.remove(stageId)
695
1021
  if (pending.card_message_id != null) {
696
- await ctx.api
697
- .editMessageText(
698
- pending.chat_id,
699
- pending.card_message_id,
700
- `ℹ️ **${escapeHtmlForTg(pending.agent)}** already has standing-ACL access to ` +
701
- `\`${pending.key}\` (schedule.secrets[]). ` +
702
- `**No grant minted** — a token would shadow the standing ACL. ` +
703
- richMessage(`The agent can read it directly.`),
704
- { reply_markup: { inline_keyboard: [] } },
705
- )
706
- .catch(() => {})
1022
+ // #3627 drive-by: this text used to be a raw string CONCATENATED
1023
+ // with a `richMessage()` object, which renders as
1024
+ // `…already has access[object Object]` with literal `**` markers
1025
+ // (the edit ran with parse_mode unset). Whole body now goes
1026
+ // through the one rich path, like every sibling resolution edit.
1027
+ await editResolvedCard(
1028
+ ctx,
1029
+ pending,
1030
+ pending.card_message_id,
1031
+ `ℹ️ **${escapeHtmlForTg(pending.agent)}** already has standing-ACL access to ` +
1032
+ `\`${pending.key}\` (schedule.secrets[]). ` +
1033
+ `**No grant minted** — a token would shadow the standing ACL. ` +
1034
+ `The agent can read it directly.`,
1035
+ 'standing_acl',
1036
+ )
707
1037
  }
708
- return
1038
+ return { kind: 'ok' }
709
1039
  }
710
1040
  } catch {
711
1041
  // Probe failed: fall through and mint as before (fail-open).
@@ -782,32 +1112,47 @@ async function performVaultAccessApproval(
782
1112
  const result = await mintGrantViaBroker(mintArgs)
783
1113
  if (result.kind === 'unreachable') {
784
1114
  await switchroomReply(ctx, `🔴 Broker unreachable: ${escapeHtmlForTg(result.msg)}`, { html: true })
785
- return
1115
+ return { kind: 'failed', msg: result.msg }
786
1116
  }
787
1117
  if (result.kind === 'error') {
788
- // Mint refused (most likely wrong passphrase). Drop the staged
1118
+ // #3627 item 3: a WRONG PASSPHRASE is retryable. Leave the stage and
1119
+ // the card exactly as they are ("waiting for your vault passphrase")
1120
+ // and hand the decision to the caller, which owns the per-entry
1121
+ // attempt counter and re-prompts or locks out. Any other mint refusal
1122
+ // (ACL, bad request, broker internals) stays terminal below — a
1123
+ // re-prompt would just burn the operator's attempts on an error no
1124
+ // passphrase can fix.
1125
+ // Gated on the passphrase attestation: under `approvalAuth: telegram-id`
1126
+ // the gateway never sends a passphrase, so there is nothing for the
1127
+ // operator to retype — a mismatch-shaped message there must stay
1128
+ // terminal rather than leaving the stage alive with no re-prompt.
1129
+ if (attestation.kind === 'passphrase' && isPassphraseMismatchBrokerError(result.msg)) {
1130
+ return { kind: 'passphrase-mismatch', msg: result.msg }
1131
+ }
1132
+ // Mint refused for a non-passphrase reason. Drop the staged
789
1133
  // request so a re-attempt starts cleanly. The operator can ask
790
1134
  // the agent to re-issue, or the broker error message will tell
791
1135
  // them the next step.
792
1136
  pendingVaultRequestAccesses.delete(stageId)
793
1137
  pendingCardStore.remove(stageId)
794
1138
  if (pending.card_message_id != null) {
795
- await ctx.api
796
- .editMessageText(
797
- pending.chat_id,
798
- pending.card_message_id,
799
- richMessage(`**mint_grant failed:** ${escapeHtmlForTg(result.msg)}`),
800
- { reply_markup: { inline_keyboard: [] } },
801
- )
802
- .catch(() => {})
1139
+ await editResolvedCard(
1140
+ ctx,
1141
+ pending,
1142
+ pending.card_message_id,
1143
+ `**mint_grant failed:** ${escapeHtmlForTg(result.msg)}`,
1144
+ 'mint_failed',
1145
+ )
803
1146
  }
804
- return
1147
+ return { kind: 'failed', msg: result.msg }
805
1148
  }
806
1149
 
807
1150
  const { token, id } = result
808
- const tokenPath = join(homedir(), '.switchroom', 'agents', pending.agent, '.vault-token')
1151
+ // #3627: single source of truth for the token path (see the note on the
1152
+ // deferred-secret write above) — honours SWITCHROOM_AGENTS_DIR.
1153
+ const tokenPath = vaultTokenFilePath(pending.agent)
809
1154
  try {
810
- mkdirSync(join(homedir(), '.switchroom', 'agents', pending.agent), { recursive: true })
1155
+ mkdirSync(dirname(tokenPath), { recursive: true })
811
1156
  writeFileSync(tokenPath, token, { mode: 0o600 })
812
1157
  } catch (err) {
813
1158
  await switchroomReply(
@@ -818,7 +1163,7 @@ async function performVaultAccessApproval(
818
1163
  `--keys ${escapeHtmlForTg(pending.key)} --duration ${Math.round(pending.ttl_seconds / 86400)}d\` on the host._`,
819
1164
  { html: true },
820
1165
  )
821
- return
1166
+ return { kind: 'failed', msg: String(err) }
822
1167
  }
823
1168
 
824
1169
  pendingVaultRequestAccesses.delete(stageId)
@@ -833,25 +1178,22 @@ async function performVaultAccessApproval(
833
1178
  getVaultApprovalAuthMode() === 'telegram-id'
834
1179
  ? `\n_Approver verified by Telegram identity — broker auto-unlocked at startup._`
835
1180
  : ''
836
- await ctx.api
837
- .editMessageText(
838
- pending.chat_id,
839
- pending.card_message_id,
840
- richMessage(
841
- buildVaultGrantApprovedCardText({
842
- agentEscaped: escapeHtmlForTg(pending.agent),
843
- scope: pending.scope,
844
- key: pending.key,
845
- days,
846
- grantId: id,
847
- reasonEscaped:
848
- reasonNormalized.length > 0 ? escapeHtmlForTg(reasonNormalized) : undefined,
849
- footer,
850
- }),
851
- ),
852
- { reply_markup: { inline_keyboard: [] } },
853
- )
854
- .catch(() => {})
1181
+ await editResolvedCard(
1182
+ ctx,
1183
+ pending,
1184
+ pending.card_message_id,
1185
+ buildVaultGrantApprovedCardText({
1186
+ agentEscaped: escapeHtmlForTg(pending.agent),
1187
+ scope: pending.scope,
1188
+ key: pending.key,
1189
+ days,
1190
+ grantId: id,
1191
+ reasonEscaped:
1192
+ reasonNormalized.length > 0 ? escapeHtmlForTg(reasonNormalized) : undefined,
1193
+ footer,
1194
+ }),
1195
+ 'grant_approved',
1196
+ )
855
1197
  }
856
1198
 
857
1199
  // #1052: deliver a synthetic inbound message back to the agent so
@@ -891,6 +1233,7 @@ async function performVaultAccessApproval(
891
1233
  `telegram gateway: vault_grant_approved injection agent=${pending.agent} ` +
892
1234
  `key=${pending.key} stage=${stageId} delivered=${delivered}\n`,
893
1235
  )
1236
+ return { kind: 'ok' }
894
1237
  }
895
1238
 
896
1239
  /**
@@ -1364,6 +1707,9 @@ async function handleVaultRequestAccessCallback(ctx: Context, data: string): Pro
1364
1707
  cardChatId: pending.chat_id,
1365
1708
  cardMessageId: pending.card_message_id,
1366
1709
  senderId,
1710
+ // #3627: carried so a wrong-passphrase RE-prompt lands in the same
1711
+ // forum topic as the card it belongs to.
1712
+ ...(pending.threadId != null ? { threadId: pending.threadId } : {}),
1367
1713
  }
1368
1714
  const items =
1369
1715
  existing?.kind === 'passphrase-for-access-approve'
@@ -1372,6 +1718,13 @@ async function handleVaultRequestAccessCallback(ctx: Context, data: string): Pro
1372
1718
  pendingVaultOps.set(pending.chat_id, {
1373
1719
  kind: 'passphrase-for-access-approve',
1374
1720
  items,
1721
+ // #3627: a card joining a queue that already burned attempts inherits
1722
+ // the count — the cap belongs to the passphrase ENTRY sequence, and
1723
+ // resetting it here would hand out unlimited retries by tapping a
1724
+ // second card between attempts.
1725
+ ...(existing?.kind === 'passphrase-for-access-approve' && existing.attempts
1726
+ ? { attempts: existing.attempts }
1727
+ : {}),
1375
1728
  startedAt: existing?.kind === 'passphrase-for-access-approve' ? existing.startedAt : Date.now(),
1376
1729
  })
1377
1730
  // Card text differs slightly when joining an existing batch so
@@ -1408,37 +1761,55 @@ async function handleVaultRequestAccessCallback(ctx: Context, data: string): Pro
1408
1761
  // that later messages bury.
1409
1762
  // 3. It fires a notification — `disable_notification` is deliberately
1410
1763
  // NOT set — so the operator is actually pinged to act.
1764
+ // 4. #3627: the header leads with 🚨, not ⚠️ — this prompt BLOCKS an
1765
+ // approval the operator already tapped, so it has to out-shout the
1766
+ // generic warnings the gateway posts everywhere else. Body text
1767
+ // lives in `buildAccessPassphrasePromptText` so the retry prompt
1768
+ // below can never drift from this one.
1411
1769
  // Attention-grabbing header, short lines, key in code formatting.
1412
- const promptText = joiningBatch
1413
- ? `**⚠️🔐 ACTION NEEDED: passphrase required**\n\n` +
1414
- `Type your vault passphrase as your **next message**.\n` +
1415
- `One entry covers **${items.length}** pending approvals in this chat, no re-type per card.\n\n` +
1416
- `_We delete the passphrase message the moment we read it._`
1417
- : isAdminOnly
1418
- ? `**⚠️🔐 ACTION NEEDED: passphrase required**\n\n` +
1419
- `\`${pending.key}\` is an **admin-only credential**.\n` +
1420
- `Type your vault passphrase as your **next message** to mint the grant for **${escapeHtmlForTg(pending.agent)}**.\n\n` +
1421
- `_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._`
1422
- : `**⚠️🔐 ACTION NEEDED: passphrase required**\n\n` +
1423
- `Your vault is locked.\n` +
1424
- `Reply with your passphrase as your **next message** to unlock and mint the grant for **${escapeHtmlForTg(pending.agent)}**.\n\n` +
1425
- `_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._`
1426
-
1427
- // #1075: deleted-topic safe — fall back to the main chat. Wrapped
1428
- // through robustApiCall for flood-wait retries, mirroring the card send.
1429
- await retryWithThreadFallback<{ message_id: number }>(
1430
- robustApiCall,
1431
- (tid) =>
1432
- lockedBot.api.sendRichMessage(pending.chat_id, richMessage(promptText), {
1433
- ...(tid != null && Number.isFinite(tid) ? { message_thread_id: tid } : {}),
1434
- }),
1435
- { threadId: pending.threadId, chat_id: pending.chat_id, verb: 'vault_request_access.passphrase_prompt' },
1436
- ).catch(() => {})
1770
+ await sendAccessPassphrasePrompt(
1771
+ {
1772
+ chat_id: pending.chat_id,
1773
+ ...(pending.threadId != null ? { threadId: pending.threadId } : {}),
1774
+ },
1775
+ {
1776
+ kind: 'first',
1777
+ variant: joiningBatch ? 'batch' : isAdminOnly ? 'admin-only' : 'locked',
1778
+ itemCount: items.length,
1779
+ agentEscaped: escapeHtmlForTg(pending.agent),
1780
+ key: pending.key,
1781
+ },
1782
+ )
1437
1783
  return
1438
1784
  }
1439
1785
 
1440
1786
  await ctx.answerCallbackQuery({ text: '⏳ Minting grant…' }).catch(() => {})
1441
- await performVaultAccessApproval(ctx, pending, stageId, senderId, { kind: 'passphrase', passphrase: cached.passphrase })
1787
+ const outcome = await performVaultAccessApproval(ctx, pending, stageId, senderId, {
1788
+ kind: 'passphrase',
1789
+ passphrase: cached.passphrase,
1790
+ })
1791
+ // #3627: the CACHED passphrase was wrong (stale cache, or it was cached
1792
+ // by a flow that never validated it). Same contract as the typed-entry
1793
+ // path — the stage survives, the cache is dropped, and the operator gets
1794
+ // a re-prompt with the remaining attempts instead of a dead card.
1795
+ if (outcome.kind === 'passphrase-mismatch') {
1796
+ const openOp = pendingVaultOps.get(pending.chat_id)
1797
+ await resolveAccessApprovalPassphraseMismatch(ctx, {
1798
+ chat_id: pending.chat_id,
1799
+ failed: [
1800
+ {
1801
+ stageId,
1802
+ cardChatId: pending.chat_id,
1803
+ cardMessageId: pending.card_message_id ?? 0,
1804
+ senderId,
1805
+ ...(pending.threadId != null ? { threadId: pending.threadId } : {}),
1806
+ },
1807
+ ],
1808
+ priorAttempts:
1809
+ openOp?.kind === 'passphrase-for-access-approve' ? (openOp.attempts ?? 0) : 0,
1810
+ brokerMsg: outcome.msg,
1811
+ })
1812
+ }
1442
1813
  return
1443
1814
  }
1444
1815
 
@@ -2121,9 +2492,10 @@ async function executeGrantWizard(ctx: Context, chatId: string, state: Extract<P
2121
2492
  }
2122
2493
  // Write token to the agent's .vault-token file
2123
2494
  const { token, id } = result
2124
- const tokenPath = join(homedir(), '.switchroom', 'agents', state.agent!, '.vault-token')
2495
+ // #3627: same single source of truth as the other two token writes.
2496
+ const tokenPath = vaultTokenFilePath(state.agent!)
2125
2497
  try {
2126
- mkdirSync(join(homedir(), '.switchroom', 'agents', state.agent!), { recursive: true })
2498
+ mkdirSync(dirname(tokenPath), { recursive: true })
2127
2499
  writeFileSync(tokenPath, token, { mode: 0o600 })
2128
2500
  } catch (err) {
2129
2501
  await switchroomReply(ctx, `**Grant created but token write failed:** ${escapeHtmlForTg(String(err))}`, { html: true })
@@ -2853,6 +3225,7 @@ async function handleAuthDashboardCallback(ctx: Context): Promise<void> {
2853
3225
  return {
2854
3226
  handleVaultRecentDenialCallback,
2855
3227
  performVaultAccessApproval,
3228
+ resolveAccessApprovalPassphraseMismatch,
2856
3229
  handleSkillProposalCallback,
2857
3230
  handleMentalModelProposeCallback,
2858
3231
  handleVaultRequestAccessCallback,