switchroom 0.18.12 → 0.18.13
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/agent-scheduler/index.js +8 -0
- package/dist/auth-broker/index.js +63 -65
- package/dist/cli/ms-365-write-pretool.mjs +31 -8
- package/dist/cli/notion-write-pretool.mjs +9 -1
- package/dist/cli/skill-validate-pretool.mjs +144 -2847
- package/dist/cli/switchroom.js +952 -3126
- package/dist/host-control/main.js +216 -2862
- package/dist/vault/approvals/kernel-server.js +67 -0
- package/dist/vault/broker/server.js +98 -44
- package/package.json +1 -1
- package/telegram-plugin/dist/bridge/bridge.js +49 -3
- package/telegram-plugin/dist/gateway/gateway.js +656 -2326
- package/telegram-plugin/dist/server.js +65 -3
- package/telegram-plugin/format.ts +19 -0
- package/telegram-plugin/gateway/approval-hold.ts +21 -2
- package/telegram-plugin/gateway/callback-query-handlers.ts +12 -0
- package/telegram-plugin/gateway/gateway.ts +221 -73
- package/telegram-plugin/history.ts +51 -0
- package/telegram-plugin/inline-keyboard-callbacks.ts +94 -0
- package/telegram-plugin/model-unavailable.ts +41 -11
- package/telegram-plugin/outbound-field-redact.ts +69 -0
- package/telegram-plugin/render/render.ts +32 -14
- package/telegram-plugin/scoped-approval.ts +11 -2
- package/telegram-plugin/secret-detect/chunker.ts +18 -4
- package/telegram-plugin/secret-detect/index.ts +12 -56
- package/telegram-plugin/send-gate-degraded.test.ts +131 -0
- package/telegram-plugin/send-gate.test.ts +25 -6
- package/telegram-plugin/send-gate.ts +82 -8
- package/telegram-plugin/session-tail.ts +82 -7
- package/telegram-plugin/subagent-watcher.ts +71 -16
- package/telegram-plugin/tests/approval-hold-outcome.test.ts +36 -5
- package/telegram-plugin/tests/callback-query-handlers.test.ts +65 -0
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +57 -0
- package/telegram-plugin/tests/history.test.ts +115 -0
- package/telegram-plugin/tests/inbound-message-types.test.ts +5 -1
- package/telegram-plugin/tests/inline-keyboard-callbacks.test.ts +164 -0
- package/telegram-plugin/tests/operator-events-session-tail.test.ts +74 -0
- package/telegram-plugin/tests/outbound-field-redact.test.ts +107 -0
- package/telegram-plugin/tests/reaction-gate-routing.test.ts +173 -0
- package/telegram-plugin/tests/render/render.test.ts +88 -0
- package/telegram-plugin/tests/scoped-approval.test.ts +27 -0
- package/telegram-plugin/tests/secret-detect-chunk-overlap.test.ts +65 -0
- package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +5 -4
- package/telegram-plugin/tests/session-tail-sidecar-reap.test.ts +268 -0
- package/telegram-plugin/tests/subagent-watcher-fd-leak.test.ts +275 -0
- package/telegram-plugin/tests/worktree-watch-cwds.test.ts +215 -1
- package/telegram-plugin/worktree-watch-cwds.ts +194 -5
- package/telegram-plugin/secret-detect/secretlint-source.ts +0 -95
- package/telegram-plugin/tests/secret-detect-secretlint.test.ts +0 -105
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
type AskUserArgs,
|
|
35
35
|
type AskUserOutcome,
|
|
36
36
|
} from '../ask-user.js'
|
|
37
|
+
import { redactAskUserFields, redactChecklistFields } from '../outbound-field-redact.js'
|
|
37
38
|
import { parseInterruptMarker } from '../interrupt-marker.js'
|
|
38
39
|
import {
|
|
39
40
|
ToolFlightTracker,
|
|
@@ -340,6 +341,7 @@ import {
|
|
|
340
341
|
} from '../telegram-button-constraints.js'
|
|
341
342
|
import {
|
|
342
343
|
wrapAgentCallbacks,
|
|
344
|
+
redactAgentKeyboard,
|
|
343
345
|
parseAgentCallback,
|
|
344
346
|
extractAgentButtonMeta,
|
|
345
347
|
keyboardIsSingleUse,
|
|
@@ -691,8 +693,8 @@ import {
|
|
|
691
693
|
startSubagentWatcher,
|
|
692
694
|
type SubagentWatcherHandle,
|
|
693
695
|
} from '../subagent-watcher.js'
|
|
694
|
-
import { listRecords as listWorktreeRecords } from '../../src/worktree/registry.js'
|
|
695
|
-
import {
|
|
696
|
+
import { listRecords as listWorktreeRecords, touchHeartbeat as touchWorktreeHeartbeat } from '../../src/worktree/registry.js'
|
|
697
|
+
import { makeWorktreeWatchProvider } from '../worktree-watch-cwds.js'
|
|
696
698
|
import {
|
|
697
699
|
startBootCard,
|
|
698
700
|
resolvePersonaName,
|
|
@@ -5466,9 +5468,10 @@ const recordFloodWindow = makeFloodWindowRecorder(FLOOD_WINDOWS_PATH)
|
|
|
5466
5468
|
// edit floor + no-op-edit skip + PRIORITY SHEDDING + DEGRADED MODE). Wrapped
|
|
5467
5469
|
// HERE at the robustApiCall layer so every Bot API call routed through the
|
|
5468
5470
|
// standard retry policy also transits one scheduler (no call site can bypass
|
|
5469
|
-
// it).
|
|
5470
|
-
// the gate
|
|
5471
|
-
// before. Composes with #3094's pre-call flood gate
|
|
5471
|
+
// it). ON BY DEFAULT (escape hatch): only when SWITCHROOM_TELEGRAM_SEND_GATE is
|
|
5472
|
+
// explicitly set to 0/false/off/no is the gate a pure passthrough and the retry
|
|
5473
|
+
// policy behaves exactly as before. Composes with #3094's pre-call flood gate
|
|
5474
|
+
// and #3097's non-essential
|
|
5472
5475
|
// drop policy — those decide whether a call happens at all; this paces the
|
|
5473
5476
|
// calls that do.
|
|
5474
5477
|
//
|
|
@@ -5527,6 +5530,65 @@ const swallowingApiCall = createSwallowingRetryApiCall(
|
|
|
5527
5530
|
(line) => process.stderr.write(line),
|
|
5528
5531
|
)
|
|
5529
5532
|
|
|
5533
|
+
/**
|
|
5534
|
+
* The ONE seam every `setMessageReaction` in this gateway goes through (#3155).
|
|
5535
|
+
*
|
|
5536
|
+
* Reactions are cosmetic outbound traffic. Before this seam, ~13 raw
|
|
5537
|
+
* `bot.api.setMessageReaction(...)` / `lockedBot.api.setMessageReaction(...)`
|
|
5538
|
+
* call sites fired reactions OUTSIDE the send gate AND the flood circuit
|
|
5539
|
+
* breaker, so:
|
|
5540
|
+
*
|
|
5541
|
+
* (a) reaction spam was never PACED/SHED by the gate when it's on — a busy
|
|
5542
|
+
* turn's status-reaction churn added straight onto the per-bot flood
|
|
5543
|
+
* budget the REPLIES need; and
|
|
5544
|
+
* (b) a 429 from a reaction was INVISIBLE to the breaker — the flood window
|
|
5545
|
+
* is only learned through the retry module's `onFloodWait` hook, which a
|
|
5546
|
+
* raw `bot.api` call never reaches. Reactions were a residual flood
|
|
5547
|
+
* vector even with the gate ON.
|
|
5548
|
+
*
|
|
5549
|
+
* Routing through `robustApiCall` (= chat-lock → send-gate → retry/breaker,
|
|
5550
|
+
* the SAME path every other outbound call takes) with
|
|
5551
|
+
* `priorityClass: 'cosmetic'` fixes both: the gate paces/sheds reactions under
|
|
5552
|
+
* pressure, and a 429 runs `onFloodWait` so the window is recorded. Callers
|
|
5553
|
+
* keep their own fire-and-forget / `.catch(() => {})` / `await` semantics —
|
|
5554
|
+
* this returns the promise and does NOT swallow.
|
|
5555
|
+
*/
|
|
5556
|
+
const gatedSetMessageReaction = (
|
|
5557
|
+
chatId: string,
|
|
5558
|
+
messageId: number,
|
|
5559
|
+
reaction: ReactionTypeEmoji[],
|
|
5560
|
+
): Promise<unknown> =>
|
|
5561
|
+
robustApiCall(() => lockedBot.api.setMessageReaction(chatId, messageId, reaction), {
|
|
5562
|
+
chat_id: chatId,
|
|
5563
|
+
verb: 'set-message-reaction',
|
|
5564
|
+
priorityClass: 'cosmetic',
|
|
5565
|
+
})
|
|
5566
|
+
|
|
5567
|
+
/** React with a single emoji through the gate (cosmetic). Wraps {@link gatedSetMessageReaction}. */
|
|
5568
|
+
const sendReaction = (
|
|
5569
|
+
chatId: string,
|
|
5570
|
+
messageId: number,
|
|
5571
|
+
emoji: ReactionTypeEmoji['emoji'],
|
|
5572
|
+
): Promise<unknown> => gatedSetMessageReaction(chatId, messageId, [{ type: 'emoji', emoji }])
|
|
5573
|
+
|
|
5574
|
+
/**
|
|
5575
|
+
* Bot-API surface handed to `redactAuthCodeMessage` (#488) so its 🔑 reaction
|
|
5576
|
+
* routes through the send gate + flood breaker like every other reaction
|
|
5577
|
+
* (#3155). The OAuth-code DELETE stays raw/best-effort exactly as before — its
|
|
5578
|
+
* own `.then(onOk, onErr)` logging (the "token may still be visible" breadcrumb)
|
|
5579
|
+
* must keep firing, which a swallowing wrapper would suppress.
|
|
5580
|
+
*/
|
|
5581
|
+
const redactAuthCodeApi = {
|
|
5582
|
+
deleteMessage: (chatId: string, messageId: number) =>
|
|
5583
|
+
// allow-raw-bot-api: auth-code redact delete stays raw/best-effort (behavior unchanged, #488); #3155 gates only the reaction.
|
|
5584
|
+
bot.api.deleteMessage(chatId, messageId),
|
|
5585
|
+
setMessageReaction: (
|
|
5586
|
+
chatId: string,
|
|
5587
|
+
messageId: number,
|
|
5588
|
+
reaction: Array<{ type: 'emoji'; emoji: string }>,
|
|
5589
|
+
) => gatedSetMessageReaction(chatId, messageId, reaction as ReactionTypeEmoji[]),
|
|
5590
|
+
}
|
|
5591
|
+
|
|
5530
5592
|
/**
|
|
5531
5593
|
* The wrapper for NON-ESSENTIAL sends that must NEVER retry (#3084).
|
|
5532
5594
|
*
|
|
@@ -11724,10 +11786,21 @@ async function executeSendChecklist(args: Record<string, unknown>): Promise<{ co
|
|
|
11724
11786
|
|
|
11725
11787
|
assertAllowedChat(chat_id)
|
|
11726
11788
|
|
|
11727
|
-
|
|
11728
|
-
|
|
11789
|
+
// #2044 outbound secret scrub. Checklist title + task strings are
|
|
11790
|
+
// agent-authored free text sent to Telegram, same class as the reply
|
|
11791
|
+
// `text` path and ask_user — route both through the SAME redactor before
|
|
11792
|
+
// send (via the pure, unit-tested redactChecklistFields helper) so an
|
|
11793
|
+
// echoed token / DATABASE_URL is masked.
|
|
11794
|
+
const { title: redactedTitle, tasks: redactedTasks } = redactChecklistFields(
|
|
11729
11795
|
title,
|
|
11730
11796
|
tasks,
|
|
11797
|
+
(t) => redactOutboundText(t, 'send_checklist'),
|
|
11798
|
+
)
|
|
11799
|
+
|
|
11800
|
+
const sent = await rawSendChecklist({
|
|
11801
|
+
chat_id,
|
|
11802
|
+
title: redactedTitle!,
|
|
11803
|
+
tasks: redactedTasks!,
|
|
11731
11804
|
...(threadId != null ? { message_thread_id: threadId } : {}),
|
|
11732
11805
|
...(replyTo != null ? { reply_to_message_id: replyTo } : {}),
|
|
11733
11806
|
...(protectContent ? { protect_content: true } : {}),
|
|
@@ -11809,7 +11882,19 @@ async function executeUpdateChecklist(args: Record<string, unknown>): Promise<{
|
|
|
11809
11882
|
|
|
11810
11883
|
assertAllowedChat(chat_id)
|
|
11811
11884
|
|
|
11812
|
-
|
|
11885
|
+
// #2044 outbound secret scrub. update_checklist forwards the same
|
|
11886
|
+
// agent-authored title + task text to Telegram as send_checklist, so it
|
|
11887
|
+
// shares the identical leak class — redact both through the reply-path
|
|
11888
|
+
// redactor (via the shared redactChecklistFields helper) before the edit
|
|
11889
|
+
// lands. title / tasks may be undefined here (partial patch); the helper
|
|
11890
|
+
// passes undefined through untouched.
|
|
11891
|
+
const { title: redactedTitle, tasks: redactedTasks } = redactChecklistFields(
|
|
11892
|
+
title,
|
|
11893
|
+
tasks,
|
|
11894
|
+
(t) => redactOutboundText(t, 'update_checklist'),
|
|
11895
|
+
)
|
|
11896
|
+
|
|
11897
|
+
await rawEditMessageChecklist({ chat_id, message_id, title: redactedTitle, tasks: redactedTasks })
|
|
11813
11898
|
|
|
11814
11899
|
process.stderr.write(`telegram gateway: update_checklist: updated chatId=${chat_id} messageId=${message_id}\n`)
|
|
11815
11900
|
return { content: [{ type: 'text', text: `checklist updated (id: ${message_id})` }] }
|
|
@@ -12537,8 +12622,18 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
12537
12622
|
.join('; ')
|
|
12538
12623
|
throw new Error(`inline_keyboard validation failed: ${summary}`)
|
|
12539
12624
|
}
|
|
12540
|
-
|
|
12541
|
-
|
|
12625
|
+
// #3148 fast-follow: mask any secret an agent put in a visible button
|
|
12626
|
+
// `text` label, its `ack_text` toast, or a `copy_text.text` clipboard
|
|
12627
|
+
// payload BEFORE the keyboard is sent — the same outbound scrub the reply
|
|
12628
|
+
// `text` body uses. `callback_data` (the routing key) is left exact.
|
|
12629
|
+
// Feeding BOTH the meta extraction and the callback wrap from the redacted
|
|
12630
|
+
// copy means the stashed toast, the tap echo (`button_text`), and the
|
|
12631
|
+
// "✅ You chose: <label>" annotation (#789) all read already-masked bytes.
|
|
12632
|
+
const redactedKeyboard = redactAgentKeyboard(rawKeyboard, (s) =>
|
|
12633
|
+
redactOutboundText(s, 'reply_inline_keyboard'),
|
|
12634
|
+
)
|
|
12635
|
+
replyButtonMeta = extractAgentButtonMeta(redactedKeyboard)
|
|
12636
|
+
replyMarkup = { inline_keyboard: wrapAgentCallbacks(redactedKeyboard) }
|
|
12542
12637
|
}
|
|
12543
12638
|
|
|
12544
12639
|
// on-demand voice: append a single '🔊 Listen' button that synthesizes the
|
|
@@ -13509,6 +13604,21 @@ async function executeAskUser(rawArgs: Record<string, unknown>): Promise<unknown
|
|
|
13509
13604
|
const args = validateAskUserArgs(rawArgs as unknown as AskUserArgs)
|
|
13510
13605
|
assertAllowedChat(args.chatId)
|
|
13511
13606
|
|
|
13607
|
+
// #2044 outbound secret scrub. The question text and each option/button
|
|
13608
|
+
// label are agent-authored free text sent to Telegram, exactly like the
|
|
13609
|
+
// reply `text` path — an agent that echoes a token or DATABASE_URL it just
|
|
13610
|
+
// read into a question or a button label would transmit it unmasked. Route
|
|
13611
|
+
// both through the SAME redactor as reply (via the pure, unit-tested
|
|
13612
|
+
// redactAskUserFields helper), assigning back onto the validated args so
|
|
13613
|
+
// every downstream consumer — the send, the timeout-edit re-render, and
|
|
13614
|
+
// the tap-echo of the chosen label stored in pendingAskUser — sees the
|
|
13615
|
+
// masked value.
|
|
13616
|
+
const scrubbed = redactAskUserFields(args.question, args.options, (t) =>
|
|
13617
|
+
redactOutboundText(t, 'ask_user'),
|
|
13618
|
+
)
|
|
13619
|
+
args.question = scrubbed.question
|
|
13620
|
+
args.options = scrubbed.options
|
|
13621
|
+
|
|
13512
13622
|
// Resolve thread + reply-to using the same auto-thread heuristic
|
|
13513
13623
|
// executeReply uses, so an agent that omits message_thread_id still
|
|
13514
13624
|
// routes into the right forum topic and quotes the user's last
|
|
@@ -14609,9 +14719,11 @@ async function executeReact(args: Record<string, unknown>): Promise<unknown> {
|
|
|
14609
14719
|
if (!args.message_id) throw new Error('react: message_id is required')
|
|
14610
14720
|
if (!args.emoji) throw new Error('react: emoji is required')
|
|
14611
14721
|
assertAllowedChat(String(args.chat_id ?? ''))
|
|
14612
|
-
await
|
|
14613
|
-
|
|
14614
|
-
|
|
14722
|
+
await sendReaction(
|
|
14723
|
+
String(args.chat_id ?? ''),
|
|
14724
|
+
Number(args.message_id),
|
|
14725
|
+
args.emoji as ReactionTypeEmoji['emoji'],
|
|
14726
|
+
)
|
|
14615
14727
|
return { content: [{ type: 'text', text: 'reacted' }] }
|
|
14616
14728
|
}
|
|
14617
14729
|
|
|
@@ -17590,6 +17702,17 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
17590
17702
|
|
|
17591
17703
|
// ─── PTY partial handler ─────────────────────────────────────────────────
|
|
17592
17704
|
function handlePtyPartial(text: string): void {
|
|
17705
|
+
// #2044 outbound secret scrub. The PTY-tail partial is the assistant's
|
|
17706
|
+
// reply text extracted from Claude Code's TUI as it renders — an agent
|
|
17707
|
+
// that echoes a secret shows it here (in the live draft-preview stream)
|
|
17708
|
+
// BEFORE the reply tool ever fires, and this path forwards the text
|
|
17709
|
+
// straight to Telegram with no other redaction. Mask at the gateway
|
|
17710
|
+
// boundary through the SAME reply-path redactor so the draft stream, its
|
|
17711
|
+
// dedup key, and lastPtyPreviewByChat all compare redacted-against-
|
|
17712
|
+
// redacted — the identical in-place-mutation invariant the answer stream
|
|
17713
|
+
// relies on. Idempotent: replaying a buffered (already-masked) partial
|
|
17714
|
+
// re-masks to itself.
|
|
17715
|
+
text = redactOutboundText(text, 'pty_preview')
|
|
17593
17716
|
// #1067: build the PtyHandlerState from a snapshot of currentTurn.
|
|
17594
17717
|
// The pty-partial-handler module keeps its own state-shape contract
|
|
17595
17718
|
// (currentSessionChatId / currentSessionThreadId) because it's
|
|
@@ -17624,6 +17747,13 @@ function handlePtyPartial(text: string): void {
|
|
|
17624
17747
|
}
|
|
17625
17748
|
|
|
17626
17749
|
function handlePtyActivity(text: string): void {
|
|
17750
|
+
// #2044 outbound secret scrub. PTY-activity is agent-derived free text
|
|
17751
|
+
// forwarded to Telegram via handleStreamReply — same leak class as the
|
|
17752
|
+
// draft preview above. Redact through the reply-path redactor before it
|
|
17753
|
+
// streams. (This handler is currently unwired in the gateway — the live
|
|
17754
|
+
// PTY path is handlePtyPartial — but the mask is kept here so the fix is
|
|
17755
|
+
// durable if the activity lane is ever re-armed.)
|
|
17756
|
+
text = redactOutboundText(text, 'pty_activity')
|
|
17627
17757
|
// #1067: snapshot at entry. handleStreamReply is async and runs in
|
|
17628
17758
|
// the background via void; the closure already captures `chatId` /
|
|
17629
17759
|
// `threadId` locals, so the supersession is correctly scoped.
|
|
@@ -17976,9 +18106,7 @@ function maybeEarlyAckReaction(ctx: Context, from: NonNullable<Context['from']>)
|
|
|
17976
18106
|
if (activeTurnStartedAt.has(statusKey(chatId, threadId))) return
|
|
17977
18107
|
const access = loadAccess()
|
|
17978
18108
|
if (!access.allowFrom.includes(String(from.id))) return
|
|
17979
|
-
void
|
|
17980
|
-
{ type: 'emoji', emoji: '👀' as ReactionTypeEmoji['emoji'] },
|
|
17981
|
-
]).catch(() => {})
|
|
18109
|
+
void sendReaction(chatId, msgId, '👀' as ReactionTypeEmoji['emoji']).catch(() => {})
|
|
17982
18110
|
// #2527: log the early-ack fire so operators can see how often the
|
|
17983
18111
|
// fast pre-coalesce DM path triggers vs. the controller path.
|
|
17984
18112
|
logStreamingEvent({ kind: 'early_ack_reaction', chatId, messageId: msgId, emoji: '👀' })
|
|
@@ -18219,9 +18347,7 @@ async function handleInbound(
|
|
|
18219
18347
|
)
|
|
18220
18348
|
if (inFlight) {
|
|
18221
18349
|
if (msgId != null) {
|
|
18222
|
-
void
|
|
18223
|
-
{ type: 'emoji', emoji: '⚡' as ReactionTypeEmoji['emoji'] },
|
|
18224
|
-
]).catch(() => {})
|
|
18350
|
+
void sendReaction(chat_id, msgId, '⚡' as ReactionTypeEmoji['emoji']).catch(() => {})
|
|
18225
18351
|
}
|
|
18226
18352
|
await executeHaltNow('stop-keyword')
|
|
18227
18353
|
}
|
|
@@ -18266,9 +18392,7 @@ async function handleInbound(
|
|
|
18266
18392
|
`in_flight=${toolFlightTracker.inFlightCount()}\n`,
|
|
18267
18393
|
)
|
|
18268
18394
|
if (msgId != null) {
|
|
18269
|
-
void
|
|
18270
|
-
{ type: 'emoji', emoji: '⚡' as ReactionTypeEmoji['emoji'] },
|
|
18271
|
-
]).catch(() => {})
|
|
18395
|
+
void sendReaction(chat_id, msgId, '⚡' as ReactionTypeEmoji['emoji']).catch(() => {})
|
|
18272
18396
|
}
|
|
18273
18397
|
if (interrupt.emptyBody) {
|
|
18274
18398
|
// #3020: empty `!` is a pure halt (no replacement body) — same shared
|
|
@@ -18402,9 +18526,7 @@ async function handleInbound(
|
|
|
18402
18526
|
})
|
|
18403
18527
|
if (msgId != null) {
|
|
18404
18528
|
const emoji = behavior === 'allow' ? '✅' : '❌'
|
|
18405
|
-
void
|
|
18406
|
-
{ type: 'emoji', emoji: emoji as ReactionTypeEmoji['emoji'] },
|
|
18407
|
-
]).catch(() => {})
|
|
18529
|
+
void sendReaction(chat_id, msgId, emoji as ReactionTypeEmoji['emoji']).catch(() => {})
|
|
18408
18530
|
}
|
|
18409
18531
|
return
|
|
18410
18532
|
}
|
|
@@ -18460,7 +18582,7 @@ async function handleInbound(
|
|
|
18460
18582
|
)
|
|
18461
18583
|
}
|
|
18462
18584
|
// Redact the OAuth code paste from chat history (#488).
|
|
18463
|
-
redactAuthCodeMessage(
|
|
18585
|
+
redactAuthCodeMessage(redactAuthCodeApi as never, chat_id, msgId ?? null, line => process.stderr.write(line))
|
|
18464
18586
|
return
|
|
18465
18587
|
}
|
|
18466
18588
|
// Stale — drop the pending entry but let the message fall through
|
|
@@ -18492,7 +18614,7 @@ async function handleInbound(
|
|
|
18492
18614
|
'_Still finishing the previous paste — one moment._',
|
|
18493
18615
|
{ html: true },
|
|
18494
18616
|
)
|
|
18495
|
-
redactAuthCodeMessage(
|
|
18617
|
+
redactAuthCodeMessage(redactAuthCodeApi as never, chat_id, msgId ?? null, line => process.stderr.write(line))
|
|
18496
18618
|
return
|
|
18497
18619
|
}
|
|
18498
18620
|
const result = await submitLoopbackRedirect(pendingLoop, text.trim())
|
|
@@ -18505,7 +18627,7 @@ async function handleInbound(
|
|
|
18505
18627
|
{ html: true },
|
|
18506
18628
|
)
|
|
18507
18629
|
// Redact the pasted redirect (carries the OAuth code) from history.
|
|
18508
|
-
redactAuthCodeMessage(
|
|
18630
|
+
redactAuthCodeMessage(redactAuthCodeApi as never, chat_id, msgId ?? null, line => process.stderr.write(line))
|
|
18509
18631
|
return
|
|
18510
18632
|
}
|
|
18511
18633
|
if (result.retryable) {
|
|
@@ -18518,7 +18640,7 @@ async function handleInbound(
|
|
|
18518
18640
|
{ html: true },
|
|
18519
18641
|
)
|
|
18520
18642
|
// Redact even a rejected paste — it may still carry a live code.
|
|
18521
|
-
redactAuthCodeMessage(
|
|
18643
|
+
redactAuthCodeMessage(redactAuthCodeApi as never, chat_id, msgId ?? null, line => process.stderr.write(line))
|
|
18522
18644
|
return
|
|
18523
18645
|
}
|
|
18524
18646
|
// Non-retryable — the flow is spent. Kill the CLI child before
|
|
@@ -18533,7 +18655,7 @@ async function handleInbound(
|
|
|
18533
18655
|
`**/auth ${pendingLoop.provider} add failed:** ${escapeHtmlForTg(result.reason)}`,
|
|
18534
18656
|
{ html: true },
|
|
18535
18657
|
)
|
|
18536
|
-
redactAuthCodeMessage(
|
|
18658
|
+
redactAuthCodeMessage(redactAuthCodeApi as never, chat_id, msgId ?? null, line => process.stderr.write(line))
|
|
18537
18659
|
return
|
|
18538
18660
|
}
|
|
18539
18661
|
// Stale — the intercept window has closed. Kill the child and drop the
|
|
@@ -18553,7 +18675,7 @@ async function handleInbound(
|
|
|
18553
18675
|
// reference AND a code/error param), so ordinary chatter mentioning
|
|
18554
18676
|
// localhost flows through untouched. Redact and drop rather than forward.
|
|
18555
18677
|
if (shouldConsumeLoopbackPaste(text)) {
|
|
18556
|
-
redactAuthCodeMessage(
|
|
18678
|
+
redactAuthCodeMessage(redactAuthCodeApi as never, chat_id, msgId ?? null, line => process.stderr.write(line))
|
|
18557
18679
|
await switchroomReply(
|
|
18558
18680
|
ctx,
|
|
18559
18681
|
'_That looked like an OAuth redirect/code, so I removed it from chat and did not forward it. ' +
|
|
@@ -18588,7 +18710,7 @@ async function handleInbound(
|
|
|
18588
18710
|
// Single-use code so a third party can't replay it after exchange,
|
|
18589
18711
|
// but plaintext OAuth tokens in chat history are still poor
|
|
18590
18712
|
// hygiene. The helper handles delete + 🔑 reaction silently.
|
|
18591
|
-
redactAuthCodeMessage(
|
|
18713
|
+
redactAuthCodeMessage(redactAuthCodeApi as never, chat_id, msgId ?? null, line => process.stderr.write(line))
|
|
18592
18714
|
return
|
|
18593
18715
|
}
|
|
18594
18716
|
pendingReauthFlows.delete(interceptKey)
|
|
@@ -19051,12 +19173,12 @@ async function handleInbound(
|
|
|
19051
19173
|
if (isSteering) {
|
|
19052
19174
|
// Explicit steer: mark with 🤝 on the inbound message; leave the
|
|
19053
19175
|
// existing StatusReactionController running for the in-flight turn.
|
|
19054
|
-
void
|
|
19176
|
+
void sendReaction(chat_id, msgId, '🤝').catch(() => {})
|
|
19055
19177
|
} else if (priorTurnInFlight) {
|
|
19056
19178
|
// Queued mid-turn message (new default): don't touch the existing
|
|
19057
19179
|
// controller; just ack the inbound message with 👀 so the user
|
|
19058
19180
|
// knows we received it, without disrupting the in-flight reaction.
|
|
19059
|
-
void
|
|
19181
|
+
void sendReaction(chat_id, msgId, '👀').catch(() => {})
|
|
19060
19182
|
// #203: time-to-ack metric — measure gateway-receive → ack-post delta.
|
|
19061
19183
|
logStreamingEvent({ kind: 'inbound_ack', chatId: chat_id, messageId: msgId, ackDelayMs: Date.now() - inboundReceivedAt })
|
|
19062
19184
|
} else {
|
|
@@ -19099,9 +19221,7 @@ async function handleInbound(
|
|
|
19099
19221
|
// msgId here and use it as the reaction-session token in log events.
|
|
19100
19222
|
const ctrlTurnToken = `${chat_id}:${msgId}`
|
|
19101
19223
|
const ctrl = new StatusReactionController(async (emoji) => {
|
|
19102
|
-
await
|
|
19103
|
-
{ type: 'emoji', emoji: emoji as ReactionTypeEmoji['emoji'] },
|
|
19104
|
-
])
|
|
19224
|
+
await sendReaction(chat_id, msgId, emoji as ReactionTypeEmoji['emoji'])
|
|
19105
19225
|
// #203: every status-reaction transition is a user-visible signal.
|
|
19106
19226
|
signalTracker.noteSignal(key, Date.now())
|
|
19107
19227
|
}, allowedReactions, {
|
|
@@ -19184,9 +19304,7 @@ async function handleInbound(
|
|
|
19184
19304
|
}
|
|
19185
19305
|
}
|
|
19186
19306
|
} else if (access.ackReaction) {
|
|
19187
|
-
void
|
|
19188
|
-
{ type: 'emoji', emoji: access.ackReaction as ReactionTypeEmoji['emoji'] },
|
|
19189
|
-
]).catch(() => {})
|
|
19307
|
+
void sendReaction(chat_id, msgId, access.ackReaction as ReactionTypeEmoji['emoji']).catch(() => {})
|
|
19190
19308
|
// #203: time-to-ack metric for the custom-ack-reaction path.
|
|
19191
19309
|
logStreamingEvent({ kind: 'inbound_ack', chatId: chat_id, messageId: msgId, ackDelayMs: Date.now() - inboundReceivedAt })
|
|
19192
19310
|
}
|
|
@@ -20529,7 +20647,7 @@ async function sweepBeforeSelfRestart(): Promise<void> {
|
|
|
20529
20647
|
try {
|
|
20530
20648
|
await sweepActiveReactions(
|
|
20531
20649
|
agentDir,
|
|
20532
|
-
(chatId, messageId) =>
|
|
20650
|
+
(chatId, messageId) => sendReaction(chatId, messageId, '👍' as ReactionTypeEmoji['emoji']),
|
|
20533
20651
|
{ log: (msg) => process.stderr.write(`telegram gateway: pre-restart reaction sweep — ${msg}\n`) },
|
|
20534
20652
|
)
|
|
20535
20653
|
} catch (err) {
|
|
@@ -24905,7 +25023,18 @@ bot.command('usage', async ctx => {
|
|
|
24905
25023
|
// /auth snapshot does. switchroomReply routes through the rich path
|
|
24906
25024
|
// (replyWithRichMessage), which accepts reply_markup. Build a grammy
|
|
24907
25025
|
// InlineKeyboard so the markup type matches switchroomReply's contract.
|
|
24908
|
-
|
|
25026
|
+
// Defense-in-depth (mirrors the operator-private `/auth` treatment,
|
|
25027
|
+
// gateway.ts ~24036): outside a private chat, strip the
|
|
25028
|
+
// `auth:use:<label>` "Switch fleet" rows so the fleet-wide account-
|
|
25029
|
+
// swap button is never even offered in a group/forum. The dispatch-
|
|
25030
|
+
// site allowFrom gate is the load-bearing control; this just avoids
|
|
25031
|
+
// dangling a privileged button in front of non-operators.
|
|
25032
|
+
let kbRows = buildSnapshotKeyboard(snapshots, { now: new Date(), demo })
|
|
25033
|
+
if (ctx.chat?.type !== 'private') {
|
|
25034
|
+
kbRows = kbRows.filter(
|
|
25035
|
+
(row) => !row.some((b) => b.callbackData?.startsWith('auth:use:')),
|
|
25036
|
+
)
|
|
25037
|
+
}
|
|
24909
25038
|
const keyboard = new InlineKeyboard()
|
|
24910
25039
|
kbRows.forEach((row, ri) => {
|
|
24911
25040
|
if (ri > 0) keyboard.row()
|
|
@@ -25033,7 +25162,19 @@ bot.on('callback_query:data', async ctx => {
|
|
|
25033
25162
|
// Auth dashboard buttons (`auth:<verb>:<agent>[:<slot>]`). Route
|
|
25034
25163
|
// through a dedicated handler that maps each action onto the
|
|
25035
25164
|
// existing CLI invocations plus dashboard refresh.
|
|
25165
|
+
//
|
|
25166
|
+
// Strict allowFrom gate like every other mutating callback family
|
|
25167
|
+
// (`eff:`/`apv:`/`cfg:`/`cn:`/`mdl:`). Its absence was a vulnerability:
|
|
25168
|
+
// `auth:use:<label>` drives `client.setActive(label)`, a fleet-wide
|
|
25169
|
+
// OAuth account swap — on an admin forum/supergroup agent with an empty
|
|
25170
|
+
// group allowFrom, any member could tap it and swap the active account.
|
|
25036
25171
|
if (data.startsWith('auth:')) {
|
|
25172
|
+
const access = loadAccess()
|
|
25173
|
+
const senderId = String(ctx.from?.id ?? '')
|
|
25174
|
+
if (!access.allowFrom.includes(senderId)) {
|
|
25175
|
+
await ctx.answerCallbackQuery({ text: 'Not authorized.' })
|
|
25176
|
+
return
|
|
25177
|
+
}
|
|
25037
25178
|
await handleAuthDashboardCallback(ctx)
|
|
25038
25179
|
return
|
|
25039
25180
|
}
|
|
@@ -26735,9 +26876,7 @@ async function handleAckOnly(
|
|
|
26735
26876
|
const chat_id = String(ctx.chat!.id)
|
|
26736
26877
|
const msgId = ctx.message?.message_id
|
|
26737
26878
|
if (msgId != null) {
|
|
26738
|
-
void
|
|
26739
|
-
{ type: 'emoji', emoji: (opts.emoji ?? '👀') as ReactionTypeEmoji['emoji'] },
|
|
26740
|
-
]).catch(() => {})
|
|
26879
|
+
void sendReaction(chat_id, msgId, (opts.emoji ?? '👀') as ReactionTypeEmoji['emoji']).catch(() => {})
|
|
26741
26880
|
}
|
|
26742
26881
|
const prefix = opts.warn ? 'WARN ' : ''
|
|
26743
26882
|
process.stderr.write(`telegram gateway: ${prefix}inbound ${kind} ack-only chat_id=${chat_id} from=${ctx.from?.id ?? '?'}\n`)
|
|
@@ -26770,9 +26909,7 @@ async function handleRefusal(
|
|
|
26770
26909
|
const msgId = ctx.message?.message_id
|
|
26771
26910
|
const messageThreadId = ctx.message?.message_thread_id
|
|
26772
26911
|
if (msgId != null) {
|
|
26773
|
-
void
|
|
26774
|
-
{ type: 'emoji', emoji: '🚫' as ReactionTypeEmoji['emoji'] },
|
|
26775
|
-
]).catch(() => {})
|
|
26912
|
+
void sendReaction(chat_id, msgId, '🚫' as ReactionTypeEmoji['emoji']).catch(() => {})
|
|
26776
26913
|
}
|
|
26777
26914
|
// #1075: thread-id-bearing — swallow on THREAD_NOT_FOUND so a
|
|
26778
26915
|
// deleted topic doesn't crash the refusal handler.
|
|
@@ -27831,7 +27968,7 @@ process.on('SIGINT', () => void shutdown('SIGINT'))
|
|
|
27831
27968
|
if (startupAgentDir != null) {
|
|
27832
27969
|
void sweepActiveReactions(
|
|
27833
27970
|
startupAgentDir,
|
|
27834
|
-
(chatId, messageId) =>
|
|
27971
|
+
(chatId, messageId) => sendReaction(chatId, messageId, '👍' as ReactionTypeEmoji['emoji']),
|
|
27835
27972
|
{ log: (msg) => process.stderr.write(`telegram gateway: startup reaction sweep — ${msg}\n`) },
|
|
27836
27973
|
)
|
|
27837
27974
|
}
|
|
@@ -28740,30 +28877,41 @@ void (async () => {
|
|
|
28740
28877
|
// Best-effort: a registry read failure (e.g. no worktree dir
|
|
28741
28878
|
// on an agent that never claims one) must not affect the
|
|
28742
28879
|
// primary agentCwd watch.
|
|
28743
|
-
|
|
28744
|
-
|
|
28745
|
-
|
|
28746
|
-
|
|
28747
|
-
|
|
28748
|
-
|
|
28749
|
-
|
|
28750
|
-
|
|
28751
|
-
|
|
28752
|
-
|
|
28753
|
-
|
|
28754
|
-
|
|
28755
|
-
|
|
28756
|
-
|
|
28757
|
-
|
|
28758
|
-
|
|
28759
|
-
|
|
28760
|
-
|
|
28761
|
-
|
|
28762
|
-
|
|
28763
|
-
|
|
28764
|
-
|
|
28765
|
-
|
|
28766
|
-
|
|
28880
|
+
// On every ~1s rescan tick the provider does BOTH:
|
|
28881
|
+
// 1. advances the heartbeat of every worktree THIS agent owns
|
|
28882
|
+
// — the F1/H3 production driver that keeps `touchHeartbeat`
|
|
28883
|
+
// alive (it had ZERO callers, so every claim read "stale"
|
|
28884
|
+
// 10 min after creation and the reaper's staleness
|
|
28885
|
+
// guarantee collapsed); throttled to ≤1 write / 2 min per
|
|
28886
|
+
// record, and
|
|
28887
|
+
// 2. returns the fail-CLOSED set of owned worktree cwds for the
|
|
28888
|
+
// watcher to also watch (#1116 / Gap-2 ownership predicate).
|
|
28889
|
+
// Extracted to telegram-plugin/worktree-watch-cwds.ts as
|
|
28890
|
+
// `makeWorktreeWatchProvider` so the wiring — specifically that
|
|
28891
|
+
// the provider ACTUALLY drives heartbeats, not just cwds — is
|
|
28892
|
+
// under direct unit test (see the provider behaviour test in
|
|
28893
|
+
// telegram-plugin/tests/worktree-watch-cwds.test.ts). Fully
|
|
28894
|
+
// best-effort: it never throws out of the provider.
|
|
28895
|
+
extraWatchCwdsProvider: makeWorktreeWatchProvider({
|
|
28896
|
+
self: process.env.SWITCHROOM_AGENT_NAME,
|
|
28897
|
+
// Durable, non-env identity fallback (#1116 / #2893): when
|
|
28898
|
+
// SWITCHROOM_AGENT_NAME is somehow unset, derive this agent's
|
|
28899
|
+
// own identity from its own directory so worktree ownership
|
|
28900
|
+
// still resolves (env is only the fast path). `watcherAgentDir`
|
|
28901
|
+
// is guaranteed non-null in this branch (the whole watcher is
|
|
28902
|
+
// gated on it above). Kill-switch
|
|
28903
|
+
// SWITCHROOM_WORKTREE_IDENTITY_FALLBACK=0 restores the pre-fix
|
|
28904
|
+
// env-only behaviour. One `agentDir` governs BOTH the heartbeat
|
|
28905
|
+
// refresh and the cwd derivation.
|
|
28906
|
+
agentDir:
|
|
28907
|
+
process.env.SWITCHROOM_WORKTREE_IDENTITY_FALLBACK === '0'
|
|
28908
|
+
? undefined
|
|
28909
|
+
: watcherAgentDir,
|
|
28910
|
+
listRecords: listWorktreeRecords,
|
|
28911
|
+
touchHeartbeat: touchWorktreeHeartbeat,
|
|
28912
|
+
log: (msg) =>
|
|
28913
|
+
process.stderr.write(`telegram gateway: ${msg}\n`),
|
|
28914
|
+
}),
|
|
28767
28915
|
// Bug 0 fix: previously omitted, leaving the watcher unable to
|
|
28768
28916
|
// write liveness/stall/turn_end updates to the registry DB.
|
|
28769
28917
|
// Liveness writes are now persisted across the gateway lifetime.
|
|
@@ -178,6 +178,57 @@ export function initHistory(stateDir: string, retentionDays = 30): void {
|
|
|
178
178
|
}
|
|
179
179
|
}
|
|
180
180
|
|
|
181
|
+
// Migration (review finding H5): make INSERT OR REPLACE idempotent for thread-less rows.
|
|
182
|
+
//
|
|
183
|
+
// The table's PRIMARY KEY is (chat_id, thread_id, message_id), but thread_id
|
|
184
|
+
// is nullable and SQLite treats NULL as DISTINCT from NULL in a PK/UNIQUE
|
|
185
|
+
// index. Every DM and every non-topic group message has thread_id = NULL, so
|
|
186
|
+
// two inserts of the same (chat_id, message_id) with NULL thread do NOT
|
|
187
|
+
// conflict — `INSERT OR REPLACE` APPENDS a duplicate row instead of replacing
|
|
188
|
+
// it. On the documented at-least-once boot replay / synthesized-resume
|
|
189
|
+
// re-record, an already-stored message is duplicated, inflating
|
|
190
|
+
// get_recent_messages and the getRecentOutboundCount / hasOutboundDeliveredSince
|
|
191
|
+
// counters that feed the silence / over-ping detectors.
|
|
192
|
+
//
|
|
193
|
+
// Fix: a UNIQUE index over COALESCE(thread_id, '') gives every logical
|
|
194
|
+
// (chat, thread-or-general, message_id) key a NON-null uniqueness value, so
|
|
195
|
+
// REPLACE conflict-resolves and dedupes thread-less rows too. INSERT OR
|
|
196
|
+
// REPLACE resolves against ANY unique index, so no writer change is needed.
|
|
197
|
+
// A forum-topic row (non-null thread) and a general row (NULL thread) that
|
|
198
|
+
// share a message_id keep DISTINCT keys (the topic id vs ''), so they stay
|
|
199
|
+
// separate. Stored thread_id values remain real NULLs, so every read path
|
|
200
|
+
// (`thread_id IS NULL` / `thread_id = ?`) is unchanged.
|
|
201
|
+
const LOGICAL_KEY_INDEX = 'idx_messages_logical_key'
|
|
202
|
+
const logicalKeyIndexExists =
|
|
203
|
+
db
|
|
204
|
+
.prepare(`SELECT 1 FROM sqlite_master WHERE type = 'index' AND name = ?`)
|
|
205
|
+
.get(LOGICAL_KEY_INDEX) != null
|
|
206
|
+
if (!logicalKeyIndexExists) {
|
|
207
|
+
// De-dupe rows an earlier (pre-fix) build already appended, keeping the
|
|
208
|
+
// NEWEST row per logical key (highest ts, then highest rowid = the last
|
|
209
|
+
// write, which is what INSERT OR REPLACE would have left). This MUST run
|
|
210
|
+
// before the UNIQUE index is created, or CREATE UNIQUE INDEX would fail on
|
|
211
|
+
// the existing duplicates. On a fresh/empty DB it is a harmless no-op.
|
|
212
|
+
db.exec(`
|
|
213
|
+
DELETE FROM messages
|
|
214
|
+
WHERE rowid NOT IN (
|
|
215
|
+
SELECT keep_rowid FROM (
|
|
216
|
+
SELECT rowid AS keep_rowid,
|
|
217
|
+
ROW_NUMBER() OVER (
|
|
218
|
+
PARTITION BY chat_id, COALESCE(thread_id, ''), message_id
|
|
219
|
+
ORDER BY ts DESC, rowid DESC
|
|
220
|
+
) AS rn
|
|
221
|
+
FROM messages
|
|
222
|
+
)
|
|
223
|
+
WHERE rn = 1
|
|
224
|
+
)
|
|
225
|
+
`)
|
|
226
|
+
db.exec(
|
|
227
|
+
`CREATE UNIQUE INDEX IF NOT EXISTS ${LOGICAL_KEY_INDEX} ` +
|
|
228
|
+
`ON messages (chat_id, COALESCE(thread_id, ''), message_id)`,
|
|
229
|
+
)
|
|
230
|
+
}
|
|
231
|
+
|
|
181
232
|
// Readable by owner and others so the web dashboard (different uid than the
|
|
182
233
|
// agent) can stream replies back to Hermes Desktop. The WAL sidecar files
|
|
183
234
|
// (-shm/-wal) are also chmod'd so SQLite readonly opens succeed for uid=1000.
|