@bjesuiter/codex-switcher 1.7.2 → 1.7.4
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/README.md +3 -2
- package/cdx.mjs +227 -44
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -6,11 +6,12 @@ Switch the coding-agents [pi](https://pi.dev/), [codex](https://developers.opena
|
|
|
6
6
|
|
|
7
7
|
## Latest Changes
|
|
8
8
|
|
|
9
|
-
### 1.7.
|
|
9
|
+
### 1.7.4
|
|
10
10
|
|
|
11
11
|
#### Fixes
|
|
12
12
|
|
|
13
|
-
-
|
|
13
|
+
- Detect Cloudflare/bot-protection HTML challenge responses during OAuth device flow startup and token polling, and report an explicit `cloudflare_challenge` reason instead of only a generic HTTP 403.
|
|
14
|
+
- When that challenge is detected, show a clear workaround: retry without `--device-flow` to use browser/manual callback flow.
|
|
14
15
|
|
|
15
16
|
see full changelog here: https://github.com/bjesuiter/codex-switcher/blob/main/CHANGELOG.md
|
|
16
17
|
|
package/cdx.mjs
CHANGED
|
@@ -15,7 +15,7 @@ import { generatePKCE } from "@openauthjs/openauth/pkce";
|
|
|
15
15
|
import http from "node:http";
|
|
16
16
|
|
|
17
17
|
//#region package.json
|
|
18
|
-
var version = "1.7.
|
|
18
|
+
var version = "1.7.4";
|
|
19
19
|
|
|
20
20
|
//#endregion
|
|
21
21
|
//#region lib/platform/path-resolver.ts
|
|
@@ -1036,6 +1036,46 @@ const truncateForLog = (value, maxLength = 300) => {
|
|
|
1036
1036
|
if (value.length <= maxLength) return value;
|
|
1037
1037
|
return `${value.slice(0, maxLength)}…`;
|
|
1038
1038
|
};
|
|
1039
|
+
const isCloudflareChallengeResponse = (headers, bodyText) => {
|
|
1040
|
+
if (headers.get("cf-mitigated")?.toLowerCase() === "challenge") return true;
|
|
1041
|
+
if (!bodyText) return false;
|
|
1042
|
+
return [
|
|
1043
|
+
/<title>Just a moment\.\.\.<\/title>/i,
|
|
1044
|
+
/Enable JavaScript and cookies to continue/i,
|
|
1045
|
+
/challenge-platform/i,
|
|
1046
|
+
/_cf_chl_opt/i
|
|
1047
|
+
].some((pattern) => pattern.test(bodyText));
|
|
1048
|
+
};
|
|
1049
|
+
const parseOAuthErrorResponse = async (res) => {
|
|
1050
|
+
let rawBody;
|
|
1051
|
+
try {
|
|
1052
|
+
rawBody = await res.text();
|
|
1053
|
+
} catch {
|
|
1054
|
+
rawBody = void 0;
|
|
1055
|
+
}
|
|
1056
|
+
const failureReason = isCloudflareChallengeResponse(res.headers, rawBody) ? "cloudflare_challenge" : void 0;
|
|
1057
|
+
if (!rawBody) return { ...failureReason ? { failureReason } : {} };
|
|
1058
|
+
const trimmed = rawBody.trim();
|
|
1059
|
+
if (!trimmed) return { ...failureReason ? { failureReason } : {} };
|
|
1060
|
+
try {
|
|
1061
|
+
const json = JSON.parse(trimmed);
|
|
1062
|
+
return {
|
|
1063
|
+
...json.error ? { oauthError: json.error } : {},
|
|
1064
|
+
...typeof json.interval === "number" ? { interval: json.interval } : {},
|
|
1065
|
+
responseBody: truncateForLog(JSON.stringify({
|
|
1066
|
+
...json.error ? { error: json.error } : {},
|
|
1067
|
+
...json.error_description ? { error_description: json.error_description } : {},
|
|
1068
|
+
...typeof json.interval === "number" ? { interval: json.interval } : {}
|
|
1069
|
+
})),
|
|
1070
|
+
...failureReason ? { failureReason } : {}
|
|
1071
|
+
};
|
|
1072
|
+
} catch {
|
|
1073
|
+
return {
|
|
1074
|
+
responseBody: failureReason === "cloudflare_challenge" ? "Cloudflare challenge response detected (HTML page)" : truncateForLog(trimmed),
|
|
1075
|
+
...failureReason ? { failureReason } : {}
|
|
1076
|
+
};
|
|
1077
|
+
}
|
|
1078
|
+
};
|
|
1039
1079
|
const startDeviceAuthorizationFlow = async () => {
|
|
1040
1080
|
try {
|
|
1041
1081
|
const res = await fetch(DEVICE_CODE_URL, {
|
|
@@ -1047,28 +1087,14 @@ const startDeviceAuthorizationFlow = async () => {
|
|
|
1047
1087
|
})
|
|
1048
1088
|
});
|
|
1049
1089
|
if (!res.ok) {
|
|
1050
|
-
|
|
1051
|
-
let responseBody;
|
|
1052
|
-
try {
|
|
1053
|
-
const json = await res.json();
|
|
1054
|
-
oauthError = json.error;
|
|
1055
|
-
responseBody = truncateForLog(JSON.stringify({
|
|
1056
|
-
...json.error ? { error: json.error } : {},
|
|
1057
|
-
...json.error_description ? { error_description: json.error_description } : {}
|
|
1058
|
-
}));
|
|
1059
|
-
} catch {
|
|
1060
|
-
try {
|
|
1061
|
-
responseBody = truncateForLog(await res.text());
|
|
1062
|
-
} catch {
|
|
1063
|
-
responseBody = void 0;
|
|
1064
|
-
}
|
|
1065
|
-
}
|
|
1090
|
+
const { oauthError, responseBody, failureReason } = await parseOAuthErrorResponse(res);
|
|
1066
1091
|
return {
|
|
1067
1092
|
type: "failed",
|
|
1068
|
-
error: `Device code request failed with HTTP ${res.status} ${res.statusText}`,
|
|
1093
|
+
error: failureReason === "cloudflare_challenge" ? "Device code request was blocked by a Cloudflare challenge response." : `Device code request failed with HTTP ${res.status} ${res.statusText}`,
|
|
1069
1094
|
status: res.status,
|
|
1070
1095
|
...oauthError ? { oauthError } : {},
|
|
1071
|
-
...responseBody ? { responseBody } : {}
|
|
1096
|
+
...responseBody ? { responseBody } : {},
|
|
1097
|
+
...failureReason ? { failureReason } : {}
|
|
1072
1098
|
};
|
|
1073
1099
|
}
|
|
1074
1100
|
const json = await res.json();
|
|
@@ -1121,25 +1147,7 @@ const pollDeviceAuthorizationToken = async (deviceCode) => {
|
|
|
1121
1147
|
idToken: json.id_token
|
|
1122
1148
|
};
|
|
1123
1149
|
}
|
|
1124
|
-
|
|
1125
|
-
let interval;
|
|
1126
|
-
let responseBody;
|
|
1127
|
-
try {
|
|
1128
|
-
const json = await res.json();
|
|
1129
|
-
errorCode = json.error;
|
|
1130
|
-
interval = json.interval;
|
|
1131
|
-
responseBody = truncateForLog(JSON.stringify({
|
|
1132
|
-
...json.error ? { error: json.error } : {},
|
|
1133
|
-
...json.error_description ? { error_description: json.error_description } : {},
|
|
1134
|
-
...typeof json.interval === "number" ? { interval: json.interval } : {}
|
|
1135
|
-
}));
|
|
1136
|
-
} catch {
|
|
1137
|
-
try {
|
|
1138
|
-
responseBody = truncateForLog(await res.text());
|
|
1139
|
-
} catch {
|
|
1140
|
-
responseBody = void 0;
|
|
1141
|
-
}
|
|
1142
|
-
}
|
|
1150
|
+
const { oauthError: errorCode, interval, responseBody, failureReason } = await parseOAuthErrorResponse(res);
|
|
1143
1151
|
if (errorCode === "authorization_pending") return {
|
|
1144
1152
|
type: "pending",
|
|
1145
1153
|
interval: typeof interval === "number" && interval > 0 ? interval : 5
|
|
@@ -1152,10 +1160,11 @@ const pollDeviceAuthorizationToken = async (deviceCode) => {
|
|
|
1152
1160
|
if (errorCode === "expired_token") return { type: "expired" };
|
|
1153
1161
|
return {
|
|
1154
1162
|
type: "failed",
|
|
1155
|
-
error: `Device token polling failed with HTTP ${res.status} ${res.statusText}`,
|
|
1163
|
+
error: failureReason === "cloudflare_challenge" ? "Device token polling was blocked by a Cloudflare challenge response." : `Device token polling failed with HTTP ${res.status} ${res.statusText}`,
|
|
1156
1164
|
status: res.status,
|
|
1157
1165
|
...errorCode ? { oauthError: errorCode } : {},
|
|
1158
|
-
...responseBody ? { responseBody } : {}
|
|
1166
|
+
...responseBody ? { responseBody } : {},
|
|
1167
|
+
...failureReason ? { failureReason } : {}
|
|
1159
1168
|
};
|
|
1160
1169
|
} catch (error) {
|
|
1161
1170
|
return {
|
|
@@ -1310,10 +1319,14 @@ const startOAuthServer = (state) => {
|
|
|
1310
1319
|
},
|
|
1311
1320
|
waitForCode: () => codePromise
|
|
1312
1321
|
});
|
|
1313
|
-
}).on("error", () => {
|
|
1322
|
+
}).on("error", (error) => {
|
|
1323
|
+
const err = error;
|
|
1314
1324
|
resolve({
|
|
1315
1325
|
port: CALLBACK_PORT,
|
|
1316
1326
|
ready: false,
|
|
1327
|
+
reason: err?.code === "EADDRINUSE" ? "port_in_use" : "listen_failed",
|
|
1328
|
+
...typeof err?.message === "string" ? { error: err.message } : {},
|
|
1329
|
+
...typeof err?.code === "string" ? { errorCode: err.code } : {},
|
|
1317
1330
|
close: () => {
|
|
1318
1331
|
try {
|
|
1319
1332
|
server.close();
|
|
@@ -1333,6 +1346,101 @@ const isLikelyRemoteEnvironment = () => {
|
|
|
1333
1346
|
if (process.env.SSH_CONNECTION || process.env.SSH_CLIENT || process.env.SSH_TTY) return true;
|
|
1334
1347
|
return !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY;
|
|
1335
1348
|
};
|
|
1349
|
+
const parseLsofListeningProcess = (output) => {
|
|
1350
|
+
const lines = output.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
1351
|
+
let pid = null;
|
|
1352
|
+
let command;
|
|
1353
|
+
for (const line of lines) {
|
|
1354
|
+
if (line.startsWith("p") && pid === null) {
|
|
1355
|
+
const parsedPid = Number.parseInt(line.slice(1), 10);
|
|
1356
|
+
if (!Number.isNaN(parsedPid) && parsedPid > 0) pid = parsedPid;
|
|
1357
|
+
continue;
|
|
1358
|
+
}
|
|
1359
|
+
if (line.startsWith("c") && pid !== null && !command) {
|
|
1360
|
+
const parsedCommand = line.slice(1).trim();
|
|
1361
|
+
if (parsedCommand) command = parsedCommand;
|
|
1362
|
+
}
|
|
1363
|
+
if (pid !== null && command) break;
|
|
1364
|
+
}
|
|
1365
|
+
if (pid === null) return null;
|
|
1366
|
+
return {
|
|
1367
|
+
pid,
|
|
1368
|
+
...command ? { command } : {}
|
|
1369
|
+
};
|
|
1370
|
+
};
|
|
1371
|
+
const parseWindowsNetstatListeningPid = (output, port) => {
|
|
1372
|
+
const lines = output.split(/\r?\n/);
|
|
1373
|
+
const portSuffix = `:${port}`;
|
|
1374
|
+
for (const rawLine of lines) {
|
|
1375
|
+
const line = rawLine.trim();
|
|
1376
|
+
if (!line || !/LISTENING/i.test(line) || !line.includes(portSuffix)) continue;
|
|
1377
|
+
const pidRaw = line.split(/\s+/).at(-1);
|
|
1378
|
+
const parsedPid = pidRaw ? Number.parseInt(pidRaw, 10) : NaN;
|
|
1379
|
+
if (!Number.isNaN(parsedPid) && parsedPid > 0) return parsedPid;
|
|
1380
|
+
}
|
|
1381
|
+
return null;
|
|
1382
|
+
};
|
|
1383
|
+
const findListeningProcessOnPort = (port, platform = process.platform) => {
|
|
1384
|
+
if (platform === "win32") {
|
|
1385
|
+
const netstat = Bun.spawnSync({
|
|
1386
|
+
cmd: [
|
|
1387
|
+
"netstat",
|
|
1388
|
+
"-ano",
|
|
1389
|
+
"-p",
|
|
1390
|
+
"tcp"
|
|
1391
|
+
],
|
|
1392
|
+
stdout: "pipe",
|
|
1393
|
+
stderr: "pipe"
|
|
1394
|
+
});
|
|
1395
|
+
if (netstat.exitCode !== 0) return null;
|
|
1396
|
+
const pid = parseWindowsNetstatListeningPid(Buffer.from(netstat.stdout).toString("utf8"), port);
|
|
1397
|
+
if (!pid) return null;
|
|
1398
|
+
const tasklist = Bun.spawnSync({
|
|
1399
|
+
cmd: [
|
|
1400
|
+
"tasklist",
|
|
1401
|
+
"/FI",
|
|
1402
|
+
`PID eq ${pid}`,
|
|
1403
|
+
"/FO",
|
|
1404
|
+
"CSV",
|
|
1405
|
+
"/NH"
|
|
1406
|
+
],
|
|
1407
|
+
stdout: "pipe",
|
|
1408
|
+
stderr: "pipe"
|
|
1409
|
+
});
|
|
1410
|
+
if (tasklist.exitCode !== 0) return { pid };
|
|
1411
|
+
const line = Buffer.from(tasklist.stdout).toString("utf8").trim().split(/\r?\n/)[0] ?? "";
|
|
1412
|
+
if (!line || /No tasks are running/i.test(line)) return { pid };
|
|
1413
|
+
const command = line.replace(/^"|"$/g, "").split("\",\"")[0]?.trim();
|
|
1414
|
+
return {
|
|
1415
|
+
pid,
|
|
1416
|
+
...command ? { command } : {}
|
|
1417
|
+
};
|
|
1418
|
+
}
|
|
1419
|
+
const lsof = Bun.spawnSync({
|
|
1420
|
+
cmd: [
|
|
1421
|
+
"lsof",
|
|
1422
|
+
"-nP",
|
|
1423
|
+
`-iTCP:${port}`,
|
|
1424
|
+
"-sTCP:LISTEN",
|
|
1425
|
+
"-FpPc"
|
|
1426
|
+
],
|
|
1427
|
+
stdout: "pipe",
|
|
1428
|
+
stderr: "pipe"
|
|
1429
|
+
});
|
|
1430
|
+
if (lsof.exitCode !== 0) return null;
|
|
1431
|
+
return parseLsofListeningProcess(Buffer.from(lsof.stdout).toString("utf8"));
|
|
1432
|
+
};
|
|
1433
|
+
const killProcessByPid = (pid) => {
|
|
1434
|
+
try {
|
|
1435
|
+
process.kill(pid, "SIGTERM");
|
|
1436
|
+
return { ok: true };
|
|
1437
|
+
} catch (error) {
|
|
1438
|
+
return {
|
|
1439
|
+
ok: false,
|
|
1440
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1441
|
+
};
|
|
1442
|
+
}
|
|
1443
|
+
};
|
|
1336
1444
|
const parseOAuthCallbackInput = (input) => {
|
|
1337
1445
|
const trimmed = input.trim();
|
|
1338
1446
|
if (!trimmed) return null;
|
|
@@ -1399,6 +1507,34 @@ const promptBrowserFallbackChoice = async () => {
|
|
|
1399
1507
|
if (p.isCancel(selection)) return "cancel";
|
|
1400
1508
|
return selection;
|
|
1401
1509
|
};
|
|
1510
|
+
const promptPortConflictChoice = async (listeningProcess) => {
|
|
1511
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
1512
|
+
p.log.info("Non-interactive terminal detected. Falling back to device OAuth flow.");
|
|
1513
|
+
return "device";
|
|
1514
|
+
}
|
|
1515
|
+
const killHint = listeningProcess ? `PID ${listeningProcess.pid}${listeningProcess.command ? ` (${listeningProcess.command})` : ""}` : "Attempt to free port and retry";
|
|
1516
|
+
const selection = await p.select({
|
|
1517
|
+
message: `Port ${CALLBACK_PORT} is already in use. How do you want to continue?`,
|
|
1518
|
+
options: [
|
|
1519
|
+
{
|
|
1520
|
+
value: "kill_and_retry",
|
|
1521
|
+
label: `Kill existing listener on port ${CALLBACK_PORT} and retry browser flow`,
|
|
1522
|
+
hint: killHint
|
|
1523
|
+
},
|
|
1524
|
+
{
|
|
1525
|
+
value: "device",
|
|
1526
|
+
label: "Continue with OAuth device flow",
|
|
1527
|
+
hint: "No local callback server required"
|
|
1528
|
+
},
|
|
1529
|
+
{
|
|
1530
|
+
value: "cancel",
|
|
1531
|
+
label: "Cancel login"
|
|
1532
|
+
}
|
|
1533
|
+
]
|
|
1534
|
+
});
|
|
1535
|
+
if (p.isCancel(selection)) return "cancel";
|
|
1536
|
+
return selection;
|
|
1537
|
+
};
|
|
1402
1538
|
const promptManualAuthorizationCode = async (authorizationUrl, expectedState) => {
|
|
1403
1539
|
p.log.info("Manual login selected.");
|
|
1404
1540
|
p.log.message(`Open this URL in a browser:\n${authorizationUrl}`);
|
|
@@ -1434,6 +1570,10 @@ const runDeviceOAuthFlow = async (useSpinner) => {
|
|
|
1434
1570
|
if (typeof deviceFlowResult.status === "number") p.log.error(`HTTP status: ${deviceFlowResult.status}`);
|
|
1435
1571
|
if (deviceFlowResult.oauthError) p.log.error(`OAuth error: ${deviceFlowResult.oauthError}`);
|
|
1436
1572
|
if (deviceFlowResult.responseBody) p.log.error(`Response: ${deviceFlowResult.responseBody}`);
|
|
1573
|
+
if (deviceFlowResult.failureReason === "cloudflare_challenge") {
|
|
1574
|
+
p.log.warning("Detected Cloudflare challenge on auth.openai.com.");
|
|
1575
|
+
p.log.info("Retry without --device-flow to use browser/manual callback flow.");
|
|
1576
|
+
}
|
|
1437
1577
|
return null;
|
|
1438
1578
|
}
|
|
1439
1579
|
const deviceFlow = deviceFlowResult.flow;
|
|
@@ -1483,6 +1623,10 @@ const runDeviceOAuthFlow = async (useSpinner) => {
|
|
|
1483
1623
|
if (typeof pollResult.status === "number") p.log.error(`HTTP status: ${pollResult.status}`);
|
|
1484
1624
|
if (pollResult.oauthError) p.log.error(`OAuth error: ${pollResult.oauthError}`);
|
|
1485
1625
|
if (pollResult.responseBody) p.log.error(`Response: ${pollResult.responseBody}`);
|
|
1626
|
+
if (pollResult.failureReason === "cloudflare_challenge") {
|
|
1627
|
+
p.log.warning("Detected Cloudflare challenge on auth.openai.com.");
|
|
1628
|
+
p.log.info("Retry without --device-flow to use browser/manual callback flow.");
|
|
1629
|
+
}
|
|
1486
1630
|
}
|
|
1487
1631
|
return null;
|
|
1488
1632
|
}
|
|
@@ -1492,9 +1636,48 @@ const runDeviceOAuthFlow = async (useSpinner) => {
|
|
|
1492
1636
|
};
|
|
1493
1637
|
const requestTokenViaOAuth = async (flow, options) => {
|
|
1494
1638
|
if (options.authFlow === "device") return runDeviceOAuthFlow(options.useSpinner);
|
|
1495
|
-
|
|
1496
|
-
if (!server.ready) {
|
|
1497
|
-
p.log.
|
|
1639
|
+
let server = await startOAuthServer(flow.state);
|
|
1640
|
+
if (!server.ready) if (server.reason === "port_in_use") {
|
|
1641
|
+
p.log.warning(`Local callback server port ${CALLBACK_PORT} is already in use.`);
|
|
1642
|
+
const listeningProcess = findListeningProcessOnPort(CALLBACK_PORT);
|
|
1643
|
+
if (listeningProcess) p.log.info(`Detected listener on port ${CALLBACK_PORT}: PID ${listeningProcess.pid}${listeningProcess.command ? ` (${listeningProcess.command})` : ""}`);
|
|
1644
|
+
const choice = await promptPortConflictChoice(listeningProcess);
|
|
1645
|
+
if (choice === "cancel") {
|
|
1646
|
+
p.log.info("Login cancelled.");
|
|
1647
|
+
return null;
|
|
1648
|
+
}
|
|
1649
|
+
if (choice === "device") return runDeviceOAuthFlow(options.useSpinner);
|
|
1650
|
+
if (listeningProcess) {
|
|
1651
|
+
const confirmed = await p.confirm({
|
|
1652
|
+
message: `Kill PID ${listeningProcess.pid}${listeningProcess.command ? ` (${listeningProcess.command})` : ""}?`,
|
|
1653
|
+
initialValue: false
|
|
1654
|
+
});
|
|
1655
|
+
if (p.isCancel(confirmed) || !confirmed) {
|
|
1656
|
+
p.log.info("Cancelled. No process was terminated.");
|
|
1657
|
+
return null;
|
|
1658
|
+
}
|
|
1659
|
+
const killResult = killProcessByPid(listeningProcess.pid);
|
|
1660
|
+
if (!killResult.ok) {
|
|
1661
|
+
p.log.error(`Failed to terminate PID ${listeningProcess.pid}.`);
|
|
1662
|
+
p.log.error(`Technical details: ${killResult.error ?? "unknown error"}`);
|
|
1663
|
+
p.log.info("Try device flow instead: cdx login --device-flow");
|
|
1664
|
+
return null;
|
|
1665
|
+
}
|
|
1666
|
+
p.log.success(`Sent SIGTERM to PID ${listeningProcess.pid}. Retrying local server...`);
|
|
1667
|
+
await sleep(250);
|
|
1668
|
+
} else p.log.warning(`Could not identify which process is listening on port ${CALLBACK_PORT}. Retrying once...`);
|
|
1669
|
+
server = await startOAuthServer(flow.state);
|
|
1670
|
+
if (!server.ready) {
|
|
1671
|
+
p.log.error(`Failed to start local server on port ${CALLBACK_PORT} after retry.`);
|
|
1672
|
+
if (server.error) p.log.error(`Technical details: ${server.error}`);
|
|
1673
|
+
if (server.errorCode) p.log.error(`Error code: ${server.errorCode}`);
|
|
1674
|
+
p.log.info("Try device flow instead: cdx login --device-flow");
|
|
1675
|
+
return null;
|
|
1676
|
+
}
|
|
1677
|
+
} else {
|
|
1678
|
+
p.log.error(`Failed to start local server on port ${CALLBACK_PORT}.`);
|
|
1679
|
+
if (server.error) p.log.error(`Technical details: ${server.error}`);
|
|
1680
|
+
if (server.errorCode) p.log.error(`Error code: ${server.errorCode}`);
|
|
1498
1681
|
p.log.info("Please ensure the port is not in use.");
|
|
1499
1682
|
return null;
|
|
1500
1683
|
}
|