@bitkyc08/opencodex 2.8.0 → 2.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -0
- package/bin/ocx.mjs +32 -4
- package/gui/dist/assets/index-CHwf3tTD.css +1 -0
- package/gui/dist/assets/index-u5eFOv2y.js +67 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/anthropic-image-normalize.ts +114 -20
- package/src/adapters/anthropic.ts +126 -10
- package/src/adapters/azure.ts +3 -3
- package/src/adapters/base.ts +7 -3
- package/src/adapters/cursor/discovery.ts +14 -4
- package/src/adapters/cursor/effort-map.ts +18 -6
- package/src/adapters/cursor/framing.ts +102 -27
- package/src/adapters/cursor/kv-store.ts +30 -3
- package/src/adapters/cursor/live-models.ts +22 -2
- package/src/adapters/cursor/live-transport.ts +245 -49
- package/src/adapters/cursor/mcp-manager.ts +105 -8
- package/src/adapters/cursor/native-exec-mcp.ts +5 -3
- package/src/adapters/cursor/native-exec-shell.ts +296 -14
- package/src/adapters/cursor/native-exec.ts +381 -33
- package/src/adapters/cursor/protobuf-events.ts +28 -1
- package/src/adapters/cursor/protobuf-request.ts +71 -39
- package/src/adapters/cursor/request-builder.ts +2 -2
- package/src/adapters/cursor/transport.ts +2 -0
- package/src/adapters/cursor.ts +13 -2
- package/src/adapters/google-antigravity-replay.ts +184 -17
- package/src/adapters/google.ts +58 -8
- package/src/adapters/kiro-thinking.ts +23 -9
- package/src/adapters/kiro-tools.ts +49 -18
- package/src/adapters/kiro.ts +377 -133
- package/src/adapters/mimo-free.ts +36 -4
- package/src/adapters/openai-chat.ts +143 -17
- package/src/adapters/openai-responses.ts +130 -14
- package/src/adapters/run-turn-queue.ts +7 -1
- package/src/bridge.ts +466 -69
- package/src/chat/outbound.ts +144 -38
- package/src/claude/inbound-debug.ts +53 -8
- package/src/claude/outbound.ts +224 -38
- package/src/cli/agent-driven.ts +34 -1
- package/src/cli/catalog-prewarm.ts +5 -2
- package/src/cli/claude-desktop.ts +2 -2
- package/src/cli/doctor.ts +12 -0
- package/src/cli/export-command.ts +187 -0
- package/src/cli/help.ts +11 -0
- package/src/cli/index.ts +13 -3
- package/src/cli/init.ts +129 -102
- package/src/cli/opencode.ts +36 -151
- package/src/cli/star-prompt.ts +13 -4
- package/src/cli/status-oauth.ts +12 -2
- package/src/clients/config-export.ts +377 -0
- package/src/codex/account-runtime-state.ts +19 -1
- package/src/codex/account-store.ts +162 -82
- package/src/codex/auth-api.ts +467 -159
- package/src/codex/auth-context.ts +15 -2
- package/src/codex/catalog/aggregation.ts +15 -0
- package/src/codex/catalog/effort.ts +16 -6
- package/src/codex/catalog/metadata.ts +6 -0
- package/src/codex/catalog/parsing.ts +3 -1
- package/src/codex/catalog/provider-fetch.ts +29 -0
- package/src/codex/catalog/sync.ts +64 -7
- package/src/codex/catalog.ts +2 -2
- package/src/codex/inject.ts +5 -5
- package/src/codex/main-account-cache.ts +8 -1
- package/src/codex/model-cache.ts +81 -2
- package/src/codex/pool-rotation.ts +39 -0
- package/src/codex/project-config-warnings.ts +12 -1
- package/src/codex/quota.ts +35 -3
- package/src/codex/routing.ts +46 -1
- package/src/codex/shim.ts +10 -4
- package/src/codex/subagent-model-fallback.ts +12 -0
- package/src/codex/websocket-registry.ts +27 -0
- package/src/combos/failover.ts +31 -1
- package/src/combos/request.ts +9 -0
- package/src/combos/resolve.ts +60 -4
- package/src/combos/types.ts +12 -0
- package/src/config.ts +510 -55
- package/src/github/star-state.ts +13 -1
- package/src/images/fulfill.ts +39 -1
- package/src/images/loop.ts +52 -12
- package/src/lib/admission.ts +83 -0
- package/src/lib/app-owned-memory-stores.ts +173 -0
- package/src/lib/app-owned-memory.ts +265 -0
- package/src/lib/bun-stream-caps.ts +31 -7
- package/src/lib/config-ownership.ts +33 -0
- package/src/lib/crash-guard.ts +65 -5
- package/src/lib/debug-log-buffer.ts +47 -6
- package/src/lib/destination-policy.ts +12 -1
- package/src/lib/errors.ts +3 -0
- package/src/lib/gcp-adc.ts +40 -2
- package/src/lib/injection-debug-log.ts +26 -2
- package/src/lib/provider-outbound.ts +3 -0
- package/src/lib/sidecar-tracker.ts +5 -2
- package/src/lib/sse-decoder.ts +257 -37
- package/src/lib/state-store-registrations.ts +109 -0
- package/src/lib/state-store-sweeper.ts +184 -0
- package/src/lib/translator-budget.ts +356 -0
- package/src/lib/windows-secret-acl.ts +33 -12
- package/src/lib/winsw.ts +14 -1
- package/src/oauth/anthropic-routing.ts +31 -7
- package/src/oauth/google-antigravity.ts +2 -1
- package/src/oauth/health.ts +30 -12
- package/src/oauth/index.ts +127 -23
- package/src/oauth/kiro-credentials.ts +72 -1
- package/src/oauth/kiro.ts +23 -4
- package/src/oauth/store.ts +165 -18
- package/src/oauth/token-guardian.ts +43 -4
- package/src/oauth/types.ts +2 -1
- package/src/providers/base-url-choices.ts +10 -0
- package/src/providers/derive.ts +12 -0
- package/src/providers/free-directory.ts +4 -1
- package/src/providers/key-failover.ts +12 -0
- package/src/providers/openai-sidecar.ts +4 -1
- package/src/providers/quota.ts +68 -7
- package/src/providers/registry.ts +279 -3
- package/src/responses/parser.ts +5 -1
- package/src/responses/spill-store.ts +394 -0
- package/src/responses/state.ts +520 -102
- package/src/router.ts +18 -1
- package/src/server/adapter-resolve.ts +20 -3
- package/src/server/auth-cors.ts +121 -28
- package/src/server/chat-completions.ts +57 -12
- package/src/server/claude-messages.ts +85 -13
- package/src/server/index.ts +242 -100
- package/src/server/lifecycle.ts +155 -25
- package/src/server/management/agent-settings-routes.ts +79 -36
- package/src/server/management/api-key-usage.ts +167 -0
- package/src/server/management/body.ts +35 -0
- package/src/server/management/combo-routes.ts +5 -1
- package/src/server/management/config-routes.ts +42 -12
- package/src/server/management/logs-usage-routes.ts +41 -21
- package/src/server/management/model-routes.ts +188 -54
- package/src/server/management/oauth-account-routes.ts +115 -26
- package/src/server/management/provider-routes.ts +56 -6
- package/src/server/management/shared.ts +16 -3
- package/src/server/management/sidebar-routes.ts +50 -1
- package/src/server/management/system-restart.ts +13 -6
- package/src/server/management/system-routes.ts +15 -3
- package/src/server/management/usage-summary-cache.ts +86 -0
- package/src/server/management-api.ts +39 -5
- package/src/server/management-auth.ts +65 -14
- package/src/server/port-reclaim.ts +58 -12
- package/src/server/ports.ts +2 -0
- package/src/server/proxy-liveness.ts +60 -14
- package/src/server/relay-eager.ts +20 -4
- package/src/server/relay.ts +548 -154
- package/src/server/request-decompress.ts +51 -4
- package/src/server/request-log.ts +134 -15
- package/src/server/responses/collaboration.ts +15 -4
- package/src/server/responses/compact.ts +3 -0
- package/src/server/responses/core.ts +241 -66
- package/src/server/responses-image-gen-repair.ts +19 -5
- package/src/server/responses-item-id-repair.ts +23 -5
- package/src/server/sse-payload-rewrite.ts +71 -12
- package/src/server/startup-health-cache.ts +14 -1
- package/src/server/system-env.ts +8 -1
- package/src/server/windows-tcp-drop.ts +15 -5
- package/src/server/ws-bridge.ts +25 -0
- package/src/service.ts +179 -13
- package/src/storage/policy-job.ts +93 -23
- package/src/storage/policy-worker.ts +6 -0
- package/src/storage/restore-job.ts +62 -16
- package/src/storage/restore-worker.ts +6 -0
- package/src/storage/storage-mutation-coordinator.ts +36 -6
- package/src/storage/worker-lifecycle.ts +181 -47
- package/src/tray/windows.ts +97 -25
- package/src/types.ts +39 -6
- package/src/update/index.ts +24 -5
- package/src/update/job.ts +598 -73
- package/src/usage/log.ts +115 -17
- package/src/usage/summary.ts +67 -2
- package/src/vision/index.ts +112 -22
- package/src/web-search/loop.ts +38 -6
- package/src/web-search/progress-stream.ts +14 -3
- package/gui/dist/assets/index-BDjpkcRN.js +0 -67
- package/gui/dist/assets/index-BHsKRFh9.css +0 -1
|
@@ -28,12 +28,15 @@ import {
|
|
|
28
28
|
seedPoolRotationAccount,
|
|
29
29
|
} from "../codex/pool-rotation";
|
|
30
30
|
import type { OcxAccountPoolRotationStrategy, OcxConfig } from "../types";
|
|
31
|
+
import { sweepExpiredOnWrite } from "../lib/state-store-sweeper";
|
|
32
|
+
import { retainedUtf8Bytes } from "../lib/admission";
|
|
31
33
|
|
|
32
34
|
const PROVIDER = "anthropic";
|
|
33
35
|
const DEFAULT_COOLDOWN_MS = 60_000;
|
|
34
36
|
const MAX_COOLDOWN_MS = 15 * 60_000;
|
|
35
37
|
const AFFINITY_IDLE_TTL_MS = 24 * 60 * 60_000;
|
|
36
38
|
const MAX_AFFINITY_ENTRIES = 2_000;
|
|
39
|
+
const MAX_AFFINITY_COMPONENT_BYTES = 512;
|
|
37
40
|
const UNKNOWN_USAGE_SCORE = 100;
|
|
38
41
|
const DEFAULT_AUTO_SWITCH_THRESHOLD = 80;
|
|
39
42
|
/** Cap same-request 429 rotations so short Retry-After cannot infinite-loop. */
|
|
@@ -62,6 +65,11 @@ interface AffinityEntry {
|
|
|
62
65
|
const upstreamHealth = new Map<string, AccountHealth>();
|
|
63
66
|
const sessionAffinity = new Map<string, AffinityEntry>();
|
|
64
67
|
|
|
68
|
+
function normalizeAffinityComponent(value: string | null | undefined): string {
|
|
69
|
+
const normalized = value?.trim() ?? "";
|
|
70
|
+
return normalized && retainedUtf8Bytes(normalized) <= MAX_AFFINITY_COMPONENT_BYTES ? normalized : "";
|
|
71
|
+
}
|
|
72
|
+
|
|
65
73
|
export function anthropicAccountPoolConfig(config: OcxConfig): AnthropicAccountPoolConfig {
|
|
66
74
|
const raw = config.anthropicAccountPool;
|
|
67
75
|
if (!raw || typeof raw !== "object") return {};
|
|
@@ -110,12 +118,26 @@ export function clearAnthropicAccountCooldown(accountId: string): boolean {
|
|
|
110
118
|
return upstreamHealth.delete(accountId);
|
|
111
119
|
}
|
|
112
120
|
|
|
121
|
+
export function sweepExpiredAnthropicRoutingHealth(now = Date.now()): number {
|
|
122
|
+
let removed = 0;
|
|
123
|
+
for (const [accountId, health] of upstreamHealth) {
|
|
124
|
+
if (health.cooldownUntil > now) continue;
|
|
125
|
+
upstreamHealth.delete(accountId);
|
|
126
|
+
removed += 1;
|
|
127
|
+
}
|
|
128
|
+
return removed;
|
|
129
|
+
}
|
|
130
|
+
|
|
113
131
|
/** Test / logout helper. */
|
|
114
132
|
export function clearAnthropicAccountPoolState(): void {
|
|
115
133
|
upstreamHealth.clear();
|
|
116
134
|
sessionAffinity.clear();
|
|
117
135
|
}
|
|
118
136
|
|
|
137
|
+
export function anthropicSessionAffinitySizeForTests(): number {
|
|
138
|
+
return sessionAffinity.size;
|
|
139
|
+
}
|
|
140
|
+
|
|
119
141
|
function isCooled(accountId: string, now: number): boolean {
|
|
120
142
|
return getAnthropicAccountHealthSnapshot(accountId, now) !== null;
|
|
121
143
|
}
|
|
@@ -344,7 +366,7 @@ export function resolveAnthropicAccountForSession(
|
|
|
344
366
|
return { accountId: set.activeAccountId, reason: "pool-disabled" };
|
|
345
367
|
}
|
|
346
368
|
|
|
347
|
-
const key = sessionKey
|
|
369
|
+
const key = normalizeAffinityComponent(sessionKey);
|
|
348
370
|
if (key) {
|
|
349
371
|
const affined = sessionAffinity.get(key);
|
|
350
372
|
if (affined && now - affined.lastUsedAt <= AFFINITY_IDLE_TTL_MS) {
|
|
@@ -374,7 +396,7 @@ export function resolveAnthropicAccountForSession(
|
|
|
374
396
|
if (strategyPick) {
|
|
375
397
|
// Do not promote active here — token validation may still fail. Callers
|
|
376
398
|
// (responses/core) promote after getAnthropicPoolAccessToken succeeds.
|
|
377
|
-
if (key) {
|
|
399
|
+
if (key && normalizeAffinityComponent(strategyPick.accountId)) {
|
|
378
400
|
sessionAffinity.set(key, { accountId: strategyPick.accountId, lastUsedAt: now });
|
|
379
401
|
pruneExpiredAffinity(now);
|
|
380
402
|
}
|
|
@@ -420,7 +442,7 @@ export function resolveAnthropicAccountForSession(
|
|
|
420
442
|
return { accountId: null, reason: anyCooled ? "all-cooled" : "none" };
|
|
421
443
|
}
|
|
422
444
|
|
|
423
|
-
if (key) {
|
|
445
|
+
if (key && normalizeAffinityComponent(accountId)) {
|
|
424
446
|
sessionAffinity.set(key, { accountId, lastUsedAt: now });
|
|
425
447
|
pruneExpiredAffinity(now);
|
|
426
448
|
}
|
|
@@ -432,8 +454,8 @@ export function bindAnthropicSessionAffinity(
|
|
|
432
454
|
accountId: string,
|
|
433
455
|
now = Date.now(),
|
|
434
456
|
): void {
|
|
435
|
-
const key = sessionKey
|
|
436
|
-
if (!key) return;
|
|
457
|
+
const key = normalizeAffinityComponent(sessionKey);
|
|
458
|
+
if (!key || !normalizeAffinityComponent(accountId)) return;
|
|
437
459
|
sessionAffinity.set(key, { accountId, lastUsedAt: now });
|
|
438
460
|
pruneExpiredAffinity(now);
|
|
439
461
|
}
|
|
@@ -464,6 +486,7 @@ export function rotateAnthropicAccountOn429(
|
|
|
464
486
|
cooldownUntil: now + cooldownMs,
|
|
465
487
|
cooldownSource: parsedRetry ? "retry-after" : "default",
|
|
466
488
|
});
|
|
489
|
+
sweepExpiredOnWrite(now);
|
|
467
490
|
clearAnthropicSessionAffinityForAccount(failedAccountId);
|
|
468
491
|
notePoolRotationFailure(POOL_KEY_ANTHROPIC, failedAccountId);
|
|
469
492
|
|
|
@@ -473,8 +496,9 @@ export function rotateAnthropicAccountOn429(
|
|
|
473
496
|
return null;
|
|
474
497
|
}
|
|
475
498
|
|
|
476
|
-
|
|
477
|
-
|
|
499
|
+
const affinityKey = normalizeAffinityComponent(sessionKey);
|
|
500
|
+
if (affinityKey && normalizeAffinityComponent(next)) {
|
|
501
|
+
sessionAffinity.set(affinityKey, { accountId: next, lastUsedAt: now });
|
|
478
502
|
pruneExpiredAffinity(now);
|
|
479
503
|
}
|
|
480
504
|
console.warn(
|
|
@@ -32,7 +32,8 @@ const SCOPES = [
|
|
|
32
32
|
];
|
|
33
33
|
const CALLBACK_PORT = 51121;
|
|
34
34
|
const CALLBACK_PATH = "/callback";
|
|
35
|
-
|
|
35
|
+
// Keep provider-side margins small: the shared OAuth freshness gate applies an additional minute.
|
|
36
|
+
const REFRESH_SKEW_MS = 5 * 60 * 1000;
|
|
36
37
|
const REQUEST_TIMEOUT_MS = 30_000;
|
|
37
38
|
const ONBOARD_ATTEMPTS = 5;
|
|
38
39
|
const ONBOARD_POLL_MS = 2_000;
|
package/src/oauth/health.ts
CHANGED
|
@@ -3,8 +3,8 @@ import { getAnthropicAccountHealthSnapshot } from "./anthropic-routing";
|
|
|
3
3
|
import { isAccountNeedsReauth } from "../codex/account-runtime-state";
|
|
4
4
|
import { getCodexAccountCredential, listCodexAccountIds } from "../codex/account-store";
|
|
5
5
|
import { MAIN_CODEX_ACCOUNT_ID } from "../codex/main-account";
|
|
6
|
+
import { configuredAdminToken } from "../lib/admin-secrets";
|
|
6
7
|
import { maskAccountId } from "../lib/privacy";
|
|
7
|
-
import { loadServiceTokenFromFile } from "../lib/service-secrets";
|
|
8
8
|
import { findLiveProxy, probeHostname } from "../server/proxy-liveness";
|
|
9
9
|
import { loadAuthStore, peekAuthStore, peekOAuthRefreshIntent, readOAuthRefreshIntent } from "./store";
|
|
10
10
|
import type { ProviderAccount } from "./types";
|
|
@@ -320,13 +320,20 @@ function coerceRemoteAccountHealth(
|
|
|
320
320
|
return projectOAuthAccountHealth({ needsReauth: account.needsReauth === true });
|
|
321
321
|
}
|
|
322
322
|
|
|
323
|
+
type LiveProxyCodexHealthResult = {
|
|
324
|
+
source: CodexHealthSource;
|
|
325
|
+
entries: OAuthHealthEntry[] | null;
|
|
326
|
+
};
|
|
327
|
+
|
|
323
328
|
async function fetchCodexHealthFromLiveProxy(
|
|
324
329
|
fetchImpl: typeof fetch = fetch,
|
|
325
330
|
findLiveProxyImpl: typeof findLiveProxy = findLiveProxy,
|
|
326
|
-
): Promise<
|
|
331
|
+
): Promise<LiveProxyCodexHealthResult> {
|
|
327
332
|
const live = await findLiveProxyImpl();
|
|
328
|
-
if (!live) return null;
|
|
329
|
-
|
|
333
|
+
if (!live) return { source: "unavailable", entries: null };
|
|
334
|
+
// This is a management-plane endpoint. A data-plane service token is intentionally not
|
|
335
|
+
// interchangeable with the admin credential even on loopback.
|
|
336
|
+
const token = configuredAdminToken();
|
|
330
337
|
const headers: Record<string, string> = {};
|
|
331
338
|
if (token) headers.Authorization = `Bearer ${token}`;
|
|
332
339
|
try {
|
|
@@ -334,22 +341,29 @@ async function fetchCodexHealthFromLiveProxy(
|
|
|
334
341
|
`http://${probeHostname(live.hostname)}:${live.port}/api/codex-auth/accounts`,
|
|
335
342
|
{ headers, signal: AbortSignal.timeout(4000) },
|
|
336
343
|
);
|
|
337
|
-
if (
|
|
344
|
+
if (res.status === 401 || res.status === 403) {
|
|
345
|
+
return { source: "management-auth-failed", entries: null };
|
|
346
|
+
}
|
|
347
|
+
if (!res.ok) return { source: "management-api-unavailable", entries: null };
|
|
338
348
|
const json = await res.json() as { accounts?: ProxyCodexAccountHealth[] };
|
|
339
|
-
if (!Array.isArray(json.accounts)) return null;
|
|
349
|
+
if (!Array.isArray(json.accounts)) return { source: "management-api-unavailable", entries: null };
|
|
340
350
|
const entries: OAuthHealthEntry[] = [];
|
|
341
351
|
for (const account of json.accounts) {
|
|
342
352
|
if (!account?.id || typeof account.id !== "string") continue;
|
|
343
353
|
pushEntry(entries, "codex", account.id, coerceRemoteAccountHealth(account));
|
|
344
354
|
}
|
|
345
|
-
return entries;
|
|
355
|
+
return { source: "management-api", entries };
|
|
346
356
|
} catch {
|
|
347
|
-
return null;
|
|
357
|
+
return { source: "management-api-unavailable", entries: null };
|
|
348
358
|
}
|
|
349
359
|
}
|
|
350
360
|
|
|
351
361
|
/** How CLI/doctor obtained Codex cooldown/reauth (proxy memory only lives in the proxy). */
|
|
352
|
-
export type CodexHealthSource =
|
|
362
|
+
export type CodexHealthSource =
|
|
363
|
+
| "management-api"
|
|
364
|
+
| "unavailable"
|
|
365
|
+
| "management-auth-failed"
|
|
366
|
+
| "management-api-unavailable";
|
|
353
367
|
|
|
354
368
|
export type OAuthCliHealthReport = {
|
|
355
369
|
entries: OAuthHealthEntry[];
|
|
@@ -359,6 +373,10 @@ export type OAuthCliHealthReport = {
|
|
|
359
373
|
/** Shown by `ocx status` / `ocx doctor` when the proxy management API is unreachable. */
|
|
360
374
|
export const CODEX_HEALTH_UNAVAILABLE_NOTE =
|
|
361
375
|
"Codex health: unavailable (proxy not running; live cooldown/reauth requires the management API)";
|
|
376
|
+
export const CODEX_HEALTH_AUTH_FAILED_NOTE =
|
|
377
|
+
"Codex health: unavailable (proxy running; management authentication failed)";
|
|
378
|
+
export const CODEX_HEALTH_MANAGEMENT_API_UNAVAILABLE_NOTE =
|
|
379
|
+
"Codex health: unavailable (proxy running; management API did not return account health)";
|
|
362
380
|
|
|
363
381
|
/**
|
|
364
382
|
* CLI/doctor collector: observe-only OAuth store reads, and Codex health only from the
|
|
@@ -373,9 +391,9 @@ export async function collectOAuthHealthEntriesForCli(
|
|
|
373
391
|
): Promise<OAuthCliHealthReport> {
|
|
374
392
|
const entries = collectOAuthHealthEntries(now, { observeOnly: true, includeLocalCodex: false });
|
|
375
393
|
const remote = await fetchCodexHealthFromLiveProxy(deps.fetchImpl, deps.findLiveProxyImpl);
|
|
376
|
-
if (remote) {
|
|
377
|
-
for (const entry of remote) entries.push(entry);
|
|
394
|
+
if (remote.entries) {
|
|
395
|
+
for (const entry of remote.entries) entries.push(entry);
|
|
378
396
|
return { entries, codexHealthSource: "management-api" };
|
|
379
397
|
}
|
|
380
|
-
return { entries, codexHealthSource:
|
|
398
|
+
return { entries, codexHealthSource: remote.source };
|
|
381
399
|
}
|
package/src/oauth/index.ts
CHANGED
|
@@ -4,7 +4,7 @@ import type { OcxConfig, OcxProviderConfig, RefreshPolicy } from "../types";
|
|
|
4
4
|
import { loadConfig, resolveEnvValue, saveConfig } from "../config";
|
|
5
5
|
import { maskEmail } from "../lib/privacy";
|
|
6
6
|
import { KiroTokenRefreshError, environmentKiroRoutingMetadata, loginKiro, refreshKiroToken, settleKiroLoginTransaction } from "./kiro";
|
|
7
|
-
import { getAccountCredential, getAccountSet, removeAccount, saveAccountCredential, saveCredential, setActiveAccount, getCredential, credentialGeneration, createOAuthRefreshIntentLock, mergeAccountCredential, markAccountNeedsReauthIfGeneration, readOAuthRefreshIntent, writeOAuthRefreshIntent, clearOAuthRefreshIntent } from "./store";
|
|
7
|
+
import { getAccountCredential, getAccountSet, removeAccount, saveAccountCredential, saveCredential, setActiveAccount, getCredential, credentialGeneration, createOAuthRefreshIntentLock, mergeAccountCredential, markAccountNeedsReauthIfGeneration, readOAuthRefreshIntent, writeOAuthRefreshIntent, markOAuthRefreshIntentStaleOwner, clearOAuthRefreshIntent, OAuthMutationBusyError } from "./store";
|
|
8
8
|
import { loginXai, refreshXaiToken, XAI_LOCAL_CLI_DETACH_WARNING, XaiTokenRequestError } from "./xai";
|
|
9
9
|
import { ANTHROPIC_OAUTH_BETA, AnthropicTokenError, loginAnthropic, refreshAnthropicToken } from "./anthropic";
|
|
10
10
|
import { loginKimi, refreshKimiToken } from "./kimi";
|
|
@@ -19,7 +19,12 @@ import { resolveProviderModelDiscoveryUrl } from "../providers/model-discovery";
|
|
|
19
19
|
import { resolveProviderTransport } from "../providers/xai-transport";
|
|
20
20
|
import { detectClaudeCodeToken, detectGrokCliToken, hasComparableGrokIdentity, isSameGrokIdentity, shouldAdoptGrokGeneration } from "./local-token-detect";
|
|
21
21
|
import { logOAuthEvent } from "./log";
|
|
22
|
+
import { captureConfigGeneration, sweepExpiredOnWrite, type GenerationContext } from "../lib/state-store-sweeper";
|
|
23
|
+
import { retainedUtf8Bytes } from "../lib/admission";
|
|
24
|
+
import { randomUUID } from "node:crypto";
|
|
22
25
|
export {
|
|
26
|
+
CODEX_HEALTH_AUTH_FAILED_NOTE,
|
|
27
|
+
CODEX_HEALTH_MANAGEMENT_API_UNAVAILABLE_NOTE,
|
|
23
28
|
CODEX_HEALTH_UNAVAILABLE_NOTE,
|
|
24
29
|
MASKED_ACCOUNT_FALLBACK,
|
|
25
30
|
collectOAuthHealthEntries,
|
|
@@ -51,14 +56,57 @@ export interface OAuthAccessSnapshot {
|
|
|
51
56
|
kiro?: Pick<KiroOAuthMetadata, "profileArn" | "apiRegion" | "ssoRegion">;
|
|
52
57
|
}
|
|
53
58
|
|
|
54
|
-
const
|
|
59
|
+
const MAX_OAUTH_TOKEN_REFRESH_FLIGHTS = 32;
|
|
60
|
+
const OAUTH_TOKEN_REFRESH_FLIGHT_STALE_MS = 120_000;
|
|
61
|
+
interface OAuthRefreshFlightEvidence { flightId: string; dispatched: boolean }
|
|
62
|
+
interface OAuthTokenRefreshFlight extends OAuthRefreshFlightEvidence { promise: Promise<OAuthAccessSnapshot>; startedAt: number; abort: AbortController }
|
|
63
|
+
const tokenRefreshes = new Map<string, OAuthTokenRefreshFlight>();
|
|
64
|
+
export class OAuthTokenRefreshBusyError extends Error {
|
|
65
|
+
readonly code = "OAUTH_TOKEN_REFRESH_BUSY";
|
|
66
|
+
readonly retryable = true;
|
|
67
|
+
constructor() { super("OAuth token refresh capacity reached"); this.name = "OAuthTokenRefreshBusyError"; }
|
|
68
|
+
}
|
|
69
|
+
export class OAuthTokenRefreshStaleError extends Error {
|
|
70
|
+
readonly code = "OAUTH_TOKEN_REFRESH_STALE";
|
|
71
|
+
readonly retryable = true;
|
|
72
|
+
constructor() { super("OAuth token refresh owner became stale"); this.name = "OAuthTokenRefreshStaleError"; }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Focused owner-identity tests only. Synthetic owners retain no account data. */
|
|
76
|
+
export function seedOAuthTokenRefreshFlightsForTests(rows: Array<{ key: string; startedAt?: number; flightId?: string; dispatched?: boolean }>): {
|
|
77
|
+
promises: Promise<OAuthAccessSnapshot>[];
|
|
78
|
+
cleanup: () => void;
|
|
79
|
+
} {
|
|
80
|
+
const inserted: OAuthTokenRefreshFlight[] = [];
|
|
81
|
+
const promises = rows.map(({ key, startedAt, flightId, dispatched }) => {
|
|
82
|
+
const abort = new AbortController();
|
|
83
|
+
const promise = new Promise<OAuthAccessSnapshot>((_resolve, reject) => {
|
|
84
|
+
abort.signal.addEventListener("abort", () => reject(abort.signal.reason), { once: true });
|
|
85
|
+
});
|
|
86
|
+
const flight = { promise, startedAt: startedAt ?? Date.now(), abort, flightId: flightId ?? randomUUID(), dispatched: dispatched ?? false };
|
|
87
|
+
tokenRefreshes.set(key, flight);
|
|
88
|
+
inserted.push(flight);
|
|
89
|
+
return promise;
|
|
90
|
+
});
|
|
91
|
+
return {
|
|
92
|
+
promises,
|
|
93
|
+
cleanup() {
|
|
94
|
+
for (const [key, flight] of tokenRefreshes) {
|
|
95
|
+
if (!inserted.includes(flight)) continue;
|
|
96
|
+
tokenRefreshes.delete(key);
|
|
97
|
+
flight.abort.abort(new Error("test cleanup"));
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|
|
55
102
|
const XAI_PERMANENT_FAILURE_TTL_MS=30_000;
|
|
56
103
|
const permanentRefreshFailures=new Map<string,number>();
|
|
57
|
-
interface XaiRefreshDeps { intentLock?:ReturnType<typeof createOAuthRefreshIntentLock>; now?:()=>number; afterPrePersistRead?:()=>void|Promise<void
|
|
58
|
-
interface AnthropicRefreshDeps { intentLock?:ReturnType<typeof createOAuthRefreshIntentLock>; now?:()=>number; afterPrePersistRead?:()=>void|Promise<void
|
|
59
|
-
interface GenericRefreshDeps { intentLock?:ReturnType<typeof createOAuthRefreshIntentLock>; afterPrePersistRead?:()=>void|Promise<void
|
|
104
|
+
interface XaiRefreshDeps { intentLock?:ReturnType<typeof createOAuthRefreshIntentLock>; now?:()=>number; afterPrePersistRead?:()=>void|Promise<void>; signal?: AbortSignal }
|
|
105
|
+
interface AnthropicRefreshDeps { intentLock?:ReturnType<typeof createOAuthRefreshIntentLock>; now?:()=>number; afterPrePersistRead?:()=>void|Promise<void>; signal?: AbortSignal; flight?: OAuthRefreshFlightEvidence; replacedStaleFlight?: OAuthRefreshFlightEvidence }
|
|
106
|
+
interface GenericRefreshDeps { intentLock?:ReturnType<typeof createOAuthRefreshIntentLock>; afterPrePersistRead?:()=>void|Promise<void>; signal?: AbortSignal }
|
|
60
107
|
function verdictKey(p:string,a:string,c:OAuthCredentials){return `${p}\0${a}\0${credentialGeneration(c)}`;}
|
|
61
108
|
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;}
|
|
109
|
+
export function sweepExpiredXaiPermanentFailureVerdicts(now=Date.now()):number{let removed=0;for(const[key,until]of permanentRefreshFailures){if(until>now)continue;permanentRefreshFailures.delete(key);removed+=1;}return removed;}
|
|
62
110
|
|
|
63
111
|
export interface LoginOpts { forceLogin?: boolean; /** When set, persist into this account slot and require matching identity. */ reauthAccountId?: string }
|
|
64
112
|
|
|
@@ -244,24 +292,44 @@ async function resolveAccessSnapshotForAccount(
|
|
|
244
292
|
if (rejectedGeneration === undefined && cred.expires > Date.now() + REFRESH_SKEW_MS) return current;
|
|
245
293
|
|
|
246
294
|
const key = `${provider}\u0000${accountId}`;
|
|
247
|
-
|
|
248
|
-
|
|
295
|
+
let existing = tokenRefreshes.get(key);
|
|
296
|
+
let replacedStaleFlight: OAuthRefreshFlightEvidence | undefined;
|
|
297
|
+
if (existing && Date.now() - existing.startedAt <= OAUTH_TOKEN_REFRESH_FLIGHT_STALE_MS) {
|
|
249
298
|
logOAuthEvent("OAuth refresh joined existing operation", { provider, accountId });
|
|
250
|
-
return existing;
|
|
299
|
+
return existing.promise;
|
|
300
|
+
}
|
|
301
|
+
if (existing) {
|
|
302
|
+
replacedStaleFlight = { flightId: existing.flightId, dispatched: existing.dispatched };
|
|
303
|
+
existing.abort.abort(new OAuthTokenRefreshStaleError());
|
|
304
|
+
if (tokenRefreshes.get(key) === existing) tokenRefreshes.delete(key);
|
|
305
|
+
existing = undefined;
|
|
251
306
|
}
|
|
307
|
+
if (tokenRefreshes.size >= MAX_OAUTH_TOKEN_REFRESH_FLIGHTS) throw new OAuthTokenRefreshBusyError();
|
|
252
308
|
|
|
309
|
+
const abort = new AbortController();
|
|
310
|
+
const flight: OAuthTokenRefreshFlight = {
|
|
311
|
+
promise: undefined as unknown as Promise<OAuthAccessSnapshot>,
|
|
312
|
+
startedAt: Date.now(),
|
|
313
|
+
abort,
|
|
314
|
+
flightId: randomUUID(),
|
|
315
|
+
dispatched: false,
|
|
316
|
+
};
|
|
253
317
|
const refresh = (async (): Promise<OAuthAccessSnapshot> => {
|
|
254
|
-
const accessToken = await refreshAndPersistAccessToken(provider, accountId, def, cred);
|
|
318
|
+
const accessToken = await refreshAndPersistAccessToken(provider, accountId, def, cred, abort.signal, flight, replacedStaleFlight);
|
|
255
319
|
const persisted = getAccountCredential(provider, accountId);
|
|
256
320
|
if (!persisted) throw new OAuthLoginRequiredError(provider);
|
|
257
321
|
if (persisted.access !== accessToken) {
|
|
258
322
|
throw new Error(`OAuth refresh persisted an unexpected access token for ${provider}`);
|
|
259
323
|
}
|
|
260
324
|
return accessSnapshot(provider, accountId, persisted);
|
|
261
|
-
})().
|
|
262
|
-
if (
|
|
325
|
+
})().catch(error => {
|
|
326
|
+
if (abort.signal.reason instanceof OAuthTokenRefreshStaleError) throw abort.signal.reason;
|
|
327
|
+
throw error;
|
|
328
|
+
}).finally(() => {
|
|
329
|
+
if (tokenRefreshes.get(key) === flight) tokenRefreshes.delete(key);
|
|
263
330
|
});
|
|
264
|
-
|
|
331
|
+
flight.promise = refresh;
|
|
332
|
+
tokenRefreshes.set(key, flight);
|
|
265
333
|
return refresh;
|
|
266
334
|
}
|
|
267
335
|
|
|
@@ -323,7 +391,7 @@ function merged(fresh: OAuthCredentials, previous: OAuthCredentials): OAuthCrede
|
|
|
323
391
|
...(fresh.kiro === undefined && previous.kiro ? { kiro: previous.kiro } : {}),
|
|
324
392
|
};
|
|
325
393
|
}
|
|
326
|
-
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),
|
|
394
|
+
export async function refreshXaiAccountWithLock(provider:string,accountId:string,def:OAuthProviderDef,callerCredential:OAuthCredentials,deps:XaiRefreshDeps={}):Promise<string>{const writerGeneration=captureConfigGeneration();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,deps.signal),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(error instanceof OAuthMutationBusyError){permanentRefreshFailures.delete(verdictKey(provider,accountId,candidate));throw error;}if(!terminal(error))throw error;const failedAt=now();permanentRefreshFailures.set(verdictKey(provider,accountId,candidate),failedAt+XAI_PERMANENT_FAILURE_TTL_MS);sweepExpiredOnWrite(failedAt);await markAccountNeedsReauthIfGeneration(provider,accountId,generation,writerGeneration);throw new OAuthLoginRequiredError(provider);}}finally{guard.release();}}
|
|
327
395
|
|
|
328
396
|
function newerClaudeCredential(stored: OAuthCredentials, now: number): OAuthCredentials | undefined {
|
|
329
397
|
if (stored.source !== "local-cli") return undefined;
|
|
@@ -339,6 +407,7 @@ export async function refreshAnthropicAccountWithLock(
|
|
|
339
407
|
callerCredential: OAuthCredentials,
|
|
340
408
|
deps: AnthropicRefreshDeps = {},
|
|
341
409
|
): Promise<string> {
|
|
410
|
+
const writerGeneration = captureConfigGeneration();
|
|
342
411
|
const now = deps.now ?? Date.now;
|
|
343
412
|
const guard = await (deps.intentLock ?? createOAuthRefreshIntentLock(provider, accountId)).acquire();
|
|
344
413
|
try {
|
|
@@ -346,7 +415,7 @@ export async function refreshAnthropicAccountWithLock(
|
|
|
346
415
|
if (!stored) throw new OAuthLoginRequiredError(provider);
|
|
347
416
|
const account = getAccountSet(provider)?.accounts.find(candidate => candidate.id === accountId);
|
|
348
417
|
const generation = credentialGeneration(stored);
|
|
349
|
-
|
|
418
|
+
let pendingIntent = readOAuthRefreshIntent(provider, accountId);
|
|
350
419
|
const disk = newerClaudeCredential(stored, now());
|
|
351
420
|
if (disk) {
|
|
352
421
|
const outcome = await mergeAccountCredential(provider, accountId, disk, {
|
|
@@ -361,8 +430,19 @@ export async function refreshAnthropicAccountWithLock(
|
|
|
361
430
|
if (pendingIntent) clearOAuthRefreshIntent(provider, accountId, pendingIntent.generation);
|
|
362
431
|
return disk.access;
|
|
363
432
|
}
|
|
433
|
+
if (!pendingIntent?.uncertain && pendingIntent?.generation === generation) {
|
|
434
|
+
if (pendingIntent.staleOwner) throw new OAuthTokenRefreshStaleError();
|
|
435
|
+
if (deps.replacedStaleFlight && pendingIntent.flightId === deps.replacedStaleFlight.flightId) {
|
|
436
|
+
if (deps.replacedStaleFlight.dispatched) {
|
|
437
|
+
markOAuthRefreshIntentStaleOwner(provider, accountId, generation, deps.replacedStaleFlight.flightId);
|
|
438
|
+
throw new OAuthTokenRefreshStaleError();
|
|
439
|
+
}
|
|
440
|
+
clearOAuthRefreshIntent(provider, accountId, generation);
|
|
441
|
+
pendingIntent = undefined;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
364
444
|
if (pendingIntent?.uncertain || pendingIntent?.generation === generation) {
|
|
365
|
-
await markAccountNeedsReauthIfGeneration(provider, accountId, generation);
|
|
445
|
+
await markAccountNeedsReauthIfGeneration(provider, accountId, generation, writerGeneration);
|
|
366
446
|
throw new OAuthLoginRequiredError(provider);
|
|
367
447
|
}
|
|
368
448
|
if (pendingIntent) clearOAuthRefreshIntent(provider, accountId, pendingIntent.generation);
|
|
@@ -374,8 +454,10 @@ export async function refreshAnthropicAccountWithLock(
|
|
|
374
454
|
}
|
|
375
455
|
|
|
376
456
|
try {
|
|
377
|
-
writeOAuthRefreshIntent(provider, accountId, generation, now());
|
|
378
|
-
|
|
457
|
+
writeOAuthRefreshIntent(provider, accountId, generation, now(), deps.flight?.flightId);
|
|
458
|
+
if (deps.signal?.aborted) throw deps.signal.reason;
|
|
459
|
+
if (deps.flight) deps.flight.dispatched = true;
|
|
460
|
+
const fresh = merged(await def.refresh(stored.refresh, deps.signal), stored);
|
|
379
461
|
const outcome = await mergeAccountCredential(provider, accountId, fresh, {
|
|
380
462
|
expectedGeneration: generation,
|
|
381
463
|
afterPrePersistRead: deps.afterPrePersistRead,
|
|
@@ -388,8 +470,9 @@ export async function refreshAnthropicAccountWithLock(
|
|
|
388
470
|
clearOAuthRefreshIntent(provider, accountId, generation);
|
|
389
471
|
return fresh.access;
|
|
390
472
|
} catch (error) {
|
|
473
|
+
if (error instanceof OAuthMutationBusyError) throw error;
|
|
391
474
|
if (!terminal(error)) throw error;
|
|
392
|
-
await markAccountNeedsReauthIfGeneration(provider, accountId, generation);
|
|
475
|
+
await markAccountNeedsReauthIfGeneration(provider, accountId, generation, writerGeneration);
|
|
393
476
|
clearOAuthRefreshIntent(provider, accountId, generation);
|
|
394
477
|
throw new OAuthLoginRequiredError(provider);
|
|
395
478
|
}
|
|
@@ -405,6 +488,7 @@ export async function refreshGenericAccountWithLock(
|
|
|
405
488
|
callerCredential: OAuthCredentials,
|
|
406
489
|
deps: GenericRefreshDeps = {},
|
|
407
490
|
): Promise<string> {
|
|
491
|
+
const writerGeneration = captureConfigGeneration();
|
|
408
492
|
logOAuthEvent("OAuth refresh started", { provider, accountId });
|
|
409
493
|
const guard = await (deps.intentLock ?? createOAuthRefreshIntentLock(provider, accountId)).acquire();
|
|
410
494
|
try {
|
|
@@ -419,7 +503,7 @@ export async function refreshGenericAccountWithLock(
|
|
|
419
503
|
}
|
|
420
504
|
const generation = credentialGeneration(stored);
|
|
421
505
|
try {
|
|
422
|
-
const fresh = merged(await def.refresh(stored.refresh,
|
|
506
|
+
const fresh = merged(await def.refresh(stored.refresh, deps.signal, stored), stored);
|
|
423
507
|
const outcome = await mergeAccountCredential(provider, accountId, fresh, {
|
|
424
508
|
expectedGeneration: generation,
|
|
425
509
|
afterPrePersistRead: deps.afterPrePersistRead,
|
|
@@ -431,8 +515,9 @@ export async function refreshGenericAccountWithLock(
|
|
|
431
515
|
logOAuthEvent("OAuth credentials rotated and persisted", { provider, accountId });
|
|
432
516
|
return fresh.access;
|
|
433
517
|
} catch (error) {
|
|
518
|
+
if (error instanceof OAuthMutationBusyError) throw error;
|
|
434
519
|
if (!terminal(error)) throw error;
|
|
435
|
-
await markAccountNeedsReauthIfGeneration(provider, accountId, generation);
|
|
520
|
+
await markAccountNeedsReauthIfGeneration(provider, accountId, generation, writerGeneration);
|
|
436
521
|
throw new OAuthLoginRequiredError(provider);
|
|
437
522
|
}
|
|
438
523
|
} finally {
|
|
@@ -445,10 +530,13 @@ async function refreshAndPersistAccessToken(
|
|
|
445
530
|
accountId: string,
|
|
446
531
|
def: OAuthProviderDef,
|
|
447
532
|
cred: OAuthCredentials,
|
|
533
|
+
signal?: AbortSignal,
|
|
534
|
+
flight?: OAuthRefreshFlightEvidence,
|
|
535
|
+
replacedStaleFlight?: OAuthRefreshFlightEvidence,
|
|
448
536
|
): Promise<string> {
|
|
449
|
-
if (provider === "xai") return refreshXaiAccountWithLock(provider, accountId, def, cred);
|
|
450
|
-
if (provider === "anthropic") return refreshAnthropicAccountWithLock(provider, accountId, def, cred);
|
|
451
|
-
return refreshGenericAccountWithLock(provider, accountId, def, cred);
|
|
537
|
+
if (provider === "xai") return refreshXaiAccountWithLock(provider, accountId, def, cred, { signal });
|
|
538
|
+
if (provider === "anthropic") return refreshAnthropicAccountWithLock(provider, accountId, def, cred, { signal, flight, replacedStaleFlight });
|
|
539
|
+
return refreshGenericAccountWithLock(provider, accountId, def, cred, { signal });
|
|
452
540
|
}
|
|
453
541
|
|
|
454
542
|
/**
|
|
@@ -831,6 +919,21 @@ interface ManualCodeSlot {
|
|
|
831
919
|
expectedState?: string;
|
|
832
920
|
}
|
|
833
921
|
const loginManual = new Map<string, ManualCodeSlot>();
|
|
922
|
+
const OAUTH_PENDING_CODE_MAX_BYTES = 4 * 1024;
|
|
923
|
+
let lastOAuthFlowReconciledGeneration = 0;
|
|
924
|
+
|
|
925
|
+
export function reconcileOAuthFlowState(context: GenerationContext): number {
|
|
926
|
+
if (context.generation <= lastOAuthFlowReconciledGeneration) return 0;
|
|
927
|
+
let removed = 0;
|
|
928
|
+
for (const [provider, state] of loginState) {
|
|
929
|
+
if (context.providerNames.has(provider) || !state.done || loginAbort.has(provider)) continue;
|
|
930
|
+
if (loginState.delete(provider)) removed += 1;
|
|
931
|
+
if (loginManual.delete(provider)) removed += 1;
|
|
932
|
+
if (loginAbort.delete(provider)) removed += 1;
|
|
933
|
+
}
|
|
934
|
+
lastOAuthFlowReconciledGeneration = context.generation;
|
|
935
|
+
return removed;
|
|
936
|
+
}
|
|
834
937
|
|
|
835
938
|
function clearManualCodeSlot(provider: string): void {
|
|
836
939
|
loginManual.delete(provider);
|
|
@@ -879,6 +982,7 @@ function waitForManualLoginCode(provider: string, signal: AbortSignal, expectedS
|
|
|
879
982
|
export function submitManualLoginCode(provider: string, input: string): { ok: true } | { ok: false; error: string } {
|
|
880
983
|
const trimmed = input.trim();
|
|
881
984
|
if (!trimmed) return { ok: false, error: "empty code" };
|
|
985
|
+
if (retainedUtf8Bytes(trimmed) > OAUTH_PENDING_CODE_MAX_BYTES) return { ok: false, error: "code too large" };
|
|
882
986
|
const st = loginState.get(provider);
|
|
883
987
|
if (!st || st.done) return { ok: false, error: "no login in progress" };
|
|
884
988
|
const slot = ensureManualCodeSlot(provider);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { chmodSync, closeSync, existsSync, fsyncSync, linkSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { chmodSync, closeSync, existsSync, fsyncSync, linkSync, openSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { isAbsolute, join, posix, win32 } from "node:path";
|
|
5
5
|
import { Database } from "bun:sqlite";
|
|
@@ -169,6 +169,77 @@ export function resolveKiroCliNativeSessionEntries(
|
|
|
169
169
|
return [{ location: "kiro-cli-linux-data", path: posix.join(home, ".local", "share", "kiro-cli", "data.sqlite3") }];
|
|
170
170
|
}
|
|
171
171
|
|
|
172
|
+
/**
|
|
173
|
+
* Resolve the absolute kiro-cli executable for spawn/login helpers.
|
|
174
|
+
*
|
|
175
|
+
* Pure + parameterized like `resolveKiroCliNativeSessionEntries` so Windows install layouts can be
|
|
176
|
+
* covered from any host. PATH remains the first choice; only when bare `kiro-cli` is missing do we
|
|
177
|
+
* fall back to the platform-native install directories next to the session database.
|
|
178
|
+
*
|
|
179
|
+
* Windows: official MSI installs to `C:\Program Files\Kiro-Cli\kiro-cli.exe`, while some local
|
|
180
|
+
* installs keep the binary next to `%LOCALAPPDATA%\Kiro-Cli\data.sqlite3`.
|
|
181
|
+
* macOS/Linux: prefer PATH, then the usual user-local bin directories.
|
|
182
|
+
*/
|
|
183
|
+
export function resolveKiroCliExecutable(
|
|
184
|
+
inputs: KiroCliNativeInputs & {
|
|
185
|
+
pathEntries?: string[];
|
|
186
|
+
exists?: (path: string) => boolean;
|
|
187
|
+
isFile?: (path: string) => boolean;
|
|
188
|
+
},
|
|
189
|
+
): string {
|
|
190
|
+
const exists = inputs.exists ?? existsSync;
|
|
191
|
+
// A directory named `kiro-cli` on PATH satisfies existsSync and would then be handed to
|
|
192
|
+
// spawn(), which fails with EACCES at login instead of falling through to the next candidate.
|
|
193
|
+
// When a caller injects `exists` it owns the whole filesystem view, so the real stat would
|
|
194
|
+
// reject every synthetic path; such callers inject `isFile` too when they care about it.
|
|
195
|
+
const isFile = inputs.isFile ?? (inputs.exists ? () => true : ((path: string) => {
|
|
196
|
+
try {
|
|
197
|
+
return statSync(path).isFile();
|
|
198
|
+
} catch {
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
}));
|
|
202
|
+
const pathEntries = inputs.pathEntries
|
|
203
|
+
?? (inputs.env.PATH ?? inputs.env.Path ?? "").split(inputs.platform === "win32" ? ";" : ":")
|
|
204
|
+
.map(entry => entry.trim())
|
|
205
|
+
.filter(Boolean);
|
|
206
|
+
|
|
207
|
+
const pathCandidates = inputs.platform === "win32"
|
|
208
|
+
? pathEntries.flatMap(entry => [
|
|
209
|
+
win32.join(entry, "kiro-cli.exe"),
|
|
210
|
+
win32.join(entry, "kiro-cli"),
|
|
211
|
+
])
|
|
212
|
+
: pathEntries.map(entry => posix.join(entry, "kiro-cli"));
|
|
213
|
+
|
|
214
|
+
const installCandidates: string[] = [];
|
|
215
|
+
if (inputs.platform === "win32") {
|
|
216
|
+
const localBase = inputs.env.LOCALAPPDATA?.trim()
|
|
217
|
+
|| (inputs.env.USERPROFILE?.trim() ? win32.join(inputs.env.USERPROFILE.trim(), "AppData", "Local") : "")
|
|
218
|
+
|| win32.join(inputs.home, "AppData", "Local");
|
|
219
|
+
const programFiles = inputs.env["ProgramFiles"]?.trim() || "C:\\Program Files";
|
|
220
|
+
installCandidates.push(
|
|
221
|
+
win32.join(localBase, "Kiro-Cli", "kiro-cli.exe"),
|
|
222
|
+
win32.join(programFiles, "Kiro-Cli", "kiro-cli.exe"),
|
|
223
|
+
);
|
|
224
|
+
} else if (inputs.platform === "darwin") {
|
|
225
|
+
installCandidates.push(
|
|
226
|
+
posix.join(inputs.home, ".local", "bin", "kiro-cli"),
|
|
227
|
+
"/usr/local/bin/kiro-cli",
|
|
228
|
+
"/opt/homebrew/bin/kiro-cli",
|
|
229
|
+
);
|
|
230
|
+
} else {
|
|
231
|
+
installCandidates.push(
|
|
232
|
+
posix.join(inputs.home, ".local", "bin", "kiro-cli"),
|
|
233
|
+
"/usr/local/bin/kiro-cli",
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
for (const candidate of [...pathCandidates, ...installCandidates]) {
|
|
238
|
+
if (exists(candidate) && isFile(candidate)) return candidate;
|
|
239
|
+
}
|
|
240
|
+
return inputs.platform === "win32" ? "kiro-cli.exe" : "kiro-cli";
|
|
241
|
+
}
|
|
242
|
+
|
|
172
243
|
function nativeKiroCliSessionEntries(): Array<{ location: KiroCliNativeLocation; path: string }> {
|
|
173
244
|
// Only the stores that `kiro-cli logout` / `kiro-cli login` themselves mutate. Import fallbacks
|
|
174
245
|
// (Amazon Q / SSO cache) and KIROCLI_DB_PATH selectors must not be snapshotted for rollback.
|
package/src/oauth/kiro.ts
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
persistKiroCliSessionRecovery,
|
|
19
19
|
readImportedKiroCredential,
|
|
20
20
|
readKiroCliSqliteCredential,
|
|
21
|
+
resolveKiroCliExecutable,
|
|
21
22
|
restoreKiroCliSession,
|
|
22
23
|
restoreStaleKiroCliSessionRecovery,
|
|
23
24
|
requireKiroRegion,
|
|
@@ -25,11 +26,14 @@ import {
|
|
|
25
26
|
type KiroCliSessionSnapshot,
|
|
26
27
|
type KiroImportDiagnostic,
|
|
27
28
|
} from "./kiro-credentials";
|
|
29
|
+
import { homedir } from "node:os";
|
|
28
30
|
import { getAccountSet, saveAccountCredential } from "./store";
|
|
29
31
|
|
|
30
32
|
const DEFAULT_REGION = "us-east-1";
|
|
31
33
|
const REFRESH_URL = "https://prod.{region}.auth.desktop.kiro.dev/refreshToken";
|
|
32
34
|
const OIDC_URL = "https://oidc.{region}.amazonaws.com/token";
|
|
35
|
+
const KIRO_CLI_UNIX_INSTALL_COMMAND = "curl -fsSL https://cli.kiro.dev/install | bash";
|
|
36
|
+
const KIRO_CLI_WINDOWS_INSTALL_COMMAND = "irm 'https://cli.kiro.dev/install.ps1' | iex";
|
|
33
37
|
const KIRO_TERMINAL_REFRESH_ERRORS = new Set([
|
|
34
38
|
"invalid_grant",
|
|
35
39
|
"refresh_token_reused",
|
|
@@ -68,13 +72,28 @@ export interface KiroLoginOptions {
|
|
|
68
72
|
cliRunner?: KiroCliRunner;
|
|
69
73
|
}
|
|
70
74
|
|
|
75
|
+
export function kiroCliInstallGuidance(platform = process.platform): string {
|
|
76
|
+
return platform === "win32"
|
|
77
|
+
? `install the Kiro CLI in PowerShell (\`${KIRO_CLI_WINDOWS_INSTALL_COMMAND}\`)`
|
|
78
|
+
: `install the Kiro CLI (\`${KIRO_CLI_UNIX_INSTALL_COMMAND}\`)`;
|
|
79
|
+
}
|
|
80
|
+
|
|
71
81
|
const pendingKiroLoginTransactions = new WeakMap<OAuthCredentials, KiroCliSessionSnapshot>();
|
|
72
82
|
/** Forced logins that started with no native CLI DB must logout on persistence failure. */
|
|
73
83
|
const pendingKiroEmptyPriorSessions = new WeakSet<OAuthCredentials>();
|
|
74
84
|
|
|
85
|
+
|
|
86
|
+
function resolveRuntimeKiroCliExecutable(): string {
|
|
87
|
+
return resolveKiroCliExecutable({
|
|
88
|
+
env: process.env,
|
|
89
|
+
platform: process.platform,
|
|
90
|
+
home: process.platform === "win32" ? homedir() : (process.env.HOME || homedir()),
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
75
94
|
function logoutKiroCliBestEffort(): void {
|
|
76
95
|
try {
|
|
77
|
-
Bun.spawnSync([
|
|
96
|
+
Bun.spawnSync([resolveRuntimeKiroCliExecutable(), "logout"], {
|
|
78
97
|
stdin: "ignore",
|
|
79
98
|
stdout: "ignore",
|
|
80
99
|
stderr: "ignore",
|
|
@@ -128,7 +147,7 @@ async function defaultKiroCliRunner(args: string[], signal?: AbortSignal): Promi
|
|
|
128
147
|
throwIfKiroLoginCancelled(signal);
|
|
129
148
|
let child: ReturnType<typeof Bun.spawn>;
|
|
130
149
|
try {
|
|
131
|
-
child = Bun.spawn([
|
|
150
|
+
child = Bun.spawn([resolveRuntimeKiroCliExecutable(), ...args], {
|
|
132
151
|
stdin: "ignore",
|
|
133
152
|
stdout: "pipe",
|
|
134
153
|
stderr: "ignore",
|
|
@@ -346,7 +365,7 @@ export async function loginKiro(ctrl: OAuthController, options: KiroLoginOptions
|
|
|
346
365
|
url: "",
|
|
347
366
|
instructions:
|
|
348
367
|
"No kiro-cli token found. Paste a Kiro access token below (starts with 'aoa'). " +
|
|
349
|
-
|
|
368
|
+
`Otherwise ${kiroCliInstallGuidance()}, ` +
|
|
350
369
|
"run `kiro-cli login`, and retry — or set KIRO_ACCESS_TOKEN.",
|
|
351
370
|
});
|
|
352
371
|
ctrl.onProgress?.("No kiro-cli token found. Paste a Kiro access token (starts with 'aoa'), or install the Kiro CLI and run `kiro-cli login` first.");
|
|
@@ -364,7 +383,7 @@ export async function loginKiro(ctrl: OAuthController, options: KiroLoginOptions
|
|
|
364
383
|
}
|
|
365
384
|
|
|
366
385
|
throw new Error(
|
|
367
|
-
|
|
386
|
+
`Kiro: no token found. ${kiroCliInstallGuidance()} ` +
|
|
368
387
|
"and run `kiro-cli login` to import its session, or set KIRO_ACCESS_TOKEN. " +
|
|
369
388
|
"Browser login is not supported for Kiro.",
|
|
370
389
|
);
|