@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.
Files changed (3) hide show
  1. package/README.md +3 -2
  2. package/cdx.mjs +227 -44
  3. 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.2
9
+ ### 1.7.4
10
10
 
11
11
  #### Fixes
12
12
 
13
- - Improve device OAuth failure diagnostics during login/relogin. When device flow startup or polling fails, `cdx` now prints technical details (HTTP status, OAuth error code, and response/body snippets where available) instead of only showing a generic "not available right now" message.
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.2";
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
- let oauthError;
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
- let errorCode;
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
- const server = await startOAuthServer(flow.state);
1496
- if (!server.ready) {
1497
- p.log.error("Failed to start local server on port 1455.");
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bjesuiter/codex-switcher",
3
- "version": "1.7.2",
3
+ "version": "1.7.4",
4
4
  "type": "module",
5
5
  "description": "CLI tool to switch between multiple OpenAI accounts for OpenCode",
6
6
  "bin": {