switchroom 0.13.33 → 0.13.36
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/bin/timezone-hook.sh +1 -1
- package/dist/agent-scheduler/index.js +8 -1
- package/dist/auth-broker/index.js +8 -1
- package/dist/cli/switchroom.js +176 -26
- package/dist/host-control/main.js +5222 -203
- package/dist/vault/approvals/kernel-server.js +9 -2
- package/dist/vault/broker/server.js +9 -2
- package/package.json +1 -1
- package/profiles/default/CLAUDE.md.hbs +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +234 -31
- package/telegram-plugin/docs/waiting-ux-spec.md +40 -0
- package/telegram-plugin/gateway/config-approval-handler.test.ts +188 -1
- package/telegram-plugin/gateway/config-approval-handler.ts +170 -15
- package/telegram-plugin/gateway/diff-preview-card.test.ts +2 -2
- package/telegram-plugin/gateway/diff-preview-card.ts +2 -2
- package/telegram-plugin/gateway/drive-write-approval.test.ts +70 -0
- package/telegram-plugin/gateway/drive-write-approval.ts +51 -2
- package/telegram-plugin/gateway/error-envelope-card.ts +64 -0
- package/telegram-plugin/gateway/gateway.ts +112 -15
- package/telegram-plugin/gateway/ipc-protocol.ts +10 -1
- package/telegram-plugin/gateway/oversize-card-body.test.ts +108 -0
- package/telegram-plugin/gateway/oversize-card-body.ts +114 -0
- package/telegram-plugin/gateway/unhandled-rejection-policy.ts +46 -1
- package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +118 -41
- package/telegram-plugin/hooks/silent-end-scan.mjs +190 -0
- package/telegram-plugin/pending-work-progress.ts +37 -1
- package/telegram-plugin/tests/boot-clears-clean-shutdown-marker.test.ts +75 -0
- package/telegram-plugin/tests/error-envelope-unlock-card.test.ts +79 -0
- package/telegram-plugin/tests/pending-work-progress.test.ts +134 -0
- package/telegram-plugin/tests/silent-end-integration.test.ts +268 -0
- package/telegram-plugin/tests/silent-end-interrupt-stop-integration.test.ts +242 -0
- package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +314 -0
- package/telegram-plugin/tests/silent-end.test.ts +227 -38
- package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +51 -6
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Render a one-tap unlock card for hostd error_envelopes that carry a
|
|
3
|
+
* `flip_yaml_flag` fix (#1758 Phase 1).
|
|
4
|
+
*
|
|
5
|
+
* CRITICAL safety: the `yaml_path` MUST be on the
|
|
6
|
+
* `UNLOCK_CARD_YAML_ALLOWLIST` exported from
|
|
7
|
+
* `src/host-control/config-edit-validator.ts`. A malformed or hostile
|
|
8
|
+
* envelope from any backend could otherwise nudge the operator into
|
|
9
|
+
* one-tap-approving an arbitrary flag flip. Non-allowlisted paths fall
|
|
10
|
+
* back to plain-text rendering (the caller surfaces `resp.error` as
|
|
11
|
+
* today).
|
|
12
|
+
*
|
|
13
|
+
* Phase 1 scope: ONLY `flip_yaml_flag`. `request_vault_grant` is
|
|
14
|
+
* explicitly deferred to a later phase (still plain-text rendered).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { HostdResponse } from "../../src/host-control/protocol.js";
|
|
18
|
+
import { isAllowlistedYamlPath } from "../../src/host-control/config-edit-validator.js";
|
|
19
|
+
import {
|
|
20
|
+
buildApprovalCard,
|
|
21
|
+
type BuiltApprovalCard,
|
|
22
|
+
} from "./approval-card.js";
|
|
23
|
+
|
|
24
|
+
export type UnlockCardOutcome =
|
|
25
|
+
| { kind: "card"; card: BuiltApprovalCard; yaml_path: string; to: unknown }
|
|
26
|
+
| { kind: "plain-text" };
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Decide whether to render a one-tap unlock card for the given
|
|
30
|
+
* response. Returns `{kind: "plain-text"}` whenever the envelope
|
|
31
|
+
* lacks a `flip_yaml_flag` fix OR the path isn't on the allowlist.
|
|
32
|
+
*
|
|
33
|
+
* `approvalRequestId` is the 32-hex nonce minted by the approval
|
|
34
|
+
* kernel; caller is responsible for binding the card to that nonce
|
|
35
|
+
* and recording the apply-on-tap intent.
|
|
36
|
+
*/
|
|
37
|
+
export function renderErrorEnvelopeCard(
|
|
38
|
+
resp: HostdResponse,
|
|
39
|
+
agentName: string,
|
|
40
|
+
approvalRequestId: string,
|
|
41
|
+
): UnlockCardOutcome {
|
|
42
|
+
const env = resp.error_envelope;
|
|
43
|
+
if (!env || !env.fix) return { kind: "plain-text" };
|
|
44
|
+
if (env.fix.kind !== "flip_yaml_flag") {
|
|
45
|
+
// request_vault_grant is Phase-2 work; everything else has no
|
|
46
|
+
// unlock-card UX. Caller falls back to plain-text rendering.
|
|
47
|
+
return { kind: "plain-text" };
|
|
48
|
+
}
|
|
49
|
+
const { yaml_path, to } = env.fix;
|
|
50
|
+
if (!isAllowlistedYamlPath(yaml_path)) {
|
|
51
|
+
// Defense-in-depth: never render a one-tap card for a path the
|
|
52
|
+
// operator hasn't explicitly opted into.
|
|
53
|
+
return { kind: "plain-text" };
|
|
54
|
+
}
|
|
55
|
+
const card = buildApprovalCard({
|
|
56
|
+
request_id: approvalRequestId,
|
|
57
|
+
agent: agentName,
|
|
58
|
+
scope_humanized: `flip ${yaml_path} → ${JSON.stringify(to)}`,
|
|
59
|
+
why: env.human + (env.why ? ` — ${env.why}` : ""),
|
|
60
|
+
offer_always: false,
|
|
61
|
+
offer_ttl: false,
|
|
62
|
+
});
|
|
63
|
+
return { kind: "card", card, yaml_path, to };
|
|
64
|
+
}
|
|
@@ -319,12 +319,16 @@ import {
|
|
|
319
319
|
import {
|
|
320
320
|
writeCleanShutdownMarker,
|
|
321
321
|
readCleanShutdownMarker,
|
|
322
|
-
//
|
|
323
|
-
// the marker is
|
|
324
|
-
//
|
|
325
|
-
//
|
|
326
|
-
//
|
|
327
|
-
//
|
|
322
|
+
// 2026-05-25 — clearCleanShutdownMarker IS imported and called on every
|
|
323
|
+
// boot after the marker is read. The earlier "intentionally NOT imported"
|
|
324
|
+
// comment was wrong: it assumed every shutdown writes a fresh marker, but
|
|
325
|
+
// unhandledRejection / uncaughtException paths explicitly SKIP the write
|
|
326
|
+
// (gateway.ts:15107 — "crash path"). A marker from a prior graceful
|
|
327
|
+
// shutdown then sits on disk for hours and triggers a misleading stale-
|
|
328
|
+
// marker crash banner on the next boot after an unhandled rejection.
|
|
329
|
+
// Clearing on boot collapses the marker to "describes the immediately
|
|
330
|
+
// preceding shutdown only" semantics.
|
|
331
|
+
clearCleanShutdownMarker,
|
|
328
332
|
shouldSuppressRecoveryBanner,
|
|
329
333
|
resolveShutdownMarker,
|
|
330
334
|
DEFAULT_MAX_AGE_MS as CLEAN_SHUTDOWN_MAX_AGE_MS,
|
|
@@ -3434,6 +3438,14 @@ pendingProgress.startTimer({
|
|
|
3434
3438
|
)
|
|
3435
3439
|
},
|
|
3436
3440
|
emitMetric: (event) => emitRuntimeMetric(event),
|
|
3441
|
+
// #1760 defense-in-depth: if a newer turn for this chat is active at
|
|
3442
|
+
// tick time, the prior turn's pending-progress is stale (the
|
|
3443
|
+
// canonical teardown was missed). Drop the ticker instead of editing
|
|
3444
|
+
// the old anchor — see pending-work-progress.ts's docblock.
|
|
3445
|
+
isActiveTurnNewerThan: (key, activatedAt) => {
|
|
3446
|
+
const turnStartedAt = activeTurnStartedAt.get(key)
|
|
3447
|
+
return turnStartedAt != null && turnStartedAt > activatedAt
|
|
3448
|
+
},
|
|
3437
3449
|
})
|
|
3438
3450
|
|
|
3439
3451
|
// Per-agent buffer for synthetic inbounds the gateway couldn't deliver
|
|
@@ -4109,6 +4121,24 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
4109
4121
|
)
|
|
4110
4122
|
}
|
|
4111
4123
|
},
|
|
4124
|
+
// #1762: send the full diff as a `.patch` attachment when the
|
|
4125
|
+
// card body would exceed Telegram's 4096-char sendMessage limit.
|
|
4126
|
+
postAttachment: async (args) => {
|
|
4127
|
+
const input = new InputFile(Buffer.from(args.content, 'utf8'), args.filename)
|
|
4128
|
+
await robustApiCall(
|
|
4129
|
+
() =>
|
|
4130
|
+
bot.api.sendDocument(args.chatId, input, {
|
|
4131
|
+
...(args.threadId !== undefined
|
|
4132
|
+
? { message_thread_id: args.threadId }
|
|
4133
|
+
: {}),
|
|
4134
|
+
}),
|
|
4135
|
+
{
|
|
4136
|
+
chat_id: String(args.chatId),
|
|
4137
|
+
verb: 'config-approval-attachment',
|
|
4138
|
+
...(args.threadId !== undefined ? { threadId: args.threadId } : {}),
|
|
4139
|
+
},
|
|
4140
|
+
)
|
|
4141
|
+
},
|
|
4112
4142
|
log: (m) =>
|
|
4113
4143
|
process.stderr.write(`telegram gateway: config-approval — ${m}\n`),
|
|
4114
4144
|
})
|
|
@@ -4594,12 +4624,21 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
4594
4624
|
// #1122 KPI: a `reply` always produces a fresh user-visible outbound
|
|
4595
4625
|
// message — count it for the outbound-gap / TTFO KPI AND reset the
|
|
4596
4626
|
// silence-poke clock so the next poke is measured from this send.
|
|
4597
|
-
// Also clear any silent-end state file so the Stop hook doesn't fire
|
|
4598
|
-
// a stale block when the session ends (deterministic restore of the
|
|
4599
|
-
// detection PR3 inadvertently removed).
|
|
4600
4627
|
signalTracker.noteOutbound(statusKey(chat_id, threadId), Date.now())
|
|
4601
4628
|
silencePoke.noteOutbound(statusKey(chat_id, threadId), Date.now())
|
|
4602
|
-
|
|
4629
|
+
// #1741 — only clear silent-end state on a plausibly-final reply.
|
|
4630
|
+
// An interim ack (disable_notification:true, short text, no done)
|
|
4631
|
+
// must NOT clear the state file; otherwise a turn that ends with
|
|
4632
|
+
// ack-only + answer-as-transcript leaves no state for the Stop
|
|
4633
|
+
// hook to act on if `turn_end` never lands (the `turn_duration`
|
|
4634
|
+
// system event is unreliable for trivial-prompt turns — see the
|
|
4635
|
+
// executeReply finalize comments). Final-answer replies still
|
|
4636
|
+
// clear; the main turn-end path also re-writes the state when
|
|
4637
|
+
// finalAnswerDelivered=false, so this is a belt-and-braces gate
|
|
4638
|
+
// for the turn_end-missing case (#1741).
|
|
4639
|
+
if (isFinalAnswerReply({ text: rawText, disableNotification })) {
|
|
4640
|
+
clearSilentEndState(statusKey(chat_id, threadId))
|
|
4641
|
+
}
|
|
4603
4642
|
|
|
4604
4643
|
if (previewMessageId != null && reply_to != null && replyMode !== 'off') {
|
|
4605
4644
|
await deleteStalePreview(previewMessageId)
|
|
@@ -4680,6 +4719,10 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
4680
4719
|
// - #1664 silent-end re-prompt fires even when the
|
|
4681
4720
|
// accumulated silent content qualifies as substantive;
|
|
4682
4721
|
// - retries within the dedup window may double-send.
|
|
4722
|
+
// #1760 primary fix — clear any stale prior-turn ticker
|
|
4723
|
+
// before re-anchoring on this silent-reply edit. See the
|
|
4724
|
+
// matching comment at the executeReply finalize site below.
|
|
4725
|
+
pendingProgress.clearPending(statusKey(chat_id, threadId), 'reply_finalize')
|
|
4683
4726
|
pendingProgress.noteOutbound(statusKey(chat_id, threadId), {
|
|
4684
4727
|
messageId: decision.messageId,
|
|
4685
4728
|
text: decision.mergedText,
|
|
@@ -4873,6 +4916,14 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
4873
4916
|
if (sentIds.length === chunks.length && chunks.length > 0) {
|
|
4874
4917
|
const anchorMsgId = sentIds[chunks.length - 1]
|
|
4875
4918
|
if (typeof anchorMsgId === 'number') {
|
|
4919
|
+
// #1760 primary fix — clear any stale prior-turn ticker BEFORE
|
|
4920
|
+
// re-anchoring. The canonical teardown wires (turn_end,
|
|
4921
|
+
// subagent_handback, inbound) can be missed (e.g. SDK turn_end
|
|
4922
|
+
// event dropped, as in the #1760 live evidence). Tearing down on
|
|
4923
|
+
// every reply-finalize is idempotent and resilient: it's a no-op
|
|
4924
|
+
// when nothing is active, and drops a stale ambient before the
|
|
4925
|
+
// new turn captures its anchor.
|
|
4926
|
+
pendingProgress.clearPending(statusKey(chat_id, threadId), 'reply_finalize')
|
|
4876
4927
|
pendingProgress.noteOutbound(statusKey(chat_id, threadId), {
|
|
4877
4928
|
messageId: anchorMsgId,
|
|
4878
4929
|
text: chunks[chunks.length - 1],
|
|
@@ -5170,7 +5221,19 @@ async function executeStreamReply(args: Record<string, unknown>): Promise<unknow
|
|
|
5170
5221
|
const sKey = statusKey(streamChatId, streamThreadId)
|
|
5171
5222
|
signalTracker.noteOutbound(sKey, Date.now())
|
|
5172
5223
|
silencePoke.noteOutbound(sKey, Date.now())
|
|
5173
|
-
|
|
5224
|
+
// #1741 — see executeReply for the rationale: only a plausibly-
|
|
5225
|
+
// final stream_reply clears the silent-end state. An interim
|
|
5226
|
+
// ack via stream_reply must NOT clear; the Stop hook needs
|
|
5227
|
+
// the state to persist if turn_end fails to land.
|
|
5228
|
+
if (
|
|
5229
|
+
isFinalAnswerReply({
|
|
5230
|
+
text: (args.text as string | undefined) ?? '',
|
|
5231
|
+
disableNotification: args.disable_notification === true,
|
|
5232
|
+
done: args.done === true,
|
|
5233
|
+
})
|
|
5234
|
+
) {
|
|
5235
|
+
clearSilentEndState(sKey)
|
|
5236
|
+
}
|
|
5174
5237
|
}
|
|
5175
5238
|
}
|
|
5176
5239
|
|
|
@@ -5298,6 +5361,10 @@ async function executeStreamReply(args: Record<string, unknown>): Promise<unknow
|
|
|
5298
5361
|
streamFormat === 'html' ? 'HTML'
|
|
5299
5362
|
: streamFormat === 'markdownv2' ? 'MarkdownV2'
|
|
5300
5363
|
: undefined
|
|
5364
|
+
// #1760 primary fix — clear any stale prior-turn ticker before
|
|
5365
|
+
// re-anchoring on stream_reply done. See the matching comment at
|
|
5366
|
+
// the executeReply finalize site.
|
|
5367
|
+
pendingProgress.clearPending(statusKey(sChatId, sThreadId), 'reply_finalize')
|
|
5301
5368
|
pendingProgress.noteOutbound(statusKey(sChatId, sThreadId), {
|
|
5302
5369
|
messageId: result.messageId,
|
|
5303
5370
|
text: args.text as string,
|
|
@@ -5320,6 +5387,20 @@ async function executeStreamReply(args: Record<string, unknown>): Promise<unknow
|
|
|
5320
5387
|
})
|
|
5321
5388
|
) {
|
|
5322
5389
|
turn.finalAnswerDelivered = true
|
|
5390
|
+
// #1744 follow-up — stream_reply edge case. The first-emit gate at
|
|
5391
|
+
// L5178 only clears silent-end state on the FIRST emit of a stream.
|
|
5392
|
+
// If a stream's first emit was ack-shaped (disable_notification:true,
|
|
5393
|
+
// short text, no done) it correctly did NOT clear the state. But a
|
|
5394
|
+
// LATER emit in the same stream may flip `done=true` or carry
|
|
5395
|
+
// substantive text — that's the real final answer landing, and the
|
|
5396
|
+
// state file must be cleared here too. clearSilentEndState is
|
|
5397
|
+
// idempotent (no-op when the file is absent or the turnKey doesn't
|
|
5398
|
+
// match), so calling it unconditionally on every final-answer-shaped
|
|
5399
|
+
// emit is safe even if the first-emit path already cleared.
|
|
5400
|
+
const streamThreadIdForClear = args.message_thread_id != null
|
|
5401
|
+
? Number(args.message_thread_id)
|
|
5402
|
+
: undefined
|
|
5403
|
+
clearSilentEndState(statusKey(streamChatId, streamThreadIdForClear))
|
|
5323
5404
|
}
|
|
5324
5405
|
// v0.13.30 follow-up — release the buffer gate on every successful
|
|
5325
5406
|
// stream_reply too. Same rationale as executeReply: short replies
|
|
@@ -15618,10 +15699,26 @@ void (async () => {
|
|
|
15618
15699
|
} else {
|
|
15619
15700
|
process.stderr.write(`telegram gateway: boot.clean_shutdown_marker_stale age=${ageSec}s signal=${cleanMarker.signal}${reasonTag}\n`)
|
|
15620
15701
|
}
|
|
15621
|
-
//
|
|
15622
|
-
//
|
|
15623
|
-
//
|
|
15624
|
-
//
|
|
15702
|
+
// 2026-05-25 — clear the marker after this boot has read it.
|
|
15703
|
+
// Pre-fix the comment here claimed the file was "self-
|
|
15704
|
+
// overwriting, age-gated, harmless to leave on disk" — that's
|
|
15705
|
+
// true ONLY for the cycle where every shutdown writes a fresh
|
|
15706
|
+
// marker. The unhandledRejection / uncaughtException paths
|
|
15707
|
+
// explicitly SKIP writing (gateway.ts:15107 — the "crash path")
|
|
15708
|
+
// so a marker from an earlier graceful shutdown sits on disk
|
|
15709
|
+
// for hours, then on the next boot looks stale-by-age and
|
|
15710
|
+
// fires a misleading agent-crashed banner with detail
|
|
15711
|
+
// `clean-shutdown marker stale age=39976s` (clerk 2026-05-25
|
|
15712
|
+
// 01:11). Clearing now means the marker only ever describes
|
|
15713
|
+
// the IMMEDIATELY PRECEDING shutdown, not "some shutdown in
|
|
15714
|
+
// the past". After this clear: a subsequent crash with no
|
|
15715
|
+
// marker write = no marker file = correctly classified
|
|
15716
|
+
// 'crash' via the sessionMarker fallback (boot-reason.ts:84);
|
|
15717
|
+
// a graceful shutdown writes a fresh marker that the next
|
|
15718
|
+
// boot reads + clears. The historical session-greeting.sh
|
|
15719
|
+
// ownership the old comment referred to is gone since #142
|
|
15720
|
+
// but the GC step was never re-homed — this is it.
|
|
15721
|
+
clearCleanShutdownMarker(GATEWAY_CLEAN_SHUTDOWN_MARKER_PATH)
|
|
15625
15722
|
}
|
|
15626
15723
|
|
|
15627
15724
|
if (marker) {
|
|
@@ -107,8 +107,17 @@ export interface ConfigApprovalResolvedEvent {
|
|
|
107
107
|
/** Echoes the requestId from the originating request_config_approval. */
|
|
108
108
|
requestId: string;
|
|
109
109
|
verdict: "approve" | "deny" | "timeout";
|
|
110
|
-
/** Diagnostic detail when present
|
|
110
|
+
/** Diagnostic detail when present. */
|
|
111
111
|
reason?: string;
|
|
112
|
+
/**
|
|
113
|
+
* Distinguishes an actual operator tap-deny (`"operator"`) from a
|
|
114
|
+
* gateway-side dispatch failure that auto-denied because the card
|
|
115
|
+
* never reached the operator (`"dispatch_failure"`). Only set on
|
|
116
|
+
* `verdict: "deny"` events. Caller (hostd) maps `dispatch_failure`
|
|
117
|
+
* to a distinct error code so the failure isn't misattributed to
|
|
118
|
+
* the operator. Issue #1762.
|
|
119
|
+
*/
|
|
120
|
+
denySource?: "operator" | "dispatch_failure";
|
|
112
121
|
}
|
|
113
122
|
|
|
114
123
|
export type GatewayToClient =
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the shared truncateRawToFit helper (#1767).
|
|
3
|
+
*
|
|
4
|
+
* Covers:
|
|
5
|
+
* - No-op when the rendered body already fits.
|
|
6
|
+
* - Binary-search shrinks the raw slice; result fits under `cap`.
|
|
7
|
+
* - Line-snap when newlines exist; sentinel appended.
|
|
8
|
+
* - Char-truncation fallback when a single unbroken line exceeds
|
|
9
|
+
* the budget (no `\n` to snap to).
|
|
10
|
+
* - Defensive hard-cut when even the framing-alone overflows.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, it, expect } from "vitest";
|
|
14
|
+
import { truncateRawToFit } from "./oversize-card-body.js";
|
|
15
|
+
|
|
16
|
+
const SENTINEL = "\n[… truncated]";
|
|
17
|
+
|
|
18
|
+
function frame(escapeMultiplier: number) {
|
|
19
|
+
// Render closure that mimics a `<pre>`-wrapped HTML-escaped body
|
|
20
|
+
// where every `&` inflates `escapeMultiplier`-fold.
|
|
21
|
+
return (raw: string) => {
|
|
22
|
+
const inflated = raw.replace(/&/g, "&".repeat(escapeMultiplier));
|
|
23
|
+
return `<b>Hdr</b>\n<pre>${inflated}</pre>`;
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe("truncateRawToFit", () => {
|
|
28
|
+
it("returns the full body unchanged when it fits under cap", () => {
|
|
29
|
+
const raw = "small content";
|
|
30
|
+
const { body, truncated } = truncateRawToFit({
|
|
31
|
+
raw,
|
|
32
|
+
render: (s) => `<pre>${s}</pre>`,
|
|
33
|
+
cap: 100,
|
|
34
|
+
sentinel: SENTINEL,
|
|
35
|
+
});
|
|
36
|
+
expect(truncated).toBe(false);
|
|
37
|
+
expect(body).toBe("<pre>small content</pre>");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("shrinks raw via binary-search until the rendered body fits the cap (5x escape inflation)", () => {
|
|
41
|
+
const raw = "&".repeat(2000); // 2000 chars raw, 10000 after 5x inflate
|
|
42
|
+
const { body, truncated } = truncateRawToFit({
|
|
43
|
+
raw,
|
|
44
|
+
render: frame(5),
|
|
45
|
+
cap: 1000,
|
|
46
|
+
sentinel: SENTINEL,
|
|
47
|
+
});
|
|
48
|
+
expect(truncated).toBe(true);
|
|
49
|
+
expect(body.length).toBeLessThanOrEqual(1000);
|
|
50
|
+
expect(body).toContain("truncated");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("snaps to the last newline within the chosen raw prefix (no mid-line cut)", () => {
|
|
54
|
+
const raw = ["line-a", "line-b", "line-c", "line-d", "line-e"]
|
|
55
|
+
.map((l) => l.repeat(50))
|
|
56
|
+
.join("\n");
|
|
57
|
+
const { body, truncated } = truncateRawToFit({
|
|
58
|
+
raw,
|
|
59
|
+
render: (s) => `<pre>${s}</pre>`,
|
|
60
|
+
cap: 600,
|
|
61
|
+
sentinel: SENTINEL,
|
|
62
|
+
});
|
|
63
|
+
expect(truncated).toBe(true);
|
|
64
|
+
// The portion before the sentinel must end at a complete line —
|
|
65
|
+
// no partial `line-?` suffix mid-word.
|
|
66
|
+
const beforeSentinel = body.slice(
|
|
67
|
+
0,
|
|
68
|
+
body.length - SENTINEL.length - "</pre>".length,
|
|
69
|
+
);
|
|
70
|
+
// Every line in the source repeated its label 50x — assert the
|
|
71
|
+
// tail of the kept content ends with a full repeated block, not
|
|
72
|
+
// a chopped one. Easiest check: the body must not end mid-word
|
|
73
|
+
// like `line-` (the dash followed by no letter).
|
|
74
|
+
expect(beforeSentinel).not.toMatch(/line-$/);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("falls through to char-truncation when a single unbroken line exceeds cap", () => {
|
|
78
|
+
const raw = "x".repeat(5000); // no newlines at all
|
|
79
|
+
const { body, truncated } = truncateRawToFit({
|
|
80
|
+
raw,
|
|
81
|
+
render: (s) => `<pre>${s}</pre>`,
|
|
82
|
+
cap: 500,
|
|
83
|
+
sentinel: SENTINEL,
|
|
84
|
+
});
|
|
85
|
+
expect(truncated).toBe(true);
|
|
86
|
+
expect(body.length).toBeLessThanOrEqual(500);
|
|
87
|
+
// Some of the line content survives — the helper shouldn't
|
|
88
|
+
// collapse to "<pre>" + sentinel only.
|
|
89
|
+
expect(body).toMatch(/x{100,}/);
|
|
90
|
+
expect(body).toContain("truncated");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("hard-cuts at hardLimit when framing alone overflows (defensive)", () => {
|
|
94
|
+
// Framing-alone is 5000 chars (way past cap), and the renderer
|
|
95
|
+
// ignores its input — so no slice of raw can ever fit. The
|
|
96
|
+
// helper should hard-cut to hardLimit rather than loop forever.
|
|
97
|
+
const huge = "Z".repeat(5000);
|
|
98
|
+
const { body, truncated } = truncateRawToFit({
|
|
99
|
+
raw: "ignored",
|
|
100
|
+
render: () => huge,
|
|
101
|
+
cap: 1000,
|
|
102
|
+
sentinel: SENTINEL,
|
|
103
|
+
hardLimit: 1100,
|
|
104
|
+
});
|
|
105
|
+
expect(truncated).toBe(true);
|
|
106
|
+
expect(body.length).toBeLessThanOrEqual(1100);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared "render-and-fit" helper for approval cards that wrap
|
|
3
|
+
* user-supplied content in HTML framing. (#1762 / #1767)
|
|
4
|
+
*
|
|
5
|
+
* Telegram's `sendMessage` caps the body at 4096 chars and we render
|
|
6
|
+
* with `parse_mode=HTML`. Worst-case escape inflates raw content up
|
|
7
|
+
* to 5x (`&` → `&`), so a naive raw-input cap is unsafe — the
|
|
8
|
+
* post-escape body can blow past the limit and `sendMessage` then
|
|
9
|
+
* returns a generic 400 that surfaces upstream as a silent
|
|
10
|
+
* `E_DENIED`.
|
|
11
|
+
*
|
|
12
|
+
* This helper binary-searches the largest prefix of the RAW content
|
|
13
|
+
* whose rendered body still fits under `cap`, snaps to the last
|
|
14
|
+
* newline so we don't cut mid-line (and never cut mid-entity like
|
|
15
|
+
* `&am|p;` — raw doesn't contain entities yet), and appends a
|
|
16
|
+
* sentinel pointing at the attached full content (if any).
|
|
17
|
+
*
|
|
18
|
+
* Callers own the framing: pass a `render(slice)` closure that
|
|
19
|
+
* embeds the slice in whatever escaped envelope they want, and the
|
|
20
|
+
* helper guarantees the returned `body` fits.
|
|
21
|
+
*
|
|
22
|
+
* Both `config-approval-handler.ts` (config-edit diffs) and
|
|
23
|
+
* `drive-write-approval.ts` (Drive write preview cards) use this.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
export interface TruncateRawToFitInput {
|
|
27
|
+
/** Raw, un-escaped content to slice. */
|
|
28
|
+
raw: string;
|
|
29
|
+
/**
|
|
30
|
+
* Build the full rendered card body from a (possibly truncated)
|
|
31
|
+
* raw slice. The closure owns HTML escaping + all framing. Called
|
|
32
|
+
* O(log n) times during the binary search; keep it cheap.
|
|
33
|
+
*/
|
|
34
|
+
render: (rawSlice: string) => string;
|
|
35
|
+
/**
|
|
36
|
+
* Maximum rendered length (chars). Should be set below Telegram's
|
|
37
|
+
* 4096 hard limit to leave margin for invisible framing wobble.
|
|
38
|
+
*/
|
|
39
|
+
cap: number;
|
|
40
|
+
/**
|
|
41
|
+
* Marker appended to the truncated slice before re-rendering — e.g.
|
|
42
|
+
* `"\n[… diff continues, see attached file]"`. The render closure
|
|
43
|
+
* receives `rawSlice + sentinel` so the marker is visible inside
|
|
44
|
+
* the same envelope (code block etc.).
|
|
45
|
+
*/
|
|
46
|
+
sentinel: string;
|
|
47
|
+
/** Absolute hard cap for the defensive last-resort raw cut. Default `cap + 196`. */
|
|
48
|
+
hardLimit?: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface TruncateRawToFitResult {
|
|
52
|
+
/** Rendered body, guaranteed to fit within `cap` (best-effort) or `hardLimit` (defensive). */
|
|
53
|
+
body: string;
|
|
54
|
+
/** True iff the helper had to truncate (raw was sliced or hard-cut). */
|
|
55
|
+
truncated: boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Try the full content first; if it fits, return as-is. Otherwise
|
|
60
|
+
* binary-search the largest raw prefix whose rendered body fits,
|
|
61
|
+
* snap to the last newline boundary, append the sentinel, re-render
|
|
62
|
+
* and return.
|
|
63
|
+
*
|
|
64
|
+
* Defensive last resort: if even the empty-slice + sentinel render
|
|
65
|
+
* overflows (means the framing alone exceeds `cap` — caller bug or
|
|
66
|
+
* adversarial reason field that slipped past clipping), we hard-cut
|
|
67
|
+
* the rendered body to `hardLimit` chars. Should be unreachable in
|
|
68
|
+
* production but cheaper than crashing.
|
|
69
|
+
*/
|
|
70
|
+
export function truncateRawToFit(
|
|
71
|
+
input: TruncateRawToFitInput,
|
|
72
|
+
): TruncateRawToFitResult {
|
|
73
|
+
const { raw, render, cap, sentinel } = input;
|
|
74
|
+
const hardLimit = input.hardLimit ?? cap + 196;
|
|
75
|
+
|
|
76
|
+
const fullBody = render(raw);
|
|
77
|
+
if (fullBody.length <= cap) {
|
|
78
|
+
return { body: fullBody, truncated: false };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Binary-search the largest raw prefix length whose rendered body
|
|
82
|
+
// fits (with sentinel suffixed before render). We track the best
|
|
83
|
+
// slice rather than just the length so we can snap after the loop.
|
|
84
|
+
let lo = 0;
|
|
85
|
+
let hi = raw.length;
|
|
86
|
+
let bestSliceLen = 0;
|
|
87
|
+
while (lo <= hi) {
|
|
88
|
+
const mid = (lo + hi) >>> 1;
|
|
89
|
+
const candidate = raw.slice(0, mid) + sentinel;
|
|
90
|
+
if (render(candidate).length <= cap) {
|
|
91
|
+
bestSliceLen = mid;
|
|
92
|
+
lo = mid + 1;
|
|
93
|
+
} else {
|
|
94
|
+
hi = mid - 1;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Snap to the last newline within the chosen raw prefix so we
|
|
99
|
+
// never cut a line in half. If a single unbroken line exceeds
|
|
100
|
+
// the budget, fall through with the char-truncated slice — the
|
|
101
|
+
// caller's framing (e.g. `<pre>`) handles the visual gracefully.
|
|
102
|
+
let chosenRaw = raw.slice(0, bestSliceLen);
|
|
103
|
+
const lastNl = chosenRaw.lastIndexOf("\n");
|
|
104
|
+
if (lastNl > 0) chosenRaw = chosenRaw.slice(0, lastNl);
|
|
105
|
+
|
|
106
|
+
let body = render(chosenRaw + sentinel);
|
|
107
|
+
|
|
108
|
+
// Defensive: framing-alone overflow. Hard-cut to hardLimit so the
|
|
109
|
+
// outbound sendMessage at least has a chance of succeeding.
|
|
110
|
+
if (body.length > hardLimit) {
|
|
111
|
+
body = body.slice(0, hardLimit - 1);
|
|
112
|
+
}
|
|
113
|
+
return { body, truncated: true };
|
|
114
|
+
}
|
|
@@ -10,13 +10,15 @@
|
|
|
10
10
|
* Pure helper so it can be tested without spinning up the gateway.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import { GrammyError } from 'grammy'
|
|
13
|
+
import { GrammyError, HttpError } from 'grammy'
|
|
14
14
|
|
|
15
15
|
export type RejectionAction = 'shutdown' | 'log_only'
|
|
16
16
|
|
|
17
17
|
export interface RejectionPolicyOptions {
|
|
18
18
|
/** Allow tests to inject error type detection without depending on grammy. */
|
|
19
19
|
isGrammyError?: (err: unknown) => boolean
|
|
20
|
+
/** Allow tests to inject HttpError detection without depending on grammy. */
|
|
21
|
+
isHttpError?: (err: unknown) => boolean
|
|
20
22
|
}
|
|
21
23
|
|
|
22
24
|
/**
|
|
@@ -42,9 +44,52 @@ export function classifyRejection(
|
|
|
42
44
|
? opts.isGrammyError(err)
|
|
43
45
|
: err instanceof GrammyError
|
|
44
46
|
|
|
47
|
+
// Transient network-layer failures: grammy throws an `HttpError` wrapping
|
|
48
|
+
// the underlying fetch failure (ECONNRESET, ETIMEDOUT, fetch failed, DNS
|
|
49
|
+
// failures, etc.). These are the SAME class `retry-api-call.ts:146-162`
|
|
50
|
+
// already retries with exponential backoff — if one leaks past the retry
|
|
51
|
+
// policy (3 attempts exhausted, or a fire-and-forget callsite without
|
|
52
|
+
// robustApiCall wrapping), crashing the gateway turns one bad packet into
|
|
53
|
+
// a crash banner. log_only is the right posture: the request failed, the
|
|
54
|
+
// user-visible UX recovers on the next retry cycle, and a daemon that
|
|
55
|
+
// crashes on network errors isn't always-on.
|
|
56
|
+
//
|
|
57
|
+
// Surfaced 2026-05-25 on clerk via the boot-card sendMessage path: an
|
|
58
|
+
// HttpError leaked past the boot-card's try/catch (the async post-settle
|
|
59
|
+
// probe-loop IIFE at boot-card.ts:616 had no .catch on its outer void),
|
|
60
|
+
// triggering an unhandledRejection → shutdown → user-visible
|
|
61
|
+
// "agent-crashed" banner for what was really just a transient network hiccup.
|
|
62
|
+
const isHttp =
|
|
63
|
+
opts.isHttpError != null
|
|
64
|
+
? opts.isHttpError(err)
|
|
65
|
+
: err instanceof HttpError
|
|
66
|
+
if (isHttp) return 'log_only'
|
|
67
|
+
|
|
45
68
|
if (!isGrammy) return 'shutdown'
|
|
46
69
|
|
|
47
70
|
const e = err as { error_code?: number; description?: string }
|
|
71
|
+
|
|
72
|
+
// 429 (Too Many Requests / flood-wait): grammy's flood-wait response.
|
|
73
|
+
// Already handled in retry-api-call.ts:100-108 with the
|
|
74
|
+
// `parameters.retry_after` backoff. If one leaks past — caller exceeded
|
|
75
|
+
// maxRetries=3 of sustained 429s, or didn't wrap in robustApiCall — the
|
|
76
|
+
// right posture is log_only (matches the HttpError rationale above).
|
|
77
|
+
// The bot is rate-limited; crashing makes it worse (boot fires more
|
|
78
|
+
// API calls that hit fresh 429s).
|
|
79
|
+
//
|
|
80
|
+
// Surfaced 2026-05-25 on clerk via a sendMessage that exceeded the 3-
|
|
81
|
+
// attempt retry budget; the rejection bubbled to this handler, triggered
|
|
82
|
+
// shutdown, and posted an "agent-crashed" operator-event banner.
|
|
83
|
+
if (e.error_code === 429) return 'log_only'
|
|
84
|
+
|
|
85
|
+
// 5xx (Bad Gateway / Service Unavailable / Gateway Timeout): Telegram
|
|
86
|
+
// intermittently returns these during their own load events. Same
|
|
87
|
+
// posture as 429 — retry policy already backs off and re-tries; if
|
|
88
|
+
// one leaks past, log don't crash.
|
|
89
|
+
if (typeof e.error_code === 'number' && e.error_code >= 500 && e.error_code < 600) {
|
|
90
|
+
return 'log_only'
|
|
91
|
+
}
|
|
92
|
+
|
|
48
93
|
if (e.error_code !== 400) return 'shutdown'
|
|
49
94
|
|
|
50
95
|
const desc = (e.description ?? '').toLowerCase()
|