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 +1 -1
- package/src/auth-pages.js +178 -0
- package/src/auth.js +35 -12
- package/src/commands/setup.js +66 -4
- package/test/auth-server-leak.test.js +36 -0
- package/test/setup-args.test.js +168 -1
package/package.json
CHANGED
|
@@ -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, '<')
|
|
21
|
+
.replace(/>/g, '>')
|
|
22
|
+
.replace(/"/g, '"')
|
|
23
|
+
.replace(/'/g, ''');
|
|
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
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
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
|
|
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
|
|
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) => {
|
package/src/commands/setup.js
CHANGED
|
@@ -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) => {
|
|
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)
|
|
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
|
-
|
|
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 });
|
package/test/setup-args.test.js
CHANGED
|
@@ -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
|
+
});
|