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.
- package/dist/auth-broker/index.js +53 -0
- package/dist/cli/switchroom.js +2444 -1264
- package/dist/host-control/main.js +54 -1
- package/dist/vault/approvals/kernel-server.js +53 -0
- package/dist/vault/broker/server.js +53 -0
- package/package.json +4 -2
- package/skills/switchroom-release/SKILL.md +103 -20
- package/telegram-plugin/card-format.ts +92 -3
- package/telegram-plugin/dist/gateway/gateway.js +769 -172
- package/telegram-plugin/edit-flood-fuse.ts +477 -0
- package/telegram-plugin/format.ts +19 -7
- package/telegram-plugin/gateway/boot-sweep-gate.ts +164 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +454 -81
- package/telegram-plugin/gateway/gateway.ts +66 -56
- package/telegram-plugin/gateway/inbound-interceptors.ts +27 -4
- package/telegram-plugin/gateway/narrative-lane.ts +49 -3
- package/telegram-plugin/gateway/status-pin-api.ts +145 -0
- package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +325 -45
- package/telegram-plugin/retry-api-call.ts +15 -2
- package/telegram-plugin/send-gate.ts +1 -1
- package/telegram-plugin/status-no-truncate.ts +64 -1
- package/telegram-plugin/status-pin-driver.ts +50 -27
- package/telegram-plugin/status-pin.ts +43 -5
- package/telegram-plugin/tests/activity-card-send-gate.test.ts +275 -0
- package/telegram-plugin/tests/activity-card-wiring.test.ts +16 -7
- package/telegram-plugin/tests/boot-pin-sweep-wiring.test.ts +101 -0
- package/telegram-plugin/tests/boot-sweep-gate.test.ts +293 -0
- package/telegram-plugin/tests/boot-version-string.test.ts +0 -0
- package/telegram-plugin/tests/edit-flood-fuse.test.ts +431 -0
- package/telegram-plugin/tests/pinned-card-collapse.test.ts +356 -0
- package/telegram-plugin/tests/status-pin-api.test.ts +178 -0
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +94 -11
- package/telegram-plugin/tests/status-pin.test.ts +106 -5
- package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +631 -1
- package/telegram-plugin/tests/tool-activity-summary.test.ts +19 -10
- package/telegram-plugin/tests/vault-approval-posture.test.ts +6 -1
- package/telegram-plugin/tests/vault-passphrase-retry.test.ts +666 -0
- package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +42 -21
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +233 -1
- package/telegram-plugin/tool-activity-summary.ts +85 -13
- package/telegram-plugin/worker-activity-feed.ts +5 -1
- package/vendor/hindsight-memory/scripts/drain_pending.py +193 -25
- package/vendor/hindsight-memory/scripts/lib/pending.py +84 -5
- package/vendor/hindsight-memory/scripts/lib/retain_split.py +21 -10
- package/vendor/hindsight-memory/scripts/recall.py +74 -5
- package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +158 -4
- package/vendor/hindsight-memory/scripts/tests/test_pending_failure_class.py +105 -0
- package/vendor/hindsight-memory/scripts/tests/test_pending_wedge.py +300 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_degraded_notice.py +365 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +12 -4
- package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +27 -2
- package/vendor/hindsight-memory/scripts/tests/test_retain_split.py +19 -11
- 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 {
|
|
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
|
-
|
|
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(
|
|
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<
|
|
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
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
)
|
|
706
|
-
.
|
|
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
|
-
//
|
|
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
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
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
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
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, {
|
|
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
|
-
|
|
2495
|
+
// #3627: same single source of truth as the other two token writes.
|
|
2496
|
+
const tokenPath = vaultTokenFilePath(state.agent!)
|
|
2125
2497
|
try {
|
|
2126
|
-
mkdirSync(
|
|
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,
|