switchroom 0.18.11 → 0.18.12

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.
Files changed (122) hide show
  1. package/dist/agent-scheduler/index.js +29 -5
  2. package/dist/auth-broker/index.js +53 -13
  3. package/dist/cli/hindsight-mental-model-pretool.mjs +39 -0
  4. package/dist/cli/notion-write-pretool.mjs +29 -5
  5. package/dist/cli/switchroom.js +2453 -1293
  6. package/dist/cli/ui/index.html +163 -17
  7. package/dist/host-control/main.js +504 -100
  8. package/dist/vault/approvals/kernel-server.js +53 -13
  9. package/dist/vault/broker/server.js +162 -114
  10. package/package.json +3 -4
  11. package/profiles/_base/start.sh.hbs +65 -0
  12. package/profiles/_shared/vault-protocol.md.hbs +3 -1
  13. package/profiles/coding/CLAUDE.md.hbs +1 -1
  14. package/profiles/default/CLAUDE.md.hbs +2 -2
  15. package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
  16. package/profiles/health-coach/CLAUDE.md.hbs +1 -1
  17. package/telegram-plugin/bridge/bridge.ts +37 -0
  18. package/telegram-plugin/bridge/inbound-dedup.ts +101 -0
  19. package/telegram-plugin/dist/bridge/bridge.js +73 -1
  20. package/telegram-plugin/dist/gateway/gateway.js +3602 -1007
  21. package/telegram-plugin/dist/server.js +74 -2
  22. package/telegram-plugin/flood-circuit-breaker.ts +493 -21
  23. package/telegram-plugin/gateway/approval-hold.ts +583 -0
  24. package/telegram-plugin/gateway/auth-command.ts +92 -2
  25. package/telegram-plugin/gateway/auth-loopback-relay.ts +670 -0
  26. package/telegram-plugin/gateway/boot-card.ts +12 -5
  27. package/telegram-plugin/gateway/callback-query-handlers.ts +76 -1
  28. package/telegram-plugin/gateway/config-approval-handler.ts +6 -1
  29. package/telegram-plugin/gateway/disconnect-flush.ts +19 -0
  30. package/telegram-plugin/gateway/dm-pin-sweep.test.ts +251 -0
  31. package/telegram-plugin/gateway/dm-pin-sweep.ts +178 -0
  32. package/telegram-plugin/gateway/gateway.ts +1482 -165
  33. package/telegram-plugin/gateway/hostd-dispatch.ts +23 -0
  34. package/telegram-plugin/gateway/idle-clear.ts +90 -6
  35. package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +26 -5
  36. package/telegram-plugin/gateway/inject-handler.ts +8 -0
  37. package/telegram-plugin/gateway/ipc-protocol.ts +46 -3
  38. package/telegram-plugin/gateway/ipc-server.ts +43 -0
  39. package/telegram-plugin/gateway/mental-model-propose-resolve.ts +145 -37
  40. package/telegram-plugin/gateway/model-command.ts +9 -3
  41. package/telegram-plugin/gateway/pending-session-command.ts +13 -1
  42. package/telegram-plugin/gateway/permission-ttl-sweep.ts +66 -0
  43. package/telegram-plugin/gateway/pre-approval-check.ts +74 -0
  44. package/telegram-plugin/gateway/queued-card-store.ts +217 -0
  45. package/telegram-plugin/gateway/session-model-file.ts +26 -1
  46. package/telegram-plugin/gateway/turn-end-gate-backstop.ts +59 -0
  47. package/telegram-plugin/gateway/turn-end-gate.ts +95 -0
  48. package/telegram-plugin/gateway/turn-typing-loop.ts +10 -2
  49. package/telegram-plugin/gateway/unhandled-rejection-policy.ts +13 -0
  50. package/telegram-plugin/hooks/dispatch-claim-scan.mjs +259 -0
  51. package/telegram-plugin/hooks/dispatch-claim-stop.mjs +129 -0
  52. package/telegram-plugin/hooks/hooks.json +9 -0
  53. package/telegram-plugin/inline-keyboard-callbacks.ts +209 -2
  54. package/telegram-plugin/operator-events.ts +23 -0
  55. package/telegram-plugin/package.json +0 -1
  56. package/telegram-plugin/permission-rule.ts +1 -0
  57. package/telegram-plugin/permission-title.ts +1 -0
  58. package/telegram-plugin/retry-api-call.ts +212 -2
  59. package/telegram-plugin/send-gate-degraded.test.ts +443 -0
  60. package/telegram-plugin/send-gate-observability.test.ts +470 -0
  61. package/telegram-plugin/send-gate-observability.ts +355 -0
  62. package/telegram-plugin/send-gate.test.ts +698 -0
  63. package/telegram-plugin/send-gate.ts +982 -0
  64. package/telegram-plugin/shared/bot-runtime.ts +17 -5
  65. package/telegram-plugin/shared/gw-trace-gate.ts +105 -0
  66. package/telegram-plugin/status-pin-driver.ts +52 -7
  67. package/telegram-plugin/status-pin.ts +81 -0
  68. package/telegram-plugin/subagent-watcher.ts +102 -2
  69. package/telegram-plugin/tests/activity-card-wiring.test.ts +18 -5
  70. package/telegram-plugin/tests/approval-hold-harness.ts +425 -0
  71. package/telegram-plugin/tests/approval-hold-outcome.test.ts +296 -0
  72. package/telegram-plugin/tests/approval-hold-record.test.ts +531 -0
  73. package/telegram-plugin/tests/approval-hold-redeliver.test.ts +602 -0
  74. package/telegram-plugin/tests/auth-loopback-relay.test.ts +533 -0
  75. package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +53 -7
  76. package/telegram-plugin/tests/busy-key-reaper.test.ts +1 -0
  77. package/telegram-plugin/tests/dispatch-claim-scan.test.ts +250 -0
  78. package/telegram-plugin/tests/flood-breaker-blindness.test.ts +213 -0
  79. package/telegram-plugin/tests/flood-windows-persistence.test.ts +224 -0
  80. package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
  81. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +29 -1
  82. package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +66 -0
  83. package/telegram-plugin/tests/gw-trace-gate.test.ts +105 -0
  84. package/telegram-plugin/tests/idle-clear.test.ts +233 -3
  85. package/telegram-plugin/tests/inbound-dedup.test.ts +93 -0
  86. package/telegram-plugin/tests/inline-keyboard-callbacks.test.ts +284 -0
  87. package/telegram-plugin/tests/ipc-server-check-pre-approved.test.ts +194 -0
  88. package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +123 -0
  89. package/telegram-plugin/tests/missed-approvals-wiring.test.ts +1 -1
  90. package/telegram-plugin/tests/model-command.test.ts +14 -0
  91. package/telegram-plugin/tests/pending-session-command.test.ts +21 -0
  92. package/telegram-plugin/tests/permission-card-routing.test.ts +30 -5
  93. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +8 -7
  94. package/telegram-plugin/tests/permission-rearm-wiring.test.ts +1 -1
  95. package/telegram-plugin/tests/pre-approval-check.test.ts +148 -0
  96. package/telegram-plugin/tests/queued-card-store.test.ts +232 -0
  97. package/telegram-plugin/tests/reaction-flush-turn-gated.test.ts +100 -0
  98. package/telegram-plugin/tests/retry-api-call.test.ts +398 -0
  99. package/telegram-plugin/tests/session-model-file.test.ts +50 -0
  100. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +35 -14
  101. package/telegram-plugin/tests/status-pin.test.ts +275 -1
  102. package/telegram-plugin/tests/subagent-watcher-deferral-log-ratelimit.test.ts +316 -0
  103. package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +92 -0
  104. package/telegram-plugin/tests/turn-end-gate.test.ts +137 -0
  105. package/telegram-plugin/tests/typing-emitter.test.ts +586 -0
  106. package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +20 -0
  107. package/telegram-plugin/typing-emitter.ts +224 -0
  108. package/telegram-plugin/uat/scenarios/jtbd-feel-like-a-colleague-dm.test.ts +136 -0
  109. package/telegram-plugin/welcome-text.ts +42 -0
  110. package/vendor/hindsight-memory/scripts/drain_pending.py +22 -6
  111. package/vendor/hindsight-memory/scripts/lib/client.py +12 -5
  112. package/vendor/hindsight-memory/scripts/lib/directives.py +38 -3
  113. package/vendor/hindsight-memory/scripts/lib/pending.py +36 -9
  114. package/vendor/hindsight-memory/scripts/session_end.py +14 -3
  115. package/vendor/hindsight-memory/scripts/session_start.py +21 -0
  116. package/vendor/hindsight-memory/scripts/tests/test_directives.py +38 -0
  117. package/vendor/hindsight-memory/tests/test_drain_pending.py +68 -0
  118. package/vendor/hindsight-memory/tests/test_pending.py +44 -0
  119. package/vendor/hindsight-memory/tests/test_session_end_pending.py +38 -0
  120. package/vendor/hindsight-memory/tests/test_session_start_drain.py +155 -0
  121. package/telegram-plugin/channel-envelope-safety.test.ts +0 -56
  122. package/telegram-plugin/channel-envelope-safety.ts +0 -56
@@ -0,0 +1,583 @@
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
+ * web mounts all of `~/.switchroom`, so both locations are reachable.
273
+ */
274
+ fallbackDir?: string,
275
+ ): BlockedApprovalStore {
276
+ const primary = join(dir, `${agent}.json`)
277
+ const fallback = fallbackDir != null ? join(fallbackDir, 'blocked-approval.json') : null
278
+
279
+ /** Where the last successful write landed — `read`/`clear` must agree with it. */
280
+ let active = primary
281
+
282
+ function tryWrite(target: string, body: string, dirMode?: number): boolean {
283
+ const parent = dirname(target)
284
+ try {
285
+ mkdirSync(parent, { recursive: true })
286
+ // Symlink-planting guard. The shared dir is 1777 (the /tmp model), so
287
+ // any local uid can pre-create `<agent>.json` as a symlink pointing at a
288
+ // file the AGENT uid can write (its own state, a config file). Writing
289
+ // through it would let a planter redirect our 0644 write onto an
290
+ // arbitrary target. lstat (never follows) and refuse: the sticky bit
291
+ // stops us unlinking a symlink we don't own, so the safe move is to
292
+ // treat this location as unusable and fall back.
293
+ try {
294
+ if (lstatSync(target).isSymbolicLink()) return false
295
+ } catch {
296
+ // ENOENT — no file yet, the normal case.
297
+ }
298
+ // The `mode` args to mkdirSync/writeFileSync are MASKED by the process
299
+ // umask (and ignored outright for an existing dir), so passing them
300
+ // guarantees nothing. Both modes are load-bearing — the shared dir must be
301
+ // writable by every agent uid, the file readable by switchroom-web — so
302
+ // set them explicitly. The dir chmod is best-effort: we may not own it,
303
+ // which is fine, because the write below is the real test of whether this
304
+ // location works at all.
305
+ if (dirMode != null) {
306
+ try { chmodSync(parent, dirMode) } catch { /* not ours to chmod */ }
307
+ }
308
+ writeFileSync(target, body, { encoding: 'utf-8', mode: BLOCKED_APPROVAL_FILE_MODE })
309
+ chmodSync(target, BLOCKED_APPROVAL_FILE_MODE)
310
+ return true
311
+ } catch {
312
+ return false
313
+ }
314
+ }
315
+
316
+ return {
317
+ get path() { return active },
318
+
319
+ write(rec) {
320
+ assertNoRawInput(rec)
321
+ const body = JSON.stringify(rec)
322
+
323
+ if (tryWrite(primary, body, BLOCKED_APPROVAL_DIR_MODE)) {
324
+ active = primary
325
+ // Best-effort removal of a stale FALLBACK record: an earlier write may
326
+ // have landed there while the shared dir was unusable. Once the shared
327
+ // surface works, a leftover fallback file would make a recovered host
328
+ // double-report the block (web reads both locations).
329
+ if (fallback != null) {
330
+ try { unlinkSync(fallback) } catch { /* absent is the normal case */ }
331
+ }
332
+ return
333
+ }
334
+ // The shared dir refused us — almost always because it was auto-created
335
+ // root-owned and we are a per-agent uid. Fall back to the agent's own
336
+ // state dir rather than losing the record, and say so LOUDLY: a
337
+ // half-visible leash surface is worth a noisy log line.
338
+ if (fallback != null && tryWrite(fallback, body)) {
339
+ active = fallback
340
+ process.stderr.write(
341
+ `telegram gateway: blocked-approval write to ${primary} failed ` +
342
+ `(shared dir not writable by this agent uid?) — wrote ${fallback} instead. ` +
343
+ `The hold is intact; the shared surface may be missing this agent.\n`,
344
+ )
345
+ return
346
+ }
347
+ process.stderr.write(
348
+ `telegram gateway: blocked-approval write FAILED at ${primary}` +
349
+ (fallback != null ? ` and ${fallback}` : '') +
350
+ ` — the agent is HELD but the off-Telegram surface is blind. ` +
351
+ `The hold itself is unaffected (the approval is never auto-denied).\n`,
352
+ )
353
+ },
354
+
355
+ clear() {
356
+ // Clear BOTH: an earlier write may have landed in the fallback.
357
+ for (const p of [primary, fallback]) {
358
+ if (p == null) continue
359
+ try {
360
+ unlinkSync(p)
361
+ } catch {
362
+ // Absent is the normal case — nothing was blocked.
363
+ }
364
+ }
365
+ active = primary
366
+ },
367
+
368
+ read() {
369
+ for (const p of [primary, fallback]) {
370
+ if (p == null) continue
371
+ try {
372
+ const parsed = JSON.parse(readFileSync(p, 'utf-8'))
373
+ if (parsed != null && typeof parsed === 'object') return parsed as BlockedApprovalRecord
374
+ } catch {
375
+ // try the next location
376
+ }
377
+ }
378
+ return null
379
+ },
380
+ }
381
+ }
382
+
383
+ /**
384
+ * Which held entry the blocked-approval surface should show — the RECONCILE
385
+ * selection, extracted from gateway.ts's `reconcileBlockedApprovals()` so it is
386
+ * unit-testable (gateway.ts has top-level side effects and cannot be imported
387
+ * from a test).
388
+ *
389
+ * The rules, both load-bearing:
390
+ *
391
+ * - **Oldest hold wins.** The store holds ONE record per agent, but an agent
392
+ * can hold several permissions at once (parallel tool calls). The one that
393
+ * has been waiting longest (smallest `undeliverable.since`) is the record.
394
+ * - **Reconcile, not clear.** Returning `null` — meaning the caller should
395
+ * `clear()` — happens ONLY when no entry is held at all. Resolving one of
396
+ * several holds must surface the next-oldest, never blank the file while a
397
+ * real block remains.
398
+ */
399
+ export function selectOldestHeld<T extends { undeliverable?: UndeliverableMark | null }>(
400
+ entries: Iterable<readonly [string, T]>,
401
+ ): { requestId: string; pend: T; mark: UndeliverableMark } | null {
402
+ let oldest: { requestId: string; pend: T; mark: UndeliverableMark } | null = null
403
+ for (const [requestId, pend] of entries) {
404
+ const u = pend.undeliverable
405
+ if (u == null) continue
406
+ if (oldest == null || u.since < oldest.mark.since) oldest = { requestId, pend, mark: u }
407
+ }
408
+ return oldest
409
+ }
410
+
411
+ /**
412
+ * Classify a card-send failure: is this ask HELD, or does it fall through?
413
+ *
414
+ * This is the leash rule in one function, and its POLARITY is the whole point:
415
+ * it FAILS CLOSED. Anything we do not positively recognise as permanent is HELD.
416
+ *
417
+ * The first version of this function was an allowlist of marker strings
418
+ * (FLOOD_WAIT_ACTIVE / GIVE_UP_MESSAGE / LOCAL_RESOURCE_EXHAUSTED) on the
419
+ * premise that a network partition or a Telegram 5xx would surface as a
420
+ * give-up. **That premise was false, and the allowlist failed OPEN into
421
+ * auto-deny** — the exact breach this series exists to close, with a different
422
+ * first cause. Driving real errors through the real `retryApiCall`:
423
+ *
424
+ * Telegram 502 / 500 → raw GrammyError ← the network-retry branch is
425
+ * guarded by `!isGrammyErr`
426
+ * (retry-api-call.ts:377), so a
427
+ * 5xx is re-thrown on attempt 0
428
+ * ECONNRESET / fetch failed → raw Error ← on the LAST attempt the
429
+ * backoff branch is skipped and
430
+ * the raw error is re-thrown
431
+ * (retry-api-call.ts:384-396)
432
+ * short-but-persistent 429 → GIVE_UP_MESSAGE ← the only path that reaches it
433
+ * long 429 → FLOOD_WAIT_ACTIVE
434
+ * ENOSPC → LOCAL_RESOURCE_EXHAUSTED
435
+ *
436
+ * A single routine 502 at the instant the card is posted was therefore enough to
437
+ * reproduce 2026-07-11 in full: no card, no mark, TTL fires, agent told the
438
+ * operator declined. The send is ONE-SHOT — without a mark there is no
439
+ * re-delivery path at all.
440
+ *
441
+ * So the default is HOLD. Only a status we KNOW is permanent — a 400 (a card
442
+ * that will never parse) or a 403 (the bot is blocked/removed from that chat) —
443
+ * falls through to the TTL, because holding on those would park the agent
444
+ * forever on a card that can never render. And even that is safe now: PR 3 gives
445
+ * the no-card timeout a missed-approvals fallback, so a permanent failure is
446
+ * re-offered to the operator rather than vanishing.
447
+ *
448
+ * If a future error class is unrecognised, it is HELD. A held approval is
449
+ * visible and recoverable; a fabricated denial is neither.
450
+ */
451
+ export function holdReasonFor(err: unknown): UndeliverableReason | null {
452
+ if (isFloodWaitActiveError(err)) return 'flood_wait'
453
+ // PERMANENT — and only these. Note 429 is deliberately NOT in this list: a
454
+ // rate-limit is the most transient failure there is.
455
+ const code = (err as { error_code?: unknown } | null | undefined)?.error_code
456
+ if (code === 400 || code === 403) return null
457
+ // Everything else — 5xx, raw network errors, give-ups, local-resource
458
+ // exhaustion, and anything we have never seen — is transient. FAIL CLOSED.
459
+ return 'transient'
460
+ }
461
+
462
+ /**
463
+ * THE LEASH, as one shared decision — used by `sweepPermissionTtl`, which BOTH
464
+ * gateway.ts and the outcome test's harness call.
465
+ *
466
+ * The harness used to keep a PRIVATE copy of this check (a `ttlFreeze` flag), so
467
+ * deleting the real guard left every behavioural assertion GREEN — including the
468
+ * flagship "no deny verdict is ever dispatched". Only a source-text grep noticed,
469
+ * and greps drift. A test that cannot fail is not a test, and this is the test for
470
+ * the `no-self-escalation` invariant.
471
+ *
472
+ * A HELD entry NEVER expires. The TTL answers one question: "how long did the
473
+ * operator have to answer?" For a card that never landed — flood ban, 5xx, network
474
+ * partition — the answer is ZERO SECONDS.
475
+ */
476
+ export function shouldExpirePermission(
477
+ pend: { undeliverable?: UndeliverableMark | null; startedAt: number },
478
+ now: number,
479
+ ttlMs: number,
480
+ ): boolean {
481
+ // Never auto-deny an ask no human ever saw. Not even on a deadline.
482
+ if (isHeldUndeliverable(pend)) return false
483
+ return now - pend.startedAt > ttlMs
484
+ }
485
+
486
+ /**
487
+ * The TTL freeze predicate (PR 3).
488
+ *
489
+ * An entry marked undeliverable NEVER expires. The TTL answers "how long did
490
+ * the operator have to respond?" — and for a card that never landed, the answer
491
+ * is zero seconds. Letting a clock the operator was never shown run out, and
492
+ * then reporting the silence to the agent as a denial, is the bug.
493
+ */
494
+ export function isHeldUndeliverable(
495
+ pend: { undeliverable?: UndeliverableMark | null },
496
+ ): boolean {
497
+ return pend.undeliverable != null
498
+ }
499
+
500
+ /**
501
+ * Per-tick re-delivery cap (PR 2).
502
+ *
503
+ * A backlog of held cards must not BURST the instant the window closes — that
504
+ * burst is the one realistic way this design could re-earn the very ban it
505
+ * exists to survive. Three per 60s tick drains any plausible backlog quickly
506
+ * while staying far under Telegram's rate ceiling.
507
+ */
508
+ export const HELD_CARD_REDELIVERY_CAP = 3
509
+
510
+ export interface HeldPermissionEntry {
511
+ undeliverable?: UndeliverableMark | null
512
+ /** Cards that actually landed. A held entry has none — that is the point. */
513
+ cards: unknown[]
514
+ /** Consecutive failed re-delivery attempts. Past the cap we stop RE-SENDING. */
515
+ redeliveryFailures?: number
516
+ }
517
+
518
+ export interface RedeliverySelection {
519
+ /** Request ids to re-post on this tick. */
520
+ send: string[]
521
+ /** Held ids the cap deferred to a later tick. Logged, never silently dropped. */
522
+ deferred: string[]
523
+ }
524
+
525
+ /**
526
+ * Choose which held cards to re-post on this reaper tick.
527
+ *
528
+ * Guards, all of which must hold before a single request goes out:
529
+ *
530
+ * (i) the flood window is CLOSED — `floodRemainingMs === 0`. Strictly
531
+ * stronger than `robustApiCall`'s own short-circuit, which only refuses
532
+ * windows longer than its sleep ceiling.
533
+ * (ii) the entry is marked undeliverable AND has no landed card
534
+ * (`cards.length === 0`). This is the double-delivery guard: the bridge
535
+ * re-sends unresolved requests on IPC reconnect
536
+ * (`bridge/permission-ledger.ts`), so a card may already be live.
537
+ * (iii) the request is not already in flight from a previous tick.
538
+ * (iv) the per-tick cap.
539
+ *
540
+ * `robustApiCall`'s independent pre-call probe is the fifth guard, and it sits
541
+ * downstream of this function — if the window re-opens between selection and
542
+ * send, the send still refuses itself.
543
+ */
544
+ export function selectHeldForRedelivery(
545
+ entries: Iterable<readonly [string, HeldPermissionEntry]>,
546
+ opts: {
547
+ floodRemainingMs: number
548
+ inFlight: ReadonlySet<string>
549
+ /** Now, for the per-entry backoff check. */
550
+ now: number
551
+ cap?: number
552
+ },
553
+ ): RedeliverySelection {
554
+ const empty: RedeliverySelection = { send: [], deferred: [] }
555
+ // Guard (i) — the window is still open. Send nothing at all.
556
+ if (opts.floodRemainingMs > 0) return empty
557
+
558
+ const cap = opts.cap ?? HELD_CARD_REDELIVERY_CAP
559
+ const send: string[] = []
560
+ const deferred: string[] = []
561
+
562
+ for (const [requestId, pend] of entries) {
563
+ // Guard (ii) — held, and nothing landed for it yet.
564
+ if (!isHeldUndeliverable(pend)) continue
565
+ if (pend.cards.length > 0) continue
566
+ // Guard (iii) — a previous tick is still posting this one.
567
+ if (opts.inFlight.has(requestId)) continue
568
+ // Guard (v) — respect the per-entry backoff. A card that keeps failing is
569
+ // retried on a widening interval (1m, 2m, 4m … 30m) rather than every tick:
570
+ // bounded RATE, unbounded RETRY. It is never abandoned, so a channel that
571
+ // recovers always gets the card back — and the approval is never auto-denied.
572
+ const dueAt = pend.undeliverable?.retryableAt ?? 0
573
+ if (opts.now < dueAt) continue
574
+ // Guard (iv) — cap. Everything past it is DEFERRED, not dropped.
575
+ if (send.length >= cap) {
576
+ deferred.push(requestId)
577
+ continue
578
+ }
579
+ send.push(requestId)
580
+ }
581
+
582
+ return { send, deferred }
583
+ }