@bitkyc08/opencodex 2.6.17 → 2.6.18

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 (84) hide show
  1. package/README.md +9 -0
  2. package/bin/ocx.mjs +70 -5
  3. package/gui/dist/assets/index-DDcEW0Cm.css +1 -0
  4. package/gui/dist/assets/index-DbTEyo46.js +9 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +3 -1
  7. package/src/adapters/anthropic.ts +9 -2
  8. package/src/adapters/base.ts +6 -0
  9. package/src/adapters/cursor/arg-codec.ts +38 -0
  10. package/src/adapters/cursor/arg-normalize.ts +88 -0
  11. package/src/adapters/cursor/cursor-errors.ts +85 -0
  12. package/src/adapters/cursor/discovery.ts +144 -0
  13. package/src/adapters/cursor/effort-map.ts +74 -0
  14. package/src/adapters/cursor/exec-policy.ts +44 -0
  15. package/src/adapters/cursor/framing.ts +136 -0
  16. package/src/adapters/cursor/gen/agent_pb.ts +15274 -0
  17. package/src/adapters/cursor/kv-store.ts +25 -0
  18. package/src/adapters/cursor/live-models.ts +93 -0
  19. package/src/adapters/cursor/live-smoke-gate.ts +41 -0
  20. package/src/adapters/cursor/live-transport.ts +758 -0
  21. package/src/adapters/cursor/mcp-config.ts +42 -0
  22. package/src/adapters/cursor/mcp-manager.ts +236 -0
  23. package/src/adapters/cursor/message-mapper.ts +46 -0
  24. package/src/adapters/cursor/native-exec-common.ts +55 -0
  25. package/src/adapters/cursor/native-exec-desktop.ts +177 -0
  26. package/src/adapters/cursor/native-exec-fs.ts +284 -0
  27. package/src/adapters/cursor/native-exec-mcp.ts +151 -0
  28. package/src/adapters/cursor/native-exec-network.ts +32 -0
  29. package/src/adapters/cursor/native-exec-shell.ts +191 -0
  30. package/src/adapters/cursor/native-exec-tools.ts +118 -0
  31. package/src/adapters/cursor/native-exec.ts +177 -0
  32. package/src/adapters/cursor/protobuf-events.ts +309 -0
  33. package/src/adapters/cursor/protobuf-request.ts +347 -0
  34. package/src/adapters/cursor/request-builder.ts +98 -0
  35. package/src/adapters/cursor/tool-definitions.ts +301 -0
  36. package/src/adapters/cursor/transport-retry.ts +116 -0
  37. package/src/adapters/cursor/transport.ts +47 -0
  38. package/src/adapters/cursor/types.ts +36 -0
  39. package/src/adapters/cursor.ts +99 -0
  40. package/src/adapters/google.ts +7 -1
  41. package/src/adapters/kiro.ts +15 -0
  42. package/src/adapters/openai-chat.ts +7 -2
  43. package/src/adapters/run-turn-queue.ts +58 -0
  44. package/src/adapters/tool-catalog-nudge.ts +71 -0
  45. package/src/bridge.ts +7 -1
  46. package/src/cli-help.ts +9 -2
  47. package/src/cli-status.ts +7 -5
  48. package/src/cli.ts +122 -79
  49. package/src/codex-catalog.ts +213 -71
  50. package/src/codex-history-provider.ts +31 -14
  51. package/src/codex-inject.ts +17 -9
  52. package/src/codex-paths.ts +2 -1
  53. package/src/codex-shim.ts +30 -7
  54. package/src/codex-sync.ts +70 -0
  55. package/src/config.ts +58 -2
  56. package/src/doctor.ts +4 -2
  57. package/src/index.ts +1 -0
  58. package/src/model-cache.ts +22 -2
  59. package/src/oauth/callback-server.ts +44 -16
  60. package/src/oauth/cursor.ts +188 -0
  61. package/src/oauth/index.ts +29 -3
  62. package/src/oauth/key-providers.ts +20 -33
  63. package/src/oauth/login-cli.ts +7 -4
  64. package/src/open-url.ts +5 -1
  65. package/src/ports.ts +13 -0
  66. package/src/process-control.ts +76 -0
  67. package/src/provider-label.ts +10 -5
  68. package/src/providers/derive.ts +30 -3
  69. package/src/providers/registry.ts +39 -1
  70. package/src/proxy-liveness.ts +122 -0
  71. package/src/responses/parser.ts +1 -0
  72. package/src/responses/state.ts +83 -0
  73. package/src/router.ts +38 -23
  74. package/src/server/adapter-resolve.ts +3 -0
  75. package/src/server.ts +130 -18
  76. package/src/service.ts +94 -32
  77. package/src/types.ts +24 -1
  78. package/src/update-job.ts +360 -0
  79. package/src/update.ts +73 -11
  80. package/src/usage-log.ts +3 -3
  81. package/src/usage-summary.ts +3 -2
  82. package/src/win-paths.ts +68 -0
  83. package/gui/dist/assets/index-DIBiVVC0.css +0 -1
  84. package/gui/dist/assets/index-DcnD944i.js +0 -9
package/src/codex-shim.ts CHANGED
@@ -3,6 +3,7 @@ import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, renameSync,
3
3
  import { getConfigDir } from "./config";
4
4
  import { durableBunPath } from "./bun-runtime";
5
5
  import { serviceApiTokenFilePath } from "./service-secrets";
6
+ import { windowsEnvIndirectBatchValue } from "./win-paths";
6
7
 
7
8
  const SHIM_MARKER = "opencodex codex autostart shim";
8
9
  let lastShimDiscoveryError: string | null = null;
@@ -96,8 +97,11 @@ function findWindowsCodexTargets(): ShimFileState[] | null {
96
97
 
97
98
  const cmd = join(dir, "codex.cmd");
98
99
  const ps1 = join(dir, "codex.ps1");
100
+ // npm also installs an extensionless `codex` sh launcher for Git-Bash/MSYS shells;
101
+ // leaving it unshimmed means Git-Bash users silently get no autostart.
102
+ const gitBashLauncher = join(dir, "codex");
99
103
  const targets: ShimFileState[] = [];
100
- for (const path of [cmd, ps1]) {
104
+ for (const path of [cmd, ps1, gitBashLauncher]) {
101
105
  if (!existsSync(path) || isShim(path)) continue;
102
106
  try {
103
107
  if (!lstatSync(path).isDirectory()) {
@@ -119,9 +123,8 @@ function shQuote(value: string): string {
119
123
  return `'${value.replace(/'/g, "'\\''")}'`;
120
124
  }
121
125
 
122
- export function buildUnixCodexShim(realCodexPath: string, bunPath: string, cliPath: string): string {
126
+ export function buildUnixCodexShim(realCodexPath: string, bunPath: string, cliPath: string, tokenFile = serviceApiTokenFilePath()): string {
123
127
  const internalCommands = CODEX_INTERNAL_COMMANDS.join("|");
124
- const tokenFile = serviceApiTokenFilePath();
125
128
  return `#!/usr/bin/env sh
126
129
  # ${SHIM_MARKER}
127
130
  if [ -z "$OPENCODEX_API_AUTH_TOKEN" ] && [ -f ${shQuote(tokenFile)} ]; then
@@ -150,7 +153,12 @@ function windowsBatchValue(value: string): string {
150
153
  }
151
154
 
152
155
  function windowsBatchSet(name: string, value: string): string {
153
- return `set "${name}=${windowsBatchValue(value)}"`;
156
+ // Paths are rewritten to %USERPROFILE%-style env indirection: cmd.exe parses .cmd
157
+ // files in the OEM codepage, so a literal non-ASCII profile prefix (Korean/Chinese
158
+ // usernames) written as UTF-8 turns to mojibake. The env token expands natively in
159
+ // the right codepage at parse time; no `chcp` here — this shim runs in the USER's
160
+ // console and must not leak a codepage change into it.
161
+ return `set "${name}=${windowsEnvIndirectBatchValue(value, windowsBatchValue)}"`;
154
162
  }
155
163
 
156
164
  export function buildWindowsCodexShim(realCodexPath: string, bunPath: string, cliPath: string): string {
@@ -214,13 +222,28 @@ function writeState(state: ShimState): void {
214
222
  writeFileSync(statePath(), JSON.stringify(state, null, 2) + "\n", "utf8");
215
223
  }
216
224
 
225
+ /** Git-Bash accepts `C:/...` but not backslashed paths inside sh scripts. */
226
+ function gitBashPath(path: string): string {
227
+ return path.replace(/\\/g, "/");
228
+ }
229
+
217
230
  function writeShim(wrapperPath: string, realCodexPath: string): void {
218
231
  const { bun, cli } = cliEntry();
219
232
  if (process.platform === "win32") {
220
- if (wrapperPath.toLowerCase().endsWith(".ps1")) {
221
- writeFileSync(wrapperPath, buildWindowsPowerShellCodexShim(realCodexPath, bun, cli), "utf8");
222
- } else {
233
+ const lower = wrapperPath.toLowerCase();
234
+ if (lower.endsWith(".ps1")) {
235
+ // UTF-8 BOM: Windows PowerShell 5.1 decodes BOM-less .ps1 files in the ANSI
236
+ // codepage, which mangles non-ASCII paths embedded in the shim.
237
+ writeFileSync(wrapperPath, `\uFEFF${buildWindowsPowerShellCodexShim(realCodexPath, bun, cli)}`, "utf8");
238
+ } else if (lower.endsWith(".cmd") || lower.endsWith(".bat")) {
223
239
  writeFileSync(wrapperPath, buildWindowsCodexShim(realCodexPath, bun, cli), "utf8");
240
+ } else {
241
+ // Extensionless Git-Bash sh launcher: sh shim with forward-slash paths.
242
+ writeFileSync(
243
+ wrapperPath,
244
+ buildUnixCodexShim(gitBashPath(realCodexPath), gitBashPath(bun), gitBashPath(cli), gitBashPath(serviceApiTokenFilePath())),
245
+ "utf8",
246
+ );
224
247
  }
225
248
  } else {
226
249
  writeFileSync(wrapperPath, buildUnixCodexShim(realCodexPath, bun, cli), "utf8");
@@ -0,0 +1,70 @@
1
+ import { injectCodexConfig } from "./codex-inject";
2
+ import { refreshCodexModelCatalog } from "./codex-refresh";
3
+ import { applyProxyEnv, loadConfig } from "./config";
4
+ import type { OcxConfig } from "./types";
5
+
6
+ export interface CodexSyncResult {
7
+ ok: boolean;
8
+ added: number;
9
+ catalogPath: string | null;
10
+ catalogExists: boolean;
11
+ cacheSynced: boolean;
12
+ message: string;
13
+ warning?: string;
14
+ }
15
+
16
+ interface CodexSyncDeps {
17
+ refreshCodexModelCatalog: typeof refreshCodexModelCatalog;
18
+ injectCodexConfig: typeof injectCodexConfig;
19
+ }
20
+
21
+ const defaultDeps: CodexSyncDeps = {
22
+ refreshCodexModelCatalog,
23
+ injectCodexConfig,
24
+ };
25
+
26
+ export async function syncModelsToCodex(
27
+ port?: number,
28
+ config: OcxConfig = loadConfig(),
29
+ log: Pick<Console, "log" | "error"> | null = console,
30
+ deps: CodexSyncDeps = defaultDeps,
31
+ ): Promise<CodexSyncResult> {
32
+ applyProxyEnv(config); // `ocx ensure`/`ocx sync` fetch provider models outside the server process
33
+ const p = port ?? config.port ?? 10100;
34
+ let added = 0;
35
+ let catalogPath: string | null = null;
36
+ let catalogPathForInjection: string | null | undefined;
37
+ let catalogExists = false;
38
+ let cacheSynced = false;
39
+ let warning: string | undefined;
40
+
41
+ try {
42
+ const cat = await deps.refreshCodexModelCatalog(config);
43
+ added = cat.added;
44
+ catalogExists = cat.catalogExists;
45
+ cacheSynced = cat.cacheSynced;
46
+ catalogPathForInjection = cat.catalogExists ? cat.path : null;
47
+ catalogPath = catalogPathForInjection;
48
+ if (cat.added > 0) {
49
+ log?.log(` + ${cat.added} models appended to Codex catalog (${cat.path})`);
50
+ } else if (!cat.catalogExists) {
51
+ warning = "catalog sync skipped: no Codex catalog source found; keeping Codex's native catalog.";
52
+ log?.error(warning);
53
+ }
54
+ } catch (e) {
55
+ warning = `catalog sync skipped: ${e instanceof Error ? e.message : String(e)}`;
56
+ log?.error(warning);
57
+ }
58
+
59
+ const result = await deps.injectCodexConfig(p, config, { catalogPath: catalogPathForInjection });
60
+ log?.log(result.message);
61
+ return {
62
+ ok: result.success,
63
+ added,
64
+ catalogPath,
65
+ catalogExists,
66
+ cacheSynced,
67
+ message: result.message,
68
+ ...(warning ? { warning } : {}),
69
+ };
70
+ }
package/src/config.ts CHANGED
@@ -16,12 +16,23 @@ export function atomicWriteFile(path: string, content: string): void {
16
16
  renameSync(tmp, path);
17
17
  }
18
18
 
19
+ /**
20
+ * Expand a leading `~` to the home directory in user-supplied paths
21
+ * (OPENCODEX_HOME/CODEX_HOME set from GUIs/service files where no shell expanded it).
22
+ * `~user` and `%VAR%`/`$VAR` forms pass through untouched — those belong to the shell.
23
+ */
24
+ export function expandUserPath(raw: string): string {
25
+ if (raw === "~") return homedir();
26
+ if (raw.startsWith("~/") || raw.startsWith("~\\")) return join(homedir(), raw.slice(2));
27
+ return raw;
28
+ }
29
+
19
30
  let resolvedConfigDirCache: { raw: string | undefined; path: string } | null = null;
20
31
 
21
32
  function resolveConfigDir(): string {
22
33
  const raw = process.env["OPENCODEX_HOME"]?.trim() || undefined;
23
34
  if (resolvedConfigDirCache && resolvedConfigDirCache.raw === raw) return resolvedConfigDirCache.path;
24
- const path = raw ? resolve(raw) : join(homedir(), ".opencodex");
35
+ const path = raw ? resolve(expandUserPath(raw)) : join(homedir(), ".opencodex");
25
36
  resolvedConfigDirCache = { raw, path };
26
37
  return path;
27
38
  }
@@ -308,6 +319,30 @@ export function resolveEnvValue(value: string | undefined): string | undefined {
308
319
  return value;
309
320
  }
310
321
 
322
+ /**
323
+ * Mirror `config.proxy` into HTTP(S)_PROXY env vars so Bun's native fetch routes every outbound
324
+ * provider call through the proxy — no per-callsite changes (verified: Bun honors these plus
325
+ * NO_PROXY). User-set env vars always win; localhost/127.0.0.1 are appended to NO_PROXY so the
326
+ * CLI's own health checks and running-proxy API calls stay direct. Call once per process entry
327
+ * that makes outbound provider requests (server start, catalog sync).
328
+ */
329
+ export function applyProxyEnv(config: OcxConfig): void {
330
+ const proxy = resolveEnvValue(config.proxy);
331
+ if (!proxy) return;
332
+ if (!process.env.HTTP_PROXY?.trim() && !process.env.http_proxy?.trim()) process.env.HTTP_PROXY = proxy;
333
+ if (!process.env.HTTPS_PROXY?.trim() && !process.env.https_proxy?.trim()) process.env.HTTPS_PROXY = proxy;
334
+ const existing = process.env.NO_PROXY ?? process.env.no_proxy ?? "";
335
+ const entries = existing.split(",").map(s => s.trim()).filter(Boolean);
336
+ const seen = new Set(entries.map(e => e.toLowerCase()));
337
+ for (const host of ["localhost", "127.0.0.1", "::1", "[::1]"]) {
338
+ if (!seen.has(host)) {
339
+ entries.push(host);
340
+ seen.add(host);
341
+ }
342
+ }
343
+ process.env.NO_PROXY = entries.join(",");
344
+ }
345
+
311
346
  export function writePid(pid: number): void {
312
347
  const dir = getConfigDir();
313
348
  if (!existsSync(dir)) {
@@ -392,7 +427,7 @@ function warnConfigRepaired(configPath: string, error: z.ZodError): void {
392
427
  console.error(`opencodex config at ${configPath}: repaired missing field(s) [${fields}] with defaults. Your providers and accounts are preserved.`);
393
428
  }
394
429
 
395
- function readPidFileValue(): number | null {
430
+ export function readPidFileValue(): number | null {
396
431
  try {
397
432
  return parsePidFile(readFileSync(getPidPath(), "utf-8"));
398
433
  } catch {
@@ -407,6 +442,27 @@ export function removeRuntimePort(expectedPid?: number): void {
407
442
  } catch { /* ignore */ }
408
443
  }
409
444
 
445
+ /**
446
+ * Snapshot-guarded stale-state purge: remove the pid/runtime files only when their content
447
+ * still matches what the caller saw BEFORE its liveness probe. A concurrent `ocx start` can
448
+ * write fresh records mid-probe; an unconditional purge would erase the new proxy's state.
449
+ */
450
+ export function removePidIfValueIs(snapshot: number | null): void {
451
+ if (!existsSync(getPidPath())) return;
452
+ if (readPidFileValue() !== snapshot) return;
453
+ try {
454
+ unlinkSync(getPidPath());
455
+ } catch { /* ignore */ }
456
+ }
457
+
458
+ export function removeRuntimePortIfPidIs(snapshotPid: number | null): void {
459
+ const current = readRuntimePort();
460
+ if ((current?.pid ?? null) !== snapshotPid) return;
461
+ try {
462
+ unlinkSync(getRuntimePortPath());
463
+ } catch { /* ignore */ }
464
+ }
465
+
410
466
  export function parsePidFile(raw: string): number | null {
411
467
  const trimmed = raw.trim();
412
468
  if (!/^\d+$/.test(trimmed)) return null;
package/src/doctor.ts CHANGED
@@ -10,7 +10,7 @@
10
10
  import { existsSync, readFileSync } from "node:fs";
11
11
  import { homedir } from "node:os";
12
12
  import { join, resolve } from "node:path";
13
- import { getConfigDir, getConfigPath } from "./config";
13
+ import { expandUserPath, getConfigDir, getConfigPath } from "./config";
14
14
  import { readCodexTokens } from "./codex-auth-collision";
15
15
 
16
16
  const WHAM_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
@@ -20,7 +20,9 @@ export type PathRow = { label: string; path: string; exists: boolean };
20
20
 
21
21
  export function resolveCodexHomeDir(): string {
22
22
  const raw = process.env["CODEX_HOME"]?.trim();
23
- return raw ? resolve(raw) : join(homedir(), ".codex");
23
+ // `~` parity with the hardened runtime paths (codex-paths.ts) — a literal "~/..." here
24
+ // would report every Codex file as missing while the runtime happily uses the real dir.
25
+ return raw ? resolve(expandUserPath(raw)) : join(homedir(), ".codex");
24
26
  }
25
27
 
26
28
  export function collectPaths(): PathRow[] {
package/src/index.ts CHANGED
@@ -3,6 +3,7 @@ export { parseRequest } from "./responses/parser";
3
3
  export { bridgeToResponsesSSE, buildResponseJSON, formatErrorResponse } from "./bridge";
4
4
  export { createAnthropicAdapter } from "./adapters/anthropic";
5
5
  export { createAzureAdapter } from "./adapters/azure";
6
+ export { createCursorAdapter } from "./adapters/cursor";
6
7
  export { createGoogleAdapter } from "./adapters/google";
7
8
  export { createOpenAIChatAdapter } from "./adapters/openai-chat";
8
9
  export { createResponsesPassthroughAdapter } from "./adapters/openai-responses";
@@ -19,6 +19,21 @@ interface CacheEntry {
19
19
 
20
20
  const cache = new Map<string, CacheEntry>();
21
21
 
22
+ /** Cooldown after a failed live `/models` fetch, so a dead/unreachable provider doesn't re-pay
23
+ * the full fetch timeout on every catalog poll (issue #54: UI stalls behind corporate proxies). */
24
+ export const MODELS_FETCH_FAILURE_COOLDOWN_MS = 30_000;
25
+
26
+ const failureAt = new Map<string, number>();
27
+
28
+ export function markModelsFetchFailure(provider: string, now = Date.now()): void {
29
+ failureAt.set(provider, now);
30
+ }
31
+
32
+ export function isModelsFetchCoolingDown(provider: string, cooldownMs = MODELS_FETCH_FAILURE_COOLDOWN_MS, now = Date.now()): boolean {
33
+ const at = failureAt.get(provider);
34
+ return at !== undefined && now - at < cooldownMs;
35
+ }
36
+
22
37
  /** Fresh cached models for a provider, or null when absent/stale (caller should re-fetch). */
23
38
  export function getFreshCached(provider: string, ttlMs: number, now = Date.now()): CatalogModel[] | null {
24
39
  const entry = cache.get(provider);
@@ -37,6 +52,11 @@ export function setCached(provider: string, models: CatalogModel[], now = Date.n
37
52
 
38
53
  /** Drop one provider's cache (or all) so the next resolve forces a live re-fetch. */
39
54
  export function clearModelCache(provider?: string): void {
40
- if (provider) cache.delete(provider);
41
- else cache.clear();
55
+ if (provider) {
56
+ cache.delete(provider);
57
+ failureAt.delete(provider);
58
+ } else {
59
+ cache.clear();
60
+ failureAt.clear();
61
+ }
42
62
  }
@@ -8,6 +8,7 @@
8
8
  * Handles: port allocation (preferred → random fallback), callback server, CSRF state,
9
9
  * manual-input race, 300s timeout. Providers implement generateAuthUrl() + exchangeToken().
10
10
  */
11
+ import { isAddrInUse } from "../ports";
11
12
  import type { OAuthController, OAuthCredentials } from "./types";
12
13
 
13
14
  const DEFAULT_TIMEOUT = 300_000;
@@ -36,6 +37,19 @@ function errorHtml(message: string): string {
36
37
 
37
38
  export type CallbackResult = { code: string; state: string };
38
39
 
40
+ /**
41
+ * The redirect URI advertised to providers must stay `localhost` (it is what the OAuth
42
+ * apps have registered), but Windows commonly resolves `localhost` to `::1` first while
43
+ * we historically bound IPv4-only — the browser then hits refusal/timeouts/wrong server.
44
+ * When advertising `localhost` over an IPv4 loopback bind, also bind `::1` best-effort.
45
+ */
46
+ export function loopbackBindHostnames(callbackHostname: string, bindHostname: string): string[] {
47
+ if (callbackHostname.trim().toLowerCase() === "localhost" && bindHostname === "127.0.0.1") {
48
+ return ["127.0.0.1", "::1"];
49
+ }
50
+ return [bindHostname];
51
+ }
52
+
39
53
  export interface OAuthCallbackFlowOptions {
40
54
  preferredPort: number;
41
55
  callbackPath?: string;
@@ -96,7 +110,7 @@ export abstract class OAuthCallbackFlow {
96
110
  /** Execute the OAuth login flow. */
97
111
  async login(): Promise<OAuthCredentials> {
98
112
  const state = this.generateState();
99
- const { server, redirectUri } = await this.#startCallbackServer(state);
113
+ const { servers, redirectUri } = await this.#startCallbackServer(state);
100
114
  try {
101
115
  const { url: authUrl, instructions } = await this.generateAuthUrl(state, redirectUri);
102
116
  this.ctrl.onAuth?.({ url: authUrl, instructions });
@@ -105,39 +119,53 @@ export abstract class OAuthCallbackFlow {
105
119
  this.ctrl.onProgress?.("Exchanging authorization code for tokens...");
106
120
  return await this.exchangeToken(code, state, redirectUri);
107
121
  } finally {
108
- server.stop();
122
+ for (const server of servers) server.stop();
109
123
  }
110
124
  }
111
125
 
112
- async #startCallbackServer(expectedState: string): Promise<{ server: BunServer; redirectUri: string }> {
126
+ async #startCallbackServer(expectedState: string): Promise<{ servers: BunServer[]; redirectUri: string }> {
113
127
  try {
114
- const server = this.#createServer(this.preferredPort, expectedState);
128
+ const servers = this.#createServers(this.preferredPort, expectedState);
115
129
  if (this.redirectUri) {
116
- return { server, redirectUri: this.redirectUri };
130
+ return { servers, redirectUri: this.redirectUri };
117
131
  }
118
132
  const redirectUri = `http://${this.callbackHostname}:${this.preferredPort}${this.callbackPath}`;
119
- return { server, redirectUri };
133
+ return { servers, redirectUri };
120
134
  } catch {
121
135
  if (this.redirectUri) {
122
136
  throw new Error(
123
137
  `OAuth callback port ${this.preferredPort} unavailable; cannot fall back to a random port when redirectUri is set`,
124
138
  );
125
139
  }
126
- const server = this.#createServer(0, expectedState);
127
- const actualPort = server.port;
140
+ const servers = this.#createServers(0, expectedState);
141
+ const actualPort = servers[0].port;
128
142
  const redirectUri = `http://${this.callbackHostname}:${actualPort}${this.callbackPath}`;
129
143
  this.ctrl.onProgress?.(`Preferred port ${this.preferredPort} unavailable, using port ${actualPort}`);
130
- return { server, redirectUri };
144
+ return { servers, redirectUri };
131
145
  }
132
146
  }
133
147
 
134
- #createServer(port: number, expectedState: string): BunServer {
135
- return Bun.serve({
136
- hostname: this.callbackBindHostname,
137
- port,
138
- reusePort: false,
139
- fetch: (req: Request) => this.#handleCallback(req, expectedState),
140
- });
148
+ #createServers(port: number, expectedState: string): BunServer[] {
149
+ const fetch = (req: Request) => this.#handleCallback(req, expectedState);
150
+ const [primaryHost, ...extraHosts] = loopbackBindHostnames(this.callbackHostname, this.callbackBindHostname);
151
+ const primary = Bun.serve({ hostname: primaryHost, port, reusePort: false, fetch });
152
+ const servers = [primary];
153
+ for (const host of extraHosts) {
154
+ try {
155
+ servers.push(Bun.serve({ hostname: host, port: primary.port, reusePort: false, fetch }));
156
+ } catch (err) {
157
+ // extraHosts is only non-empty when we advertise ambiguous `localhost`. A foreign
158
+ // process HOLDING the IPv6 loopback port could then receive the browser's OAuth
159
+ // callback (localhost may resolve to ::1 first) — treat the whole port as unusable
160
+ // so the caller falls back to a fresh one. IPv6 merely unsupported/unavailable
161
+ // (EAFNOSUPPORT etc.) keeps the IPv4-only degradation.
162
+ if (isAddrInUse(err)) {
163
+ for (const server of servers) server.stop(true);
164
+ throw err;
165
+ }
166
+ }
167
+ }
168
+ return servers;
141
169
  }
142
170
 
143
171
  #handleCallback(req: Request, expectedState: string): Response {
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Cursor OAuth — PKCE poll flow. Standalone: talks directly to cursor.com / api2.cursor.sh,
3
+ * with no dependency on a local Cursor IDE/CLI install or on jawcode. Ported from jawcode
4
+ * packages/ai/src/utils/oauth/cursor.ts and adapted to opencodex's OAuthController (see kimi.ts).
5
+ *
6
+ * Security: the login URL carries only the PKCE challenge (SHA-256 of the verifier); the verifier
7
+ * is sent only to /auth/poll. Tokens and the verifier are never logged — thrown errors and progress
8
+ * messages are status/string only.
9
+ */
10
+ import { generatePKCE } from "./pkce";
11
+ import type { OAuthController, OAuthCredentials } from "./types";
12
+
13
+ const CURSOR_LOGIN_URL = "https://cursor.com/loginDeepControl";
14
+ const CURSOR_POLL_URL = "https://api2.cursor.sh/auth/poll";
15
+ const CURSOR_REFRESH_URL = "https://api2.cursor.sh/auth/exchange_user_api_key";
16
+
17
+ const POLL_MAX_ATTEMPTS = 150;
18
+ const POLL_BASE_DELAY_MS = 1000;
19
+ const POLL_MAX_DELAY_MS = 10_000;
20
+ const POLL_BACKOFF = 1.2;
21
+ const EXPIRY_SKEW_MS = 5 * 60 * 1000;
22
+ const FALLBACK_TTL_MS = 60 * 60 * 1000;
23
+
24
+ const REFRESH_TIMEOUT_MS = 15_000;
25
+ const REFRESH_ATTEMPTS = 3;
26
+ const REFRESH_RETRY_BASE_MS = 300;
27
+
28
+ export interface CursorAuthParams {
29
+ verifier: string;
30
+ challenge: string;
31
+ uuid: string;
32
+ loginUrl: string;
33
+ }
34
+
35
+ /** Generate PKCE params + the cursor.com deep-link login URL (challenge only — never the verifier). */
36
+ export async function generateCursorAuthParams(): Promise<CursorAuthParams> {
37
+ const { verifier, challenge } = await generatePKCE();
38
+ const uuid = crypto.randomUUID();
39
+ const params = new URLSearchParams({ challenge, uuid, mode: "login", redirectTarget: "cli" });
40
+ return { verifier, challenge, uuid, loginUrl: `${CURSOR_LOGIN_URL}?${params.toString()}` };
41
+ }
42
+
43
+ /** Abort-aware delay (mirrors kimi.ts) — rejects if the controller signal aborts. */
44
+ function sleep(ms: number, signal?: AbortSignal): Promise<void> {
45
+ return new Promise((resolve, reject) => {
46
+ if (signal?.aborted) return reject(new Error("Cursor login cancelled"));
47
+ const timer = setTimeout(resolve, ms);
48
+ signal?.addEventListener(
49
+ "abort",
50
+ () => {
51
+ clearTimeout(timer);
52
+ reject(new Error("Cursor login cancelled"));
53
+ },
54
+ { once: true },
55
+ );
56
+ });
57
+ }
58
+
59
+ /**
60
+ * Poll cursor.com for login completion. 404 = still pending (back off), 200 = tokens.
61
+ * `baseDelayMs` is injectable so tests can avoid the real 1s cadence; production uses the default.
62
+ */
63
+ export async function pollCursorAuth(
64
+ uuid: string,
65
+ verifier: string,
66
+ signal?: AbortSignal,
67
+ baseDelayMs: number = POLL_BASE_DELAY_MS,
68
+ ): Promise<{ accessToken: string; refreshToken: string }> {
69
+ let delay = baseDelayMs;
70
+ let consecutiveErrors = 0;
71
+
72
+ for (let attempt = 0; attempt < POLL_MAX_ATTEMPTS; attempt++) {
73
+ await sleep(delay, signal);
74
+
75
+ try {
76
+ const url = `${CURSOR_POLL_URL}?uuid=${encodeURIComponent(uuid)}&verifier=${encodeURIComponent(verifier)}`;
77
+ const response = await fetch(url, { signal });
78
+
79
+ if (response.status === 404) {
80
+ consecutiveErrors = 0;
81
+ delay = Math.min(delay * POLL_BACKOFF, POLL_MAX_DELAY_MS);
82
+ continue;
83
+ }
84
+
85
+ if (response.ok) {
86
+ const data = (await response.json()) as { accessToken?: string; refreshToken?: string };
87
+ if (!data.accessToken || !data.refreshToken) {
88
+ throw new Error("Cursor auth response missing tokens");
89
+ }
90
+ return { accessToken: data.accessToken, refreshToken: data.refreshToken };
91
+ }
92
+
93
+ throw new Error(`Cursor auth poll failed: ${response.status}`);
94
+ } catch (err) {
95
+ if (signal?.aborted) throw err instanceof Error ? err : new Error("Cursor login cancelled");
96
+ consecutiveErrors++;
97
+ if (consecutiveErrors >= 3) {
98
+ throw new Error("Too many consecutive errors during Cursor auth polling");
99
+ }
100
+ delay = Math.min(delay * POLL_BACKOFF, POLL_MAX_DELAY_MS);
101
+ }
102
+ }
103
+
104
+ throw new Error("Cursor authentication polling timeout");
105
+ }
106
+
107
+ /** Run the standalone Cursor login: surface the URL via `onAuth`, then poll until approved. */
108
+ export async function loginCursor(
109
+ ctrl: OAuthController,
110
+ pollBaseDelayMs: number = POLL_BASE_DELAY_MS,
111
+ ): Promise<OAuthCredentials> {
112
+ const { verifier, uuid, loginUrl } = await generateCursorAuthParams();
113
+ ctrl.onAuth?.({ url: loginUrl, instructions: "Approve the Cursor login in your browser, then return here." });
114
+ ctrl.onProgress?.("Waiting for Cursor login approval…");
115
+ const { accessToken, refreshToken } = await pollCursorAuth(uuid, verifier, ctrl.signal, pollBaseDelayMs);
116
+ return { access: accessToken, refresh: refreshToken, expires: getTokenExpiry(accessToken) };
117
+ }
118
+
119
+ function isRetryableRefreshStatus(status: number): boolean {
120
+ return status === 429 || status === 500 || status === 502 || status === 503 || status === 504;
121
+ }
122
+
123
+ function refreshRetryDelayMs(attempt: number): number {
124
+ const exp = REFRESH_RETRY_BASE_MS * 2 ** attempt;
125
+ return Math.floor(exp * (0.8 + Math.random() * 0.4));
126
+ }
127
+
128
+ function refreshTimeoutSignal(parent: AbortSignal | undefined): AbortSignal {
129
+ const timeout = AbortSignal.timeout(REFRESH_TIMEOUT_MS);
130
+ return parent ? AbortSignal.any([parent, timeout]) : timeout;
131
+ }
132
+
133
+ /**
134
+ * Exchange a refresh token for fresh credentials. Keeps the old refresh if the server omits one.
135
+ *
136
+ * Hardened with a per-attempt timeout and bounded retry on transient failures (network errors and
137
+ * 429/5xx). Non-retryable statuses (e.g. 401/403 from an expired refresh token) fail fast so the
138
+ * caller can surface a re-auth prompt. Errors never include the token value.
139
+ */
140
+ export async function refreshCursorToken(refresh: string, signal?: AbortSignal): Promise<OAuthCredentials> {
141
+ let lastError: unknown;
142
+ for (let attempt = 0; attempt < REFRESH_ATTEMPTS; attempt++) {
143
+ if (signal?.aborted) throw signal.reason ?? new Error("Cursor token refresh aborted");
144
+ let response: Response;
145
+ try {
146
+ response = await fetch(CURSOR_REFRESH_URL, {
147
+ method: "POST",
148
+ headers: { Authorization: `Bearer ${refresh}`, "Content-Type": "application/json" },
149
+ body: "{}",
150
+ signal: refreshTimeoutSignal(signal),
151
+ });
152
+ } catch (err) {
153
+ // Network/timeout error: retry unless the caller aborted or we are out of attempts.
154
+ if (signal?.aborted) throw err;
155
+ lastError = err;
156
+ if (attempt === REFRESH_ATTEMPTS - 1) break;
157
+ await new Promise(resolve => setTimeout(resolve, refreshRetryDelayMs(attempt)));
158
+ continue;
159
+ }
160
+ if (response.ok) {
161
+ const data = (await response.json()) as { accessToken?: string; refreshToken?: string };
162
+ if (!data.accessToken) throw new Error("Cursor refresh response missing access token");
163
+ return { access: data.accessToken, refresh: data.refreshToken || refresh, expires: getTokenExpiry(data.accessToken) };
164
+ }
165
+ if (!isRetryableRefreshStatus(response.status) || attempt === REFRESH_ATTEMPTS - 1) {
166
+ throw new Error(`Cursor token refresh failed: ${response.status}`);
167
+ }
168
+ lastError = new Error(`Cursor token refresh failed: ${response.status}`);
169
+ await response.body?.cancel().catch(() => {});
170
+ await new Promise(resolve => setTimeout(resolve, refreshRetryDelayMs(attempt)));
171
+ }
172
+ throw lastError instanceof Error ? lastError : new Error("Cursor token refresh failed");
173
+ }
174
+
175
+ /** Resolve a token's expiry (epoch ms) from its JWT `exp`, minus a 5-minute skew; ~1h fallback. */
176
+ export function getTokenExpiry(token: string): number {
177
+ try {
178
+ const parts = token.split(".");
179
+ const payload = parts.length === 3 ? parts[1] : undefined;
180
+ if (payload) {
181
+ const decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf-8")) as { exp?: number };
182
+ if (typeof decoded.exp === "number") return decoded.exp * 1000 - EXPIRY_SKEW_MS;
183
+ }
184
+ } catch {
185
+ // fall through to the fixed fallback below
186
+ }
187
+ return Date.now() + FALLBACK_TTL_MS;
188
+ }
@@ -9,7 +9,9 @@ import { loginKimi, refreshKimiToken } from "./kimi";
9
9
  import { loginKiro, readKiroCliSqlite, refreshKiroToken } from "./kiro";
10
10
  import { loginChatGPT, refreshChatGPTToken } from "./chatgpt";
11
11
  import { loginAntigravity, refreshAntigravityToken } from "./google-antigravity";
12
+ import { loginCursor, refreshCursorToken } from "./cursor";
12
13
  import { deriveOAuthDefaultModel, deriveOAuthProviderConfig } from "../providers/derive";
14
+ import { effectiveGoogleMode } from "../providers/registry";
13
15
 
14
16
  const REFRESH_SKEW_MS = 60_000;
15
17
  const tokenRefreshes = new Map<string, Promise<string>>();
@@ -67,6 +69,12 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderDef> = {
67
69
  providerConfig: oauthConfig("google-antigravity"),
68
70
  defaultModel: oauthDefaultModel("google-antigravity"),
69
71
  },
72
+ cursor: {
73
+ login: (ctrl) => loginCursor(ctrl),
74
+ refresh: refreshCursorToken,
75
+ providerConfig: oauthConfig("cursor"),
76
+ defaultModel: oauthDefaultModel("cursor"),
77
+ },
70
78
  chatgpt: {
71
79
  login: loginChatGPT,
72
80
  refresh: (rt) => refreshChatGPTToken(rt),
@@ -180,11 +188,21 @@ export async function resolveModelsAuthToken(name: string, prov: OcxProviderConf
180
188
  * Provider-correct `GET /models` request (URL + headers), so both model-listing paths fetch the
181
189
  * LIVE catalog correctly per adapter. Anthropic is the special case: its endpoint is `/v1/models`
182
190
  * (not `/models`), it needs `anthropic-version`, and it authenticates with `x-api-key` (key) or
183
- * `Authorization: Bearer` + the OAuth beta (oauth) — not a bare Bearer. Everyone else uses the
184
- * OpenAI-style `/models` + Bearer. Response shape is `{ data: [{ id, owned_by? }] }` for both.
191
+ * `Authorization: Bearer` + the OAuth beta (oauth) — not a bare Bearer. Google (ai-studio mode)
192
+ * is the other special case: `x-goog-api-key` + `/v1beta/models`, returning `{ models: [...] }`
193
+ * (parsed by the caller). Everyone else uses the OpenAI-style `/models` + Bearer with a
194
+ * `{ data: [{ id, owned_by? }] }` response.
185
195
  */
186
- export function buildModelsRequest(prov: OcxProviderConfig, apiKey: string | undefined): { url: string; headers: Record<string, string> } {
196
+ export function buildModelsRequest(prov: OcxProviderConfig, apiKey: string | undefined, providerName = ""): { url: string; headers: Record<string, string> } {
187
197
  const headers: Record<string, string> = { ...(prov.headers ?? {}) };
198
+ if (effectiveGoogleMode(providerName, prov) === "ai-studio") {
199
+ // Generative Language API: API key goes in x-goog-api-key (never Authorization: Bearer),
200
+ // models live under /v1beta (v1 misses preview models), and pageSize maxes at 1000 —
201
+ // enough to list everything without a pageToken loop. Vertex/antigravity keep the
202
+ // generic branch (they fall back to their static model lists).
203
+ if (apiKey) headers["x-goog-api-key"] = apiKey;
204
+ return { url: `${prov.baseUrl}/v1beta/models?pageSize=1000`, headers };
205
+ }
188
206
  if (prov.adapter === "anthropic") {
189
207
  headers["anthropic-version"] = "2023-06-01";
190
208
  if (prov.authMode === "oauth") {
@@ -292,6 +310,14 @@ export function getLoginStatus(provider: string): { loggedIn: boolean; email?: s
292
310
  return { loggedIn: !!cred, email: maskEmail(cred?.email) ?? undefined, source: cred?.source, error: st?.error, done: st?.done ?? false };
293
311
  }
294
312
 
313
+ /** Token-safe per-provider login state for the CLI `ocx status` logins section (no tokens, masked email). */
314
+ export function oauthLoginSummary(): Array<{ provider: string; loggedIn: boolean; email?: string }> {
315
+ return listOAuthProviders().map(provider => {
316
+ const status = getLoginStatus(provider);
317
+ return { provider, loggedIn: status.loggedIn, ...(status.email ? { email: status.email } : {}) };
318
+ });
319
+ }
320
+
295
321
  export function clearLoginState(provider: string): void {
296
322
  loginAbort.get(provider)?.abort("cleared");
297
323
  loginAbort.delete(provider);