@yansigit/opencodex 2.36.1-dev.20260829.50 → 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.
Files changed (77) hide show
  1. package/README.md +9 -4
  2. package/gui/dist/assets/{ApiKeys-BLPOvK4z.js → ApiKeys-GpvAosG-.js} +1 -1
  3. package/gui/dist/assets/{Claude-BbHv3EU7.js → Claude-Biju6rfh.js} +1 -1
  4. package/gui/dist/assets/{CodexSet-hgu5hES-.js → CodexSet-_EkE9hNC.js} +1 -1
  5. package/gui/dist/assets/{FileIntegrationPage-RCmDvvsx.js → FileIntegrationPage-BL55KJCW.js} +1 -1
  6. package/gui/dist/assets/{Grok-DH-ggRqA.js → Grok-Dp3sOS5B.js} +1 -1
  7. package/gui/dist/assets/{Integrations-D6m5E2-T.js → Integrations-Bx0V4Dx1.js} +2 -2
  8. package/gui/dist/assets/{IntegrationsOverview-C1rbnXV7.js → IntegrationsOverview-CT41lCh4.js} +1 -1
  9. package/gui/dist/assets/{Logs-Bqb0vYRk.js → Logs-BxS_u7Hr.js} +1 -1
  10. package/gui/dist/assets/{Models-gMnzHf0c.js → Models-DvaBTkUM.js} +1 -1
  11. package/gui/dist/assets/{NumberStepper-awaoZNlj.js → NumberStepper-97eq4OKR.js} +1 -1
  12. package/gui/dist/assets/Providers-CB9I0Vv1.js +1 -0
  13. package/gui/dist/assets/{RestoreDialog-DX7SGWIk.js → RestoreDialog-C9sltYZD.js} +1 -1
  14. package/gui/dist/assets/{Startup-B1htiLU9.js → Startup-BvFv8yC2.js} +1 -1
  15. package/gui/dist/assets/{Storage-B3MfosmS.js → Storage-CKa2mqSI.js} +1 -1
  16. package/gui/dist/assets/{Subagents-L6Q2V9PZ.js → Subagents-B4oMNOVh.js} +1 -1
  17. package/gui/dist/assets/{Usage-xbZnkDoj.js → Usage-gaSKtbMw.js} +1 -1
  18. package/gui/dist/assets/{codex-stale-banner-W4FhikN7.js → codex-stale-banner-B5VQPO3t.js} +1 -1
  19. package/gui/dist/assets/{data-surface-B8PX8rjm.js → data-surface-B0APtUxm.js} +1 -1
  20. package/gui/dist/assets/{data-surface-BbBMp0Wj.js → data-surface-CH5yKPY1.js} +1 -1
  21. package/gui/dist/assets/index-C8flH881.css +1 -0
  22. package/gui/dist/assets/{index-JEC_6CUW.js → index-DWxg-UlU.js} +3 -3
  23. package/gui/dist/assets/{model-display-BvLWy9D5.js → model-display-vDl3mo07.js} +1 -1
  24. package/gui/dist/assets/{provider-payload-BRfSsonS.js → provider-payload-C3Xp5N0y.js} +1 -1
  25. package/gui/dist/assets/{section-tabs-B8sLMiz6.js → section-tabs-Bpm5lFcZ.js} +1 -1
  26. package/gui/dist/assets/shared-BHpPx-nZ.js +69 -0
  27. package/gui/dist/index.html +3 -3
  28. package/package.json +1 -1
  29. package/src/adapters/base.ts +2 -0
  30. package/src/adapters/command-code.ts +2 -1
  31. package/src/adapters/google-http.ts +34 -14
  32. package/src/adapters/google.ts +4 -1
  33. package/src/cli/index.ts +9 -1
  34. package/src/codex/inject.ts +14 -9
  35. package/src/config/process-state.ts +15 -0
  36. package/src/config.ts +17 -0
  37. package/src/generated/compatibility-version.json +61 -41
  38. package/src/lib/bun-stream-caps.ts +2 -1
  39. package/src/lib/pinned-http.ts +7 -1
  40. package/src/lib/process-control.ts +3 -2
  41. package/src/lib/provider-outbound.ts +77 -27
  42. package/src/lib/provider-runtime-fetch.ts +23 -0
  43. package/src/lib/provider-tls-profile.ts +97 -11
  44. package/src/lib/server-tls.ts +47 -0
  45. package/src/lib/test-provider-fetch.ts +6 -0
  46. package/src/lib/test-server-start.ts +5 -0
  47. package/src/lib/upstream-retry.ts +39 -25
  48. package/src/oauth/anthropic.ts +4 -3
  49. package/src/oauth/chatgpt.ts +7 -3
  50. package/src/oauth/command-code.ts +3 -2
  51. package/src/oauth/cursor.ts +3 -2
  52. package/src/oauth/github-copilot.ts +7 -5
  53. package/src/oauth/google-antigravity.ts +5 -4
  54. package/src/oauth/key-providers.ts +11 -13
  55. package/src/oauth/kimi.ts +7 -6
  56. package/src/oauth/kiro.ts +3 -2
  57. package/src/oauth/nous.ts +5 -5
  58. package/src/oauth/transport.ts +77 -0
  59. package/src/oauth/xai.ts +5 -4
  60. package/src/providers/replit/probe.ts +8 -3
  61. package/src/providers/xai-transport.ts +16 -2
  62. package/src/server/auth-cors.ts +15 -1
  63. package/src/server/chat-native.ts +9 -3
  64. package/src/server/index.ts +63 -23
  65. package/src/server/management/config-routes.ts +117 -15
  66. package/src/server/management/context.ts +4 -0
  67. package/src/server/management/provider-routes.ts +1 -0
  68. package/src/server/responses/compact.ts +5 -2
  69. package/src/server/responses/core.ts +16 -19
  70. package/src/server/responses/fetch-helpers.ts +15 -7
  71. package/src/server/ws-bridge.ts +33 -4
  72. package/src/types/config.ts +9 -0
  73. package/src/types/provider.ts +2 -0
  74. package/src/types.ts +1 -0
  75. package/gui/dist/assets/Providers-Bo6wuQMa.js +0 -1
  76. package/gui/dist/assets/index-pLho_LBF.css +0 -1
  77. 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 requested fallback to Bun fetch; native transport initialization failed");
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 runtime.importWreq();
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 bunFetch;
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)) return bunFetch(input, init);
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
- if (!proxy) {
251
- await (runtime.resolveDestination ?? resolvePublicAddresses)(String(url), {
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 = await getTransport(proxy);
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
- return bunFetch(input, { ...init, redirect: "manual" });
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
- return await initialized.module.fetch(input, wreqInit) as Response;
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 = 3;
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 res = await fetchWithResetRetry(doFetch, opts);
367
- for (let attempt = 0; attempt < attempts - 1; attempt++) {
368
- if (res.ok || !isTransientUpstreamStatus(res.status)) return res;
369
- if (opts.abortSignal?.aborted) return res;
370
- if (Date.now() - attemptStart > slowAttemptMs) return res;
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 fetchWithResetRetry(doFetch, opts, "transient-5xx");
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
- // Keep the prior 5xx evidence attached: the origin already responded, so
387
- // this rejection is not pre-connection and must not classify as neutral.
388
- throw new UpstreamRetryEvidenceError(transientStatuses, err);
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
- return res;
405
+ throw lastError ?? new Error("upstream fetch failed");
392
406
  }
@@ -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 fetch(url, {
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}: ${responseBody}`,
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(`Anthropic OAuth returned invalid JSON: ${responseBody.slice(0, 200)}`);
78
+ throw new Error("Anthropic OAuth returned invalid JSON");
78
79
  }
79
80
  }
80
81
 
@@ -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 fetch(TOKEN_URL, {
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
- return [parsed.error, parsed.error_description].filter(Boolean).join(": ") || `HTTP ${resp.status}`;
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 fetch(TOKEN_URL, {
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 fetch("https://api.commandcode.ai/alpha/whoami", {
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 fetch("https://api.commandcode.ai/alpha/whoami", {
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
  });
@@ -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 fetch(url, { signal });
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 fetch(CURSOR_REFRESH_URL, {
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 fetch(DEVICE_CODE_URL, {
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 fetch(ACCESS_TOKEN_URL, {
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 fetch(ACCESS_TOKEN_URL, {
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 fetch(COPILOT_TOKEN_URL, {
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 fetch(GITHUB_USER_URL, {
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 fetch(TOKEN_ENDPOINT, {
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 fetch(`${PROD_API}/${API_VERSION}:loadCodeAssist`, {
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 fetch(`${DAILY_API}/${API_VERSION}:onboardUser`, {
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 fetch(USERINFO_ENDPOINT, {
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),