@bitkyc08/opencodex 2.68.0 → 2.69.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 +25 -17
- package/gui/dist/assets/App-DDbgOTh6.js +51 -0
- package/gui/dist/assets/{Tray-Bn8WhKBH.js → Tray-uqKRqfvw.js} +1 -1
- package/gui/dist/assets/index-BF9QnKO_.css +1 -0
- package/gui/dist/assets/index-CuaBJkG4.js +86 -0
- package/gui/dist/assets/{quota-summary-DL-bNuNL.js → quota-summary-COppQSVq.js} +1 -1
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/factory-droid.svg +8 -0
- package/package.json +1 -1
- package/src/adapters/anthropic-output-schema.ts +109 -11
- package/src/adapters/coding-agent/protocol.ts +106 -11
- package/src/adapters/coding-agent/turn.ts +23 -18
- package/src/adapters/google-errors.ts +16 -1
- package/src/adapters/google-http.ts +14 -0
- package/src/adapters/openai-chat/passthrough.ts +2 -0
- package/src/adapters/openai-chat/summary-budget.ts +72 -0
- package/src/adapters/openai-chat/tool-call-id-remint.ts +11 -2
- package/src/adapters/openai-chat.ts +6 -9
- package/src/bridge/response-json.ts +3 -1
- package/src/bridge/sse.ts +3 -1
- package/src/claude/desktop-picker.ts +6 -2
- package/src/claude/desktop-system-proxy.ts +155 -0
- package/src/claude/inbound-model-options.ts +10 -2
- package/src/claude/inbound.ts +1 -1
- package/src/claude/intercept/picker-ca-cleanup.ts +31 -0
- package/src/claude/intercept/picker-ca.ts +283 -83
- package/src/claude/intercept/picker-models.ts +4 -1
- package/src/claude/intercept/picker-trust.ts +15 -2
- package/src/claude/intercept/runtime.ts +99 -32
- package/src/cli/account-api.ts +5 -2
- package/src/cli/account-extended.ts +47 -11
- package/src/cli/account-target.ts +12 -7
- package/src/cli/account.ts +47 -3
- package/src/cli/capabilities.ts +33 -7
- package/src/cli/claude-agent-startup-sync.ts +2 -0
- package/src/cli/claude-desktop.ts +37 -13
- package/src/cli/cross-home-owner.ts +138 -0
- package/src/cli/dispatch.ts +1 -1
- package/src/cli/doctor.ts +18 -8
- package/src/cli/ensure-desired-integrations.ts +5 -0
- package/src/cli/export-command.ts +6 -6
- package/src/cli/help.ts +1 -1
- package/src/cli/index.ts +15 -18
- package/src/cli/integrations.ts +8 -1
- package/src/cli/models-runtime-subcommands.ts +1 -0
- package/src/cli/models-runtime.ts +106 -4
- package/src/cli/models.ts +6 -1
- package/src/cli/opencode.ts +2 -83
- package/src/cli/registry.ts +3 -2
- package/src/cli/status.ts +86 -7
- package/src/client/connect.ts +24 -10
- package/src/client/link-ingress.ts +3 -1
- package/src/client/link-join.ts +76 -9
- package/src/client/link-relay-transport.ts +184 -0
- package/src/client/link-relay.ts +16 -4
- package/src/client/machine-listener.ts +2 -0
- package/src/clients/config-export/constants.ts +8 -0
- package/src/clients/config-export/contracts.ts +13 -1
- package/src/clients/config-export/droid.ts +71 -0
- package/src/clients/config-export/kilo.ts +116 -0
- package/src/clients/config-export.ts +33 -2
- package/src/codex/auth-api/routes.ts +1 -1
- package/src/codex/catalog/access-programs.ts +14 -2
- package/src/codex/catalog/provider-models.ts +1 -1
- package/src/codex/catalog-auto-refresh.ts +122 -0
- package/src/codex/config-drift-heal.ts +80 -0
- package/src/codex/desktop-switches.ts +7 -5
- package/src/codex/inject/bounded-config-reader.ts +69 -0
- package/src/codex/inject/config-toml.ts +31 -3
- package/src/codex/inject/routing-target.ts +6 -1
- package/src/codex/inject.ts +4 -0
- package/src/codex/model-entitlements.ts +32 -5
- package/src/codex/native-profile-startup.ts +17 -5
- package/src/codex/project-config-warnings.ts +72 -21
- package/src/codex/quota-auto-refresh-state.ts +8 -2
- package/src/codex/quota-auto-refresh.ts +80 -9
- package/src/codex/sync.ts +10 -0
- package/src/combos/failover.ts +4 -1
- package/src/combos/jev.ts +15 -4
- package/src/combos/types.ts +16 -0
- package/src/config/diagnostics.ts +19 -1
- package/src/config/load-degrade.ts +29 -0
- package/src/config/macos-system-proxy.ts +114 -0
- package/src/config/proxy-env.ts +67 -20
- package/src/config/schema/config-schema.ts +15 -0
- package/src/config/schema/leaf-validators.ts +23 -0
- package/src/config.ts +2 -0
- package/src/generated/compatibility-version.json +220 -144
- package/src/integrations/catalog-refresh.ts +1 -1
- package/src/integrations/config-io.ts +12 -3
- package/src/integrations/droid-settings.ts +65 -0
- package/src/integrations/kilo-candidates.ts +30 -0
- package/src/integrations/mutation-plan.ts +62 -7
- package/src/integrations/native/ownership-preflight.ts +46 -12
- package/src/integrations/registry.ts +136 -7
- package/src/integrations/state.ts +52 -4
- package/src/integrations/target.ts +3 -0
- package/src/integrations/writer.ts +55 -14
- package/src/lib/account-selection-events.ts +14 -0
- package/src/lib/jsonc.ts +110 -0
- package/src/lib/standalone.ts +9 -1
- package/src/lib/windows-system-proxy.ts +101 -1
- package/src/link/relay-auth.ts +29 -0
- package/src/link/ssh-argv.ts +6 -3
- package/src/link/ssh-runner.ts +5 -5
- package/src/oauth/generic-account-failover.ts +52 -8
- package/src/oauth/index.ts +37 -14
- package/src/oauth/store.ts +96 -11
- package/src/oauth/token-guardian.ts +7 -3
- package/src/oauth/types.ts +2 -0
- package/src/plugins/loader.ts +5 -3
- package/src/providers/fastwire.ts +3 -0
- package/src/providers/key-failover.ts +13 -5
- package/src/providers/kiro-model-catalog.ts +4 -3
- package/src/providers/model-rename-fields.ts +1 -0
- package/src/providers/openai-tiers-destination.ts +9 -0
- package/src/providers/quota/account-cache.ts +17 -3
- package/src/providers/quota/vendor-probes-oauth.ts +2 -0
- package/src/providers/quota.ts +2 -2
- package/src/responses/code-mode-helper-compat.ts +14 -1
- package/src/responses/custom-tool-compat.ts +1 -1
- package/src/server/auth-cors.ts +1 -0
- package/src/server/images.ts +70 -20
- package/src/server/index/link-listener.ts +20 -3
- package/src/server/index/link-relay-sessions.ts +138 -0
- package/src/server/index/optional-listeners.ts +11 -1
- package/src/server/index/serve-options.ts +12 -0
- package/src/server/inference/client-encoder-delivery.ts +1 -0
- package/src/server/local-management-read-client.ts +21 -2
- package/src/server/management/agent-settings-routes.ts +19 -5
- package/src/server/management/combo-routes.ts +5 -3
- package/src/server/management/config-routes.ts +32 -6
- package/src/server/management/model-routes.ts +232 -7
- package/src/server/management/model-rows.ts +199 -1
- package/src/server/management/oauth-account-routes.ts +63 -3
- package/src/server/management/route-registry.ts +2 -0
- package/src/server/port-reclaim.ts +170 -32
- package/src/server/responses/adapter-delivery.ts +6 -4
- package/src/server/responses/adapter-dispatch.ts +72 -2
- package/src/server/responses/core-combo.ts +6 -1
- package/src/server/responses/core-normalize.ts +19 -4
- package/src/server/responses/core-options.ts +2 -0
- package/src/server/responses/memory-models.ts +169 -0
- package/src/server/responses/passthrough-delivery.ts +4 -0
- package/src/server/responses/passthrough-dispatch.ts +10 -0
- package/src/server/responses/request-prepare.ts +86 -4
- package/src/server/responses/request-transport.ts +4 -0
- package/src/server/responses/run-turn-execution.ts +5 -2
- package/src/server/responses/shadow-target-availability.ts +17 -4
- package/src/server/responses/sidecar-execution.ts +18 -2
- package/src/server/responses/skills-snapshot.ts +239 -0
- package/src/server/responses-undeclared-tool-guard.ts +43 -6
- package/src/server/search.ts +1 -1
- package/src/service/guards.ts +4 -5
- package/src/service/state.ts +39 -2
- package/src/service.ts +2 -2
- package/src/types/config.ts +46 -4
- package/src/types/provider.ts +9 -0
- package/src/types/request.ts +6 -0
- package/src/types/tools.ts +38 -8
- package/src/types.ts +3 -0
- package/src/update/async-check.ts +5 -1
- package/src/update/index.ts +30 -12
- package/src/update/job.ts +3 -1
- package/src/update/pnpm-read-policy.d.mts +4 -0
- package/src/update/pnpm-read-policy.mjs +44 -0
- package/src/update/worker-launch.ts +152 -11
- package/src/usage/cost.ts +6 -3
- package/src/usage/log.ts +4 -0
- package/src/web-search/backends.ts +2 -2
- package/src/web-search/devin-executor.ts +1 -1
- package/src/web-search/sidecar-providers.ts +5 -5
- package/gui/dist/assets/App-mpsibsFj.js +0 -51
- package/gui/dist/assets/index-BDUBS8PW.css +0 -1
- package/gui/dist/assets/index-kUE8tuAK.js +0 -86
|
@@ -42,6 +42,7 @@ import { kiroEvidenceIdentity } from "../providers/kiro-account-state-disk";
|
|
|
42
42
|
import { kiroAccountSupportsModel } from "../providers/kiro-model-catalog";
|
|
43
43
|
import { ACCOUNT_QUOTA_TTL_MS } from "../providers/quota-wire";
|
|
44
44
|
import { sweepExpiredOnWrite } from "../lib/state-store-sweeper";
|
|
45
|
+
import { subscribeOAuthAccountPauseChanges } from "../lib/account-selection-events";
|
|
45
46
|
import type { OcxConfig, OcxProviderConfig } from "../types";
|
|
46
47
|
|
|
47
48
|
/** Cap same-request rotations so a short Retry-After cannot spin. Mirrors the Anthropic bound. */
|
|
@@ -83,7 +84,7 @@ const EXCLUDED_PROVIDERS = new Set(["openai", "anthropic"]);
|
|
|
83
84
|
|
|
84
85
|
interface AccountHealth {
|
|
85
86
|
cooldownUntil: number;
|
|
86
|
-
cooldownSource: "retry-after" | "default" | "kiro-suspension";
|
|
87
|
+
cooldownSource: "retry-after" | "default" | "kiro-suspension" | "auth";
|
|
87
88
|
identity?: string;
|
|
88
89
|
}
|
|
89
90
|
|
|
@@ -98,6 +99,8 @@ const health = new Map<string, AccountHealth>();
|
|
|
98
99
|
/** Provider -> recent eligible-account count. TTL-bounded; never holds credential material. */
|
|
99
100
|
const presence = new Map<string, PresenceEntry>();
|
|
100
101
|
|
|
102
|
+
subscribeOAuthAccountPauseChanges(provider => presence.delete(provider));
|
|
103
|
+
|
|
101
104
|
const healthKey = (provider: string, accountId: string, family?: QuotaModelFamily) =>
|
|
102
105
|
family ? `${provider}\u0000${accountId}\u0000${family}` : `${provider}\u0000${accountId}`;
|
|
103
106
|
|
|
@@ -114,6 +117,13 @@ export function quarantineKiroSuspendedAccount(accountId: string, generation?: s
|
|
|
114
117
|
function isCooled(provider: string, accountId: string, now: number, family?: QuotaModelFamily): boolean {
|
|
115
118
|
const entry = health.get(healthKey(provider, accountId, family));
|
|
116
119
|
if (!entry) return false;
|
|
120
|
+
if (provider === "google-antigravity" && entry.cooldownSource === "auth") {
|
|
121
|
+
const live = getAccountSet(provider)?.accounts.find(row => row.id === accountId);
|
|
122
|
+
if (!live || entry.identity !== credentialGeneration(live.credential)) {
|
|
123
|
+
health.delete(healthKey(provider, accountId, family));
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
117
127
|
if (provider === "kiro" && entry.cooldownSource === "kiro-suspension") {
|
|
118
128
|
const live = getAccountSet("kiro")?.accounts.find(row => row.id === accountId);
|
|
119
129
|
if (!live || entry.identity !== kiroEvidenceIdentity(live)) {
|
|
@@ -128,12 +138,13 @@ function isCooled(provider: string, accountId: string, now: number, family?: Quo
|
|
|
128
138
|
return true;
|
|
129
139
|
}
|
|
130
140
|
|
|
131
|
-
export type KiroSkipReason = "needs_reauth" | "suspended" | "cooldown" | "quota_exhausted";
|
|
141
|
+
export type KiroSkipReason = "paused" | "needs_reauth" | "suspended" | "cooldown" | "quota_exhausted";
|
|
132
142
|
|
|
133
143
|
/** Eligibility for automatic alternatives; an active singleton can still send. */
|
|
134
144
|
export function kiroAutoSelection(
|
|
135
145
|
account: ProviderAccount, now = Date.now(),
|
|
136
146
|
): { autoSelectable: boolean; skipReason?: KiroSkipReason } {
|
|
147
|
+
if (account.paused === true) return { autoSelectable: false, skipReason: "paused" };
|
|
137
148
|
if (account.needsReauth === true) return { autoSelectable: false, skipReason: "needs_reauth" };
|
|
138
149
|
const cooled = isCooled("kiro", account.id, now);
|
|
139
150
|
if (cooled && health.get(healthKey("kiro", account.id))?.cooldownSource === "kiro-suspension")
|
|
@@ -163,8 +174,10 @@ function eligibleAccountCount(providerName: string, now: number): number {
|
|
|
163
174
|
const set = getAccountSet(providerName);
|
|
164
175
|
// Kiro terminal refresh marks the just-refused account needsReauth before the alternate
|
|
165
176
|
// selector runs. Both stored logins still express consent to recover through the survivor.
|
|
166
|
-
const eligible = set
|
|
167
|
-
|
|
177
|
+
const eligible = set
|
|
178
|
+
? set.accounts.filter(account => account.paused !== true
|
|
179
|
+
&& (providerName === "kiro" || account.needsReauth !== true)).length
|
|
180
|
+
: 0;
|
|
168
181
|
presence.set(providerName, { eligible, readAt: now });
|
|
169
182
|
return eligible;
|
|
170
183
|
}
|
|
@@ -260,8 +273,9 @@ function eligibleIdsIn(
|
|
|
260
273
|
): string[] {
|
|
261
274
|
if (!set) return [];
|
|
262
275
|
return set.accounts
|
|
263
|
-
.filter(account =>
|
|
264
|
-
|
|
276
|
+
.filter(account => account.paused !== true
|
|
277
|
+
&& (providerName === "kiro" ? kiroAutoSelection(account, now).autoSelectable
|
|
278
|
+
: account.needsReauth !== true && !isCooled(providerName, account.id, now, family)))
|
|
265
279
|
.map(account => account.id);
|
|
266
280
|
}
|
|
267
281
|
|
|
@@ -285,6 +299,34 @@ export function hasEligibleGenericOAuthFailoverTarget(
|
|
|
285
299
|
return eligibleIdsIn(set, providerName, now, family).some(id => id !== failedAccountId);
|
|
286
300
|
}
|
|
287
301
|
|
|
302
|
+
/** Select a live Antigravity sibling using activation captured before the refused send. */
|
|
303
|
+
export function rotateAntigravityAccountOnAuthRefusal(
|
|
304
|
+
poolActivated: boolean,
|
|
305
|
+
failedAccountId: string,
|
|
306
|
+
sentGeneration: string,
|
|
307
|
+
requestedModelId: string | null | undefined,
|
|
308
|
+
now = Date.now(),
|
|
309
|
+
): string | null {
|
|
310
|
+
if (!poolActivated) return null;
|
|
311
|
+
const set = getAccountSet("google-antigravity");
|
|
312
|
+
if (!set || set.accounts.length < 2) return null;
|
|
313
|
+
const family = classifyModelFamilyForQuota("google-antigravity", requestedModelId);
|
|
314
|
+
const failed = set.accounts.find(row => row.id === failedAccountId);
|
|
315
|
+
if (failed && credentialGeneration(failed.credential) === sentGeneration) {
|
|
316
|
+
health.set(healthKey("google-antigravity", failedAccountId, family), {
|
|
317
|
+
cooldownUntil: now + DEFAULT_COOLDOWN_MS,
|
|
318
|
+
cooldownSource: "auth",
|
|
319
|
+
identity: sentGeneration,
|
|
320
|
+
});
|
|
321
|
+
sweepExpiredOnWrite(now);
|
|
322
|
+
}
|
|
323
|
+
const eligible = new Set(eligibleIdsIn(set, "google-antigravity", now, family));
|
|
324
|
+
const order = set.accounts.map(row => row.id);
|
|
325
|
+
const start = order.indexOf(failedAccountId);
|
|
326
|
+
const ring = start >= 0 ? [...order.slice(start + 1), ...order.slice(0, start)] : order;
|
|
327
|
+
return ring.find(id => id !== failedAccountId && eligible.has(id)) ?? null;
|
|
328
|
+
}
|
|
329
|
+
|
|
288
330
|
/** Generic pool strategies the kernel can actually run. `quota` IS the pre-kernel path. */
|
|
289
331
|
type ActiveGenericStrategy = "round-robin" | "fill-first" | "least-loaded";
|
|
290
332
|
|
|
@@ -579,7 +621,9 @@ export function preferredInitialAccount(
|
|
|
579
621
|
if (!selected) return null;
|
|
580
622
|
const active = selected.activeAccountId;
|
|
581
623
|
const accountRows = new Map(selected.accounts.map(account => [account.id, account]));
|
|
582
|
-
const order = selected.accounts
|
|
624
|
+
const order = selected.accounts
|
|
625
|
+
.filter(account => account.paused !== true && account.needsReauth !== true)
|
|
626
|
+
.map(account => account.id);
|
|
583
627
|
if (order.length < 2) return null;
|
|
584
628
|
|
|
585
629
|
const modelEligible = preferKiroModelSupport(providerName,
|
|
@@ -629,7 +673,7 @@ export function preferredInitialAccount(
|
|
|
629
673
|
}
|
|
630
674
|
|
|
631
675
|
const activeRow = selected.accounts.find(account => account.id === active);
|
|
632
|
-
if (activeRow && activeRow.needsReauth !== true
|
|
676
|
+
if (activeRow && activeRow.paused !== true && activeRow.needsReauth !== true
|
|
633
677
|
&& !isCooled(providerName, activeRow.id, now, classifyModelFamilyForQuota(providerName, requestedModelId))
|
|
634
678
|
&& !isAccountQuotaExhausted(providerName, activeRow.id, requestedModelId, activeRow)) return null;
|
|
635
679
|
|
package/src/oauth/index.ts
CHANGED
|
@@ -108,6 +108,7 @@ export interface ObservedOAuthAccessSnapshot extends OAuthAccessSnapshot {
|
|
|
108
108
|
|
|
109
109
|
export type OAuthActiveTokenObservation =
|
|
110
110
|
| { readonly kind: "available"; readonly snapshot: ObservedOAuthAccessSnapshot }
|
|
111
|
+
| { readonly kind: "paused" }
|
|
111
112
|
| { readonly kind: "missing" }
|
|
112
113
|
| { readonly kind: "malformed" }
|
|
113
114
|
| { readonly kind: "needs-reauth" }
|
|
@@ -418,6 +419,14 @@ export class OAuthLoginRequiredError extends Error {
|
|
|
418
419
|
}
|
|
419
420
|
}
|
|
420
421
|
|
|
422
|
+
/** An operator-paused account is temporarily unavailable, not an invalid login. */
|
|
423
|
+
export class OAuthAccountPausedError extends Error {
|
|
424
|
+
constructor() {
|
|
425
|
+
super("OAuth account is paused. Resume it in account settings and retry.");
|
|
426
|
+
this.name = "OAuthAccountPausedError";
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
421
430
|
export class OAuthProviderPublicationError extends Error {
|
|
422
431
|
constructor() {
|
|
423
432
|
super("OAuth credential was saved, but the provider entry was not written. Resolve the account namespace collision, then retry login.");
|
|
@@ -455,6 +464,7 @@ export function publicOAuthAuthenticationErrorMessage(error: unknown): string {
|
|
|
455
464
|
}
|
|
456
465
|
if (
|
|
457
466
|
(error instanceof OAuthLoginRequiredError && isOAuthProvider(error.provider))
|
|
467
|
+
|| error instanceof OAuthAccountPausedError
|
|
458
468
|
|| error instanceof OAuthProviderPublicationError
|
|
459
469
|
// Reauth identity outcomes carry fixed, account-free remediation text. Dropping them to the
|
|
460
470
|
// generic message hides WHICH failure the user must fix (sign in with the selected account).
|
|
@@ -466,7 +476,7 @@ export function publicOAuthAuthenticationErrorMessage(error: unknown): string {
|
|
|
466
476
|
return "OAuth authentication failed. Check the OpenCodex account status and retry.";
|
|
467
477
|
}
|
|
468
478
|
|
|
469
|
-
function accessSnapshot(provider: string, accountId: string, cred: OAuthCredentials): OAuthAccessSnapshot {
|
|
479
|
+
function accessSnapshot(provider: string, accountId: string, cred: OAuthCredentials, oauthProvider = provider): OAuthAccessSnapshot {
|
|
470
480
|
// Derived, not read back: a stored `authType` is trusted when present, but a credential imported
|
|
471
481
|
// before the field existed still routes correctly because the client pair implies SSO OIDC.
|
|
472
482
|
const kiroAuthType = cred.kiro?.authType
|
|
@@ -484,9 +494,11 @@ function accessSnapshot(provider: string, accountId: string, cred: OAuthCredenti
|
|
|
484
494
|
// Validated here, not at the call site: an unvalidated origin from a legacy or crafted
|
|
485
495
|
// credential must never travel with a bearer, and dropping it makes the transport fall back to
|
|
486
496
|
// the canonical host rather than to whatever the previous account was using.
|
|
487
|
-
|
|
497
|
+
// The host rides on the OAuth definition the snapshot was resolved through, not the routed
|
|
498
|
+
// slot name: a custom provider reusing the Devin definition keeps its stored tenant URL.
|
|
499
|
+
const accountApiBaseUrl = oauthProvider === "github-copilot"
|
|
488
500
|
? validateCopilotApiBaseUrl(cred.apiBaseUrl)
|
|
489
|
-
:
|
|
501
|
+
: oauthProvider === "devin" || oauthProvider === "devin-cli"
|
|
490
502
|
? validateDevinApiBaseUrl(cred.apiBaseUrl)
|
|
491
503
|
: undefined;
|
|
492
504
|
return {
|
|
@@ -529,6 +541,7 @@ export function observeActiveOAuthAccessToken(
|
|
|
529
541
|
const accountSet = authStore.store[provider];
|
|
530
542
|
const account = accountSet?.accounts.find(candidate => candidate.id === accountSet.activeAccountId);
|
|
531
543
|
if (!account) return { kind: "missing" };
|
|
544
|
+
if (account.paused === true) return { kind: "paused" };
|
|
532
545
|
if (account.needsReauth) return { kind: "needs-reauth" };
|
|
533
546
|
if (account.credential.expires <= now) return { kind: "expired" };
|
|
534
547
|
if (account.credential.expires <= now + REFRESH_SKEW_MS) return { kind: "near-expiry" };
|
|
@@ -550,18 +563,20 @@ async function resolveAccessSnapshotForAccount(
|
|
|
550
563
|
accountId: string,
|
|
551
564
|
rejectedGeneration?: string,
|
|
552
565
|
requireUsableAccount = false,
|
|
566
|
+
oauthProvider = provider,
|
|
553
567
|
): Promise<OAuthAccessSnapshot> {
|
|
554
|
-
const def = OAUTH_PROVIDERS[
|
|
555
|
-
if (!def) throw new UnsupportedOAuthProviderError(
|
|
568
|
+
const def = OAUTH_PROVIDERS[oauthProvider];
|
|
569
|
+
if (!def) throw new UnsupportedOAuthProviderError(oauthProvider);
|
|
556
570
|
// One store read answers both questions. A caller that opts in gets the account REJECTED
|
|
557
571
|
// when it needs reauthentication, which a bare credential read cannot detect: a revoked
|
|
558
572
|
// account keeps a readable credential, so resolution would otherwise succeed and the
|
|
559
573
|
// request would dispatch on an account already known to need a fresh login.
|
|
560
574
|
const row = getAccountCredentialWithStatus(provider, accountId);
|
|
561
575
|
if (!row) throw new OAuthLoginRequiredError(provider);
|
|
576
|
+
if (row.paused === true) throw new OAuthAccountPausedError();
|
|
562
577
|
if (requireUsableAccount && row.needsReauth) throw new OAuthLoginRequiredError(provider);
|
|
563
578
|
const cred = row.credential;
|
|
564
|
-
const current = accessSnapshot(provider, accountId, cred);
|
|
579
|
+
const current = accessSnapshot(provider, accountId, cred, oauthProvider);
|
|
565
580
|
if (rejectedGeneration !== undefined && current.generation !== rejectedGeneration) return current;
|
|
566
581
|
if (rejectedGeneration === undefined && cred.expires > Date.now() + REFRESH_SKEW_MS) return current;
|
|
567
582
|
|
|
@@ -590,12 +605,14 @@ async function resolveAccessSnapshotForAccount(
|
|
|
590
605
|
};
|
|
591
606
|
const refresh = (async (): Promise<OAuthAccessSnapshot> => {
|
|
592
607
|
const accessToken = await refreshAndPersistAccessToken(provider, accountId, def, cred, abort.signal, flight, replacedStaleFlight);
|
|
593
|
-
const
|
|
594
|
-
if (!
|
|
608
|
+
const persistedRow = getAccountCredentialWithStatus(provider, accountId);
|
|
609
|
+
if (!persistedRow) throw new OAuthLoginRequiredError(provider);
|
|
610
|
+
if (persistedRow.paused === true) throw new OAuthAccountPausedError();
|
|
611
|
+
const persisted = persistedRow.credential;
|
|
595
612
|
if (persisted.access !== accessToken) {
|
|
596
613
|
throw new Error(`OAuth refresh persisted an unexpected access token for ${provider}`);
|
|
597
614
|
}
|
|
598
|
-
return accessSnapshot(provider, accountId, persisted);
|
|
615
|
+
return accessSnapshot(provider, accountId, persisted, oauthProvider);
|
|
599
616
|
})().catch(error => {
|
|
600
617
|
if (abort.signal.reason instanceof OAuthTokenRefreshStaleError) throw abort.signal.reason;
|
|
601
618
|
throw error;
|
|
@@ -607,10 +624,13 @@ async function resolveAccessSnapshotForAccount(
|
|
|
607
624
|
return refresh;
|
|
608
625
|
}
|
|
609
626
|
|
|
610
|
-
export async function getValidAccessTokenSnapshot(
|
|
627
|
+
export async function getValidAccessTokenSnapshot(
|
|
628
|
+
provider: string,
|
|
629
|
+
options: { oauthProvider?: string } = {},
|
|
630
|
+
): Promise<OAuthAccessSnapshot> {
|
|
611
631
|
const set = getAccountSet(provider);
|
|
612
632
|
if (!set) throw new OAuthLoginRequiredError(provider);
|
|
613
|
-
return resolveAccessSnapshotForAccount(provider, set.activeAccountId);
|
|
633
|
+
return resolveAccessSnapshotForAccount(provider, set.activeAccountId, undefined, false, options.oauthProvider);
|
|
614
634
|
}
|
|
615
635
|
|
|
616
636
|
/** Providers whose upstream-401 replay path may force a snapshot refresh. */
|
|
@@ -836,7 +856,7 @@ function merged(fresh: OAuthCredentials, previous: OAuthCredentials): OAuthCrede
|
|
|
836
856
|
...(fresh.kiro === undefined && previous.kiro ? { kiro: previous.kiro } : {}),
|
|
837
857
|
};
|
|
838
858
|
}
|
|
839
|
-
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
|
|
859
|
+
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 row=getAccountCredentialWithStatus(provider,accountId);if(!row)throw new OAuthLoginRequiredError(provider);if(row.paused)throw new OAuthAccountPausedError();const stored=row.credential;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();}}
|
|
840
860
|
|
|
841
861
|
function newerClaudeCredential(stored: OAuthCredentials, now: number): OAuthCredentials | undefined {
|
|
842
862
|
if (stored.source !== "local-cli") return undefined;
|
|
@@ -1018,8 +1038,11 @@ export async function refreshGenericAccountWithLock(
|
|
|
1018
1038
|
logOAuthEvent("OAuth refresh started", { provider, accountId });
|
|
1019
1039
|
const guard = await (deps.intentLock ?? createOAuthRefreshIntentLock(provider, accountId)).acquire();
|
|
1020
1040
|
try {
|
|
1021
|
-
|
|
1022
|
-
|
|
1041
|
+
// Re-read under the lock: a pause committed while this caller waited must stop the refresh.
|
|
1042
|
+
const row = getAccountCredentialWithStatus(provider, accountId);
|
|
1043
|
+
if (!row) throw new OAuthLoginRequiredError(provider);
|
|
1044
|
+
if (row.paused) throw new OAuthAccountPausedError();
|
|
1045
|
+
const stored = row.credential;
|
|
1023
1046
|
if (
|
|
1024
1047
|
credentialGeneration(stored) !== credentialGeneration(callerCredential)
|
|
1025
1048
|
&& stored.expires > Date.now() + REFRESH_SKEW_MS
|
package/src/oauth/store.ts
CHANGED
|
@@ -27,7 +27,7 @@ import { atomicWriteFileNoFollowUnclaimed } from "../config/atomic-write";
|
|
|
27
27
|
import { assertNotRealHomeUnderTest } from "../lib/test-home-guard";
|
|
28
28
|
import { recordOwnedConfigPath } from "../lib/config-ownership";
|
|
29
29
|
import { MAX_PENDING_OAUTH_MUTATIONS } from "../lib/translator-budget";
|
|
30
|
-
import { publishAccountSelection } from "../lib/account-selection-events";
|
|
30
|
+
import { publishAccountSelection, publishOAuthAccountPauseChange } from "../lib/account-selection-events";
|
|
31
31
|
import {
|
|
32
32
|
captureConfigGeneration,
|
|
33
33
|
type GenerationContext,
|
|
@@ -625,6 +625,7 @@ function normalizeAccount(value: unknown): ProviderAccount | null {
|
|
|
625
625
|
const account: ProviderAccount = { id: candidate.id, credential };
|
|
626
626
|
if (typeof candidate.alias === "string" && candidate.alias.trim()) account.alias = candidate.alias.trim();
|
|
627
627
|
if (candidate.needsReauth === true) account.needsReauth = true;
|
|
628
|
+
if (candidate.paused === true) account.paused = true;
|
|
628
629
|
if (typeof candidate.addedAt === "number") account.addedAt = candidate.addedAt;
|
|
629
630
|
if (typeof candidate.loginId === "string"
|
|
630
631
|
&& /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(candidate.loginId)) {
|
|
@@ -874,7 +875,7 @@ export async function saveCredentialWithReceipt(
|
|
|
874
875
|
if (existing) {
|
|
875
876
|
existing.credential = safe;
|
|
876
877
|
delete existing.needsReauth;
|
|
877
|
-
set.activeAccountId = existing.id;
|
|
878
|
+
if (existing.paused !== true) set.activeAccountId = existing.id;
|
|
878
879
|
accountId = existing.id;
|
|
879
880
|
} else {
|
|
880
881
|
// Legacy migration: a pre-identity row (no accountId/email) for this provider is the
|
|
@@ -912,6 +913,15 @@ export async function saveCredentialWithReceipt(
|
|
|
912
913
|
accountId = id;
|
|
913
914
|
}
|
|
914
915
|
}
|
|
916
|
+
const updatedSet = store[provider];
|
|
917
|
+
const savedAccount = updatedSet?.accounts.find(account => account.id === accountId);
|
|
918
|
+
const activeAccount = updatedSet?.accounts.find(account => account.id === updatedSet.activeAccountId);
|
|
919
|
+
if (savedAccount?.paused === true
|
|
920
|
+
&& (!activeAccount || activeAccount.paused === true || activeAccount.needsReauth === true)) {
|
|
921
|
+
const fallback = updatedSet.accounts.find(account => account.id !== accountId
|
|
922
|
+
&& account.paused !== true && account.needsReauth !== true);
|
|
923
|
+
if (fallback) updatedSet.activeAccountId = fallback.id;
|
|
924
|
+
}
|
|
915
925
|
// Every explicit login, including an in-place legacy slot upgrade, starts new evidence.
|
|
916
926
|
store[provider]!.accounts.find(account => account.id === accountId)!.loginId = randomUUID();
|
|
917
927
|
return {
|
|
@@ -1047,7 +1057,7 @@ export async function upsertCredentialByIdentity(
|
|
|
1047
1057
|
}
|
|
1048
1058
|
|
|
1049
1059
|
/**
|
|
1050
|
-
* Remove the ACTIVE account;
|
|
1060
|
+
* Remove the ACTIVE account; promote the first usable survivor when available.
|
|
1051
1061
|
*
|
|
1052
1062
|
* Returns what actually happened, which a caller cannot otherwise know. A read-then-remove
|
|
1053
1063
|
* preflight is not equivalent: `mutateStore` serializes mutations, so between a caller's
|
|
@@ -1064,7 +1074,8 @@ export async function removeCredential(provider: string): Promise<"removed" | "n
|
|
|
1064
1074
|
delete store[provider];
|
|
1065
1075
|
return "removed" as const;
|
|
1066
1076
|
}
|
|
1067
|
-
set.activeAccountId = set.accounts
|
|
1077
|
+
set.activeAccountId = set.accounts.find(account => account.paused !== true && account.needsReauth !== true)?.id
|
|
1078
|
+
?? set.accounts[0]!.id;
|
|
1068
1079
|
return "removed" as const;
|
|
1069
1080
|
}, [provider], { scrubLegacyBackup: result => result === "removed" ? [provider] : [] });
|
|
1070
1081
|
}
|
|
@@ -1106,10 +1117,14 @@ export function getAccountCredential(provider: string, accountId: string): OAuth
|
|
|
1106
1117
|
export function getAccountCredentialWithStatus(
|
|
1107
1118
|
provider: string,
|
|
1108
1119
|
accountId: string,
|
|
1109
|
-
): { credential: OAuthCredentials; needsReauth: boolean } | null {
|
|
1120
|
+
): { credential: OAuthCredentials; needsReauth: boolean; paused: boolean } | null {
|
|
1110
1121
|
const account = loadAuthStore()[provider]?.accounts.find(a => a.id === accountId);
|
|
1111
1122
|
if (!account?.credential) return null;
|
|
1112
|
-
return {
|
|
1123
|
+
return {
|
|
1124
|
+
credential: account.credential,
|
|
1125
|
+
needsReauth: account.needsReauth === true,
|
|
1126
|
+
paused: account.paused === true,
|
|
1127
|
+
};
|
|
1113
1128
|
}
|
|
1114
1129
|
|
|
1115
1130
|
/** Persist a refreshed credential for a SPECIFIC account without touching activeAccountId. */
|
|
@@ -1122,11 +1137,19 @@ export async function saveAccountCredential(
|
|
|
1122
1137
|
const safe = normalizeCredential(cred);
|
|
1123
1138
|
if (!safe) return;
|
|
1124
1139
|
await mutateStore(store => {
|
|
1125
|
-
const
|
|
1126
|
-
|
|
1140
|
+
const set = store[provider];
|
|
1141
|
+
const account = set?.accounts.find(a => a.id === accountId);
|
|
1142
|
+
if (!set || !account) return;
|
|
1127
1143
|
account.credential = safe;
|
|
1128
1144
|
if (opts.rotateLoginId) account.loginId = randomUUID();
|
|
1129
1145
|
delete account.needsReauth;
|
|
1146
|
+
// A pause that found no usable fallback leaves the active id on a paused row. Once this
|
|
1147
|
+
// reauthentication makes an unpaused account usable, hand the selection to it.
|
|
1148
|
+
const active = set.accounts.find(a => a.id === set.activeAccountId);
|
|
1149
|
+
if (active?.paused === true && account.paused !== true && account.id !== active.id) {
|
|
1150
|
+
set.activeAccountId = account.id;
|
|
1151
|
+
set.selectionRevision = randomUUID();
|
|
1152
|
+
}
|
|
1130
1153
|
}, [provider, accountId, safe], { assertBeforePersist: opts.assertBeforePersist });
|
|
1131
1154
|
}
|
|
1132
1155
|
|
|
@@ -1162,7 +1185,7 @@ export async function commitOAuthAccountSelection(
|
|
|
1162
1185
|
const valid = (set: ProviderAccountSet): boolean => {
|
|
1163
1186
|
if (expected && (set.activeAccountId !== expected.accountId || set.selectionRevision !== expected.revision)) return false;
|
|
1164
1187
|
const account = set.accounts.find(account => account.id === accountId);
|
|
1165
|
-
if (!account || (requireUsableAccount && account.needsReauth === true)) return false;
|
|
1188
|
+
if (!account || account.paused === true || (requireUsableAccount && account.needsReauth === true)) return false;
|
|
1166
1189
|
return expectedCredentialGeneration === undefined || credentialGeneration(account.credential) === expectedCredentialGeneration;
|
|
1167
1190
|
};
|
|
1168
1191
|
if (expected?.accountId === accountId) {
|
|
@@ -1196,7 +1219,64 @@ export async function setAccountAlias(provider: string, accountId: string, alias
|
|
|
1196
1219
|
}, [provider, accountId, alias]);
|
|
1197
1220
|
}
|
|
1198
1221
|
|
|
1199
|
-
|
|
1222
|
+
export type SetAccountPausedResult =
|
|
1223
|
+
| { status: "updated"; activeAccountId: string; activeAccountChanged: boolean }
|
|
1224
|
+
| { status: "unchanged"; activeAccountId: string; activeAccountChanged: boolean }
|
|
1225
|
+
| { status: "not-found" };
|
|
1226
|
+
|
|
1227
|
+
/** Persist an operator pause and move an active account to the next usable unpaused slot when available. */
|
|
1228
|
+
export async function setAccountPaused(
|
|
1229
|
+
provider: string,
|
|
1230
|
+
accountId: string,
|
|
1231
|
+
paused: boolean,
|
|
1232
|
+
): Promise<SetAccountPausedResult> {
|
|
1233
|
+
const result = await mutateStore(store => {
|
|
1234
|
+
const set = store[provider];
|
|
1235
|
+
const account = set?.accounts.find(candidate => candidate.id === accountId);
|
|
1236
|
+
if (!set || !account) return { status: "not-found" } as const;
|
|
1237
|
+
if ((account.paused === true) === paused) {
|
|
1238
|
+
return { status: "unchanged", activeAccountId: set.activeAccountId, activeAccountChanged: false } as const;
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
if (paused) account.paused = true;
|
|
1242
|
+
else delete account.paused;
|
|
1243
|
+
// Pause is part of selection eligibility even when the operator changes a non-active
|
|
1244
|
+
// account. Bump the provider selection revision so observers receive the existing
|
|
1245
|
+
// post-persistence roster invalidation and stale automatic proposals cannot commit.
|
|
1246
|
+
set.selectionRevision = randomUUID();
|
|
1247
|
+
|
|
1248
|
+
let activeAccountChanged = false;
|
|
1249
|
+
if (paused && set.activeAccountId === accountId) {
|
|
1250
|
+
const start = set.accounts.findIndex(candidate => candidate.id === accountId);
|
|
1251
|
+
const ring = [...set.accounts.slice(start + 1), ...set.accounts.slice(0, start)];
|
|
1252
|
+
const fallback = ring.find(candidate => candidate.paused !== true && candidate.needsReauth !== true);
|
|
1253
|
+
if (fallback) {
|
|
1254
|
+
set.activeAccountId = fallback.id;
|
|
1255
|
+
set.selectionRevision = randomUUID();
|
|
1256
|
+
activeAccountChanged = true;
|
|
1257
|
+
}
|
|
1258
|
+
} else if (!paused) {
|
|
1259
|
+
// If all accounts had been paused, the active id still points at a paused
|
|
1260
|
+
// slot. Resuming the first usable account must restore a usable selection.
|
|
1261
|
+
const active = set.accounts.find(candidate => candidate.id === set.activeAccountId);
|
|
1262
|
+
if ((!active || active.paused === true || active.needsReauth === true) && account.needsReauth !== true) {
|
|
1263
|
+
set.activeAccountId = accountId;
|
|
1264
|
+
set.selectionRevision = randomUUID();
|
|
1265
|
+
activeAccountChanged = true;
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
return {
|
|
1270
|
+
status: "updated",
|
|
1271
|
+
activeAccountId: set.activeAccountId,
|
|
1272
|
+
activeAccountChanged,
|
|
1273
|
+
} as const;
|
|
1274
|
+
}, [provider, accountId, paused]);
|
|
1275
|
+
if (result.status === "updated") publishOAuthAccountPauseChange(provider);
|
|
1276
|
+
return result;
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
/** Remove one account by id; active removal promotes the first usable survivor when available. */
|
|
1200
1280
|
export async function removeAccount(provider: string, accountId: string): Promise<boolean> {
|
|
1201
1281
|
const removed = await mutateStore(store => {
|
|
1202
1282
|
const set = store[provider];
|
|
@@ -1208,7 +1288,11 @@ export async function removeAccount(provider: string, accountId: string): Promis
|
|
|
1208
1288
|
delete store[provider];
|
|
1209
1289
|
return true;
|
|
1210
1290
|
}
|
|
1211
|
-
if (set.activeAccountId === accountId)
|
|
1291
|
+
if (set.activeAccountId === accountId) {
|
|
1292
|
+
const next = set.accounts.find(account => account.paused !== true && account.needsReauth !== true)
|
|
1293
|
+
?? set.accounts[0]!;
|
|
1294
|
+
set.activeAccountId = next.id;
|
|
1295
|
+
}
|
|
1212
1296
|
return true;
|
|
1213
1297
|
}, [provider, accountId], { scrubLegacyBackup: removed => removed ? [provider] : [] });
|
|
1214
1298
|
return removed;
|
|
@@ -1238,6 +1322,7 @@ export async function replaceProviderAccountSet(
|
|
|
1238
1322
|
credential: { ...account.credential, ...(account.credential.kiro ? { kiro: { ...account.credential.kiro } } : {}) },
|
|
1239
1323
|
...(account.alias ? { alias: account.alias } : {}),
|
|
1240
1324
|
...(account.needsReauth ? { needsReauth: true } : {}),
|
|
1325
|
+
...(account.paused ? { paused: true } : {}),
|
|
1241
1326
|
...(account.addedAt !== undefined ? { addedAt: account.addedAt } : {}),
|
|
1242
1327
|
...(account.loginId ? { loginId: account.loginId } : {}),
|
|
1243
1328
|
})),
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
import { loadConfig } from "../config";
|
|
15
15
|
import type { OcxConfig, OcxTokenGuardianConfig } from "../types";
|
|
16
16
|
import { listAccounts } from "./store";
|
|
17
|
-
import { getValidAccessTokenForAccount, listOAuthProviders, OAuthLoginRequiredError, resolveRefreshPolicy } from "./index";
|
|
17
|
+
import { getValidAccessTokenForAccount, listOAuthProviders, OAuthAccountPausedError, OAuthLoginRequiredError, resolveRefreshPolicy } from "./index";
|
|
18
18
|
import {
|
|
19
19
|
getValidCodexToken,
|
|
20
20
|
listCodexAccountIds,
|
|
@@ -141,11 +141,12 @@ export async function guardianSweep(nowMs: number = Date.now()): Promise<Guardia
|
|
|
141
141
|
const tasks: Array<() => Promise<void>> = [];
|
|
142
142
|
|
|
143
143
|
// A) OAuth providers — every account in each provider's set (multiauth keep-alive),
|
|
144
|
-
// skipping accounts already marked needsReauth (terminal; only a re-login fixes them)
|
|
144
|
+
// skipping accounts already marked needsReauth (terminal; only a re-login fixes them) or
|
|
145
|
+
// paused by the operator (manual exclusion from all automatic account use).
|
|
145
146
|
for (const provider of listOAuthProviders()) {
|
|
146
147
|
if (resolveRefreshPolicy(provider, config) !== "proactive") continue;
|
|
147
148
|
for (const account of listAccounts(provider)) {
|
|
148
|
-
if (account.needsReauth) continue;
|
|
149
|
+
if (account.needsReauth || account.paused) continue;
|
|
149
150
|
if (account.credential.expires > nowMs + horizonMs) continue;
|
|
150
151
|
const key = `oauth:${provider}:${account.id}`;
|
|
151
152
|
if (inBackoff(key, nowMs)) { result.skippedBackoff.push(key); continue; }
|
|
@@ -155,6 +156,9 @@ export async function guardianSweep(nowMs: number = Date.now()): Promise<Guardia
|
|
|
155
156
|
backoff.delete(key);
|
|
156
157
|
result.refreshed.push(key);
|
|
157
158
|
} catch (err) {
|
|
159
|
+
// The account may have been paused after this task was queued. It is no longer an
|
|
160
|
+
// automatic refresh failure and must not acquire retry backoff.
|
|
161
|
+
if (err instanceof OAuthAccountPausedError) return;
|
|
158
162
|
// Terminal grant failures surface as OAuthLoginRequiredError (account marked
|
|
159
163
|
// needsReauth by the resolver) — back off at the ceiling; transient errors backoff exponentially.
|
|
160
164
|
const permanent = err instanceof OAuthLoginRequiredError;
|
package/src/oauth/types.ts
CHANGED
|
@@ -88,6 +88,8 @@ export interface ProviderAccount {
|
|
|
88
88
|
credential: OAuthCredentials;
|
|
89
89
|
/** Terminal refresh failure (invalid_grant / reused / revoked) — re-login required. */
|
|
90
90
|
needsReauth?: boolean;
|
|
91
|
+
/** Operator exclusion from generic OAuth account selection until explicitly resumed. */
|
|
92
|
+
paused?: boolean;
|
|
91
93
|
addedAt?: number;
|
|
92
94
|
}
|
|
93
95
|
|
package/src/plugins/loader.ts
CHANGED
|
@@ -81,10 +81,12 @@ export function macAclListingTrustError(listing: string, currentUser = userInfo(
|
|
|
81
81
|
if (!entry) { unparseable = true; continue; }
|
|
82
82
|
if (entry[2] === "deny") continue;
|
|
83
83
|
const principal = entry[1]!;
|
|
84
|
-
// The file owner
|
|
85
|
-
//
|
|
84
|
+
// The file owner and this process's user already control the path without an ACE. Require the
|
|
85
|
+
// `user:` prefix: a bare or group principal might include other users. macOS `ls` prints a
|
|
86
|
+
// resolved directory-record NAME here, so neither `0` nor `root` proves that the ACE is UID 0.
|
|
87
|
+
// On a genuinely root-owned path, `root` is still admitted by the owner comparison.
|
|
86
88
|
if (principal.startsWith("user:")
|
|
87
|
-
&& [owner, currentUser
|
|
89
|
+
&& [owner, currentUser].includes(principal.slice(5))) continue;
|
|
88
90
|
const rights = entry[3]!.split(",");
|
|
89
91
|
if (rights.includes("only_inherit")) continue;
|
|
90
92
|
if (rights.some(right => !MAC_ACL_BENIGN_TOKENS.has(right))) return "has an access control list";
|
|
@@ -340,6 +340,9 @@ export function createAdapterTierMetadata(
|
|
|
340
340
|
const loggedWireValue = wireValue === null ? null : sanitizeLogMetadataString(wireValue);
|
|
341
341
|
const outcome: AttemptTierOutcome = {
|
|
342
342
|
wireKind,
|
|
343
|
+
...(context.responseTierAuthoritative !== undefined
|
|
344
|
+
? { responseTierAuthoritative: context.responseTierAuthoritative }
|
|
345
|
+
: {}),
|
|
343
346
|
...(wireValue === null
|
|
344
347
|
? { wireValue: null }
|
|
345
348
|
: loggedWireValue ? { wireValue: loggedWireValue } : {}),
|
|
@@ -12,7 +12,7 @@ import { commitProviderApiKeySelection } from "./api-key-selection";
|
|
|
12
12
|
import type { ProviderApiKeySelection } from "../types/provider";
|
|
13
13
|
import { routedProviderConfig } from "../router";
|
|
14
14
|
import { getProviderRegistryEntry } from "./registry";
|
|
15
|
-
import { normalizedBaseUrl } from "./quota/vendor-probes-key";
|
|
15
|
+
import { isCanonicalCommandCodeBaseUrl, normalizedBaseUrl } from "./quota/vendor-probes-key";
|
|
16
16
|
import type { OcxConfig, OcxProviderConfig, RateLimitRetryPolicy, ResetReplayPolicy, TransientRetryPolicy } from "../types";
|
|
17
17
|
import { OPENCODE_GO_SESSION_HEADER } from "./opencode-go-transport";
|
|
18
18
|
import { resolveProviderTransport, type OcxProviderTransport } from "./xai-transport";
|
|
@@ -566,7 +566,8 @@ export function selectProactiveApiKeyTransport(
|
|
|
566
566
|
* `enabled: false` to opt out). When the knob is absent, the OpenCode Go destination
|
|
567
567
|
* (subscription traffic such as Muse Spark) falls back to a patient same-key policy so a
|
|
568
568
|
* burst 429 waits and replays instead of surfacing to the client and aborting a long
|
|
569
|
-
* session;
|
|
569
|
+
* session; key-auth Command Code at its canonical endpoints gets the same fallback (#5180), and
|
|
570
|
+
* every other provider without the knob keeps today's fail-fast behavior.
|
|
570
571
|
* OAuth/forward/local credentials are never replayed on the same token. The returned
|
|
571
572
|
* policy is fully defaulted so callers never re-check fields.
|
|
572
573
|
*/
|
|
@@ -590,10 +591,17 @@ export function rateLimitRetryPolicyFor(
|
|
|
590
591
|
respectRetryAfter: policy.respectRetryAfter ?? DEFAULT_RATE_LIMIT_RETRY.respectRetryAfter,
|
|
591
592
|
};
|
|
592
593
|
}
|
|
593
|
-
// No explicit knob: patient fallback for
|
|
594
|
+
// No explicit knob: patient fallback for OpenCode Go and canonical Command Code only.
|
|
594
595
|
if (provider.authMode !== undefined && provider.authMode !== "key") return null;
|
|
595
|
-
if (
|
|
596
|
-
|
|
596
|
+
if (isOpenCodeGoDestination(provider)) return { ...OPENCODE_GO_RATE_LIMIT_RETRY };
|
|
597
|
+
// Command Code's Provider API rate-limits long muse-spark turns the same way (#5180): a single
|
|
598
|
+
// key cannot fail over, and the Codex client does not retry a 429, so the turn aborts. The key
|
|
599
|
+
// endpoint gets the same patient same-key policy. A row repointed at a custom relay is not the
|
|
600
|
+
// canonical endpoint and keeps fail-fast unless it sets `retryOn429`.
|
|
601
|
+
if (typeof provider.baseUrl === "string" && isCanonicalCommandCodeBaseUrl(provider.baseUrl.trim())) {
|
|
602
|
+
return { ...OPENCODE_GO_RATE_LIMIT_RETRY };
|
|
603
|
+
}
|
|
604
|
+
return null;
|
|
597
605
|
}
|
|
598
606
|
|
|
599
607
|
/**
|
|
@@ -27,7 +27,8 @@ export function kiroModelDiscoveryEnabled(): boolean {
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
function liveAccount(accountId: string): ProviderAccount | undefined {
|
|
30
|
-
return getAccountSet("kiro")?.accounts.find(account => account.id === accountId
|
|
30
|
+
return getAccountSet("kiro")?.accounts.find(account => account.id === accountId
|
|
31
|
+
&& account.needsReauth !== true && account.paused !== true);
|
|
31
32
|
}
|
|
32
33
|
|
|
33
34
|
function currentIdentity(accountId: string): string | undefined {
|
|
@@ -74,7 +75,7 @@ export function refreshKiroAccountModelsDetached(
|
|
|
74
75
|
providerConfig: OcxProviderConfig,
|
|
75
76
|
dependencies: ProviderOutboundDependencies = {},
|
|
76
77
|
): void {
|
|
77
|
-
if (!kiroModelDiscoveryEnabled() || account.needsReauth === true) return;
|
|
78
|
+
if (!kiroModelDiscoveryEnabled() || account.needsReauth === true || account.paused === true) return;
|
|
78
79
|
const identity = kiroEvidenceIdentity(account);
|
|
79
80
|
if (currentIdentity(account.id) !== identity) return;
|
|
80
81
|
const old = validRow(account);
|
|
@@ -138,7 +139,7 @@ export function kiroAccountSupportsModel(accountId: string, model: string): bool
|
|
|
138
139
|
export function kiroObservedContextWindow(model: string): number | undefined {
|
|
139
140
|
const normalized = normalizeKiroModelId(model);
|
|
140
141
|
if (normalized === "auto") return undefined;
|
|
141
|
-
const accounts = getAccountSet("kiro")?.accounts.filter(account => account.needsReauth !== true) ?? [];
|
|
142
|
+
const accounts = getAccountSet("kiro")?.accounts.filter(account => account.needsReauth !== true && account.paused !== true) ?? [];
|
|
142
143
|
const limits: number[] = [];
|
|
143
144
|
let unknown = accounts.length === 0;
|
|
144
145
|
for (const account of accounts) {
|
|
@@ -25,6 +25,15 @@ export function isCanonicalOpenAiForwardProvider(provider: OcxProviderConfig): b
|
|
|
25
25
|
&& normalizedBaseUrl(provider.baseUrl) === CODEX_FORWARD_BASE_URL;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Response evidence is separate from Fast capability and request serialization. Preserve the
|
|
30
|
+
* known Codex exception (#2558); other destinations may declare their response contract without
|
|
31
|
+
* a gateway-name or URL heuristic. Absence retains the authoritative legacy default.
|
|
32
|
+
*/
|
|
33
|
+
export function responseTierAuthorityForProvider(provider: OcxProviderConfig): boolean | undefined {
|
|
34
|
+
return isCanonicalOpenAiForwardProvider(provider) ? false : provider.responseTierAuthoritative;
|
|
35
|
+
}
|
|
36
|
+
|
|
28
37
|
const OPENAI_API_ORIGIN = "https://api.openai.com";
|
|
29
38
|
const OPENAI_API_BASE_URL = `${OPENAI_API_ORIGIN}/v1`;
|
|
30
39
|
const OPENAI_API_RESPONSES_URL = `${OPENAI_API_BASE_URL}/responses`;
|