@bitkyc08/opencodex 2.7.30 → 2.7.33-preview.20260722

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 (59) hide show
  1. package/README.ja.md +438 -0
  2. package/README.ko.md +1 -1
  3. package/README.md +1 -1
  4. package/README.ru.md +480 -0
  5. package/README.zh-CN.md +1 -1
  6. package/bin/ocx.mjs +18 -1
  7. package/gui/dist/assets/index-B79f-04T.js +52 -0
  8. package/gui/dist/assets/index-D6Fcl4yM.css +1 -0
  9. package/gui/dist/index.html +2 -2
  10. package/package.json +1 -1
  11. package/src/adapters/anthropic.ts +17 -2
  12. package/src/adapters/cursor/discovery.ts +2 -2
  13. package/src/adapters/google-tool-schema.ts +4 -0
  14. package/src/adapters/google.ts +17 -2
  15. package/src/adapters/openai-chat.ts +36 -1
  16. package/src/adapters/openai-responses.ts +2 -1
  17. package/src/bridge.ts +12 -4
  18. package/src/cli/account-api.ts +4 -2
  19. package/src/cli/account-extended.ts +34 -0
  20. package/src/cli/account.ts +3 -1
  21. package/src/cli/claude.ts +6 -1
  22. package/src/cli/help.ts +25 -4
  23. package/src/cli/init.ts +38 -3
  24. package/src/cli/models.ts +206 -7
  25. package/src/codex/auth-api.ts +45 -3
  26. package/src/codex/catalog.ts +105 -12
  27. package/src/codex/routing.ts +85 -4
  28. package/src/combos/index.ts +1 -1
  29. package/src/combos/request.ts +26 -4
  30. package/src/combos/types.ts +4 -4
  31. package/src/config.ts +60 -3
  32. package/src/lib/upstream-retry.ts +21 -0
  33. package/src/lib/winsw.ts +7 -1
  34. package/src/oauth/anthropic.ts +23 -1
  35. package/src/oauth/github-copilot.ts +1 -0
  36. package/src/oauth/index.ts +88 -8
  37. package/src/oauth/kiro.ts +12 -1
  38. package/src/oauth/local-token-detect.ts +3 -0
  39. package/src/oauth/store.ts +43 -0
  40. package/src/oauth/types.ts +3 -1
  41. package/src/providers/antigravity-models.ts +123 -14
  42. package/src/providers/api-keys.ts +12 -0
  43. package/src/providers/openrouter-routing.ts +102 -0
  44. package/src/providers/registry.ts +41 -17
  45. package/src/router.ts +2 -1
  46. package/src/server/auth-cors.ts +5 -0
  47. package/src/server/index.ts +3 -3
  48. package/src/server/management-api.ts +143 -6
  49. package/src/server/relay.ts +31 -5
  50. package/src/server/request-log.ts +12 -0
  51. package/src/server/responses.ts +127 -31
  52. package/src/service.ts +12 -3
  53. package/src/types.ts +44 -3
  54. package/src/update/index.ts +19 -2
  55. package/src/update/job.ts +13 -3
  56. package/src/usage/expected-prices.ts +22 -9
  57. package/src/usage/summary.ts +43 -0
  58. package/gui/dist/assets/index-B-UauL1p.css +0 -1
  59. package/gui/dist/assets/index-avcinRsG.js +0 -40
@@ -25,14 +25,31 @@ export type CodexThreadResolution =
25
25
  const threadAccountMap = new Map<string, ThreadAffinityEntry>();
26
26
  type CodexUpstreamHealth = {
27
27
  consecutiveFailures: number;
28
+ /** Consecutive healthy terminals observed while recovering from escalation level 2+. */
29
+ consecutiveSuccesses?: number;
28
30
  lastFailureStatus?: number;
29
31
  lastFailureAt?: number;
32
+ /** Hard cooldown (quota 429). Survives a later 2xx; blocks auth + selection. */
30
33
  cooldownUntil?: number;
34
+ /**
35
+ * Soft avoid after connect_error / timeout / transient 5xx. Cleared on 2xx.
36
+ * Blocks pool selection + thread affinity reuse so a sticky session can leave a
37
+ * flaky account without throwing CodexAccountCooldownError (hard-only).
38
+ */
39
+ softAvoidUntil?: number;
31
40
  };
32
41
 
33
42
  const CODEX_DEFAULT_QUOTA_COOLDOWN_MS = 60_000;
34
43
  const CODEX_MAX_QUOTA_COOLDOWN_MS = 24 * 60 * 60_000;
35
44
  export const CODEX_FAILURE_WINDOW_MS = 5 * 60_000;
45
+ /** How long a transient failure keeps the account out of pool selection. */
46
+ export const CODEX_TRANSIENT_SOFT_AVOID_MS = 30_000;
47
+ const CODEX_TRANSIENT_SOFT_AVOID_ESCALATION_MS = [
48
+ CODEX_TRANSIENT_SOFT_AVOID_MS,
49
+ 2 * 60_000,
50
+ 10 * 60_000,
51
+ 30 * 60_000,
52
+ ] as const;
36
53
  export const CODEX_THREAD_AFFINITY_IDLE_TTL_MS = 24 * 60 * 60_000;
37
54
  export const CODEX_THREAD_AFFINITY_MAX_ENTRIES = 2048;
38
55
  // Min interval between quota threshold re-evaluations for a single bound thread.
@@ -47,6 +64,8 @@ export type CodexUpstreamOutcomeMeta = {
47
64
  retryAfter?: string | null;
48
65
  resetAt?: unknown | unknown[];
49
66
  now?: number;
67
+ /** When set, clears affinity for this thread immediately on transient failure. */
68
+ threadId?: string | null;
50
69
  };
51
70
 
52
71
  function hasConfiguredPoolAccount(config: OcxConfig, accountId: string): boolean {
@@ -162,8 +181,21 @@ export function isCodexAccountInCooldown(accountId: string, now = Date.now()): b
162
181
  return getCodexAccountCooldownUntil(accountId, now) !== null;
163
182
  }
164
183
 
184
+ export function getCodexAccountSoftAvoidUntil(accountId: string, now = Date.now()): number | null {
185
+ const softAvoidUntil = upstreamHealth.get(accountId)?.softAvoidUntil;
186
+ return typeof softAvoidUntil === "number" && Number.isFinite(softAvoidUntil) && softAvoidUntil > now
187
+ ? softAvoidUntil
188
+ : null;
189
+ }
190
+
191
+ export function isCodexAccountSoftAvoided(accountId: string, now = Date.now()): boolean {
192
+ return getCodexAccountSoftAvoidUntil(accountId, now) !== null;
193
+ }
194
+
165
195
  function isCodexAccountSelectable(config: OcxConfig, accountId: string, now: number): boolean {
166
- return !isCodexAccountInCooldown(accountId, now) && isCodexAccountUsable(config, accountId);
196
+ return !isCodexAccountInCooldown(accountId, now)
197
+ && !isCodexAccountSoftAvoided(accountId, now)
198
+ && isCodexAccountUsable(config, accountId);
167
199
  }
168
200
 
169
201
  function isThreadAffinityExpired(entry: ThreadAffinityEntry, now: number): boolean {
@@ -215,6 +247,7 @@ function getEligiblePoolAccounts(config: OcxConfig, excludeId?: string, now = Da
215
247
  const ids = (config.codexAccounts ?? [])
216
248
  .filter(account => !account.isMain && account.id !== excludeId && !isAccountNeedsReauth(account.id))
217
249
  .filter(account => !isCodexAccountInCooldown(account.id, now))
250
+ .filter(account => !isCodexAccountSoftAvoided(account.id, now))
218
251
  .filter(account => isCodexAccountUsable(config, account.id))
219
252
  .map(account => account.id);
220
253
  // The main Codex account is not stored in config.codexAccounts; include it as a
@@ -223,6 +256,7 @@ function getEligiblePoolAccounts(config: OcxConfig, excludeId?: string, now = Da
223
256
  excludeId !== MAIN_CODEX_ACCOUNT_ID
224
257
  && !isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID)
225
258
  && !isCodexAccountInCooldown(MAIN_CODEX_ACCOUNT_ID, now)
259
+ && !isCodexAccountSoftAvoided(MAIN_CODEX_ACCOUNT_ID, now)
226
260
  && isCodexAccountUsable(config, MAIN_CODEX_ACCOUNT_ID)
227
261
  ) {
228
262
  ids.unshift(MAIN_CODEX_ACCOUNT_ID);
@@ -352,6 +386,9 @@ export function resolveCodexAccountForThreadDetailed(
352
386
  if (
353
387
  isThreadAffinityGenerationLive(entry)
354
388
  && isCodexAccountSelectable(config, entry.accountId, now)
389
+ // Affined threads must leave a failing account once the streak trips failover
390
+ // (soft-avoid covers the first-hit case; this catches post-avoid residual streaks).
391
+ && !shouldFailover(config, entry.accountId, now)
355
392
  ) {
356
393
  entry.lastUsedAt = now;
357
394
  // Periodic quota re-eval: a long-lived bound thread must still switch when
@@ -420,7 +457,22 @@ export function recordCodexUpstreamOutcome(
420
457
  const now = meta.now ?? Date.now();
421
458
  const outcomeClass = classifyCodexUpstreamOutcome(outcome);
422
459
  if (outcomeClass === "success") {
460
+ const current = upstreamHealth.get(accountId);
423
461
  const cooldownUntil = getCodexAccountCooldownUntil(accountId, now);
462
+ const failoverEnabled = (config.upstreamFailoverThreshold ?? 3) > 0;
463
+ if (failoverEnabled && current && current.consecutiveFailures >= 2) {
464
+ const consecutiveSuccesses = (current.consecutiveSuccesses ?? 0) + 1;
465
+ if (consecutiveSuccesses < 2) {
466
+ upstreamHealth.set(accountId, {
467
+ ...current,
468
+ consecutiveSuccesses,
469
+ ...(cooldownUntil ? { cooldownUntil } : {}),
470
+ });
471
+ return;
472
+ }
473
+ }
474
+ // Level 1 clears immediately; escalated accounts need two consecutive healthy terminals.
475
+ // Hard quota cooldown intentionally survives either recovery path.
424
476
  if (cooldownUntil) upstreamHealth.set(accountId, { consecutiveFailures: 0, cooldownUntil });
425
477
  else upstreamHealth.delete(accountId);
426
478
  return;
@@ -454,15 +506,44 @@ export function recordCodexUpstreamOutcome(
454
506
  return;
455
507
  }
456
508
 
509
+ // transient (connect_error / timeout / 5xx)
457
510
  const current = upstreamHealth.get(accountId);
458
511
  const stale = current?.lastFailureAt ? now - current.lastFailureAt > CODEX_FAILURE_WINDOW_MS : false;
459
- const cooldownUntil = getCodexAccountCooldownUntil(accountId, now) ?? undefined;
512
+ const hardCooldownUntil = getCodexAccountCooldownUntil(accountId, now) ?? undefined;
513
+ // Soft avoid + affinity clears are part of failover. When threshold is 0, leave
514
+ // sticky sessions alone (same as shouldFailover / applyFailureFailover no-ops).
515
+ const failoverEnabled = (config.upstreamFailoverThreshold ?? 3) > 0;
516
+ const consecutiveFailures = stale ? 1 : (current?.consecutiveFailures ?? 0) + 1;
517
+ const escalationMs = CODEX_TRANSIENT_SOFT_AVOID_ESCALATION_MS[
518
+ Math.min(consecutiveFailures, CODEX_TRANSIENT_SOFT_AVOID_ESCALATION_MS.length) - 1
519
+ ]!;
520
+ const softAvoidUntil = failoverEnabled
521
+ ? Math.max(
522
+ getCodexAccountSoftAvoidUntil(accountId, now) ?? 0,
523
+ now + escalationMs,
524
+ )
525
+ : undefined;
460
526
  upstreamHealth.set(accountId, {
461
- consecutiveFailures: stale ? 1 : (current?.consecutiveFailures ?? 0) + 1,
527
+ consecutiveFailures,
462
528
  lastFailureStatus,
463
529
  lastFailureAt: now,
464
- ...(cooldownUntil ? { cooldownUntil } : {}),
530
+ ...(hardCooldownUntil ? { cooldownUntil: hardCooldownUntil } : {}),
531
+ ...(softAvoidUntil !== undefined ? { softAvoidUntil } : {}),
465
532
  });
533
+ // Drop this thread's pin immediately so the next continue can rebind without
534
+ // waiting for the soft-avoid selectable check. Guard: only delete when the
535
+ // thread is still pinned to the FAILING account — a late failure from account A
536
+ // must not delete a newer healthy binding to account B (race: T→A, A fails,
537
+ // T→B, late A failure must not delete B's mapping).
538
+ if (failoverEnabled && meta.threadId) {
539
+ const bound = threadAccountMap.get(meta.threadId);
540
+ if (bound?.accountId === accountId) threadAccountMap.delete(meta.threadId);
541
+ }
542
+ // Once the account is past the failover streak, clear every thread still pinned
543
+ // to it — matching 429 affinity behavior so "continue" cannot stay on a bad peer.
544
+ if (shouldFailover(config, accountId, now)) {
545
+ clearThreadAccountMapForAccount(accountId);
546
+ }
466
547
  if (config.activeCodexAccountId === accountId) applyFailureFailover(config, accountId, now);
467
548
  }
468
549
 
@@ -1,5 +1,4 @@
1
1
  export {
2
- COMBO_DEFAULT_EFFORT,
3
2
  COMBO_NAMESPACE,
4
3
  comboConfigError,
5
4
  comboConfigIssues,
@@ -34,4 +33,5 @@ export {
34
33
  export {
35
34
  comboIdFromRawBody,
36
35
  concreteComboRequestBody,
36
+ resetComboEffortWarningStateForTests,
37
37
  } from "./request";
@@ -1,6 +1,12 @@
1
1
  import type { OcxComboDefaultEffort, OcxComboTarget } from "../types";
2
2
  import { parseComboModelId } from "./types";
3
3
 
4
+ const warnedUnsupportedDefaults = new Set<string>();
5
+
6
+ export function resetComboEffortWarningStateForTests(): void {
7
+ warnedUnsupportedDefaults.clear();
8
+ }
9
+
4
10
  export function comboIdFromRawBody(body: unknown): string | null {
5
11
  if (!body || typeof body !== "object" || Array.isArray(body)) return null;
6
12
  const model = (body as { model?: unknown }).model;
@@ -12,19 +18,35 @@ export function concreteComboRequestBody(
12
18
  body: unknown,
13
19
  target: Pick<OcxComboTarget, "provider" | "model">,
14
20
  defaultEffort: OcxComboDefaultEffort | null,
21
+ targetReasoningEfforts: readonly string[] | undefined,
15
22
  ): Record<string, unknown> {
16
23
  const clone = structuredClone(body) as Record<string, unknown>;
17
24
  clone.model = `${target.provider}/${target.model}`;
18
25
  if (!defaultEffort) return clone;
19
26
  const reasoning = clone.reasoning;
20
- if (reasoning === undefined) {
21
- clone.reasoning = { effort: defaultEffort };
22
- } else if (
27
+ const needsDefault = reasoning === undefined || (
23
28
  reasoning
24
29
  && typeof reasoning === "object"
25
30
  && !Array.isArray(reasoning)
26
31
  && !Object.prototype.hasOwnProperty.call(reasoning, "effort")
27
- ) {
32
+ );
33
+ if (!needsDefault) return clone;
34
+ if (!targetReasoningEfforts?.includes(defaultEffort)) {
35
+ const key = `${target.provider}/${target.model}:${defaultEffort}`;
36
+ if (!warnedUnsupportedDefaults.has(key)) {
37
+ warnedUnsupportedDefaults.add(key);
38
+ console.debug("[opencodex] combo default effort omitted", {
39
+ provider: target.provider,
40
+ model: target.model,
41
+ requestedEffort: defaultEffort,
42
+ capability: targetReasoningEfforts === undefined ? "unknown" : "unsupported",
43
+ });
44
+ }
45
+ return clone;
46
+ }
47
+ if (reasoning === undefined) {
48
+ clone.reasoning = { effort: defaultEffort };
49
+ } else {
28
50
  clone.reasoning = { ...(reasoning as Record<string, unknown>), effort: defaultEffort };
29
51
  }
30
52
  return clone;
@@ -8,7 +8,6 @@ import type {
8
8
  } from "../types";
9
9
 
10
10
  export const COMBO_NAMESPACE = "combo";
11
- export const COMBO_DEFAULT_EFFORT: OcxComboDefaultEffort = "medium";
12
11
  const COMBO_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
13
12
 
14
13
  export interface ComboValidationIssue {
@@ -19,7 +18,7 @@ export interface ComboValidationIssue {
19
18
  export interface NormalizedComboConfig {
20
19
  strategy: OcxComboStrategy;
21
20
  stickyLimit: number;
22
- defaultEffort: OcxComboDefaultEffort;
21
+ defaultEffort: OcxComboDefaultEffort | null;
23
22
  targets: Array<Required<OcxComboTarget>>;
24
23
  }
25
24
 
@@ -81,6 +80,7 @@ export function comboConfigIssues(
81
80
  issues.push({ path: ["stickyLimit"], message: "stickyLimit must be an integer from 1 to 100" });
82
81
  }
83
82
  if (body.defaultEffort !== undefined
83
+ && body.defaultEffort !== null
84
84
  && (typeof body.defaultEffort !== "string" || !isCodexReasoningEffort(body.defaultEffort))) {
85
85
  issues.push({
86
86
  path: ["defaultEffort"],
@@ -164,7 +164,7 @@ export function normalizeComboConfig(raw: OcxComboConfig): NormalizedComboConfig
164
164
  return {
165
165
  strategy: raw.strategy ?? "failover",
166
166
  stickyLimit: raw.stickyLimit ?? 1,
167
- defaultEffort: raw.defaultEffort ?? COMBO_DEFAULT_EFFORT,
167
+ defaultEffort: raw.defaultEffort ?? null,
168
168
  targets: raw.targets.map(target => ({
169
169
  provider: target.provider.trim(),
170
170
  model: target.model.trim(),
@@ -179,7 +179,7 @@ export function comboDefaultEffort(
179
179
  ): OcxComboDefaultEffort | null {
180
180
  const combos = config.combos;
181
181
  if (!combos || !Object.hasOwn(combos, id)) return null;
182
- const value: unknown = combos[id]!.defaultEffort ?? COMBO_DEFAULT_EFFORT;
182
+ const value: unknown = combos[id]!.defaultEffort ?? null;
183
183
  return typeof value === "string" && isCodexReasoningEffort(value)
184
184
  ? value as OcxComboDefaultEffort
185
185
  : null;
package/src/config.ts CHANGED
@@ -6,7 +6,8 @@ import * as z from "zod/v4";
6
6
  import { comboConfigIssues } from "./combos/types";
7
7
  import { hardenSecretDir, hardenSecretPath } from "./lib/windows-secret-acl";
8
8
  import { providerDestinationConfigError } from "./lib/destination-policy";
9
- import type { OcxConfig } from "./types";
9
+ import { openRouterRoutingConfigError } from "./providers/openrouter-routing";
10
+ import { OPENAI_PROVIDER_TIER_VERSION, type OcxConfig } from "./types";
10
11
 
11
12
  let _atomicSeq = 0;
12
13
 
@@ -155,6 +156,28 @@ function isAlreadyExistsError(error: unknown): boolean {
155
156
  return (error as NodeJS.ErrnoException | undefined)?.code === "EEXIST";
156
157
  }
157
158
 
159
+ /**
160
+ * Classify an existing `.pre-openai-tiers-v2.bak` snapshot.
161
+ *
162
+ * - `"stale"`: unparseable JSON (not written by us / truncated) or already a
163
+ * post-migration (tier v2) snapshot — safe to delete or replace.
164
+ * - `"rollback"`: parses as a valid pre-migration (v1) config — a
165
+ * user-intentional rollback point that must never be silently destroyed.
166
+ *
167
+ * Shared by the startup migration backup path and `ocx init` cleanup so both
168
+ * apply the same preservation policy (issue #257 / sol review 260722).
169
+ */
170
+ export function classifyOpenAiTierBackup(backupBytes: Uint8Array): "stale" | "rollback" {
171
+ try {
172
+ // Use Buffer.from to ensure proper UTF-8 decoding from Uint8Array/Buffer.
173
+ const parsed = JSON.parse(Buffer.from(backupBytes).toString("utf8")) as Record<string, unknown>;
174
+ return parsed.openaiProviderTierVersion === 2 ? "stale" : "rollback";
175
+ } catch {
176
+ // Unparseable: not a config file we created, treat as stale.
177
+ return "stale";
178
+ }
179
+ }
180
+
158
181
  export function backupConfigBeforeOpenAiTierMigration(
159
182
  configPath = getConfigPath(),
160
183
  io: OpenAiTierBackupIO = {
@@ -178,8 +201,24 @@ export function backupConfigBeforeOpenAiTierMigration(
178
201
  // docs/fixtures and is never reused or overwritten as the v2 snapshot.
179
202
  const backup = `${source}.pre-openai-tiers-v2.bak`;
180
203
  if (io.exists(backup)) {
181
- if (!sameBytes(original, io.read(backup))) throw new OpenAiTierBackupCollisionError();
182
- return "reused";
204
+ if (!sameBytes(original, io.read(backup))) {
205
+ // The backup differs from the current config. Only treat it as stale when it is
206
+ // clearly not a user-intentional rollback point:
207
+ // - unparseable JSON: written by a different tool or truncated
208
+ // - already at tier version 2: the backup is from a post-migration config (e.g.
209
+ // ocx init wrote a fresh v2 config, making the old backup obsolete)
210
+ // A backup that parses as a valid pre-migration (v1) config is kept as-is and
211
+ // we throw a collision error, because silently replacing a user-created rollback
212
+ // point would be surprising and potentially destructive.
213
+ const backupBytes = io.read(backup);
214
+ if (classifyOpenAiTierBackup(backupBytes) === "rollback") {
215
+ throw new OpenAiTierBackupCollisionError();
216
+ }
217
+ console.warn("[openai-provider-migration] Replacing stale pre-migration backup (post-migration config was rewritten since last migration).");
218
+ io.unlink(backup);
219
+ } else {
220
+ return "reused";
221
+ }
183
222
  }
184
223
  const temp = `${backup}.ocx.${process.pid}.${++_atomicSeq}.tmp`;
185
224
  let published = false;
@@ -374,6 +413,20 @@ const configSchema = z.object({
374
413
  });
375
414
  }
376
415
  const provider = config.providers[name];
416
+ const openRouterRoutingError = openRouterRoutingConfigError(provider);
417
+ if (openRouterRoutingError) {
418
+ ctx.addIssue({
419
+ code: "custom",
420
+ path: [
421
+ "providers",
422
+ name,
423
+ openRouterRoutingError.startsWith("modelOpenRouterRouting")
424
+ ? "modelOpenRouterRouting"
425
+ : "openRouterRouting",
426
+ ],
427
+ message: openRouterRoutingError,
428
+ });
429
+ }
377
430
  if (Object.hasOwn(provider, "virtualModels")) {
378
431
  ctx.addIssue({
379
432
  code: "custom",
@@ -641,6 +694,10 @@ export function getDefaultConfig(): OcxConfig {
641
694
  // Adding extra providers (e.g. opencode-go) and switching defaultProvider is a user/runtime choice.
642
695
  return {
643
696
  port: 10100,
697
+ // Fresh/re-initialized configs are already written in the current three-tier
698
+ // OpenAI shape. Mark them as such so startup does not mistake them for a
699
+ // legacy config and collide with an immutable backup from an earlier setup.
700
+ openaiProviderTierVersion: OPENAI_PROVIDER_TIER_VERSION,
644
701
  providers: {
645
702
  openai: {
646
703
  adapter: "openai-responses",
@@ -148,6 +148,27 @@ export interface TransientRetryOptions extends ResetRetryOptions {
148
148
  export type UpstreamSendRecovery = "connection-reset" | "transient-5xx";
149
149
  type ReplayableFetch = (recovery?: UpstreamSendRecovery) => Promise<Response>;
150
150
 
151
+ /**
152
+ * Opt out of Bun's keep-alive pool after a connection-reset retry.
153
+ *
154
+ * Prefer the Bun fetch extension `keepalive: false` (transport-level) over
155
+ * relying on the hop-by-hop `Connection: close` header alone — Bun has ignored
156
+ * that header in past releases (oven-sh/bun#20492), so a header-only retry can
157
+ * still reuse the same half-closed pooled socket. Still set Connection: close
158
+ * as a belt-and-suspenders signal for intermediaries that honor it.
159
+ */
160
+ export function applyUpstreamRecoveryInit<T extends RequestInit>(
161
+ init: T,
162
+ recovery?: UpstreamSendRecovery,
163
+ ): T & { headers: Headers } {
164
+ const headers = new Headers(init.headers);
165
+ if (recovery !== "connection-reset") {
166
+ return { ...init, headers };
167
+ }
168
+ headers.set("connection", "close");
169
+ return { ...init, headers, keepalive: false };
170
+ }
171
+
151
172
  /**
152
173
  * Run `doFetch`, retrying only connection-reset-shaped rejections (see
153
174
  * isConnectionResetError) with jittered backoff. The caller's thunk must be replay-safe
package/src/lib/winsw.ts CHANGED
@@ -223,7 +223,13 @@ export function probeScmRegistration(run: () => string = queryScmForService): bo
223
223
  const text = [e.stderr, e.stdout, e.message]
224
224
  .map(v => (typeof v === "string" ? v : ""))
225
225
  .join("\n");
226
- if (e.status === 1060 || /FAILED 1060/i.test(text)) return false;
226
+ // ERROR_SERVICE_DOES_NOT_EXIST: the numeric identifier 1060 is locale-invariant
227
+ // (FALHA 1060 pt-BR, localized ko output, English FAILED 1060). The query is a
228
+ // fixed `sc.exe query <service>` so a standalone 1060 in its output is proof of
229
+ // absence. Bun may deliver e.status as 36 (1060 & 0xff) — status 36 ALONE is
230
+ // NOT accepted (collides with any status ≡ 36 mod 256); the textual 1060 is
231
+ // required corroboration and covers those hosts.
232
+ if (e.status === 1060 || /\b1060\b/.test(text)) return false;
227
233
  return "error";
228
234
  }
229
235
  }
@@ -34,6 +34,17 @@ interface AnthropicTokenResponse {
34
34
  account?: { uuid?: string; email_address?: string };
35
35
  }
36
36
 
37
+ export class AnthropicTokenError extends Error {
38
+ constructor(
39
+ message: string,
40
+ readonly httpStatus: number | undefined,
41
+ readonly oauthError: string | undefined,
42
+ ) {
43
+ super(message);
44
+ this.name = "AnthropicTokenError";
45
+ }
46
+ }
47
+
37
48
  async function postJson(url: string, body: Record<string, string | number>): Promise<string> {
38
49
  const response = await fetch(url, {
39
50
  method: "POST",
@@ -43,7 +54,18 @@ async function postJson(url: string, body: Record<string, string | number>): Pro
43
54
  });
44
55
  const responseBody = await response.text();
45
56
  if (!response.ok) {
46
- throw new Error(`Anthropic OAuth HTTP ${response.status}: ${responseBody}`);
57
+ let oauthError: string | undefined;
58
+ try {
59
+ const parsed = JSON.parse(responseBody) as { error?: unknown };
60
+ if (typeof parsed.error === "string") oauthError = parsed.error;
61
+ } catch {
62
+ // Best-effort only: malformed error bodies remain structured by HTTP status.
63
+ }
64
+ throw new AnthropicTokenError(
65
+ `Anthropic OAuth HTTP ${response.status}: ${responseBody}`,
66
+ response.status,
67
+ oauthError,
68
+ );
47
69
  }
48
70
  return responseBody;
49
71
  }
@@ -395,6 +395,7 @@ export async function loginGithubCopilot(ctrl: OAuthController): Promise<OAuthCr
395
395
  ctrl.onAuth?.({
396
396
  url: device.verifyUrl,
397
397
  instructions: `Enter code: ${device.userCode}`,
398
+ deviceCode: device.userCode,
398
399
  });
399
400
  ctrl.onProgress?.("Waiting for GitHub device authorization…");
400
401
  const github = await pollGithubDeviceToken(
@@ -3,9 +3,9 @@ import { parseCallbackInput } from "./callback-server";
3
3
  import type { OcxConfig, OcxProviderConfig, RefreshPolicy } from "../types";
4
4
  import { loadConfig, resolveEnvValue, saveConfig } from "../config";
5
5
  import { maskEmail } from "../lib/privacy";
6
- import { getAccountCredential, getAccountSet, saveAccountCredential, saveCredential, markAccountNeedsReauth, getCredential, credentialGeneration, createOAuthRefreshIntentLock, mergeAccountCredential, markAccountNeedsReauthIfGeneration } from "./store";
6
+ import { getAccountCredential, getAccountSet, saveAccountCredential, saveCredential, markAccountNeedsReauth, getCredential, credentialGeneration, createOAuthRefreshIntentLock, mergeAccountCredential, markAccountNeedsReauthIfGeneration, readOAuthRefreshIntent, writeOAuthRefreshIntent, clearOAuthRefreshIntent } from "./store";
7
7
  import { loginXai, refreshXaiToken, XAI_LOCAL_CLI_DETACH_WARNING, XaiTokenRequestError } from "./xai";
8
- import { ANTHROPIC_OAUTH_BETA, loginAnthropic, refreshAnthropicToken } from "./anthropic";
8
+ import { ANTHROPIC_OAUTH_BETA, AnthropicTokenError, loginAnthropic, refreshAnthropicToken } from "./anthropic";
9
9
  import { loginKimi, refreshKimiToken } from "./kimi";
10
10
  import { loginKiro, readKiroCliSqlite, refreshKiroToken } from "./kiro";
11
11
  import { loginChatGPT, refreshChatGPTToken } from "./chatgpt";
@@ -15,7 +15,7 @@ import { loginGithubCopilot, refreshGithubCopilotToken, validateCopilotApiBaseUr
15
15
  import { deriveOAuthDefaultModel, deriveOAuthProviderConfig } from "../providers/derive";
16
16
  import { effectiveGoogleMode } from "../providers/registry";
17
17
  import { resolveProviderTransport } from "../providers/xai-transport";
18
- import { detectGrokCliToken, hasComparableGrokIdentity, isSameGrokIdentity, shouldAdoptGrokGeneration } from "./local-token-detect";
18
+ import { detectClaudeCodeToken, detectGrokCliToken, hasComparableGrokIdentity, isSameGrokIdentity, shouldAdoptGrokGeneration } from "./local-token-detect";
19
19
 
20
20
  const REFRESH_SKEW_MS = 60_000;
21
21
  export interface OAuthAccessSnapshot {
@@ -29,6 +29,7 @@ const tokenRefreshes = new Map<string, Promise<OAuthAccessSnapshot>>();
29
29
  const XAI_PERMANENT_FAILURE_TTL_MS=30_000;
30
30
  const permanentRefreshFailures=new Map<string,number>();
31
31
  interface XaiRefreshDeps { intentLock?:ReturnType<typeof createOAuthRefreshIntentLock>; now?:()=>number; afterPrePersistRead?:()=>void|Promise<void> }
32
+ interface AnthropicRefreshDeps { intentLock?:ReturnType<typeof createOAuthRefreshIntentLock>; now?:()=>number; afterPrePersistRead?:()=>void|Promise<void> }
32
33
  function verdictKey(p:string,a:string,c:OAuthCredentials){return `${p}\0${a}\0${credentialGeneration(c)}`;}
33
34
  function cached(p:string,a:string,c:OAuthCredentials,now:()=>number){const k=verdictKey(p,a,c),u=permanentRefreshFailures.get(k);if(u===undefined)return false;if(u<=now()){permanentRefreshFailures.delete(k);return false;}return true;}
34
35
 
@@ -257,7 +258,11 @@ function isTerminalRefreshError(err: unknown): boolean {
257
258
  || msg.includes("access_denied")
258
259
  || msg.includes("expired_token");
259
260
  }
260
- function terminal(error:unknown):boolean{return error instanceof XaiTokenRequestError?["invalid_grant","refresh_token_reused","revoked_token"].includes(error.oauthError??""):isTerminalRefreshError(error);}
261
+ function terminal(error:unknown):boolean{
262
+ if(error instanceof XaiTokenRequestError)return ["invalid_grant","refresh_token_reused","revoked_token"].includes(error.oauthError??"");
263
+ if(error instanceof AnthropicTokenError)return (error.httpStatus===400||error.httpStatus===401)&&["invalid_grant","refresh_token_reused","revoked","revoked_token","refresh_token_revoked"].includes(error.oauthError??"");
264
+ return isTerminalRefreshError(error);
265
+ }
261
266
  function authoritative(stored:OAuthCredentials,active:boolean,now:()=>number):OAuthCredentials{if(stored.source!=="local-cli")return stored;const disk=detectGrokCliToken();if(!disk)return stored;const allowed=isSameGrokIdentity(stored,disk)||(active&&!hasComparableGrokIdentity(stored,disk));return allowed&&shouldAdoptGrokGeneration(stored,disk,now(),REFRESH_SKEW_MS)?disk:stored;}
262
267
  function merged(fresh: OAuthCredentials, previous: OAuthCredentials): OAuthCredentials {
263
268
  return {
@@ -271,6 +276,79 @@ function merged(fresh: OAuthCredentials, previous: OAuthCredentials): OAuthCrede
271
276
  }
272
277
  export async function refreshXaiAccountWithLock(provider:string,accountId:string,def:OAuthProviderDef,callerCredential:OAuthCredentials,deps:XaiRefreshDeps={}):Promise<string>{const now=deps.now??Date.now;const guard=await(deps.intentLock??createOAuthRefreshIntentLock(provider,accountId)).acquire();try{const stored=getAccountCredential(provider,accountId);if(!stored)throw new OAuthLoginRequiredError(provider);const active=getAccountSet(provider)?.activeAccountId===accountId,candidate=authoritative(stored,active,now);if(credentialGeneration(candidate)!==credentialGeneration(callerCredential)&&candidate.expires>now()+REFRESH_SKEW_MS){if(credentialGeneration(candidate)!==credentialGeneration(stored)){const o=await mergeAccountCredential(provider,accountId,candidate,{expectedGeneration:credentialGeneration(stored),afterPrePersistRead:deps.afterPrePersistRead});if(o.superseded){if(o.stored.expires>now()+REFRESH_SKEW_MS)return o.stored.access;throw new OAuthLoginRequiredError(provider);}}return candidate.access;}if(cached(provider,accountId,candidate,now))throw new OAuthLoginRequiredError(provider);const generation=credentialGeneration(candidate);try{const fresh=merged(await def.refresh(candidate.refresh),candidate);const o=await mergeAccountCredential(provider,accountId,fresh,{expectedGeneration:generation,afterPrePersistRead:deps.afterPrePersistRead});if(o.superseded){if(o.stored.expires>now()+REFRESH_SKEW_MS)return o.stored.access;throw new OAuthLoginRequiredError(provider);}permanentRefreshFailures.delete(verdictKey(provider,accountId,candidate));if(candidate.source==="local-cli")console.warn(XAI_LOCAL_CLI_DETACH_WARNING);return fresh.access;}catch(error){if(!terminal(error))throw error;permanentRefreshFailures.set(verdictKey(provider,accountId,candidate),now()+XAI_PERMANENT_FAILURE_TTL_MS);await markAccountNeedsReauthIfGeneration(provider,accountId,generation);throw new OAuthLoginRequiredError(provider);}}finally{guard.release();}}
273
278
 
279
+ function newerClaudeCredential(stored: OAuthCredentials, now: number): OAuthCredentials | undefined {
280
+ if (stored.source !== "local-cli") return undefined;
281
+ const disk = detectClaudeCodeToken();
282
+ if (!disk || disk.expires <= now + REFRESH_SKEW_MS) return undefined;
283
+ return credentialGeneration(disk) !== credentialGeneration(stored) ? disk : undefined;
284
+ }
285
+
286
+ export async function refreshAnthropicAccountWithLock(
287
+ provider: string,
288
+ accountId: string,
289
+ def: OAuthProviderDef,
290
+ callerCredential: OAuthCredentials,
291
+ deps: AnthropicRefreshDeps = {},
292
+ ): Promise<string> {
293
+ const now = deps.now ?? Date.now;
294
+ const guard = await (deps.intentLock ?? createOAuthRefreshIntentLock(provider, accountId)).acquire();
295
+ try {
296
+ const stored = getAccountCredential(provider, accountId);
297
+ if (!stored) throw new OAuthLoginRequiredError(provider);
298
+ const account = getAccountSet(provider)?.accounts.find(candidate => candidate.id === accountId);
299
+ const generation = credentialGeneration(stored);
300
+ const pendingIntent = readOAuthRefreshIntent(provider, accountId);
301
+ const disk = newerClaudeCredential(stored, now());
302
+ if (disk) {
303
+ const outcome = await mergeAccountCredential(provider, accountId, disk, {
304
+ expectedGeneration: credentialGeneration(stored),
305
+ afterPrePersistRead: deps.afterPrePersistRead,
306
+ });
307
+ if (outcome.superseded) {
308
+ if (pendingIntent) clearOAuthRefreshIntent(provider, accountId, pendingIntent.generation);
309
+ if (outcome.stored.expires > now() + REFRESH_SKEW_MS) return outcome.stored.access;
310
+ throw new OAuthLoginRequiredError(provider);
311
+ }
312
+ if (pendingIntent) clearOAuthRefreshIntent(provider, accountId, pendingIntent.generation);
313
+ return disk.access;
314
+ }
315
+ if (pendingIntent?.uncertain || pendingIntent?.generation === generation) {
316
+ await markAccountNeedsReauthIfGeneration(provider, accountId, generation);
317
+ throw new OAuthLoginRequiredError(provider);
318
+ }
319
+ if (pendingIntent) clearOAuthRefreshIntent(provider, accountId, pendingIntent.generation);
320
+ if (account?.needsReauth) {
321
+ throw new OAuthLoginRequiredError(provider);
322
+ }
323
+ if (credentialGeneration(stored) !== credentialGeneration(callerCredential) && stored.expires > now() + REFRESH_SKEW_MS) {
324
+ return stored.access;
325
+ }
326
+
327
+ try {
328
+ writeOAuthRefreshIntent(provider, accountId, generation, now());
329
+ const fresh = merged(await def.refresh(stored.refresh), stored);
330
+ const outcome = await mergeAccountCredential(provider, accountId, fresh, {
331
+ expectedGeneration: generation,
332
+ afterPrePersistRead: deps.afterPrePersistRead,
333
+ });
334
+ if (outcome.superseded) {
335
+ clearOAuthRefreshIntent(provider, accountId, generation);
336
+ if (outcome.stored.expires > now() + REFRESH_SKEW_MS) return outcome.stored.access;
337
+ throw new OAuthLoginRequiredError(provider);
338
+ }
339
+ clearOAuthRefreshIntent(provider, accountId, generation);
340
+ return fresh.access;
341
+ } catch (error) {
342
+ if (!terminal(error)) throw error;
343
+ await markAccountNeedsReauthIfGeneration(provider, accountId, generation);
344
+ clearOAuthRefreshIntent(provider, accountId, generation);
345
+ throw new OAuthLoginRequiredError(provider);
346
+ }
347
+ } finally {
348
+ guard.release();
349
+ }
350
+ }
351
+
274
352
  async function refreshAndPersistAccessToken(
275
353
  provider: string,
276
354
  accountId: string,
@@ -288,6 +366,7 @@ async function refreshAndPersistAccessToken(
288
366
  }
289
367
  }
290
368
  if (provider === "xai") return refreshXaiAccountWithLock(provider, accountId, def, cred);
369
+ if (provider === "anthropic") return refreshAnthropicAccountWithLock(provider, accountId, def, cred);
291
370
  try {
292
371
  const fresh = await def.refresh(cred.refresh);
293
372
  const detachedLocalCli = provider === "xai" && cred.source === "local-cli";
@@ -567,7 +646,7 @@ export function submitManualLoginCode(provider: string, input: string): { ok: tr
567
646
  return { ok: true };
568
647
  }
569
648
 
570
- export interface OAuthAccountSummary { id: string; email?: string; active: boolean; needsReauth?: boolean; expiresAt?: number }
649
+ export interface OAuthAccountSummary { id: string; alias?: string; email?: string; active: boolean; needsReauth?: boolean; expiresAt?: number }
571
650
 
572
651
  export function getLoginStatus(provider: string): { loggedIn: boolean; email?: string; source?: OAuthCredentials["source"]; error?: string; done: boolean; activeAccountId?: string; accounts?: OAuthAccountSummary[] } {
573
652
  const cred = getCredential(provider);
@@ -575,6 +654,7 @@ export function getLoginStatus(provider: string): { loggedIn: boolean; email?: s
575
654
  const set = getAccountSet(provider);
576
655
  const accounts: OAuthAccountSummary[] | undefined = set?.accounts.map(a => ({
577
656
  id: a.id,
657
+ ...(a.alias ? { alias: a.alias } : {}),
578
658
  email: maskEmail(a.credential.email) ?? undefined,
579
659
  active: a.id === set.activeAccountId,
580
660
  ...(a.needsReauth ? { needsReauth: true } : {}),
@@ -616,7 +696,7 @@ export function cancelLoginFlow(provider: string): boolean {
616
696
  return true;
617
697
  }
618
698
 
619
- export async function startLoginFlow(provider: string, opts?: LoginOpts): Promise<{ url: string; instructions?: string }> {
699
+ export async function startLoginFlow(provider: string, opts?: LoginOpts): Promise<{ url: string; instructions?: string; deviceCode?: string }> {
620
700
  const def = OAUTH_PROVIDERS[provider];
621
701
  if (!def) throw new UnsupportedOAuthProviderError(provider);
622
702
  const existing = loginState.get(provider);
@@ -630,9 +710,9 @@ export async function startLoginFlow(provider: string, opts?: LoginOpts): Promis
630
710
  return new Promise((resolve, reject) => {
631
711
  let urlResolved = false;
632
712
  const ctrl: OAuthController = {
633
- onAuth: ({ url, instructions }) => {
713
+ onAuth: ({ url, instructions, deviceCode }) => {
634
714
  urlResolved = true;
635
- resolve({ url, instructions });
715
+ resolve({ url, instructions, deviceCode });
636
716
  },
637
717
  onProgress: () => {},
638
718
  // GUI fallback when the browser cannot hit the loopback callback server.
package/src/oauth/kiro.ts CHANGED
@@ -50,7 +50,9 @@ export function readKiroCliSqlite(): ImportedKiroToken | null {
50
50
 
51
51
  /**
52
52
  * Import-first login: kiro-cli SQLite → KIRO_ACCESS_TOKEN env → manual paste (CLI only).
53
- * In GUI (no onManualCodeInput) with no SQLite token and no env, throws a clear error never hangs.
53
+ * When no local token is available, resolves the login flow via onAuth with instructions
54
+ * so the GUI renders the paste-input field, then blocks on onManualCodeInput for the token.
55
+ * If neither onAuth nor onManualCodeInput is available, throws a clear error.
54
56
  */
55
57
  export async function loginKiro(ctrl: OAuthController): Promise<OAuthCredentials> {
56
58
  const imported = readImportedKiroCredential();
@@ -71,6 +73,15 @@ export async function loginKiro(ctrl: OAuthController): Promise<OAuthCredentials
71
73
  }
72
74
 
73
75
  if (ctrl.onManualCodeInput) {
76
+ // Resolve the login flow immediately so the GUI receives instructions and
77
+ // shows the paste-input field. Without this, onManualCodeInput blocks
78
+ // forever and the HTTP response never reaches the dashboard.
79
+ ctrl.onAuth?.({
80
+ url: "",
81
+ instructions:
82
+ "No kiro-cli token found. Paste a Kiro access token below (starts with 'aoa'). " +
83
+ "Run `kiro-cli login` first, or set KIRO_ACCESS_TOKEN.",
84
+ });
74
85
  ctrl.onProgress?.("No kiro-cli token found. Paste a Kiro access token (starts with 'aoa').");
75
86
  const raw = (await ctrl.onManualCodeInput()).trim();
76
87
  if (raw) return { access: raw, refresh: "", expires: Date.now() + 3600_000, source: "manual" };
@@ -97,6 +97,9 @@ export function readClaudeCredentialsFile(): string | null {
97
97
 
98
98
  /** Keychain first on macOS, then the cross-platform credentials file. */
99
99
  function readClaudeSecureStorage(): string | null {
100
+ // An explicit config-dir override identifies a separate Claude installation/profile.
101
+ // Do not let the default macOS Keychain entry shadow that requested credential file.
102
+ if (process.env.CLAUDE_CONFIG_DIR?.trim()) return readClaudeCredentialsFile() ?? readClaudeKeychain();
100
103
  return readClaudeKeychain() ?? readClaudeCredentialsFile();
101
104
  }
102
105