@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.
- package/README.md +9 -0
- package/bin/ocx.mjs +70 -5
- package/gui/dist/assets/index-DDcEW0Cm.css +1 -0
- package/gui/dist/assets/index-DbTEyo46.js +9 -0
- package/gui/dist/index.html +2 -2
- package/package.json +3 -1
- package/src/adapters/anthropic.ts +9 -2
- package/src/adapters/base.ts +6 -0
- package/src/adapters/cursor/arg-codec.ts +38 -0
- package/src/adapters/cursor/arg-normalize.ts +88 -0
- package/src/adapters/cursor/cursor-errors.ts +85 -0
- package/src/adapters/cursor/discovery.ts +144 -0
- package/src/adapters/cursor/effort-map.ts +74 -0
- package/src/adapters/cursor/exec-policy.ts +44 -0
- package/src/adapters/cursor/framing.ts +136 -0
- package/src/adapters/cursor/gen/agent_pb.ts +15274 -0
- package/src/adapters/cursor/kv-store.ts +25 -0
- package/src/adapters/cursor/live-models.ts +93 -0
- package/src/adapters/cursor/live-smoke-gate.ts +41 -0
- package/src/adapters/cursor/live-transport.ts +758 -0
- package/src/adapters/cursor/mcp-config.ts +42 -0
- package/src/adapters/cursor/mcp-manager.ts +236 -0
- package/src/adapters/cursor/message-mapper.ts +46 -0
- package/src/adapters/cursor/native-exec-common.ts +55 -0
- package/src/adapters/cursor/native-exec-desktop.ts +177 -0
- package/src/adapters/cursor/native-exec-fs.ts +284 -0
- package/src/adapters/cursor/native-exec-mcp.ts +151 -0
- package/src/adapters/cursor/native-exec-network.ts +32 -0
- package/src/adapters/cursor/native-exec-shell.ts +191 -0
- package/src/adapters/cursor/native-exec-tools.ts +118 -0
- package/src/adapters/cursor/native-exec.ts +177 -0
- package/src/adapters/cursor/protobuf-events.ts +309 -0
- package/src/adapters/cursor/protobuf-request.ts +347 -0
- package/src/adapters/cursor/request-builder.ts +98 -0
- package/src/adapters/cursor/tool-definitions.ts +301 -0
- package/src/adapters/cursor/transport-retry.ts +116 -0
- package/src/adapters/cursor/transport.ts +47 -0
- package/src/adapters/cursor/types.ts +36 -0
- package/src/adapters/cursor.ts +99 -0
- package/src/adapters/google.ts +7 -1
- package/src/adapters/kiro.ts +15 -0
- package/src/adapters/openai-chat.ts +7 -2
- package/src/adapters/run-turn-queue.ts +58 -0
- package/src/adapters/tool-catalog-nudge.ts +71 -0
- package/src/bridge.ts +7 -1
- package/src/cli-help.ts +9 -2
- package/src/cli-status.ts +7 -5
- package/src/cli.ts +122 -79
- package/src/codex-catalog.ts +213 -71
- package/src/codex-history-provider.ts +31 -14
- package/src/codex-inject.ts +17 -9
- package/src/codex-paths.ts +2 -1
- package/src/codex-shim.ts +30 -7
- package/src/codex-sync.ts +70 -0
- package/src/config.ts +58 -2
- package/src/doctor.ts +4 -2
- package/src/index.ts +1 -0
- package/src/model-cache.ts +22 -2
- package/src/oauth/callback-server.ts +44 -16
- package/src/oauth/cursor.ts +188 -0
- package/src/oauth/index.ts +29 -3
- package/src/oauth/key-providers.ts +20 -33
- package/src/oauth/login-cli.ts +7 -4
- package/src/open-url.ts +5 -1
- package/src/ports.ts +13 -0
- package/src/process-control.ts +76 -0
- package/src/provider-label.ts +10 -5
- package/src/providers/derive.ts +30 -3
- package/src/providers/registry.ts +39 -1
- package/src/proxy-liveness.ts +122 -0
- package/src/responses/parser.ts +1 -0
- package/src/responses/state.ts +83 -0
- package/src/router.ts +38 -23
- package/src/server/adapter-resolve.ts +3 -0
- package/src/server.ts +130 -18
- package/src/service.ts +94 -32
- package/src/types.ts +24 -1
- package/src/update-job.ts +360 -0
- package/src/update.ts +73 -11
- package/src/usage-log.ts +3 -3
- package/src/usage-summary.ts +3 -2
- package/src/win-paths.ts +68 -0
- package/gui/dist/assets/index-DIBiVVC0.css +0 -1
- 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
|
-
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
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";
|
package/src/model-cache.ts
CHANGED
|
@@ -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)
|
|
41
|
-
|
|
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 {
|
|
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<{
|
|
126
|
+
async #startCallbackServer(expectedState: string): Promise<{ servers: BunServer[]; redirectUri: string }> {
|
|
113
127
|
try {
|
|
114
|
-
const
|
|
128
|
+
const servers = this.#createServers(this.preferredPort, expectedState);
|
|
115
129
|
if (this.redirectUri) {
|
|
116
|
-
return {
|
|
130
|
+
return { servers, redirectUri: this.redirectUri };
|
|
117
131
|
}
|
|
118
132
|
const redirectUri = `http://${this.callbackHostname}:${this.preferredPort}${this.callbackPath}`;
|
|
119
|
-
return {
|
|
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
|
|
127
|
-
const actualPort =
|
|
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 {
|
|
144
|
+
return { servers, redirectUri };
|
|
131
145
|
}
|
|
132
146
|
}
|
|
133
147
|
|
|
134
|
-
#
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
+
}
|
package/src/oauth/index.ts
CHANGED
|
@@ -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.
|
|
184
|
-
*
|
|
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);
|