unbound-cli 1.6.3 → 1.6.5

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.
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Best-effort reporting of onboarding/setup step failures to the backend's
3
+ * setup-failure ledger (POST /api/v1/setup/failed/).
4
+ *
5
+ * This is the client half of "no onboarding step fails silently": when a setup
6
+ * step fails (script exits non-zero, throws, or its download fails) the CLI
7
+ * records the failure against the device so it shows up server-side instead of
8
+ * vanishing.
9
+ *
10
+ * CONTRACT (do not change here — backend owns it):
11
+ * POST {backendUrl}/api/v1/setup/failed/
12
+ * body: { serial_number: string (<=255, required),
13
+ * step: string (<=64, required),
14
+ * exit_code: integer }
15
+ * auth: same as /setup/complete/ — gateway API key in Authorization header,
16
+ * User-Agent "UnboundCLI/<version>". install_mode/managed are forced
17
+ * server-side, so we never send them.
18
+ *
19
+ * BEST-EFFORT, ALWAYS: a telemetry POST failure (offline, 429, 4xx, timeout)
20
+ * must NEVER mask or replace the real error shown to the user, and must NOT
21
+ * change the CLI's exit code. Every path here swallows its own errors.
22
+ */
23
+
24
+ const os = require('os');
25
+ const api = require('./api');
26
+ const config = require('./config');
27
+
28
+ // The failure report is best-effort telemetry that runs while the user is
29
+ // already waiting on a real setup failure. Bound it tightly (well under the
30
+ // default 30s API timeout) so a hung/slow backend can't delay surfacing the
31
+ // user's error. On timeout the POST rejects and the catch below swallows it.
32
+ const REPORT_TIMEOUT_MS = 3000;
33
+
34
+ // JS-thrown error (no process exit code available).
35
+ const EXIT_THROWN = 1;
36
+ // Download/transport failure before any process ran.
37
+ const EXIT_DOWNLOAD_FAILED = -1;
38
+
39
+ /**
40
+ * Best-effort device identifier for the failure ledger. The Node CLI can't read
41
+ * the hardware serial reliably, so we use os.hostname() — the backend only uses
42
+ * serial_number as an org-scoped upsert key, not as a true hardware serial.
43
+ * Truncated to the backend's 255-char limit; falls back to "unknown-device".
44
+ */
45
+ function deviceIdentifier() {
46
+ let name;
47
+ try { name = os.hostname(); } catch { name = null; }
48
+ if (!name || typeof name !== 'string' || !name.trim()) return 'unknown-device';
49
+ return name.trim().slice(0, 255);
50
+ }
51
+
52
+ // Normalize a step name to the backend's 64-char limit so an over-long stage
53
+ // label can't cause a 400 that we'd then (silently) drop.
54
+ function normalizeStep(step) {
55
+ const s = (step == null ? '' : String(step)).trim() || 'unknown';
56
+ return s.slice(0, 64);
57
+ }
58
+
59
+ // Coerce to an integer exit code; default to the thrown-error sentinel.
60
+ function normalizeExitCode(code) {
61
+ const n = Number(code);
62
+ return Number.isInteger(n) ? n : EXIT_THROWN;
63
+ }
64
+
65
+ // Maps a caught error to the exit code we report to the ledger: a download
66
+ // failure uses the download sentinel, a script exit uses its real code, and
67
+ // anything else falls back to the thrown sentinel.
68
+ function exitCodeForError(err) {
69
+ if (err && err.downloadFailed) return EXIT_DOWNLOAD_FAILED;
70
+ if (err && Number.isInteger(err.exitCode)) return err.exitCode;
71
+ return EXIT_THROWN;
72
+ }
73
+
74
+ /**
75
+ * Reports a single failed setup step. Resolves to true if the POST succeeded,
76
+ * false otherwise — but NEVER rejects, so callers can `await` it inline in an
77
+ * error path without risk of masking the original failure.
78
+ *
79
+ * @param {object} args
80
+ * @param {string} args.step - the tool/stage that failed (<=64 chars).
81
+ * @param {number} args.exitCode - the process exit code (or a sentinel).
82
+ * @param {string} [args.apiKey] - gateway API key (defaults to stored key).
83
+ * @param {string} [args.backendUrl] - explicit backend URL override.
84
+ */
85
+ async function reportSetupFailure({ step, exitCode, apiKey, backendUrl } = {}) {
86
+ try {
87
+ const key = apiKey || config.getApiKey();
88
+ // No key → nothing to authenticate with; the ledger upserts by org, so a
89
+ // keyless report can't be attributed. Skip quietly.
90
+ if (!key) return false;
91
+
92
+ await api.post('/api/v1/setup/failed/', {
93
+ apiKey: key,
94
+ baseUrl: backendUrl,
95
+ timeoutMs: REPORT_TIMEOUT_MS,
96
+ body: {
97
+ serial_number: deviceIdentifier(),
98
+ step: normalizeStep(step),
99
+ exit_code: normalizeExitCode(exitCode),
100
+ },
101
+ });
102
+ return true;
103
+ } catch {
104
+ // Offline / 429 / 4xx / timeout — all swallowed. Telemetry must never
105
+ // surface over the real error or change the exit code.
106
+ return false;
107
+ }
108
+ }
109
+
110
+ module.exports = {
111
+ reportSetupFailure,
112
+ deviceIdentifier,
113
+ exitCodeForError,
114
+ EXIT_THROWN,
115
+ EXIT_DOWNLOAD_FAILED,
116
+ // Exported for tests:
117
+ _normalizeStep: normalizeStep,
118
+ _normalizeExitCode: normalizeExitCode,
119
+ };
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Opt-out Sentry error reporting for the CLI.
3
+ *
4
+ * Design constraints (see onboarding-failure-observability spec):
5
+ * - No DSN env var (UNBOUND_CLI_SENTRY_DSN) → fully disabled. Nothing inits,
6
+ * no network, no throw. The DSN is filled in later by the maintainer.
7
+ * - UNBOUND_TELEMETRY in {0,false,no} → disabled even if a DSN is present.
8
+ * - beforeSend scrubs secrets (API/discovery keys), home-dir file paths, and
9
+ * the OS username before anything leaves the machine.
10
+ * - The CLI is short-lived, so callers must flush() before the process exits
11
+ * or queued events are dropped.
12
+ *
13
+ * Everything here is best-effort: a Sentry failure must never change the CLI's
14
+ * own behavior or exit code. All entry points swallow their own errors.
15
+ */
16
+
17
+ const os = require('os');
18
+ const { version } = require('../package.json');
19
+
20
+ let Sentry = null; // lazily required only when we actually init
21
+ let initialized = false;
22
+
23
+ // Opt-out: any of these values (case-insensitive) for UNBOUND_TELEMETRY disables
24
+ // telemetry entirely. Unset / any other value leaves it enabled (subject to DSN).
25
+ function telemetryOptedOut() {
26
+ const v = (process.env.UNBOUND_TELEMETRY || '').trim().toLowerCase();
27
+ return v === '0' || v === 'false' || v === 'no';
28
+ }
29
+
30
+ function isEnabled() {
31
+ return !!process.env.UNBOUND_CLI_SENTRY_DSN && !telemetryOptedOut();
32
+ }
33
+
34
+ // Matches secret-key-shaped tokens so a value that slips into a message/path is
35
+ // redacted even when we don't know which flag produced it. Conservative on
36
+ // length so ordinary words aren't clobbered.
37
+ const KEY_RE = /\b(?:sk|pk|ub|unb|gw|disc)[-_][A-Za-z0-9_-]{8,}\b/gi;
38
+
39
+ // Redacts known-sensitive substrings from an arbitrary string: the concrete
40
+ // key values we were handed, then key-shaped tokens, then home-dir paths and
41
+ // the OS username. Returns the input unchanged when not a string.
42
+ function scrubString(str, secrets) {
43
+ if (typeof str !== 'string' || !str) return str;
44
+ let out = str;
45
+ for (const secret of secrets) {
46
+ if (secret) out = out.split(secret).join('[redacted]');
47
+ }
48
+ out = out.replace(KEY_RE, '[redacted]');
49
+ try {
50
+ const home = os.homedir();
51
+ if (home) out = out.split(home).join('~');
52
+ } catch { /* ignore */ }
53
+ try {
54
+ const username = os.userInfo().username;
55
+ if (username && username.length >= 3) {
56
+ out = out.split(username).join('[user]');
57
+ }
58
+ } catch { /* ignore */ }
59
+ return out;
60
+ }
61
+
62
+ // Recursively scrub strings in nested event structures (messages, exception
63
+ // values, stack frame paths, breadcrumbs). Depth-bounded so a pathological
64
+ // event can't blow the stack.
65
+ function scrubDeep(value, secrets, depth) {
66
+ if (depth > 8 || value == null) return value;
67
+ if (typeof value === 'string') return scrubString(value, secrets);
68
+ if (Array.isArray(value)) return value.map((v) => scrubDeep(v, secrets, depth + 1));
69
+ if (typeof value === 'object') {
70
+ for (const k of Object.keys(value)) {
71
+ value[k] = scrubDeep(value[k], secrets, depth + 1);
72
+ }
73
+ }
74
+ return value;
75
+ }
76
+
77
+ // Collects the concrete secret values passed on the command line so beforeSend
78
+ // can redact the exact strings (not just key-shaped guesses). Mutated by
79
+ // rememberSecret(); kept tiny.
80
+ const knownSecrets = new Set();
81
+
82
+ function rememberSecret(value) {
83
+ if (typeof value === 'string' && value.length >= 6) knownSecrets.add(value);
84
+ }
85
+
86
+ function beforeSend(event) {
87
+ try {
88
+ const secrets = [...knownSecrets];
89
+ // Drop server_name (often the hostname) and the user object before scrub.
90
+ delete event.server_name;
91
+ if (event.user) delete event.user.username;
92
+ scrubDeep(event, secrets, 0);
93
+ } catch {
94
+ // Never let scrubbing throw — if we can't scrub safely, drop the event.
95
+ return null;
96
+ }
97
+ return event;
98
+ }
99
+
100
+ /**
101
+ * Initializes Sentry if (and only if) a DSN is configured and telemetry is not
102
+ * opted out. Safe to call once at startup. Never throws.
103
+ *
104
+ * @param {object} [opts]
105
+ * @param {string} [opts.runId] - shared onboard/setup/discover run id (tag).
106
+ * @param {string} [opts.flow] - flow name tag (defaults to 'onboard').
107
+ */
108
+ function init({ runId, flow = 'onboard' } = {}) {
109
+ if (initialized || !isEnabled()) return;
110
+ try {
111
+ Sentry = require('@sentry/node');
112
+ Sentry.init({
113
+ dsn: process.env.UNBOUND_CLI_SENTRY_DSN,
114
+ release: `unbound-cli@${version}`,
115
+ // We only want explicit captures, not unrelated auto-instrumentation noise.
116
+ integrations: [],
117
+ defaultIntegrations: false,
118
+ tracesSampleRate: 0,
119
+ sendDefaultPii: false,
120
+ beforeSend,
121
+ });
122
+ Sentry.setTags({
123
+ flow,
124
+ cli_version: version,
125
+ platform: process.platform,
126
+ ...(runId ? { run_id: runId } : {}),
127
+ });
128
+ initialized = true;
129
+ } catch {
130
+ // Missing module / bad DSN / anything — telemetry just stays off.
131
+ Sentry = null;
132
+ initialized = false;
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Captures an error if telemetry is active. No-op otherwise. Never throws and
138
+ * never changes the caller's control flow — callers rethrow/exit on their own.
139
+ */
140
+ function captureError(err, context = {}) {
141
+ if (!initialized || !Sentry) return;
142
+ try {
143
+ Sentry.captureException(err, context.tags ? { tags: context.tags } : undefined);
144
+ } catch { /* best-effort */ }
145
+ }
146
+
147
+ /**
148
+ * Flushes queued events with a short bound, then closes the client. Must be
149
+ * awaited before the process exits or short-lived runs lose their events.
150
+ * Never throws.
151
+ */
152
+ async function flush(timeoutMs = 2000) {
153
+ if (!initialized || !Sentry) return;
154
+ try {
155
+ // close() flushes queued events and then disables the client. Calling
156
+ // flush() first would only double the worst-case exit latency (2x
157
+ // timeoutMs) for a short-lived CLI, so close() alone is enough.
158
+ await Sentry.close(timeoutMs);
159
+ } catch { /* best-effort */ }
160
+ initialized = false;
161
+ }
162
+
163
+ /**
164
+ * Wraps a command action so any thrown error is captured to Sentry (before
165
+ * being rethrown) and queued events are flushed before the process exits.
166
+ * Because the CLI is short-lived, the flush is the only thing keeping events
167
+ * alive — so it runs on BOTH the success and error paths.
168
+ *
169
+ * The wrapper preserves the action's own error handling: it rethrows, so the
170
+ * existing try/catch inside the action (which sets process.exitCode and prints
171
+ * the message) still runs exactly as before. Telemetry never changes behavior.
172
+ *
173
+ * @param {string} flow - flow tag for captured events (e.g. 'setup').
174
+ * @param {(...args:any[])=>Promise<any>} action - the commander action handler.
175
+ */
176
+ function wrapAction(flow, action) {
177
+ return async (...args) => {
178
+ try {
179
+ const result = await action(...args);
180
+ await flush();
181
+ return result;
182
+ } catch (err) {
183
+ captureError(err, { tags: { flow } });
184
+ await flush();
185
+ throw err;
186
+ }
187
+ };
188
+ }
189
+
190
+ module.exports = {
191
+ init,
192
+ captureError,
193
+ flush,
194
+ wrapAction,
195
+ rememberSecret,
196
+ isEnabled,
197
+ // Exported for tests:
198
+ _beforeSend: beforeSend,
199
+ _scrubString: scrubString,
200
+ _telemetryOptedOut: telemetryOptedOut,
201
+ };
package/src/utils.js CHANGED
@@ -1,5 +1,8 @@
1
1
  const readline = require('readline');
2
2
  const fs = require('fs');
3
+ const os = require('os');
4
+ const path = require('path');
5
+ const crypto = require('crypto');
3
6
  const https = require('https');
4
7
 
5
8
  function formatDate(dateStr) {
@@ -31,14 +34,38 @@ function shellEscape(str) {
31
34
  }
32
35
 
33
36
  /**
34
- * Downloads a URL to a local file, following up to 3 redirects.
35
- * Forwards response-stream errors to the Promise rejection so callers
36
- * (and their finally blocks) can clean up partial downloads.
37
+ * Downloads a URL to a local file, following up to 3 redirects. This is the
38
+ * ONE secure implementation setup.js and discover.js both import it so the
39
+ * download-then-run paths (which `sudo unbound …` executes as ROOT) share a
40
+ * single hardened code path instead of drifted copies.
41
+ *
42
+ * SECURITY: the script file we are about to EXECUTE must not be attacker-
43
+ * controllable. `destPath` is expected to live inside a per-run private temp dir
44
+ * (see withSecureTempFile / mkdtempSync, mode 0700) so it can't be pre-created
45
+ * or symlinked by a local attacker. As defense in depth we ALSO open the file
46
+ * with O_EXCL|O_NOFOLLOW via createWriteStream({ flags: 'wx' }) and mode 0o600:
47
+ * an existing path or symlink at destPath yields EEXIST/ELOOP and rejects rather
48
+ * than being followed or overwritten. This closes the TOCTOU root-privesc window.
49
+ *
50
+ * Verifies BOTH a 200 status AND a non-empty body before resolving: a 200 with
51
+ * zero bytes is a failed download (the script never arrived), not a runnable
52
+ * empty script. This is what lets the download-then-run paths close the old
53
+ * `curl | bash` / `curl | python3` pipe hole, where a failed download still
54
+ * exited 0.
55
+ *
56
+ * On any transport/stream error the partner stream is destroyed and the partial
57
+ * file is unlinked before rejecting, so a one-shot CLI doesn't leak a socket or
58
+ * leave a partial temp file behind (the mkdtemp-dir cleanup also covers it).
59
+ *
60
+ * Injectable transport (tests only): pass `{ transport }` to use a custom
61
+ * https-like module exposing `.get(url, cb)`, so the SHIPPED function can be
62
+ * exercised against a local server instead of real network/TLS.
37
63
  */
38
- function downloadToFile(url, destPath) {
64
+ function downloadToFile(url, destPath, { transport } = {}) {
65
+ const client = transport || https;
39
66
  return new Promise((resolve, reject) => {
40
67
  const request = (u, remaining) => {
41
- https.get(u, (res) => {
68
+ client.get(u, (res) => {
42
69
  if ([301, 302, 303, 307, 308].includes(res.statusCode) && res.headers.location && remaining > 0) {
43
70
  res.resume();
44
71
  return request(res.headers.location, remaining - 1);
@@ -47,17 +74,66 @@ function downloadToFile(url, destPath) {
47
74
  res.resume();
48
75
  return reject(new Error(`Failed to download ${u}: HTTP ${res.statusCode}`));
49
76
  }
50
- const file = fs.createWriteStream(destPath);
77
+ // wx = O_CREAT|O_EXCL (fail if it already exists, never follow a symlink
78
+ // into it); mode 0o600 keeps the downloaded script owner-only.
79
+ const file = fs.createWriteStream(destPath, { flags: 'wx', mode: 0o600 });
80
+ let bytes = 0;
81
+ let settled = false;
82
+ const fail = (err) => {
83
+ if (settled) return;
84
+ settled = true;
85
+ // Tear down both ends so a one-shot CLI doesn't leak a socket.
86
+ res.destroy();
87
+ file.destroy();
88
+ // Remove the partial file we wrote — but NOT when the open itself
89
+ // failed (EEXIST from `wx` / ELOOP from a symlink): that file/symlink
90
+ // is pre-existing and attacker-controlled, so we must never delete it.
91
+ if (err && (err.code === 'EEXIST' || err.code === 'ELOOP')) {
92
+ reject(err);
93
+ return;
94
+ }
95
+ fs.unlink(destPath, () => reject(err));
96
+ };
97
+ res.on('data', (chunk) => { bytes += chunk.length; });
98
+ res.on('error', fail);
99
+ file.on('error', fail);
51
100
  res.pipe(file);
52
- res.on('error', reject);
53
- file.on('finish', () => file.close(resolve));
54
- file.on('error', reject);
101
+ file.on('finish', () => file.close(() => {
102
+ if (settled) return;
103
+ if (bytes === 0) {
104
+ settled = true;
105
+ fs.unlink(destPath, () => reject(new Error(`Failed to download ${u}: empty response body`)));
106
+ return;
107
+ }
108
+ settled = true;
109
+ resolve();
110
+ }));
55
111
  }).on('error', reject);
56
112
  };
57
113
  request(url, 3);
58
114
  });
59
115
  }
60
116
 
117
+ /**
118
+ * Runs `fn(filePath)` with `filePath` pointing inside a freshly-created PRIVATE
119
+ * temp directory (fs.mkdtempSync, mode 0700), then removes the whole directory
120
+ * (and the file) afterward — success or failure. A private dir defeats symlink/
121
+ * pre-creation attacks on the predictable old /tmp path, and the random file
122
+ * name uses crypto.randomUUID() rather than Date.now()+Math.random().
123
+ *
124
+ * @param {string} suffix file extension incl. dot, e.g. '.py' or '.sh'.
125
+ * @param {(p:string)=>Promise} fn receives the absolute temp file path.
126
+ */
127
+ async function withSecureTempFile(suffix, fn) {
128
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'unbound-'));
129
+ const file = path.join(dir, `${crypto.randomUUID()}${suffix}`);
130
+ try {
131
+ return await fn(file);
132
+ } finally {
133
+ try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* best-effort */ }
134
+ }
135
+ }
136
+
61
137
  const DISCOVER_BASE_URL = 'https://raw.githubusercontent.com/websentry-ai/coding-discovery-tool/refs/heads/main';
62
138
 
63
- module.exports = { formatDate, confirm, parseCommaSeparated, shellEscape, downloadToFile, DISCOVER_BASE_URL };
139
+ module.exports = { formatDate, confirm, parseCommaSeparated, shellEscape, downloadToFile, withSecureTempFile, DISCOVER_BASE_URL };
@@ -0,0 +1,201 @@
1
+ const { test } = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const http = require('http');
4
+ const { URL } = require('url');
5
+
6
+ // WEB-4941, root cause #1: loginWithBrowser's local callback server leaked a
7
+ // keep-alive socket. server.close() stops accepting NEW connections but does NOT
8
+ // destroy the socket the browser holds open via HTTP/1.1 keep-alive, so the
9
+ // process stayed alive ~60s. The fix sets `Connection: close` on every response
10
+ // and force-drops lingering sockets via closeAllConnections (shutdownServer).
11
+ //
12
+ // These tests drive the REAL loginWithBrowser through its injected `open` seam,
13
+ // reproducing the exact leaking condition with a keepAlive agent, and assert
14
+ // (a) the success result, (b) every response carries `connection: close`, and
15
+ // (c) the server actually stopped listening (fresh connect → ECONNREFUSED).
16
+ // No real ~/.unbound write, no real network beyond loopback, no real browser.
17
+
18
+ const auth = require('../src/auth');
19
+ const config = require('../src/config');
20
+
21
+ // Issues a single GET over a keepAlive agent and resolves with status, headers,
22
+ // and the underlying client socket. keepAlive is the load-bearing detail: it
23
+ // reproduces the exact socket the browser holds open, so this test FAILS against
24
+ // the pre-fix code (which left that socket — and the event loop — open).
25
+ function getKeepAlive(agent, url) {
26
+ return new Promise((resolve, reject) => {
27
+ const req = http.get(url, { agent }, (res) => {
28
+ res.resume(); // drain the body so the response completes
29
+ res.on('end', () => resolve({ statusCode: res.statusCode, headers: res.headers, socket: req.socket }));
30
+ });
31
+ req.on('error', reject);
32
+ });
33
+ }
34
+
35
+ // The actual leak-proof: post-fix the server force-closes the answered socket
36
+ // (Connection: close + closeAllConnections), so the client socket emits 'close'
37
+ // promptly. Against the pre-fix code the socket stayed in keep-alive limbo and
38
+ // this would time out — so THIS assertion (with the Connection: close header) is
39
+ // what distinguishes the fix. expectConnectionRefused below only proves the
40
+ // server stopped listening, which a bare server.close() already did pre-fix.
41
+ function expectSocketClosed(socket, ms) {
42
+ if (socket.destroyed) return Promise.resolve();
43
+ return new Promise((resolve, reject) => {
44
+ const timer = setTimeout(
45
+ () => reject(new Error(`socket was not closed within ${ms}ms — keep-alive leak`)),
46
+ ms,
47
+ );
48
+ socket.once('close', () => { clearTimeout(timer); resolve(); });
49
+ });
50
+ }
51
+
52
+ // Confirms shutdownServer ran: a fresh connection to the port is refused.
53
+ function expectConnectionRefused(port) {
54
+ return new Promise((resolve, reject) => {
55
+ const req = http.get(`http://localhost:${port}/callback`, (res) => {
56
+ res.resume();
57
+ reject(new Error(`expected ECONNREFUSED but got HTTP ${res.statusCode}`));
58
+ });
59
+ req.on('error', (err) => {
60
+ if (err.code === 'ECONNREFUSED') resolve();
61
+ else reject(err);
62
+ });
63
+ });
64
+ }
65
+
66
+ // Pulls the callback_url the server told the browser to redirect to, then
67
+ // appends the success params the /callback handler reads (api_key/email/org).
68
+ function buildSuccessCallback(authUrl) {
69
+ const callbackUrl = new URL(authUrl).searchParams.get('callback_url');
70
+ return `${callbackUrl}?api_key=sk-test&email=a@b.co&org=Acme`;
71
+ }
72
+
73
+ // Monkeypatch config so no real ~/.unbound write happens. Restored in finally.
74
+ function withStubbedConfig(fn) {
75
+ const saved = {
76
+ setApiKey: config.setApiKey,
77
+ writeConfig: config.writeConfig,
78
+ readConfig: config.readConfig,
79
+ };
80
+ config.setApiKey = () => {};
81
+ config.writeConfig = () => {};
82
+ config.readConfig = () => ({});
83
+ return Promise.resolve()
84
+ .then(fn)
85
+ .finally(() => {
86
+ config.setApiKey = saved.setApiKey;
87
+ config.writeConfig = saved.writeConfig;
88
+ config.readConfig = saved.readConfig;
89
+ });
90
+ }
91
+
92
+ // Hard safety net so a regression that reintroduces the leak can't hang CI.
93
+ function withTimeout(promise, ms, label) {
94
+ let timer;
95
+ const guard = new Promise((_, reject) => {
96
+ timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
97
+ });
98
+ return Promise.race([promise, guard]).finally(() => clearTimeout(timer));
99
+ }
100
+
101
+ test('loginWithBrowser: success path resolves, sets Connection: close, and stops listening', async () => {
102
+ await withStubbedConfig(async () => {
103
+ const agent = new http.Agent({ keepAlive: true });
104
+ let port;
105
+ let successHeaders;
106
+ let successSocket;
107
+
108
+ const fakeOpen = async (authUrl) => {
109
+ port = Number(new URL(new URL(authUrl).searchParams.get('callback_url')).port);
110
+ const res = await getKeepAlive(agent, buildSuccessCallback(authUrl));
111
+ successHeaders = res.headers;
112
+ successSocket = res.socket;
113
+ };
114
+
115
+ try {
116
+ const result = await withTimeout(
117
+ auth.loginWithBrowser('http://frontend.invalid', { open: fakeOpen }),
118
+ 10_000,
119
+ 'loginWithBrowser success path',
120
+ );
121
+
122
+ assert.deepEqual(result, { email: 'a@b.co', orgName: 'Acme' });
123
+ assert.equal(successHeaders.connection, 'close', 'success response must set Connection: close');
124
+
125
+ // The leak-proof: the held keep-alive socket is actually closed by the
126
+ // server. This times out (fails) against the pre-fix code.
127
+ await expectSocketClosed(successSocket, 5_000);
128
+
129
+ // Secondary: the server also stopped listening (fresh connect refused).
130
+ await withTimeout(expectConnectionRefused(port), 5_000, 'fresh connect after shutdown');
131
+ } finally {
132
+ agent.destroy();
133
+ }
134
+ });
135
+ });
136
+
137
+ test('loginWithBrowser: 404 path also sets Connection: close', async () => {
138
+ await withStubbedConfig(async () => {
139
+ const agent = new http.Agent({ keepAlive: true });
140
+ let notFoundHeaders;
141
+
142
+ const fakeOpen = async (authUrl) => {
143
+ const callbackUrl = new URL(authUrl).searchParams.get('callback_url');
144
+ const origin = new URL(callbackUrl).origin;
145
+ // 404 first (non-/callback path) — the server is still up; then the real
146
+ // /callback to let loginWithBrowser resolve and shut the server down.
147
+ const notFound = await getKeepAlive(agent, `${origin}/nope`);
148
+ notFoundHeaders = notFound.headers;
149
+ await getKeepAlive(agent, buildSuccessCallback(authUrl));
150
+ };
151
+
152
+ try {
153
+ await withTimeout(
154
+ auth.loginWithBrowser('http://frontend.invalid', { open: fakeOpen }),
155
+ 10_000,
156
+ 'loginWithBrowser 404-then-success',
157
+ );
158
+
159
+ assert.equal(notFoundHeaders.connection, 'close', '404 response must set Connection: close');
160
+ } finally {
161
+ agent.destroy();
162
+ }
163
+ });
164
+ });
165
+
166
+ test('loginWithBrowser: missing-api-key path rejects, sets Connection: close, and tears down', async () => {
167
+ await withStubbedConfig(async () => {
168
+ const agent = new http.Agent({ keepAlive: true });
169
+ let resolveCallback;
170
+ let rejectCallback;
171
+ const callbackDone = new Promise((res, rej) => { resolveCallback = res; rejectCallback = rej; });
172
+
173
+ // Mirror the real `open`: it returns after launching the browser, and the
174
+ // /callback request arrives over HTTP a tick LATER — after loginWithBrowser
175
+ // is already awaiting callbackPromise. Firing it inline (before that await)
176
+ // would reject an unhandled promise. Hitting /callback with no api_key drives
177
+ // the 400 reject + shutdown path. Forward a client error to callbackDone so a
178
+ // failed request surfaces as a test failure rather than a hang.
179
+ const fakeOpen = async (authUrl) => {
180
+ const callbackUrl = new URL(authUrl).searchParams.get('callback_url');
181
+ setImmediate(() => { getKeepAlive(agent, callbackUrl).then(resolveCallback, rejectCallback); });
182
+ };
183
+
184
+ try {
185
+ await withTimeout(
186
+ assert.rejects(
187
+ () => auth.loginWithBrowser('http://frontend.invalid', { open: fakeOpen }),
188
+ /No API key received/,
189
+ ),
190
+ 10_000,
191
+ 'loginWithBrowser missing-api-key path',
192
+ );
193
+
194
+ const res = await withTimeout(callbackDone, 5_000, 'missing-api-key callback');
195
+ assert.equal(res.headers.connection, 'close', '400 response must set Connection: close');
196
+ await expectSocketClosed(res.socket, 5_000);
197
+ } finally {
198
+ agent.destroy();
199
+ }
200
+ });
201
+ });