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,670 @@
1
+ /**
2
+ * Telegram-native OAuth loopback relay for Google / Microsoft account add
3
+ * (issue #2582).
4
+ *
5
+ * The problem: `switchroom auth google|microsoft account add <email>` mints
6
+ * credentials via the desktop-loopback OAuth flow — the CLI binds an ephemeral
7
+ * `127.0.0.1:<port>` listener, prints a consent URL whose `redirect_uri`
8
+ * points at that port, and waits for the browser to redirect back with
9
+ * `?code=...&state=...`. On a headless fleet host the redirect target isn't
10
+ * reachable from the operator's phone, which *looks* un-relayable — but isn't.
11
+ * The operator can open the consent URL on their phone, approve, and although
12
+ * the redirect to `127.0.0.1:<port>` fails to load there, their address bar
13
+ * holds the `?code=...&state=...`. Paste that back over Telegram and the
14
+ * gateway can hand it to the still-waiting loopback listener.
15
+ *
16
+ * This module owns that relay end-to-end, mirroring `auth-add-flow.ts`:
17
+ *
18
+ * 1. Operator sends `/auth google add <email>` (or `/auth microsoft add`).
19
+ * 2. Gateway calls {@link startLoopbackFlow} → spawns the real
20
+ * `switchroom auth <provider> account add` CLI as a child process with
21
+ * **piped stdout**, scrapes the consent URL the CLI prints, extracts the
22
+ * CSRF `state` + loopback `port` from it, and stashes a
23
+ * {@link PendingLoopbackFlow} keyed by chat.
24
+ * 3. Gateway replies to chat with the consent URL + paste instructions.
25
+ * 4. Operator approves on their phone, copies the failed-redirect URL from
26
+ * the address bar, pastes it into chat. The gateway's message intercept
27
+ * catches the paste and calls {@link submitLoopbackRedirect}.
28
+ * 5. Helper validates `state` against the pending flow, then HTTP-GETs the
29
+ * live loopback listener at `127.0.0.1:<port>` with the pasted query
30
+ * string. The CLI's own listener re-validates `state`, exchanges the
31
+ * code, registers the account with the auth-broker, and exits 0.
32
+ * 6. Success = the child process exits 0. Confirmed to chat; the pasted
33
+ * redirect (which carries the `code`) is redacted by the gateway.
34
+ *
35
+ * Why the CLI subprocess (vs. importing `runLoopbackOAuth` in-process):
36
+ *
37
+ * - The gateway *can* import `src/` modules (auth-add-flow.ts already
38
+ * imports `src/auth/manager.js`), so an in-process drive is technically
39
+ * possible. But `runLoopbackOAuth` only returns a `TokenResponse` — the
40
+ * large, security-sensitive registration surface (scope selection, vault
41
+ * client-secret resolution, `buildGoogleCredentials`, broker `addAccount`,
42
+ * `--replace` semantics) lives in `src/cli/auth-google.ts` /
43
+ * `src/cli/auth-microsoft.ts`. Reproducing it in the gateway would
44
+ * duplicate that surface and drift from the CLI. Driving the CLI keeps it
45
+ * the single source of truth for registration.
46
+ * - Unlike `claude setup-token` (which writes to `/dev/tty` and needs a
47
+ * tmux pty — see auth-add-flow.ts), the switchroom CLI prints the consent
48
+ * URL via `console.log` to stdout. A plain piped child process captures
49
+ * it — no tmux, no pty. This is the incident-validated mechanism
50
+ * (2026-06-26): run the flow in the agent container that holds the grant,
51
+ * feed the listener a loopback GET.
52
+ *
53
+ * **Security invariants (must hold):**
54
+ * - The `code` and any token are NEVER logged or echoed to chat. This module
55
+ * does not log. Confirmations name only the account label. The gateway
56
+ * redacts the pasted redirect message.
57
+ * - `state` is validated in-process before the loopback GET, AND the CLI's
58
+ * own listener re-validates it — defence in depth.
59
+ * - Single-use: a flow that has begun submitting or completed rejects any
60
+ * further paste (no code accepted after completion). TTL sweep drops
61
+ * abandoned flows.
62
+ */
63
+
64
+ import { spawn } from 'node:child_process'
65
+ import type { ChildProcess } from 'node:child_process'
66
+
67
+ export type LoopbackProvider = 'google' | 'microsoft'
68
+
69
+ /* ── Injectable child-process seam (unit tests mock this) ─────────────────── */
70
+
71
+ /**
72
+ * The narrow slice of `ChildProcess` the relay drives. Injected in tests as a
73
+ * fake so no real CLI is spawned; satisfied by a real `ChildProcess` in prod.
74
+ */
75
+ export interface RelayChild {
76
+ stdout: NodeJS.ReadableStream | null
77
+ stderr: NodeJS.ReadableStream | null
78
+ on(event: 'exit', cb: (code: number | null) => void): void
79
+ on(event: 'error', cb: (err: Error) => void): void
80
+ kill(signal?: NodeJS.Signals | number): boolean
81
+ }
82
+
83
+ export interface SpawnRelayOpts {
84
+ replace?: boolean
85
+ /** Google `--write` (Drive write scope). */
86
+ write?: boolean
87
+ /** Microsoft `--org-mode`. */
88
+ orgMode?: boolean
89
+ /** Override the CLI binary (tests / non-default install). */
90
+ binary?: string
91
+ }
92
+
93
+ export type SpawnRelay = (
94
+ provider: LoopbackProvider,
95
+ email: string,
96
+ opts: SpawnRelayOpts,
97
+ ) => RelayChild
98
+
99
+ /**
100
+ * Default spawn: the real `switchroom` CLI, stdout+stderr piped, stdin
101
+ * ignored. `BROWSER=/bin/true` suppresses any browser auto-open; the Drive
102
+ * tier is pinned to desktop-loopback (the only Drive-viable flow — see
103
+ * src/drive/oauth.ts). stdin is `ignore` so a stray interactive prompt gets
104
+ * EOF and the child exits with an error rather than hanging forever.
105
+ */
106
+ export function defaultSpawnRelay(
107
+ provider: LoopbackProvider,
108
+ email: string,
109
+ opts: SpawnRelayOpts,
110
+ ): RelayChild {
111
+ const binary = opts.binary ?? 'switchroom'
112
+ const args =
113
+ provider === 'google'
114
+ ? [
115
+ 'auth',
116
+ 'google',
117
+ 'account',
118
+ 'add',
119
+ email,
120
+ ...(opts.replace ? ['--replace'] : []),
121
+ ...(opts.write ? ['--write'] : []),
122
+ ]
123
+ : [
124
+ 'auth',
125
+ 'microsoft',
126
+ 'account',
127
+ 'add',
128
+ email,
129
+ ...(opts.replace ? ['--replace'] : []),
130
+ ...(opts.orgMode ? ['--org-mode'] : []),
131
+ ]
132
+ const child: ChildProcess = spawn(binary, args, {
133
+ stdio: ['ignore', 'pipe', 'pipe'],
134
+ env: {
135
+ ...process.env,
136
+ BROWSER: '/bin/true',
137
+ SWITCHROOM_DRIVE_OAUTH_TIER:
138
+ process.env.SWITCHROOM_DRIVE_OAUTH_TIER ?? 'desktop_loopback',
139
+ },
140
+ })
141
+ return child
142
+ }
143
+
144
+ /* ── Pending-state map ────────────────────────────────────────────────────── */
145
+
146
+ /**
147
+ * In-flight loopback relay keyed by Telegram chat/thread key. The gateway's
148
+ * generic message intercept reads this map to decide whether a pasted
149
+ * `127.0.0.1:<port>/?...` redirect belongs to a loopback relay flow.
150
+ */
151
+ export interface PendingLoopbackFlow {
152
+ provider: LoopbackProvider
153
+ email: string
154
+ /** CSRF state parsed from the consent URL — the paste must echo it. */
155
+ state: string
156
+ /** Ephemeral loopback port the CLI listener is bound to. */
157
+ port: number
158
+ /** The consent URL surfaced to chat (never contains the code). */
159
+ consentUrl: string
160
+ /** The live CLI child process (its listener awaits the loopback GET). */
161
+ child: RelayChild
162
+ startedAt: number
163
+ /**
164
+ * Set once a valid paste has been accepted and the loopback GET is in
165
+ * flight / the flow has completed. Guards against a second paste replaying
166
+ * or racing the first (single-use).
167
+ */
168
+ submitting: boolean
169
+ /**
170
+ * Retryable-rejection counter (bad paste, wrong state, wrong port). The
171
+ * flow is ended after {@link MAX_PASTE_ATTEMPTS} rejections rather than
172
+ * waiting for the TTL — an operator repeatedly pasting the wrong thing
173
+ * should get a decisive stop, not a silent 10-minute window.
174
+ */
175
+ attempts: number
176
+ /** Set by {@link trackFlowExit} once the CLI child has exited. */
177
+ exited?: boolean
178
+ /** Exit code recorded by {@link trackFlowExit} (null = signal-killed). */
179
+ exitCode?: number | null
180
+ }
181
+
182
+ /**
183
+ * Bounded paste attempts — after this many retryable rejections the flow is
184
+ * ended (non-retryable) so a confused paste loop can't run to the TTL.
185
+ */
186
+ export const MAX_PASTE_ATTEMPTS = 5
187
+
188
+ export const pendingLoopbackFlows = new Map<string, PendingLoopbackFlow>()
189
+
190
+ /* ── ANSI stripping ───────────────────────────────────────────────────────── */
191
+
192
+ // eslint-disable-next-line no-control-regex
193
+ const ANSI_RE = /\x1b\[[0-9;]*m/g
194
+
195
+ function stripAnsi(s: string): string {
196
+ return s.replace(ANSI_RE, '')
197
+ }
198
+
199
+ /* ── Consent-URL extraction + parsing ─────────────────────────────────────── */
200
+
201
+ const CONSENT_HOST: Record<LoopbackProvider, RegExp> = {
202
+ // Google: https://accounts.google.com/o/oauth2/auth?...
203
+ google: /https:\/\/accounts\.google\.com\/o\/oauth2\/[^\s"'<>]+/,
204
+ // Microsoft: https://login.microsoftonline.com/<tenant>/oauth2/v2.0/authorize?...
205
+ microsoft: /https:\/\/login\.microsoftonline\.com\/[^\s"'<>]+/,
206
+ }
207
+
208
+ /**
209
+ * Scrape the provider's consent URL out of a chunk of CLI stdout. Returns the
210
+ * first match that carries both a `redirect_uri` and a `state` param (the
211
+ * loopback consent URL), or `null` if none is present yet.
212
+ */
213
+ export function extractConsentUrl(
214
+ text: string,
215
+ provider: LoopbackProvider,
216
+ ): string | null {
217
+ const clean = stripAnsi(text)
218
+ const re = new RegExp(CONSENT_HOST[provider].source, 'g')
219
+ let m: RegExpExecArray | null
220
+ while ((m = re.exec(clean)) !== null) {
221
+ const candidate = m[0]
222
+ // Trim trailing punctuation the terminal may append (rare, but the
223
+ // regex class already excludes whitespace/quotes/angle-brackets).
224
+ let url: URL
225
+ try {
226
+ url = new URL(candidate)
227
+ } catch {
228
+ continue
229
+ }
230
+ if (url.searchParams.has('redirect_uri') && url.searchParams.has('state')) {
231
+ return candidate
232
+ }
233
+ }
234
+ return null
235
+ }
236
+
237
+ export interface ParsedConsentUrl {
238
+ state: string
239
+ port: number
240
+ redirectUri: string
241
+ }
242
+
243
+ /**
244
+ * Pull the CSRF `state` and the loopback `port` out of a consent URL. Throws
245
+ * if the URL is malformed or is not a loopback (`127.0.0.1` / `localhost`)
246
+ * redirect — a non-loopback redirect means this isn't a relayable flow.
247
+ */
248
+ export function parseConsentUrl(consentUrl: string): ParsedConsentUrl {
249
+ const url = new URL(consentUrl)
250
+ const state = url.searchParams.get('state')
251
+ const redirectUri = url.searchParams.get('redirect_uri')
252
+ if (!state) throw new Error('consent URL missing state parameter')
253
+ if (!redirectUri) throw new Error('consent URL missing redirect_uri parameter')
254
+ let redir: URL
255
+ try {
256
+ redir = new URL(redirectUri)
257
+ } catch {
258
+ throw new Error('consent URL redirect_uri is not a valid URL')
259
+ }
260
+ if (redir.hostname !== '127.0.0.1' && redir.hostname !== 'localhost') {
261
+ throw new Error(
262
+ `redirect_uri host is ${redir.hostname}, not a loopback address — not a relayable flow`,
263
+ )
264
+ }
265
+ const port = Number(redir.port)
266
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) {
267
+ throw new Error(`redirect_uri has no usable port (got ${redir.port || '(none)'})`)
268
+ }
269
+ return { state, port, redirectUri }
270
+ }
271
+
272
+ /* ── Pasted-redirect parsing ──────────────────────────────────────────────── */
273
+
274
+ export type ParsedRedirect =
275
+ | { ok: true; host: string; port: number; code: string; state: string }
276
+ | { ok: false; reason: string }
277
+
278
+ /**
279
+ * Parse the redirect URL the operator pastes back from their phone's address
280
+ * bar. Tolerant of surrounding whitespace and a missing scheme
281
+ * (`127.0.0.1:1234/?code=...` → treated as `http://`). Rejects non-loopback
282
+ * hosts and anything missing `code` or `state`.
283
+ *
284
+ * The `code` is returned so the caller can forward it to the loopback
285
+ * listener — it is NEVER logged.
286
+ */
287
+ export function parseLoopbackRedirect(input: string): ParsedRedirect {
288
+ const trimmed = (input ?? '').trim()
289
+ if (trimmed.length === 0) return { ok: false, reason: 'empty input' }
290
+ // Accept a bare `127.0.0.1:port/?...` (no scheme) by prepending http://.
291
+ const withScheme = /^https?:\/\//i.test(trimmed) ? trimmed : `http://${trimmed}`
292
+ let url: URL
293
+ try {
294
+ url = new URL(withScheme)
295
+ } catch {
296
+ return { ok: false, reason: 'not a URL' }
297
+ }
298
+ if (url.hostname !== '127.0.0.1' && url.hostname !== 'localhost') {
299
+ return {
300
+ ok: false,
301
+ reason: `host is ${url.hostname || '(none)'}, expected 127.0.0.1`,
302
+ }
303
+ }
304
+ const port = Number(url.port)
305
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) {
306
+ return { ok: false, reason: 'missing or invalid port' }
307
+ }
308
+ const err = url.searchParams.get('error')
309
+ if (err) {
310
+ return { ok: false, reason: `provider returned error: ${err}` }
311
+ }
312
+ const code = url.searchParams.get('code')
313
+ const state = url.searchParams.get('state')
314
+ if (!code) return { ok: false, reason: 'missing code parameter' }
315
+ if (!state) return { ok: false, reason: 'missing state parameter' }
316
+ return { ok: true, host: url.hostname, port, code, state }
317
+ }
318
+
319
+ /**
320
+ * A loopback host reference (`127.0.0.1` / `localhost`) anywhere in the text.
321
+ * Anchors the fail-safe detection to the loopback OAuth flow so a bare `code=`
322
+ * on some foreign host is NOT swept up (preserves the deliberate narrowness).
323
+ */
324
+ const LOOPBACK_HOST_REF_RE = /(?:127\.0\.0\.1|localhost)/i
325
+
326
+ /**
327
+ * A `code=` / token param carrying a non-empty value — the same param shapes
328
+ * the well-formed loopback-redirect path exchanges. Matched loosely (no strict
329
+ * URL structure) so a malformed paste — dropped port, missing `/`, stray
330
+ * surrounding text — that still carries a live OAuth `code` is caught.
331
+ */
332
+ const AUTH_CODE_PARAM_RE =
333
+ /[?&#/]?\b(?:code|access_token|refresh_token|id_token)=[^\s&#]+/i
334
+
335
+ /** A provider-denied `error=` param (the flow should surface + end). */
336
+ const PROVIDER_ERROR_PARAM_RE = /[?&]error=[^\s&]+/i
337
+
338
+ /**
339
+ * Fail-safe (security audit #3084, F2): does this text *look like* a loopback
340
+ * OAuth paste that carries a live `code` (or a provider `error`), even when it
341
+ * is too malformed to parse as a clean redirect URL? True only when BOTH a
342
+ * loopback host reference AND a code/token/error param are present — so
343
+ * unrelated chatter mentioning `localhost` (no `code=`) still flows through.
344
+ *
345
+ * This is intentionally broader than {@link parseLoopbackRedirect}: a paste
346
+ * with a dropped port or a missing `/` won't parse, yet may still hold a live
347
+ * credential that must be redacted rather than forwarded to the agent.
348
+ */
349
+ export function looksLikeLoopbackCodePaste(text: string): boolean {
350
+ const trimmed = (text ?? '').trim()
351
+ if (trimmed.length === 0) return false
352
+ if (!LOOPBACK_HOST_REF_RE.test(trimmed)) return false
353
+ return (
354
+ AUTH_CODE_PARAM_RE.test(trimmed) || PROVIDER_ERROR_PARAM_RE.test(trimmed)
355
+ )
356
+ }
357
+
358
+ /**
359
+ * Decide whether an inbound chat message should be CONSUMED by the loopback
360
+ * paste intercept (and therefore never reach other handlers, and be deleted
361
+ * from history). Deliberately narrow: a message merely *mentioning*
362
+ * `localhost`/`127.0.0.1` ("what's listening on localhost?") must flow
363
+ * through untouched. We consume when the text parses as a loopback redirect
364
+ * URL carrying a `code` — or a loopback URL carrying an `error` param (the
365
+ * provider-denied case, which the flow should surface) — OR, fail-safe, when
366
+ * it merely *looks like* a loopback code paste that is too malformed to parse
367
+ * cleanly but still carries a live `code` (security audit #3084, F2). A live
368
+ * credential must never fall through to the agent session unredacted.
369
+ */
370
+ export function shouldConsumeLoopbackPaste(text: string): boolean {
371
+ const trimmed = (text ?? '').trim()
372
+ if (trimmed.length === 0) return false
373
+ const parsed = parseLoopbackRedirect(trimmed)
374
+ if (parsed.ok) return true
375
+ // parseLoopbackRedirect rejects the error-param case with a
376
+ // "provider returned error" reason — that IS a redirect paste and the
377
+ // intercept should consume it to end/inform the flow.
378
+ if (!parsed.ok && parsed.reason.startsWith('provider returned error')) {
379
+ return true
380
+ }
381
+ // Fail-safe: malformed-but-code-bearing loopback paste.
382
+ if (looksLikeLoopbackCodePaste(trimmed)) return true
383
+ return false
384
+ }
385
+
386
+ /**
387
+ * Attach an exit tracker to a pending flow so a child that dies BEFORE the
388
+ * operator pastes (crash, listener error) is observable: `flow.exited` /
389
+ * `flow.exitCode` are set, and {@link submitLoopbackRedirect} fails fast with
390
+ * a clear message instead of hanging out its completion timeout waiting for
391
+ * an `exit` event that already fired.
392
+ */
393
+ export function trackFlowExit(flow: PendingLoopbackFlow): void {
394
+ flow.child.on('exit', (code) => {
395
+ flow.exited = true
396
+ flow.exitCode = code
397
+ })
398
+ }
399
+
400
+ /* ── Flow lifecycle ───────────────────────────────────────────────────────── */
401
+
402
+ export interface StartLoopbackResult {
403
+ consentUrl: string
404
+ state: string
405
+ port: number
406
+ child: RelayChild
407
+ }
408
+
409
+ export interface StartLoopbackOpts extends SpawnRelayOpts {
410
+ spawnImpl?: SpawnRelay
411
+ /** How long to wait for the CLI to print its consent URL. Default 20s. */
412
+ urlTimeoutMs?: number
413
+ }
414
+
415
+ /**
416
+ * Spawn the CLI account-add flow and resolve once its consent URL has been
417
+ * scraped from stdout. On timeout or premature child exit the child is killed
418
+ * and the promise rejects. The caller stashes the returned handle in
419
+ * {@link pendingLoopbackFlows} and keeps the child alive until the operator
420
+ * pastes the redirect (or the flow is cancelled / reaped).
421
+ */
422
+ export async function startLoopbackFlow(
423
+ provider: LoopbackProvider,
424
+ email: string,
425
+ opts: StartLoopbackOpts = {},
426
+ ): Promise<StartLoopbackResult> {
427
+ const spawnImpl = opts.spawnImpl ?? defaultSpawnRelay
428
+ const urlTimeoutMs = opts.urlTimeoutMs ?? 20_000
429
+ const child = spawnImpl(provider, email, {
430
+ replace: opts.replace,
431
+ write: opts.write,
432
+ orgMode: opts.orgMode,
433
+ binary: opts.binary,
434
+ })
435
+
436
+ return await new Promise<StartLoopbackResult>((resolve, reject) => {
437
+ let settled = false
438
+ let buffer = ''
439
+ let timer: ReturnType<typeof setTimeout> | null = null
440
+
441
+ const cleanup = () => {
442
+ if (timer) clearTimeout(timer)
443
+ timer = null
444
+ }
445
+ const finishOk = (result: StartLoopbackResult) => {
446
+ if (settled) return
447
+ settled = true
448
+ cleanup()
449
+ resolve(result)
450
+ }
451
+ const finishErr = (err: Error) => {
452
+ if (settled) return
453
+ settled = true
454
+ cleanup()
455
+ try {
456
+ child.kill()
457
+ } catch {
458
+ /* best-effort */
459
+ }
460
+ reject(err)
461
+ }
462
+
463
+ timer = setTimeout(() => {
464
+ finishErr(
465
+ new Error(
466
+ `CLI did not print a consent URL within ${Math.round(urlTimeoutMs / 1000)}s`,
467
+ ),
468
+ )
469
+ }, urlTimeoutMs)
470
+
471
+ const onChunk = (chunk: Buffer | string) => {
472
+ // Cap the buffer so a chatty CLI can't grow it unbounded before the URL.
473
+ buffer = (buffer + chunk.toString()).slice(-16_384)
474
+ const consentUrl = extractConsentUrl(buffer, provider)
475
+ if (!consentUrl) return
476
+ let parsed: ParsedConsentUrl
477
+ try {
478
+ parsed = parseConsentUrl(consentUrl)
479
+ } catch (e) {
480
+ finishErr(e instanceof Error ? e : new Error(String(e)))
481
+ return
482
+ }
483
+ finishOk({ consentUrl, state: parsed.state, port: parsed.port, child })
484
+ }
485
+
486
+ child.stdout?.on('data', onChunk)
487
+ // Some CLIs print prompts to stderr; scan it too.
488
+ child.stderr?.on('data', onChunk)
489
+ child.on('error', (err) => finishErr(err))
490
+ child.on('exit', (code) => {
491
+ finishErr(
492
+ new Error(
493
+ `CLI exited (code ${code ?? 'null'}) before printing a consent URL`,
494
+ ),
495
+ )
496
+ })
497
+ })
498
+ }
499
+
500
+ export type SubmitResult =
501
+ | { ok: true }
502
+ | { ok: false; reason: string; retryable: boolean }
503
+
504
+ export interface SubmitOpts {
505
+ fetchImpl?: typeof fetch
506
+ /** How long to wait for the child to exit 0 after the loopback GET. Default 60s. */
507
+ completionTimeoutMs?: number
508
+ }
509
+
510
+ const CODE_LIKE_RE = /\b(code|access_token|refresh_token|id_token)=[^\s&]+/gi
511
+ const LOOPBACK_URL_RE = /https?:\/\/(127\.0\.0\.1|localhost):\d+\/\S*/gi
512
+
513
+ /**
514
+ * Scrub anything code/token/loopback-URL-shaped out of a string before it is
515
+ * shown to the operator. Defence in depth — the CLI does not print the code,
516
+ * but stderr tails are attacker-adjacent text.
517
+ */
518
+ export function scrubSensitive(s: string): string {
519
+ return s
520
+ .replace(LOOPBACK_URL_RE, '[redirect-url-redacted]')
521
+ .replace(CODE_LIKE_RE, (_m, k) => `${k}=[redacted]`)
522
+ }
523
+
524
+ /**
525
+ * Validate the pasted redirect against the pending flow and, on success,
526
+ * hand the code to the live loopback listener. Resolves `{ ok: true }` once
527
+ * the CLI child exits 0 (account registered).
528
+ *
529
+ * Retryable failures (bad paste, state mismatch, wrong port) leave the flow
530
+ * pending so the operator can paste again. Non-retryable failures
531
+ * (already-submitted, listener/registration error) end the flow — the caller
532
+ * removes it from the pending map.
533
+ */
534
+ export async function submitLoopbackRedirect(
535
+ flow: PendingLoopbackFlow,
536
+ pastedText: string,
537
+ opts: SubmitOpts = {},
538
+ ): Promise<SubmitResult> {
539
+ const fetchImpl = opts.fetchImpl ?? fetch
540
+ const completionTimeoutMs = opts.completionTimeoutMs ?? 60_000
541
+
542
+ // A retryable rejection that would exceed the attempt budget becomes a
543
+ // non-retryable flow-ending one (bounded paste attempts — see
544
+ // MAX_PASTE_ATTEMPTS).
545
+ const rejectRetryable = (reason: string): SubmitResult => {
546
+ flow.attempts += 1
547
+ if (flow.attempts >= MAX_PASTE_ATTEMPTS) {
548
+ return {
549
+ ok: false,
550
+ reason:
551
+ `${reason} — ${MAX_PASTE_ATTEMPTS} rejected pastes; ending this flow. ` +
552
+ `Start over with a fresh add command.`,
553
+ retryable: false,
554
+ }
555
+ }
556
+ return { ok: false, reason, retryable: true }
557
+ }
558
+
559
+ if (flow.submitting) {
560
+ return {
561
+ ok: false,
562
+ reason: 'this flow has already been completed',
563
+ retryable: false,
564
+ }
565
+ }
566
+ if (flow.exited) {
567
+ // Child died before the paste (crash, port bind error). The listener is
568
+ // gone — no paste can ever succeed; fail fast instead of timing out.
569
+ return {
570
+ ok: false,
571
+ reason: `the CLI exited (code ${flow.exitCode ?? 'null'}) before the paste — start a new flow`,
572
+ retryable: false,
573
+ }
574
+ }
575
+
576
+ const parsed = parseLoopbackRedirect(pastedText)
577
+ if (!parsed.ok) {
578
+ return rejectRetryable(parsed.reason)
579
+ }
580
+ if (parsed.state !== flow.state) {
581
+ // CSRF guard — keep waiting; the operator may have pasted a stale URL.
582
+ return rejectRetryable('state parameter does not match this flow (CSRF guard)')
583
+ }
584
+ if (parsed.port !== flow.port) {
585
+ return rejectRetryable(
586
+ `redirect port ${parsed.port} does not match this flow's listener`,
587
+ )
588
+ }
589
+ // Single-use latch — set before any await so a racing second paste bounces.
590
+ flow.submitting = true
591
+
592
+ // Forward the code + state to the live loopback listener. The listener
593
+ // re-validates state, exchanges the code, registers the account, exits 0.
594
+ const loopbackUrl = new URL(`http://127.0.0.1:${flow.port}/`)
595
+ loopbackUrl.searchParams.set('state', parsed.state)
596
+ loopbackUrl.searchParams.set('code', parsed.code)
597
+
598
+ // Collect a bounded stderr tail for error reporting (scrubbed before use).
599
+ let stderrTail = ''
600
+ const onErrChunk = (chunk: Buffer | string) => {
601
+ stderrTail = (stderrTail + chunk.toString()).slice(-2_048)
602
+ }
603
+ flow.child.stderr?.on('data', onErrChunk)
604
+
605
+ const exitPromise = new Promise<number | null>((resolve) => {
606
+ // Guard the tiny race where the child exits between the upfront
607
+ // `flow.exited` check and this listener registration — an `exit` event
608
+ // that already fired never re-fires, which would hang us to the timeout.
609
+ if (flow.exited) {
610
+ resolve(flow.exitCode ?? null)
611
+ return
612
+ }
613
+ flow.child.on('exit', (code) => resolve(code))
614
+ })
615
+
616
+ try {
617
+ await fetchImpl(loopbackUrl.toString(), { method: 'GET' })
618
+ } catch (e) {
619
+ // The listener typically closes the socket immediately after accepting
620
+ // the code, which can surface as a fetch error even on success — so we
621
+ // do NOT treat a fetch error as fatal here; the child exit code is the
622
+ // real signal. Fall through to await the exit.
623
+ void e
624
+ }
625
+
626
+ const timeout = new Promise<'timeout'>((resolve) =>
627
+ setTimeout(() => resolve('timeout'), completionTimeoutMs),
628
+ )
629
+ const outcome = await Promise.race([exitPromise, timeout])
630
+
631
+ if (outcome === 'timeout') {
632
+ try {
633
+ flow.child.kill()
634
+ } catch {
635
+ /* best-effort */
636
+ }
637
+ return {
638
+ ok: false,
639
+ reason: `registration did not complete within ${Math.round(
640
+ completionTimeoutMs / 1000,
641
+ )}s`,
642
+ retryable: false,
643
+ }
644
+ }
645
+
646
+ if (outcome === 0) {
647
+ return { ok: true }
648
+ }
649
+ const tail = scrubSensitive(stripAnsi(stderrTail)).trim()
650
+ return {
651
+ ok: false,
652
+ reason: tail
653
+ ? `CLI exited with code ${outcome}: ${tail}`
654
+ : `CLI exited with code ${outcome}`,
655
+ retryable: false,
656
+ }
657
+ }
658
+
659
+ /**
660
+ * Cancel an in-flight loopback relay: kill the CLI child. Idempotent — safe to
661
+ * call after the child has already exited. The caller removes the pending map
662
+ * entry.
663
+ */
664
+ export function cancelLoopbackFlow(flow: PendingLoopbackFlow): void {
665
+ try {
666
+ flow.child.kill()
667
+ } catch {
668
+ /* best-effort */
669
+ }
670
+ }
@@ -69,7 +69,7 @@ import {
69
69
  } from './config-snapshot.js'
70
70
  import { join } from 'path'
71
71
  import { bootCardChatKey, loadBootCardMsgId, saveBootCardMsgId } from './boot-card-msgid.js'
72
- import { suppressNonEssentialSendMs } from '../flood-circuit-breaker.js'
72
+ import { nonEssentialSendSuppression } from '../flood-circuit-breaker.js'
73
73
  import { loadConfig as _loadSwitchroomConfig } from '../../src/config/loader.js'
74
74
  import { resolveAgentConfig as _resolveAgentConfig } from '../../src/config/merge.js'
75
75
 
@@ -669,11 +669,18 @@ export async function startBootCard(
669
669
  // keeps the agent mute for longer. Skip loudly (log) and return a no-op.
670
670
  if (opts.floodStatePath != null) {
671
671
  const now = (opts.nowMs ?? Date.now)()
672
- const remainingMs = suppressNonEssentialSendMs(opts.floodStatePath, now)
673
- if (remainingMs > 0) {
672
+ const s = nonEssentialSendSuppression(opts.floodStatePath, now)
673
+ if (s.suppress) {
674
+ // #3106: two reasons, and the operator must be told WHICH. A blind
675
+ // breaker (unreadable marker) is a bug in the deployment, not a ban.
674
676
  logger(
675
- `telegram gateway: boot-card: SUPPRESSED — Telegram flood-wait active for ~${Math.round(remainingMs / 1000)}s; ` +
676
- `not posting a restart card into the open ban window (issue #2923)\n`,
677
+ s.reason === 'blind'
678
+ ? `telegram gateway: boot-card: SUPPRESSED flood-breaker is BLIND: cannot read ` +
679
+ `${opts.floodStatePath} (${s.error}). Cannot rule out an open Telegram ban, so the ` +
680
+ `restart card is held back rather than posted into one. Fix the marker's ` +
681
+ `ownership/mode (issue #3106)\n`
682
+ : `telegram gateway: boot-card: SUPPRESSED — Telegram flood-wait active for ~${Math.round(s.remainingMs / 1000)}s; ` +
683
+ `not posting a restart card into the open ban window (issue #2923)\n`,
677
684
  )
678
685
  return { messageId: -1, complete: () => {} }
679
686
  }