@bitkyc08/opencodex 2.38.0 → 2.39.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/bin/ocx.mjs +58 -6
- package/gui/dist/assets/index-D-lchsPw.js +112 -0
- package/gui/dist/assets/index-uvENYLin.css +1 -0
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/baseten.svg +13 -0
- package/gui/dist/provider-icons/bizrouter.svg +41 -0
- package/gui/dist/provider-icons/cerebras.svg +26 -0
- package/gui/dist/provider-icons/deepinfra.svg +75 -0
- package/gui/dist/provider-icons/digitalocean.svg +10 -0
- package/gui/dist/provider-icons/featherless.svg +4 -0
- package/gui/dist/provider-icons/gajae-code.svg +410 -0
- package/gui/dist/provider-icons/hermes-agent.svg +207 -0
- package/gui/dist/provider-icons/hyperbolic.svg +18 -0
- package/gui/dist/provider-icons/kilo.svg +13 -0
- package/gui/dist/provider-icons/litellm.svg +1 -0
- package/gui/dist/provider-icons/minimax.svg +1 -0
- package/gui/dist/provider-icons/nanogpt.svg +74 -0
- package/gui/dist/provider-icons/nebius.svg +1 -0
- package/gui/dist/provider-icons/neuralwatt.svg +27 -0
- package/gui/dist/provider-icons/nous.svg +149 -0
- package/gui/dist/provider-icons/novita.svg +32 -0
- package/gui/dist/provider-icons/orcarouter.svg +175 -0
- package/gui/dist/provider-icons/parallel.svg +13 -0
- package/gui/dist/provider-icons/sambanova.svg +276 -0
- package/gui/dist/provider-icons/scaleway.svg +11 -0
- package/gui/dist/provider-icons/siliconflow.svg +18 -0
- package/gui/dist/provider-icons/synthetic.svg +12 -0
- package/gui/dist/provider-icons/together.svg +18 -0
- package/gui/dist/provider-icons/umans.svg +30 -0
- package/gui/dist/provider-icons/venice.svg +165 -0
- package/gui/dist/provider-icons/vultr.svg +15 -0
- package/gui/dist/provider-icons/zai.svg +218 -0
- package/gui/dist/provider-icons/zenmux.svg +1 -0
- package/package.json +1 -1
- package/src/adapters/cursor/live-models.ts +1 -0
- package/src/adapters/openai-responses.ts +72 -10
- package/src/bridge.ts +15 -15
- package/src/cli/dispatch.ts +54 -18
- package/src/cli/index.ts +420 -30
- package/src/cli/integrations.ts +24 -2
- package/src/cli/uninstall-plan.ts +86 -0
- package/src/codex/account-store.ts +121 -8
- package/src/codex/auth-api.ts +202 -33
- package/src/codex/catalog/provider-fetch.ts +76 -9
- package/src/codex/history-job.ts +10 -0
- package/src/codex/history-manifest.ts +35 -2
- package/src/codex/history-provider.ts +196 -19
- package/src/codex/history-worker.ts +3 -0
- package/src/codex/quota-401-recovery.ts +190 -0
- package/src/codex/quota-recovery-timing.ts +28 -0
- package/src/codex/quota.ts +6 -0
- package/src/codex/routing.ts +68 -16
- package/src/codex/subagent-model-fallback.ts +4 -1
- package/src/config/pending-teardown-names.d.mts +8 -0
- package/src/config/pending-teardown-names.mjs +69 -0
- package/src/config/pending-teardown.ts +286 -0
- package/src/generated/compatibility-version.json +84 -32
- package/src/integrations/journal.ts +12 -1
- package/src/integrations/writer.ts +82 -7
- package/src/lib/process-control.ts +30 -5
- package/src/lib/state-store-registrations.ts +8 -0
- package/src/oauth/index.ts +220 -13
- package/src/oauth/store.ts +220 -18
- package/src/responses/spill-store.ts +20 -0
- package/src/responses/state.ts +288 -2
- package/src/server/management/integration-routes.ts +36 -5
- package/src/server/management-api.ts +66 -12
- package/src/server/responses/encrypted-payload.ts +18 -1
- package/src/server/stop-teardown.ts +84 -0
- package/src/service.ts +155 -20
- package/src/update/index.ts +36 -4
- package/src/update/proxy-liveness-probe.d.mts +6 -0
- package/src/update/proxy-liveness-probe.mjs +84 -0
- package/src/update/stop-contract.d.mts +2 -0
- package/src/update/stop-contract.mjs +15 -0
- package/src/update/stop-decision.d.mts +10 -0
- package/src/update/stop-decision.mjs +34 -0
- package/src/vision/eligibility.ts +19 -1
- package/src/vision/index.ts +4 -18
- package/gui/dist/assets/index-C14iCj_Q.js +0 -112
- package/gui/dist/assets/index-D7PIz7_g.css +0 -1
|
@@ -263,7 +263,24 @@ function preflight(input: IntegrationWriteInput) {
|
|
|
263
263
|
return { failed: undefined, store, io, clientId, spec, exportSpec, configPath, detectDir, before, parsed, contribution, record, classified } as const;
|
|
264
264
|
}
|
|
265
265
|
|
|
266
|
-
|
|
266
|
+
/**
|
|
267
|
+
* How a conflicted document is treated.
|
|
268
|
+
*
|
|
269
|
+
* `refuse` is the default and the only behavior that existed: a conflict means
|
|
270
|
+
* something we did not write occupies our paths, or our own block was edited,
|
|
271
|
+
* and guessing which one the user meant to keep is how a toggle deletes work.
|
|
272
|
+
*
|
|
273
|
+
* `overwrite` is the explicit escape hatch. It is never reached by a plain
|
|
274
|
+
* apply -- the caller has to ask for it by name -- because the whole value of
|
|
275
|
+
* the refusal is that it cannot be triggered by accident.
|
|
276
|
+
*/
|
|
277
|
+
type ConflictPolicy = "refuse" | "overwrite";
|
|
278
|
+
|
|
279
|
+
function applyOrRefreshIntegration(
|
|
280
|
+
input: IntegrationWriteInput,
|
|
281
|
+
allowAbsent: boolean,
|
|
282
|
+
conflictPolicy: ConflictPolicy = "refuse",
|
|
283
|
+
): WriteOutcome {
|
|
267
284
|
const pre = preflight(input);
|
|
268
285
|
if (pre.failed) return pre.failed;
|
|
269
286
|
const { store, io, clientId, spec, exportSpec, configPath, detectDir, before, parsed, contribution, record, classified } = pre;
|
|
@@ -278,10 +295,20 @@ function applyOrRefreshIntegration(input: IntegrationWriteInput, allowAbsent: bo
|
|
|
278
295
|
`The generated ${clientId} integration is loopback-only and does not emit the admission header a non-loopback bind requires. Give it loopback access instead, through a tunnel or a local forwarder.`);
|
|
279
296
|
}
|
|
280
297
|
if (classified.state === "conflict") {
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
298
|
+
if (conflictPolicy === "refuse") {
|
|
299
|
+
return refuse(clientId, "conflict", "conflict",
|
|
300
|
+
classified.reason === "foreign-edit"
|
|
301
|
+
? `${configPath} changed after opencodex wrote it`
|
|
302
|
+
: `${configPath} already contains an opencodex block we did not write`);
|
|
303
|
+
}
|
|
304
|
+
/*
|
|
305
|
+
* The caller asked for the overwrite explicitly, so the merge below runs
|
|
306
|
+
* against the document as it stands and our block replaces whatever holds
|
|
307
|
+
* our paths. Everything that makes it recoverable is shared with apply --
|
|
308
|
+
* the snapshot, the atomic write, the compare-before-commit recheck and the
|
|
309
|
+
* journal row all come from the same commit() call -- which is why this is a
|
|
310
|
+
* policy flag on one code path rather than a second implementation.
|
|
311
|
+
*/
|
|
285
312
|
}
|
|
286
313
|
/*
|
|
287
314
|
* `unsafe` from the classifier means the document is not one we may write
|
|
@@ -327,7 +354,22 @@ function applyOrRefreshIntegration(input: IntegrationWriteInput, allowAbsent: bo
|
|
|
327
354
|
*/
|
|
328
355
|
const base = classified.state === "stale" && record
|
|
329
356
|
? removeFragments(parsed, record.fragmentPaths, new Set(record.createdContainers ?? [])).doc
|
|
330
|
-
:
|
|
357
|
+
: classified.state === "conflict" && record
|
|
358
|
+
/*
|
|
359
|
+
* A forced overwrite of a `foreign-edit` conflict drops what the previous
|
|
360
|
+
* record owned for the same reason a stale refresh does: the replacement
|
|
361
|
+
* record covers the paths we are about to write, so a path the old record
|
|
362
|
+
* owned and the new one does not would be stranded forever, unremovable by
|
|
363
|
+
* any later disable.
|
|
364
|
+
*
|
|
365
|
+
* With NO record -- an `unowned-key` conflict -- there is nothing to drop and
|
|
366
|
+
* the merge runs against the user's document directly. That is correct:
|
|
367
|
+
* createdContainerPaths then attributes every container they already had to
|
|
368
|
+
* them, so a later disable removes our leaves and leaves their structure
|
|
369
|
+
* standing.
|
|
370
|
+
*/
|
|
371
|
+
? removeFragments(parsed, record.fragmentPaths, new Set(record.createdContainers ?? [])).doc
|
|
372
|
+
: parsed;
|
|
331
373
|
// Computed against the document as it stands BEFORE the merge: afterwards
|
|
332
374
|
// every container exists and "did we create this?" is unanswerable.
|
|
333
375
|
const created = createdContainerPaths(base, contribution);
|
|
@@ -375,7 +417,16 @@ function applyOrRefreshIntegration(input: IntegrationWriteInput, allowAbsent: bo
|
|
|
375
417
|
const snapshot = store.captureSnapshot(clientId, opId, before);
|
|
376
418
|
const at = new Date(io.now()).toISOString();
|
|
377
419
|
const entry: JournalEntry = {
|
|
378
|
-
|
|
420
|
+
/*
|
|
421
|
+
* `overwrite` is its own kind rather than reusing `apply`. The rollback list
|
|
422
|
+
* is the one place a user goes after a mistake, and "applied" is a lie about
|
|
423
|
+
* an operation that replaced a block somebody else wrote.
|
|
424
|
+
*/
|
|
425
|
+
opId, clientId,
|
|
426
|
+
kind: classified.state === "conflict"
|
|
427
|
+
? "overwrite"
|
|
428
|
+
: classified.state === "stale" ? "refresh" : "apply",
|
|
429
|
+
at, configPath,
|
|
379
430
|
snapshot, resultFingerprint: fingerprint(text), resultAbsent: false, priorRecord: record,
|
|
380
431
|
};
|
|
381
432
|
const refreshablePaths = refreshablePathsOf(contribution);
|
|
@@ -406,6 +457,23 @@ export function applyIntegration(input: IntegrationWriteInput): WriteOutcome {
|
|
|
406
457
|
return applyOrRefreshIntegration(input, true);
|
|
407
458
|
}
|
|
408
459
|
|
|
460
|
+
/**
|
|
461
|
+
* Apply over a conflicted config, replacing whatever holds our paths.
|
|
462
|
+
*
|
|
463
|
+
* Separate from `applyIntegration` and never a flag on it: a caller has to name
|
|
464
|
+
* this function to get the behavior, so no existing call site can acquire it by
|
|
465
|
+
* passing a default through.
|
|
466
|
+
*
|
|
467
|
+
* What it does NOT relax. `unsafe` still refuses -- a blocked container means the
|
|
468
|
+
* merge would replace a value it cannot reason about, and a snapshot is not a
|
|
469
|
+
* licence for that. `not_installed` and `non_loopback` still refuse; neither is a
|
|
470
|
+
* conflict. And a non-conflict state behaves exactly as apply does, so calling
|
|
471
|
+
* this on a clean file is not a way to skip any other check.
|
|
472
|
+
*/
|
|
473
|
+
export function overwriteIntegration(input: IntegrationWriteInput): WriteOutcome {
|
|
474
|
+
return applyOrRefreshIntegration(input, true, "overwrite");
|
|
475
|
+
}
|
|
476
|
+
|
|
409
477
|
/** Refresh an owned stale block, but never create or reconnect an absent one. */
|
|
410
478
|
export function refreshIntegration(input: IntegrationWriteInput): WriteOutcome {
|
|
411
479
|
return applyOrRefreshIntegration(input, false);
|
|
@@ -698,6 +766,13 @@ export function refreshIntegrationCoordinated(
|
|
|
698
766
|
return coordinatedWrite(input, refreshIntegration, options);
|
|
699
767
|
}
|
|
700
768
|
|
|
769
|
+
export function overwriteIntegrationCoordinated(
|
|
770
|
+
input: IntegrationWriteInput,
|
|
771
|
+
options?: CoordinatedIntegrationOptions,
|
|
772
|
+
): Promise<WriteOutcome> {
|
|
773
|
+
return coordinatedWrite(input, overwriteIntegration, options);
|
|
774
|
+
}
|
|
775
|
+
|
|
701
776
|
export function disableIntegrationCoordinated(
|
|
702
777
|
input: IntegrationWriteInput,
|
|
703
778
|
options?: CoordinatedIntegrationOptions,
|
|
@@ -29,6 +29,24 @@ export interface GracefulStopIo {
|
|
|
29
29
|
waitExit?: (pid: number, timeoutMs: number) => boolean;
|
|
30
30
|
env?: Record<string, string | undefined>;
|
|
31
31
|
exitTimeoutMs?: number;
|
|
32
|
+
/**
|
|
33
|
+
* Nonce of the pending-teardown receipt this caller claimed.
|
|
34
|
+
*
|
|
35
|
+
* `ocx stop` sets it because it restores shared client config itself, only after
|
|
36
|
+
* proving a stopped Task Scheduler did not respawn the proxy (#3008). The nonce is what
|
|
37
|
+
* makes the deferral an owned obligation rather than a flag anyone can set: the proxy
|
|
38
|
+
* honours it only when it names the receipt actually on disk. Direct callers omit it
|
|
39
|
+
* and keep the self-contained behaviour.
|
|
40
|
+
*/
|
|
41
|
+
deferSharedTeardownNonce?: string;
|
|
42
|
+
/**
|
|
43
|
+
* Endpoint the caller already resolved for this pid.
|
|
44
|
+
*
|
|
45
|
+
* `ocx stop` records this same snapshot in its pending-teardown receipt. Re-reading the
|
|
46
|
+
* runtime file here could pick up a different one, which would make the receipt name an
|
|
47
|
+
* endpoint the stop never contacted — and recovery probes exactly that endpoint.
|
|
48
|
+
*/
|
|
49
|
+
runtimeEndpoint?: { hostname: string; port: number };
|
|
32
50
|
}
|
|
33
51
|
|
|
34
52
|
/**
|
|
@@ -67,7 +85,7 @@ export class ProxyOwnershipRefusedError extends Error {}
|
|
|
67
85
|
*/
|
|
68
86
|
export async function stopProxyGracefully(pid: number, io: GracefulStopIo = {}): Promise<GracefulStopResult> {
|
|
69
87
|
const readRuntime = io.readRuntime ?? readRuntimePort;
|
|
70
|
-
const runtime = readRuntime(pid);
|
|
88
|
+
const runtime = io.runtimeEndpoint ?? readRuntime(pid);
|
|
71
89
|
if (!runtime?.port) return false;
|
|
72
90
|
const env = io.env ?? process.env;
|
|
73
91
|
const headers: Record<string, string> = {};
|
|
@@ -75,7 +93,14 @@ export async function stopProxyGracefully(pid: number, io: GracefulStopIo = {}):
|
|
|
75
93
|
if (token) headers["x-opencodex-api-key"] = token;
|
|
76
94
|
const fetchFn = io.fetchFn ?? fetch;
|
|
77
95
|
try {
|
|
78
|
-
|
|
96
|
+
// `ocx stop` asks the proxy NOT to restore shared client config: it does that itself,
|
|
97
|
+
// after verifying a stopped Task Scheduler did not respawn the proxy (#3008). Letting
|
|
98
|
+
// the child do it means a survivor found seconds later has already lost its config.
|
|
99
|
+
const stopUrl = `http://${gracefulStopHost(runtime.hostname)}:${runtime.port}/api/stop`
|
|
100
|
+
+ (io.deferSharedTeardownNonce
|
|
101
|
+
? `?deferSharedTeardown=1&teardownNonce=${encodeURIComponent(io.deferSharedTeardownNonce)}`
|
|
102
|
+
: "");
|
|
103
|
+
const res = await fetchFn(stopUrl, {
|
|
79
104
|
method: "POST",
|
|
80
105
|
headers,
|
|
81
106
|
// Hung proxies with many CLOSE_WAIT clients can be slow to accept; give them
|
|
@@ -107,10 +132,10 @@ function drainDeadlineMs(): number {
|
|
|
107
132
|
}
|
|
108
133
|
|
|
109
134
|
/** Graceful-first stop: management-API drain, then the platform kill ladder. */
|
|
110
|
-
export async function stopProxy(pid: number): Promise<void> {
|
|
135
|
+
export async function stopProxy(pid: number, io: GracefulStopIo = {}): Promise<void> {
|
|
111
136
|
if (!isProcessAlive(pid)) return;
|
|
112
|
-
const runtime = readRuntimePort(pid);
|
|
113
|
-
const graceful = await stopProxyGracefully(pid);
|
|
137
|
+
const runtime = io.runtimeEndpoint ?? readRuntimePort(pid);
|
|
138
|
+
const graceful = await stopProxyGracefully(pid, io);
|
|
114
139
|
if (graceful === "refused") {
|
|
115
140
|
// The proxy refused on purpose (foreign service owns it). Forcing would strip shared
|
|
116
141
|
// config while that service keeps the proxy alive.
|
|
@@ -9,6 +9,7 @@ import { reconcileProviderFetchWarnings } from "../codex/catalog/provider-fetch"
|
|
|
9
9
|
import { reconcileModelCacheGeneration } from "../codex/model-cache";
|
|
10
10
|
import { reconcilePoolRotationState } from "../codex/pool-rotation";
|
|
11
11
|
import { reconcileCodexQuotaAccounts } from "../codex/quota";
|
|
12
|
+
import { reconcileQuotaRecovery, sweepExpiredQuotaRecovery } from "../codex/quota-401-recovery";
|
|
12
13
|
import {
|
|
13
14
|
listLiveCodexAccountIds,
|
|
14
15
|
reconcileCodexRoutingHealth,
|
|
@@ -84,6 +85,13 @@ export const STATE_STORE_REGISTRATIONS = [
|
|
|
84
85
|
},
|
|
85
86
|
{ name: "anthropic-routing-health", sweepExpired: sweepExpiredAnthropicRoutingHealth },
|
|
86
87
|
{ name: "xai-refresh-verdicts", sweepExpired: sweepExpiredXaiPermanentFailureVerdicts },
|
|
88
|
+
{
|
|
89
|
+
name: "codex-quota-401-recovery",
|
|
90
|
+
// Only backoff windows and abandoned leases expire. A spent fence is durable: expiring
|
|
91
|
+
// it would grant the same credential lineage a second refresh (#3019).
|
|
92
|
+
sweepExpired: sweepExpiredQuotaRecovery,
|
|
93
|
+
reconcileGeneration: context => reconcileQuotaRecovery(context.codexAccountIds),
|
|
94
|
+
},
|
|
87
95
|
{
|
|
88
96
|
name: "responses-continuation",
|
|
89
97
|
sweepExpired: sweepExpiredResponseStates,
|
package/src/oauth/index.ts
CHANGED
|
@@ -1,10 +1,34 @@
|
|
|
1
1
|
import type { KiroOAuthMetadata, OAuthController, OAuthCredentials } from "./types";
|
|
2
2
|
import { parseCallbackInput } from "./callback-server";
|
|
3
3
|
import type { OcxConfig, OcxProviderConfig, RefreshPolicy } from "../types";
|
|
4
|
-
import { loadConfig, resolveEnvValue, saveConfig } from "../config";
|
|
4
|
+
import { ConfigMutationLockError, loadConfig, resolveEnvValue, saveConfig } from "../config";
|
|
5
5
|
import { maskEmail } from "../lib/privacy";
|
|
6
6
|
import { KiroTokenRefreshError, environmentKiroRoutingMetadata, loginKiro, refreshKiroToken, settleKiroLoginTransaction } from "./kiro";
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
OAuthMutationBusyError,
|
|
9
|
+
OAuthRefreshIntentIOError,
|
|
10
|
+
clearOAuthRefreshIntent,
|
|
11
|
+
clearOAuthRefreshIntentIfMatch,
|
|
12
|
+
createOAuthRefreshIntentLock,
|
|
13
|
+
credentialGeneration,
|
|
14
|
+
getAccountCredential,
|
|
15
|
+
getAccountCredentialWithStatus,
|
|
16
|
+
getAccountSet,
|
|
17
|
+
getCredential,
|
|
18
|
+
markAccountNeedsReauthIfGeneration,
|
|
19
|
+
markOAuthRefreshIntentCleanupPending,
|
|
20
|
+
markOAuthRefreshIntentStaleOwner,
|
|
21
|
+
mergeAccountCredential,
|
|
22
|
+
normalizeAuthStoreBuffer,
|
|
23
|
+
readOAuthRefreshIntent,
|
|
24
|
+
removeAccount,
|
|
25
|
+
saveAccountCredential,
|
|
26
|
+
saveCredential,
|
|
27
|
+
setActiveAccount,
|
|
28
|
+
writeOAuthRefreshIntent,
|
|
29
|
+
type OAuthRefreshIntent,
|
|
30
|
+
type OAuthRefreshIntentCleanupPending,
|
|
31
|
+
} from "./store";
|
|
8
32
|
import { loginXai, refreshXaiToken, XAI_LOCAL_CLI_DETACH_WARNING, XaiTokenRequestError } from "./xai";
|
|
9
33
|
import { ANTHROPIC_OAUTH_BETA, AnthropicTokenError, loginAnthropic, refreshAnthropicToken } from "./anthropic";
|
|
10
34
|
import { loginKimi, refreshKimiToken } from "./kimi";
|
|
@@ -559,9 +583,152 @@ function terminal(error:unknown):boolean{
|
|
|
559
583
|
// Local durable-write/read/cleanup failures are operational, not credential
|
|
560
584
|
// death: the provider credential was never rejected or consumed. Never mark
|
|
561
585
|
// the account needsReauth for broken local persistence infrastructure.
|
|
562
|
-
if (error instanceof RefreshIntentIOError) return false;
|
|
586
|
+
if (error instanceof RefreshIntentIOError || error instanceof OAuthRefreshIntentIOError) return false;
|
|
563
587
|
return isTerminalRefreshError(error);
|
|
564
588
|
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* True when the token endpoint definitively answered and rejected the request.
|
|
592
|
+
*
|
|
593
|
+
* The Anthropic adapter attaches an HTTP status only to an explicit non-success response,
|
|
594
|
+
* which is the retryable rejection this PR handles. Everything else (timeout, dropped
|
|
595
|
+
* connection, a body that could not be read or parsed, or a local persistence fault) leaves
|
|
596
|
+
* the outcome unknown: the server may already have rotated the token, and a blind replay
|
|
597
|
+
* could trip refresh-token-reuse revocation. Those cases must keep the refresh intent.
|
|
598
|
+
*
|
|
599
|
+
* Deliberately narrower than `terminal()`, which asks whether the CREDENTIAL is dead.
|
|
600
|
+
* This asks the different question of whether the ATTEMPT is known to have failed.
|
|
601
|
+
*/
|
|
602
|
+
function definitivelyAnswered(error: unknown): boolean {
|
|
603
|
+
if (error instanceof AnthropicTokenError) return error.httpStatus !== undefined;
|
|
604
|
+
return false;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* Intent cleanup is secondary to the refresh outcome it protects.
|
|
609
|
+
*
|
|
610
|
+
* Once a credential is already durable, cleanup remains secondary and best-effort. A known
|
|
611
|
+
* failed attempt takes the stricter path below: its retry-safe marker must become durable before
|
|
612
|
+
* the original provider error can be returned.
|
|
613
|
+
*/
|
|
614
|
+
function clearAnthropicRefreshIntentBestEffort(
|
|
615
|
+
provider: string,
|
|
616
|
+
accountId: string,
|
|
617
|
+
expected: OAuthRefreshIntent,
|
|
618
|
+
): boolean {
|
|
619
|
+
try {
|
|
620
|
+
return expected.attemptId
|
|
621
|
+
? clearOAuthRefreshIntentIfMatch(provider, accountId, expected)
|
|
622
|
+
: clearOAuthRefreshIntent(provider, accountId, expected.generation);
|
|
623
|
+
} catch {
|
|
624
|
+
console.warn(
|
|
625
|
+
"[opencodex] Anthropic refresh intent cleanup failed; preserving the durable replay guard.",
|
|
626
|
+
);
|
|
627
|
+
return false;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
const ANTHROPIC_INTENT_MARK_RETRY_DELAYS_MS = [10, 25, 50] as const;
|
|
632
|
+
|
|
633
|
+
function isConfigMutationLockContention(error: unknown): boolean {
|
|
634
|
+
if (!(error instanceof ConfigMutationLockError)) return false;
|
|
635
|
+
const cause = error.cause;
|
|
636
|
+
const code = cause && typeof cause === "object" && "code" in cause
|
|
637
|
+
? String((cause as { code?: unknown }).code)
|
|
638
|
+
: "";
|
|
639
|
+
return code === "SQLITE_BUSY" || code === "SQLITE_LOCKED";
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
async function clearAnthropicRefreshIntentForKnownFailure(
|
|
643
|
+
provider: string,
|
|
644
|
+
accountId: string,
|
|
645
|
+
expected: OAuthRefreshIntent,
|
|
646
|
+
cleanupPending: OAuthRefreshIntentCleanupPending,
|
|
647
|
+
refreshError: unknown,
|
|
648
|
+
): Promise<boolean> {
|
|
649
|
+
let marked: OAuthRefreshIntent | undefined;
|
|
650
|
+
for (let attempt = 0; attempt <= ANTHROPIC_INTENT_MARK_RETRY_DELAYS_MS.length; attempt += 1) {
|
|
651
|
+
try {
|
|
652
|
+
marked = markOAuthRefreshIntentCleanupPending(
|
|
653
|
+
provider,
|
|
654
|
+
accountId,
|
|
655
|
+
expected,
|
|
656
|
+
cleanupPending,
|
|
657
|
+
);
|
|
658
|
+
break;
|
|
659
|
+
} catch (cause) {
|
|
660
|
+
const retryDelay = ANTHROPIC_INTENT_MARK_RETRY_DELAYS_MS[attempt];
|
|
661
|
+
if (!isConfigMutationLockContention(cause) || retryDelay === undefined) {
|
|
662
|
+
throw new OAuthRefreshIntentIOError(
|
|
663
|
+
"mark-cleanup-pending",
|
|
664
|
+
cause,
|
|
665
|
+
refreshError,
|
|
666
|
+
);
|
|
667
|
+
}
|
|
668
|
+
// The provider has definitively answered, so caller cancellation no longer changes the
|
|
669
|
+
// settlement obligation. Yield briefly while retaining the per-account refresh lock, then
|
|
670
|
+
// rerun the existing compare-and-swap marker against current disk state.
|
|
671
|
+
await Bun.sleep(retryDelay);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
if (!marked) {
|
|
675
|
+
throw new OAuthRefreshIntentIOError(
|
|
676
|
+
"mark-cleanup-pending",
|
|
677
|
+
new Error("Anthropic refresh intent changed before safe cleanup"),
|
|
678
|
+
refreshError,
|
|
679
|
+
);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
let cleared: boolean;
|
|
683
|
+
try {
|
|
684
|
+
cleared = clearOAuthRefreshIntentIfMatch(provider, accountId, marked);
|
|
685
|
+
} catch {
|
|
686
|
+
console.warn(
|
|
687
|
+
"[opencodex] Anthropic refresh intent cleanup failed; retry-safe cleanup remains pending.",
|
|
688
|
+
);
|
|
689
|
+
return false;
|
|
690
|
+
}
|
|
691
|
+
if (!cleared) {
|
|
692
|
+
throw new OAuthRefreshIntentIOError(
|
|
693
|
+
"clear-cleanup-pending",
|
|
694
|
+
new Error("Anthropic refresh intent changed during safe cleanup"),
|
|
695
|
+
refreshError,
|
|
696
|
+
);
|
|
697
|
+
}
|
|
698
|
+
return true;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
function resumeAnthropicRefreshIntentCleanup(
|
|
702
|
+
provider: string,
|
|
703
|
+
accountId: string,
|
|
704
|
+
pendingIntent: OAuthRefreshIntent,
|
|
705
|
+
): void {
|
|
706
|
+
let cleared: boolean;
|
|
707
|
+
try {
|
|
708
|
+
cleared = clearOAuthRefreshIntentIfMatch(provider, accountId, pendingIntent);
|
|
709
|
+
} catch (cause) {
|
|
710
|
+
throw new OAuthRefreshIntentIOError(
|
|
711
|
+
"resume-cleanup",
|
|
712
|
+
cause,
|
|
713
|
+
);
|
|
714
|
+
}
|
|
715
|
+
if (!cleared) {
|
|
716
|
+
throw new OAuthRefreshIntentIOError(
|
|
717
|
+
"resume-cleanup",
|
|
718
|
+
new Error("Pending Anthropic refresh intent changed before cleanup"),
|
|
719
|
+
);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
function clearObservedAnthropicRefreshIntent(
|
|
724
|
+
provider: string,
|
|
725
|
+
accountId: string,
|
|
726
|
+
pendingIntent: OAuthRefreshIntent,
|
|
727
|
+
): boolean {
|
|
728
|
+
return pendingIntent.attemptId
|
|
729
|
+
? clearOAuthRefreshIntentIfMatch(provider, accountId, pendingIntent)
|
|
730
|
+
: clearOAuthRefreshIntent(provider, accountId, pendingIntent.generation);
|
|
731
|
+
}
|
|
565
732
|
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;}
|
|
566
733
|
function merged(fresh: OAuthCredentials, previous: OAuthCredentials): OAuthCredentials {
|
|
567
734
|
return {
|
|
@@ -647,13 +814,19 @@ export async function refreshAnthropicAccountWithLock(
|
|
|
647
814
|
afterPrePersistRead: deps.afterPrePersistRead,
|
|
648
815
|
});
|
|
649
816
|
if (outcome.superseded) {
|
|
650
|
-
|
|
817
|
+
// The disk credential is already durable here, so cleanup is secondary: an unlink
|
|
818
|
+
// failure must not mask a committed credential by throwing over the return below.
|
|
819
|
+
if (pendingIntent) clearAnthropicRefreshIntentBestEffort(provider, accountId, pendingIntent);
|
|
651
820
|
if (outcome.stored.expires > now() + REFRESH_SKEW_MS) return outcome.stored.access;
|
|
652
821
|
throw new OAuthLoginRequiredError(provider);
|
|
653
822
|
}
|
|
654
|
-
if (pendingIntent)
|
|
823
|
+
if (pendingIntent) clearAnthropicRefreshIntentBestEffort(provider, accountId, pendingIntent);
|
|
655
824
|
return disk.access;
|
|
656
825
|
}
|
|
826
|
+
if (pendingIntent?.cleanupPending && pendingIntent.generation === generation) {
|
|
827
|
+
resumeAnthropicRefreshIntentCleanup(provider, accountId, pendingIntent);
|
|
828
|
+
pendingIntent = undefined;
|
|
829
|
+
}
|
|
657
830
|
if (!pendingIntent?.uncertain && pendingIntent?.generation === generation) {
|
|
658
831
|
if (pendingIntent.staleOwner) throw new OAuthTokenRefreshStaleError();
|
|
659
832
|
if (deps.replacedStaleFlight && pendingIntent.flightId === deps.replacedStaleFlight.flightId) {
|
|
@@ -661,7 +834,9 @@ export async function refreshAnthropicAccountWithLock(
|
|
|
661
834
|
markOAuthRefreshIntentStaleOwner(provider, accountId, generation, deps.replacedStaleFlight.flightId);
|
|
662
835
|
throw new OAuthTokenRefreshStaleError();
|
|
663
836
|
}
|
|
664
|
-
|
|
837
|
+
if (!clearObservedAnthropicRefreshIntent(provider, accountId, pendingIntent)) {
|
|
838
|
+
throw new OAuthTokenRefreshStaleError();
|
|
839
|
+
}
|
|
665
840
|
pendingIntent = undefined;
|
|
666
841
|
}
|
|
667
842
|
}
|
|
@@ -669,7 +844,9 @@ export async function refreshAnthropicAccountWithLock(
|
|
|
669
844
|
await markAccountNeedsReauthIfGeneration(provider, accountId, generation, writerGeneration);
|
|
670
845
|
throw new OAuthLoginRequiredError(provider);
|
|
671
846
|
}
|
|
672
|
-
if (pendingIntent
|
|
847
|
+
if (pendingIntent && !clearObservedAnthropicRefreshIntent(provider, accountId, pendingIntent)) {
|
|
848
|
+
throw new OAuthTokenRefreshStaleError();
|
|
849
|
+
}
|
|
673
850
|
if (account?.needsReauth) {
|
|
674
851
|
throw new OAuthLoginRequiredError(provider);
|
|
675
852
|
}
|
|
@@ -677,9 +854,15 @@ export async function refreshAnthropicAccountWithLock(
|
|
|
677
854
|
return stored.access;
|
|
678
855
|
}
|
|
679
856
|
|
|
857
|
+
let refreshMayHaveReachedProvider = false;
|
|
858
|
+
let attemptIntent: OAuthRefreshIntent | undefined;
|
|
680
859
|
try {
|
|
681
|
-
writeOAuthRefreshIntent(provider, accountId, generation, now(), deps.flight?.flightId);
|
|
860
|
+
attemptIntent = writeOAuthRefreshIntent(provider, accountId, generation, now(), deps.flight?.flightId);
|
|
682
861
|
if (deps.signal?.aborted) throw deps.signal.reason;
|
|
862
|
+
// From this point on, even a synchronous client error is conservatively post-dispatch:
|
|
863
|
+
// the provider may have received and rotated the refresh token before the caller learned
|
|
864
|
+
// the outcome.
|
|
865
|
+
refreshMayHaveReachedProvider = true;
|
|
683
866
|
if (deps.flight) deps.flight.dispatched = true;
|
|
684
867
|
const fresh = merged(await def.refresh(stored.refresh, deps.signal), stored);
|
|
685
868
|
const outcome = await mergeAccountCredential(provider, accountId, fresh, {
|
|
@@ -687,17 +870,41 @@ export async function refreshAnthropicAccountWithLock(
|
|
|
687
870
|
afterPrePersistRead: deps.afterPrePersistRead,
|
|
688
871
|
});
|
|
689
872
|
if (outcome.superseded) {
|
|
690
|
-
|
|
873
|
+
if (attemptIntent) clearAnthropicRefreshIntentBestEffort(provider, accountId, attemptIntent);
|
|
691
874
|
if (outcome.stored.expires > now() + REFRESH_SKEW_MS) return outcome.stored.access;
|
|
692
875
|
throw new OAuthLoginRequiredError(provider);
|
|
693
876
|
}
|
|
694
|
-
|
|
877
|
+
// The rotated credential is durable now. A cleanup failure must not turn that committed
|
|
878
|
+
// success into a refresh failure; the old-generation intent remains a conservative guard.
|
|
879
|
+
if (attemptIntent) clearAnthropicRefreshIntentBestEffort(provider, accountId, attemptIntent);
|
|
695
880
|
return fresh.access;
|
|
696
881
|
} catch (error) {
|
|
697
|
-
if (error instanceof OAuthMutationBusyError) throw error;
|
|
698
|
-
if (!terminal(error))
|
|
882
|
+
if (error instanceof OAuthMutationBusyError || error instanceof OAuthTokenRefreshStaleError) throw error;
|
|
883
|
+
if (!terminal(error)) {
|
|
884
|
+
// A non-terminal failure tells the caller to retry, but the intent outlived it, so
|
|
885
|
+
// the next attempt hit the pending-intent branch above and raised
|
|
886
|
+
// OAuthLoginRequiredError. One 503 locked the account out of refresh until manual
|
|
887
|
+
// re-auth even after upstream recovered.
|
|
888
|
+
//
|
|
889
|
+
// Only clear the intent when the server DEFINITIVELY answered and rejected the
|
|
890
|
+
// request. The adapter attaches an HTTP status only to that explicit non-success
|
|
891
|
+
// response. A timeout, a dropped connection, or an unreadable/unparseable body
|
|
892
|
+
// carries no status: the server may already have
|
|
893
|
+
// rotated the token, and replaying it could trip refresh-token-reuse revocation.
|
|
894
|
+
// Those outcomes keep the intent so the guard still refuses a blind replay.
|
|
895
|
+
if ((!refreshMayHaveReachedProvider || definitivelyAnswered(error)) && attemptIntent) {
|
|
896
|
+
await clearAnthropicRefreshIntentForKnownFailure(
|
|
897
|
+
provider,
|
|
898
|
+
accountId,
|
|
899
|
+
attemptIntent,
|
|
900
|
+
refreshMayHaveReachedProvider ? "definitive-rejection" : "pre-dispatch",
|
|
901
|
+
error,
|
|
902
|
+
);
|
|
903
|
+
}
|
|
904
|
+
throw error;
|
|
905
|
+
}
|
|
699
906
|
await markAccountNeedsReauthIfGeneration(provider, accountId, generation, writerGeneration);
|
|
700
|
-
|
|
907
|
+
if (attemptIntent) clearAnthropicRefreshIntentBestEffort(provider, accountId, attemptIntent);
|
|
701
908
|
throw new OAuthLoginRequiredError(provider);
|
|
702
909
|
}
|
|
703
910
|
} finally {
|