switchroom 0.18.11 → 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 +37 -5
- package/dist/auth-broker/index.js +116 -78
- package/dist/cli/hindsight-mental-model-pretool.mjs +39 -0
- package/dist/cli/ms-365-write-pretool.mjs +31 -8
- package/dist/cli/notion-write-pretool.mjs +38 -6
- package/dist/cli/skill-validate-pretool.mjs +144 -2847
- package/dist/cli/switchroom.js +3529 -4543
- package/dist/cli/ui/index.html +163 -17
- package/dist/host-control/main.js +605 -2847
- package/dist/vault/approvals/kernel-server.js +120 -13
- package/dist/vault/broker/server.js +259 -157
- package/package.json +3 -4
- package/profiles/_base/start.sh.hbs +65 -0
- package/profiles/_shared/vault-protocol.md.hbs +3 -1
- package/profiles/coding/CLAUDE.md.hbs +1 -1
- package/profiles/default/CLAUDE.md.hbs +2 -2
- package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
- package/profiles/health-coach/CLAUDE.md.hbs +1 -1
- package/telegram-plugin/bridge/bridge.ts +37 -0
- package/telegram-plugin/bridge/inbound-dedup.ts +101 -0
- package/telegram-plugin/dist/bridge/bridge.js +122 -4
- package/telegram-plugin/dist/gateway/gateway.js +4213 -3288
- package/telegram-plugin/dist/server.js +139 -5
- package/telegram-plugin/flood-circuit-breaker.ts +493 -21
- package/telegram-plugin/format.ts +19 -0
- package/telegram-plugin/gateway/approval-hold.ts +602 -0
- package/telegram-plugin/gateway/auth-command.ts +92 -2
- package/telegram-plugin/gateway/auth-loopback-relay.ts +670 -0
- package/telegram-plugin/gateway/boot-card.ts +12 -5
- package/telegram-plugin/gateway/callback-query-handlers.ts +88 -1
- package/telegram-plugin/gateway/config-approval-handler.ts +6 -1
- package/telegram-plugin/gateway/disconnect-flush.ts +19 -0
- package/telegram-plugin/gateway/dm-pin-sweep.test.ts +251 -0
- package/telegram-plugin/gateway/dm-pin-sweep.ts +178 -0
- package/telegram-plugin/gateway/gateway.ts +1695 -230
- package/telegram-plugin/gateway/hostd-dispatch.ts +23 -0
- package/telegram-plugin/gateway/idle-clear.ts +90 -6
- package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +26 -5
- package/telegram-plugin/gateway/inject-handler.ts +8 -0
- package/telegram-plugin/gateway/ipc-protocol.ts +46 -3
- package/telegram-plugin/gateway/ipc-server.ts +43 -0
- package/telegram-plugin/gateway/mental-model-propose-resolve.ts +145 -37
- package/telegram-plugin/gateway/model-command.ts +9 -3
- package/telegram-plugin/gateway/pending-session-command.ts +13 -1
- package/telegram-plugin/gateway/permission-ttl-sweep.ts +66 -0
- package/telegram-plugin/gateway/pre-approval-check.ts +74 -0
- package/telegram-plugin/gateway/queued-card-store.ts +217 -0
- package/telegram-plugin/gateway/session-model-file.ts +26 -1
- package/telegram-plugin/gateway/turn-end-gate-backstop.ts +59 -0
- package/telegram-plugin/gateway/turn-end-gate.ts +95 -0
- package/telegram-plugin/gateway/turn-typing-loop.ts +10 -2
- package/telegram-plugin/gateway/unhandled-rejection-policy.ts +13 -0
- package/telegram-plugin/history.ts +51 -0
- package/telegram-plugin/hooks/dispatch-claim-scan.mjs +259 -0
- package/telegram-plugin/hooks/dispatch-claim-stop.mjs +129 -0
- package/telegram-plugin/hooks/hooks.json +9 -0
- package/telegram-plugin/inline-keyboard-callbacks.ts +303 -2
- package/telegram-plugin/model-unavailable.ts +41 -11
- package/telegram-plugin/operator-events.ts +23 -0
- package/telegram-plugin/outbound-field-redact.ts +69 -0
- package/telegram-plugin/package.json +0 -1
- package/telegram-plugin/permission-rule.ts +1 -0
- package/telegram-plugin/permission-title.ts +1 -0
- package/telegram-plugin/render/render.ts +32 -14
- package/telegram-plugin/retry-api-call.ts +212 -2
- 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 +574 -0
- package/telegram-plugin/send-gate-observability.test.ts +470 -0
- package/telegram-plugin/send-gate-observability.ts +355 -0
- package/telegram-plugin/send-gate.test.ts +717 -0
- package/telegram-plugin/send-gate.ts +1056 -0
- package/telegram-plugin/session-tail.ts +82 -7
- package/telegram-plugin/shared/bot-runtime.ts +17 -5
- package/telegram-plugin/shared/gw-trace-gate.ts +105 -0
- package/telegram-plugin/status-pin-driver.ts +52 -7
- package/telegram-plugin/status-pin.ts +81 -0
- package/telegram-plugin/subagent-watcher.ts +173 -18
- package/telegram-plugin/tests/activity-card-wiring.test.ts +18 -5
- package/telegram-plugin/tests/approval-hold-harness.ts +425 -0
- package/telegram-plugin/tests/approval-hold-outcome.test.ts +327 -0
- package/telegram-plugin/tests/approval-hold-record.test.ts +531 -0
- package/telegram-plugin/tests/approval-hold-redeliver.test.ts +602 -0
- package/telegram-plugin/tests/auth-loopback-relay.test.ts +533 -0
- package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +53 -7
- package/telegram-plugin/tests/busy-key-reaper.test.ts +1 -0
- package/telegram-plugin/tests/callback-query-handlers.test.ts +65 -0
- package/telegram-plugin/tests/dispatch-claim-scan.test.ts +250 -0
- package/telegram-plugin/tests/flood-breaker-blindness.test.ts +213 -0
- package/telegram-plugin/tests/flood-windows-persistence.test.ts +224 -0
- package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
- package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +29 -1
- package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +66 -0
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +57 -0
- package/telegram-plugin/tests/gw-trace-gate.test.ts +105 -0
- package/telegram-plugin/tests/history.test.ts +115 -0
- package/telegram-plugin/tests/idle-clear.test.ts +233 -3
- package/telegram-plugin/tests/inbound-dedup.test.ts +93 -0
- package/telegram-plugin/tests/inbound-message-types.test.ts +5 -1
- package/telegram-plugin/tests/inline-keyboard-callbacks.test.ts +448 -0
- package/telegram-plugin/tests/ipc-server-check-pre-approved.test.ts +194 -0
- package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +123 -0
- package/telegram-plugin/tests/missed-approvals-wiring.test.ts +1 -1
- package/telegram-plugin/tests/model-command.test.ts +14 -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/pending-session-command.test.ts +21 -0
- package/telegram-plugin/tests/permission-card-routing.test.ts +30 -5
- package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +8 -7
- package/telegram-plugin/tests/permission-rearm-wiring.test.ts +1 -1
- package/telegram-plugin/tests/pre-approval-check.test.ts +148 -0
- package/telegram-plugin/tests/queued-card-store.test.ts +232 -0
- package/telegram-plugin/tests/reaction-flush-turn-gated.test.ts +100 -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/retry-api-call.test.ts +398 -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-model-file.test.ts +50 -0
- package/telegram-plugin/tests/session-tail-sidecar-reap.test.ts +268 -0
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +35 -14
- package/telegram-plugin/tests/status-pin.test.ts +275 -1
- package/telegram-plugin/tests/subagent-watcher-deferral-log-ratelimit.test.ts +316 -0
- package/telegram-plugin/tests/subagent-watcher-fd-leak.test.ts +275 -0
- package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +92 -0
- package/telegram-plugin/tests/turn-end-gate.test.ts +137 -0
- package/telegram-plugin/tests/typing-emitter.test.ts +586 -0
- package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +20 -0
- package/telegram-plugin/tests/worktree-watch-cwds.test.ts +215 -1
- package/telegram-plugin/typing-emitter.ts +224 -0
- package/telegram-plugin/uat/scenarios/jtbd-feel-like-a-colleague-dm.test.ts +136 -0
- package/telegram-plugin/welcome-text.ts +42 -0
- package/telegram-plugin/worktree-watch-cwds.ts +194 -5
- package/vendor/hindsight-memory/scripts/drain_pending.py +22 -6
- package/vendor/hindsight-memory/scripts/lib/client.py +12 -5
- package/vendor/hindsight-memory/scripts/lib/directives.py +38 -3
- package/vendor/hindsight-memory/scripts/lib/pending.py +36 -9
- package/vendor/hindsight-memory/scripts/session_end.py +14 -3
- package/vendor/hindsight-memory/scripts/session_start.py +21 -0
- package/vendor/hindsight-memory/scripts/tests/test_directives.py +38 -0
- package/vendor/hindsight-memory/tests/test_drain_pending.py +68 -0
- package/vendor/hindsight-memory/tests/test_pending.py +44 -0
- package/vendor/hindsight-memory/tests/test_session_end_pending.py +38 -0
- package/vendor/hindsight-memory/tests/test_session_start_drain.py +155 -0
- package/telegram-plugin/channel-envelope-safety.test.ts +0 -56
- package/telegram-plugin/channel-envelope-safety.ts +0 -56
- package/telegram-plugin/secret-detect/secretlint-source.ts +0 -95
- package/telegram-plugin/tests/secret-detect-secretlint.test.ts +0 -105
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
*
|
|
17
17
|
* | Thrown error | This wrapper does |
|
|
18
18
|
* |-----------------------------------------------|--------------------------------------------|
|
|
19
|
-
* | GrammyError 429
|
|
19
|
+
* | GrammyError 429 (retry_after <= ceiling) | sleep retry_after seconds, retry |
|
|
20
|
+
* | GrammyError 429 (retry_after > ceiling) | record window, throw `FLOOD_WAIT_ACTIVE` |
|
|
20
21
|
* | GrammyError 400 "message is not modified" | swallow, return undefined |
|
|
21
22
|
* | GrammyError 400 "message to edit not found" | swallow, return undefined |
|
|
22
23
|
* | GrammyError 400 "message to delete not found" | swallow, return undefined |
|
|
@@ -43,6 +44,39 @@ export interface RetryCallOpts {
|
|
|
43
44
|
* call sites to self-document. Future: surface in retry logs / metrics.
|
|
44
45
|
*/
|
|
45
46
|
verb?: string
|
|
47
|
+
/**
|
|
48
|
+
* Chat type of the destination (private / group / supergroup / channel).
|
|
49
|
+
* Consumed by the outbound send gate (#3084, send-gate.ts) to key the
|
|
50
|
+
* per-group bucket. Optional and additive — the retry policy ignores it.
|
|
51
|
+
*/
|
|
52
|
+
chatType?: 'private' | 'group' | 'supergroup' | 'channel'
|
|
53
|
+
/**
|
|
54
|
+
* For edit calls: the target message id. Enables the send gate's
|
|
55
|
+
* per-message edit floor + last-write-wins coalescing. Retry policy ignores.
|
|
56
|
+
*/
|
|
57
|
+
messageId?: number
|
|
58
|
+
/**
|
|
59
|
+
* For edit calls: the rendered payload the send gate hashes to skip no-op
|
|
60
|
+
* edits (identical → dropped) and to coalesce. Retry policy ignores it.
|
|
61
|
+
*/
|
|
62
|
+
editPayload?: unknown
|
|
63
|
+
/**
|
|
64
|
+
* Priority class for the outbound send gate's shedding + degraded mode
|
|
65
|
+
* (#3084 PR 2, send-gate.ts). The retry policy itself ignores it; it only
|
|
66
|
+
* governs how the gate treats the call when it is under pressure or a flood
|
|
67
|
+
* window is open:
|
|
68
|
+
* - `critical` — final reply chunks, approval / vault cards, error
|
|
69
|
+
* notices. Never shed; queued unbounded. In degraded mode a critical
|
|
70
|
+
* send waits for a short window but fails fast (structured
|
|
71
|
+
* `FLOOD_WAIT_ACTIVE`) when the remaining window is long.
|
|
72
|
+
* - `useful` — progress-card creation, worker handbacks, checklists,
|
|
73
|
+
* boot/config cards. Queued with a TTL; dropped when stale. DEFAULT
|
|
74
|
+
* when unset.
|
|
75
|
+
* - `cosmetic` — typing, reactions, all card EDITS, stream updates,
|
|
76
|
+
* heartbeats. Shed immediately when no token is free OR any flood
|
|
77
|
+
* window is open.
|
|
78
|
+
*/
|
|
79
|
+
priorityClass?: 'critical' | 'useful' | 'cosmetic'
|
|
46
80
|
}
|
|
47
81
|
|
|
48
82
|
export interface RetryObserver {
|
|
@@ -57,6 +91,24 @@ export interface RetryObserver {
|
|
|
57
91
|
export interface RetryApiCallConfig {
|
|
58
92
|
/** Max retries before giving up. Defaults to 3. */
|
|
59
93
|
maxRetries?: number
|
|
94
|
+
/**
|
|
95
|
+
* Ceiling (ms) on how long a single 429 flood-wait may be slept IN-PROCESS.
|
|
96
|
+
* A `retry_after` above this is not slept at all — the window is recorded and
|
|
97
|
+
* `FLOOD_WAIT_ACTIVE` is thrown instead (see #3084). Defaults to
|
|
98
|
+
* `DEFAULT_MAX_FLOOD_SLEEP_MS`.
|
|
99
|
+
*/
|
|
100
|
+
maxFloodSleepMs?: number
|
|
101
|
+
/**
|
|
102
|
+
* Remaining ms of a KNOWN-OPEN per-bot flood window, or 0 when none.
|
|
103
|
+
*
|
|
104
|
+
* Wired to the #2923 circuit-breaker state file (see
|
|
105
|
+
* `makeFloodWaitProbe`). When the remaining window is LONGER than
|
|
106
|
+
* `maxFloodSleepMs`, `retryApiCall` short-circuits BEFORE issuing the call:
|
|
107
|
+
* the ban is per-bot-token, so nothing can succeed while it's open, and
|
|
108
|
+
* every attempt only feeds the flood counter (#3084). Fails OPEN — a
|
|
109
|
+
* missing/corrupt/throwing probe means "no window", never a silenced bot.
|
|
110
|
+
*/
|
|
111
|
+
floodWaitRemainingMs?: () => number
|
|
60
112
|
/** Sleep helper — injected so tests can use fake timers. */
|
|
61
113
|
sleep?: (ms: number) => Promise<void>
|
|
62
114
|
/** Optional observer hooks. */
|
|
@@ -105,6 +157,107 @@ export function isLocalResourceError(err: unknown): boolean {
|
|
|
105
157
|
*/
|
|
106
158
|
export const LOCAL_RESOURCE_EXHAUSTED = 'LOCAL_RESOURCE_EXHAUSTED'
|
|
107
159
|
|
|
160
|
+
/**
|
|
161
|
+
* Thrown when every retry is exhausted — a network partition, a Telegram 5xx
|
|
162
|
+
* outage, or a short-but-persistent 429 (only 429s longer than the in-process
|
|
163
|
+
* sleep ceiling raise FLOOD_WAIT_ACTIVE; shorter ones are slept, retried, and
|
|
164
|
+
* end here). Exported as a constant because `approval-hold.ts:holdReasonFor`
|
|
165
|
+
* classifies on it to decide whether an undeliverable approval is HELD — a
|
|
166
|
+
* string that drifts would silently reopen the auto-deny hole.
|
|
167
|
+
*/
|
|
168
|
+
export const GIVE_UP_MESSAGE = 'retryApiCall: max retries exceeded'
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Marker error thrown when Telegram's reported `retry_after` exceeds the
|
|
172
|
+
* in-process sleep ceiling (#3084) — i.e. the bot is under a LONG per-bot
|
|
173
|
+
* flood ban, not a momentary rate-limit blip.
|
|
174
|
+
*
|
|
175
|
+
* Carries `{ retryAfterSec, untilTs, original }` so a caller can render a
|
|
176
|
+
* useful degraded state ("rate-limited by Telegram until HH:MM") instead of
|
|
177
|
+
* being parked on an await for hours.
|
|
178
|
+
*/
|
|
179
|
+
export const FLOOD_WAIT_ACTIVE = 'FLOOD_WAIT_ACTIVE'
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Shape of the error thrown for an over-ceiling flood-wait.
|
|
183
|
+
*
|
|
184
|
+
* It deliberately carries Telegram's OWN 429 field shape (`error_code: 429`,
|
|
185
|
+
* `parameters.retry_after`) in addition to the friendlier fields. Several
|
|
186
|
+
* best-effort card surfaces duck-type their rate-limit cooldown on exactly
|
|
187
|
+
* those two fields rather than on `instanceof GrammyError`:
|
|
188
|
+
*
|
|
189
|
+
* - `worker-activity-feed.ts` `extractRetryAfterSecs` → `noteRateLimited`
|
|
190
|
+
* - `issues-card.ts` `extractRetryAfterSecs`
|
|
191
|
+
* - `pending-work-progress.ts` (its catch assumes upstream retry_after backoff)
|
|
192
|
+
*
|
|
193
|
+
* All three route through `robustApiCall`, so once retryApiCall stops sleeping
|
|
194
|
+
* a long ban and starts THROWING, they see this marker instead of the raw
|
|
195
|
+
* GrammyError. Without `error_code`/`parameters` they would classify it as a
|
|
196
|
+
* generic 'transient' error, arm NO cooldown, and let their 5-6s heartbeats
|
|
197
|
+
* re-drive a fresh Bot API call into the still-open ban — thousands of requests
|
|
198
|
+
* over a 4.6h window, feeding the very flood counter #2923 exists to starve.
|
|
199
|
+
* Carrying the 429 shape makes the existing cooldown gates fire on the full
|
|
200
|
+
* window with no change to those modules.
|
|
201
|
+
*
|
|
202
|
+
* It remains a plain `Error` (NOT a `GrammyError`), so the `instanceof`-based
|
|
203
|
+
* probes — `isHtmlParseRejectError`, `isMessageTooLongError`,
|
|
204
|
+
* `isPhotoDimensionRejectError` — still correctly do not match it.
|
|
205
|
+
*/
|
|
206
|
+
export interface FloodWaitActiveError extends Error {
|
|
207
|
+
/** Telegram's reported retry_after, in seconds. */
|
|
208
|
+
retryAfterSec: number
|
|
209
|
+
/** Epoch ms at which the flood window is expected to expire. */
|
|
210
|
+
untilTs: number
|
|
211
|
+
/** Telegram's 429 code — see the duck-typing note above. */
|
|
212
|
+
error_code: 429
|
|
213
|
+
/** Telegram's 429 payload — see the duck-typing note above. */
|
|
214
|
+
parameters: { retry_after: number }
|
|
215
|
+
/** The originating GrammyError, or null for a pre-call short-circuit. */
|
|
216
|
+
original: unknown
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Build the marker with the full Telegram-429 duck-type shape.
|
|
221
|
+
*
|
|
222
|
+
* Exported so the outbound send gate (#3084 PR 2) can COMPOSE the same
|
|
223
|
+
* structured error for its own degraded-mode fail-fast (a critical send into a
|
|
224
|
+
* long open flood window) rather than duplicating the 429 duck-type shape. One
|
|
225
|
+
* error shape means every downstream cooldown gate + the MCP reply path treat a
|
|
226
|
+
* gate fail-fast identically to a retry-layer flood-wait.
|
|
227
|
+
*/
|
|
228
|
+
export function makeFloodWaitActiveError(
|
|
229
|
+
retryAfterSec: number,
|
|
230
|
+
untilTs: number,
|
|
231
|
+
original: unknown,
|
|
232
|
+
): FloodWaitActiveError {
|
|
233
|
+
return Object.assign(new Error(FLOOD_WAIT_ACTIVE), {
|
|
234
|
+
retryAfterSec,
|
|
235
|
+
untilTs,
|
|
236
|
+
error_code: 429 as const,
|
|
237
|
+
parameters: { retry_after: retryAfterSec },
|
|
238
|
+
original,
|
|
239
|
+
})
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** True when `err` is the `FLOOD_WAIT_ACTIVE` marker thrown by `retryApiCall`. */
|
|
243
|
+
export function isFloodWaitActiveError(err: unknown): err is FloodWaitActiveError {
|
|
244
|
+
return err instanceof Error && err.message === FLOOD_WAIT_ACTIVE
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Default ceiling on a single in-process flood-wait sleep: 120s.
|
|
249
|
+
*
|
|
250
|
+
* Why 120s and not 30-60s: the flood-waits this bot has historically ridden out
|
|
251
|
+
* successfully were 28s and 75s — sleeping through those is the RIGHT behaviour
|
|
252
|
+
* and must not regress, so the ceiling has to sit above 75s with headroom. Why
|
|
253
|
+
* not higher: the ban this guards against (#3084, overlord 2026-07-11) reported
|
|
254
|
+
* retry_after = 16739s (~4.6h). A 120s ceiling bounds the worst case in-process
|
|
255
|
+
* block to maxRetries × 120s (6s→6min at the default 3) instead of hours, which
|
|
256
|
+
* is short enough that a caller — and the human waiting on a reply — gets a
|
|
257
|
+
* degraded answer rather than an apparently-wedged agent.
|
|
258
|
+
*/
|
|
259
|
+
export const DEFAULT_MAX_FLOOD_SLEEP_MS = 120_000
|
|
260
|
+
|
|
108
261
|
const DEFAULT_SLEEP = (ms: number) => new Promise<void>((r) => setTimeout(r, ms))
|
|
109
262
|
|
|
110
263
|
/**
|
|
@@ -120,16 +273,56 @@ export function createRetryApiCall(
|
|
|
120
273
|
config: RetryApiCallConfig = {},
|
|
121
274
|
): <T>(fn: () => Promise<T>, opts?: RetryCallOpts) => Promise<T> {
|
|
122
275
|
const maxRetries = config.maxRetries ?? 3
|
|
276
|
+
const maxFloodSleepMs = config.maxFloodSleepMs ?? DEFAULT_MAX_FLOOD_SLEEP_MS
|
|
123
277
|
const sleep = config.sleep ?? DEFAULT_SLEEP
|
|
124
278
|
const observer = config.observer
|
|
125
279
|
const log = config.log
|
|
126
280
|
const onFloodWait = config.onFloodWait
|
|
281
|
+
const floodWaitRemainingMs = config.floodWaitRemainingMs
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Remaining ms of a known-open flood window. FAILS OPEN: any throw from the
|
|
285
|
+
* probe (unreadable state dir, corrupt JSON, clock weirdness) is treated as
|
|
286
|
+
* "no window" — a broken marker file must never permanently silence the bot.
|
|
287
|
+
*/
|
|
288
|
+
function openWindowMs(): number {
|
|
289
|
+
if (!floodWaitRemainingMs) return 0
|
|
290
|
+
try {
|
|
291
|
+
const ms = floodWaitRemainingMs()
|
|
292
|
+
return Number.isFinite(ms) && ms > 0 ? ms : 0
|
|
293
|
+
} catch {
|
|
294
|
+
return 0
|
|
295
|
+
}
|
|
296
|
+
}
|
|
127
297
|
|
|
128
298
|
return async function retryApiCall<T>(
|
|
129
299
|
fn: () => Promise<T>,
|
|
130
300
|
opts?: RetryCallOpts,
|
|
131
301
|
): Promise<T> {
|
|
132
302
|
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
303
|
+
// #3084 — do not send INTO a known-open long ban. Failing the call fast
|
|
304
|
+
// (rather than sleeping it) is only safe if the caller can't just turn
|
|
305
|
+
// around and re-drive it: the card surfaces re-attempt on a 5-6s
|
|
306
|
+
// heartbeat, which over a 4.6h ban would be thousands of requests into
|
|
307
|
+
// the open window — the exact ban-extension the #2923 breaker exists to
|
|
308
|
+
// prevent. So the policy itself refuses to issue the call while the
|
|
309
|
+
// window is open. The ban is per-bot-TOKEN: nothing can succeed during
|
|
310
|
+
// it, so short-circuiting loses no delivery that would otherwise land.
|
|
311
|
+
//
|
|
312
|
+
// Only LONG windows short-circuit (remaining > the sleep ceiling). A
|
|
313
|
+
// short window is left to the normal path — it may already have expired
|
|
314
|
+
// server-side, and today's sleep-and-retry rides it out.
|
|
315
|
+
const remaining = openWindowMs()
|
|
316
|
+
if (remaining > maxFloodSleepMs) {
|
|
317
|
+
const retryAfterSec = Math.ceil(remaining / 1000)
|
|
318
|
+
log?.(
|
|
319
|
+
`telegram gateway: flood window still open for ${retryAfterSec}s — ` +
|
|
320
|
+
`not issuing the ${opts?.verb ?? 'api-call'} (would feed the ban)\n`,
|
|
321
|
+
)
|
|
322
|
+
const gated = makeFloodWaitActiveError(retryAfterSec, Date.now() + remaining, null)
|
|
323
|
+
observer?.onGiveUp?.({ attempts: attempt + 1, error: gated })
|
|
324
|
+
throw gated
|
|
325
|
+
}
|
|
133
326
|
try {
|
|
134
327
|
return await fn()
|
|
135
328
|
} catch (err) {
|
|
@@ -164,6 +357,23 @@ export function createRetryApiCall(
|
|
|
164
357
|
} catch {
|
|
165
358
|
/* best-effort — never let the breaker hook break the retry path */
|
|
166
359
|
}
|
|
360
|
+
// #3084: a LONG ban must not be slept in-process. On 2026-07-11
|
|
361
|
+
// overlord got retry_after = 16739s (~4.6h); `await sleep(delayMs)`
|
|
362
|
+
// parked the send path — and every caller awaiting it — for hours.
|
|
363
|
+
// That is a wedge primitive, not a retry. Above the ceiling we've
|
|
364
|
+
// already recorded the window (the breaker above suppresses
|
|
365
|
+
// non-essential sends for its duration); now fail FAST with a
|
|
366
|
+
// distinct non-retryable marker so the caller can surface a degraded
|
|
367
|
+
// state. Under the ceiling, behaviour is unchanged: sleep and retry.
|
|
368
|
+
if (delayMs > maxFloodSleepMs) {
|
|
369
|
+
log?.(
|
|
370
|
+
`telegram gateway: 429 flood ban of ${retryAfter}s exceeds the ` +
|
|
371
|
+
`${Math.round(maxFloodSleepMs / 1000)}s in-process sleep ceiling — ` +
|
|
372
|
+
`not sleeping it; surfacing degraded state\n`,
|
|
373
|
+
)
|
|
374
|
+
observer?.onGiveUp?.({ attempts: attempt + 1, error: err })
|
|
375
|
+
throw makeFloodWaitActiveError(retryAfter, Date.now() + delayMs, err)
|
|
376
|
+
}
|
|
167
377
|
log?.(`telegram gateway: 429 rate limited, waiting ${retryAfter}s\n`)
|
|
168
378
|
observer?.onRetry?.({ attempt, reason: 'flood_wait', delayMs })
|
|
169
379
|
await sleep(delayMs)
|
|
@@ -227,7 +437,7 @@ export function createRetryApiCall(
|
|
|
227
437
|
throw err
|
|
228
438
|
}
|
|
229
439
|
}
|
|
230
|
-
const giveUpErr = new Error(
|
|
440
|
+
const giveUpErr = new Error(GIVE_UP_MESSAGE)
|
|
231
441
|
observer?.onGiveUp?.({ attempts: maxRetries, error: giveUpErr })
|
|
232
442
|
throw giveUpErr
|
|
233
443
|
}
|
|
@@ -307,9 +307,18 @@ export function isDestructiveBashCommand(command: string): boolean {
|
|
|
307
307
|
if (/\b(chmod|chown|chgrp)\b[^|;&]*(\s-(-recursive|[a-z]*r[a-z]*)\b)/.test(c)) return true;
|
|
308
308
|
// redirection clobbering devices or system dirs
|
|
309
309
|
if (/>\s*\/(dev|etc|boot|sys|proc)\b/.test(c)) return true;
|
|
310
|
-
// destructive git
|
|
310
|
+
// destructive git — discards or rewrites history / working-tree state.
|
|
311
|
+
// checkout: `-f`/`--force` (force-overwrite), `git checkout .` / `./`
|
|
312
|
+
// / `./<path>` (path-restore of the whole tree or a subtree) and
|
|
313
|
+
// `checkout … -- <path>` all discard uncommitted work. A plain
|
|
314
|
+
// `git checkout <branch>` (branch switch, reversible) is deliberately
|
|
315
|
+
// NOT flagged. NOTE: a bare single-path discard without `--`
|
|
316
|
+
// (`git checkout src/x.ts`) is syntactically ambiguous with a branch
|
|
317
|
+
// name and is a known uncaught form — not full coverage.
|
|
318
|
+
// stash: `drop`/`clear`/`pop` remove stash state irreversibly
|
|
319
|
+
// (`stash`/`list`/`show`/`apply` keep it and stay unflagged).
|
|
311
320
|
if (/\bgit\b/.test(c) &&
|
|
312
|
-
/(push\b[^|;&]*(--force|-f\b|--force-with-lease)|push\s+[^\s]*\s+\+|reset\s+--hard|clean\s+-[a-z]*[fd]|filter-branch|reflog\s+expire|update-ref\s+-d|branch\s+-d{1,2}\b|checkout\s
|
|
321
|
+
/(push\b[^|;&]*(--force|-f\b|--force-with-lease)|push\s+[^\s]*\s+\+|reset\s+--hard|clean\s+-[a-z]*[fd]|filter-branch|reflog\s+expire|update-ref\s+-d|branch\s+-d{1,2}\b|checkout\b[^|;&]*(\s-f\b|\s--force\b|\s--(\s|$)|\s\.(\s|$|\/))|stash\s+(drop|clear|pop)\b|restore\b)/.test(c)) return true;
|
|
313
322
|
// power / process control
|
|
314
323
|
if (/(^|\s|;|&&|\|\||\()(shutdown|reboot|halt|poweroff|kill|killall|pkill)\b/.test(c)) return true;
|
|
315
324
|
if (/(^|\s)init\s+0\b/.test(c)) return true;
|
|
@@ -1,13 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Sliding-window chunker for ReDoS-bounded detection.
|
|
3
3
|
*
|
|
4
|
-
* Inputs larger than 32 KB are split into 16 KB windows with
|
|
4
|
+
* Inputs larger than 32 KB are split into 16 KB windows with 8 KB overlap.
|
|
5
5
|
* Each window is scanned independently; the caller is responsible for
|
|
6
6
|
* dedupe-by-byte-offset when merging per-window hits back together.
|
|
7
7
|
*
|
|
8
8
|
* The overlap exists so a secret that straddles a window boundary is still
|
|
9
|
-
*
|
|
10
|
-
*
|
|
9
|
+
* fully contained in at least one scan. The guarantee is exact: a secret is
|
|
10
|
+
* only missed if its length EXCEEDS the overlap (if length ≤ OVERLAP, a
|
|
11
|
+
* boundary-straddling secret is wholly inside the next window, which starts
|
|
12
|
+
* OVERLAP bytes before the boundary). The old 1 KB overlap was smaller than
|
|
13
|
+
* a real PEM private key — a 4096-bit RSA key in PEM armor is ~3.2 KB, so a
|
|
14
|
+
* boundary-straddling RSA key in a >32 KB payload slipped through unmasked
|
|
15
|
+
* (2026-07 secret-scrub review, tp-support F2). At 8 KB the overlap clears
|
|
16
|
+
* a 4096-bit RSA PEM (~3.2 KB) with >2x margin and also covers larger EC /
|
|
17
|
+
* certificate blobs. Cost: windows advance by WINDOW_SIZE−OVERLAP = 8 KB,
|
|
18
|
+
* so a big payload is scanned in ~2x as many windows — bounded and cheap.
|
|
11
19
|
*/
|
|
12
20
|
|
|
13
21
|
export interface Window {
|
|
@@ -19,7 +27,13 @@ export interface Window {
|
|
|
19
27
|
|
|
20
28
|
export const CHUNK_THRESHOLD = 32 * 1024
|
|
21
29
|
export const WINDOW_SIZE = 16 * 1024
|
|
22
|
-
|
|
30
|
+
/**
|
|
31
|
+
* 8 KB. MUST be ≥ the largest secret we need to catch across a window
|
|
32
|
+
* boundary; a 4096-bit RSA private key in PEM armor is ~3.2 KB, so this
|
|
33
|
+
* clears it with >2x headroom. See the module header for the exact
|
|
34
|
+
* "missed only if secret length > OVERLAP" guarantee.
|
|
35
|
+
*/
|
|
36
|
+
export const OVERLAP = 8 * 1024
|
|
23
37
|
|
|
24
38
|
export function chunk(text: string): Window[] {
|
|
25
39
|
if (text.length <= CHUNK_THRESHOLD) {
|
|
@@ -11,17 +11,23 @@
|
|
|
11
11
|
* PEM blocks, CLI flags)
|
|
12
12
|
* 3. KEY=VALUE heuristic with Shannon-entropy gate (≥ 4.0)
|
|
13
13
|
*
|
|
14
|
-
* Big inputs (>32 KB) are chunked into 16 KB windows with
|
|
15
|
-
* (chunker.ts) for ReDoS bounding; we dedupe by byte-offset after.
|
|
14
|
+
* Big inputs (>32 KB) are chunked into 16 KB windows with 8 KB overlap
|
|
15
|
+
* (chunker.ts) for ReDoS bounding; we dedupe by byte-offset after. The
|
|
16
|
+
* overlap must exceed the largest single secret (an 8192-bit RSA PEM is
|
|
17
|
+
* ~6.5 KB) so a boundary-straddling key is never split across windows.
|
|
16
18
|
*
|
|
17
19
|
* Nearby test/mock/example/fixture/dummy markers (within 40 chars) demote
|
|
18
20
|
* a hit to `suppressed: true`. The caller decides what that means (our
|
|
19
21
|
* convention: suppressed high-confidence → ambiguous, user is asked).
|
|
20
22
|
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
23
|
+
* Detection is vendored-patterns-only and synchronous: every live caller
|
|
24
|
+
* (inbound gate, outbound scrub, `redact.ts`, `pipeline.ts`) runs
|
|
25
|
+
* `detectSecrets`. There is deliberately NO Secretlint/async "safety net"
|
|
26
|
+
* layered underneath — an earlier `detectSecretsAsync` + Secretlint wrapper
|
|
27
|
+
* was never wired into any live path (test-only), so it was a false safety
|
|
28
|
+
* net and has been removed (2026-07 secret-scrub review, tp-support F1).
|
|
29
|
+
* Arming a new async scanner belongs in its own validated change, not the
|
|
30
|
+
* scrub-coverage PR. Gitleaks TOML is loaded via `gitleaks-loader.ts`.
|
|
25
31
|
*/
|
|
26
32
|
import { ALL_PATTERNS } from './patterns.js'
|
|
27
33
|
import { scanKeyValue, type RawHit } from './kv-scanner.js'
|
|
@@ -209,53 +215,3 @@ function dropOverlaps(hits: RawHit[]): RawHit[] {
|
|
|
209
215
|
export { maskToken } from './mask.js'
|
|
210
216
|
export { redactUrls } from './url-redact.js'
|
|
211
217
|
export { deriveSlug } from './slug.js'
|
|
212
|
-
export { detectViaSecretlint } from './secretlint-source.js'
|
|
213
|
-
|
|
214
|
-
/**
|
|
215
|
-
* Async detection pipeline — runs `detectSecrets` (fast vendored engine)
|
|
216
|
-
* and Secretlint in parallel, then merges the results by deduping on
|
|
217
|
-
* `[start, end)` byte ranges. If Secretlint and a vendored pattern both
|
|
218
|
-
* match the same span, the first one wins (vendored, since it's listed
|
|
219
|
-
* first in the merge array below).
|
|
220
|
-
*
|
|
221
|
-
* Slug collisions are re-resolved on the merged list so the overall
|
|
222
|
-
* output has unique `suggested_slug` values.
|
|
223
|
-
*/
|
|
224
|
-
export async function detectSecretsAsync(text: string): Promise<Detection[]> {
|
|
225
|
-
if (!text || text.length === 0) return []
|
|
226
|
-
const [vendored, viaSecretlint] = await Promise.all([
|
|
227
|
-
Promise.resolve(detectSecrets(text)),
|
|
228
|
-
// Lazy-import keeps the sync `detectSecrets` path free of Secretlint
|
|
229
|
-
// initialization cost; paid once on first async call.
|
|
230
|
-
import('./secretlint-source.js').then((m) => m.detectViaSecretlint(text)),
|
|
231
|
-
])
|
|
232
|
-
|
|
233
|
-
// Merge with range-based dedupe. On an exact-range tie, prefer the
|
|
234
|
-
// higher-confidence detection (else vendored-first). This matters since
|
|
235
|
-
// the vendored generic high-entropy fallback emits `ambiguous` — without
|
|
236
|
-
// the confidence tie-break it would shadow a Secretlint `high` provider
|
|
237
|
-
// hit on the same span and silently downgrade it (mirrors the sync
|
|
238
|
-
// dedupeRaw's high-over-ambiguous rule).
|
|
239
|
-
const seen = new Map<string, Detection>()
|
|
240
|
-
const consider = (d: Detection): void => {
|
|
241
|
-
const key = `${d.start}:${d.end}`
|
|
242
|
-
const existing = seen.get(key)
|
|
243
|
-
if (!existing || (existing.confidence === 'ambiguous' && d.confidence === 'high')) {
|
|
244
|
-
seen.set(key, d)
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
for (const d of vendored) consider(d)
|
|
248
|
-
for (const d of viaSecretlint) consider(d)
|
|
249
|
-
|
|
250
|
-
// Re-derive slugs against the merged set (Secretlint and vendored each
|
|
251
|
-
// had independent `existing` sets; we coalesce here).
|
|
252
|
-
const existing = new Set<string>()
|
|
253
|
-
const out: Detection[] = Array.from(seen.values())
|
|
254
|
-
.sort((a, b) => a.start - b.start)
|
|
255
|
-
.map((d) => {
|
|
256
|
-
const slug = deriveSlug({ key_name: d.key_name, rule_id: d.rule_id }, existing)
|
|
257
|
-
existing.add(slug)
|
|
258
|
-
return { ...d, suggested_slug: slug }
|
|
259
|
-
})
|
|
260
|
-
return out
|
|
261
|
-
}
|