switchroom 0.18.32 → 0.19.0
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 +17 -1
- package/dist/cli/switchroom.js +847 -729
- package/dist/host-control/main.js +18 -2
- package/dist/vault/approvals/kernel-server.js +17 -1
- package/dist/vault/broker/server.js +44 -2
- package/package.json +2 -2
- package/profiles/_base/start.sh.hbs +105 -18
- package/telegram-plugin/dist/gateway/gateway.js +60612 -56998
- package/telegram-plugin/gateway/agent-button-callback-handler.ts +237 -0
- package/telegram-plugin/gateway/ask-callback-handler.ts +92 -0
- package/telegram-plugin/gateway/attachment-message-handlers.ts +152 -0
- package/telegram-plugin/gateway/boot-card.ts +169 -1
- package/telegram-plugin/gateway/bot-commands-model-effort.ts +209 -0
- package/telegram-plugin/gateway/bot-commands-start-info.ts +108 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +124 -0
- package/telegram-plugin/gateway/card-approval-keyboards.test.ts +28 -0
- package/telegram-plugin/gateway/card-tool-handlers.ts +639 -0
- package/telegram-plugin/gateway/checklist-message-handler.ts +107 -0
- package/telegram-plugin/gateway/delivery-confirm-wiring.ts +133 -0
- package/telegram-plugin/gateway/gateway.ts +1347 -6758
- package/telegram-plugin/gateway/inbound-interceptors.ts +1133 -0
- package/telegram-plugin/gateway/inbound-router.ts +400 -0
- package/telegram-plugin/gateway/liveness-wiring.ts +440 -0
- package/telegram-plugin/gateway/media-message-handlers.ts +256 -0
- package/telegram-plugin/gateway/mental-model-propose-card.ts +16 -0
- package/telegram-plugin/gateway/model-command.ts +23 -0
- package/telegram-plugin/gateway/narrative-lane.ts +865 -0
- package/telegram-plugin/gateway/obligation-wiring.ts +333 -0
- package/telegram-plugin/gateway/photo-message-handler.ts +80 -0
- package/telegram-plugin/gateway/pinned-message-handler.ts +86 -0
- package/telegram-plugin/gateway/secret-request-card.test.ts +46 -0
- package/telegram-plugin/gateway/secret-request-card.ts +45 -0
- package/telegram-plugin/gateway/stream-render.ts +2166 -0
- package/telegram-plugin/gateway/turn-end.ts +606 -0
- package/telegram-plugin/gateway/turn-start-surfaces.ts +298 -0
- package/telegram-plugin/gateway/vault-request-access-card.ts +16 -0
- package/telegram-plugin/gateway/vault-request-save-card.test.ts +49 -0
- package/telegram-plugin/gateway/vault-request-save-card.ts +52 -0
- package/telegram-plugin/gateway/voice-message-handler.ts +123 -0
- package/telegram-plugin/gateway/voice-ondemand-callback-handler.ts +204 -0
- package/telegram-plugin/gateway/worker-feed-dispatch.ts +40 -0
- package/telegram-plugin/narrative-dedup.ts +24 -1
- package/telegram-plugin/narrative-flush.ts +2 -2
- package/telegram-plugin/render/render.ts +25 -1
- package/telegram-plugin/status-no-truncate.ts +13 -0
- package/telegram-plugin/subagent-watcher.ts +186 -3
- package/telegram-plugin/tests/activity-card-wiring.test.ts +8 -3
- package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +18 -3
- package/telegram-plugin/tests/agent-button-callback-handler.test.ts +149 -0
- package/telegram-plugin/tests/ask-callback-handler.test.ts +118 -0
- package/telegram-plugin/tests/attachment-message-handlers.test.ts +135 -0
- package/telegram-plugin/tests/boot-card-routing.test.ts +139 -0
- package/telegram-plugin/tests/bot-commands-model-effort.test.ts +189 -0
- package/telegram-plugin/tests/bot-commands-start-info.test.ts +240 -0
- package/telegram-plugin/tests/buffer-gate-broadened.test.ts +15 -6
- package/telegram-plugin/tests/busy-ack-wiring.test.ts +6 -1
- package/telegram-plugin/tests/button-tap-turn-gated.test.ts +18 -9
- package/telegram-plugin/tests/callback-query-handlers.test.ts +101 -0
- package/telegram-plugin/tests/card-tool-handlers.test.ts +497 -0
- package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +5 -2
- package/telegram-plugin/tests/checklist-message-handler.test.ts +160 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +47 -10
- package/telegram-plugin/tests/emission-determinism-wiring.test.ts +27 -9
- package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +30 -7
- package/telegram-plugin/tests/gateway-boot-side-effect-gating.test.ts +39 -18
- package/telegram-plugin/tests/gateway-boot-smoke.test.ts +160 -0
- package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +3 -7
- package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +44 -29
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +8 -2
- package/telegram-plugin/tests/gateway-request-secret.test.ts +7 -3
- package/telegram-plugin/tests/gateway-secret-detect.test.ts +20 -10
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +8 -2
- package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +14 -3
- package/telegram-plugin/tests/inbound-message-types.test.ts +52 -16
- package/telegram-plugin/tests/media-message-handlers.test.ts +276 -0
- package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -4
- package/telegram-plugin/tests/model-command.test.ts +30 -0
- package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +27 -9
- package/telegram-plugin/tests/narrative-dedup.test.ts +32 -0
- package/telegram-plugin/tests/narrative-flush.test.ts +6 -2
- package/telegram-plugin/tests/narrative-lane-golden.test.ts +458 -0
- package/telegram-plugin/tests/no-reply-bounded-drain.test.ts +14 -3
- package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +16 -7
- package/telegram-plugin/tests/per-topic-current-turn.test.ts +32 -8
- package/telegram-plugin/tests/photo-message-handler.test.ts +114 -0
- package/telegram-plugin/tests/pinned-message-handler.test.ts +108 -0
- package/telegram-plugin/tests/render/render.test.ts +42 -0
- package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +8 -4
- package/telegram-plugin/tests/secret-detect-fail-closed.test.ts +38 -28
- package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +28 -18
- package/telegram-plugin/tests/silence-liveness-wiring.test.ts +22 -8
- package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +42 -49
- package/telegram-plugin/tests/stop-command.test.ts +22 -12
- package/telegram-plugin/tests/stream-render-golden.test.ts +424 -0
- package/telegram-plugin/tests/subagent-watcher-boot-skip-dead.test.ts +218 -0
- package/telegram-plugin/tests/subagent-watcher-resume-reregister.test.ts +14 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +35 -3
- package/telegram-plugin/tests/turn-flush-safety.test.ts +183 -5
- package/telegram-plugin/tests/turn-flush-suppression-wiring.test.ts +9 -4
- package/telegram-plugin/tests/vault-approval-posture.test.ts +8 -2
- package/telegram-plugin/tests/vault-grant-union.test.ts +4 -1
- package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +16 -5
- package/telegram-plugin/tests/vault-request-access-tool.test.ts +10 -5
- package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +4 -1
- package/telegram-plugin/tests/vault-subcommands.test.ts +6 -1
- package/telegram-plugin/tests/voice-message-handler.test.ts +111 -0
- package/telegram-plugin/tests/voice-ondemand-callback-handler.test.ts +140 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +86 -19
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +110 -20
- package/telegram-plugin/tests/worker-feed-resume-guard.test.ts +86 -0
- package/telegram-plugin/tool-activity-summary.ts +83 -35
- package/telegram-plugin/turn-flush-safety.ts +80 -14
- package/telegram-plugin/uat/restart-capability.ts +76 -0
- package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +14 -4
- package/telegram-plugin/uat/scenarios/bridge-flap-resilience-dm.test.ts +11 -1
- package/telegram-plugin/uat/scenarios/cross-turn-pending-progress-dm.test.ts +19 -2
- package/telegram-plugin/uat/scenarios/jtbd-always-on-after-restart-dm.test.ts +6 -12
- package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +6 -12
- package/telegram-plugin/uat/scenarios/jtbd-interrupted-turn-resumes-dm.test.ts +6 -12
- package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +47 -13
- package/telegram-plugin/worker-activity-feed.ts +10 -4
|
@@ -0,0 +1,1133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inbound intercept gauntlet (switchroom#2996 P7).
|
|
3
|
+
*
|
|
4
|
+
* The side-effecting early-return checks that sit at the head of
|
|
5
|
+
* `handleInbound`, extracted one-per-PR out of gateway.ts into pure,
|
|
6
|
+
* unit-testable functions that take (a) the per-message routing facts captured
|
|
7
|
+
* in `handleInbound` before the gauntlet and (b) an `InboundInterceptorDeps`
|
|
8
|
+
* object holding LIVE references to the gateway's collaborators (functions,
|
|
9
|
+
* Maps, the bound `bot.api` surface) — never value snapshots. Each returns an
|
|
10
|
+
* `InterceptOutcome`; when `handled` is true the caller must return immediately
|
|
11
|
+
* (the intercept has fully serviced the message and it is never forwarded).
|
|
12
|
+
*
|
|
13
|
+
* Ordering is load-bearing and preserved by the caller: the functions here fire
|
|
14
|
+
* in the same sequence they did inline. The characterization harness
|
|
15
|
+
* (tests/inbound-router-characterization.test.ts) is the parity oracle.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { Context } from 'grammy'
|
|
19
|
+
import type { ReactionTypeEmoji } from 'grammy/types'
|
|
20
|
+
import { parseStopKeyword, buildStopReply } from './stop-command.js'
|
|
21
|
+
import { decideInterruptTiming, resolveSafeBoundaryEnabled } from './interrupt-defer.js'
|
|
22
|
+
import { naturalAction } from '../permission-title.js'
|
|
23
|
+
import { richMessage } from '../rich-send.js'
|
|
24
|
+
import { renderVaultRequestSaveCard, buildVaultRequestSaveKeyboard } from './vault-request-save-card.js'
|
|
25
|
+
import { runPipeline } from '../secret-detect/pipeline.js'
|
|
26
|
+
import { maskToken } from '../secret-detect/mask.js'
|
|
27
|
+
import { defaultVaultWrite, defaultVaultList } from '../secret-detect/vault-write.js'
|
|
28
|
+
import { parseVaultCliError, renderVaultCliError } from '../secret-detect/vault-error.js'
|
|
29
|
+
import { detectSecrets } from '../secret-detect/index.js'
|
|
30
|
+
import type { StagingMap } from '../secret-detect/staging.js'
|
|
31
|
+
import type { PendingAuthAddFlow } from './auth-add-flow.js'
|
|
32
|
+
import type {
|
|
33
|
+
createCallbackQueryHandlers,
|
|
34
|
+
PendingVaultOp,
|
|
35
|
+
PendingVaultRequestSave,
|
|
36
|
+
PendingVaultRequestAccess,
|
|
37
|
+
DeferredSecret,
|
|
38
|
+
} from './callback-query-handlers.js'
|
|
39
|
+
import type { InlineKeyboard } from 'grammy'
|
|
40
|
+
import {
|
|
41
|
+
pendingLoopbackFlows,
|
|
42
|
+
submitLoopbackRedirect,
|
|
43
|
+
cancelLoopbackFlow,
|
|
44
|
+
shouldConsumeLoopbackPaste,
|
|
45
|
+
} from './auth-loopback-relay.js'
|
|
46
|
+
import type { AddAccountCredentials } from '../../src/auth/broker/client.js'
|
|
47
|
+
import type { parseInterruptMarker } from '../interrupt-marker.js'
|
|
48
|
+
import type { RetryCallOpts } from '../retry-api-call.js'
|
|
49
|
+
import type { AttachmentMeta } from './gateway.js'
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Result of an intercept. `handled: true` means the intercept fully serviced
|
|
53
|
+
* the message and the caller must `return` (never forward to the agent).
|
|
54
|
+
*/
|
|
55
|
+
export interface InterceptOutcome {
|
|
56
|
+
handled: boolean
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Live collaborators injected from gateway.ts module scope. Grows as each P7
|
|
61
|
+
* stage moves an intercept across this boundary. Every field is a live
|
|
62
|
+
* reference (function / Map / object) — never a captured value snapshot.
|
|
63
|
+
*
|
|
64
|
+
* `botApi` is a LAZY accessor for the gateway's bound `bot.api` surface — the
|
|
65
|
+
* grammY `bot` is assigned late (only on the prod boot path), so the deref is
|
|
66
|
+
* deferred to call time. Injecting it as `deps.botApi().sendMessage(...)` also
|
|
67
|
+
* keeps the raw Telegram send a false-positive-free non-`bot.api.` literal that
|
|
68
|
+
* still rides the same `swallowingApiCall` retry wrapper it did inline.
|
|
69
|
+
*/
|
|
70
|
+
export interface InboundInterceptorDeps {
|
|
71
|
+
pendingSessionCommand: { list: () => Array<{ kind: string; targetLabel: string }> }
|
|
72
|
+
turnInFlightForGate: () => boolean
|
|
73
|
+
sendReaction: (
|
|
74
|
+
chatId: string,
|
|
75
|
+
messageId: number,
|
|
76
|
+
emoji: ReactionTypeEmoji['emoji'],
|
|
77
|
+
) => Promise<unknown>
|
|
78
|
+
executeHaltNow: (origin: string) => Promise<void>
|
|
79
|
+
swallowingApiCall: <T>(fn: () => Promise<T>, opts?: RetryCallOpts) => Promise<T | undefined>
|
|
80
|
+
botApi: () => {
|
|
81
|
+
sendMessage: (
|
|
82
|
+
chatId: string,
|
|
83
|
+
text: string,
|
|
84
|
+
other?: { message_thread_id?: number },
|
|
85
|
+
) => Promise<unknown>
|
|
86
|
+
deleteMessage: (chatId: string, messageId: number) => Promise<unknown>
|
|
87
|
+
}
|
|
88
|
+
log: (line: string) => void
|
|
89
|
+
// --- interrupt-marker (P7 PR-3) collaborators ---
|
|
90
|
+
loadAccess: () => { interruptSafeBoundary?: boolean; allowFrom: string[] }
|
|
91
|
+
toolFlightTracker: { isMidToolCall: () => boolean; inFlightCount: () => number }
|
|
92
|
+
cancelInterruptedObligation: () => void
|
|
93
|
+
// --- permission-reply (P7 PR-4) collaborators ---
|
|
94
|
+
dispatchPermissionVerdict: (ev: {
|
|
95
|
+
type: 'permission'
|
|
96
|
+
requestId: string
|
|
97
|
+
behavior: 'allow' | 'deny'
|
|
98
|
+
}) => void
|
|
99
|
+
resumeReactionAfterVerdict: () => void
|
|
100
|
+
pendingPermissions: {
|
|
101
|
+
get: (requestId: string) => { tool_name: string; input_preview: string } | undefined
|
|
102
|
+
}
|
|
103
|
+
postPermissionResumeMessage: (opts: { behavior: 'allow' | 'deny'; action: string }) => void
|
|
104
|
+
// --- auth-add paste-back (P7 PR-5) collaborators ---
|
|
105
|
+
pendingAuthAddFlows: {
|
|
106
|
+
get: (key: string) => PendingAuthAddFlow | undefined
|
|
107
|
+
delete: (key: string) => boolean
|
|
108
|
+
}
|
|
109
|
+
looksLikeAuthCode: (text: string) => boolean
|
|
110
|
+
/** REAUTH_INTERCEPT_TTL_MS — a fixed constant, safe to cross as a value. */
|
|
111
|
+
reauthInterceptTtlMs: number
|
|
112
|
+
submitAccountAuthCode: (flow: PendingAuthAddFlow, code: string) => Promise<AddAccountCredentials>
|
|
113
|
+
addAccountViaBroker: (
|
|
114
|
+
label: string,
|
|
115
|
+
credentials: AddAccountCredentials,
|
|
116
|
+
opts: { replace?: boolean },
|
|
117
|
+
) => Promise<unknown>
|
|
118
|
+
cleanAuthAddScratchDir: (scratchDir: string) => void
|
|
119
|
+
cancelAccountAuthSession: (flow: PendingAuthAddFlow) => void
|
|
120
|
+
switchroomReply: (
|
|
121
|
+
ctx: Context,
|
|
122
|
+
text: string,
|
|
123
|
+
options?: { html?: boolean; reply_markup?: InlineKeyboard },
|
|
124
|
+
) => Promise<unknown>
|
|
125
|
+
escapeHtmlForTg: (text: string) => string
|
|
126
|
+
/** Closure over redactAuthCodeMessage + the gateway's redactAuthCodeApi (#488). */
|
|
127
|
+
redactAuthCode: (chatId: string, msgId: number | null) => void
|
|
128
|
+
// --- reauth paste-back (P7 PR-7) collaborators ---
|
|
129
|
+
pendingReauthFlows: {
|
|
130
|
+
get: (key: string) => { agent: string; startedAt: number } | undefined
|
|
131
|
+
delete: (key: string) => boolean
|
|
132
|
+
}
|
|
133
|
+
execAuthCode: (
|
|
134
|
+
agent: string,
|
|
135
|
+
code: string,
|
|
136
|
+
) =>
|
|
137
|
+
| { result: { outcome: unknown; instructions: string[] }; errorText: null }
|
|
138
|
+
| { result: null; errorText: string }
|
|
139
|
+
renderAuthCodeOutcome: (outcome: unknown) => string | null
|
|
140
|
+
preBlock: (text: string) => string
|
|
141
|
+
formatSwitchroomOutput: (output: string) => string
|
|
142
|
+
formatAuthOutputForTelegram: (output: string) => { text: string; url: string | null }
|
|
143
|
+
// --- vault intercept (P7 PR-8) collaborators ---
|
|
144
|
+
pendingVaultOps: {
|
|
145
|
+
get: (chatId: string) => PendingVaultOp | undefined
|
|
146
|
+
delete: (chatId: string) => boolean
|
|
147
|
+
set: (chatId: string, v: PendingVaultOp) => void
|
|
148
|
+
}
|
|
149
|
+
/** VAULT_INPUT_TTL_MS / VAULT_PASSPHRASE_TTL_MS — fixed constants. */
|
|
150
|
+
vaultInputTtlMs: number
|
|
151
|
+
vaultPassphraseTtlMs: number
|
|
152
|
+
vaultPassphraseCache: {
|
|
153
|
+
get: (chatId: string) => { passphrase: string; expiresAt: number } | undefined
|
|
154
|
+
set: (chatId: string, v: { passphrase: string; expiresAt: number }) => void
|
|
155
|
+
}
|
|
156
|
+
deleteSensitiveMessage: (chatId: string, msgId: number, reason: string) => Promise<void>
|
|
157
|
+
executeVaultOp: (
|
|
158
|
+
ctx: Context,
|
|
159
|
+
chatId: string,
|
|
160
|
+
op: 'list' | 'get' | 'set' | 'delete',
|
|
161
|
+
key: string | undefined,
|
|
162
|
+
passphrase: string,
|
|
163
|
+
setValue: string | undefined,
|
|
164
|
+
) => Promise<void>
|
|
165
|
+
unlockViaBroker: (passphrase: string) => Promise<{ ok: boolean; msg?: string }>
|
|
166
|
+
/** LAZY accessor — callbackQueryHandlers is a boot-assigned `let` in gateway.ts. */
|
|
167
|
+
callbackQueryHandlers: () => Pick<
|
|
168
|
+
ReturnType<typeof createCallbackQueryHandlers>,
|
|
169
|
+
| 'executeDeferredSecretSave'
|
|
170
|
+
| 'performVaultAccessApproval'
|
|
171
|
+
| 'parseGrantDuration'
|
|
172
|
+
| 'grantWizardConfirm'
|
|
173
|
+
>
|
|
174
|
+
pendingVaultRequestAccesses: {
|
|
175
|
+
get: (stageId: string) => PendingVaultRequestAccess | undefined
|
|
176
|
+
}
|
|
177
|
+
pendingVaultRequestSaves: {
|
|
178
|
+
get: (stageId: string) => PendingVaultRequestSave | undefined
|
|
179
|
+
}
|
|
180
|
+
vaultKeyRegex: RegExp
|
|
181
|
+
vaultKeyRegexLabel: string
|
|
182
|
+
// --- secret-detect (P7 PR-9) collaborators ---
|
|
183
|
+
secretStaging: StagingMap
|
|
184
|
+
awaitingAuthCodeAt: { get: (chatId: string) => number | undefined; delete: (chatId: string) => boolean }
|
|
185
|
+
/** AUTH_CODE_CONTEXT_TTL_MS — fixed constant. */
|
|
186
|
+
authCodeContextTtlMs: number
|
|
187
|
+
deferredSecrets: { set: (key: string, v: DeferredSecret) => void }
|
|
188
|
+
deferredKey: (chatId: string, messageId: number) => string
|
|
189
|
+
mintDeferredSecretKernelRequest: (slug: string, approverSet: string[]) => Promise<string | null>
|
|
190
|
+
buildDeferredSecretKeyboard: (deferKey: string) => InlineKeyboard
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Per-message routing facts captured in `handleInbound` before the gauntlet.
|
|
195
|
+
* `extraAttachments` is only length-inspected here, so it is typed structurally
|
|
196
|
+
* to avoid pulling gateway-local attachment types across the boundary.
|
|
197
|
+
*/
|
|
198
|
+
export interface StopKeywordParams {
|
|
199
|
+
text: string
|
|
200
|
+
chat_id: string
|
|
201
|
+
msgId: number | undefined
|
|
202
|
+
messageThreadId: number | undefined
|
|
203
|
+
downloadImage: (() => Promise<string | undefined>) | undefined
|
|
204
|
+
attachment: AttachmentMeta | undefined
|
|
205
|
+
extraAttachments: readonly unknown[] | undefined
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* #3020 — bare operator "stop": the kill switch without the `!` marker.
|
|
210
|
+
* Exact-word only (`parseStopKeyword`): "stop the build" flows to the agent as
|
|
211
|
+
* a normal turn. Intercepted here (never forwarded) so it fires mid-turn
|
|
212
|
+
* instead of queueing behind the very turn it's trying to cancel. Pure text
|
|
213
|
+
* only (7b): a photo/attachment captioned "stop" is content for the agent, not
|
|
214
|
+
* a halt request — it flows through as a normal turn.
|
|
215
|
+
*
|
|
216
|
+
* Authorization: the same allowFrom gate as any inbound (the caller's `gate()`
|
|
217
|
+
* runs before this) — unauthorized senders never reach here.
|
|
218
|
+
*/
|
|
219
|
+
export async function interceptStopKeyword(
|
|
220
|
+
p: StopKeywordParams,
|
|
221
|
+
deps: InboundInterceptorDeps,
|
|
222
|
+
): Promise<InterceptOutcome> {
|
|
223
|
+
if (
|
|
224
|
+
!(
|
|
225
|
+
parseStopKeyword(p.text) &&
|
|
226
|
+
p.downloadImage == null &&
|
|
227
|
+
p.attachment == null &&
|
|
228
|
+
(p.extraAttachments == null || p.extraAttachments.length === 0)
|
|
229
|
+
)
|
|
230
|
+
) {
|
|
231
|
+
return { handled: false }
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const queuedLabels = deps.pendingSessionCommand.list().map(c => `/${c.kind} ${c.targetLabel}`)
|
|
235
|
+
const inFlight = deps.turnInFlightForGate()
|
|
236
|
+
deps.log(
|
|
237
|
+
`telegram gateway: stop-keyword received chat_id=${p.chat_id} in_flight_turn=${inFlight} ` +
|
|
238
|
+
`queued_cmds=${queuedLabels.length}\n`,
|
|
239
|
+
)
|
|
240
|
+
if (inFlight) {
|
|
241
|
+
if (p.msgId != null) {
|
|
242
|
+
void deps.sendReaction(p.chat_id, p.msgId, '⚡' as ReactionTypeEmoji['emoji']).catch(() => {})
|
|
243
|
+
}
|
|
244
|
+
await deps.executeHaltNow('stop-keyword')
|
|
245
|
+
}
|
|
246
|
+
const stopReply = buildStopReply(inFlight, queuedLabels)
|
|
247
|
+
// #1075: thread-id-bearing — swallow so a deleted topic can't crash us.
|
|
248
|
+
await deps.swallowingApiCall(
|
|
249
|
+
() =>
|
|
250
|
+
deps.botApi().sendMessage(
|
|
251
|
+
p.chat_id,
|
|
252
|
+
stopReply.text,
|
|
253
|
+
p.messageThreadId != null ? { message_thread_id: p.messageThreadId } : {},
|
|
254
|
+
),
|
|
255
|
+
{
|
|
256
|
+
chat_id: p.chat_id,
|
|
257
|
+
verb: 'stop-keyword-reply',
|
|
258
|
+
...(p.messageThreadId != null ? { threadId: p.messageThreadId } : {}),
|
|
259
|
+
},
|
|
260
|
+
)
|
|
261
|
+
return { handled: true }
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Per-message facts for the interrupt-marker intercept (P7 PR-3). */
|
|
265
|
+
export interface InterruptMarkerParams {
|
|
266
|
+
/** The parse result — the caller keeps `parseInterruptMarker(text)` inline
|
|
267
|
+
* because `interrupt.isInterrupt` also feeds the downstream envelope/delivery
|
|
268
|
+
* path; the same value object crosses here so parse happens exactly once. */
|
|
269
|
+
interrupt: ReturnType<typeof parseInterruptMarker>
|
|
270
|
+
chat_id: string
|
|
271
|
+
msgId: number | undefined
|
|
272
|
+
messageThreadId: number | undefined
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Outcome of the interrupt-marker intercept. `handled: true` — empty `!` fully
|
|
277
|
+
* serviced (halt fired + follow-up sent), caller returns. Otherwise the caller
|
|
278
|
+
* continues the pipeline with `replacedText` (the `!` body for an interrupt,
|
|
279
|
+
* the original text untouched for a non-interrupt) and `deferInterrupt` — an
|
|
280
|
+
* AT-RECEIPT captured VALUE the delivery site consumes (Problem B: when true,
|
|
281
|
+
* the synchronous SIGINT was skipped and the built inbound is stashed at the
|
|
282
|
+
* delivery site to fire at a safe boundary).
|
|
283
|
+
*/
|
|
284
|
+
export type InterruptMarkerOutcome =
|
|
285
|
+
| { handled: true }
|
|
286
|
+
| { handled: false; deferInterrupt: boolean; replacedText: string | null }
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* `!`-prefix interrupt (#575): SIGINT the in-flight turn and replace the
|
|
290
|
+
* inbound with the marker body. Empty `!` is a pure halt (#3020) — same shared
|
|
291
|
+
* `executeHaltNow` sequence as /stop. Safe-boundary deferral (Problem B): a
|
|
292
|
+
* non-empty `!` mid-tool-call defers the SIGINT to the delivery site instead
|
|
293
|
+
* of firing synchronously.
|
|
294
|
+
*
|
|
295
|
+
* The tmux SIGINT keeps its dynamic `import('../../src/agents/tmux.js')`
|
|
296
|
+
* exactly as inline — the gateway runs INSIDE the agent container in docker
|
|
297
|
+
* mode, so `sendAgentInterrupt` (tmux send-keys, local socket) is used instead
|
|
298
|
+
* of the docker-inspect PID probe (UAT 2026-05-13 discovery).
|
|
299
|
+
*/
|
|
300
|
+
export async function interceptInterruptMarker(
|
|
301
|
+
p: InterruptMarkerParams,
|
|
302
|
+
deps: InboundInterceptorDeps,
|
|
303
|
+
): Promise<InterruptMarkerOutcome> {
|
|
304
|
+
const { interrupt } = p
|
|
305
|
+
if (!interrupt.isInterrupt) {
|
|
306
|
+
return { handled: false, deferInterrupt: false, replacedText: null }
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const agentName = process.env.SWITCHROOM_AGENT_NAME
|
|
310
|
+
const access = deps.loadAccess()
|
|
311
|
+
const deferInterrupt =
|
|
312
|
+
!interrupt.emptyBody &&
|
|
313
|
+
decideInterruptTiming({
|
|
314
|
+
safeBoundaryEnabled: resolveSafeBoundaryEnabled(access.interruptSafeBoundary),
|
|
315
|
+
midToolCall: deps.toolFlightTracker.isMidToolCall(),
|
|
316
|
+
}) === 'defer'
|
|
317
|
+
deps.log(
|
|
318
|
+
`telegram gateway: interrupt-marker received chat_id=${p.chat_id} agent=${agentName ?? '-'} ` +
|
|
319
|
+
`body_len=${interrupt.body.length} empty=${interrupt.emptyBody} defer=${deferInterrupt} ` +
|
|
320
|
+
`in_flight=${deps.toolFlightTracker.inFlightCount()}\n`,
|
|
321
|
+
)
|
|
322
|
+
if (p.msgId != null) {
|
|
323
|
+
void deps.sendReaction(p.chat_id, p.msgId, '⚡' as ReactionTypeEmoji['emoji']).catch(() => {})
|
|
324
|
+
}
|
|
325
|
+
if (interrupt.emptyBody) {
|
|
326
|
+
// #3020: empty `!` is a pure halt (no replacement body) — same shared
|
|
327
|
+
// sequence as /stop: safe-boundary deferral, tmux C-c, obligation
|
|
328
|
+
// cancel, deterministic busy release.
|
|
329
|
+
await deps.executeHaltNow('bang-empty')
|
|
330
|
+
// #1075: thread-id-bearing — route through swallowingApiCall so
|
|
331
|
+
// a deleted topic doesn't crash the gateway; the reaction
|
|
332
|
+
// already acked the user so a missing follow-up is tolerable.
|
|
333
|
+
await deps.swallowingApiCall(
|
|
334
|
+
() =>
|
|
335
|
+
deps.botApi().sendMessage(
|
|
336
|
+
p.chat_id,
|
|
337
|
+
'⚡ Interrupted. Send your replacement instruction now.',
|
|
338
|
+
p.messageThreadId != null ? { message_thread_id: p.messageThreadId } : {},
|
|
339
|
+
),
|
|
340
|
+
{
|
|
341
|
+
chat_id: p.chat_id,
|
|
342
|
+
verb: 'interrupt-empty-body',
|
|
343
|
+
...(p.messageThreadId != null ? { threadId: p.messageThreadId } : {}),
|
|
344
|
+
},
|
|
345
|
+
)
|
|
346
|
+
return { handled: true }
|
|
347
|
+
}
|
|
348
|
+
if (agentName && !deferInterrupt) {
|
|
349
|
+
try {
|
|
350
|
+
// The gateway runs INSIDE the agent container in docker mode,
|
|
351
|
+
// so calling `interruptAgent` (which probes `docker inspect`
|
|
352
|
+
// for the PID) always returns "no running PID" — the host's
|
|
353
|
+
// docker socket isn't visible to us. Skip the PID round-trip
|
|
354
|
+
// entirely and use tmux send-keys directly: the tmux socket
|
|
355
|
+
// is local to our container. Discovered during UAT overnight
|
|
356
|
+
// 2026-05-13 — pre-fix every `!` interrupt produced an
|
|
357
|
+
// "Agent has no running PID" stderr line and the user got
|
|
358
|
+
// ghosted because the SIGINT never fired.
|
|
359
|
+
const { sendAgentInterrupt } = await import('../../src/agents/tmux.js')
|
|
360
|
+
const r = sendAgentInterrupt({ agentName })
|
|
361
|
+
if ('ok' in r) {
|
|
362
|
+
deps.log(
|
|
363
|
+
`telegram gateway: interrupt-marker SIGINT delivered via tmux send-keys agent=${agentName}\n`,
|
|
364
|
+
)
|
|
365
|
+
} else {
|
|
366
|
+
deps.log(
|
|
367
|
+
`telegram gateway: interrupt-marker SIGINT via tmux failed agent=${agentName}: ${r.error}\n`,
|
|
368
|
+
)
|
|
369
|
+
}
|
|
370
|
+
} catch (err) {
|
|
371
|
+
deps.log(`telegram gateway: interrupt-marker SIGINT failed: ${(err as Error).message}\n`)
|
|
372
|
+
}
|
|
373
|
+
// The SIGINT just killed the in-flight turn — cancel its obligation so the
|
|
374
|
+
// interrupted (user-redirected) question isn't re-presented/escalated later.
|
|
375
|
+
deps.cancelInterruptedObligation()
|
|
376
|
+
}
|
|
377
|
+
// Replace the inbound text with the body and continue normal
|
|
378
|
+
// processing. The agent receives a fresh turn with no `!` prefix.
|
|
379
|
+
return { handled: false, deferInterrupt, replacedText: interrupt.body }
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* `y <id>` / `n <id>` permission verdict reply. The 5-char request id
|
|
384
|
+
* deliberately excludes `l` (ambiguity with `1`/`I` on phone keyboards).
|
|
385
|
+
* Moved verbatim from gateway.ts (P7 PR-4).
|
|
386
|
+
*/
|
|
387
|
+
const PERMISSION_REPLY_RE = /^\s*(y|yes|n|no)\s+([a-km-z]{5})\s*$/i
|
|
388
|
+
|
|
389
|
+
/** Per-message facts for the permission-reply intercept (P7 PR-4). */
|
|
390
|
+
export interface PermissionReplyParams {
|
|
391
|
+
text: string
|
|
392
|
+
chat_id: string
|
|
393
|
+
msgId: number | undefined
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Text-reply permission verdict (`y ab3de` / `no ab3de`): forward the verdict
|
|
398
|
+
* to the connected bridge, un-park the status reaction, post the
|
|
399
|
+
* "continuing…" resume line, and ack with ✅/❌. Fully intercepts — the
|
|
400
|
+
* verdict text is never forwarded to the agent as a turn.
|
|
401
|
+
*/
|
|
402
|
+
export function interceptPermissionReply(
|
|
403
|
+
p: PermissionReplyParams,
|
|
404
|
+
deps: InboundInterceptorDeps,
|
|
405
|
+
): InterceptOutcome {
|
|
406
|
+
const permMatch = PERMISSION_REPLY_RE.exec(p.text)
|
|
407
|
+
if (!permMatch) return { handled: false }
|
|
408
|
+
// Forward permission reply to connected bridge
|
|
409
|
+
const behavior = permMatch[1]!.toLowerCase().startsWith('y') ? 'allow' : 'deny'
|
|
410
|
+
const request_id = permMatch[2]!.toLowerCase()
|
|
411
|
+
deps.dispatchPermissionVerdict({
|
|
412
|
+
type: 'permission',
|
|
413
|
+
requestId: request_id,
|
|
414
|
+
behavior,
|
|
415
|
+
})
|
|
416
|
+
deps.resumeReactionAfterVerdict()
|
|
417
|
+
const ftDetails = deps.pendingPermissions.get(request_id)
|
|
418
|
+
deps.postPermissionResumeMessage({
|
|
419
|
+
behavior,
|
|
420
|
+
action: ftDetails ? naturalAction(ftDetails.tool_name, ftDetails.input_preview) : '',
|
|
421
|
+
})
|
|
422
|
+
if (p.msgId != null) {
|
|
423
|
+
const emoji = behavior === 'allow' ? '✅' : '❌'
|
|
424
|
+
void deps.sendReaction(p.chat_id, p.msgId, emoji as ReactionTypeEmoji['emoji']).catch(() => {})
|
|
425
|
+
}
|
|
426
|
+
return { handled: true }
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** Per-message facts for the auth-add paste-back intercept (P7 PR-5). */
|
|
430
|
+
export interface AuthAddParams {
|
|
431
|
+
ctx: Context
|
|
432
|
+
text: string
|
|
433
|
+
chat_id: string
|
|
434
|
+
msgId: number | undefined
|
|
435
|
+
/** chatKey(chat, thread) — computed once in handleInbound and shared by the
|
|
436
|
+
* auth-add / loopback / reauth / vault intercepts (cross-topic isolation). */
|
|
437
|
+
interceptKey: string
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* `/auth add` paste-back intercept — sibling to pendingReauthFlows. Both
|
|
442
|
+
* intercepts are deliberate so the LLM never sees the OAuth code (it doesn't
|
|
443
|
+
* need to + plaintext OAuth in chat history is bad hygiene). The add-flow
|
|
444
|
+
* intercept comes first because /auth add creates fresh credentials at the
|
|
445
|
+
* broker layer, vs /reauth which mutates an existing agent's slot — different
|
|
446
|
+
* success paths.
|
|
447
|
+
*
|
|
448
|
+
* PR3 supergroup-mode: keyed by chatKey(chat, thread) so an OAuth code pasted
|
|
449
|
+
* into topic A isn't intercepted when topic B has a separate /auth add flow
|
|
450
|
+
* pending (security: prevents cross-topic credential mis-attribution).
|
|
451
|
+
*
|
|
452
|
+
* A stale (TTL-expired) pending entry is dropped but the message falls through
|
|
453
|
+
* to the later intercepts (`handled: false`), exactly as inline.
|
|
454
|
+
*/
|
|
455
|
+
export async function interceptAuthAdd(
|
|
456
|
+
p: AuthAddParams,
|
|
457
|
+
deps: InboundInterceptorDeps,
|
|
458
|
+
): Promise<InterceptOutcome> {
|
|
459
|
+
const pendingAdd = deps.pendingAuthAddFlows.get(p.interceptKey)
|
|
460
|
+
if (!(pendingAdd && deps.looksLikeAuthCode(p.text))) return { handled: false }
|
|
461
|
+
const elapsed = Date.now() - pendingAdd.startedAt
|
|
462
|
+
if (elapsed < deps.reauthInterceptTtlMs) {
|
|
463
|
+
deps.pendingAuthAddFlows.delete(p.interceptKey)
|
|
464
|
+
try {
|
|
465
|
+
const credentials = await deps.submitAccountAuthCode(pendingAdd, p.text.trim())
|
|
466
|
+
try {
|
|
467
|
+
await deps.addAccountViaBroker(pendingAdd.label, credentials, { replace: false })
|
|
468
|
+
// success — wipe scratch dir now that the broker owns the creds
|
|
469
|
+
deps.cleanAuthAddScratchDir(pendingAdd.scratchDir)
|
|
470
|
+
await deps.switchroomReply(
|
|
471
|
+
p.ctx,
|
|
472
|
+
`✓ Account \`${pendingAdd.label}\` added.\n` +
|
|
473
|
+
`The fleet's active account hasn't changed. Send ` +
|
|
474
|
+
`\`/auth use ${deps.escapeHtmlForTg(pendingAdd.label)}\` to switch to it.`,
|
|
475
|
+
{ html: true },
|
|
476
|
+
)
|
|
477
|
+
} catch (brokerErr) {
|
|
478
|
+
// Broker rejected (e.g. label already exists). Wipe scratch
|
|
479
|
+
// either way — the credentials are useless without broker
|
|
480
|
+
// bookkeeping.
|
|
481
|
+
deps.cleanAuthAddScratchDir(pendingAdd.scratchDir)
|
|
482
|
+
await deps.switchroomReply(
|
|
483
|
+
p.ctx,
|
|
484
|
+
`**/auth add failed at broker:** ${deps.escapeHtmlForTg((brokerErr as Error)?.message ?? String(brokerErr))}`,
|
|
485
|
+
{ html: true },
|
|
486
|
+
)
|
|
487
|
+
}
|
|
488
|
+
} catch (err) {
|
|
489
|
+
// submitAccountAuthCode wiped the scratch dir on its own
|
|
490
|
+
// failure paths (timeout, child exit, stdin broken).
|
|
491
|
+
await deps.switchroomReply(
|
|
492
|
+
p.ctx,
|
|
493
|
+
`**/auth add code failed:** ${deps.escapeHtmlForTg((err as Error)?.message ?? String(err))}`,
|
|
494
|
+
{ html: true },
|
|
495
|
+
)
|
|
496
|
+
}
|
|
497
|
+
// Redact the OAuth code paste from chat history (#488).
|
|
498
|
+
deps.redactAuthCode(p.chat_id, p.msgId ?? null)
|
|
499
|
+
return { handled: true }
|
|
500
|
+
}
|
|
501
|
+
// Stale — drop the pending entry but let the message fall through
|
|
502
|
+
// to other intercepts (defensively wipe scratch).
|
|
503
|
+
deps.cancelAccountAuthSession(pendingAdd)
|
|
504
|
+
deps.pendingAuthAddFlows.delete(p.interceptKey)
|
|
505
|
+
return { handled: false }
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Loopback OAuth relay paste-back intercept (issue #2582) — sibling to the
|
|
510
|
+
* /auth add intercept above. When a Google/Microsoft loopback relay is pending
|
|
511
|
+
* for this chat and the operator pastes their `127.0.0.1:<port>` redirect URL,
|
|
512
|
+
* validate `state` and hand the code to the waiting CLI listener. The LLM
|
|
513
|
+
* never sees the code (same hygiene rationale as auth-add). Consume-gate is
|
|
514
|
+
* deliberately narrow (PR #3100 review finding 1): only a message that
|
|
515
|
+
* actually parses as a loopback redirect — carrying a code, or a provider
|
|
516
|
+
* `error` param — is consumed and deleted; unrelated chatter that merely
|
|
517
|
+
* mentions localhost/127.0.0.1 flows through untouched.
|
|
518
|
+
*
|
|
519
|
+
* Includes the fail-safe redaction tail (security audit #3084, F2/F3): a
|
|
520
|
+
* loopback-looking paste with no live flow (stale TTL, malformed, no flow at
|
|
521
|
+
* all) is redacted and dropped — it must NEVER reach the (prompt-injectable)
|
|
522
|
+
* agent session while carrying a possibly-live credential.
|
|
523
|
+
*
|
|
524
|
+
* The loopback state/functions are imported directly from
|
|
525
|
+
* auth-loopback-relay.ts (they already own their state module — same live Map
|
|
526
|
+
* instance the gateway uses).
|
|
527
|
+
*/
|
|
528
|
+
export async function interceptLoopbackRelay(
|
|
529
|
+
p: AuthAddParams,
|
|
530
|
+
deps: InboundInterceptorDeps,
|
|
531
|
+
): Promise<InterceptOutcome> {
|
|
532
|
+
const pendingLoop = pendingLoopbackFlows.get(p.interceptKey)
|
|
533
|
+
if (pendingLoop && shouldConsumeLoopbackPaste(p.text)) {
|
|
534
|
+
const elapsed = Date.now() - pendingLoop.startedAt
|
|
535
|
+
if (elapsed < deps.reauthInterceptTtlMs) {
|
|
536
|
+
if (pendingLoop.submitting) {
|
|
537
|
+
// A submit is already in flight (double-paste race). Don't call
|
|
538
|
+
// submitLoopbackRedirect — it would answer a non-retryable "already
|
|
539
|
+
// completed" and we'd delete the entry out from under the first
|
|
540
|
+
// submit. Benign ack; still redact (the paste carries a live code).
|
|
541
|
+
await deps.switchroomReply(
|
|
542
|
+
p.ctx,
|
|
543
|
+
'_Still finishing the previous paste — one moment._',
|
|
544
|
+
{ html: true },
|
|
545
|
+
)
|
|
546
|
+
deps.redactAuthCode(p.chat_id, p.msgId ?? null)
|
|
547
|
+
return { handled: true }
|
|
548
|
+
}
|
|
549
|
+
const result = await submitLoopbackRedirect(pendingLoop, p.text.trim())
|
|
550
|
+
if (result.ok) {
|
|
551
|
+
pendingLoopbackFlows.delete(p.interceptKey)
|
|
552
|
+
await deps.switchroomReply(
|
|
553
|
+
p.ctx,
|
|
554
|
+
`✓ ${pendingLoop.provider === 'google' ? 'Google' : 'Microsoft'} account ` +
|
|
555
|
+
`\`${deps.escapeHtmlForTg(pendingLoop.email)}\` registered with the auth-broker.`,
|
|
556
|
+
{ html: true },
|
|
557
|
+
)
|
|
558
|
+
// Redact the pasted redirect (carries the OAuth code) from history.
|
|
559
|
+
deps.redactAuthCode(p.chat_id, p.msgId ?? null)
|
|
560
|
+
return { handled: true }
|
|
561
|
+
}
|
|
562
|
+
if (result.retryable) {
|
|
563
|
+
// Keep the flow pending so the operator can paste again.
|
|
564
|
+
await deps.switchroomReply(
|
|
565
|
+
p.ctx,
|
|
566
|
+
`**Paste not accepted:** ${deps.escapeHtmlForTg(result.reason)}\n` +
|
|
567
|
+
`Re-open the consent URL, approve, and paste the full ` +
|
|
568
|
+
`\`127.0.0.1\` URL from your address bar. \`/auth ${pendingLoop.provider} cancel\` to abort.`,
|
|
569
|
+
{ html: true },
|
|
570
|
+
)
|
|
571
|
+
// Redact even a rejected paste — it may still carry a live code.
|
|
572
|
+
deps.redactAuthCode(p.chat_id, p.msgId ?? null)
|
|
573
|
+
return { handled: true }
|
|
574
|
+
}
|
|
575
|
+
// Non-retryable — the flow is spent. Kill the CLI child before
|
|
576
|
+
// dropping the entry (re-review finding, PR #3100): on the attempts-
|
|
577
|
+
// exhausted path the child is still alive with a bound 127.0.0.1
|
|
578
|
+
// listener and nothing else would ever reap it. cancelLoopbackFlow is
|
|
579
|
+
// idempotent — safe on the already-exited / timed-out paths too.
|
|
580
|
+
cancelLoopbackFlow(pendingLoop)
|
|
581
|
+
pendingLoopbackFlows.delete(p.interceptKey)
|
|
582
|
+
await deps.switchroomReply(
|
|
583
|
+
p.ctx,
|
|
584
|
+
`**/auth ${pendingLoop.provider} add failed:** ${deps.escapeHtmlForTg(result.reason)}`,
|
|
585
|
+
{ html: true },
|
|
586
|
+
)
|
|
587
|
+
deps.redactAuthCode(p.chat_id, p.msgId ?? null)
|
|
588
|
+
return { handled: true }
|
|
589
|
+
}
|
|
590
|
+
// Stale — the intercept window has closed. Kill the child and drop the
|
|
591
|
+
// entry, then fall through to the fail-safe below. We deliberately do NOT
|
|
592
|
+
// let the paste reach the agent: the pasted `code` may still be live
|
|
593
|
+
// (security audit #3084, F3), so the fail-safe redacts it.
|
|
594
|
+
cancelLoopbackFlow(pendingLoop)
|
|
595
|
+
pendingLoopbackFlows.delete(p.interceptKey)
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// Fail-safe redaction (security audit #3084, F2/F3). A message that looks
|
|
599
|
+
// like a loopback OAuth redirect/code — even one too malformed to parse
|
|
600
|
+
// cleanly, or one that arrived just after the intercept TTL closed, or one
|
|
601
|
+
// with no active flow at all — must NEVER reach the (prompt-injectable)
|
|
602
|
+
// agent session or linger unredacted in chat while carrying a possibly-live
|
|
603
|
+
// credential. shouldConsumeLoopbackPaste is narrow (requires a loopback host
|
|
604
|
+
// reference AND a code/error param), so ordinary chatter mentioning
|
|
605
|
+
// localhost flows through untouched. Redact and drop rather than forward.
|
|
606
|
+
if (shouldConsumeLoopbackPaste(p.text)) {
|
|
607
|
+
deps.redactAuthCode(p.chat_id, p.msgId ?? null)
|
|
608
|
+
await deps.switchroomReply(
|
|
609
|
+
p.ctx,
|
|
610
|
+
'_That looked like an OAuth redirect/code, so I removed it from chat and did not forward it. ' +
|
|
611
|
+
'If a Google/Microsoft account add is in progress, re-run the add command and paste the fresh ' +
|
|
612
|
+
'`127.0.0.1` URL — the previous code may have expired._',
|
|
613
|
+
{ html: true },
|
|
614
|
+
)
|
|
615
|
+
return { handled: true }
|
|
616
|
+
}
|
|
617
|
+
return { handled: false }
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* /reauth OAuth-code paste-back intercept — the elder sibling of auth-add
|
|
622
|
+
* (mutates an existing agent's slot via `switchroom auth code`, vs /auth add
|
|
623
|
+
* which creates fresh credentials at the broker). Runs AFTER auth-add /
|
|
624
|
+
* loopback (load-bearing order). Same chatKey-scoped isolation + TTL window;
|
|
625
|
+
* the code paste is redacted from history either way (#488). A stale entry is
|
|
626
|
+
* dropped and the message falls through (`handled: false`).
|
|
627
|
+
*/
|
|
628
|
+
export async function interceptReauth(
|
|
629
|
+
p: AuthAddParams,
|
|
630
|
+
deps: InboundInterceptorDeps,
|
|
631
|
+
): Promise<InterceptOutcome> {
|
|
632
|
+
const pendingReauth = deps.pendingReauthFlows.get(p.interceptKey)
|
|
633
|
+
if (!(pendingReauth && deps.looksLikeAuthCode(p.text))) return { handled: false }
|
|
634
|
+
const elapsed = Date.now() - pendingReauth.startedAt
|
|
635
|
+
if (elapsed < deps.reauthInterceptTtlMs) {
|
|
636
|
+
deps.pendingReauthFlows.delete(p.interceptKey)
|
|
637
|
+
const { result, errorText } = deps.execAuthCode(pendingReauth.agent, p.text.trim())
|
|
638
|
+
if (errorText) {
|
|
639
|
+
await deps.switchroomReply(
|
|
640
|
+
p.ctx,
|
|
641
|
+
`**auth code failed:**\n${deps.preBlock(deps.formatSwitchroomOutput(errorText))}`,
|
|
642
|
+
{ html: true },
|
|
643
|
+
)
|
|
644
|
+
} else if (result) {
|
|
645
|
+
const outcomeMsg = deps.renderAuthCodeOutcome(result.outcome)
|
|
646
|
+
if (outcomeMsg) {
|
|
647
|
+
await deps.switchroomReply(p.ctx, outcomeMsg, { html: true })
|
|
648
|
+
} else {
|
|
649
|
+
// success or no structured outcome — fall back to formatted text
|
|
650
|
+
const output = result.instructions.join('\n')
|
|
651
|
+
const formatted = deps.formatAuthOutputForTelegram(output)
|
|
652
|
+
await deps.switchroomReply(p.ctx, formatted.text, { html: true })
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
// Redact the OAuth code paste from chat history (#488).
|
|
656
|
+
// Single-use code so a third party can't replay it after exchange,
|
|
657
|
+
// but plaintext OAuth tokens in chat history are still poor
|
|
658
|
+
// hygiene. The helper handles delete + 🔑 reaction silently.
|
|
659
|
+
deps.redactAuthCode(p.chat_id, p.msgId ?? null)
|
|
660
|
+
return { handled: true }
|
|
661
|
+
}
|
|
662
|
+
deps.pendingReauthFlows.delete(p.interceptKey)
|
|
663
|
+
return { handled: false }
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
/** Per-message facts for the vault intercept (P7 PR-8). Keyed by chat_id
|
|
668
|
+
* (a vault op is chat-scoped, not topic-scoped — unlike the auth intercepts). */
|
|
669
|
+
export interface VaultInterceptParams {
|
|
670
|
+
ctx: Context
|
|
671
|
+
text: string
|
|
672
|
+
chat_id: string
|
|
673
|
+
msgId: number | undefined
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* Vault pending-op intercept: the text reply that completes a /vault flow
|
|
678
|
+
* (passphrase, broker unlock, deferred-secret save, access-approve passphrase
|
|
679
|
+
* drain, grant-wizard duration, secret value, rename-vault-save). Fully
|
|
680
|
+
* intercepts any fresh pending entry (`handled: true` — the reply text is a
|
|
681
|
+
* passphrase or secret, never forwarded to the agent); a TTL-stale entry is
|
|
682
|
+
* dropped and the message falls through.
|
|
683
|
+
*
|
|
684
|
+
* Moved VERBATIM from gateway.ts (#2996 P7 PR-8). The two raw
|
|
685
|
+
* `p.ctx.api\n.editMessageText` calls keep their inline line-split shape and
|
|
686
|
+
* `.catch(() => {})` best-effort semantics exactly as before.
|
|
687
|
+
*/
|
|
688
|
+
export async function interceptVault(
|
|
689
|
+
p: VaultInterceptParams,
|
|
690
|
+
deps: InboundInterceptorDeps,
|
|
691
|
+
): Promise<InterceptOutcome> {
|
|
692
|
+
const pendingVault = deps.pendingVaultOps.get(p.chat_id)
|
|
693
|
+
if (pendingVault) {
|
|
694
|
+
const elapsed = Date.now() - pendingVault.startedAt
|
|
695
|
+
if (elapsed > deps.vaultInputTtlMs) {
|
|
696
|
+
deps.pendingVaultOps.delete(p.chat_id)
|
|
697
|
+
} else {
|
|
698
|
+
deps.pendingVaultOps.delete(p.chat_id)
|
|
699
|
+
if (pendingVault.kind === 'passphrase') {
|
|
700
|
+
const passphrase = p.text.trim()
|
|
701
|
+
if (!passphrase) {
|
|
702
|
+
await deps.switchroomReply(p.ctx, 'Passphrase cannot be empty. Try /vault again.', { html: true })
|
|
703
|
+
return { handled: true }
|
|
704
|
+
}
|
|
705
|
+
deps.vaultPassphraseCache.set(p.chat_id, { passphrase, expiresAt: Date.now() + deps.vaultPassphraseTtlMs })
|
|
706
|
+
if (p.msgId != null) await deps.deleteSensitiveMessage(p.chat_id, p.msgId, 'vault passphrase')
|
|
707
|
+
await deps.executeVaultOp(p.ctx, p.chat_id, pendingVault.op, pendingVault.key, passphrase, undefined)
|
|
708
|
+
} else if (pendingVault.kind === 'unlock') {
|
|
709
|
+
// Issue #158: passphrase for /vault unlock — sent directly to the
|
|
710
|
+
// broker unlock socket, never cached or logged.
|
|
711
|
+
const passphrase = p.text.trim()
|
|
712
|
+
if (!passphrase) {
|
|
713
|
+
await deps.switchroomReply(p.ctx, 'Passphrase cannot be empty. Try /vault unlock again.', { html: true })
|
|
714
|
+
return { handled: true }
|
|
715
|
+
}
|
|
716
|
+
if (p.msgId != null) await deps.deleteSensitiveMessage(p.chat_id, p.msgId, 'vault unlock passphrase')
|
|
717
|
+
const result = await deps.unlockViaBroker(passphrase)
|
|
718
|
+
if (result.ok) {
|
|
719
|
+
await deps.switchroomReply(p.ctx, '🔓 Vault broker unlocked.', { html: true })
|
|
720
|
+
} else {
|
|
721
|
+
await deps.switchroomReply(p.ctx, `**vault unlock failed:** ${deps.escapeHtmlForTg(result.msg ?? 'unknown error')}`, { html: true })
|
|
722
|
+
}
|
|
723
|
+
} else if (pendingVault.kind === 'passphrase-for-deferred') {
|
|
724
|
+
// Issue #44: passphrase entered after tapping "🔓 Unlock vault &
|
|
725
|
+
// save" on the deferred-secret card. Cache the passphrase then
|
|
726
|
+
// auto-write the held secret directly — no re-paste required.
|
|
727
|
+
// The passphrase message itself is deleted so it doesn't linger
|
|
728
|
+
// in chat history (same protection as the original secret got).
|
|
729
|
+
const passphrase = p.text.trim()
|
|
730
|
+
if (!passphrase) {
|
|
731
|
+
await deps.switchroomReply(p.ctx, 'Passphrase cannot be empty. Tap the unlock button again.', { html: true })
|
|
732
|
+
return { handled: true }
|
|
733
|
+
}
|
|
734
|
+
deps.vaultPassphraseCache.set(p.chat_id, { passphrase, expiresAt: Date.now() + deps.vaultPassphraseTtlMs })
|
|
735
|
+
if (p.msgId != null) await deps.deleteSensitiveMessage(p.chat_id, p.msgId, 'vault passphrase')
|
|
736
|
+
await deps.callbackQueryHandlers().executeDeferredSecretSave(p.ctx, pendingVault.deferKey, passphrase, pendingVault.cardMessageId)
|
|
737
|
+
} else if (pendingVault.kind === 'passphrase-for-access-approve') {
|
|
738
|
+
// #1012 Phase 2 follow-up + #1051: operator tapped Approve on
|
|
739
|
+
// one or more vault_request_access cards without first
|
|
740
|
+
// unlocking. We captured the next message as the passphrase,
|
|
741
|
+
// cache it, delete the chat copy, and resume the approve
|
|
742
|
+
// flow for EVERY queued stage (#1051 — without the queue, a
|
|
743
|
+
// concurrent second tap orphaned the first stage). Wrong
|
|
744
|
+
// passphrase surfaces via the broker's
|
|
745
|
+
// DENIED:passphrase-mismatch path and edits each card to the
|
|
746
|
+
// mint_grant-failed message (see performVaultAccessApproval).
|
|
747
|
+
const passphrase = p.text.trim()
|
|
748
|
+
if (!passphrase) {
|
|
749
|
+
await deps.switchroomReply(p.ctx, 'Passphrase cannot be empty. Ask the agent to re-issue the request card.', { html: true })
|
|
750
|
+
return { handled: true }
|
|
751
|
+
}
|
|
752
|
+
deps.vaultPassphraseCache.set(p.chat_id, { passphrase, expiresAt: Date.now() + deps.vaultPassphraseTtlMs })
|
|
753
|
+
if (p.msgId != null) await deps.deleteSensitiveMessage(p.chat_id, p.msgId, 'vault passphrase')
|
|
754
|
+
// Drain the queued items SEQUENTIALLY. The grant-union step
|
|
755
|
+
// inside performVaultAccessApproval lists existing grants
|
|
756
|
+
// before minting; sequential processing means the union
|
|
757
|
+
// grows monotonically (item 1 mints grant for [keyA]; item 2
|
|
758
|
+
// lists, finds [keyA], unions with keyB → mints [keyA, keyB]).
|
|
759
|
+
// Parallel processing would race on the list-and-merge.
|
|
760
|
+
for (const item of pendingVault.items) {
|
|
761
|
+
const stagedAccess = deps.pendingVaultRequestAccesses.get(item.stageId)
|
|
762
|
+
if (!stagedAccess) {
|
|
763
|
+
// Stage expired or denied between tap and passphrase reply.
|
|
764
|
+
// Edit its card to a clean explanation; don't break the
|
|
765
|
+
// loop (sibling cards may still be valid).
|
|
766
|
+
await p.ctx.api
|
|
767
|
+
.editMessageText(
|
|
768
|
+
item.cardChatId,
|
|
769
|
+
item.cardMessageId,
|
|
770
|
+
richMessage(`⌛ _Vault unlocked, but this access-request card expired or was denied before you replied. Ask the agent to re-issue if still needed._`),
|
|
771
|
+
{ reply_markup: { inline_keyboard: [] } },
|
|
772
|
+
)
|
|
773
|
+
.catch(() => {})
|
|
774
|
+
continue
|
|
775
|
+
}
|
|
776
|
+
await deps.callbackQueryHandlers().performVaultAccessApproval(p.ctx, stagedAccess, item.stageId, item.senderId, { kind: 'passphrase', passphrase })
|
|
777
|
+
}
|
|
778
|
+
} else if (pendingVault.kind === 'grant-wizard' && pendingVault.awaitingCustomDuration) {
|
|
779
|
+
// Issue #227: custom duration text reply for grant wizard
|
|
780
|
+
const input = p.text.trim()
|
|
781
|
+
const ttlSeconds = deps.callbackQueryHandlers().parseGrantDuration(input)
|
|
782
|
+
if (ttlSeconds === null) {
|
|
783
|
+
// Re-set state so user can try again
|
|
784
|
+
deps.pendingVaultOps.set(p.chat_id, { ...pendingVault, startedAt: Date.now() })
|
|
785
|
+
await deps.switchroomReply(p.ctx, '⚠️ Invalid duration. Use \`Nd\` (days) or \`Nh\` (hours), e.g. \`30d\` or \`12h\`.', { html: true })
|
|
786
|
+
return { handled: true }
|
|
787
|
+
}
|
|
788
|
+
const newState = { ...pendingVault, ttlSeconds, awaitingCustomDuration: false }
|
|
789
|
+
await deps.callbackQueryHandlers().grantWizardConfirm(p.ctx, p.chat_id, newState)
|
|
790
|
+
} else if (pendingVault.kind === 'grant-wizard') {
|
|
791
|
+
// Text received mid-wizard but not awaiting custom duration — ignore and re-set
|
|
792
|
+
deps.pendingVaultOps.set(p.chat_id, { ...pendingVault, startedAt: Date.now() })
|
|
793
|
+
} else if (pendingVault.kind === 'value') {
|
|
794
|
+
let value = p.text
|
|
795
|
+
const codeBlockMatch = /^```[\w]*\n?([\s\S]*?)```$/m.exec(p.text)
|
|
796
|
+
if (codeBlockMatch) value = codeBlockMatch[1]!
|
|
797
|
+
if (p.msgId != null) await deps.deleteSensitiveMessage(p.chat_id, p.msgId, 'vault secret value')
|
|
798
|
+
await deps.executeVaultOp(p.ctx, p.chat_id, 'set', pendingVault.key, pendingVault.passphrase, value.trim())
|
|
799
|
+
} else if (pendingVault.kind === 'rename-vault-save') {
|
|
800
|
+
// Issue #969 P1a: user tapped Rename on a vault_request_save
|
|
801
|
+
// card and is now telling us the new key name. Validate the
|
|
802
|
+
// slug, update the staged entry, refresh the card.
|
|
803
|
+
const newKey = p.text.trim()
|
|
804
|
+
const staged = deps.pendingVaultRequestSaves.get(pendingVault.stageId)
|
|
805
|
+
if (!staged) {
|
|
806
|
+
await deps.switchroomReply(p.ctx, '⌛ That save card expired before you renamed. Ask the agent to re-issue.', { html: true })
|
|
807
|
+
return { handled: true }
|
|
808
|
+
}
|
|
809
|
+
if (!deps.vaultKeyRegex.test(newKey)) {
|
|
810
|
+
// Re-arm the pending state so the user can try again.
|
|
811
|
+
deps.pendingVaultOps.set(p.chat_id, { ...pendingVault, startedAt: Date.now() })
|
|
812
|
+
await deps.switchroomReply(p.ctx, `⚠️ Key must match \`${deps.vaultKeyRegexLabel}\`. Send a different name.`, { html: true })
|
|
813
|
+
return { handled: true }
|
|
814
|
+
}
|
|
815
|
+
staged.key = newKey
|
|
816
|
+
if (p.msgId != null) await deps.deleteSensitiveMessage(p.chat_id, p.msgId, 'vault rename input')
|
|
817
|
+
// Edit the card in place with the new suggested key + same buttons.
|
|
818
|
+
if (staged.card_message_id != null) {
|
|
819
|
+
await p.ctx.api
|
|
820
|
+
.editMessageText(
|
|
821
|
+
staged.chat_id,
|
|
822
|
+
staged.card_message_id,
|
|
823
|
+
richMessage(renderVaultRequestSaveCard(staged, staged.agent)),
|
|
824
|
+
{ reply_markup: buildVaultRequestSaveKeyboard(pendingVault.stageId) },
|
|
825
|
+
)
|
|
826
|
+
.catch(() => {})
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
return { handled: true }
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
return { handled: false }
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
/** Per-message facts for the secret-detect intercepts (P7 PR-9). */
|
|
837
|
+
export interface SecretStagingCommandParams {
|
|
838
|
+
ctx: Context
|
|
839
|
+
text: string
|
|
840
|
+
chat_id: string
|
|
841
|
+
msgId: number | undefined
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
/**
|
|
845
|
+
* Secret-detect follow-up command intercept: `stash NAME` / `ignore` /
|
|
846
|
+
* `rename X` / `forget` on a pending ambiguous detection. The user is replying
|
|
847
|
+
* to our "looks like a high-entropy string — reply `stash NAME` or `ignore`"
|
|
848
|
+
* prompt; we look up the most recent staged detection for this chat and act on
|
|
849
|
+
* it. With no staged entry the message falls through to normal handling.
|
|
850
|
+
* Moved verbatim from gateway.ts (#2996 P7 PR-9).
|
|
851
|
+
*/
|
|
852
|
+
export async function interceptSecretStagingCommand(
|
|
853
|
+
p: SecretStagingCommandParams,
|
|
854
|
+
deps: InboundInterceptorDeps,
|
|
855
|
+
): Promise<InterceptOutcome> {
|
|
856
|
+
// --- Secret-detect follow-up command intercept ---
|
|
857
|
+
// `stash NAME` / `ignore` / `rename X` / `forget` on a pending ambiguous
|
|
858
|
+
// detection. The user is replying to our "looks like a high-entropy
|
|
859
|
+
// string — reply `stash NAME` or `ignore`" prompt. We look up the most
|
|
860
|
+
// recent staged detection for this chat and act on it.
|
|
861
|
+
const stagedMatch = /^\s*(stash|ignore|rename|forget)\b\s*(\S+)?/i.exec(p.text)
|
|
862
|
+
if (stagedMatch) {
|
|
863
|
+
const staged = deps.secretStaging.latestForChat(p.chat_id)
|
|
864
|
+
if (staged != null) {
|
|
865
|
+
const verb = stagedMatch[1]!.toLowerCase()
|
|
866
|
+
const arg = stagedMatch[2]?.trim()
|
|
867
|
+
if (verb === 'ignore' || verb === 'forget') {
|
|
868
|
+
deps.secretStaging.delete(staged.chat_id, staged.message_id)
|
|
869
|
+
await deps.switchroomReply(p.ctx, 'ok — ignored. nothing stored.', { html: true })
|
|
870
|
+
// #1075: operates on a message in a (possibly-deleted) thread.
|
|
871
|
+
if (p.msgId != null) {
|
|
872
|
+
const delMsgId = p.msgId
|
|
873
|
+
void deps.swallowingApiCall(
|
|
874
|
+
() => deps.botApi().deleteMessage(p.chat_id, delMsgId),
|
|
875
|
+
{ chat_id: p.chat_id, verb: 'staged-secret.delete-ignored' },
|
|
876
|
+
)
|
|
877
|
+
}
|
|
878
|
+
return { handled: true }
|
|
879
|
+
}
|
|
880
|
+
if (verb === 'stash' || verb === 'rename') {
|
|
881
|
+
const cached = deps.vaultPassphraseCache.get(p.chat_id)
|
|
882
|
+
if (!cached || cached.expiresAt <= Date.now()) {
|
|
883
|
+
await deps.switchroomReply(p.ctx, 'No vault passphrase cached. Run \`/vault list\` first (or any /vault command) to unlock, then re-send \`stash NAME\`.', { html: true })
|
|
884
|
+
return { handled: true }
|
|
885
|
+
}
|
|
886
|
+
const slugBase = arg && arg.length > 0 ? arg : staged.detection.suggested_slug
|
|
887
|
+
const listed = defaultVaultList(cached.passphrase)
|
|
888
|
+
const existing = new Set(listed.ok ? listed.keys : [])
|
|
889
|
+
let slug = slugBase
|
|
890
|
+
let n = 2
|
|
891
|
+
while (existing.has(slug)) slug = `${slugBase}_${n++}`
|
|
892
|
+
const write = defaultVaultWrite(slug, staged.detection.matched_text, cached.passphrase)
|
|
893
|
+
if (!write.ok) {
|
|
894
|
+
// Route P0a markers (#969) through the structured renderer so
|
|
895
|
+
// a "new key, needs operator approval" failure surfaces a
|
|
896
|
+
// host-CLI hint instead of a raw stderr blob.
|
|
897
|
+
const parsed = parseVaultCliError(write.output)
|
|
898
|
+
const rendered = renderVaultCliError(parsed, { verb: 'save', key: slug })
|
|
899
|
+
const body = rendered.suppressRaw
|
|
900
|
+
? rendered.html
|
|
901
|
+
: `**vault write failed:**\n${deps.preBlock(write.output)}`
|
|
902
|
+
await deps.switchroomReply(p.ctx, body, { html: true })
|
|
903
|
+
return { handled: true }
|
|
904
|
+
}
|
|
905
|
+
deps.secretStaging.delete(staged.chat_id, staged.message_id)
|
|
906
|
+
if (p.msgId != null) await deps.deleteSensitiveMessage(p.chat_id, p.msgId, 'stash command')
|
|
907
|
+
await deps.deleteSensitiveMessage(p.chat_id, staged.message_id, 'detected secret')
|
|
908
|
+
await deps.switchroomReply(p.ctx, `✅ stored as \`vault:${slug}\` (masked: \`${maskToken(staged.detection.matched_text)}\`)`, { html: true })
|
|
909
|
+
return { handled: true }
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
// No staged entry to act on — fall through to normal handling.
|
|
913
|
+
}
|
|
914
|
+
return { handled: false }
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
/** Params for the secret-detect pipeline intercept (P7 PR-9). `effectiveText`
|
|
918
|
+
* is the steer/queue-prefix-stripped text captured in handleInbound; the
|
|
919
|
+
* scrub may REWRITE it, so it crosses back in the outcome as a value. */
|
|
920
|
+
export interface SecretDetectPipelineParams {
|
|
921
|
+
ctx: Context
|
|
922
|
+
chat_id: string
|
|
923
|
+
msgId: number | undefined
|
|
924
|
+
effectiveText: string
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
/** Outcome of the pipeline intercept: `handled: true` drops/consumes the
|
|
928
|
+
* message; otherwise the caller MUST adopt `effectiveText` (possibly rewritten
|
|
929
|
+
* with vault refs) for everything downstream — history, envelope, IPC. */
|
|
930
|
+
export type SecretDetectPipelineOutcome =
|
|
931
|
+
| { handled: true }
|
|
932
|
+
| { handled: false; effectiveText: string }
|
|
933
|
+
|
|
934
|
+
/**
|
|
935
|
+
* Secret detection + vault-scrub (moved verbatim, #2996 P7 PR-9). If the user
|
|
936
|
+
* pasted a secret, intercept BEFORE recording to history or broadcasting to
|
|
937
|
+
* the agent: write to vault, delete the Telegram message, rewrite the prompt
|
|
938
|
+
* so the downstream session .jsonl, Hindsight memory, and IPC payload never
|
|
939
|
+
* see the raw bytes. No cached passphrase → high-confidence hits are deferred
|
|
940
|
+
* behind the one-tap unlock+save card (#44).
|
|
941
|
+
*
|
|
942
|
+
* FAIL-CLOSED: if the pipeline throws, drop the message and warn the user —
|
|
943
|
+
* never fall through to recordInbound/broadcast with raw bytes. See
|
|
944
|
+
* gateway-secret-detect.test.ts and secret-detect-fail-closed.test.ts.
|
|
945
|
+
*/
|
|
946
|
+
export async function interceptSecretDetectPipeline(
|
|
947
|
+
p: SecretDetectPipelineParams,
|
|
948
|
+
deps: InboundInterceptorDeps,
|
|
949
|
+
): Promise<SecretDetectPipelineOutcome> {
|
|
950
|
+
let effectiveText = p.effectiveText
|
|
951
|
+
// --- Secret detection + vault-scrub ---
|
|
952
|
+
// If the user pasted a secret, intercept BEFORE we record to history or
|
|
953
|
+
// broadcast to the agent: write to vault, delete the Telegram message,
|
|
954
|
+
// rewrite the prompt so the downstream session .jsonl, Hindsight memory,
|
|
955
|
+
// and IPC payload never see the raw bytes. If there's no cached vault
|
|
956
|
+
// passphrase, high-confidence hits are deferred and the user is asked to
|
|
957
|
+
// unlock first.
|
|
958
|
+
//
|
|
959
|
+
// FAIL-CLOSED: if the pipeline throws, drop the message and warn the user
|
|
960
|
+
// — never fall through to recordInbound/broadcast with raw bytes. See
|
|
961
|
+
// gateway-secret-detect.test.ts and secret-detect-fail-closed.test.ts.
|
|
962
|
+
try {
|
|
963
|
+
// Channel B context rule: if we emitted "Paste the browser code here"
|
|
964
|
+
// recently in this chat, treat the inbound as auth-flow-sensitive —
|
|
965
|
+
// high-confidence secret detection regardless of pattern match. This
|
|
966
|
+
// survives Anthropic changing their token format because it tracks the
|
|
967
|
+
// gateway's own prompt, not the token shape.
|
|
968
|
+
const authCodeSentAt = deps.awaitingAuthCodeAt.get(p.chat_id)
|
|
969
|
+
const isAuthFlowContext =
|
|
970
|
+
authCodeSentAt !== undefined && Date.now() - authCodeSentAt < deps.authCodeContextTtlMs
|
|
971
|
+
if (isAuthFlowContext) {
|
|
972
|
+
process.stderr.write(`[secret-detect] auth-flow context rule active for chat ${p.chat_id}\n`)
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
const cachedPp = deps.vaultPassphraseCache.get(p.chat_id)
|
|
976
|
+
const passphrase = cachedPp && cachedPp.expiresAt > Date.now() ? cachedPp.passphrase : null
|
|
977
|
+
if (passphrase) {
|
|
978
|
+
const pipeRes = runPipeline({
|
|
979
|
+
chat_id: p.chat_id,
|
|
980
|
+
message_id: p.msgId ?? null,
|
|
981
|
+
text: effectiveText,
|
|
982
|
+
passphrase,
|
|
983
|
+
vaultWrite: defaultVaultWrite,
|
|
984
|
+
vaultList: defaultVaultList,
|
|
985
|
+
})
|
|
986
|
+
if (pipeRes.stored.length > 0) {
|
|
987
|
+
effectiveText = pipeRes.rewritten_text
|
|
988
|
+
if (isAuthFlowContext) {
|
|
989
|
+
deps.awaitingAuthCodeAt.delete(p.chat_id) // consume: one message per prompt
|
|
990
|
+
}
|
|
991
|
+
// 2026-05-12: route through deps.deleteSensitiveMessage so a
|
|
992
|
+
// failed delete posts an in-chat warning naming the leaked
|
|
993
|
+
// message id, instead of only logging to stderr (invisible
|
|
994
|
+
// on mobile). Previously the operator saw "we deleted it
|
|
995
|
+
// from chat" while the raw secret stayed visible. See
|
|
996
|
+
// tests/secret-detect-delete-must-surface-failures.test.ts.
|
|
997
|
+
if (p.msgId != null) {
|
|
998
|
+
await deps.deleteSensitiveMessage(p.chat_id, p.msgId, 'detected secret')
|
|
999
|
+
}
|
|
1000
|
+
const lines = pipeRes.stored.map((s) =>
|
|
1001
|
+
`• \`${s.masked}\` → \`vault:${s.actual_slug}\``,
|
|
1002
|
+
)
|
|
1003
|
+
await deps.switchroomReply(
|
|
1004
|
+
p.ctx,
|
|
1005
|
+
[`🔒 captured ${pipeRes.stored.length} secret${pipeRes.stored.length === 1 ? '' : 's'}:`, ...lines, '', 'reply \`rename X\` or \`forget\`.'].join('\n'),
|
|
1006
|
+
{ html: true },
|
|
1007
|
+
)
|
|
1008
|
+
for (const s of pipeRes.stored) {
|
|
1009
|
+
deps.secretStaging.set({
|
|
1010
|
+
chat_id: p.chat_id,
|
|
1011
|
+
message_id: p.msgId ?? 0,
|
|
1012
|
+
detection: { ...s.detection, suggested_slug: s.actual_slug },
|
|
1013
|
+
staged_at: Date.now(),
|
|
1014
|
+
})
|
|
1015
|
+
}
|
|
1016
|
+
} else if (isAuthFlowContext && pipeRes.stored.length === 0) {
|
|
1017
|
+
// Channel B fallback: pattern didn't fire (Anthropic may have changed
|
|
1018
|
+
// the token format) but we know this is an auth code paste because we
|
|
1019
|
+
// prompted for it. Delete + stage + warn so no raw bytes leak.
|
|
1020
|
+
deps.awaitingAuthCodeAt.delete(p.chat_id) // consume: one message per prompt
|
|
1021
|
+
// 2026-05-12: route through deps.deleteSensitiveMessage. See
|
|
1022
|
+
// tests/secret-detect-delete-must-surface-failures.test.ts.
|
|
1023
|
+
if (p.msgId != null) {
|
|
1024
|
+
await deps.deleteSensitiveMessage(p.chat_id, p.msgId, 'auth-flow secret')
|
|
1025
|
+
}
|
|
1026
|
+
// Issue #44: even with passphrase cached we hit this branch when the
|
|
1027
|
+
// pattern didn't fire — but at this point a vault write would still
|
|
1028
|
+
// need the user's intent. Stash with a one-tap unlock+save card so
|
|
1029
|
+
// the post-context flow stays seamless.
|
|
1030
|
+
const dKey = deps.deferredKey(p.chat_id, p.msgId ?? 0)
|
|
1031
|
+
const cachedBranchDetection = detectSecrets(effectiveText).find((d) => d.confidence === 'high' && !d.suppressed)
|
|
1032
|
+
const cachedBranchSlug = cachedBranchDetection?.suggested_slug ?? (isAuthFlowContext ? 'anthropic_oauth_code' : 'secret')
|
|
1033
|
+
const cachedBranchKernelId = await deps.mintDeferredSecretKernelRequest(
|
|
1034
|
+
cachedBranchSlug,
|
|
1035
|
+
deps.loadAccess().allowFrom,
|
|
1036
|
+
)
|
|
1037
|
+
deps.deferredSecrets.set(dKey, {
|
|
1038
|
+
chat_id: p.chat_id,
|
|
1039
|
+
original_message_id: p.msgId ?? 0,
|
|
1040
|
+
text: effectiveText,
|
|
1041
|
+
staged_at: Date.now(),
|
|
1042
|
+
suggested_slug: cachedBranchSlug,
|
|
1043
|
+
kernel_request_id: cachedBranchKernelId ?? undefined,
|
|
1044
|
+
})
|
|
1045
|
+
await deps.switchroomReply(
|
|
1046
|
+
p.ctx,
|
|
1047
|
+
'🔒 auth-flow secret detected. we deleted it from chat. tap below to save it to the vault — no re-paste needed.',
|
|
1048
|
+
{ html: true, reply_markup: deps.buildDeferredSecretKeyboard(dKey) },
|
|
1049
|
+
)
|
|
1050
|
+
return { handled: true }
|
|
1051
|
+
} else if (pipeRes.ambiguous.length > 0) {
|
|
1052
|
+
for (const d of pipeRes.ambiguous) {
|
|
1053
|
+
deps.secretStaging.set({ chat_id: p.chat_id, message_id: p.msgId ?? 0, detection: d, staged_at: Date.now() })
|
|
1054
|
+
}
|
|
1055
|
+
const top = pipeRes.ambiguous[0]!
|
|
1056
|
+
await deps.switchroomReply(
|
|
1057
|
+
p.ctx,
|
|
1058
|
+
`👀 looks like a high-entropy string (rule: \`${top.rule_id}\`). reply \`stash NAME\` to store in vault, or \`ignore\`.`,
|
|
1059
|
+
{ html: true },
|
|
1060
|
+
)
|
|
1061
|
+
// For ambiguous, we do NOT delete the message or rewrite — let the
|
|
1062
|
+
// user confirm first.
|
|
1063
|
+
}
|
|
1064
|
+
} else {
|
|
1065
|
+
// No passphrase cached — detect, but defer. Issue #44 turned this
|
|
1066
|
+
// into a one-tap unlock-and-save flow: previously the user had to
|
|
1067
|
+
// run `/vault list`, type their passphrase, then re-paste the
|
|
1068
|
+
// original secret (six steps for a non-technical user). Now they
|
|
1069
|
+
// tap a button and re-enter the passphrase exactly once.
|
|
1070
|
+
const detections = detectSecrets(effectiveText)
|
|
1071
|
+
const highConfDetection = detections.find((d) => d.confidence === 'high' && !d.suppressed)
|
|
1072
|
+
const hasHigh = highConfDetection !== undefined || isAuthFlowContext
|
|
1073
|
+
if (hasHigh) {
|
|
1074
|
+
if (isAuthFlowContext) {
|
|
1075
|
+
deps.awaitingAuthCodeAt.delete(p.chat_id) // consume: one message per prompt
|
|
1076
|
+
}
|
|
1077
|
+
// Capture the slug at defer-time so the post-unlock auto-write
|
|
1078
|
+
// doesn't have to re-detect (which has a degenerate case for
|
|
1079
|
+
// Channel-B context defers where no pattern fired).
|
|
1080
|
+
const suggestedSlug =
|
|
1081
|
+
highConfDetection?.suggested_slug
|
|
1082
|
+
?? (isAuthFlowContext ? 'anthropic_oauth_code' : 'secret')
|
|
1083
|
+
const dKey = deps.deferredKey(p.chat_id, p.msgId ?? 0)
|
|
1084
|
+
const noPassKernelId = await deps.mintDeferredSecretKernelRequest(
|
|
1085
|
+
suggestedSlug,
|
|
1086
|
+
deps.loadAccess().allowFrom,
|
|
1087
|
+
)
|
|
1088
|
+
deps.deferredSecrets.set(dKey, {
|
|
1089
|
+
chat_id: p.chat_id,
|
|
1090
|
+
original_message_id: p.msgId ?? 0,
|
|
1091
|
+
text: effectiveText,
|
|
1092
|
+
staged_at: Date.now(),
|
|
1093
|
+
suggested_slug: suggestedSlug,
|
|
1094
|
+
kernel_request_id: noPassKernelId ?? undefined,
|
|
1095
|
+
})
|
|
1096
|
+
// 2026-05-12: route through deps.deleteSensitiveMessage. See
|
|
1097
|
+
// tests/secret-detect-delete-must-surface-failures.test.ts.
|
|
1098
|
+
if (p.msgId != null) {
|
|
1099
|
+
await deps.deleteSensitiveMessage(p.chat_id, p.msgId, 'detected secret')
|
|
1100
|
+
}
|
|
1101
|
+
await deps.switchroomReply(
|
|
1102
|
+
p.ctx,
|
|
1103
|
+
'🔒 caught a secret. we deleted it from chat. tap below to unlock the vault and save it — no re-paste needed.',
|
|
1104
|
+
{ html: true, reply_markup: deps.buildDeferredSecretKeyboard(dKey) },
|
|
1105
|
+
)
|
|
1106
|
+
return { handled: true }
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
} catch (err) {
|
|
1110
|
+
// FAIL-CLOSED: if the detector throws, we must NOT fall through to
|
|
1111
|
+
// recordInbound() / ipcServer.broadcast() with the raw text — that
|
|
1112
|
+
// would stamp the secret into SQLite and emit it to the agent
|
|
1113
|
+
// unscrubbed. Drop the message on the floor and warn the user.
|
|
1114
|
+
process.stderr.write(`[secret-detect] pipeline error: ${(err as Error).message}\n`)
|
|
1115
|
+
try {
|
|
1116
|
+
await deps.switchroomReply(
|
|
1117
|
+
p.ctx,
|
|
1118
|
+
'⚠️ secret-detect pipeline crashed; this message was dropped for safety. please try again or check the agent log.',
|
|
1119
|
+
{ html: true },
|
|
1120
|
+
)
|
|
1121
|
+
} catch {}
|
|
1122
|
+
// 2026-05-12: route through deps.deleteSensitiveMessage. The
|
|
1123
|
+
// pipeline-error fail-closed path is the LAST line of defence —
|
|
1124
|
+
// if delete fails here, the operator must know so they can
|
|
1125
|
+
// delete manually. See
|
|
1126
|
+
// tests/secret-detect-delete-must-surface-failures.test.ts.
|
|
1127
|
+
if (p.msgId != null) {
|
|
1128
|
+
await deps.deleteSensitiveMessage(p.chat_id, p.msgId, 'secret-detect pipeline-error fallback')
|
|
1129
|
+
}
|
|
1130
|
+
return { handled: true }
|
|
1131
|
+
}
|
|
1132
|
+
return { handled: false, effectiveText }
|
|
1133
|
+
}
|