@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.
- package/README.ja.md +438 -0
- package/README.ko.md +1 -1
- package/README.md +1 -1
- package/README.ru.md +480 -0
- package/README.zh-CN.md +1 -1
- package/bin/ocx.mjs +18 -1
- package/gui/dist/assets/index-B79f-04T.js +52 -0
- package/gui/dist/assets/index-D6Fcl4yM.css +1 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/anthropic.ts +17 -2
- package/src/adapters/cursor/discovery.ts +2 -2
- package/src/adapters/google-tool-schema.ts +4 -0
- package/src/adapters/google.ts +17 -2
- package/src/adapters/openai-chat.ts +36 -1
- package/src/adapters/openai-responses.ts +2 -1
- package/src/bridge.ts +12 -4
- package/src/cli/account-api.ts +4 -2
- package/src/cli/account-extended.ts +34 -0
- package/src/cli/account.ts +3 -1
- package/src/cli/claude.ts +6 -1
- package/src/cli/help.ts +25 -4
- package/src/cli/init.ts +38 -3
- package/src/cli/models.ts +206 -7
- package/src/codex/auth-api.ts +45 -3
- package/src/codex/catalog.ts +105 -12
- package/src/codex/routing.ts +85 -4
- package/src/combos/index.ts +1 -1
- package/src/combos/request.ts +26 -4
- package/src/combos/types.ts +4 -4
- package/src/config.ts +60 -3
- package/src/lib/upstream-retry.ts +21 -0
- package/src/lib/winsw.ts +7 -1
- package/src/oauth/anthropic.ts +23 -1
- package/src/oauth/github-copilot.ts +1 -0
- package/src/oauth/index.ts +88 -8
- package/src/oauth/kiro.ts +12 -1
- package/src/oauth/local-token-detect.ts +3 -0
- package/src/oauth/store.ts +43 -0
- package/src/oauth/types.ts +3 -1
- package/src/providers/antigravity-models.ts +123 -14
- package/src/providers/api-keys.ts +12 -0
- package/src/providers/openrouter-routing.ts +102 -0
- package/src/providers/registry.ts +41 -17
- package/src/router.ts +2 -1
- package/src/server/auth-cors.ts +5 -0
- package/src/server/index.ts +3 -3
- package/src/server/management-api.ts +143 -6
- package/src/server/relay.ts +31 -5
- package/src/server/request-log.ts +12 -0
- package/src/server/responses.ts +127 -31
- package/src/service.ts +12 -3
- package/src/types.ts +44 -3
- package/src/update/index.ts +19 -2
- package/src/update/job.ts +13 -3
- package/src/usage/expected-prices.ts +22 -9
- package/src/usage/summary.ts +43 -0
- package/gui/dist/assets/index-B-UauL1p.css +0 -1
- package/gui/dist/assets/index-avcinRsG.js +0 -40
package/src/codex/routing.ts
CHANGED
|
@@ -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)
|
|
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
|
|
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
|
|
527
|
+
consecutiveFailures,
|
|
462
528
|
lastFailureStatus,
|
|
463
529
|
lastFailureAt: now,
|
|
464
|
-
...(
|
|
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
|
|
package/src/combos/index.ts
CHANGED
package/src/combos/request.ts
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/src/combos/types.ts
CHANGED
|
@@ -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 ??
|
|
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 ??
|
|
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
|
|
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)))
|
|
182
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/oauth/anthropic.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
package/src/oauth/index.ts
CHANGED
|
@@ -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{
|
|
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
|
-
*
|
|
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
|
|