unbound-cli 1.10.0 → 1.11.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unbound-cli",
3
- "version": "1.10.0",
3
+ "version": "1.11.1",
4
4
  "description": "CLI tool for Unbound - AI Gateway management",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -0,0 +1,178 @@
1
+ /**
2
+ * HTML pages served by the local auth-callback server (src/auth.js).
3
+ *
4
+ * Design follows the unbound-fe light-surface language (matching the auth
5
+ * login page): #fafafb scene with a soft purple glow, white card on a gray-200
6
+ * border, #3d3d3d headings, brand purple #7B56FB (logo) accents, Inter-first
7
+ * font stack. Fully self-contained —
8
+ * no external fonts, scripts, or images — so the page renders identically
9
+ * offline and never blocks on a network fetch from a localhost one-shot server.
10
+ *
11
+ * SECURITY: email/org arrive as query params on a loopback URL anyone local
12
+ * can hit, so every interpolated value MUST go through escapeHtml. Never
13
+ * interpolate the api_key into a page.
14
+ */
15
+
16
+ /** Minimal HTML entity escape for untrusted interpolated text. */
17
+ function escapeHtml(value) {
18
+ return String(value)
19
+ .replace(/&/g, '&')
20
+ .replace(/</g, '&lt;')
21
+ .replace(/>/g, '&gt;')
22
+ .replace(/"/g, '&quot;')
23
+ .replace(/'/g, '&#39;');
24
+ }
25
+
26
+ // Unbound shield mark (public/Unbound.svg in unbound-fe), inlined verbatim.
27
+ const LOGO_SVG =
28
+ '<svg width="28" height="27" viewBox="0 0 24 23" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">' +
29
+ '<path d="M5.12736 6.88525C6.33833 14.4266 11.9473 18.6338 12.0038 18.6757L9.577 22.0419C9.28788 21.8335 2.47374 16.8456 0.979492 7.53649L5.12639 6.88525H5.12736Z" fill="#7B56FB"/>' +
30
+ '<path d="M18.7655 6.81787L22.8988 7.24132C22.8637 7.59566 21.9496 15.9907 14.6361 21.9394L12.0059 18.6755C17.9322 13.8559 18.7577 6.88796 18.7655 6.81787Z" fill="#7B56FB"/>' +
31
+ '<path d="M12.1878 1.4375C14.6214 1.4375 17.3928 1.8113 20.282 2.91325L18.7654 6.81775C11.5638 4.07068 5.19258 6.85572 5.12833 6.88492L3.42188 3.10697C3.63993 3.00865 7.20178 1.4375 12.1878 1.4375Z" fill="#7B56FB"/>' +
32
+ '</svg>';
33
+
34
+ /**
35
+ * Shared page shell. `status` slots the icon block, heading, and body copy;
36
+ * `accent` tints the icon ring (brand purple on success, red on failure).
37
+ */
38
+ function renderPage({ title, icon, heading, body }) {
39
+ return `<!DOCTYPE html>
40
+ <html lang="en">
41
+ <head>
42
+ <meta charset="utf-8">
43
+ <meta name="viewport" content="width=device-width, initial-scale=1">
44
+ <title>${title}</title>
45
+ <style>
46
+ * { margin: 0; padding: 0; box-sizing: border-box; }
47
+ body {
48
+ font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
49
+ background: #fafafb;
50
+ min-height: 100vh;
51
+ display: flex;
52
+ align-items: center;
53
+ justify-content: center;
54
+ padding: 24px;
55
+ /* Soft brand glow behind the card, echoing the light auth/login scene. */
56
+ background-image: radial-gradient(600px 400px at 50% 30%, rgba(123, 86, 251, 0.07), transparent 70%);
57
+ }
58
+ .card {
59
+ background: #ffffff;
60
+ border: 1px solid #e5e7eb;
61
+ border-radius: 16px;
62
+ padding: 44px 40px 36px;
63
+ max-width: 400px;
64
+ width: 100%;
65
+ text-align: center;
66
+ box-shadow: 0 16px 48px rgba(23, 19, 43, 0.08);
67
+ animation: rise 0.45s ease-out both;
68
+ }
69
+ .brand {
70
+ display: inline-flex;
71
+ align-items: center;
72
+ gap: 8px;
73
+ margin-bottom: 28px;
74
+ }
75
+ .brand span {
76
+ color: #17132B;
77
+ font-size: 17px;
78
+ font-weight: 600;
79
+ letter-spacing: 0.02em;
80
+ }
81
+ .icon {
82
+ display: block;
83
+ width: 64px;
84
+ height: 64px;
85
+ margin: 0 auto 20px;
86
+ }
87
+ .icon circle {
88
+ stroke-dasharray: 166;
89
+ stroke-dashoffset: 166;
90
+ animation: draw 0.6s 0.15s ease-out forwards;
91
+ }
92
+ .icon .mark {
93
+ stroke-dasharray: 48;
94
+ stroke-dashoffset: 48;
95
+ animation: draw 0.35s 0.65s ease-out forwards;
96
+ }
97
+ h1 { color: #3d3d3d; font-size: 20px; font-weight: 600; margin-bottom: 10px; }
98
+ p { color: #6b7280; font-size: 14px; line-height: 1.6; }
99
+ .identity {
100
+ display: inline-flex;
101
+ flex-direction: column;
102
+ gap: 2px;
103
+ margin: 14px 0 6px;
104
+ padding: 9px 18px;
105
+ border: 1px solid #e5e7eb;
106
+ border-radius: 12px;
107
+ background: #fafafb;
108
+ font-size: 13px;
109
+ max-width: 100%;
110
+ overflow-wrap: anywhere;
111
+ }
112
+ .identity b { color: #3d3d3d; font-weight: 500; }
113
+ .identity i { color: #6b7280; font-style: normal; font-size: 12px; }
114
+ @keyframes rise { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
115
+ @keyframes draw { to { stroke-dashoffset: 0; } }
116
+ @media (prefers-reduced-motion: reduce) {
117
+ .card, .icon circle, .icon .mark { animation: none; stroke-dashoffset: 0; }
118
+ }
119
+ </style>
120
+ </head>
121
+ <body>
122
+ <main class="card">
123
+ <div class="brand">${LOGO_SVG}<span>unbound</span></div>
124
+ ${icon}
125
+ <h1>${heading}</h1>
126
+ ${body}
127
+ </main>
128
+ </body>
129
+ </html>
130
+ `;
131
+ }
132
+
133
+ // Stroke-drawn status icons: circle sweeps in first, then the mark.
134
+ const CHECK_ICON =
135
+ '<svg class="icon" viewBox="0 0 56 56" fill="none" aria-hidden="true">' +
136
+ '<circle cx="28" cy="28" r="26" stroke="#7B56FB" stroke-width="2.5"/>' +
137
+ '<path class="mark" d="M17 29l8 8 14-16" stroke="#7B56FB" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>' +
138
+ '</svg>';
139
+
140
+ const CROSS_ICON =
141
+ '<svg class="icon" viewBox="0 0 56 56" fill="none" aria-hidden="true">' +
142
+ '<circle cx="28" cy="28" r="26" stroke="#EF4444" stroke-width="2.5"/>' +
143
+ '<path class="mark" d="M20 20l16 16M36 20L20 36" stroke="#EF4444" stroke-width="3" stroke-linecap="round"/>' +
144
+ '</svg>';
145
+
146
+ /**
147
+ * Success page shown after the API key lands. `email`/`orgName` are untrusted
148
+ * query-param values — escaped here, and omitted entirely when absent.
149
+ */
150
+ function successPage({ email, orgName } = {}) {
151
+ // Stacked (email over org) rather than one truncating line — real tenant
152
+ // emails and org names routinely overflow a 400px card side by side.
153
+ const identityParts = [];
154
+ if (email) identityParts.push(`<b>${escapeHtml(email)}</b>`);
155
+ if (orgName) identityParts.push(`<i>${escapeHtml(orgName)}</i>`);
156
+ const identity = identityParts.length
157
+ ? `<div class="identity">${identityParts.join('')}</div>`
158
+ : '';
159
+
160
+ return renderPage({
161
+ title: 'Authentication Successful | Unbound',
162
+ icon: CHECK_ICON,
163
+ heading: 'Authentication Successful',
164
+ body: `${identity}<p>You can close this tab and return to the terminal.</p>`,
165
+ });
166
+ }
167
+
168
+ /** Failure page for a callback that arrived without an api_key. */
169
+ function failurePage() {
170
+ return renderPage({
171
+ title: 'Authentication Failed | Unbound',
172
+ icon: CROSS_ICON,
173
+ heading: 'Authentication Failed',
174
+ body: '<p>You can close this tab and try again from the terminal.</p>',
175
+ });
176
+ }
177
+
178
+ module.exports = { successPage, failurePage, escapeHtml };
package/src/auth.js CHANGED
@@ -4,6 +4,7 @@
4
4
  */
5
5
  const http = require('http');
6
6
  const { URL } = require('url');
7
+ const authPages = require('./auth-pages');
7
8
  const config = require('./config');
8
9
  const output = require('./output');
9
10
  const api = require('./api');
@@ -35,18 +36,38 @@ async function loginWithBrowser(frontendUrl, { open: openFn } = {}) {
35
36
  // / timed-out socket so the process exits immediately. closeAllConnections is
36
37
  // Node >=18.2 — guard for the package's >=18 engine floor.
37
38
  //
38
- // Calling this synchronously right after res.end() is safe ONLY because the
39
- // callback responses are a few hundred bytes of static HTML — they fit in the
40
- // socket send buffer, so the body reaches the kernel before closeAllConnections
41
- // destroys the socket. If these pages ever grow to multi-KB (inline assets,
42
- // styling), defer teardown to res.on('finish') to avoid truncating the response.
39
+ // The styled callback pages (auth-pages.js) are multi-KB, so calling this
40
+ // synchronously after res.end() could destroy the socket before the body
41
+ // flushes and truncate the page. Callback responses therefore defer teardown
42
+ // to res 'finish' (body flushed) / 'close' (client vanished mid-write) via
43
+ // respondAndShutdown; the guard keeps the second event and the timeout path
44
+ // from double-closing the server (server.close() on a closed server throws).
45
+ let tornDown = false;
43
46
  function shutdownServer() {
47
+ if (tornDown) return;
48
+ tornDown = true;
44
49
  server.close();
45
50
  if (typeof server.closeAllConnections === 'function') {
46
51
  server.closeAllConnections();
47
52
  }
48
53
  }
49
54
 
55
+ // Sends a terminal (success/failure) callback response and tears the server
56
+ // down once the body has actually left userspace. Resolution of the login
57
+ // promise stays with the caller — this only owns response + teardown.
58
+ function respondAndShutdown(res, statusCode, html) {
59
+ res.on('finish', shutdownServer);
60
+ res.on('close', shutdownServer);
61
+ res.writeHead(statusCode, { 'Content-Type': 'text/html; charset=utf-8', 'Connection': 'close' });
62
+ res.end(html);
63
+ }
64
+
65
+ // First /callback wins. Teardown is deferred until the response body
66
+ // flushes, which leaves a window where a second /callback could arrive and
67
+ // overwrite the just-persisted credentials after the login promise has
68
+ // settled — reject duplicates outright instead of processing them.
69
+ let callbackHandled = false;
70
+
50
71
  server.on('request', (req, res) => {
51
72
  const url = new URL(req.url, `http://localhost`);
52
73
 
@@ -56,15 +77,20 @@ async function loginWithBrowser(frontendUrl, { open: openFn } = {}) {
56
77
  return;
57
78
  }
58
79
 
80
+ if (callbackHandled) {
81
+ res.writeHead(409, { 'Connection': 'close' });
82
+ res.end('Callback already processed');
83
+ return;
84
+ }
85
+ callbackHandled = true;
86
+
59
87
  const apiKey = url.searchParams.get('api_key');
60
88
  const email = url.searchParams.get('email');
61
89
  const orgName = url.searchParams.get('org');
62
90
 
63
91
  if (!apiKey) {
64
- res.writeHead(400, { 'Content-Type': 'text/html', 'Connection': 'close' });
65
- res.end('<html><body><h2>Authentication failed</h2><p>No API key received. Please try again.</p></body></html>');
92
+ respondAndShutdown(res, 400, authPages.failurePage());
66
93
  callbackReject(new Error('No API key received in callback'));
67
- shutdownServer();
68
94
  return;
69
95
  }
70
96
 
@@ -74,11 +100,8 @@ async function loginWithBrowser(frontendUrl, { open: openFn } = {}) {
74
100
  if (orgName) cfg.org_name = orgName;
75
101
  config.writeConfig(cfg);
76
102
 
77
- res.writeHead(200, { 'Content-Type': 'text/html', 'Connection': 'close' });
78
- res.end('<html><body><h2>Authentication successful!</h2><p>You can close this tab and return to the terminal.</p></body></html>');
79
-
103
+ respondAndShutdown(res, 200, authPages.successPage({ email, orgName }));
80
104
  callbackResolve({ email, orgName });
81
- shutdownServer();
82
105
  });
83
106
 
84
107
  await new Promise((resolve, reject) => {
@@ -209,11 +209,15 @@ async function runPythonScript(scriptPath, args, { capture }) {
209
209
  windowsHide: true,
210
210
  });
211
211
  let out = '';
212
+ // stderr is also kept on its own. Python block-buffers stdout when piped,
213
+ // so a crash's stdout can flush after the traceback and interleave with it
214
+ // in `out` — failureReason needs an uncontaminated stream to read.
215
+ let errOut = '';
212
216
  if (capture) {
213
217
  child.stdin.on('error', () => {});
214
218
  child.stdin.end();
215
219
  child.stdout.on('data', (d) => { out += d.toString(); });
216
- child.stderr.on('data', (d) => { out += d.toString(); });
220
+ child.stderr.on('data', (d) => { const s = d.toString(); out += s; errOut += s; });
217
221
  }
218
222
  child.on('close', (code) => {
219
223
  if (code === 0) return resolve();
@@ -223,7 +227,10 @@ async function runPythonScript(scriptPath, args, { capture }) {
223
227
  // Carry the numeric exit code so callers can report it to the failure
224
228
  // ledger (reportSetupFailure) without re-parsing the message.
225
229
  err.exitCode = code;
226
- if (capture) err.setupOutput = out.trim();
230
+ if (capture) {
231
+ err.setupOutput = out.trim();
232
+ err.stderr = errOut.trim();
233
+ }
227
234
  reject(err);
228
235
  });
229
236
  child.on('error', reject);
@@ -576,6 +583,61 @@ async function runBatch(tools, runFn, { clear = false, summary = null, report =
576
583
  return { ok: true, skipped: mdmSkipped };
577
584
  }
578
585
 
586
+ /**
587
+ * Picks the most useful one-line reason out of a failed setup script's captured
588
+ * output. The python scripts open AND close with a `====` banner, so the first
589
+ * line is never the reason a step failed — taking it verbatim produced the
590
+ * useless `Codex (gateway) (====================)` that hid a real bug. Prefer
591
+ * the last line the script itself flagged as an error, then the exit code.
592
+ *
593
+ * Tolerates a non-Error throw: this runs inside nuke's per-tool catch, so
594
+ * throwing here would abort the remaining tools instead of clearing them.
595
+ */
596
+ function failureReason(err) {
597
+ err = err || {};
598
+ // Read the traceback from stderr when we have it: `out` merges both streams by
599
+ // arrival order, so buffered stdout can land between the header and the
600
+ // exception. Fall back to the merged text for callers that don't set stderr.
601
+ const crash = exceptionLine(err.stderr) || exceptionLine(err.message);
602
+ if (crash) return truncate(crash);
603
+ const lines = (err.message || '').split('\n').map((l) => l.trim()).filter(Boolean);
604
+ // Otherwise the lines the scripts themselves flag. The dotted form catches a
605
+ // bare exception line captured without its traceback header.
606
+ const flagged = lines.filter((l) => /^(❌|failed\b|error\b|exception\b|[\w.]*(error|exception):)/i.test(l));
607
+ const pick = flagged.length ? flagged[flagged.length - 1] : null;
608
+ if (pick) return truncate(pick);
609
+ if (err.exitCode != null) return `exit code ${err.exitCode}`;
610
+ return truncate(lines[0] || 'failed');
611
+ }
612
+
613
+ /**
614
+ * The exception a python traceback ended on, or null if `text` has no traceback.
615
+ * Structural rather than positional: frame lines are indented, so the exception
616
+ * is the first UNindented line after the header. That holds even if buffered
617
+ * stdout flushed into the middle of the traceback. The LAST header wins, since
618
+ * with chained exceptions ("During handling of the above exception...") the final
619
+ * one is what actually propagated.
620
+ */
621
+ function exceptionLine(text) {
622
+ const raw = (text || '').split('\n');
623
+ let header = -1;
624
+ for (let i = 0; i < raw.length; i++) {
625
+ if (/^traceback\b/i.test(raw[i].trim())) header = i;
626
+ }
627
+ if (header === -1) return null;
628
+ const exc = raw.slice(header + 1).find(
629
+ (l) => l.trim() && !/^\s/.test(l) && !/^=+$/.test(l.trim())
630
+ );
631
+ return exc ? exc.trim() : null;
632
+ }
633
+
634
+ // Slice by code point, not code unit, so an 80-char cut can't split an emoji
635
+ // or other astral character into a broken fragment.
636
+ function truncate(s, max = 80) {
637
+ const chars = Array.from(s);
638
+ return chars.length > max ? chars.slice(0, max).join('') : s;
639
+ }
640
+
579
641
  /**
580
642
  * Clears a set of tools best-effort: a single tool failing does not abort the
581
643
  * rest (unlike runBatch, which stops on first failure). Used by `uninstall`,
@@ -592,8 +654,7 @@ async function clearToolsBestEffort(layer, tools, { mdm, backendUrl, gatewayUrl,
592
654
  await runScriptPiped(tool.script, args);
593
655
  s.succeed(`${layer}: ${tool.label}`);
594
656
  } catch (err) {
595
- const reason = (err.message || 'failed').split('\n')[0].slice(0, 80);
596
- s.fail(`${layer}: ${tool.label} (${reason})`);
657
+ s.fail(`${layer}: ${tool.label} (${failureReason(err)})`);
597
658
  failed.push(tool.label);
598
659
  }
599
660
  }
@@ -1183,6 +1244,7 @@ module.exports = {
1183
1244
  scriptSupportsBackfill,
1184
1245
  resolveSetupAllTools,
1185
1246
  clearUnboundEnvsEverywhere,
1247
+ failureReason,
1186
1248
  NUKE_ENV_VARS,
1187
1249
  removeBinaryInstall,
1188
1250
  runBinaryClear,
@@ -134,6 +134,42 @@ test('loginWithBrowser: success path resolves, sets Connection: close, and stops
134
134
  });
135
135
  });
136
136
 
137
+ test('loginWithBrowser: duplicate /callback cannot overwrite the stored credentials', async () => {
138
+ await withStubbedConfig(async () => {
139
+ const agent = new http.Agent({ keepAlive: true });
140
+ const storedKeys = [];
141
+ config.setApiKey = (key) => { storedKeys.push(key); };
142
+
143
+ const fakeOpen = async (authUrl) => {
144
+ const callbackUrl = new URL(authUrl).searchParams.get('callback_url');
145
+ await getKeepAlive(agent, `${callbackUrl}?api_key=sk-first&email=a@b.co&org=Acme`);
146
+ // Second callback with a different key: teardown is deferred to the
147
+ // response flush, so the server may still be up — it must answer 409
148
+ // without persisting; once down, ECONNREFUSED is equally acceptable.
149
+ // Either way the first credentials must be the only ones stored.
150
+ try {
151
+ const dup = await getKeepAlive(agent, `${callbackUrl}?api_key=sk-attacker&email=x@y.z&org=Evil`);
152
+ assert.equal(dup.statusCode, 409, 'late duplicate must be rejected, not processed');
153
+ } catch (err) {
154
+ if (err.code !== 'ECONNREFUSED') throw err;
155
+ }
156
+ };
157
+
158
+ try {
159
+ const result = await withTimeout(
160
+ auth.loginWithBrowser('http://frontend.invalid', { open: fakeOpen }),
161
+ 10_000,
162
+ 'loginWithBrowser duplicate-callback path',
163
+ );
164
+
165
+ assert.deepEqual(result, { email: 'a@b.co', orgName: 'Acme' });
166
+ assert.deepEqual(storedKeys, ['sk-first'], 'only the first callback may persist credentials');
167
+ } finally {
168
+ agent.destroy();
169
+ }
170
+ });
171
+ });
172
+
137
173
  test('loginWithBrowser: 404 path also sets Connection: close', async () => {
138
174
  await withStubbedConfig(async () => {
139
175
  const agent = new http.Agent({ keepAlive: true });
@@ -3,7 +3,7 @@ const assert = require('node:assert/strict');
3
3
  const fs = require('fs');
4
4
  const os = require('os');
5
5
  const path = require('path');
6
- const { buildScriptArgs, scriptSupportsBackfill, resolveSetupAllTools, clearUnboundEnvsEverywhere, NUKE_ENV_VARS, removeBinaryInstall, runBinaryClear, BINARY_INSTALL_PATHS } = require('../src/commands/setup');
6
+ const { buildScriptArgs, scriptSupportsBackfill, resolveSetupAllTools, clearUnboundEnvsEverywhere, failureReason, NUKE_ENV_VARS, removeBinaryInstall, runBinaryClear, BINARY_INSTALL_PATHS } = require('../src/commands/setup');
7
7
 
8
8
  // shellEscape single-quotes every value, so a real key surfaces as
9
9
  // --api-key '<key>' at the head of the argv tail.
@@ -451,3 +451,170 @@ test('runBinaryClear: clear exits non-zero — fail-open (ran:true, ok:false), n
451
451
  fs.rmSync(tmp, { recursive: true, force: true });
452
452
  }
453
453
  });
454
+
455
+ // The python clears open AND close with a `====` banner, so the old
456
+ // `.split('\n')[0]` reason rendered `Codex (gateway) (==================)` —
457
+ // which is what hid the real MDM-clear bug. Prefer the script's own error line.
458
+ test('failureReason: skips the ==== banner and reports the script error line', () => {
459
+ const err = new Error([
460
+ '============================================================',
461
+ 'Codex - Clearing MDM Setup',
462
+ '============================================================',
463
+ '',
464
+ 'Clearing environment variables...',
465
+ 'Failed to clear openai_base_url in codex config for alice',
466
+ '',
467
+ 'Clear Complete!',
468
+ ].join('\n'));
469
+ err.exitCode = 1;
470
+ assert.equal(failureReason(err), 'Failed to clear openai_base_url in codex config for alice');
471
+ });
472
+
473
+ test('failureReason: picks the LAST flagged line when a script reports several', () => {
474
+ const err = new Error('Failed to clear API_KEY for 1 user(s)\nFailed to clear BASE_URL for 1 user(s)');
475
+ assert.equal(failureReason(err), 'Failed to clear BASE_URL for 1 user(s)');
476
+ });
477
+
478
+ test('failureReason: recognises the scripts’ ❌ prefix', () => {
479
+ const err = new Error('====\nSetup\n====\n❌ No user home directories found');
480
+ err.exitCode = 1;
481
+ assert.equal(failureReason(err), '❌ No user home directories found');
482
+ });
483
+
484
+ // Banner-only output with no error line must not surface "Clear Complete!" as
485
+ // the reason — the exit code is the honest answer there.
486
+ test('failureReason: no flagged line falls back to the exit code, not the banner', () => {
487
+ const err = new Error('============\nCodex - Clearing MDM Setup\n============\n\nClear Complete!');
488
+ err.exitCode = 2;
489
+ assert.equal(failureReason(err), 'exit code 2');
490
+ });
491
+
492
+ // Download failures carry no exitCode; their message is already the reason.
493
+ test('failureReason: download failure with no exitCode keeps its own message', () => {
494
+ const err = new Error('Download failed: 404 Not Found');
495
+ err.downloadFailed = true;
496
+ assert.equal(failureReason(err), 'Download failed: 404 Not Found');
497
+ });
498
+
499
+ test('failureReason: empty message never returns undefined', () => {
500
+ assert.equal(failureReason(new Error('')), 'failed');
501
+ assert.equal(failureReason({}), 'failed');
502
+ });
503
+
504
+ test('failureReason: long error lines are truncated to 80 chars', () => {
505
+ const err = new Error('Failed to ' + 'x'.repeat(200));
506
+ assert.equal(failureReason(err).length, 80);
507
+ });
508
+
509
+ // A python crash prints a traceback whose LAST line is the real cause. Falling
510
+ // back to `exit code 1` there would be less useful than the old first-line rule.
511
+ test('failureReason: python traceback reports the exception, not the header', () => {
512
+ const err = new Error([
513
+ 'Traceback (most recent call last):',
514
+ ' File "/tmp/setup.py", line 704, in clear_setup',
515
+ ' remove_codex_config_base_url_for_user(username, home_dir)',
516
+ "NameError: name 'home_dir' is not defined",
517
+ ].join('\n'));
518
+ err.exitCode = 1;
519
+ assert.equal(failureReason(err), "NameError: name 'home_dir' is not defined");
520
+ });
521
+
522
+ // exitCode 0 is falsy — must not fall through to the banner-first branch.
523
+ // Module-qualified exception names have dots, so a name-shaped regex misses
524
+ // them. The traceback tail is taken instead, whatever the exception is called.
525
+ test('failureReason: dotted exception name in a traceback is reported', () => {
526
+ const err = new Error([
527
+ 'Traceback (most recent call last):',
528
+ ' File "/tmp/setup.py", line 800, in clear_managed_hooks',
529
+ ' settings = json.load(f)',
530
+ 'json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)',
531
+ ].join('\n'));
532
+ err.exitCode = 1;
533
+ assert.equal(failureReason(err), 'json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)');
534
+ });
535
+
536
+ // Not every python exception name contains "Error"/"Exception".
537
+ test('failureReason: traceback tail works for a non-Error exception name', () => {
538
+ const err = new Error('Traceback (most recent call last):\n File "x.py", line 1\nsocket.timeout: timed out');
539
+ err.exitCode = 1;
540
+ assert.equal(failureReason(err), 'socket.timeout: timed out');
541
+ });
542
+
543
+ // A bare exception line captured without its traceback header still flags.
544
+ test('failureReason: dotted exception line without a traceback header flags', () => {
545
+ const err = new Error('====\nCodex - Clearing MDM Setup\n====\nsubprocess.CalledProcessError: returned non-zero exit status 1');
546
+ err.exitCode = 1;
547
+ assert.equal(failureReason(err), 'subprocess.CalledProcessError: returned non-zero exit status 1');
548
+ });
549
+
550
+ // Banner lines after the traceback must not be mistaken for the cause.
551
+ test('failureReason: banner trailing a traceback is skipped', () => {
552
+ const err = new Error('Traceback (most recent call last):\nValueError: bad\n============');
553
+ err.exitCode = 1;
554
+ assert.equal(failureReason(err), 'ValueError: bad');
555
+ });
556
+
557
+ // runPythonScript merges stdout+stderr by arrival order and python block-buffers
558
+ // stdout when piped, so a crash's progress lines can land AFTER the traceback.
559
+ // Picking the tail would report "Clear Complete!" as the failure reason.
560
+ test('failureReason: stdout flushed after the traceback does not displace the exception', () => {
561
+ const err = new Error([
562
+ 'Traceback (most recent call last):',
563
+ ' File "/tmp/setup.py", line 9, in clear_setup',
564
+ 'ValueError: the real cause',
565
+ '============================================================',
566
+ 'Clear Complete!',
567
+ ].join('\n'));
568
+ err.exitCode = 1;
569
+ assert.equal(failureReason(err), 'ValueError: the real cause');
570
+ });
571
+
572
+ // stderr is captured separately precisely so interleaving can't matter.
573
+ test('failureReason: prefers the clean stderr stream over the merged output', () => {
574
+ const err = new Error('====\nClearing environment variables...\nTraceback (most recent call last):\nOSError: merged and interleaved\nClear Complete!');
575
+ err.stderr = 'Traceback (most recent call last):\n File "x.py", line 1\nPermissionError: from stderr';
576
+ err.exitCode = 1;
577
+ assert.equal(failureReason(err), 'PermissionError: from stderr');
578
+ });
579
+
580
+ // Chained exceptions: the LAST traceback is the one that actually propagated.
581
+ test('failureReason: chained exceptions report the final one', () => {
582
+ const err = new Error([
583
+ 'Traceback (most recent call last):',
584
+ ' File "x.py", line 1',
585
+ 'ValueError: the first one',
586
+ 'During handling of the above exception, another exception occurred:',
587
+ 'Traceback (most recent call last):',
588
+ ' File "x.py", line 5',
589
+ 'RuntimeError: the one that propagated',
590
+ ].join('\n'));
591
+ err.exitCode = 1;
592
+ assert.equal(failureReason(err), 'RuntimeError: the one that propagated');
593
+ });
594
+
595
+ // Indented frame lines are never the reason.
596
+ test('failureReason: never reports an indented traceback frame line', () => {
597
+ const err = new Error('Traceback (most recent call last):\n File "x.py", line 1, in f\n boom()\nKeyError: \'gateway_url\'');
598
+ err.exitCode = 1;
599
+ assert.equal(failureReason(err), "KeyError: 'gateway_url'");
600
+ });
601
+
602
+ test('failureReason: exitCode 0 is reported, not treated as absent', () => {
603
+ assert.equal(failureReason({ message: '====\nCodex\n====', exitCode: 0 }), 'exit code 0');
604
+ });
605
+
606
+ // Truncation slices code points, so an emoji at the boundary stays intact.
607
+ test('failureReason: truncation never splits an astral character', () => {
608
+ const reason = failureReason(new Error('Failed ' + '🔥'.repeat(100)));
609
+ assert.equal(Array.from(reason).length, 80);
610
+ assert.ok(!reason.includes('\ufffd'), reason);
611
+ assert.ok(/🔥$/.test(reason), reason);
612
+ });
613
+
614
+ // Runs inside nuke's per-tool catch — throwing here would abort the remaining
615
+ // tools instead of clearing them, so a non-Error throw must not blow up.
616
+ test('failureReason: non-Error throw (null/undefined) never throws', () => {
617
+ assert.equal(failureReason(null), 'failed');
618
+ assert.equal(failureReason(undefined), 'failed');
619
+ assert.equal(failureReason('a string'), 'failed');
620
+ });