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.
Files changed (150) hide show
  1. package/dist/agent-scheduler/index.js +37 -5
  2. package/dist/auth-broker/index.js +116 -78
  3. package/dist/cli/hindsight-mental-model-pretool.mjs +39 -0
  4. package/dist/cli/ms-365-write-pretool.mjs +31 -8
  5. package/dist/cli/notion-write-pretool.mjs +38 -6
  6. package/dist/cli/skill-validate-pretool.mjs +144 -2847
  7. package/dist/cli/switchroom.js +3529 -4543
  8. package/dist/cli/ui/index.html +163 -17
  9. package/dist/host-control/main.js +605 -2847
  10. package/dist/vault/approvals/kernel-server.js +120 -13
  11. package/dist/vault/broker/server.js +259 -157
  12. package/package.json +3 -4
  13. package/profiles/_base/start.sh.hbs +65 -0
  14. package/profiles/_shared/vault-protocol.md.hbs +3 -1
  15. package/profiles/coding/CLAUDE.md.hbs +1 -1
  16. package/profiles/default/CLAUDE.md.hbs +2 -2
  17. package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
  18. package/profiles/health-coach/CLAUDE.md.hbs +1 -1
  19. package/telegram-plugin/bridge/bridge.ts +37 -0
  20. package/telegram-plugin/bridge/inbound-dedup.ts +101 -0
  21. package/telegram-plugin/dist/bridge/bridge.js +122 -4
  22. package/telegram-plugin/dist/gateway/gateway.js +4213 -3288
  23. package/telegram-plugin/dist/server.js +139 -5
  24. package/telegram-plugin/flood-circuit-breaker.ts +493 -21
  25. package/telegram-plugin/format.ts +19 -0
  26. package/telegram-plugin/gateway/approval-hold.ts +602 -0
  27. package/telegram-plugin/gateway/auth-command.ts +92 -2
  28. package/telegram-plugin/gateway/auth-loopback-relay.ts +670 -0
  29. package/telegram-plugin/gateway/boot-card.ts +12 -5
  30. package/telegram-plugin/gateway/callback-query-handlers.ts +88 -1
  31. package/telegram-plugin/gateway/config-approval-handler.ts +6 -1
  32. package/telegram-plugin/gateway/disconnect-flush.ts +19 -0
  33. package/telegram-plugin/gateway/dm-pin-sweep.test.ts +251 -0
  34. package/telegram-plugin/gateway/dm-pin-sweep.ts +178 -0
  35. package/telegram-plugin/gateway/gateway.ts +1695 -230
  36. package/telegram-plugin/gateway/hostd-dispatch.ts +23 -0
  37. package/telegram-plugin/gateway/idle-clear.ts +90 -6
  38. package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +26 -5
  39. package/telegram-plugin/gateway/inject-handler.ts +8 -0
  40. package/telegram-plugin/gateway/ipc-protocol.ts +46 -3
  41. package/telegram-plugin/gateway/ipc-server.ts +43 -0
  42. package/telegram-plugin/gateway/mental-model-propose-resolve.ts +145 -37
  43. package/telegram-plugin/gateway/model-command.ts +9 -3
  44. package/telegram-plugin/gateway/pending-session-command.ts +13 -1
  45. package/telegram-plugin/gateway/permission-ttl-sweep.ts +66 -0
  46. package/telegram-plugin/gateway/pre-approval-check.ts +74 -0
  47. package/telegram-plugin/gateway/queued-card-store.ts +217 -0
  48. package/telegram-plugin/gateway/session-model-file.ts +26 -1
  49. package/telegram-plugin/gateway/turn-end-gate-backstop.ts +59 -0
  50. package/telegram-plugin/gateway/turn-end-gate.ts +95 -0
  51. package/telegram-plugin/gateway/turn-typing-loop.ts +10 -2
  52. package/telegram-plugin/gateway/unhandled-rejection-policy.ts +13 -0
  53. package/telegram-plugin/history.ts +51 -0
  54. package/telegram-plugin/hooks/dispatch-claim-scan.mjs +259 -0
  55. package/telegram-plugin/hooks/dispatch-claim-stop.mjs +129 -0
  56. package/telegram-plugin/hooks/hooks.json +9 -0
  57. package/telegram-plugin/inline-keyboard-callbacks.ts +303 -2
  58. package/telegram-plugin/model-unavailable.ts +41 -11
  59. package/telegram-plugin/operator-events.ts +23 -0
  60. package/telegram-plugin/outbound-field-redact.ts +69 -0
  61. package/telegram-plugin/package.json +0 -1
  62. package/telegram-plugin/permission-rule.ts +1 -0
  63. package/telegram-plugin/permission-title.ts +1 -0
  64. package/telegram-plugin/render/render.ts +32 -14
  65. package/telegram-plugin/retry-api-call.ts +212 -2
  66. package/telegram-plugin/scoped-approval.ts +11 -2
  67. package/telegram-plugin/secret-detect/chunker.ts +18 -4
  68. package/telegram-plugin/secret-detect/index.ts +12 -56
  69. package/telegram-plugin/send-gate-degraded.test.ts +574 -0
  70. package/telegram-plugin/send-gate-observability.test.ts +470 -0
  71. package/telegram-plugin/send-gate-observability.ts +355 -0
  72. package/telegram-plugin/send-gate.test.ts +717 -0
  73. package/telegram-plugin/send-gate.ts +1056 -0
  74. package/telegram-plugin/session-tail.ts +82 -7
  75. package/telegram-plugin/shared/bot-runtime.ts +17 -5
  76. package/telegram-plugin/shared/gw-trace-gate.ts +105 -0
  77. package/telegram-plugin/status-pin-driver.ts +52 -7
  78. package/telegram-plugin/status-pin.ts +81 -0
  79. package/telegram-plugin/subagent-watcher.ts +173 -18
  80. package/telegram-plugin/tests/activity-card-wiring.test.ts +18 -5
  81. package/telegram-plugin/tests/approval-hold-harness.ts +425 -0
  82. package/telegram-plugin/tests/approval-hold-outcome.test.ts +327 -0
  83. package/telegram-plugin/tests/approval-hold-record.test.ts +531 -0
  84. package/telegram-plugin/tests/approval-hold-redeliver.test.ts +602 -0
  85. package/telegram-plugin/tests/auth-loopback-relay.test.ts +533 -0
  86. package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +53 -7
  87. package/telegram-plugin/tests/busy-key-reaper.test.ts +1 -0
  88. package/telegram-plugin/tests/callback-query-handlers.test.ts +65 -0
  89. package/telegram-plugin/tests/dispatch-claim-scan.test.ts +250 -0
  90. package/telegram-plugin/tests/flood-breaker-blindness.test.ts +213 -0
  91. package/telegram-plugin/tests/flood-windows-persistence.test.ts +224 -0
  92. package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
  93. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +29 -1
  94. package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +66 -0
  95. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +57 -0
  96. package/telegram-plugin/tests/gw-trace-gate.test.ts +105 -0
  97. package/telegram-plugin/tests/history.test.ts +115 -0
  98. package/telegram-plugin/tests/idle-clear.test.ts +233 -3
  99. package/telegram-plugin/tests/inbound-dedup.test.ts +93 -0
  100. package/telegram-plugin/tests/inbound-message-types.test.ts +5 -1
  101. package/telegram-plugin/tests/inline-keyboard-callbacks.test.ts +448 -0
  102. package/telegram-plugin/tests/ipc-server-check-pre-approved.test.ts +194 -0
  103. package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +123 -0
  104. package/telegram-plugin/tests/missed-approvals-wiring.test.ts +1 -1
  105. package/telegram-plugin/tests/model-command.test.ts +14 -0
  106. package/telegram-plugin/tests/operator-events-session-tail.test.ts +74 -0
  107. package/telegram-plugin/tests/outbound-field-redact.test.ts +107 -0
  108. package/telegram-plugin/tests/pending-session-command.test.ts +21 -0
  109. package/telegram-plugin/tests/permission-card-routing.test.ts +30 -5
  110. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +8 -7
  111. package/telegram-plugin/tests/permission-rearm-wiring.test.ts +1 -1
  112. package/telegram-plugin/tests/pre-approval-check.test.ts +148 -0
  113. package/telegram-plugin/tests/queued-card-store.test.ts +232 -0
  114. package/telegram-plugin/tests/reaction-flush-turn-gated.test.ts +100 -0
  115. package/telegram-plugin/tests/reaction-gate-routing.test.ts +173 -0
  116. package/telegram-plugin/tests/render/render.test.ts +88 -0
  117. package/telegram-plugin/tests/retry-api-call.test.ts +398 -0
  118. package/telegram-plugin/tests/scoped-approval.test.ts +27 -0
  119. package/telegram-plugin/tests/secret-detect-chunk-overlap.test.ts +65 -0
  120. package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +5 -4
  121. package/telegram-plugin/tests/session-model-file.test.ts +50 -0
  122. package/telegram-plugin/tests/session-tail-sidecar-reap.test.ts +268 -0
  123. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +35 -14
  124. package/telegram-plugin/tests/status-pin.test.ts +275 -1
  125. package/telegram-plugin/tests/subagent-watcher-deferral-log-ratelimit.test.ts +316 -0
  126. package/telegram-plugin/tests/subagent-watcher-fd-leak.test.ts +275 -0
  127. package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +92 -0
  128. package/telegram-plugin/tests/turn-end-gate.test.ts +137 -0
  129. package/telegram-plugin/tests/typing-emitter.test.ts +586 -0
  130. package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +20 -0
  131. package/telegram-plugin/tests/worktree-watch-cwds.test.ts +215 -1
  132. package/telegram-plugin/typing-emitter.ts +224 -0
  133. package/telegram-plugin/uat/scenarios/jtbd-feel-like-a-colleague-dm.test.ts +136 -0
  134. package/telegram-plugin/welcome-text.ts +42 -0
  135. package/telegram-plugin/worktree-watch-cwds.ts +194 -5
  136. package/vendor/hindsight-memory/scripts/drain_pending.py +22 -6
  137. package/vendor/hindsight-memory/scripts/lib/client.py +12 -5
  138. package/vendor/hindsight-memory/scripts/lib/directives.py +38 -3
  139. package/vendor/hindsight-memory/scripts/lib/pending.py +36 -9
  140. package/vendor/hindsight-memory/scripts/session_end.py +14 -3
  141. package/vendor/hindsight-memory/scripts/session_start.py +21 -0
  142. package/vendor/hindsight-memory/scripts/tests/test_directives.py +38 -0
  143. package/vendor/hindsight-memory/tests/test_drain_pending.py +68 -0
  144. package/vendor/hindsight-memory/tests/test_pending.py +44 -0
  145. package/vendor/hindsight-memory/tests/test_session_end_pending.py +38 -0
  146. package/vendor/hindsight-memory/tests/test_session_start_drain.py +155 -0
  147. package/telegram-plugin/channel-envelope-safety.test.ts +0 -56
  148. package/telegram-plugin/channel-envelope-safety.ts +0 -56
  149. package/telegram-plugin/secret-detect/secretlint-source.ts +0 -95
  150. 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
+ }