@yansigit/opencodex 2.36.1 → 2.36.2-dev.20260829.52
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 -4
- package/gui/dist/assets/{ApiKeys-CsmNyf4I.js → ApiKeys-GpvAosG-.js} +1 -1
- package/gui/dist/assets/{Claude-B4sHsGTD.js → Claude-Biju6rfh.js} +1 -1
- package/gui/dist/assets/{CodexSet-BuAQ4YQn.js → CodexSet-_EkE9hNC.js} +1 -1
- package/gui/dist/assets/{FileIntegrationPage-BC65sEaS.js → FileIntegrationPage-BL55KJCW.js} +1 -1
- package/gui/dist/assets/{Grok-DNJWlISf.js → Grok-Dp3sOS5B.js} +1 -1
- package/gui/dist/assets/{Integrations-vGJshFZK.js → Integrations-Bx0V4Dx1.js} +2 -2
- package/gui/dist/assets/{IntegrationsOverview-DYMkDFHE.js → IntegrationsOverview-CT41lCh4.js} +1 -1
- package/gui/dist/assets/{Logs-Coc7_RPP.js → Logs-BxS_u7Hr.js} +1 -1
- package/gui/dist/assets/{Models-CKVT8Meb.js → Models-DvaBTkUM.js} +1 -1
- package/gui/dist/assets/{NumberStepper-Dug4wR8n.js → NumberStepper-97eq4OKR.js} +1 -1
- package/gui/dist/assets/Providers-CB9I0Vv1.js +1 -0
- package/gui/dist/assets/{RestoreDialog-BpuvVU2w.js → RestoreDialog-C9sltYZD.js} +1 -1
- package/gui/dist/assets/{Startup-DAUQqNEN.js → Startup-BvFv8yC2.js} +1 -1
- package/gui/dist/assets/{Storage-DfP95SlJ.js → Storage-CKa2mqSI.js} +1 -1
- package/gui/dist/assets/{Subagents-CMNsZLfk.js → Subagents-B4oMNOVh.js} +1 -1
- package/gui/dist/assets/{Usage-ESFRfBMO.js → Usage-gaSKtbMw.js} +1 -1
- package/gui/dist/assets/{codex-stale-banner-W4FhikN7.js → codex-stale-banner-B5VQPO3t.js} +1 -1
- package/gui/dist/assets/{data-surface-B8PX8rjm.js → data-surface-B0APtUxm.js} +1 -1
- package/gui/dist/assets/{data-surface-CETFXqiA.js → data-surface-CH5yKPY1.js} +1 -1
- package/gui/dist/assets/index-C8flH881.css +1 -0
- package/gui/dist/assets/{index-DHQHBRhb.js → index-DWxg-UlU.js} +3 -3
- package/gui/dist/assets/{model-display-OBTJhQwT.js → model-display-vDl3mo07.js} +1 -1
- package/gui/dist/assets/{provider-payload-CJRJUn7w.js → provider-payload-C3Xp5N0y.js} +1 -1
- package/gui/dist/assets/{section-tabs-B8sLMiz6.js → section-tabs-Bpm5lFcZ.js} +1 -1
- package/gui/dist/assets/shared-BHpPx-nZ.js +69 -0
- package/gui/dist/index.html +3 -3
- package/package.json +1 -1
- package/src/adapters/base.ts +2 -0
- package/src/adapters/command-code.ts +2 -1
- package/src/adapters/google-http.ts +34 -14
- package/src/adapters/google.ts +4 -1
- package/src/cli/index.ts +9 -1
- package/src/codex/inject.ts +14 -9
- package/src/config/process-state.ts +15 -0
- package/src/config.ts +17 -0
- package/src/generated/compatibility-version.json +61 -41
- package/src/lib/bun-stream-caps.ts +2 -1
- package/src/lib/pinned-http.ts +7 -1
- package/src/lib/process-control.ts +3 -2
- package/src/lib/provider-outbound.ts +77 -27
- package/src/lib/provider-runtime-fetch.ts +23 -0
- package/src/lib/provider-tls-profile.ts +97 -11
- package/src/lib/server-tls.ts +47 -0
- package/src/lib/test-provider-fetch.ts +6 -0
- package/src/lib/test-server-start.ts +5 -0
- package/src/lib/upstream-retry.ts +39 -25
- package/src/oauth/anthropic.ts +4 -3
- package/src/oauth/chatgpt.ts +7 -3
- package/src/oauth/command-code.ts +3 -2
- package/src/oauth/cursor.ts +3 -2
- package/src/oauth/github-copilot.ts +7 -5
- package/src/oauth/google-antigravity.ts +5 -4
- package/src/oauth/key-providers.ts +11 -13
- package/src/oauth/kimi.ts +7 -6
- package/src/oauth/kiro.ts +3 -2
- package/src/oauth/nous.ts +5 -5
- package/src/oauth/transport.ts +77 -0
- package/src/oauth/xai.ts +5 -4
- package/src/providers/replit/probe.ts +8 -3
- package/src/providers/xai-transport.ts +16 -2
- package/src/server/auth-cors.ts +15 -1
- package/src/server/chat-native.ts +9 -3
- package/src/server/index.ts +63 -23
- package/src/server/management/config-routes.ts +117 -15
- package/src/server/management/context.ts +4 -0
- package/src/server/management/provider-routes.ts +1 -0
- package/src/server/responses/compact.ts +5 -2
- package/src/server/responses/core.ts +16 -19
- package/src/server/responses/fetch-helpers.ts +15 -7
- package/src/server/ws-bridge.ts +33 -4
- package/src/types/config.ts +9 -0
- package/src/types/provider.ts +2 -0
- package/src/types.ts +1 -0
- package/gui/dist/assets/Providers-DyaCHRWL.js +0 -1
- package/gui/dist/assets/index-pLho_LBF.css +0 -1
- package/gui/dist/assets/shared-DI18-uF7.js +0 -69
|
@@ -21,6 +21,7 @@ interface WreqModule {
|
|
|
21
21
|
browser: "chrome_142";
|
|
22
22
|
os: "windows" | "macos" | "linux";
|
|
23
23
|
proxy?: string;
|
|
24
|
+
resolve?: Record<string, string[]>;
|
|
24
25
|
}): Promise<WreqTransport>;
|
|
25
26
|
fetch(input: string | URL | Request, init?: Record<string, unknown>): Promise<unknown>;
|
|
26
27
|
}
|
|
@@ -46,6 +47,8 @@ const defaultRuntime: ProviderTlsRuntimeForTest = {
|
|
|
46
47
|
let runtime = defaultRuntime;
|
|
47
48
|
const statusByProvider = new Map<string, { status: ProviderTlsProfileStatus; fingerprint: string }>();
|
|
48
49
|
const transports = new Map<string, Promise<InitializedTransport | undefined>>();
|
|
50
|
+
const requestTransports = new Set<WreqTransport>();
|
|
51
|
+
let modulePromise: Promise<WreqModule | undefined> | undefined;
|
|
49
52
|
let shutdownDetach: (() => void) | undefined;
|
|
50
53
|
let fallbackWarned = false;
|
|
51
54
|
|
|
@@ -92,7 +95,7 @@ function safeProviderTlsError(error: unknown): Error {
|
|
|
92
95
|
function warnFallbackOnce(): void {
|
|
93
96
|
if (fallbackWarned) return;
|
|
94
97
|
fallbackWarned = true;
|
|
95
|
-
console.warn("[opencodex] Antigravity TLS profile
|
|
98
|
+
console.warn("[opencodex] Antigravity TLS profile request blocked: native transport initialization failed");
|
|
96
99
|
}
|
|
97
100
|
|
|
98
101
|
function emulationOs(): "windows" | "macos" | "linux" {
|
|
@@ -184,10 +187,13 @@ function registerShutdownHook(): void {
|
|
|
184
187
|
shutdownDetach = registerOptionalShutdownHook("provider-tls-profile", () => {
|
|
185
188
|
const pending = [...transports.values()];
|
|
186
189
|
transports.clear();
|
|
190
|
+
const scoped = [...requestTransports];
|
|
191
|
+
requestTransports.clear();
|
|
187
192
|
shutdownDetach = undefined;
|
|
188
193
|
for (const transport of pending) {
|
|
189
194
|
void transport.then(value => value?.transport.close()).catch(() => undefined);
|
|
190
195
|
}
|
|
196
|
+
for (const transport of scoped) void transport.close().catch(() => undefined);
|
|
191
197
|
});
|
|
192
198
|
}
|
|
193
199
|
|
|
@@ -202,7 +208,8 @@ async function getTransport(proxy: string | undefined): Promise<InitializedTrans
|
|
|
202
208
|
const pending = (async () => {
|
|
203
209
|
let transport: WreqTransport | undefined;
|
|
204
210
|
try {
|
|
205
|
-
const module = await
|
|
211
|
+
const module = await getWreqModule();
|
|
212
|
+
if (!module) return undefined;
|
|
206
213
|
transport = await module.createTransport({
|
|
207
214
|
browser: "chrome_142",
|
|
208
215
|
os: emulationOs(),
|
|
@@ -219,6 +226,69 @@ async function getTransport(proxy: string | undefined): Promise<InitializedTrans
|
|
|
219
226
|
return pending;
|
|
220
227
|
}
|
|
221
228
|
|
|
229
|
+
function getWreqModule(): Promise<WreqModule | undefined> {
|
|
230
|
+
modulePromise ??= runtime.importWreq().catch(() => undefined);
|
|
231
|
+
return modulePromise;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function getPinnedRequestTransport(
|
|
235
|
+
hostname: string,
|
|
236
|
+
addresses: string[],
|
|
237
|
+
): Promise<InitializedTransport | undefined> {
|
|
238
|
+
let transport: WreqTransport | undefined;
|
|
239
|
+
try {
|
|
240
|
+
const module = await getWreqModule();
|
|
241
|
+
if (!module) return undefined;
|
|
242
|
+
transport = await module.createTransport({
|
|
243
|
+
browser: "chrome_142",
|
|
244
|
+
os: emulationOs(),
|
|
245
|
+
resolve: { [hostname]: addresses },
|
|
246
|
+
});
|
|
247
|
+
requestTransports.add(transport);
|
|
248
|
+
registerShutdownHook();
|
|
249
|
+
return { module, transport };
|
|
250
|
+
} catch {
|
|
251
|
+
if (transport) await transport.close().catch(() => undefined);
|
|
252
|
+
return undefined;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function closeTransportWithResponse(response: Response, transport: WreqTransport): Response {
|
|
257
|
+
const close = () => {
|
|
258
|
+
if (!requestTransports.delete(transport)) return;
|
|
259
|
+
void transport.close().catch(() => undefined);
|
|
260
|
+
};
|
|
261
|
+
if (!response.body) {
|
|
262
|
+
close();
|
|
263
|
+
return response;
|
|
264
|
+
}
|
|
265
|
+
const reader = response.body.getReader();
|
|
266
|
+
const body = new ReadableStream<Uint8Array>({
|
|
267
|
+
async pull(controller) {
|
|
268
|
+
try {
|
|
269
|
+
const chunk = await reader.read();
|
|
270
|
+
if (chunk.done) {
|
|
271
|
+
close();
|
|
272
|
+
controller.close();
|
|
273
|
+
} else {
|
|
274
|
+
controller.enqueue(chunk.value);
|
|
275
|
+
}
|
|
276
|
+
} catch (error) {
|
|
277
|
+
close();
|
|
278
|
+
controller.error(error);
|
|
279
|
+
}
|
|
280
|
+
},
|
|
281
|
+
async cancel(reason) {
|
|
282
|
+
try { await reader.cancel(reason); } finally { close(); }
|
|
283
|
+
},
|
|
284
|
+
});
|
|
285
|
+
return new Response(body, {
|
|
286
|
+
status: response.status,
|
|
287
|
+
statusText: response.statusText,
|
|
288
|
+
headers: response.headers,
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
222
292
|
export function providerTlsFetch(
|
|
223
293
|
providerName: string,
|
|
224
294
|
provider: Pick<OcxProviderConfig, "adapter" | "authMode" | "googleMode" | "baseUrl" | "tlsProfile">,
|
|
@@ -237,27 +307,34 @@ export function providerTlsFetch(
|
|
|
237
307
|
if (!profileIsSupported(providerName, provider)) {
|
|
238
308
|
setStatus(providerName, provider, "fallback");
|
|
239
309
|
warnFallbackOnce();
|
|
240
|
-
return
|
|
310
|
+
return (async () => {
|
|
311
|
+
throw safeProviderTlsError(new Error("configured native transport profile is not valid for this provider"));
|
|
312
|
+
}) as unknown as typeof globalThis.fetch;
|
|
241
313
|
}
|
|
242
314
|
return (async (input: string | URL | Request, init?: RequestInit): Promise<Response> => {
|
|
243
315
|
const url = typeof input === "string" || input instanceof URL ? input : input.url;
|
|
244
|
-
if (!isCanonicalAntigravityUrl(url))
|
|
316
|
+
if (!isCanonicalAntigravityUrl(url)) {
|
|
317
|
+
throw safeProviderTlsError(new Error("configured native transport profile refused a noncanonical destination"));
|
|
318
|
+
}
|
|
245
319
|
const proxy = proxyForUrl(url);
|
|
246
320
|
// A direct profiled request must pass the same public-destination gate as the
|
|
247
321
|
// ordinary provider executor before wreq can send its bearer-bearing request.
|
|
248
322
|
// Proxied requests deliberately leave peer selection to the configured proxy,
|
|
249
323
|
// matching providerOutbound's proxy boundary (and its inability to pin a peer).
|
|
250
|
-
|
|
251
|
-
|
|
324
|
+
const parsed = new URL(String(url));
|
|
325
|
+
const resolved = !proxy
|
|
326
|
+
? await (runtime.resolveDestination ?? resolvePublicAddresses)(String(url), {
|
|
252
327
|
context: "Antigravity profile URL",
|
|
253
328
|
allowPrivateNetwork: false,
|
|
254
|
-
})
|
|
255
|
-
|
|
256
|
-
const initialized =
|
|
329
|
+
})
|
|
330
|
+
: undefined;
|
|
331
|
+
const initialized = resolved
|
|
332
|
+
? await getPinnedRequestTransport(parsed.hostname, resolved.addresses.map(address => address.address))
|
|
333
|
+
: await getTransport(proxy);
|
|
257
334
|
if (!initialized) {
|
|
258
335
|
setStatus(providerName, provider, "fallback");
|
|
259
336
|
warnFallbackOnce();
|
|
260
|
-
|
|
337
|
+
throw safeProviderTlsError(new Error("native transport initialization failed"));
|
|
261
338
|
}
|
|
262
339
|
setStatus(providerName, provider, "active");
|
|
263
340
|
const wreqInit = {
|
|
@@ -268,8 +345,12 @@ export function providerTlsFetch(
|
|
|
268
345
|
redirect: "manual" as const,
|
|
269
346
|
};
|
|
270
347
|
try {
|
|
271
|
-
|
|
348
|
+
const response = await initialized.module.fetch(input, wreqInit) as Response;
|
|
349
|
+
return resolved ? closeTransportWithResponse(response, initialized.transport) : response;
|
|
272
350
|
} catch (error) {
|
|
351
|
+
if (resolved && requestTransports.delete(initialized.transport)) {
|
|
352
|
+
void initialized.transport.close().catch(() => undefined);
|
|
353
|
+
}
|
|
273
354
|
// Native errors are post-dispatch failures. Redact at this boundary and
|
|
274
355
|
// never replay through Bun, since generation may already have started.
|
|
275
356
|
throw safeProviderTlsError(error);
|
|
@@ -280,6 +361,7 @@ export function providerTlsFetch(
|
|
|
280
361
|
export function setProviderTlsRuntimeForTest(next: ProviderTlsRuntimeForTest | undefined): void {
|
|
281
362
|
if (next === undefined) {
|
|
282
363
|
runtime = defaultRuntime;
|
|
364
|
+
modulePromise = undefined;
|
|
283
365
|
return;
|
|
284
366
|
}
|
|
285
367
|
// Test callers normally replace only the native module. Keep those tests
|
|
@@ -294,11 +376,15 @@ export function setProviderTlsRuntimeForTest(next: ProviderTlsRuntimeForTest | u
|
|
|
294
376
|
}),
|
|
295
377
|
...next,
|
|
296
378
|
};
|
|
379
|
+
modulePromise = undefined;
|
|
297
380
|
}
|
|
298
381
|
|
|
299
382
|
export function resetProviderTlsProfileForTests(): void {
|
|
300
383
|
for (const pending of transports.values()) void pending.then(value => value?.transport.close()).catch(() => undefined);
|
|
301
384
|
transports.clear();
|
|
385
|
+
for (const transport of requestTransports) void transport.close().catch(() => undefined);
|
|
386
|
+
requestTransports.clear();
|
|
387
|
+
modulePromise = undefined;
|
|
302
388
|
shutdownDetach?.();
|
|
303
389
|
shutdownDetach = undefined;
|
|
304
390
|
statusByProvider.clear();
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { accessSync, constants, statSync } from "node:fs";
|
|
2
|
+
import type { OcxConfig, OcxServerTlsConfig } from "../types";
|
|
3
|
+
|
|
4
|
+
export function serverTlsConfigError(value: unknown): string | null {
|
|
5
|
+
if (value === undefined) return null;
|
|
6
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return "tls must be an object or omitted";
|
|
7
|
+
const tls = value as Record<string, unknown>;
|
|
8
|
+
const fields = ["certFile", "keyFile", "publicOrigin"] as const;
|
|
9
|
+
const unknown = Object.keys(tls).find(field => !(fields as readonly string[]).includes(field));
|
|
10
|
+
if (unknown) return `tls.${unknown} is not a supported field`;
|
|
11
|
+
for (const field of fields) {
|
|
12
|
+
if (typeof tls[field] !== "string" || tls[field].trim() !== tls[field] || !tls[field]) {
|
|
13
|
+
return `tls.${field} must be a non-empty trimmed string`;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
try {
|
|
17
|
+
const url = new URL(tls.publicOrigin as string);
|
|
18
|
+
if (url.protocol !== "https:" || url.username || url.password || url.pathname !== "/" || url.search || url.hash || url.origin !== tls.publicOrigin) {
|
|
19
|
+
return "tls.publicOrigin must be an exact HTTPS origin without credentials, path, query, or fragment";
|
|
20
|
+
}
|
|
21
|
+
} catch {
|
|
22
|
+
return "tls.publicOrigin must be an exact HTTPS origin without credentials, path, query, or fragment";
|
|
23
|
+
}
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function assertServerTlsFiles(tls: OcxServerTlsConfig): void {
|
|
28
|
+
for (const [label, path] of [["certificate", tls.certFile], ["private key", tls.keyFile]] as const) {
|
|
29
|
+
try {
|
|
30
|
+
accessSync(path, constants.R_OK);
|
|
31
|
+
if (!statSync(path).isFile()) throw new Error("not a regular file");
|
|
32
|
+
} catch {
|
|
33
|
+
throw new Error(`TLS ${label} is not a readable regular file: ${path}`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function clientHost(hostname: string | undefined): string {
|
|
39
|
+
const host = hostname?.trim() || "127.0.0.1";
|
|
40
|
+
if (host === "0.0.0.0" || host === "::" || host === "[::]") return "127.0.0.1";
|
|
41
|
+
return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function canonicalServerOrigin(config: Pick<OcxConfig, "hostname" | "tls">, port: number): string {
|
|
45
|
+
if (config.tls) return config.tls.publicOrigin;
|
|
46
|
+
return `http://${clientHost(config.hostname)}:${port}`;
|
|
47
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Legacy fixture seam, inert unless the repository test preload explicitly arms it. */
|
|
2
|
+
export function testProviderFetch(provider: object): typeof globalThis.fetch | undefined {
|
|
3
|
+
if ((globalThis as Record<PropertyKey, unknown>)[Symbol.for("opencodex.test.provider-fetch")] !== true) return undefined;
|
|
4
|
+
const candidate = (provider as { fetch?: unknown }).fetch;
|
|
5
|
+
return typeof candidate === "function" ? candidate as typeof globalThis.fetch : undefined;
|
|
6
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** Test-only listener seam, inert unless the repository test preload explicitly arms it. */
|
|
2
|
+
export function allowPlaintextRemoteForTests(): boolean {
|
|
3
|
+
return process.env.OCX_TEST_HOME_GUARD === "1"
|
|
4
|
+
&& (globalThis as Record<PropertyKey, unknown>)[Symbol.for("opencodex.test.plaintext-remote")] === true;
|
|
5
|
+
}
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
import { clearableDeadline } from "./abort";
|
|
18
18
|
|
|
19
19
|
// 1 initial + 2 retries: the pool may hold more than one stale socket.
|
|
20
|
-
const RESET_RETRY_MAX_ATTEMPTS =
|
|
20
|
+
const RESET_RETRY_MAX_ATTEMPTS = 1;
|
|
21
21
|
const RESET_RETRY_BASE_DELAY_MS = 150;
|
|
22
22
|
const RESET_RETRY_MAX_DELAY_MS = 1_000;
|
|
23
23
|
|
|
@@ -240,11 +240,15 @@ export interface ResetRetryOptions {
|
|
|
240
240
|
/** Short host/path label for the retry warn log (no secrets/query strings). */
|
|
241
241
|
label?: string;
|
|
242
242
|
attempts?: number;
|
|
243
|
+
/** Shared replay allowance across sends in one logical generation. */
|
|
244
|
+
replayBudget?: { remaining: number };
|
|
243
245
|
}
|
|
244
246
|
|
|
245
247
|
export interface TransientRetryOptions extends ResetRetryOptions {
|
|
246
248
|
/** Test seam: per-attempt slow budget override (defaults to TRANSIENT_RETRY_SLOW_ATTEMPT_MS). */
|
|
247
249
|
slowAttemptMs?: number;
|
|
250
|
+
/** Opt in to replaying transient 5xx responses (up to three total sends). */
|
|
251
|
+
replayTransientFailures?: boolean;
|
|
248
252
|
}
|
|
249
253
|
|
|
250
254
|
export type UpstreamSendRecovery = "connection-reset" | "transient-5xx";
|
|
@@ -330,7 +334,7 @@ export async function fetchWithResetRetry(
|
|
|
330
334
|
if (sawReset) throw new UpstreamRetryEvidenceError([], err, true);
|
|
331
335
|
throw err;
|
|
332
336
|
}
|
|
333
|
-
if (attempt === attempts - 1) throw err;
|
|
337
|
+
if (attempt === attempts - 1 || (opts.replayBudget && opts.replayBudget.remaining <= 0)) throw err;
|
|
334
338
|
sawReset = true;
|
|
335
339
|
lastError = err;
|
|
336
340
|
console.warn(
|
|
@@ -340,6 +344,7 @@ export async function fetchWithResetRetry(
|
|
|
340
344
|
baseDelayMs: RESET_RETRY_BASE_DELAY_MS,
|
|
341
345
|
maxDelayMs: RESET_RETRY_MAX_DELAY_MS,
|
|
342
346
|
}), opts.abortSignal);
|
|
347
|
+
if (opts.replayBudget) opts.replayBudget.remaining -= 1;
|
|
343
348
|
}
|
|
344
349
|
}
|
|
345
350
|
throw lastError ?? new Error("upstream fetch failed");
|
|
@@ -359,34 +364,43 @@ export async function fetchWithTransientRetry(
|
|
|
359
364
|
doFetch: ReplayableFetch,
|
|
360
365
|
opts: TransientRetryOptions = {},
|
|
361
366
|
): Promise<Response> {
|
|
362
|
-
const attempts = Math.max(1, opts.attempts ?? TRANSIENT_RETRY_MAX_ATTEMPTS);
|
|
367
|
+
const attempts = Math.max(1, opts.attempts ?? (opts.replayTransientFailures ? TRANSIENT_RETRY_MAX_ATTEMPTS : 1));
|
|
363
368
|
const slowAttemptMs = opts.slowAttemptMs ?? TRANSIENT_RETRY_SLOW_ATTEMPT_MS;
|
|
364
369
|
const transientStatuses: number[] = [];
|
|
365
370
|
let attemptStart = Date.now();
|
|
366
|
-
let
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
if (
|
|
371
|
-
console.warn(
|
|
372
|
-
`[upstream-retry] transient ${res.status}${opts.label ? ` (${opts.label})` : ""} — retrying (${attempt + 2}/${attempts})`,
|
|
373
|
-
);
|
|
374
|
-
const delay = retryBackoffDelayMs(attempt, {
|
|
375
|
-
baseDelayMs: TRANSIENT_RETRY_BASE_DELAY_MS,
|
|
376
|
-
maxDelayMs: TRANSIENT_RETRY_MAX_DELAY_MS,
|
|
377
|
-
headers: res.headers,
|
|
378
|
-
});
|
|
379
|
-
cancelResponseBodyBestEffort(res);
|
|
380
|
-
await sleepWithAbort(delay, opts.abortSignal);
|
|
381
|
-
attemptStart = Date.now();
|
|
382
|
-
transientStatuses.push(res.status);
|
|
371
|
+
let sawReset = false;
|
|
372
|
+
let resetSeen = false;
|
|
373
|
+
let lastError: unknown;
|
|
374
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
375
|
+
if (opts.abortSignal?.aborted) throw abortError(opts.abortSignal);
|
|
383
376
|
try {
|
|
384
|
-
res = await
|
|
377
|
+
const res = await doFetch(attempt === 0 ? undefined : (sawReset ? "connection-reset" : "transient-5xx"));
|
|
378
|
+
if (res.ok || !isTransientUpstreamStatus(res.status) || attempt === attempts - 1
|
|
379
|
+
|| opts.abortSignal?.aborted || Date.now() - attemptStart > slowAttemptMs
|
|
380
|
+
|| (opts.replayBudget && opts.replayBudget.remaining <= 0)) return res;
|
|
381
|
+
console.warn(`[upstream-retry] transient ${res.status}${opts.label ? ` (${opts.label})` : ""} — retrying (${attempt + 2}/${attempts})`);
|
|
382
|
+
const delay = retryBackoffDelayMs(attempt, { baseDelayMs: TRANSIENT_RETRY_BASE_DELAY_MS, maxDelayMs: TRANSIENT_RETRY_MAX_DELAY_MS, headers: res.headers });
|
|
383
|
+
cancelResponseBodyBestEffort(res);
|
|
384
|
+
await sleepWithAbort(delay, opts.abortSignal);
|
|
385
|
+
if (opts.replayBudget) opts.replayBudget.remaining -= 1;
|
|
386
|
+
transientStatuses.push(res.status);
|
|
387
|
+
sawReset = false;
|
|
388
|
+
attemptStart = Date.now();
|
|
385
389
|
} catch (err) {
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
390
|
+
if (opts.abortSignal?.aborted) throw err;
|
|
391
|
+
if (!isConnectionResetError(err)) {
|
|
392
|
+
if (transientStatuses.length || resetSeen) throw new UpstreamRetryEvidenceError(transientStatuses, err, resetSeen);
|
|
393
|
+
throw err;
|
|
394
|
+
}
|
|
395
|
+
if (attempt === attempts - 1 || (opts.replayBudget && opts.replayBudget.remaining <= 0)) throw err;
|
|
396
|
+
sawReset = true;
|
|
397
|
+
resetSeen = true;
|
|
398
|
+
lastError = err;
|
|
399
|
+
console.warn(`[upstream-retry] connection reset${opts.label ? ` (${opts.label})` : ""} — retrying (${attempt + 2}/${attempts})`);
|
|
400
|
+
await sleepWithAbort(retryBackoffDelayMs(attempt, { baseDelayMs: RESET_RETRY_BASE_DELAY_MS, maxDelayMs: RESET_RETRY_MAX_DELAY_MS }), opts.abortSignal);
|
|
401
|
+
if (opts.replayBudget) opts.replayBudget.remaining -= 1;
|
|
402
|
+
attemptStart = Date.now();
|
|
389
403
|
}
|
|
390
404
|
}
|
|
391
|
-
|
|
405
|
+
throw lastError ?? new Error("upstream fetch failed");
|
|
392
406
|
}
|
package/src/oauth/anthropic.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { OAuthCallbackFlow } from "./callback-server";
|
|
3
3
|
import { generatePKCE } from "./pkce";
|
|
4
4
|
import type { LocalTokenImportMode, OAuthController, OAuthCredentials } from "./types";
|
|
5
|
+
import { oauthFetch } from "./transport";
|
|
5
6
|
|
|
6
7
|
const CLIENT_ID = atob("OWQxYzI1MGEtZTYxYi00NGQ5LTg4ZWQtNTk0NGQxOTYyZjVl");
|
|
7
8
|
const AUTHORIZE_URL = "https://claude.ai/oauth/authorize";
|
|
@@ -46,7 +47,7 @@ export class AnthropicTokenError extends Error {
|
|
|
46
47
|
}
|
|
47
48
|
|
|
48
49
|
async function postJson(url: string, body: Record<string, string | number>): Promise<string> {
|
|
49
|
-
const response = await
|
|
50
|
+
const response = await oauthFetch(url, {
|
|
50
51
|
method: "POST",
|
|
51
52
|
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
|
52
53
|
body: JSON.stringify(body),
|
|
@@ -62,7 +63,7 @@ async function postJson(url: string, body: Record<string, string | number>): Pro
|
|
|
62
63
|
// Best-effort only: malformed error bodies remain structured by HTTP status.
|
|
63
64
|
}
|
|
64
65
|
throw new AnthropicTokenError(
|
|
65
|
-
`Anthropic OAuth HTTP ${response.status}
|
|
66
|
+
`Anthropic OAuth HTTP ${response.status}`,
|
|
66
67
|
response.status,
|
|
67
68
|
oauthError,
|
|
68
69
|
);
|
|
@@ -74,7 +75,7 @@ function parseTokenResponse(responseBody: string): AnthropicTokenResponse {
|
|
|
74
75
|
try {
|
|
75
76
|
return JSON.parse(responseBody) as AnthropicTokenResponse;
|
|
76
77
|
} catch {
|
|
77
|
-
throw new Error(
|
|
78
|
+
throw new Error("Anthropic OAuth returned invalid JSON");
|
|
78
79
|
}
|
|
79
80
|
}
|
|
80
81
|
|
package/src/oauth/chatgpt.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { OAuthCallbackFlow } from "./callback-server";
|
|
2
2
|
import type { OAuthController, OAuthCredentials } from "./types";
|
|
3
3
|
import { generatePKCE } from "./pkce";
|
|
4
|
+
import { oauthFetch } from "./transport";
|
|
4
5
|
|
|
5
6
|
const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
|
|
6
7
|
const AUTH_URL = "https://auth.openai.com/oauth/authorize";
|
|
@@ -107,7 +108,7 @@ export class ChatGPTOAuthFlow extends OAuthCallbackFlow {
|
|
|
107
108
|
|
|
108
109
|
async exchangeToken(code: string, _state: string, redirectUri: string): Promise<OAuthCredentials> {
|
|
109
110
|
if (!this.#verifier) throw new Error("ChatGPT PKCE verifier not initialized");
|
|
110
|
-
const resp = await
|
|
111
|
+
const resp = await oauthFetch(TOKEN_URL, {
|
|
111
112
|
method: "POST",
|
|
112
113
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
113
114
|
body: new URLSearchParams({
|
|
@@ -130,7 +131,10 @@ function safeErrorDescription(resp: Response): Promise<string> {
|
|
|
130
131
|
return resp.text().catch(() => "").then(text => {
|
|
131
132
|
try {
|
|
132
133
|
const parsed = JSON.parse(text) as { error?: string; error_description?: string };
|
|
133
|
-
|
|
134
|
+
const code = typeof parsed.error === "string" && /^(?:access_denied|expired_token|invalid_client|invalid_grant|invalid_request|temporarily_unavailable|unauthorized_client|unsupported_grant_type)$/.test(parsed.error)
|
|
135
|
+
? parsed.error
|
|
136
|
+
: undefined;
|
|
137
|
+
return code ?? `HTTP ${resp.status}`;
|
|
134
138
|
} catch { return `HTTP ${resp.status}`; }
|
|
135
139
|
});
|
|
136
140
|
}
|
|
@@ -144,7 +148,7 @@ export async function loginChatGPT(ctrl: OAuthController, opts?: { forceLogin?:
|
|
|
144
148
|
// Note: uses form-urlencoded per OAuth 2.0 spec (RFC 6749 §6).
|
|
145
149
|
// Codex-rs uses JSON for refresh — intentional divergence; both accepted by auth.openai.com.
|
|
146
150
|
export async function refreshChatGPTToken(refreshToken: string): Promise<OAuthCredentials> {
|
|
147
|
-
const resp = await
|
|
151
|
+
const resp = await oauthFetch(TOKEN_URL, {
|
|
148
152
|
method: "POST",
|
|
149
153
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
150
154
|
body: new URLSearchParams({
|
|
@@ -2,6 +2,7 @@ import type { OAuthController, OAuthCredentials } from "./types";
|
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { parseCallbackInput } from "./callback-server";
|
|
5
|
+
import { oauthFetch } from "./transport";
|
|
5
6
|
|
|
6
7
|
const COMMAND_CODE_STUDIO_URL = "https://commandcode.ai";
|
|
7
8
|
const COMMAND_CODE_CALLBACK_PORT = 5959;
|
|
@@ -43,7 +44,7 @@ async function importLocalCommandCodeAuth(signal?: AbortSignal): Promise<OAuthCr
|
|
|
43
44
|
if (typeof parsed.apiKey !== "string" || parsed.apiKey.length === 0) return undefined;
|
|
44
45
|
let accountId: string | undefined;
|
|
45
46
|
try {
|
|
46
|
-
const response = await
|
|
47
|
+
const response = await oauthFetch("https://api.commandcode.ai/alpha/whoami", {
|
|
47
48
|
headers: { Authorization: `Bearer ${parsed.apiKey}`, Accept: "application/json" },
|
|
48
49
|
signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(10_000)]) : AbortSignal.timeout(10_000),
|
|
49
50
|
});
|
|
@@ -129,7 +130,7 @@ function createCallbackServer(state: string): {
|
|
|
129
130
|
/** Validate a raw pasted Command Code API key and return the validated identity. */
|
|
130
131
|
async function validatePastedApiKey(apiKey: string): Promise<{ userId: string; userName: string } | undefined> {
|
|
131
132
|
try {
|
|
132
|
-
const response = await
|
|
133
|
+
const response = await oauthFetch("https://api.commandcode.ai/alpha/whoami", {
|
|
133
134
|
headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" },
|
|
134
135
|
signal: AbortSignal.timeout(10_000),
|
|
135
136
|
});
|
package/src/oauth/cursor.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { generatePKCE } from "./pkce";
|
|
11
11
|
import type { OAuthController, OAuthCredentials } from "./types";
|
|
12
|
+
import { oauthFetch } from "./transport";
|
|
12
13
|
|
|
13
14
|
const CURSOR_LOGIN_URL = "https://cursor.com/loginDeepControl";
|
|
14
15
|
const CURSOR_POLL_URL = "https://api2.cursor.sh/auth/poll";
|
|
@@ -132,7 +133,7 @@ export async function pollCursorAuth(
|
|
|
132
133
|
|
|
133
134
|
try {
|
|
134
135
|
const url = `${CURSOR_POLL_URL}?uuid=${encodeURIComponent(uuid)}&verifier=${encodeURIComponent(verifier)}`;
|
|
135
|
-
const response = await
|
|
136
|
+
const response = await oauthFetch(url, { signal });
|
|
136
137
|
|
|
137
138
|
if (response.status === 404) {
|
|
138
139
|
consecutiveErrors = 0;
|
|
@@ -215,7 +216,7 @@ export async function refreshCursorToken(refresh: string, signal?: AbortSignal):
|
|
|
215
216
|
if (signal?.aborted) throw signal.reason ?? new Error("Cursor token refresh aborted");
|
|
216
217
|
let response: Response;
|
|
217
218
|
try {
|
|
218
|
-
response = await
|
|
219
|
+
response = await oauthFetch(CURSOR_REFRESH_URL, {
|
|
219
220
|
method: "POST",
|
|
220
221
|
headers: { Authorization: `Bearer ${refresh}`, "Content-Type": "application/json" },
|
|
221
222
|
body: "{}",
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* This is an unofficial bridge — GitHub may tighten or revoke access. See registry note.
|
|
6
6
|
*/
|
|
7
7
|
import type { OAuthController, OAuthCredentials } from "./types";
|
|
8
|
+
import { oauthFetch } from "./transport";
|
|
8
9
|
|
|
9
10
|
/** VS Code's public GitHub OAuth app — required for copilot_internal/v2/token to succeed. */
|
|
10
11
|
export const GITHUB_COPILOT_OAUTH_CLIENT_ID = "Iv1.b507a08c87ecfe98";
|
|
@@ -149,7 +150,7 @@ async function requestDeviceAuthorization(signal?: AbortSignal): Promise<{
|
|
|
149
150
|
expiresInMs: number;
|
|
150
151
|
intervalMs: number;
|
|
151
152
|
}> {
|
|
152
|
-
const response = await
|
|
153
|
+
const response = await oauthFetch(DEVICE_CODE_URL, {
|
|
153
154
|
method: "POST",
|
|
154
155
|
headers: {
|
|
155
156
|
Accept: "application/json",
|
|
@@ -194,7 +195,7 @@ async function pollGithubDeviceToken(
|
|
|
194
195
|
// cadence violation GitHub may answer with slow_down.
|
|
195
196
|
await sleep(waitMs, signal);
|
|
196
197
|
if (Date.now() >= deadline) break;
|
|
197
|
-
const response = await
|
|
198
|
+
const response = await oauthFetch(ACCESS_TOKEN_URL, {
|
|
198
199
|
method: "POST",
|
|
199
200
|
headers: {
|
|
200
201
|
Accept: "application/json",
|
|
@@ -211,6 +212,7 @@ async function pollGithubDeviceToken(
|
|
|
211
212
|
? AbortSignal.any([signal, AbortSignal.timeout(Math.max(MIN_POLL_MS, deadline - Date.now()))])
|
|
212
213
|
: AbortSignal.timeout(Math.max(MIN_POLL_MS, deadline - Date.now())),
|
|
213
214
|
});
|
|
215
|
+
if (signal?.aborted) throw new Error("Login cancelled");
|
|
214
216
|
const payload = (await response.json().catch(() => ({}))) as GithubTokenResponse;
|
|
215
217
|
if (response.ok && payload.access_token) {
|
|
216
218
|
// Classic OAuth apps issue a non-expiring `gho_` access token with NO refresh token;
|
|
@@ -245,7 +247,7 @@ async function pollGithubDeviceToken(
|
|
|
245
247
|
}
|
|
246
248
|
|
|
247
249
|
async function refreshGithubAccessToken(refreshToken: string, signal?: AbortSignal): Promise<{ access: string; refresh: string }> {
|
|
248
|
-
const response = await
|
|
250
|
+
const response = await oauthFetch(ACCESS_TOKEN_URL, {
|
|
249
251
|
method: "POST",
|
|
250
252
|
headers: {
|
|
251
253
|
Accept: "application/json",
|
|
@@ -283,7 +285,7 @@ async function exchangeCopilotToken(githubAccessToken: string, signal?: AbortSig
|
|
|
283
285
|
expires: number;
|
|
284
286
|
apiBaseUrl: string;
|
|
285
287
|
}> {
|
|
286
|
-
const response = await
|
|
288
|
+
const response = await oauthFetch(COPILOT_TOKEN_URL, {
|
|
287
289
|
method: "GET",
|
|
288
290
|
headers: {
|
|
289
291
|
...GITHUB_COPILOT_EDITOR_HEADERS,
|
|
@@ -316,7 +318,7 @@ async function fetchGithubIdentityOnce(githubAccessToken: string, signal?: Abort
|
|
|
316
318
|
email?: string;
|
|
317
319
|
accountId?: string;
|
|
318
320
|
}> {
|
|
319
|
-
const response = await
|
|
321
|
+
const response = await oauthFetch(GITHUB_USER_URL, {
|
|
320
322
|
headers: {
|
|
321
323
|
Accept: "application/vnd.github+json",
|
|
322
324
|
Authorization: `Bearer ${githubAccessToken}`,
|
|
@@ -13,6 +13,7 @@ import { OAuthCallbackFlow, type OAuthCallbackFlowOptions } from "./callback-ser
|
|
|
13
13
|
import { generatePKCE } from "./pkce";
|
|
14
14
|
import type { OAuthController, OAuthCredentials } from "./types";
|
|
15
15
|
import { antigravityUserAgent, ANTIGRAVITY_IDE_VERSION } from "../adapters/client-fingerprint";
|
|
16
|
+
import { oauthFetch } from "./transport";
|
|
16
17
|
|
|
17
18
|
const CLIENT_ID = process.env.GOOGLE_ANTIGRAVITY_CLIENT_ID
|
|
18
19
|
|| "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com";
|
|
@@ -96,7 +97,7 @@ function emailFromToken(accessToken: string, idToken: string | undefined): strin
|
|
|
96
97
|
}
|
|
97
98
|
|
|
98
99
|
async function postToken(body: Record<string, string>, signal?: AbortSignal): Promise<GoogleTokenPayload> {
|
|
99
|
-
const response = await
|
|
100
|
+
const response = await oauthFetch(TOKEN_ENDPOINT, {
|
|
100
101
|
method: "POST",
|
|
101
102
|
headers: { Accept: "application/json", "Content-Type": "application/x-www-form-urlencoded" },
|
|
102
103
|
body: new URLSearchParams(body).toString(),
|
|
@@ -132,7 +133,7 @@ function extractProjectId(data: Record<string, unknown> | undefined): string | u
|
|
|
132
133
|
}
|
|
133
134
|
|
|
134
135
|
async function loadCodeAssistProject(accessToken: string, signal?: AbortSignal): Promise<string | undefined> {
|
|
135
|
-
const response = await
|
|
136
|
+
const response = await oauthFetch(`${PROD_API}/${API_VERSION}:loadCodeAssist`, {
|
|
136
137
|
method: "POST",
|
|
137
138
|
headers: { Authorization: `Bearer ${accessToken}`, Accept: "*/*", "Content-Type": "application/json", "User-Agent": antigravityUserAgent() },
|
|
138
139
|
body: JSON.stringify({ metadata: { ideType: "ANTIGRAVITY" } }),
|
|
@@ -149,7 +150,7 @@ async function onboardProject(
|
|
|
149
150
|
const sleep = sleepForTests ?? defaultSleep;
|
|
150
151
|
for (let attempt = 0; attempt < ONBOARD_ATTEMPTS; attempt++) {
|
|
151
152
|
if (signal?.aborted) throw signal.reason ?? new Error("Antigravity onboarding aborted");
|
|
152
|
-
const response = await
|
|
153
|
+
const response = await oauthFetch(`${DAILY_API}/${API_VERSION}:onboardUser`, {
|
|
153
154
|
method: "POST",
|
|
154
155
|
headers: { Authorization: `Bearer ${accessToken}`, Accept: "*/*", "Content-Type": "application/json", "User-Agent": antigravityUserAgent() },
|
|
155
156
|
// `ide_version` is a version, not a User-Agent. `antigravityUserAgent()` returns the whole
|
|
@@ -295,7 +296,7 @@ export async function validateAntigravityImportCredential(
|
|
|
295
296
|
signal?: AbortSignal,
|
|
296
297
|
): Promise<OAuthCredentials> {
|
|
297
298
|
const credential = await refreshAntigravityToken(refreshToken, signal);
|
|
298
|
-
const response = await
|
|
299
|
+
const response = await oauthFetch(USERINFO_ENDPOINT, {
|
|
299
300
|
method: "GET",
|
|
300
301
|
headers: { Accept: "application/json", Authorization: `Bearer ${credential.access}` },
|
|
301
302
|
signal: requestSignal(signal),
|