switchroom 0.18.11 → 0.18.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-scheduler/index.js +37 -5
- package/dist/auth-broker/index.js +116 -78
- package/dist/cli/hindsight-mental-model-pretool.mjs +39 -0
- package/dist/cli/ms-365-write-pretool.mjs +31 -8
- package/dist/cli/notion-write-pretool.mjs +38 -6
- package/dist/cli/skill-validate-pretool.mjs +144 -2847
- package/dist/cli/switchroom.js +3529 -4543
- package/dist/cli/ui/index.html +163 -17
- package/dist/host-control/main.js +605 -2847
- package/dist/vault/approvals/kernel-server.js +120 -13
- package/dist/vault/broker/server.js +259 -157
- package/package.json +3 -4
- package/profiles/_base/start.sh.hbs +65 -0
- package/profiles/_shared/vault-protocol.md.hbs +3 -1
- package/profiles/coding/CLAUDE.md.hbs +1 -1
- package/profiles/default/CLAUDE.md.hbs +2 -2
- package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
- package/profiles/health-coach/CLAUDE.md.hbs +1 -1
- package/telegram-plugin/bridge/bridge.ts +37 -0
- package/telegram-plugin/bridge/inbound-dedup.ts +101 -0
- package/telegram-plugin/dist/bridge/bridge.js +122 -4
- package/telegram-plugin/dist/gateway/gateway.js +4213 -3288
- package/telegram-plugin/dist/server.js +139 -5
- package/telegram-plugin/flood-circuit-breaker.ts +493 -21
- package/telegram-plugin/format.ts +19 -0
- package/telegram-plugin/gateway/approval-hold.ts +602 -0
- package/telegram-plugin/gateway/auth-command.ts +92 -2
- package/telegram-plugin/gateway/auth-loopback-relay.ts +670 -0
- package/telegram-plugin/gateway/boot-card.ts +12 -5
- package/telegram-plugin/gateway/callback-query-handlers.ts +88 -1
- package/telegram-plugin/gateway/config-approval-handler.ts +6 -1
- package/telegram-plugin/gateway/disconnect-flush.ts +19 -0
- package/telegram-plugin/gateway/dm-pin-sweep.test.ts +251 -0
- package/telegram-plugin/gateway/dm-pin-sweep.ts +178 -0
- package/telegram-plugin/gateway/gateway.ts +1695 -230
- package/telegram-plugin/gateway/hostd-dispatch.ts +23 -0
- package/telegram-plugin/gateway/idle-clear.ts +90 -6
- package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +26 -5
- package/telegram-plugin/gateway/inject-handler.ts +8 -0
- package/telegram-plugin/gateway/ipc-protocol.ts +46 -3
- package/telegram-plugin/gateway/ipc-server.ts +43 -0
- package/telegram-plugin/gateway/mental-model-propose-resolve.ts +145 -37
- package/telegram-plugin/gateway/model-command.ts +9 -3
- package/telegram-plugin/gateway/pending-session-command.ts +13 -1
- package/telegram-plugin/gateway/permission-ttl-sweep.ts +66 -0
- package/telegram-plugin/gateway/pre-approval-check.ts +74 -0
- package/telegram-plugin/gateway/queued-card-store.ts +217 -0
- package/telegram-plugin/gateway/session-model-file.ts +26 -1
- package/telegram-plugin/gateway/turn-end-gate-backstop.ts +59 -0
- package/telegram-plugin/gateway/turn-end-gate.ts +95 -0
- package/telegram-plugin/gateway/turn-typing-loop.ts +10 -2
- package/telegram-plugin/gateway/unhandled-rejection-policy.ts +13 -0
- package/telegram-plugin/history.ts +51 -0
- package/telegram-plugin/hooks/dispatch-claim-scan.mjs +259 -0
- package/telegram-plugin/hooks/dispatch-claim-stop.mjs +129 -0
- package/telegram-plugin/hooks/hooks.json +9 -0
- package/telegram-plugin/inline-keyboard-callbacks.ts +303 -2
- package/telegram-plugin/model-unavailable.ts +41 -11
- package/telegram-plugin/operator-events.ts +23 -0
- package/telegram-plugin/outbound-field-redact.ts +69 -0
- package/telegram-plugin/package.json +0 -1
- package/telegram-plugin/permission-rule.ts +1 -0
- package/telegram-plugin/permission-title.ts +1 -0
- package/telegram-plugin/render/render.ts +32 -14
- package/telegram-plugin/retry-api-call.ts +212 -2
- package/telegram-plugin/scoped-approval.ts +11 -2
- package/telegram-plugin/secret-detect/chunker.ts +18 -4
- package/telegram-plugin/secret-detect/index.ts +12 -56
- package/telegram-plugin/send-gate-degraded.test.ts +574 -0
- package/telegram-plugin/send-gate-observability.test.ts +470 -0
- package/telegram-plugin/send-gate-observability.ts +355 -0
- package/telegram-plugin/send-gate.test.ts +717 -0
- package/telegram-plugin/send-gate.ts +1056 -0
- package/telegram-plugin/session-tail.ts +82 -7
- package/telegram-plugin/shared/bot-runtime.ts +17 -5
- package/telegram-plugin/shared/gw-trace-gate.ts +105 -0
- package/telegram-plugin/status-pin-driver.ts +52 -7
- package/telegram-plugin/status-pin.ts +81 -0
- package/telegram-plugin/subagent-watcher.ts +173 -18
- package/telegram-plugin/tests/activity-card-wiring.test.ts +18 -5
- package/telegram-plugin/tests/approval-hold-harness.ts +425 -0
- package/telegram-plugin/tests/approval-hold-outcome.test.ts +327 -0
- package/telegram-plugin/tests/approval-hold-record.test.ts +531 -0
- package/telegram-plugin/tests/approval-hold-redeliver.test.ts +602 -0
- package/telegram-plugin/tests/auth-loopback-relay.test.ts +533 -0
- package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +53 -7
- package/telegram-plugin/tests/busy-key-reaper.test.ts +1 -0
- package/telegram-plugin/tests/callback-query-handlers.test.ts +65 -0
- package/telegram-plugin/tests/dispatch-claim-scan.test.ts +250 -0
- package/telegram-plugin/tests/flood-breaker-blindness.test.ts +213 -0
- package/telegram-plugin/tests/flood-windows-persistence.test.ts +224 -0
- package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
- package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +29 -1
- package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +66 -0
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +57 -0
- package/telegram-plugin/tests/gw-trace-gate.test.ts +105 -0
- package/telegram-plugin/tests/history.test.ts +115 -0
- package/telegram-plugin/tests/idle-clear.test.ts +233 -3
- package/telegram-plugin/tests/inbound-dedup.test.ts +93 -0
- package/telegram-plugin/tests/inbound-message-types.test.ts +5 -1
- package/telegram-plugin/tests/inline-keyboard-callbacks.test.ts +448 -0
- package/telegram-plugin/tests/ipc-server-check-pre-approved.test.ts +194 -0
- package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +123 -0
- package/telegram-plugin/tests/missed-approvals-wiring.test.ts +1 -1
- package/telegram-plugin/tests/model-command.test.ts +14 -0
- package/telegram-plugin/tests/operator-events-session-tail.test.ts +74 -0
- package/telegram-plugin/tests/outbound-field-redact.test.ts +107 -0
- package/telegram-plugin/tests/pending-session-command.test.ts +21 -0
- package/telegram-plugin/tests/permission-card-routing.test.ts +30 -5
- package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +8 -7
- package/telegram-plugin/tests/permission-rearm-wiring.test.ts +1 -1
- package/telegram-plugin/tests/pre-approval-check.test.ts +148 -0
- package/telegram-plugin/tests/queued-card-store.test.ts +232 -0
- package/telegram-plugin/tests/reaction-flush-turn-gated.test.ts +100 -0
- package/telegram-plugin/tests/reaction-gate-routing.test.ts +173 -0
- package/telegram-plugin/tests/render/render.test.ts +88 -0
- package/telegram-plugin/tests/retry-api-call.test.ts +398 -0
- package/telegram-plugin/tests/scoped-approval.test.ts +27 -0
- package/telegram-plugin/tests/secret-detect-chunk-overlap.test.ts +65 -0
- package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +5 -4
- package/telegram-plugin/tests/session-model-file.test.ts +50 -0
- package/telegram-plugin/tests/session-tail-sidecar-reap.test.ts +268 -0
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +35 -14
- package/telegram-plugin/tests/status-pin.test.ts +275 -1
- package/telegram-plugin/tests/subagent-watcher-deferral-log-ratelimit.test.ts +316 -0
- package/telegram-plugin/tests/subagent-watcher-fd-leak.test.ts +275 -0
- package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +92 -0
- package/telegram-plugin/tests/turn-end-gate.test.ts +137 -0
- package/telegram-plugin/tests/typing-emitter.test.ts +586 -0
- package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +20 -0
- package/telegram-plugin/tests/worktree-watch-cwds.test.ts +215 -1
- package/telegram-plugin/typing-emitter.ts +224 -0
- package/telegram-plugin/uat/scenarios/jtbd-feel-like-a-colleague-dm.test.ts +136 -0
- package/telegram-plugin/welcome-text.ts +42 -0
- package/telegram-plugin/worktree-watch-cwds.ts +194 -5
- package/vendor/hindsight-memory/scripts/drain_pending.py +22 -6
- package/vendor/hindsight-memory/scripts/lib/client.py +12 -5
- package/vendor/hindsight-memory/scripts/lib/directives.py +38 -3
- package/vendor/hindsight-memory/scripts/lib/pending.py +36 -9
- package/vendor/hindsight-memory/scripts/session_end.py +14 -3
- package/vendor/hindsight-memory/scripts/session_start.py +21 -0
- package/vendor/hindsight-memory/scripts/tests/test_directives.py +38 -0
- package/vendor/hindsight-memory/tests/test_drain_pending.py +68 -0
- package/vendor/hindsight-memory/tests/test_pending.py +44 -0
- package/vendor/hindsight-memory/tests/test_session_end_pending.py +38 -0
- package/vendor/hindsight-memory/tests/test_session_start_drain.py +155 -0
- package/telegram-plugin/channel-envelope-safety.test.ts +0 -56
- package/telegram-plugin/channel-envelope-safety.ts +0 -56
- package/telegram-plugin/secret-detect/secretlint-source.ts +0 -95
- package/telegram-plugin/tests/secret-detect-secretlint.test.ts +0 -105
|
@@ -0,0 +1,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 {
|
|
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
|
|
673
|
-
if (
|
|
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
|
-
|
|
676
|
-
`
|
|
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
|
}
|