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
|
@@ -0,0 +1,602 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #3084 follow-up — HOLD an undeliverable approval; never convert it into a
|
|
3
|
+
* silent denial.
|
|
4
|
+
*
|
|
5
|
+
* The incident (overlord, 2026-07-11): Telegram flood-banned the bot token for
|
|
6
|
+
* 4.6h. A Claude Code permission prompt fired during the ban. The gateway built
|
|
7
|
+
* the card, the send failed, and the error was written to stderr and dropped on
|
|
8
|
+
* the floor. Three things then went wrong at once:
|
|
9
|
+
*
|
|
10
|
+
* 1. `pendingPermissions` kept the entry with `cards: []` — no card had
|
|
11
|
+
* landed, so there was nothing for the operator to tap and no record that
|
|
12
|
+
* anything was waiting.
|
|
13
|
+
* 2. 60 minutes later the TTL sweep fired and AUTO-DENIED the request,
|
|
14
|
+
* telling the agent the operator had declined. No human ever saw the ask.
|
|
15
|
+
* 3. The #2862 missed-approval re-offer is anchored on `v.cards[0]` — the
|
|
16
|
+
* card's landed origin. With `cards: []` that is `undefined`, so the
|
|
17
|
+
* re-offer was skipped. The request was auto-denied AND erased from the
|
|
18
|
+
* only record that would have brought it back.
|
|
19
|
+
*
|
|
20
|
+
* Net: an undeliverable approval became a silent denial and then vanished. That
|
|
21
|
+
* is a `no-self-escalation` / `on-leash` breach — the leash decided for the
|
|
22
|
+
* operator, in their absence, and told the agent a human had spoken.
|
|
23
|
+
*
|
|
24
|
+
* The operator's call (Ken, explicit): **hold, and make the block visible.
|
|
25
|
+
* Never auto-approve. Never auto-deny — not even on a deadline.** The agent
|
|
26
|
+
* stays blocked, the ask is held, and it is re-delivered when the channel comes
|
|
27
|
+
* back. An indefinite block is the correct behaviour; a fabricated verdict is
|
|
28
|
+
* not.
|
|
29
|
+
*
|
|
30
|
+
* This module owns the three decisions that implement that, kept out of
|
|
31
|
+
* gateway.ts so they are unit-testable (gateway.ts has top-level side effects
|
|
32
|
+
* and is not importable from a test — see `tests/permission-card-routing.ts`):
|
|
33
|
+
*
|
|
34
|
+
* - `createBlockedApprovalStore` — the durable, world-readable record of what
|
|
35
|
+
* is blocked and why (PR 1).
|
|
36
|
+
* - `selectHeldForRedelivery` — which held cards to re-post when the flood
|
|
37
|
+
* window closes, capped so a backlog can't burst (PR 2).
|
|
38
|
+
* - `isHeldUndeliverable` — the TTL freeze predicate (PR 3).
|
|
39
|
+
*
|
|
40
|
+
* @see reference/jobs/approve-what-my-agent-can-touch.md
|
|
41
|
+
* @see reference/invariants.md § no-self-escalation, § on-leash
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
import { mkdirSync, writeFileSync, readFileSync, unlinkSync, chmodSync, lstatSync } from 'node:fs'
|
|
45
|
+
import { join, dirname } from 'node:path'
|
|
46
|
+
import { isFloodWaitActiveError } from '../retry-api-call.js'
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* File mode 0644 — WORLD-READABLE, and that is load-bearing, not incidental.
|
|
50
|
+
*
|
|
51
|
+
* `switchroom-web` runs as uid 1000 and cannot read the agent's telegram state
|
|
52
|
+
* files, which are 0600 (`permission-card-store.ts:63` writes
|
|
53
|
+
* `pending-perm-cards.json` at 0o600 — verified unreadable on the live box:
|
|
54
|
+
* `docker exec switchroom-web cat …/pending-perm-cards.json` → Permission
|
|
55
|
+
* denied). A 0600 blocked-approval record renders a silently EMPTY dashboard —
|
|
56
|
+
* the exact shape of the Hermes `registry.db` bug. `fleet-health/ledger.json`
|
|
57
|
+
* is world-readable for the same reason; this follows that precedent.
|
|
58
|
+
*
|
|
59
|
+
* The safety consequence of world-readable is absolute and enforced by
|
|
60
|
+
* `assertNoRawInput` below: this file carries METADATA ONLY. No `inputPreview`,
|
|
61
|
+
* no raw tool input, no `card_text`, ever. A raw credential surviving in a
|
|
62
|
+
* world-readable file is the failure this job spec names by name.
|
|
63
|
+
*/
|
|
64
|
+
export const BLOCKED_APPROVAL_FILE_MODE = 0o644
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The SHARED dir is sticky-world-writable (1777), the /tmp model — and it has
|
|
68
|
+
* to be, or the feature is a silent no-op in production.
|
|
69
|
+
*
|
|
70
|
+
* Agents run as per-agent non-root uids (`AGENT_UID_MIN = 10001`,
|
|
71
|
+
* `compose.ts:95`). A shared dir owned by the operator (or auto-created
|
|
72
|
+
* `root:root` by Docker, which is what happens to any bind source that doesn't
|
|
73
|
+
* exist — `compose.ts` calls this out by name: "docker auto-creates as root,
|
|
74
|
+
* which then traps the agent uid out of writing") is NOT writable by uid 10001.
|
|
75
|
+
* The write would EACCES into the best-effort catch below and the record would
|
|
76
|
+
* never appear. The one surface telling the operator an agent is blocked would
|
|
77
|
+
* be silently empty — the Hermes `registry.db` bug, on the write side.
|
|
78
|
+
*
|
|
79
|
+
* Sticky (`0o1000`) is what makes world-writable safe: each agent creates and
|
|
80
|
+
* owns its own 0644 `<agent>.json`, so no agent can MODIFY another's record,
|
|
81
|
+
* and the sticky bit means no agent can DELETE another's either. Same guarantee
|
|
82
|
+
* /tmp gives. Cross-agent forgery of a *new* record is out of threat model by
|
|
83
|
+
* the `single-tenant` invariant — every agent the operator wires in is
|
|
84
|
+
* implicitly trusted; this is not an authorization boundary between mutually
|
|
85
|
+
* distrusting parties.
|
|
86
|
+
*/
|
|
87
|
+
export const BLOCKED_APPROVAL_DIR_MODE = 0o1777
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Why a card could not be delivered — and every one of these is TRANSIENT.
|
|
91
|
+
*
|
|
92
|
+
* `flood_wait` is the incident cause: a per-bot-token ban, nothing can land until
|
|
93
|
+
* `untilTs`. But it is not the only way a card goes undeliverable through no
|
|
94
|
+
* fault of the operator, and the leash rule ("never auto-deny an approval no
|
|
95
|
+
* human saw") does not care WHICH transient fault shut the channel — only
|
|
96
|
+
* whether the operator ever got a chance to answer.
|
|
97
|
+
*/
|
|
98
|
+
export type UndeliverableReason = 'flood_wait' | 'transient'
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* How long to wait before re-trying a card held for a NON-flood transient fault.
|
|
102
|
+
* A flood-wait carries its own `untilTs`; a network blip has no such signal, so
|
|
103
|
+
* back off a minute and let the reaper's next tick try again.
|
|
104
|
+
*/
|
|
105
|
+
export const HELD_RETRY_BACKOFF_MS = 60_000
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Ceiling on the exponential re-delivery backoff: 30 minutes.
|
|
109
|
+
*
|
|
110
|
+
* A held card that keeps failing must not be re-sent every 60s forever — that is
|
|
111
|
+
* the request amplifier this series exists to avoid. But it must ALSO never stop
|
|
112
|
+
* being retried entirely: a terminal give-up strands the card even after the
|
|
113
|
+
* channel recovers, and because `turnInFlightForGate()` holds the inbound gate
|
|
114
|
+
* while a permission is pending, that would silently buffer the operator's normal
|
|
115
|
+
* messages forever with no signal.
|
|
116
|
+
*
|
|
117
|
+
* So the retry interval BACKS OFF (1m, 2m, 4m … capped at 30m) instead of
|
|
118
|
+
* stopping. The rate is bounded; the retry never is. A recovered channel always
|
|
119
|
+
* gets the card back within 30 minutes.
|
|
120
|
+
*/
|
|
121
|
+
export const HELD_RETRY_MAX_BACKOFF_MS = 30 * 60_000
|
|
122
|
+
|
|
123
|
+
/** Backoff for the Nth consecutive failure: 1m, 2m, 4m … capped. */
|
|
124
|
+
export function heldRetryBackoffMs(failures: number): number {
|
|
125
|
+
const n = Math.max(1, failures)
|
|
126
|
+
return Math.min(HELD_RETRY_BACKOFF_MS * 2 ** (n - 1), HELD_RETRY_MAX_BACKOFF_MS)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Stamped onto the in-memory `pendingPermissions` entry when the card send
|
|
131
|
+
* fails against a known-open flood window. Its presence is what freezes the TTL
|
|
132
|
+
* (PR 3) and what makes the entry eligible for re-delivery (PR 2).
|
|
133
|
+
*/
|
|
134
|
+
export interface UndeliverableMark {
|
|
135
|
+
/** When we first failed to deliver this card. */
|
|
136
|
+
since: number
|
|
137
|
+
/** Telegram's `untilTs` for the open flood window — when a retry may land. */
|
|
138
|
+
retryableAt: number
|
|
139
|
+
reason: UndeliverableReason
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The shared, world-readable blocked-approval record. Schema is a contract with
|
|
144
|
+
* the switchroom-web reader — do not change field names or add fields carrying
|
|
145
|
+
* tool input.
|
|
146
|
+
*/
|
|
147
|
+
export interface BlockedApprovalRecord {
|
|
148
|
+
agent: string
|
|
149
|
+
requestId: string
|
|
150
|
+
toolName: string
|
|
151
|
+
/**
|
|
152
|
+
* `naturalAction()` text ONLY — e.g. "edit: supplement-log.md". A short
|
|
153
|
+
* human sentence describing the ask. NEVER the raw `inputPreview`.
|
|
154
|
+
*/
|
|
155
|
+
action: string
|
|
156
|
+
/** When the agent first blocked on this approval. */
|
|
157
|
+
blockedSince: number
|
|
158
|
+
/** When we first failed to deliver the card. */
|
|
159
|
+
undeliverableSince: number
|
|
160
|
+
/** When the channel is expected back (the flood window's `untilTs`). */
|
|
161
|
+
retryableAt: number
|
|
162
|
+
reason: UndeliverableReason
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Fields that must never appear in a world-readable record. */
|
|
166
|
+
const FORBIDDEN_FIELDS = ['inputPreview', 'input_preview', 'cardText', 'card_text', 'description']
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Fail loudly if a caller ever tries to widen the record with raw tool input.
|
|
170
|
+
* `check-no-pii-secrets` cannot see a runtime object, so the guard lives here:
|
|
171
|
+
* this file is 0644 and arbitrary tool input in it is a credential leak.
|
|
172
|
+
*/
|
|
173
|
+
function assertNoRawInput(rec: BlockedApprovalRecord): void {
|
|
174
|
+
for (const f of FORBIDDEN_FIELDS) {
|
|
175
|
+
if (f in (rec as unknown as Record<string, unknown>)) {
|
|
176
|
+
throw new Error(
|
|
177
|
+
`blocked-approval record must not carry raw tool input (field "${f}"): ` +
|
|
178
|
+
`the record is world-readable (0644) so switchroom-web can read it`,
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Tools whose `naturalAction()` text is SAFE to put in a world-readable file.
|
|
186
|
+
*
|
|
187
|
+
* This list is short on purpose, and it is an allowlist (fail-closed) rather
|
|
188
|
+
* than a denylist. `naturalAction` is not uniformly safe:
|
|
189
|
+
*
|
|
190
|
+
* naturalAction('Edit', …) → "edit: notes.md" ← a basename
|
|
191
|
+
* naturalAction('Bash', …) → "run: curl -H 'Authorization: Bearer sk-…" ← THE RAW COMMAND
|
|
192
|
+
*
|
|
193
|
+
* For `Bash` it interpolates the raw command; for `Glob`/`Grep` the raw
|
|
194
|
+
* pattern; for `WebFetch` the raw URL (which routinely carries a token in its
|
|
195
|
+
* query string); for `mcp__*` arbitrary tool args. Writing any of those into a
|
|
196
|
+
* 0644 file is exactly the "raw credential surviving in a world-readable place"
|
|
197
|
+
* failure the job spec names.
|
|
198
|
+
*
|
|
199
|
+
* The tools below derive their text from a FILE BASENAME (or a skill name from
|
|
200
|
+
* a fixed registry), which is safe to surface and is what makes the record
|
|
201
|
+
* useful — "edit: supplement-log.md" tells the operator what is blocked.
|
|
202
|
+
* Everything else falls back to the INPUT-FREE phrasing
|
|
203
|
+
* (`naturalAction(tool, undefined)` → "run shell commands", "search files",
|
|
204
|
+
* "send (Brevo)"), which is still a human sentence but structurally cannot
|
|
205
|
+
* contain tool input.
|
|
206
|
+
*/
|
|
207
|
+
const ACTION_SAFE_WITH_INPUT = new Set([
|
|
208
|
+
'Edit',
|
|
209
|
+
'MultiEdit',
|
|
210
|
+
'NotebookEdit',
|
|
211
|
+
'Write',
|
|
212
|
+
'Read',
|
|
213
|
+
'Skill',
|
|
214
|
+
])
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* The `action` string for the world-readable record.
|
|
218
|
+
*
|
|
219
|
+
* `naturalAction` is the right VOICE for this field, but it is only
|
|
220
|
+
* input-safe for some tools — see `ACTION_SAFE_WITH_INPUT`. Unknown and MCP
|
|
221
|
+
* tools fail CLOSED to the input-free phrasing.
|
|
222
|
+
*
|
|
223
|
+
* @param naturalAction the real `permission-title.ts` helper, injected so this
|
|
224
|
+
* module stays free of a gateway import cycle and stays unit-testable.
|
|
225
|
+
*/
|
|
226
|
+
export function safeActionForRecord(
|
|
227
|
+
naturalAction: (toolName: string, inputPreview: string | undefined) => string,
|
|
228
|
+
toolName: string,
|
|
229
|
+
inputPreview: string | undefined,
|
|
230
|
+
): string {
|
|
231
|
+
return ACTION_SAFE_WITH_INPUT.has(toolName)
|
|
232
|
+
? naturalAction(toolName, inputPreview)
|
|
233
|
+
: naturalAction(toolName, undefined)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export interface BlockedApprovalStore {
|
|
237
|
+
/** Write (or replace) the blocked record for this agent. */
|
|
238
|
+
write(rec: BlockedApprovalRecord): void
|
|
239
|
+
/** Clear the record — the approval was delivered, resolved, or cancelled. */
|
|
240
|
+
clear(): void
|
|
241
|
+
/** Read the current record, or null when nothing is blocked. */
|
|
242
|
+
read(): BlockedApprovalRecord | null
|
|
243
|
+
/** Absolute path of the backing file (for logs and tests). */
|
|
244
|
+
path: string
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** `<stateRoot>/blocked-approvals` — the shared directory switchroom-web reads. */
|
|
248
|
+
export function blockedApprovalsDir(stateRoot: string): string {
|
|
249
|
+
return join(stateRoot, 'blocked-approvals')
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* One file per agent: `<dir>/<agent>.json`. Single-record, not a list — an
|
|
254
|
+
* agent blocks on ONE permission at a time (the claude turn is suspended inside
|
|
255
|
+
* the MCP permission call), so there is exactly one thing to surface.
|
|
256
|
+
*
|
|
257
|
+
* Every write is best-effort: a failure to write the SURFACE must never take
|
|
258
|
+
* down the gateway or, worse, fall back to the auto-deny the whole change
|
|
259
|
+
* exists to remove. It logs and moves on.
|
|
260
|
+
*/
|
|
261
|
+
export function createBlockedApprovalStore(
|
|
262
|
+
dir: string,
|
|
263
|
+
agent: string,
|
|
264
|
+
/**
|
|
265
|
+
* Where to write when the shared dir isn't usable — the agent's OWN state dir
|
|
266
|
+
* (`/state/agent`, host `~/.switchroom/agents/<name>/`), which the scaffold
|
|
267
|
+
* chowns to the agent uid, so a write there always succeeds.
|
|
268
|
+
*
|
|
269
|
+
* This exists so the feature cannot silently no-op. If the shared bind is
|
|
270
|
+
* missing (a container predating the volume), or auto-created root-owned, the
|
|
271
|
+
* record still lands somewhere the operator and switchroom-web can read.
|
|
272
|
+
*
|
|
273
|
+
* **Reachable by mount is not the same as readable, and neither is the same as
|
|
274
|
+
* READ.** That elision is a bug this comment used to paper over: it claimed
|
|
275
|
+
* "web mounts all of ~/.switchroom, so both locations are reachable" — true,
|
|
276
|
+
* and irrelevant, because the reader only ever scanned the shared dir. The
|
|
277
|
+
* fallback was written to a file nobody read (#3109). Two things must hold, and
|
|
278
|
+
* both are load-bearing: if either regresses, the record exists, the agent is
|
|
279
|
+
* held, and the dashboard still prints "No agent is blocked".
|
|
280
|
+
*
|
|
281
|
+
* 1. **Mode.** The record is written 0644 into the 0775 agent dir. Verified
|
|
282
|
+
* live: `docker exec switchroom-web` reads a 0644 file under
|
|
283
|
+
* `~/.switchroom/agents/<agent>/` fine and gets EACCES only on the 0600
|
|
284
|
+
* files beside it. The FILE's mode locks web out, never the directory.
|
|
285
|
+
* 2. **The reader must LOOK here.** It now does —
|
|
286
|
+
* `src/web/blocked-approvals-read.ts` scans the shared dir AND
|
|
287
|
+
* `agents/<agent>/blocked-approval.json` (its `FALLBACK_RECORD_NAME`,
|
|
288
|
+
* which must stay in sync with the filename below).
|
|
289
|
+
*/
|
|
290
|
+
fallbackDir?: string,
|
|
291
|
+
): BlockedApprovalStore {
|
|
292
|
+
const primary = join(dir, `${agent}.json`)
|
|
293
|
+
// Keep in sync with FALLBACK_RECORD_NAME in src/web/blocked-approvals-read.ts —
|
|
294
|
+
// the reader matches this filename exactly. Pinned by the fallback-contract
|
|
295
|
+
// tests in src/web/blocked-approvals.test.ts.
|
|
296
|
+
const fallback = fallbackDir != null ? join(fallbackDir, 'blocked-approval.json') : null
|
|
297
|
+
|
|
298
|
+
/** Where the last successful write landed — `read`/`clear` must agree with it. */
|
|
299
|
+
let active = primary
|
|
300
|
+
|
|
301
|
+
function tryWrite(target: string, body: string, dirMode?: number): boolean {
|
|
302
|
+
const parent = dirname(target)
|
|
303
|
+
try {
|
|
304
|
+
mkdirSync(parent, { recursive: true })
|
|
305
|
+
// Symlink-planting guard. The shared dir is 1777 (the /tmp model), so
|
|
306
|
+
// any local uid can pre-create `<agent>.json` as a symlink pointing at a
|
|
307
|
+
// file the AGENT uid can write (its own state, a config file). Writing
|
|
308
|
+
// through it would let a planter redirect our 0644 write onto an
|
|
309
|
+
// arbitrary target. lstat (never follows) and refuse: the sticky bit
|
|
310
|
+
// stops us unlinking a symlink we don't own, so the safe move is to
|
|
311
|
+
// treat this location as unusable and fall back.
|
|
312
|
+
try {
|
|
313
|
+
if (lstatSync(target).isSymbolicLink()) return false
|
|
314
|
+
} catch {
|
|
315
|
+
// ENOENT — no file yet, the normal case.
|
|
316
|
+
}
|
|
317
|
+
// The `mode` args to mkdirSync/writeFileSync are MASKED by the process
|
|
318
|
+
// umask (and ignored outright for an existing dir), so passing them
|
|
319
|
+
// guarantees nothing. Both modes are load-bearing — the shared dir must be
|
|
320
|
+
// writable by every agent uid, the file readable by switchroom-web — so
|
|
321
|
+
// set them explicitly. The dir chmod is best-effort: we may not own it,
|
|
322
|
+
// which is fine, because the write below is the real test of whether this
|
|
323
|
+
// location works at all.
|
|
324
|
+
if (dirMode != null) {
|
|
325
|
+
try { chmodSync(parent, dirMode) } catch { /* not ours to chmod */ }
|
|
326
|
+
}
|
|
327
|
+
writeFileSync(target, body, { encoding: 'utf-8', mode: BLOCKED_APPROVAL_FILE_MODE })
|
|
328
|
+
chmodSync(target, BLOCKED_APPROVAL_FILE_MODE)
|
|
329
|
+
return true
|
|
330
|
+
} catch {
|
|
331
|
+
return false
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return {
|
|
336
|
+
get path() { return active },
|
|
337
|
+
|
|
338
|
+
write(rec) {
|
|
339
|
+
assertNoRawInput(rec)
|
|
340
|
+
const body = JSON.stringify(rec)
|
|
341
|
+
|
|
342
|
+
if (tryWrite(primary, body, BLOCKED_APPROVAL_DIR_MODE)) {
|
|
343
|
+
active = primary
|
|
344
|
+
// Best-effort removal of a stale FALLBACK record: an earlier write may
|
|
345
|
+
// have landed there while the shared dir was unusable. Once the shared
|
|
346
|
+
// surface works, a leftover fallback file would make a recovered host
|
|
347
|
+
// double-report the block (web reads both locations).
|
|
348
|
+
if (fallback != null) {
|
|
349
|
+
try { unlinkSync(fallback) } catch { /* absent is the normal case */ }
|
|
350
|
+
}
|
|
351
|
+
return
|
|
352
|
+
}
|
|
353
|
+
// The shared dir refused us — almost always because it was auto-created
|
|
354
|
+
// root-owned and we are a per-agent uid. Fall back to the agent's own
|
|
355
|
+
// state dir rather than losing the record, and say so LOUDLY: a
|
|
356
|
+
// half-visible leash surface is worth a noisy log line.
|
|
357
|
+
if (fallback != null && tryWrite(fallback, body)) {
|
|
358
|
+
active = fallback
|
|
359
|
+
process.stderr.write(
|
|
360
|
+
`telegram gateway: blocked-approval write to ${primary} failed ` +
|
|
361
|
+
`(shared dir not writable by this agent uid?) — wrote ${fallback} instead. ` +
|
|
362
|
+
`The hold is intact; the shared surface may be missing this agent.\n`,
|
|
363
|
+
)
|
|
364
|
+
return
|
|
365
|
+
}
|
|
366
|
+
process.stderr.write(
|
|
367
|
+
`telegram gateway: blocked-approval write FAILED at ${primary}` +
|
|
368
|
+
(fallback != null ? ` and ${fallback}` : '') +
|
|
369
|
+
` — the agent is HELD but the off-Telegram surface is blind. ` +
|
|
370
|
+
`The hold itself is unaffected (the approval is never auto-denied).\n`,
|
|
371
|
+
)
|
|
372
|
+
},
|
|
373
|
+
|
|
374
|
+
clear() {
|
|
375
|
+
// Clear BOTH: an earlier write may have landed in the fallback.
|
|
376
|
+
for (const p of [primary, fallback]) {
|
|
377
|
+
if (p == null) continue
|
|
378
|
+
try {
|
|
379
|
+
unlinkSync(p)
|
|
380
|
+
} catch {
|
|
381
|
+
// Absent is the normal case — nothing was blocked.
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
active = primary
|
|
385
|
+
},
|
|
386
|
+
|
|
387
|
+
read() {
|
|
388
|
+
for (const p of [primary, fallback]) {
|
|
389
|
+
if (p == null) continue
|
|
390
|
+
try {
|
|
391
|
+
const parsed = JSON.parse(readFileSync(p, 'utf-8'))
|
|
392
|
+
if (parsed != null && typeof parsed === 'object') return parsed as BlockedApprovalRecord
|
|
393
|
+
} catch {
|
|
394
|
+
// try the next location
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
return null
|
|
398
|
+
},
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Which held entry the blocked-approval surface should show — the RECONCILE
|
|
404
|
+
* selection, extracted from gateway.ts's `reconcileBlockedApprovals()` so it is
|
|
405
|
+
* unit-testable (gateway.ts has top-level side effects and cannot be imported
|
|
406
|
+
* from a test).
|
|
407
|
+
*
|
|
408
|
+
* The rules, both load-bearing:
|
|
409
|
+
*
|
|
410
|
+
* - **Oldest hold wins.** The store holds ONE record per agent, but an agent
|
|
411
|
+
* can hold several permissions at once (parallel tool calls). The one that
|
|
412
|
+
* has been waiting longest (smallest `undeliverable.since`) is the record.
|
|
413
|
+
* - **Reconcile, not clear.** Returning `null` — meaning the caller should
|
|
414
|
+
* `clear()` — happens ONLY when no entry is held at all. Resolving one of
|
|
415
|
+
* several holds must surface the next-oldest, never blank the file while a
|
|
416
|
+
* real block remains.
|
|
417
|
+
*/
|
|
418
|
+
export function selectOldestHeld<T extends { undeliverable?: UndeliverableMark | null }>(
|
|
419
|
+
entries: Iterable<readonly [string, T]>,
|
|
420
|
+
): { requestId: string; pend: T; mark: UndeliverableMark } | null {
|
|
421
|
+
let oldest: { requestId: string; pend: T; mark: UndeliverableMark } | null = null
|
|
422
|
+
for (const [requestId, pend] of entries) {
|
|
423
|
+
const u = pend.undeliverable
|
|
424
|
+
if (u == null) continue
|
|
425
|
+
if (oldest == null || u.since < oldest.mark.since) oldest = { requestId, pend, mark: u }
|
|
426
|
+
}
|
|
427
|
+
return oldest
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Classify a card-send failure: is this ask HELD, or does it fall through?
|
|
432
|
+
*
|
|
433
|
+
* This is the leash rule in one function, and its POLARITY is the whole point:
|
|
434
|
+
* it FAILS CLOSED. Anything we do not positively recognise as permanent is HELD.
|
|
435
|
+
*
|
|
436
|
+
* The first version of this function was an allowlist of marker strings
|
|
437
|
+
* (FLOOD_WAIT_ACTIVE / GIVE_UP_MESSAGE / LOCAL_RESOURCE_EXHAUSTED) on the
|
|
438
|
+
* premise that a network partition or a Telegram 5xx would surface as a
|
|
439
|
+
* give-up. **That premise was false, and the allowlist failed OPEN into
|
|
440
|
+
* auto-deny** — the exact breach this series exists to close, with a different
|
|
441
|
+
* first cause. Driving real errors through the real `retryApiCall`:
|
|
442
|
+
*
|
|
443
|
+
* Telegram 502 / 500 → raw GrammyError ← the network-retry branch is
|
|
444
|
+
* guarded by `!isGrammyErr`
|
|
445
|
+
* (retry-api-call.ts:377), so a
|
|
446
|
+
* 5xx is re-thrown on attempt 0
|
|
447
|
+
* ECONNRESET / fetch failed → raw Error ← on the LAST attempt the
|
|
448
|
+
* backoff branch is skipped and
|
|
449
|
+
* the raw error is re-thrown
|
|
450
|
+
* (retry-api-call.ts:384-396)
|
|
451
|
+
* short-but-persistent 429 → GIVE_UP_MESSAGE ← the only path that reaches it
|
|
452
|
+
* long 429 → FLOOD_WAIT_ACTIVE
|
|
453
|
+
* ENOSPC → LOCAL_RESOURCE_EXHAUSTED
|
|
454
|
+
*
|
|
455
|
+
* A single routine 502 at the instant the card is posted was therefore enough to
|
|
456
|
+
* reproduce 2026-07-11 in full: no card, no mark, TTL fires, agent told the
|
|
457
|
+
* operator declined. The send is ONE-SHOT — without a mark there is no
|
|
458
|
+
* re-delivery path at all.
|
|
459
|
+
*
|
|
460
|
+
* So the default is HOLD. Only a status we KNOW is permanent — a 400 (a card
|
|
461
|
+
* that will never parse) or a 403 (the bot is blocked/removed from that chat) —
|
|
462
|
+
* falls through to the TTL, because holding on those would park the agent
|
|
463
|
+
* forever on a card that can never render. And even that is safe now: PR 3 gives
|
|
464
|
+
* the no-card timeout a missed-approvals fallback, so a permanent failure is
|
|
465
|
+
* re-offered to the operator rather than vanishing.
|
|
466
|
+
*
|
|
467
|
+
* If a future error class is unrecognised, it is HELD. A held approval is
|
|
468
|
+
* visible and recoverable; a fabricated denial is neither.
|
|
469
|
+
*/
|
|
470
|
+
export function holdReasonFor(err: unknown): UndeliverableReason | null {
|
|
471
|
+
if (isFloodWaitActiveError(err)) return 'flood_wait'
|
|
472
|
+
// PERMANENT — and only these. Note 429 is deliberately NOT in this list: a
|
|
473
|
+
// rate-limit is the most transient failure there is.
|
|
474
|
+
const code = (err as { error_code?: unknown } | null | undefined)?.error_code
|
|
475
|
+
if (code === 400 || code === 403) return null
|
|
476
|
+
// Everything else — 5xx, raw network errors, give-ups, local-resource
|
|
477
|
+
// exhaustion, and anything we have never seen — is transient. FAIL CLOSED.
|
|
478
|
+
return 'transient'
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* THE LEASH, as one shared decision — used by `sweepPermissionTtl`, which BOTH
|
|
483
|
+
* gateway.ts and the outcome test's harness call.
|
|
484
|
+
*
|
|
485
|
+
* The harness used to keep a PRIVATE copy of this check (a `ttlFreeze` flag), so
|
|
486
|
+
* deleting the real guard left every behavioural assertion GREEN — including the
|
|
487
|
+
* flagship "no deny verdict is ever dispatched". Only a source-text grep noticed,
|
|
488
|
+
* and greps drift. A test that cannot fail is not a test, and this is the test for
|
|
489
|
+
* the `no-self-escalation` invariant.
|
|
490
|
+
*
|
|
491
|
+
* A HELD entry NEVER expires. The TTL answers one question: "how long did the
|
|
492
|
+
* operator have to answer?" For a card that never landed — flood ban, 5xx, network
|
|
493
|
+
* partition — the answer is ZERO SECONDS.
|
|
494
|
+
*/
|
|
495
|
+
export function shouldExpirePermission(
|
|
496
|
+
pend: { undeliverable?: UndeliverableMark | null; startedAt: number },
|
|
497
|
+
now: number,
|
|
498
|
+
ttlMs: number,
|
|
499
|
+
): boolean {
|
|
500
|
+
// Never auto-deny an ask no human ever saw. Not even on a deadline.
|
|
501
|
+
if (isHeldUndeliverable(pend)) return false
|
|
502
|
+
return now - pend.startedAt > ttlMs
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* The TTL freeze predicate (PR 3).
|
|
507
|
+
*
|
|
508
|
+
* An entry marked undeliverable NEVER expires. The TTL answers "how long did
|
|
509
|
+
* the operator have to respond?" — and for a card that never landed, the answer
|
|
510
|
+
* is zero seconds. Letting a clock the operator was never shown run out, and
|
|
511
|
+
* then reporting the silence to the agent as a denial, is the bug.
|
|
512
|
+
*/
|
|
513
|
+
export function isHeldUndeliverable(
|
|
514
|
+
pend: { undeliverable?: UndeliverableMark | null },
|
|
515
|
+
): boolean {
|
|
516
|
+
return pend.undeliverable != null
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Per-tick re-delivery cap (PR 2).
|
|
521
|
+
*
|
|
522
|
+
* A backlog of held cards must not BURST the instant the window closes — that
|
|
523
|
+
* burst is the one realistic way this design could re-earn the very ban it
|
|
524
|
+
* exists to survive. Three per 60s tick drains any plausible backlog quickly
|
|
525
|
+
* while staying far under Telegram's rate ceiling.
|
|
526
|
+
*/
|
|
527
|
+
export const HELD_CARD_REDELIVERY_CAP = 3
|
|
528
|
+
|
|
529
|
+
export interface HeldPermissionEntry {
|
|
530
|
+
undeliverable?: UndeliverableMark | null
|
|
531
|
+
/** Cards that actually landed. A held entry has none — that is the point. */
|
|
532
|
+
cards: unknown[]
|
|
533
|
+
/** Consecutive failed re-delivery attempts. Past the cap we stop RE-SENDING. */
|
|
534
|
+
redeliveryFailures?: number
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
export interface RedeliverySelection {
|
|
538
|
+
/** Request ids to re-post on this tick. */
|
|
539
|
+
send: string[]
|
|
540
|
+
/** Held ids the cap deferred to a later tick. Logged, never silently dropped. */
|
|
541
|
+
deferred: string[]
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* Choose which held cards to re-post on this reaper tick.
|
|
546
|
+
*
|
|
547
|
+
* Guards, all of which must hold before a single request goes out:
|
|
548
|
+
*
|
|
549
|
+
* (i) the flood window is CLOSED — `floodRemainingMs === 0`. Strictly
|
|
550
|
+
* stronger than `robustApiCall`'s own short-circuit, which only refuses
|
|
551
|
+
* windows longer than its sleep ceiling.
|
|
552
|
+
* (ii) the entry is marked undeliverable AND has no landed card
|
|
553
|
+
* (`cards.length === 0`). This is the double-delivery guard: the bridge
|
|
554
|
+
* re-sends unresolved requests on IPC reconnect
|
|
555
|
+
* (`bridge/permission-ledger.ts`), so a card may already be live.
|
|
556
|
+
* (iii) the request is not already in flight from a previous tick.
|
|
557
|
+
* (iv) the per-tick cap.
|
|
558
|
+
*
|
|
559
|
+
* `robustApiCall`'s independent pre-call probe is the fifth guard, and it sits
|
|
560
|
+
* downstream of this function — if the window re-opens between selection and
|
|
561
|
+
* send, the send still refuses itself.
|
|
562
|
+
*/
|
|
563
|
+
export function selectHeldForRedelivery(
|
|
564
|
+
entries: Iterable<readonly [string, HeldPermissionEntry]>,
|
|
565
|
+
opts: {
|
|
566
|
+
floodRemainingMs: number
|
|
567
|
+
inFlight: ReadonlySet<string>
|
|
568
|
+
/** Now, for the per-entry backoff check. */
|
|
569
|
+
now: number
|
|
570
|
+
cap?: number
|
|
571
|
+
},
|
|
572
|
+
): RedeliverySelection {
|
|
573
|
+
const empty: RedeliverySelection = { send: [], deferred: [] }
|
|
574
|
+
// Guard (i) — the window is still open. Send nothing at all.
|
|
575
|
+
if (opts.floodRemainingMs > 0) return empty
|
|
576
|
+
|
|
577
|
+
const cap = opts.cap ?? HELD_CARD_REDELIVERY_CAP
|
|
578
|
+
const send: string[] = []
|
|
579
|
+
const deferred: string[] = []
|
|
580
|
+
|
|
581
|
+
for (const [requestId, pend] of entries) {
|
|
582
|
+
// Guard (ii) — held, and nothing landed for it yet.
|
|
583
|
+
if (!isHeldUndeliverable(pend)) continue
|
|
584
|
+
if (pend.cards.length > 0) continue
|
|
585
|
+
// Guard (iii) — a previous tick is still posting this one.
|
|
586
|
+
if (opts.inFlight.has(requestId)) continue
|
|
587
|
+
// Guard (v) — respect the per-entry backoff. A card that keeps failing is
|
|
588
|
+
// retried on a widening interval (1m, 2m, 4m … 30m) rather than every tick:
|
|
589
|
+
// bounded RATE, unbounded RETRY. It is never abandoned, so a channel that
|
|
590
|
+
// recovers always gets the card back — and the approval is never auto-denied.
|
|
591
|
+
const dueAt = pend.undeliverable?.retryableAt ?? 0
|
|
592
|
+
if (opts.now < dueAt) continue
|
|
593
|
+
// Guard (iv) — cap. Everything past it is DEFERRED, not dropped.
|
|
594
|
+
if (send.length >= cap) {
|
|
595
|
+
deferred.push(requestId)
|
|
596
|
+
continue
|
|
597
|
+
}
|
|
598
|
+
send.push(requestId)
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
return { send, deferred }
|
|
602
|
+
}
|