switchroom 0.18.7 → 0.18.9
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/README.md +2 -2
- package/dist/cli/switchroom.js +905 -758
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +111 -34
- package/skills/switchroom-runtime/SKILL.md +2 -0
- package/telegram-plugin/dist/gateway/gateway.js +46273 -44324
- package/telegram-plugin/flood-circuit-breaker.ts +123 -0
- package/telegram-plugin/gateway/activity-card-store.ts +63 -18
- package/telegram-plugin/gateway/approval-card-stores.ts +99 -0
- package/telegram-plugin/gateway/boot-card.ts +27 -0
- package/telegram-plugin/gateway/bot-commands-ops-info.ts +194 -0
- package/telegram-plugin/gateway/busy-ack.ts +106 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +2660 -0
- package/telegram-plugin/gateway/gateway.ts +1169 -3043
- package/telegram-plugin/gateway/inbound-delivery-machine-dispatch.ts +181 -23
- package/telegram-plugin/gateway/inbound-delivery-machine.ts +8 -0
- package/telegram-plugin/gateway/mental-model-propose-diff.ts +61 -5
- package/telegram-plugin/gateway/model-command.ts +23 -11
- package/telegram-plugin/gateway/outbound-send-path.ts +375 -0
- package/telegram-plugin/gateway/pending-state-stores.ts +106 -0
- package/telegram-plugin/gateway/register-bot-commands.ts +30 -0
- package/telegram-plugin/gateway/session-model-file.ts +198 -0
- package/telegram-plugin/gateway/status-pin-store.ts +82 -22
- package/telegram-plugin/gateway/worker-pin-reaper.ts +114 -0
- package/telegram-plugin/hooks/hooks.json +10 -10
- package/telegram-plugin/hooks/run-hook.sh +84 -0
- package/telegram-plugin/model-unavailable.ts +26 -0
- package/telegram-plugin/pty-partial-handler.ts +39 -0
- package/telegram-plugin/render/rich-render.ts +79 -1
- package/telegram-plugin/retry-api-call.ts +62 -0
- package/telegram-plugin/shared/bot-runtime.ts +8 -1
- package/telegram-plugin/silence-poke.ts +14 -0
- package/telegram-plugin/stream-controller.ts +156 -38
- package/telegram-plugin/tests/activity-card-store.test.ts +47 -2
- package/telegram-plugin/tests/approval-card-restart-outcome.test.ts +218 -0
- package/telegram-plugin/tests/approval-card-stores.test.ts +124 -0
- package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +111 -0
- package/telegram-plugin/tests/busy-ack-wiring.test.ts +118 -0
- package/telegram-plugin/tests/busy-ack.test.ts +121 -0
- package/telegram-plugin/tests/callback-query-handlers.test.ts +701 -0
- package/telegram-plugin/tests/emission-determinism-wiring.test.ts +11 -4
- package/telegram-plugin/tests/fixtures/cutover-killswitch-probe.ts +75 -0
- package/telegram-plugin/tests/flood-circuit-breaker.test.ts +74 -0
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +5 -1
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +177 -25
- package/telegram-plugin/tests/inbound-delivery-cutover-flip.test.ts +418 -0
- package/telegram-plugin/tests/inbound-delivery-dispatch-equivalence.test.ts +348 -0
- package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +141 -52
- package/telegram-plugin/tests/mental-model-name-entity-corruption.test.ts +119 -0
- package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -1
- package/telegram-plugin/tests/model-command.test.ts +2 -2
- package/telegram-plugin/tests/model-unavailable.test.ts +41 -0
- package/telegram-plugin/tests/outbound-send-chunks.test.ts +304 -0
- package/telegram-plugin/tests/outbound-send-path.test.ts +222 -0
- package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +34 -15
- package/telegram-plugin/tests/pending-state-stores.test.ts +235 -0
- package/telegram-plugin/tests/pty-partial-handler.test.ts +56 -0
- package/telegram-plugin/tests/render/render-outbound-chunks.test.ts +98 -0
- package/telegram-plugin/tests/retry-api-call.test.ts +59 -0
- package/telegram-plugin/tests/run-hook-wrapper.test.ts +132 -0
- package/telegram-plugin/tests/session-model-file.test.ts +132 -0
- package/telegram-plugin/tests/slot-banner-boot-recovery.test.ts +3 -3
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +3 -3
- package/telegram-plugin/tests/status-pin-store.test.ts +62 -6
- package/telegram-plugin/tests/stream-controller-chunk-cap.test.ts +122 -0
- package/telegram-plugin/tests/turn-flush-safety.test.ts +18 -4
- package/telegram-plugin/tests/vault-approval-posture.test.ts +15 -7
- package/telegram-plugin/tests/vault-grant-auto-resume.test.ts +8 -4
- package/telegram-plugin/tests/vault-grant-union.test.ts +8 -4
- package/telegram-plugin/tests/vault-grant-wizard.test.ts +8 -1
- package/telegram-plugin/tests/vault-grants-revoke.test.ts +8 -1
- package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +8 -4
- package/telegram-plugin/tests/vault-request-access-tool.test.ts +8 -4
- package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +8 -4
- package/telegram-plugin/tests/voice-send.test.ts +308 -0
- package/telegram-plugin/tests/worker-pin-reaper.test.ts +132 -0
- package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +118 -0
- package/telegram-plugin/uat/scenarios/jtbd-midflight-busy-ack-dm.test.ts +201 -0
- package/telegram-plugin/uat/scenarios/jtbd-worker-pin-lifecycle-dm.test.ts +208 -0
- package/telegram-plugin/uat/scenarios/vault-card-survives-gateway-restart-dm.test.ts +140 -0
- package/telegram-plugin/uat/scenarios/vault-deny-resumes-turn-dm.test.ts +84 -0
- package/telegram-plugin/uat/scenarios/vault-timeout-wakes-agent-dm.test.ts +91 -0
- package/telegram-plugin/voice-ondemand.ts +25 -1
- package/telegram-plugin/voice-send.ts +154 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram per-bot flood-wait circuit breaker (issue #2923).
|
|
3
|
+
*
|
|
4
|
+
* When a burst of outbound sends trips Telegram's per-bot-token flood limit,
|
|
5
|
+
* the API returns `429 { retry_after: N }` — a SERVER-SIDE ban on the bot
|
|
6
|
+
* token that no client-side lever clears early (observed retry_after ~4116s,
|
|
7
|
+
* ~68 min). The failure is misleading: the container is `Up`, the gateway is
|
|
8
|
+
* polling, inbound works — but every outbound send is rejected. Worse, each
|
|
9
|
+
* `docker restart` posts a fresh boot/config card = another send INTO the
|
|
10
|
+
* open window, which can reset/extend the flood counter. A local, recoverable
|
|
11
|
+
* disk-full problem thereby amplifies into a remote, unrecoverable ban.
|
|
12
|
+
*
|
|
13
|
+
* This breaker persists the flood-wait window to disk so that:
|
|
14
|
+
* - `retryApiCall`'s `onFloodWait` hook records it the moment a 429 is seen;
|
|
15
|
+
* - a restart-time NON-ESSENTIAL send (boot card, config summary) consults
|
|
16
|
+
* `isFloodWaitActive` and SKIPS while the ban is open, so a restart during
|
|
17
|
+
* a flood-wait doesn't feed the counter and prolong the ban.
|
|
18
|
+
*
|
|
19
|
+
* The state file is tiny JSON under the agent's telegram state dir. All the
|
|
20
|
+
* logic is pure + injectable so it unit tests without a real clock or bot.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'
|
|
24
|
+
import { dirname, join } from 'node:path'
|
|
25
|
+
|
|
26
|
+
export interface FloodWaitState {
|
|
27
|
+
/** Epoch ms at which the flood-wait window expires. */
|
|
28
|
+
untilTs: number
|
|
29
|
+
/** The retry_after (seconds) Telegram reported, for diagnostics. */
|
|
30
|
+
retryAfterSec: number
|
|
31
|
+
/** Epoch ms the window was (re)recorded. */
|
|
32
|
+
recordedTs: number
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Default marker filename inside the telegram state dir. */
|
|
36
|
+
export const FLOOD_STATE_FILE = 'flood-wait.json'
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Resolve the flood-wait marker path from a telegram state dir. Kept as a
|
|
40
|
+
* helper so callers share one location.
|
|
41
|
+
*/
|
|
42
|
+
export function floodStatePath(stateDir: string): string {
|
|
43
|
+
return join(stateDir, FLOOD_STATE_FILE)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Compute the flood-wait state for an observed `retry_after`. Extends (never
|
|
48
|
+
* shrinks) an existing window: if a fresh 429 reports a shorter remaining ban
|
|
49
|
+
* than we already recorded, we keep the longer expiry — the server is the
|
|
50
|
+
* authority and being conservative avoids sending back into an open window.
|
|
51
|
+
*/
|
|
52
|
+
export function computeFloodWait(
|
|
53
|
+
prior: FloodWaitState | null,
|
|
54
|
+
retryAfterSec: number,
|
|
55
|
+
now: number,
|
|
56
|
+
): FloodWaitState {
|
|
57
|
+
const candidate = now + Math.max(0, retryAfterSec) * 1000
|
|
58
|
+
const untilTs = prior && prior.untilTs > candidate ? prior.untilTs : candidate
|
|
59
|
+
return { untilTs, retryAfterSec, recordedTs: now }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Remaining ban time in ms (0 when no active window). */
|
|
63
|
+
export function floodWaitRemainingMs(state: FloodWaitState | null, now: number): number {
|
|
64
|
+
if (!state) return 0
|
|
65
|
+
return Math.max(0, state.untilTs - now)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** True while the flood-wait ban is still open. */
|
|
69
|
+
export function isFloodWaitActive(state: FloodWaitState | null, now: number): boolean {
|
|
70
|
+
return floodWaitRemainingMs(state, now) > 0
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Read persisted flood state; null on absence / parse failure. */
|
|
74
|
+
export function readFloodState(path: string): FloodWaitState | null {
|
|
75
|
+
try {
|
|
76
|
+
if (!existsSync(path)) return null
|
|
77
|
+
const raw = JSON.parse(readFileSync(path, 'utf-8')) as Partial<FloodWaitState>
|
|
78
|
+
if (typeof raw.untilTs !== 'number') return null
|
|
79
|
+
return {
|
|
80
|
+
untilTs: raw.untilTs,
|
|
81
|
+
retryAfterSec: typeof raw.retryAfterSec === 'number' ? raw.retryAfterSec : 0,
|
|
82
|
+
recordedTs: typeof raw.recordedTs === 'number' ? raw.recordedTs : 0,
|
|
83
|
+
}
|
|
84
|
+
} catch {
|
|
85
|
+
return null
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Persist flood state (best-effort — a write failure must not crash the send path). */
|
|
90
|
+
export function writeFloodState(path: string, state: FloodWaitState): void {
|
|
91
|
+
try {
|
|
92
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
93
|
+
writeFileSync(path, JSON.stringify(state), { mode: 0o600 })
|
|
94
|
+
} catch {
|
|
95
|
+
/* best-effort */
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Build the `onFloodWait` callback for `createRetryApiCall`, wired to persist
|
|
101
|
+
* (and extend) the window at `path`. Reads current state, merges the new
|
|
102
|
+
* retry_after, writes it back.
|
|
103
|
+
*/
|
|
104
|
+
export function makeFloodWaitRecorder(
|
|
105
|
+
path: string,
|
|
106
|
+
now: () => number = Date.now,
|
|
107
|
+
): (retryAfterSec: number) => void {
|
|
108
|
+
return (retryAfterSec: number) => {
|
|
109
|
+
const t = now()
|
|
110
|
+
const next = computeFloodWait(readFloodState(path), retryAfterSec, t)
|
|
111
|
+
writeFloodState(path, next)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Decide whether a NON-ESSENTIAL restart-time send (boot card, config
|
|
117
|
+
* summary) should be suppressed because a flood-wait is active. Returns the
|
|
118
|
+
* remaining ms when suppressed (>0), or 0 to proceed. Reads state fresh so a
|
|
119
|
+
* concurrently-updated window is honoured.
|
|
120
|
+
*/
|
|
121
|
+
export function suppressNonEssentialSendMs(path: string, now: number): number {
|
|
122
|
+
return floodWaitRemainingMs(readFloodState(path), now)
|
|
123
|
+
}
|
|
@@ -43,6 +43,11 @@
|
|
|
43
43
|
* failure than one frozen orphan. A benign-400 (message already
|
|
44
44
|
* deleted/vanished, or "not modified") is NOT counted as a finalize — nothing
|
|
45
45
|
* was delivered — see the `vanished` tally in `runActivityCardBootReaper`.
|
|
46
|
+
*
|
|
47
|
+
* The UNPIN half is different (#3001): unpinning is idempotent, so it gets
|
|
48
|
+
* AT-LEAST-ONCE (capped) semantics — a failed unpin re-persists the record
|
|
49
|
+
* with `finalizeAttempted: true` + an `unpinAttempts` counter so the next boot
|
|
50
|
+
* retries ONLY the unpin (never the edit), up to BOOT_UNPIN_MAX_ATTEMPTS.
|
|
46
51
|
*/
|
|
47
52
|
|
|
48
53
|
export interface ActivityCardStoreFsSeam {
|
|
@@ -73,8 +78,21 @@ export interface ActivityCardRecord {
|
|
|
73
78
|
* forever). Optional so a v1-shape record (pre-unpin-tracking) still
|
|
74
79
|
* loads and degrades to "don't attempt an unpin", never a crash. */
|
|
75
80
|
pinned?: boolean
|
|
81
|
+
/** Set when the boot reaper retains a record ONLY to retry its failed
|
|
82
|
+
* unpin (#3001). The finalizing edit stays AT-MOST-ONCE: a retained
|
|
83
|
+
* record's edit was already attempted, so the next boot skips the edit
|
|
84
|
+
* and only retries the (idempotent) unpin. */
|
|
85
|
+
finalizeAttempted?: boolean
|
|
86
|
+
/** Boot-reaper unpin retry counter (#3001); the record is forfeited at
|
|
87
|
+
* BOOT_UNPIN_MAX_ATTEMPTS. Absent = 0. */
|
|
88
|
+
unpinAttempts?: number
|
|
76
89
|
}
|
|
77
90
|
|
|
91
|
+
/** Cap on cross-boot unpin retries — same rationale as the status-pin
|
|
92
|
+
* store's BOOT_UNPIN_MAX_ATTEMPTS (bound a permanently-undeliverable
|
|
93
|
+
* unpin; unpins themselves are idempotent so retrying is safe). */
|
|
94
|
+
export const BOOT_UNPIN_MAX_ATTEMPTS = 5
|
|
95
|
+
|
|
78
96
|
interface SnapshotEnvelope {
|
|
79
97
|
v: 1
|
|
80
98
|
cards: ActivityCardRecord[]
|
|
@@ -91,7 +109,9 @@ function isCardRow(x: unknown): x is ActivityCardRecord {
|
|
|
91
109
|
(o.threadId === null || typeof o.threadId === 'number') &&
|
|
92
110
|
typeof o.activityMessageId === 'number' &&
|
|
93
111
|
typeof o.startedAt === 'number' &&
|
|
94
|
-
(o.pinned === undefined || typeof o.pinned === 'boolean')
|
|
112
|
+
(o.pinned === undefined || typeof o.pinned === 'boolean') &&
|
|
113
|
+
(o.finalizeAttempted === undefined || typeof o.finalizeAttempted === 'boolean') &&
|
|
114
|
+
(o.unpinAttempts === undefined || typeof o.unpinAttempts === 'number')
|
|
95
115
|
)
|
|
96
116
|
}
|
|
97
117
|
|
|
@@ -245,32 +265,57 @@ export async function runActivityCardBootReaper(args: {
|
|
|
245
265
|
// turn that upserted a fresh card under the same turnKey mid-reap keeps
|
|
246
266
|
// its own (different-id) record.
|
|
247
267
|
clearActivityCardRecord(args.path, args.fs, record.turnKey, record.activityMessageId, log)
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
268
|
+
// The finalizing EDIT stays at-most-once: skip it for a record retained by
|
|
269
|
+
// a prior boot purely to retry its failed unpin (finalizeAttempted).
|
|
270
|
+
if (!record.finalizeAttempted) {
|
|
271
|
+
try {
|
|
272
|
+
// Count a finalize ONLY when the edit actually landed. robustApiCall
|
|
273
|
+
// resolves to undefined on a benign-400 (the card was already deleted or
|
|
274
|
+
// the chat is gone) — that delivered nothing, so it is a `vanished`
|
|
275
|
+
// orphan, not a `finalized` one. Counting it as finalized (the pre-fix
|
|
276
|
+
// behaviour) over-reported the guarantee in the boot log.
|
|
277
|
+
const res = await args.finalizeCard(record)
|
|
278
|
+
if (res != null) finalized++
|
|
279
|
+
else vanished++
|
|
280
|
+
} catch (err) {
|
|
281
|
+
log(
|
|
282
|
+
`activity-card-store: boot reaper finalize failed ` +
|
|
283
|
+
`(chat=${record.chatId} msg=${record.activityMessageId}): ` +
|
|
284
|
+
`${(err as Error).message}\n`,
|
|
285
|
+
)
|
|
286
|
+
}
|
|
263
287
|
}
|
|
264
288
|
if (record.pinned) {
|
|
265
289
|
try {
|
|
266
290
|
await args.unpinCard(record)
|
|
267
291
|
unpinned++
|
|
268
292
|
} catch (err) {
|
|
293
|
+
// Retry-safe unpin (#3001): unlike the edit (at-most-once by design),
|
|
294
|
+
// an unpin is idempotent — so a failed one is RE-PERSISTED with an
|
|
295
|
+
// attempt counter and retried on the next boot, up to the cap, instead
|
|
296
|
+
// of being forfeited. `finalizeAttempted` guarantees the retained
|
|
297
|
+
// record can never re-run its edit.
|
|
298
|
+
const attempts = (record.unpinAttempts ?? 0) + 1
|
|
269
299
|
log(
|
|
270
300
|
`activity-card-store: boot reaper unpin failed ` +
|
|
271
|
-
`(chat=${record.chatId} msg=${record.activityMessageId}
|
|
272
|
-
|
|
301
|
+
`(chat=${record.chatId} msg=${record.activityMessageId} ` +
|
|
302
|
+
`attempt=${attempts}): ${(err as Error).message}\n`,
|
|
273
303
|
)
|
|
304
|
+
if (attempts < BOOT_UNPIN_MAX_ATTEMPTS) {
|
|
305
|
+
writeActivityCardRecord(
|
|
306
|
+
args.path,
|
|
307
|
+
args.fs,
|
|
308
|
+
{ ...record, finalizeAttempted: true, unpinAttempts: attempts },
|
|
309
|
+
log,
|
|
310
|
+
)
|
|
311
|
+
} else {
|
|
312
|
+
log(
|
|
313
|
+
`activity-card-store: boot reaper FORFEITING card unpin after ` +
|
|
314
|
+
`${attempts} failed attempts ` +
|
|
315
|
+
`(chat=${record.chatId} msg=${record.activityMessageId}) — ` +
|
|
316
|
+
`will not retry again\n`,
|
|
317
|
+
)
|
|
318
|
+
}
|
|
274
319
|
}
|
|
275
320
|
}
|
|
276
321
|
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory storage for the four AGENT-INITIATED approval-card families
|
|
3
|
+
* (vault_request_access / vault_request_save / request_secret /
|
|
4
|
+
* mental_model_propose), extracted from gateway.ts (Phase 3 step 1 of the
|
|
5
|
+
* gateway decomposition — see #2996). STORAGE ONLY: the per-card expiry LOGIC
|
|
6
|
+
* (card edits, timeout synthetics, missed-approval re-offers) still lives in
|
|
7
|
+
* gateway.ts and is injected here as `expire`. This module owns the Map
|
|
8
|
+
* lifecycle and CO-LOCATES the TTL sweep next to the storage it sweeps, so the
|
|
9
|
+
* gateway's authoritative `pendingStateReaper` calls a single `store.sweep(now)`
|
|
10
|
+
* delegation instead of open-coding a `sweepExpiredEntries(...)` per family.
|
|
11
|
+
*
|
|
12
|
+
* Why a Map-compatible surface (get/set/delete/has/iteration) rather than a
|
|
13
|
+
* bespoke API: every existing gateway call site — the callback-query handlers,
|
|
14
|
+
* the executor add-sites, and `restorePendingApprovalCards` — used the raw Map
|
|
15
|
+
* directly. Preserving the exact Map method surface keeps those call sites
|
|
16
|
+
* BYTE-IDENTICAL (the variable name and every `.get`/`.set`/`.delete`/`for..of`
|
|
17
|
+
* are unchanged), which is the invariant this phase must not break: the store
|
|
18
|
+
* moves WHERE the Map is constructed and WHERE its sweep lives, nothing about
|
|
19
|
+
* how the gateway reads or writes it. Restore semantics are therefore untouched.
|
|
20
|
+
*
|
|
21
|
+
* Race contract preserved (pinned by approval-card-stores.test.ts):
|
|
22
|
+
* - A verdict (operator tap → `.delete(stageId)`) arriving DURING an expiry
|
|
23
|
+
* sweep is single-shot-safe: the pure `expirePendingCard` core removes the
|
|
24
|
+
* entry before any fallible side effect, so the same card can never both
|
|
25
|
+
* expire and resolve. The store's `sweep` is a thin pass-through to that
|
|
26
|
+
* core via `sweepExpiredEntries`, so the ordering guarantee is unchanged.
|
|
27
|
+
* - A verdict arriving DURING restore is a plain `.set`/`.delete` on the same
|
|
28
|
+
* Map instance the restore populates — no separate storage to desync.
|
|
29
|
+
* - The shared reaper cadence is unchanged: `sweep(now)` performs exactly the
|
|
30
|
+
* work the old `sweepPendingX(now)` free functions did, in the same order.
|
|
31
|
+
*
|
|
32
|
+
* `expire` and `log` are supplied as THUNKS so a store constructed early in
|
|
33
|
+
* gateway module-eval can reference the hoisted `expire*Card` functions and the
|
|
34
|
+
* `const cardExpiryLog` arrow (which is in the temporal dead zone at
|
|
35
|
+
* construction time); both are resolved lazily at sweep time.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { sweepExpiredEntries } from './pending-card-expiry.js'
|
|
39
|
+
|
|
40
|
+
export interface SweepableCardStore<T> {
|
|
41
|
+
get(stageId: string): T | undefined
|
|
42
|
+
set(stageId: string, value: T): void
|
|
43
|
+
delete(stageId: string): boolean
|
|
44
|
+
has(stageId: string): boolean
|
|
45
|
+
clear(): void
|
|
46
|
+
readonly size: number
|
|
47
|
+
keys(): IterableIterator<string>
|
|
48
|
+
values(): IterableIterator<T>
|
|
49
|
+
entries(): IterableIterator<[string, T]>
|
|
50
|
+
forEach(cb: (value: T, key: string, map: Map<string, T>) => void): void
|
|
51
|
+
[Symbol.iterator](): IterableIterator<[string, T]>
|
|
52
|
+
/**
|
|
53
|
+
* Expire every entry past its TTL. Delegates to the same pure
|
|
54
|
+
* `sweepExpiredEntries` core the gateway used inline, so per-entry
|
|
55
|
+
* fault-isolation and single-shot ordering are byte-identical.
|
|
56
|
+
*/
|
|
57
|
+
sweep(now: number): void
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface SweepableCardStoreDeps<T> {
|
|
61
|
+
/** Predicate: is this entry past its TTL at `now`? Closes over the family TTL. */
|
|
62
|
+
isExpired: (value: T, now: number) => boolean
|
|
63
|
+
/**
|
|
64
|
+
* Per-entry expiry action (card strip + timeout synthetic + missed-approval
|
|
65
|
+
* re-offer). A THUNK so it can reference gateway functions that are hoisted /
|
|
66
|
+
* defined after this store is constructed. Called once per expired entry.
|
|
67
|
+
*/
|
|
68
|
+
expire: () => (stageId: string, value: T, now: number) => void
|
|
69
|
+
/** Error sink thunk (resolves the gateway's `cardExpiryLog` lazily). */
|
|
70
|
+
log: () => (msg: string) => void
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Create a Map-backed, self-sweeping store for one approval-card family.
|
|
75
|
+
* The backing Map is the sole storage; the returned object forwards the Map
|
|
76
|
+
* surface the gateway uses plus a co-located `sweep`.
|
|
77
|
+
*/
|
|
78
|
+
export function createSweepableCardStore<T>(
|
|
79
|
+
deps: SweepableCardStoreDeps<T>,
|
|
80
|
+
): SweepableCardStore<T> {
|
|
81
|
+
const map = new Map<string, T>()
|
|
82
|
+
return {
|
|
83
|
+
get: (stageId) => map.get(stageId),
|
|
84
|
+
set: (stageId, value) => void map.set(stageId, value),
|
|
85
|
+
delete: (stageId) => map.delete(stageId),
|
|
86
|
+
has: (stageId) => map.has(stageId),
|
|
87
|
+
clear: () => map.clear(),
|
|
88
|
+
get size() {
|
|
89
|
+
return map.size
|
|
90
|
+
},
|
|
91
|
+
keys: () => map.keys(),
|
|
92
|
+
values: () => map.values(),
|
|
93
|
+
entries: () => map.entries(),
|
|
94
|
+
forEach: (cb) => map.forEach(cb),
|
|
95
|
+
[Symbol.iterator]: () => map[Symbol.iterator](),
|
|
96
|
+
sweep: (now) =>
|
|
97
|
+
sweepExpiredEntries(map, deps.isExpired, deps.expire(), now, deps.log()),
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -69,6 +69,7 @@ import {
|
|
|
69
69
|
} from './config-snapshot.js'
|
|
70
70
|
import { join } from 'path'
|
|
71
71
|
import { bootCardChatKey, loadBootCardMsgId, saveBootCardMsgId } from './boot-card-msgid.js'
|
|
72
|
+
import { suppressNonEssentialSendMs } from '../flood-circuit-breaker.js'
|
|
72
73
|
import { loadConfig as _loadSwitchroomConfig } from '../../src/config/loader.js'
|
|
73
74
|
import { resolveAgentConfig as _resolveAgentConfig } from '../../src/config/merge.js'
|
|
74
75
|
|
|
@@ -605,6 +606,16 @@ export interface RunProbesOpts {
|
|
|
605
606
|
* behaviour.
|
|
606
607
|
*/
|
|
607
608
|
bootCardStatePath?: string
|
|
609
|
+
/**
|
|
610
|
+
* #2923 flood-wait circuit breaker. Path to the persisted flood-wait
|
|
611
|
+
* marker. When a Telegram per-bot flood ban is active, posting a boot card
|
|
612
|
+
* on restart is a NON-ESSENTIAL send straight into the open window that can
|
|
613
|
+
* reset/extend the ban — so if this path shows an active flood-wait, the
|
|
614
|
+
* boot card is SUPPRESSED (logged, not sent). Omit to always post.
|
|
615
|
+
*/
|
|
616
|
+
floodStatePath?: string
|
|
617
|
+
/** Injectable clock for the flood-wait check (tests). Defaults to Date.now. */
|
|
618
|
+
nowMs?: () => number
|
|
608
619
|
}
|
|
609
620
|
|
|
610
621
|
/** Run all six probes concurrently with their own per-probe timeouts.
|
|
@@ -652,6 +663,22 @@ export async function startBootCard(
|
|
|
652
663
|
const setTimeoutFn = opts.setTimeoutImpl ?? setTimeout
|
|
653
664
|
const settleMs = opts.settleWindowMs ?? SETTLE_WINDOW_MS
|
|
654
665
|
|
|
666
|
+
// #2923 circuit breaker: if a per-bot Telegram flood ban is active, do NOT
|
|
667
|
+
// post the boot card. It's a non-essential restart-time send straight into
|
|
668
|
+
// the open flood window — the exact thing that resets/extends the ban and
|
|
669
|
+
// keeps the agent mute for longer. Skip loudly (log) and return a no-op.
|
|
670
|
+
if (opts.floodStatePath != null) {
|
|
671
|
+
const now = (opts.nowMs ?? Date.now)()
|
|
672
|
+
const remainingMs = suppressNonEssentialSendMs(opts.floodStatePath, now)
|
|
673
|
+
if (remainingMs > 0) {
|
|
674
|
+
logger(
|
|
675
|
+
`telegram gateway: boot-card: SUPPRESSED — Telegram flood-wait active for ~${Math.round(remainingMs / 1000)}s; ` +
|
|
676
|
+
`not posting a restart card into the open ban window (issue #2923)\n`,
|
|
677
|
+
)
|
|
678
|
+
return { messageId: -1, complete: () => {} }
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
655
682
|
// Render and post the bare ack line immediately. The user gets
|
|
656
683
|
// confirmation that the agent is back without waiting on probes.
|
|
657
684
|
const ackText = renderBootCard({
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import type { Bot, Context, InlineKeyboard } from 'grammy'
|
|
2
|
+
import { hostdWillBeUsed } from './hostd-dispatch.js'
|
|
3
|
+
import { maskVaultKey } from '../demo-mask.js'
|
|
4
|
+
import { switchroomHelpText as buildSwitchroomHelpText } from '../welcome-text.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Read-only ops/info slash commands extracted verbatim from gateway.ts
|
|
8
|
+
* (#2996 Phase 5 leaf move): `/doctor`, `/grant`, `/dangerous`,
|
|
9
|
+
* `/permissions`, `/version`, `/whoami`, `/commands`.
|
|
10
|
+
*
|
|
11
|
+
* These are pure info/ops surfaces (no send-path or turn-lifecycle
|
|
12
|
+
* coupling). Every gateway-local helper they close over is passed in via
|
|
13
|
+
* `deps` (repo factory-deps precedent) so the module never back-references
|
|
14
|
+
* the gateway singleton or reads `currentTurn`. `bot` is injected so
|
|
15
|
+
* grammy registration order is preserved at the original call site.
|
|
16
|
+
*/
|
|
17
|
+
export interface OpsInfoCommandDeps {
|
|
18
|
+
AGENT_ADMIN: boolean
|
|
19
|
+
isAuthorizedSender: (ctx: Context) => boolean
|
|
20
|
+
getMyAgentName: () => string
|
|
21
|
+
switchroomReply: (
|
|
22
|
+
ctx: Context,
|
|
23
|
+
text: string,
|
|
24
|
+
options?: {
|
|
25
|
+
html?: boolean
|
|
26
|
+
reply_markup?: InlineKeyboard
|
|
27
|
+
classification?: 'query' | 'mutation' | 'heavy'
|
|
28
|
+
},
|
|
29
|
+
) => Promise<void>
|
|
30
|
+
buildDoctorScopeKeyboard: () => InlineKeyboard
|
|
31
|
+
renderSelfDoctor: (ctx: Context) => Promise<void>
|
|
32
|
+
preBlock: (text: string) => string
|
|
33
|
+
formatSwitchroomOutput: (output: string, maxLen?: number) => string
|
|
34
|
+
getCommandArgs: (ctx: Context) => string
|
|
35
|
+
assertSafeAgentName: (name: string) => void
|
|
36
|
+
runSwitchroomCommand: (
|
|
37
|
+
ctx: Context,
|
|
38
|
+
args: string[],
|
|
39
|
+
label: string,
|
|
40
|
+
classification?: 'query' | 'mutation' | 'heavy',
|
|
41
|
+
) => Promise<void>
|
|
42
|
+
switchroomExecCombined: (args: string[], timeoutMs?: number) => string
|
|
43
|
+
stripAnsi: (text: string) => string
|
|
44
|
+
hasDemoFlag: (args: string) => boolean
|
|
45
|
+
escapeHtmlForTg: (text: string) => string
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function registerOpsInfoCommands(bot: Bot, deps: OpsInfoCommandDeps): void {
|
|
49
|
+
const {
|
|
50
|
+
AGENT_ADMIN,
|
|
51
|
+
isAuthorizedSender,
|
|
52
|
+
getMyAgentName,
|
|
53
|
+
switchroomReply,
|
|
54
|
+
buildDoctorScopeKeyboard,
|
|
55
|
+
renderSelfDoctor,
|
|
56
|
+
preBlock,
|
|
57
|
+
formatSwitchroomOutput,
|
|
58
|
+
getCommandArgs,
|
|
59
|
+
assertSafeAgentName,
|
|
60
|
+
runSwitchroomCommand,
|
|
61
|
+
switchroomExecCombined,
|
|
62
|
+
stripAnsi,
|
|
63
|
+
hasDemoFlag,
|
|
64
|
+
escapeHtmlForTg,
|
|
65
|
+
} = deps
|
|
66
|
+
|
|
67
|
+
/** Compact HTML card from the `config whoami` JSON view. Names/booleans only.
|
|
68
|
+
* `demo` (the `/whoami demo` suffix) masks the vault key NAMES via maskVaultKey
|
|
69
|
+
* for screen recordings — agent/MCP/model/skills topology is left untouched
|
|
70
|
+
* (out of scope). Off by default. */
|
|
71
|
+
function formatWhoamiCard(v: {
|
|
72
|
+
name?: string; persona?: string | null; model?: string | null; tier?: string;
|
|
73
|
+
tools?: { allow?: string[]; deny?: string[] }; mcpServers?: string[]; skills?: string[];
|
|
74
|
+
vault?: { key: string; readable: boolean }[];
|
|
75
|
+
powers?: { admin?: boolean; root?: boolean; configEdit?: boolean; crossAgentHostVerbs?: boolean };
|
|
76
|
+
scheduleCount?: number; memoryBackend?: string | null;
|
|
77
|
+
}, demo = false): string {
|
|
78
|
+
const esc = escapeHtmlForTg
|
|
79
|
+
const yn = (b?: boolean) => (b ? '✓' : '✗')
|
|
80
|
+
const lines: string[] = []
|
|
81
|
+
lines.push(`👤 **${esc(v.name ?? '?')}** · ${esc(v.tier ?? 'standard')}`)
|
|
82
|
+
if (v.persona) lines.push(esc(v.persona))
|
|
83
|
+
if (v.model) lines.push(`Model: ${esc(v.model)}`)
|
|
84
|
+
const allow = v.tools?.allow ?? []
|
|
85
|
+
lines.push(`Tools: ${allow.length ? esc(allow.slice(0, 8).join(', ')) + (allow.length > 8 ? ` …(+${allow.length - 8})` : '') : '—'}`)
|
|
86
|
+
if ((v.tools?.deny ?? []).length) lines.push(`Denied: ${esc((v.tools!.deny!).join(', '))}`)
|
|
87
|
+
if ((v.mcpServers ?? []).length) lines.push(`MCP: ${esc(v.mcpServers!.join(', '))}`)
|
|
88
|
+
if ((v.skills ?? []).length) lines.push(`Skills: ${esc(v.skills!.join(', '))}`)
|
|
89
|
+
if ((v.vault ?? []).length) {
|
|
90
|
+
lines.push(`Vault keys (names only): ${v.vault!.map(k => `${esc(demo ? maskVaultKey(k.key) : k.key)} ${yn(k.readable)}`).join(', ')}`)
|
|
91
|
+
}
|
|
92
|
+
const p = v.powers ?? {}
|
|
93
|
+
lines.push(`Powers: admin ${yn(p.admin)} · root ${yn(p.root)} · config-edit ${yn(p.configEdit)} · cross-agent verbs ${yn(p.crossAgentHostVerbs)}`)
|
|
94
|
+
lines.push(`Schedule: ${v.scheduleCount ?? 0} cron · Memory: ${esc(v.memoryBackend ?? 'none')}`)
|
|
95
|
+
return lines.join('\n')
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
bot.command('doctor', async ctx => {
|
|
99
|
+
if (!isAuthorizedSender(ctx)) return
|
|
100
|
+
try {
|
|
101
|
+
// Admin agents with hostd reachable choose scope (one tap, no
|
|
102
|
+
// approval card — doctor is read-only). Everyone else keeps the
|
|
103
|
+
// original zero-extra-tap in-container behaviour.
|
|
104
|
+
if (AGENT_ADMIN && hostdWillBeUsed(getMyAgentName())) {
|
|
105
|
+
await switchroomReply(ctx, '🩺 **Doctor** — which scope?', {
|
|
106
|
+
html: true,
|
|
107
|
+
reply_markup: buildDoctorScopeKeyboard(),
|
|
108
|
+
})
|
|
109
|
+
return
|
|
110
|
+
}
|
|
111
|
+
await renderSelfDoctor(ctx)
|
|
112
|
+
} catch (err: unknown) {
|
|
113
|
+
await switchroomReply(ctx, `**doctor failed:**\n${preBlock(formatSwitchroomOutput((err as any).message ?? 'unknown error'))}`, { html: true })
|
|
114
|
+
}
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
bot.command('grant', async ctx => {
|
|
118
|
+
if (!isAuthorizedSender(ctx)) return
|
|
119
|
+
const parts = getCommandArgs(ctx).split(/\s+/).filter(Boolean)
|
|
120
|
+
if (parts.length === 0) { await switchroomReply(ctx, 'Usage: /grant <tool> or /grant <agent> <tool>'); return }
|
|
121
|
+
let agentName: string; let tool: string
|
|
122
|
+
if (parts.length === 1) { agentName = getMyAgentName(); tool = parts[0] }
|
|
123
|
+
else { agentName = parts[0]; tool = parts.slice(1).join(' ') }
|
|
124
|
+
try { assertSafeAgentName(agentName) } catch { await switchroomReply(ctx, 'Invalid agent name.'); return }
|
|
125
|
+
await runSwitchroomCommand(ctx, ['agent', 'grant', agentName, tool], `grant ${agentName} ${tool}`)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
bot.command('dangerous', async ctx => {
|
|
129
|
+
if (!isAuthorizedSender(ctx)) return
|
|
130
|
+
const parts = getCommandArgs(ctx).split(/\s+/).filter(Boolean)
|
|
131
|
+
let agentName: string; let off = false
|
|
132
|
+
if (parts.length === 0) { agentName = getMyAgentName() }
|
|
133
|
+
else if (parts.length === 1 && parts[0] === 'off') { agentName = getMyAgentName(); off = true }
|
|
134
|
+
else { agentName = parts[0]; if (parts[1] === 'off') off = true }
|
|
135
|
+
try { assertSafeAgentName(agentName) } catch { await switchroomReply(ctx, 'Invalid agent name.'); return }
|
|
136
|
+
const args = ['agent', 'dangerous', agentName]; if (off) args.push('--off')
|
|
137
|
+
await runSwitchroomCommand(ctx, args, `dangerous ${agentName}${off ? ' off' : ''}`)
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
bot.command('permissions', async ctx => {
|
|
141
|
+
if (!isAuthorizedSender(ctx)) return
|
|
142
|
+
const agentName = (typeof ctx.match === "string" ? ctx.match : "").trim() || getMyAgentName()
|
|
143
|
+
try { assertSafeAgentName(agentName) } catch { await switchroomReply(ctx, 'Invalid agent name.'); return }
|
|
144
|
+
await runSwitchroomCommand(ctx, ['agent', 'permissions', agentName], `permissions ${agentName}`)
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
// Drive-by cleanup (#927): the dead /update handler that lived here
|
|
148
|
+
// was a pre-#919 stub. Grammy registers in order so the comprehensive
|
|
149
|
+
// /update handler at line ~6516 (added in #919, hardened in #924,
|
|
150
|
+
// docker-guarded in #934) fired first and this one never ran.
|
|
151
|
+
// Removed to avoid future confusion.
|
|
152
|
+
|
|
153
|
+
bot.command('version', async ctx => {
|
|
154
|
+
if (!isAuthorizedSender(ctx)) return
|
|
155
|
+
try {
|
|
156
|
+
let output: string
|
|
157
|
+
try { output = switchroomExecCombined(['version'], 10000) }
|
|
158
|
+
catch (err: unknown) { output = (err as any).stdout ?? (err as any).message ?? 'version failed' }
|
|
159
|
+
const trimmed = stripAnsi(output).trim()
|
|
160
|
+
if (!trimmed) { await switchroomReply(ctx, 'version: no output'); return }
|
|
161
|
+
await switchroomReply(ctx, preBlock(formatSwitchroomOutput(trimmed)), { html: true })
|
|
162
|
+
} catch (err: unknown) {
|
|
163
|
+
await switchroomReply(ctx, `**version failed:**\n${preBlock(formatSwitchroomOutput((err as any).message ?? 'unknown error'))}`, { html: true })
|
|
164
|
+
}
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
// /whoami — the operator's view of THIS agent's sandbox (the same
|
|
169
|
+
// `config whoami` the agent itself can call as an MCP tool, and the host CLI
|
|
170
|
+
// exposes). Read-only, isAuthorizedSender-gated like /version — surfaces
|
|
171
|
+
// tools / MCP / vault key-NAMES (never values) / powers so the operator can
|
|
172
|
+
// see at a glance what this agent is authorized for.
|
|
173
|
+
bot.command('whoami', async ctx => {
|
|
174
|
+
if (!isAuthorizedSender(ctx)) return
|
|
175
|
+
const demo = hasDemoFlag(getCommandArgs(ctx))
|
|
176
|
+
try {
|
|
177
|
+
let raw: string
|
|
178
|
+
try { raw = switchroomExecCombined(['config', 'whoami'], 10000) }
|
|
179
|
+
catch (err: unknown) { raw = (err as any).stdout ?? (err as any).message ?? 'whoami failed' }
|
|
180
|
+
const trimmed = stripAnsi(raw).trim()
|
|
181
|
+
let card: string
|
|
182
|
+
try { card = formatWhoamiCard(JSON.parse(trimmed.split('\n').pop() ?? trimmed), demo) }
|
|
183
|
+
catch { card = preBlock(formatSwitchroomOutput(trimmed || 'whoami: no output')) }
|
|
184
|
+
await switchroomReply(ctx, card, { html: true })
|
|
185
|
+
} catch (err: unknown) {
|
|
186
|
+
await switchroomReply(ctx, `**whoami failed:**\n${preBlock(formatSwitchroomOutput((err as any).message ?? 'unknown error'))}`, { html: true })
|
|
187
|
+
}
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
bot.command('commands', async ctx => {
|
|
191
|
+
if (!isAuthorizedSender(ctx)) return
|
|
192
|
+
await switchroomReply(ctx, buildSwitchroomHelpText(getMyAgentName()), { html: true })
|
|
193
|
+
})
|
|
194
|
+
}
|