@bitkyc08/opencodex 2.77.0 → 2.78.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 +11 -10
- package/gui/dist/assets/App-BtfG5id-.js +54 -0
- package/gui/dist/assets/App-lCIslZ1L.css +1 -0
- package/gui/dist/assets/{Tray-gloIYNaW.js → Tray-DiJpSub0.js} +1 -1
- package/gui/dist/assets/index-Btc3Ogw4.js +94 -0
- package/gui/dist/assets/index-DmQB8D_R.css +1 -0
- package/gui/dist/assets/quota-summary-B_a96nXj.js +1 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/anthropic/beta-allowlist.ts +19 -2
- package/src/adapters/anthropic/client-identity.ts +13 -4
- package/src/adapters/anthropic/native-client-preamble.ts +31 -0
- package/src/adapters/anthropic/passthrough.ts +103 -25
- package/src/adapters/devin/cloud-direct/chat.ts +7 -2
- package/src/adapters/devin/trajectory.ts +49 -0
- package/src/adapters/devin.ts +13 -0
- package/src/adapters/ollama-native.ts +35 -19
- package/src/claude/intercept/client-hello.ts +103 -0
- package/src/claude/intercept/model-bindings.ts +3 -2
- package/src/claude/intercept/picker-listener.ts +279 -66
- package/src/cli/access-audio-input.ts +84 -0
- package/src/cli/access-audio-live.ts +99 -0
- package/src/cli/access-audio.ts +123 -0
- package/src/cli/access-data-client.ts +127 -0
- package/src/cli/access-data-plane.ts +104 -0
- package/src/cli/access-data-response.ts +76 -0
- package/src/cli/access.ts +65 -2
- package/src/cli/account-api.ts +30 -6
- package/src/cli/account-auth.ts +111 -42
- package/src/cli/account-key-quota.ts +118 -0
- package/src/cli/account-main.ts +11 -4
- package/src/cli/account-next-actions.ts +63 -0
- package/src/cli/account-policy-dto.ts +68 -0
- package/src/cli/account-policy.ts +167 -0
- package/src/cli/account-target.ts +8 -0
- package/src/cli/account.ts +34 -8
- package/src/cli/agent-runtime-settings.ts +130 -0
- package/src/cli/agent-settings.ts +101 -0
- package/src/cli/agent.ts +19 -2
- package/src/cli/alias.ts +7 -2
- package/src/cli/aside-profiles.ts +2 -2
- package/src/cli/capabilities-access-remote.ts +147 -0
- package/src/cli/capabilities-accounts.ts +195 -0
- package/src/cli/capabilities-agents-routing.ts +414 -0
- package/src/cli/capabilities-base.ts +783 -0
- package/src/cli/capabilities-command.ts +1 -0
- package/src/cli/capabilities-integrations.ts +264 -0
- package/src/cli/capabilities-lab.ts +214 -0
- package/src/cli/capabilities-observe-system.ts +662 -0
- package/src/cli/capabilities-provider-models.ts +302 -0
- package/src/cli/capabilities.ts +30 -1199
- package/src/cli/capability-types.ts +54 -0
- package/src/cli/catalog-command-result.ts +57 -0
- package/src/cli/chatgpt-command.ts +25 -2
- package/src/cli/claude-agent-startup-sync.ts +17 -0
- package/src/cli/claude-desktop-profile.ts +100 -0
- package/src/cli/claude-desktop.ts +39 -6
- package/src/cli/codex-shim-autorestore.ts +2 -1
- package/src/cli/codex-shim-readiness.ts +23 -3
- package/src/cli/combo-input.ts +88 -0
- package/src/cli/combo-result.ts +52 -0
- package/src/cli/combo-stats.ts +123 -0
- package/src/cli/combo.ts +37 -14
- package/src/cli/companion-timeline.ts +142 -0
- package/src/cli/companion-usage.ts +146 -0
- package/src/cli/companion.ts +12 -3
- package/src/cli/config-command.ts +24 -8
- package/src/cli/dispatch.ts +52 -79
- package/src/cli/doctor.ts +22 -4
- package/src/cli/help-catalog.ts +31 -14
- package/src/cli/help-recovery.ts +2 -2
- package/src/cli/help.ts +25 -11
- package/src/cli/index.ts +38 -27
- package/src/cli/injection-follow.ts +35 -0
- package/src/cli/inspect.ts +5 -0
- package/src/cli/integration-aside-sync.ts +69 -0
- package/src/cli/integration-cursor.ts +106 -0
- package/src/cli/integration-input.ts +60 -0
- package/src/cli/integration-journal.ts +49 -0
- package/src/cli/integration-plan-dto.ts +156 -0
- package/src/cli/integration-preview.ts +215 -0
- package/src/cli/integrations.ts +24 -18
- package/src/cli/json-input.ts +134 -0
- package/src/cli/link.ts +32 -4
- package/src/cli/local-sync-result.ts +41 -0
- package/src/cli/log-follow.ts +84 -0
- package/src/cli/log-view-filter.ts +146 -0
- package/src/cli/logout-command.ts +44 -0
- package/src/cli/model-picker-ordering.ts +137 -0
- package/src/cli/models-custom-input.ts +107 -0
- package/src/cli/models-custom-runtime.ts +112 -0
- package/src/cli/models-order.ts +194 -0
- package/src/cli/models-runtime-subcommands.ts +2 -0
- package/src/cli/models-runtime.ts +7 -0
- package/src/cli/models.ts +66 -81
- package/src/cli/observe-stream.ts +142 -0
- package/src/cli/observe.ts +75 -34
- package/src/cli/provider-batch.ts +77 -0
- package/src/cli/provider-lifecycle-runtime.ts +170 -0
- package/src/cli/provider-result.ts +124 -0
- package/src/cli/provider-runtime.ts +15 -12
- package/src/cli/provider-settings.ts +143 -0
- package/src/cli/provider.ts +112 -34
- package/src/cli/registry.ts +62 -20
- package/src/cli/remote-workspace-hub.ts +83 -0
- package/src/cli/remote-workspace.ts +6 -1
- package/src/cli/restart-failure.ts +27 -0
- package/src/cli/root.ts +12 -0
- package/src/cli/route-policy-write.ts +137 -0
- package/src/cli/route-policy.ts +8 -0
- package/src/cli/runtime-api.ts +56 -79
- package/src/cli/secret-args.ts +149 -0
- package/src/cli/settings-result.ts +30 -0
- package/src/cli/storage.ts +77 -7
- package/src/cli/system-command.ts +60 -2
- package/src/cli/system-restart-client.ts +20 -3
- package/src/cli/system-settings-parity.ts +83 -0
- package/src/cli/update-restart-candidate.ts +21 -0
- package/src/cli/update-restart-child.ts +81 -0
- package/src/cli/update-restart-home.ts +38 -0
- package/src/cli/update-restart-transport.ts +193 -0
- package/src/cli/update-restart.ts +260 -0
- package/src/cli/usage-model-search.ts +58 -0
- package/src/cli/usage-report.ts +13 -5
- package/src/cli/v2-input.ts +66 -0
- package/src/cli/v2-local-output.ts +37 -0
- package/src/cli/v2-runtime.ts +90 -0
- package/src/cli/v2.ts +102 -144
- package/src/clients/config-export/dsh.ts +21 -0
- package/src/clients/config-export.ts +7 -1
- package/src/codex/catalog/build-entries.ts +1 -1
- package/src/codex/catalog/bundled.ts +9 -2
- package/src/codex/catalog/heal-observation.ts +66 -0
- package/src/codex/catalog/publication-observer.ts +19 -0
- package/src/codex/catalog/remote.ts +13 -15
- package/src/codex/catalog/restore.ts +19 -6
- package/src/codex/catalog/retained-sync.ts +58 -10
- package/src/codex/catalog/routed-removal.ts +116 -0
- package/src/codex/catalog/write-audit-contract.ts +21 -0
- package/src/codex/catalog/write-audit.ts +171 -0
- package/src/codex/catalog-self-heal.ts +284 -0
- package/src/codex/catalog-write-serialization.ts +71 -7
- package/src/codex/codex-home-owner.ts +124 -0
- package/src/codex/convergence.ts +55 -15
- package/src/codex/features.ts +8 -1
- package/src/codex/inject/restore.ts +46 -4
- package/src/codex/inject.ts +11 -1
- package/src/codex/internal/catalog-writer.ts +120 -13
- package/src/codex/journal.ts +49 -13
- package/src/codex/management-convergence.ts +3 -0
- package/src/codex/quota-types.ts +3 -0
- package/src/codex/quota.ts +13 -3
- package/src/codex/refresh.ts +20 -9
- package/src/codex/runtime.ts +7 -1
- package/src/codex/shim-diagnostics.ts +80 -0
- package/src/codex/shim-fingerprint.ts +7 -2
- package/src/codex/shim-migration.ts +153 -0
- package/src/codex/shim-overlay.ts +509 -0
- package/src/codex/shim-path-resolution.ts +14 -2
- package/src/codex/shim-probe.ts +4 -3
- package/src/codex/shim-state-file.ts +93 -7
- package/src/codex/shim.ts +15 -68
- package/src/codex/sync.ts +24 -4
- package/src/combos/failover.ts +19 -0
- package/src/combos/index.ts +1 -0
- package/src/combos/resolve.ts +12 -0
- package/src/config/atomic-write.ts +13 -8
- package/src/config/diagnostics.ts +19 -1
- package/src/config/load-degrade.ts +3 -0
- package/src/config/schema/chatgpt-desktop.ts +22 -0
- package/src/config/schema/config-schema.ts +32 -14
- package/src/config/schema/leaf-validators.ts +3 -2
- package/src/generated/compatibility-version.json +445 -129
- package/src/integrations/merge.ts +13 -5
- package/src/integrations/mutation-plan.ts +46 -3
- package/src/integrations/omp-yaml-source.ts +181 -1
- package/src/integrations/ownership-policy.ts +66 -0
- package/src/integrations/registry.ts +50 -1
- package/src/integrations/state.ts +46 -21
- package/src/integrations/target.ts +25 -2
- package/src/integrations/writer.ts +89 -31
- package/src/lib/package-tree-integrity.ts +42 -1
- package/src/lib/proxy-env.ts +3 -0
- package/src/lib/test-home-guard.ts +2 -1
- package/src/oauth/anthropic-account-refusal.ts +19 -8
- package/src/oauth/anthropic-routing.ts +17 -12
- package/src/oauth/anthropic.ts +27 -2
- package/src/oauth/index.ts +6 -8
- package/src/oauth/pool-settings-capability.ts +9 -2
- package/src/protocols/plan-snapshot.ts +1 -1
- package/src/protocols/settings.ts +28 -4
- package/src/server/caller-session-identity.ts +32 -0
- package/src/server/claude-messages.ts +11 -4
- package/src/server/index/serve-options.ts +12 -7
- package/src/server/index.ts +1 -1
- package/src/server/management/agent-settings-routes.ts +6 -5
- package/src/server/management/context.ts +8 -1
- package/src/server/management/cursor-integration-routes.ts +3 -3
- package/src/server/management/integration-routes.ts +5 -5
- package/src/server/management/model-routes.ts +3 -3
- package/src/server/management/native-integration-routes.ts +2 -2
- package/src/server/management/oauth-account-routes.ts +96 -30
- package/src/server/management/route-registry.ts +25 -24
- package/src/server/messages-native-eligibility.ts +3 -6
- package/src/server/messages-native-oauth.ts +82 -35
- package/src/server/messages-native.ts +78 -16
- package/src/server/responses/adapter-dispatch.ts +53 -28
- package/src/server/responses/core-auth.ts +10 -4
- package/src/server/responses/core-combo.ts +46 -6
- package/src/service/cli.ts +6 -1
- package/src/service/ownership-mutation-lease.d.mts +26 -0
- package/src/service/ownership-mutation-lease.mjs +76 -1
- package/src/types/config.ts +2 -0
- package/src/types/provider.ts +10 -10
- package/src/update/index.ts +2 -1
- package/src/update/update-failure-guidance.d.mts +6 -0
- package/src/update/update-failure-guidance.mjs +30 -5
- package/gui/dist/assets/App-COdEeqSj.css +0 -1
- package/gui/dist/assets/App-DEpCJEP_.js +0 -54
- package/gui/dist/assets/index-Cd7ittMv.css +0 -1
- package/gui/dist/assets/index-DwJKFJm5.js +0 -94
- package/gui/dist/assets/quota-summary-DGcEJU3h.js +0 -1
|
@@ -36,6 +36,12 @@ export interface IntegrationTarget {
|
|
|
36
36
|
readonly format: ConfigFormat;
|
|
37
37
|
/** The contribution shape that file's reader understands. */
|
|
38
38
|
readonly buildContribution: BuildContribution;
|
|
39
|
+
/**
|
|
40
|
+
* The one YAML leaf patched in place in that file, or null when the writer
|
|
41
|
+
* re-renders the document. A client's config file and its store have
|
|
42
|
+
* different shapes, so this belongs to the target rather than the client.
|
|
43
|
+
*/
|
|
44
|
+
readonly sourcePreservingYaml: { readonly path: readonly string[] } | null;
|
|
39
45
|
/**
|
|
40
46
|
* Set when a write to the file above would not reach the client.
|
|
41
47
|
*
|
|
@@ -60,10 +66,20 @@ export interface IneffectiveWrite {
|
|
|
60
66
|
* with the store itself as the target: a client that bumps its schema after
|
|
61
67
|
* we wrote the store leaves our block there, removable, and the file no
|
|
62
68
|
* longer one we may merge into.
|
|
69
|
+
*
|
|
70
|
+
* `missing-store` — the store does not exist, but the client already manages it
|
|
71
|
+
* (DSH: a Desktop profile manifest is present). DSH renames `settings.yaml` to
|
|
72
|
+
* `settings.yaml.imported` and imports it on startup, so a config-file write would
|
|
73
|
+
* orphan our ownership record. `remedy` says how to bring the store back, and
|
|
74
|
+
* `emptyDocument` is what to create it with.
|
|
63
75
|
*/
|
|
64
|
-
readonly why:
|
|
76
|
+
readonly why: IneffectiveWriteReason;
|
|
77
|
+
readonly remedy?: string;
|
|
78
|
+
readonly emptyDocument?: string;
|
|
65
79
|
}
|
|
66
80
|
|
|
81
|
+
export type IneffectiveWriteReason = "owned-config-file" | "unestablished-schema" | "missing-store";
|
|
82
|
+
|
|
67
83
|
function configFileTarget(
|
|
68
84
|
clientId: IntegrationClientId,
|
|
69
85
|
configPath: string,
|
|
@@ -74,6 +90,7 @@ function configFileTarget(
|
|
|
74
90
|
configPath,
|
|
75
91
|
format: exportSpec.format,
|
|
76
92
|
buildContribution: exportSpec.buildContribution,
|
|
93
|
+
sourcePreservingYaml: INTEGRATION_CLIENTS[clientId].sourcePreservingYaml ?? null,
|
|
77
94
|
ineffective,
|
|
78
95
|
};
|
|
79
96
|
}
|
|
@@ -87,6 +104,7 @@ function storeTarget(
|
|
|
87
104
|
configPath,
|
|
88
105
|
format: declared.format,
|
|
89
106
|
buildContribution: declared.buildContribution,
|
|
107
|
+
sourcePreservingYaml: declared.sourcePreservingYaml ?? null,
|
|
90
108
|
ineffective,
|
|
91
109
|
};
|
|
92
110
|
}
|
|
@@ -141,7 +159,12 @@ export function resolveIntegrationTarget(args: {
|
|
|
141
159
|
if (!declared) return configFileTarget(clientId, configPath, null);
|
|
142
160
|
const storePath = declared.path(args.env, args.home);
|
|
143
161
|
const kind = io.statKind(storePath);
|
|
144
|
-
if (kind === "missing")
|
|
162
|
+
if (kind === "missing") {
|
|
163
|
+
const missing = declared.missingStore;
|
|
164
|
+
return missing?.readsStore(storePath, path => io.statKind(path)) === true
|
|
165
|
+
? configFileTarget(clientId, configPath, { store: storePath, why: "missing-store", remedy: missing.remedy, emptyDocument: missing.emptyDocument })
|
|
166
|
+
: configFileTarget(clientId, configPath, null);
|
|
167
|
+
}
|
|
145
168
|
// Only proven absence permits a legacy write. Unreadable or non-file stores
|
|
146
169
|
// cannot establish what the client reads; preserve the recorded removal target.
|
|
147
170
|
if (kind !== "file") {
|
|
@@ -33,7 +33,7 @@ import {
|
|
|
33
33
|
refreshablePathsOf,
|
|
34
34
|
semanticProtectedContributionFingerprint,
|
|
35
35
|
} from "./ownership-policy";
|
|
36
|
-
import { AmbiguousSelectorError, createdContainerPaths, mergeContribution, removeFragments } from "./merge";
|
|
36
|
+
import { AmbiguousSelectorError, createdContainerPaths, mergeContribution, parseSegment, removeFragments } from "./merge";
|
|
37
37
|
import {
|
|
38
38
|
INTEGRATION_CLIENTS,
|
|
39
39
|
assertDroidPathsUnambiguous,
|
|
@@ -48,7 +48,7 @@ import type { IntegrationState } from "./state";
|
|
|
48
48
|
import { serializeDocument, UnserializableValueError } from "./serialize";
|
|
49
49
|
import { ClientPathError } from "../clients/config-export";
|
|
50
50
|
import { matchesOperationResult, newOpId, type JournalEntry } from "./journal";
|
|
51
|
-
import { observeIntegration, type IntegrationWriteInput, type RefusalReason } from "./mutation-plan";
|
|
51
|
+
import { observeIntegration, restoreStoreDirectoryRefusal, type IntegrationWriteInput, type RefusalReason } from "./mutation-plan";
|
|
52
52
|
import { createIntegrationStateStore, type IntegrationStateStore } from "./store";
|
|
53
53
|
import {
|
|
54
54
|
patchYamlFragmentSource,
|
|
@@ -300,6 +300,8 @@ function applyOrRefreshIntegration(
|
|
|
300
300
|
return refuse(clientId, "superseded_store", classified.state,
|
|
301
301
|
target.ineffective.why === "owned-config-file"
|
|
302
302
|
? `${clientId} now reads its providers from ${readsFrom}, and opencodex still has a block in ${configPath}, which it no longer reads. Disable the ${clientId} integration to remove that block, then enable it again to write ${readsFrom}.`
|
|
303
|
+
: target.ineffective.why === "missing-store"
|
|
304
|
+
? `${clientId} reads its providers from ${readsFrom}, which does not exist. ${clientId} renames ${configPath} away and imports it on its next start, so opencodex will not write there. ${target.ineffective.remedy ?? fallback}`
|
|
303
305
|
: readsFrom === configPath
|
|
304
306
|
? `opencodex does not recognise the schema of ${readsFrom}, the file ${clientId} reads its providers from, so it will not merge into it. ${fallback}`
|
|
305
307
|
: `${clientId} now reads its providers from ${readsFrom}, whose schema opencodex does not recognise, so writing ${configPath} would change nothing it loads. ${fallback}`);
|
|
@@ -383,8 +385,15 @@ function applyOrRefreshIntegration(
|
|
|
383
385
|
let created: string[];
|
|
384
386
|
let text: string;
|
|
385
387
|
try {
|
|
388
|
+
// The source patcher updates this selected row in place. Keep it in the
|
|
389
|
+
// semantic merge too, even when we originally created it, and retain that
|
|
390
|
+
// provenance so disable can still remove the row when it becomes empty.
|
|
391
|
+
const sourceRoot = target.sourcePreservingYaml?.path[0];
|
|
392
|
+
const retainedRow = sourceRoot && parseSegment(sourceRoot).kind === "select" ? sourceRoot : null;
|
|
393
|
+
const priorCreated = record?.createdContainers ?? [];
|
|
394
|
+
const prunable = new Set(priorCreated.filter(path => path !== retainedRow));
|
|
386
395
|
const base = classified.state === "stale" && record
|
|
387
|
-
? removeFragments(parsed, record.fragmentPaths,
|
|
396
|
+
? removeFragments(parsed, record.fragmentPaths, prunable).doc
|
|
388
397
|
: classified.state === "conflict" && record
|
|
389
398
|
/*
|
|
390
399
|
* A forced overwrite of a `foreign-edit` conflict drops what the previous
|
|
@@ -399,26 +408,27 @@ function applyOrRefreshIntegration(
|
|
|
399
408
|
* them, so a later disable removes our leaves and leaves their structure
|
|
400
409
|
* standing.
|
|
401
410
|
*/
|
|
402
|
-
? removeFragments(parsed, record.fragmentPaths,
|
|
411
|
+
? removeFragments(parsed, record.fragmentPaths, prunable).doc
|
|
403
412
|
: parsed;
|
|
404
413
|
// Computed against the document as it stands BEFORE the merge: afterwards
|
|
405
414
|
// every container exists and "did we create this?" is unanswerable.
|
|
406
415
|
created = createdContainerPaths(base, contribution);
|
|
416
|
+
if (retainedRow && priorCreated.includes(retainedRow)) created = [...new Set([retainedRow, ...created])];
|
|
407
417
|
const nextDocument = mergeContribution(base, contribution);
|
|
408
418
|
if (clientId === "cline") preserveClineSelection(parsed, nextDocument);
|
|
409
|
-
if (
|
|
410
|
-
const value = sourcePreservingFragmentValue(contribution,
|
|
419
|
+
if (target.sourcePreservingYaml && before !== null) {
|
|
420
|
+
const value = sourcePreservingFragmentValue(contribution, target.sourcePreservingYaml.path);
|
|
411
421
|
const patched = value === undefined
|
|
412
422
|
? null
|
|
413
423
|
: patchYamlFragmentSource(
|
|
414
424
|
before,
|
|
415
|
-
|
|
425
|
+
target.sourcePreservingYaml.path,
|
|
416
426
|
{ kind: "upsert", value },
|
|
417
427
|
nextDocument,
|
|
418
428
|
);
|
|
419
429
|
if (patched === null) {
|
|
420
430
|
return refuse(clientId, "unsafe", "unsafe",
|
|
421
|
-
yamlRefusalReason(before,
|
|
431
|
+
yamlRefusalReason(before, target.sourcePreservingYaml.path, configPath, "it was left alone"));
|
|
422
432
|
}
|
|
423
433
|
text = patched;
|
|
424
434
|
} else {
|
|
@@ -565,12 +575,12 @@ export function disableIntegration(input: IntegrationWriteInput): WriteOutcome {
|
|
|
565
575
|
* comment makes its ancestor user-owned without protecting our leaf.
|
|
566
576
|
*/
|
|
567
577
|
const recordedCreated = record!.createdContainers ?? [];
|
|
568
|
-
const prunableCreated =
|
|
569
|
-
? sourcePrunableYamlContainers(before,
|
|
578
|
+
const prunableCreated = target.sourcePreservingYaml && before !== null
|
|
579
|
+
? sourcePrunableYamlContainers(before, target.sourcePreservingYaml.path, recordedCreated)
|
|
570
580
|
: recordedCreated;
|
|
571
581
|
if (prunableCreated === null) {
|
|
572
582
|
return refuse(clientId, "unsafe", "unsafe",
|
|
573
|
-
yamlRefusalReason(before ?? "",
|
|
583
|
+
yamlRefusalReason(before ?? "", target.sourcePreservingYaml!.path, configPath, "nothing was removed"));
|
|
574
584
|
}
|
|
575
585
|
let doc: unknown;
|
|
576
586
|
let removed: boolean;
|
|
@@ -586,14 +596,14 @@ export function disableIntegration(input: IntegrationWriteInput): WriteOutcome {
|
|
|
586
596
|
}
|
|
587
597
|
let text: string;
|
|
588
598
|
try {
|
|
589
|
-
if (
|
|
590
|
-
const patched = patchYamlFragmentSource(before,
|
|
599
|
+
if (target.sourcePreservingYaml && before !== null) {
|
|
600
|
+
const patched = patchYamlFragmentSource(before, target.sourcePreservingYaml.path, {
|
|
591
601
|
kind: "remove",
|
|
592
602
|
createdContainers: prunableCreated,
|
|
593
603
|
}, doc);
|
|
594
604
|
if (patched === null) {
|
|
595
605
|
return refuse(clientId, "unsafe", "unsafe",
|
|
596
|
-
yamlRefusalReason(before,
|
|
606
|
+
yamlRefusalReason(before, target.sourcePreservingYaml.path, configPath, "nothing was removed"));
|
|
597
607
|
}
|
|
598
608
|
text = patched;
|
|
599
609
|
} else {
|
|
@@ -651,6 +661,10 @@ export function restoreIntegration(input: IntegrationRestoreInput): WriteOutcome
|
|
|
651
661
|
return refuse(clientId, "conflict", "conflict",
|
|
652
662
|
`that operation was recorded for ${configPath}, which this client no longer writes; it now resolves to ${resolvedPath}`);
|
|
653
663
|
}
|
|
664
|
+
const directoryRefusal = restoreStoreDirectoryRefusal(input, configPath, io);
|
|
665
|
+
if (directoryRefusal !== null) {
|
|
666
|
+
return refuse(clientId, "unsafe", "unsafe", directoryRefusal);
|
|
667
|
+
}
|
|
654
668
|
/*
|
|
655
669
|
* Preview refuses this in observeRestore. Refusing here too is what keeps an
|
|
656
670
|
* undo of an older candidate from replacing the record a newer candidate owns.
|
|
@@ -862,6 +876,57 @@ function tryFreezeIntegrationInput(input: IntegrationWriteInput):
|
|
|
862
876
|
}
|
|
863
877
|
}
|
|
864
878
|
|
|
879
|
+
/**
|
|
880
|
+
* The store lock to hold beside the config file's, or null.
|
|
881
|
+
*
|
|
882
|
+
* Only for a store whose directory exists: a lock is never what creates a
|
|
883
|
+
* client's profile. A store path that cannot be resolved names nothing to lock;
|
|
884
|
+
* target resolution reports it on its own.
|
|
885
|
+
*/
|
|
886
|
+
function storeLockFile(frozen: FrozenIntegrationInput): string | null {
|
|
887
|
+
const declared = INTEGRATION_CLIENTS[frozen.clientId].currentStore;
|
|
888
|
+
if (!declared?.lockFile) return null;
|
|
889
|
+
let storePath: string;
|
|
890
|
+
try {
|
|
891
|
+
storePath = declared.path(frozen.env, frozen.home);
|
|
892
|
+
} catch (error) {
|
|
893
|
+
if (error instanceof ClientPathError) return null;
|
|
894
|
+
throw error;
|
|
895
|
+
}
|
|
896
|
+
return frozen.io.statKind(dirname(storePath)) === "dir" ? declared.lockFile(storePath) : null;
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
/**
|
|
900
|
+
* Hold the config file's sibling lock, then the store's. Every operation takes
|
|
901
|
+
* them in that order, so two of ours cannot deadlock, and the client's own
|
|
902
|
+
* writer only ever holds one of them.
|
|
903
|
+
*/
|
|
904
|
+
function withClientLocks(
|
|
905
|
+
frozen: FrozenIntegrationInput,
|
|
906
|
+
suffix: ".lock",
|
|
907
|
+
operation: () => WriteOutcome,
|
|
908
|
+
options?: CoordinatedIntegrationOptions,
|
|
909
|
+
): Promise<WriteOutcome> {
|
|
910
|
+
const run = async (heldStoreLock: string | null): Promise<WriteOutcome> => {
|
|
911
|
+
const refused = await options?.revalidate?.(frozen);
|
|
912
|
+
if (refused) return refused;
|
|
913
|
+
// A profile can appear while revalidation awaits. Acquire its lock and
|
|
914
|
+
// repeat the validation before observing or mutating its document.
|
|
915
|
+
const required = storeLockFile(frozen);
|
|
916
|
+
if (required !== null && required !== heldStoreLock) {
|
|
917
|
+
return withIntegrationWriterLock(required, () => run(required), options?.lockSeams, ".lock");
|
|
918
|
+
}
|
|
919
|
+
// No await between the last store probe and the synchronous transaction.
|
|
920
|
+
return operation();
|
|
921
|
+
};
|
|
922
|
+
return withIntegrationWriterLock(frozen.resolvedPaths.configPath, () => {
|
|
923
|
+
// Decide only after the outer lock has actually been acquired.
|
|
924
|
+
const storeLock = storeLockFile(frozen);
|
|
925
|
+
return storeLock === null ? run(null)
|
|
926
|
+
: withIntegrationWriterLock(storeLock, () => run(storeLock), options?.lockSeams, ".lock");
|
|
927
|
+
}, options?.lockSeams, suffix);
|
|
928
|
+
}
|
|
929
|
+
|
|
865
930
|
async function coordinatedWrite(
|
|
866
931
|
input: IntegrationWriteInput,
|
|
867
932
|
operation: (frozen: IntegrationWriteInput) => WriteOutcome,
|
|
@@ -879,16 +944,14 @@ async function coordinatedWrite(
|
|
|
879
944
|
// An absent client home is not created merely to acquire a sibling lock.
|
|
880
945
|
if (frozen.io.statKind(frozen.resolvedPaths.detectDir) !== "dir") {
|
|
881
946
|
const refused = await options?.revalidate?.(frozen);
|
|
882
|
-
|
|
947
|
+
if (refused) return refused;
|
|
948
|
+
if (frozen.io.statKind(frozen.resolvedPaths.detectDir) !== "dir") return operation(frozen);
|
|
883
949
|
}
|
|
884
|
-
return
|
|
885
|
-
frozen
|
|
886
|
-
async () => {
|
|
887
|
-
const refused = await options?.revalidate?.(frozen);
|
|
888
|
-
return refused ?? operation(frozen);
|
|
889
|
-
},
|
|
890
|
-
options?.lockSeams,
|
|
950
|
+
return withClientLocks(
|
|
951
|
+
frozen,
|
|
891
952
|
spec.writerLock.suffix,
|
|
953
|
+
() => operation(frozen),
|
|
954
|
+
options,
|
|
892
955
|
);
|
|
893
956
|
}
|
|
894
957
|
|
|
@@ -941,15 +1004,10 @@ export async function restoreIntegrationCoordinated(
|
|
|
941
1004
|
`${frozen.resolvedPaths.detectDir} is missing; restore will not create the client home`,
|
|
942
1005
|
);
|
|
943
1006
|
}
|
|
944
|
-
return
|
|
945
|
-
frozen
|
|
946
|
-
async () => {
|
|
947
|
-
// An undo is bound like any other confirmation, and this is the only place where that check
|
|
948
|
-
// happens with the lock held and before the snapshot, the write and the journal row.
|
|
949
|
-
const refused = await options?.revalidate?.(frozen);
|
|
950
|
-
return refused ?? run();
|
|
951
|
-
},
|
|
952
|
-
options?.lockSeams,
|
|
1007
|
+
return withClientLocks(
|
|
1008
|
+
frozen,
|
|
953
1009
|
spec.writerLock.suffix,
|
|
1010
|
+
run,
|
|
1011
|
+
options,
|
|
954
1012
|
);
|
|
955
1013
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readFileSync, statSync } from "node:fs";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { isRealBunBinary } from "./bun-binary-validator.mjs";
|
|
4
5
|
import { isStandaloneBinary } from "./standalone";
|
|
5
6
|
|
|
6
7
|
export interface PackageTreeObservation {
|
|
@@ -56,6 +57,14 @@ export interface PackageTreeIntegrityOptions {
|
|
|
56
57
|
schedule?: (callback: () => void, delayMs: number) => (() => void) | void;
|
|
57
58
|
/** Test seam for `installedVersion()`; production reads the package manifest. */
|
|
58
59
|
readInstalledVersion?: () => string | undefined;
|
|
60
|
+
/**
|
|
61
|
+
* Whether the runtime a restart would spawn is complete. An in-place npm install writes
|
|
62
|
+
* package.json and extracts the `bun` package's small placeholder before its postinstall
|
|
63
|
+
* swaps the real binary in, and the replacement spawns `process.execPath`, that same path.
|
|
64
|
+
* While this answers false the replacement is treated like an unreadable tree: no restart,
|
|
65
|
+
* then a fresh full debounce once the runtime is ready.
|
|
66
|
+
*/
|
|
67
|
+
runtimeReady?: () => boolean;
|
|
59
68
|
}
|
|
60
69
|
|
|
61
70
|
export type ObservePackageTree = () => PackageTreeObservation | null;
|
|
@@ -142,6 +151,12 @@ export function sameObservation(left: PackageTreeObservation, right: PackageTree
|
|
|
142
151
|
*/
|
|
143
152
|
const PACKAGE_TREE_RECHECK_MS = 1_000;
|
|
144
153
|
|
|
154
|
+
/**
|
|
155
|
+
* Fences a live process whose package manifest was replaced, and once the replacement is readable,
|
|
156
|
+
* stable for the debounce interval and any configured `runtimeReady` check passes, hands it to
|
|
157
|
+
* `onReplaced`. If the callback throws, the guard rechecks the tree and may call it again after
|
|
158
|
+
* another debounce interval while the replacement persists.
|
|
159
|
+
*/
|
|
145
160
|
export function createPackageTreeIntegrityGuard(
|
|
146
161
|
observe: ObservePackageTree = observePackageManifest,
|
|
147
162
|
now: () => number = Date.now,
|
|
@@ -154,6 +169,8 @@ export function createPackageTreeIntegrityGuard(
|
|
|
154
169
|
let timerScheduled = false;
|
|
155
170
|
let cancelScheduled: (() => void) | null = null;
|
|
156
171
|
let waitingForReadableTree = false;
|
|
172
|
+
/** Logged once per wait, so a postinstall that never runs is visible rather than silent. */
|
|
173
|
+
let waitingForRuntime = false;
|
|
157
174
|
let replacementCandidate: PackageTreeObservation | null = null;
|
|
158
175
|
/** The replacement identity that survived a full stability interval (see installedVersion). */
|
|
159
176
|
let settledReplacement: PackageTreeObservation | null = null;
|
|
@@ -192,9 +209,24 @@ export function createPackageTreeIntegrityGuard(
|
|
|
192
209
|
if (sameObservation(boot, current)) {
|
|
193
210
|
resetRestartTimer();
|
|
194
211
|
waitingForReadableTree = false;
|
|
212
|
+
waitingForRuntime = false;
|
|
195
213
|
replacementCandidate = null;
|
|
196
214
|
return;
|
|
197
215
|
}
|
|
216
|
+
if (options.runtimeReady && !options.runtimeReady()) {
|
|
217
|
+
if (!waitingForRuntime) {
|
|
218
|
+
waitingForRuntime = true;
|
|
219
|
+
console.warn("Package tree replaced; waiting for its Bun runtime to finish installing before restarting");
|
|
220
|
+
}
|
|
221
|
+
// A replacement settled before a failed admission is no longer restart-ready: withdraw
|
|
222
|
+
// it so installedVersion() stays silent until the fresh debounce settles it again.
|
|
223
|
+
settledReplacement = null;
|
|
224
|
+
resetRestartTimer();
|
|
225
|
+
waitingForReadableTree = true;
|
|
226
|
+
armRestartTimer(PACKAGE_TREE_RECHECK_MS);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
waitingForRuntime = false;
|
|
198
230
|
if (waitingForReadableTree) {
|
|
199
231
|
waitingForReadableTree = false;
|
|
200
232
|
replacementCandidate = current;
|
|
@@ -239,6 +271,7 @@ export function createPackageTreeIntegrityGuard(
|
|
|
239
271
|
return {
|
|
240
272
|
installedVersion: () => {
|
|
241
273
|
if (settledReplacement === null) return undefined;
|
|
274
|
+
if (options.runtimeReady && !options.runtimeReady()) return undefined;
|
|
242
275
|
const current = observe();
|
|
243
276
|
if (current === null || !sameObservation(settledReplacement, current)) return undefined;
|
|
244
277
|
return readInstalledVersion();
|
|
@@ -271,6 +304,7 @@ export function createPackageTreeIntegrityGuard(
|
|
|
271
304
|
lastOkAt = at;
|
|
272
305
|
resetRestartTimer();
|
|
273
306
|
waitingForReadableTree = false;
|
|
307
|
+
waitingForRuntime = false;
|
|
274
308
|
replacementCandidate = null;
|
|
275
309
|
return { ok: true };
|
|
276
310
|
},
|
|
@@ -292,5 +326,12 @@ export function createRuntimePackageTreeIntegrityGuard(
|
|
|
292
326
|
if (installer === "source" || isStandaloneBinary()) {
|
|
293
327
|
return { status: () => ({ ok: true }), dispose: () => {} };
|
|
294
328
|
}
|
|
295
|
-
|
|
329
|
+
// The restart replacement spawns process.execPath, the bundled Bun in an npm install. Read it
|
|
330
|
+
// now: Linux Bun resolves it lazily, and after npm swaps the package directory a first read
|
|
331
|
+
// reports the retired binary as "(deleted)"; reading it once pins the boot path.
|
|
332
|
+
const runtimePath = process.execPath;
|
|
333
|
+
return createPackageTreeIntegrityGuard(observe, now, {
|
|
334
|
+
...options,
|
|
335
|
+
runtimeReady: options.runtimeReady ?? (() => isRealBunBinary(runtimePath)),
|
|
336
|
+
});
|
|
296
337
|
}
|
package/src/lib/proxy-env.ts
CHANGED
|
@@ -96,6 +96,9 @@ export function outboundProxyConfigured(
|
|
|
96
96
|
return OUTBOUND_PROXY_ENV_KEYS.some(key => proxyEnvPresent(key, env));
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
+
// Captured before config/proxy-env applies runtime changes; Bun retains startup proxy env.
|
|
100
|
+
export const startupOutboundProxyConfigured = outboundProxyConfigured();
|
|
101
|
+
|
|
99
102
|
/**
|
|
100
103
|
* The value when `raw` is a proxy URL Bun fetch can actually use, else null.
|
|
101
104
|
* Bun rejects unparseable values and non-http(s) schemes (UnsupportedProxyProtocol),
|
|
@@ -150,7 +150,8 @@ export function assertNotRealCodexHomeUnderTest(dir: string): void {
|
|
|
150
150
|
if (canonicalize(dir) !== PROTECTED_CODEX_HOME) return;
|
|
151
151
|
throw new Error(
|
|
152
152
|
`refusing to write the real Codex home (${PROTECTED_CODEX_HOME}) from a test process. `
|
|
153
|
-
+ "Point CODEX_HOME at a temp directory for this test before writing
|
|
153
|
+
+ "Point CODEX_HOME at a temp directory for this test before writing Codex files "
|
|
154
|
+
+ "(auth.json, the catalog, models_cache.json, the journal or config.toml).",
|
|
154
155
|
);
|
|
155
156
|
}
|
|
156
157
|
|
|
@@ -9,13 +9,13 @@ import type { OcxConfig } from "../types";
|
|
|
9
9
|
import type { AnthropicRouteDecision } from "./anthropic-model-routes";
|
|
10
10
|
import { recordAnthropicAccountRefusal, rotateAnthropicAccountOnRefusal, hasAnthropicFailoverQuorum, isAnthropicAccountPoolEnabled, pickAlternateAnthropicAccount } from "./anthropic-routing";
|
|
11
11
|
|
|
12
|
-
const responseCredentials = new WeakMap<Response, Pick<OAuthAccessSnapshot, "accountId" | "generation"
|
|
12
|
+
const responseCredentials = new WeakMap<Response, Pick<OAuthAccessSnapshot, "accountId" | "generation"> & { providerAccountUuid?: string; checkProviderUuid: boolean }>();
|
|
13
13
|
const retryStates = new WeakMap<object, { firstAccountId: string; sameAccount: boolean; detour: boolean }>();
|
|
14
14
|
const verdicts = new WeakMap<Response, Promise<boolean>>();
|
|
15
15
|
|
|
16
16
|
/** Called only when the outgoing headers prove ownership of the selected stored bearer. */
|
|
17
|
-
export function bindAnthropicRefusalCredential(response: Response, snapshot: OAuthAccessSnapshot): void {
|
|
18
|
-
responseCredentials.set(response, { accountId: snapshot.accountId, generation: snapshot.generation });
|
|
17
|
+
export function bindAnthropicRefusalCredential(response: Response, snapshot: OAuthAccessSnapshot, providerAccountUuid?: string): void {
|
|
18
|
+
responseCredentials.set(response, { accountId: snapshot.accountId, generation: snapshot.generation, providerAccountUuid, checkProviderUuid: arguments.length >= 3 });
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
async function isAccountRefusal(response: Response, signal?: AbortSignal): Promise<boolean> {
|
|
@@ -55,6 +55,10 @@ export async function rotateAnthropicAccountOnResponse(
|
|
|
55
55
|
requestKey?: object;
|
|
56
56
|
allow429Recovery?: boolean;
|
|
57
57
|
allowAccountRefusal?: boolean;
|
|
58
|
+
/** Applies to alternate ranking only; the shared same-account throttle remains allowed. */
|
|
59
|
+
excludedAccountIds?: ReadonlySet<string>;
|
|
60
|
+
/** Native dispatch re-reads operator policy after asynchronous classification/waits. */
|
|
61
|
+
currentDecision?: () => AnthropicRouteDecision | null;
|
|
58
62
|
},
|
|
59
63
|
): Promise<string | null> {
|
|
60
64
|
if (options.signal?.aborted || isNonReplayableResponse(response)) return null;
|
|
@@ -62,11 +66,13 @@ export async function rotateAnthropicAccountOnResponse(
|
|
|
62
66
|
const sent = responseCredentials.get(response);
|
|
63
67
|
const current = sent && getAccountCredentialWithStatus("anthropic", sent.accountId);
|
|
64
68
|
if (!sent || sent.accountId !== options.accountId || !current || current.needsReauth
|
|
65
|
-
|| credentialGeneration(current.credential) !== sent.generation
|
|
69
|
+
|| credentialGeneration(current.credential) !== sent.generation
|
|
70
|
+
|| sent.checkProviderUuid && current.credential.accountId !== sent.providerAccountUuid) return null;
|
|
71
|
+
options.currentDecision?.();
|
|
66
72
|
const kind = classifyAnthropic429(response.headers);
|
|
67
73
|
if (kind === "family-quota") {
|
|
68
74
|
if (anthropicModelFamily(options.model) !== "Fable" || !options.canRetry || options.allow429Recovery === false) return null;
|
|
69
|
-
return pickAlternateAnthropicAccount(options.config, sent.accountId, Date.now(), options.decision ?? null, options.model);
|
|
75
|
+
return pickAlternateAnthropicAccount(options.config, sent.accountId, Date.now(), (options.currentDecision ? options.currentDecision() : options.decision ?? null), options.model, options.excludedAccountIds);
|
|
70
76
|
}
|
|
71
77
|
if (kind !== "shared-quota") {
|
|
72
78
|
if (!isAnthropicAccountPoolEnabled(options.config) && !hasAnthropicFailoverQuorum() && !current.paused) return null;
|
|
@@ -87,16 +93,19 @@ export async function rotateAnthropicAccountOnResponse(
|
|
|
87
93
|
const live = getAccountCredentialWithStatus("anthropic", sent.accountId);
|
|
88
94
|
if (!live || live.paused || live.needsReauth || options.signal?.aborted
|
|
89
95
|
|| credentialGeneration(live.credential) !== sent.generation
|
|
96
|
+
|| sent.checkProviderUuid && live.credential.accountId !== sent.providerAccountUuid
|
|
90
97
|
|| anthropicRatePauseUntil(sent.accountId, Math.max(Date.now(), retryAt))) return null;
|
|
98
|
+
options.currentDecision?.();
|
|
91
99
|
return sent.accountId;
|
|
92
100
|
}
|
|
93
101
|
if (kind === "transient-rate" && !state.detour) {
|
|
94
102
|
state.detour = true;
|
|
95
|
-
return pickAlternateAnthropicAccount(options.config, sent.accountId, Date.now(), options.decision ?? null, options.model);
|
|
103
|
+
return pickAlternateAnthropicAccount(options.config, sent.accountId, Date.now(), (options.currentDecision ? options.currentDecision() : options.decision ?? null), options.model, options.excludedAccountIds);
|
|
96
104
|
}
|
|
97
105
|
return null;
|
|
98
106
|
}
|
|
99
107
|
if (options.allow429Recovery === false) {
|
|
108
|
+
options.currentDecision?.();
|
|
100
109
|
recordAnthropicAccountRefusal(options.config, options.accountId, 429, response.headers.get("retry-after"), Date.now(), response.headers);
|
|
101
110
|
return null;
|
|
102
111
|
}
|
|
@@ -112,8 +121,10 @@ export async function rotateAnthropicAccountOnResponse(
|
|
|
112
121
|
if (!await verdict || options.signal?.aborted) return null;
|
|
113
122
|
// A late refusal must not cool a new credential stored while its body was being read.
|
|
114
123
|
const current = getAccountCredentialWithStatus("anthropic", sent.accountId);
|
|
115
|
-
if (!current || current.paused || current.needsReauth || credentialGeneration(current.credential) !== sent.generation
|
|
124
|
+
if (!current || current.paused || current.needsReauth || credentialGeneration(current.credential) !== sent.generation
|
|
125
|
+
|| sent.checkProviderUuid && current.credential.accountId !== sent.providerAccountUuid) return null;
|
|
116
126
|
}
|
|
127
|
+
const decision = options.currentDecision ? options.currentDecision() : options.decision;
|
|
117
128
|
const status = response.status === 429 ? 429 : 403;
|
|
118
129
|
if (!options.canRetry) {
|
|
119
130
|
recordAnthropicAccountRefusal(options.config, options.accountId, status,
|
|
@@ -121,5 +132,5 @@ export async function rotateAnthropicAccountOnResponse(
|
|
|
121
132
|
return null;
|
|
122
133
|
}
|
|
123
134
|
return rotateAnthropicAccountOnRefusal(options.config, options.accountId, status,
|
|
124
|
-
response.headers.get("retry-after"), options.sessionKey, Date.now(), response.headers, options.
|
|
135
|
+
response.headers.get("retry-after"), options.sessionKey, Date.now(), response.headers, decision, options.model, options.excludedAccountIds);
|
|
125
136
|
}
|
|
@@ -111,6 +111,9 @@ type OAuthAccountSelection = NonNullable<ReturnType<typeof captureOAuthAccountSe
|
|
|
111
111
|
// Undefined means this runtime has not admitted a selection yet; null means consumed.
|
|
112
112
|
// The startup baseline comes from the authoritative store, never a second persisted pin.
|
|
113
113
|
let manualPreference: OAuthAccountSelection | null | undefined;
|
|
114
|
+
let manualSelectionGeneration = 0;
|
|
115
|
+
/** Automatic pointer moves preserve affinity; manual selection revokes older bindings. */
|
|
116
|
+
export function captureAnthropicManualSelectionGeneration(): number { return manualSelectionGeneration; }
|
|
114
117
|
|
|
115
118
|
function normalizeAffinityComponent(value: string | null | undefined): string {
|
|
116
119
|
const normalized = value?.trim() ?? "";
|
|
@@ -285,6 +288,7 @@ export function clearAnthropicAccountPoolState(): void {
|
|
|
285
288
|
clearAnthropicCooldownGenerations();
|
|
286
289
|
sessionAffinity.clear();
|
|
287
290
|
manualPreference = undefined;
|
|
291
|
+
manualSelectionGeneration++;
|
|
288
292
|
quorumCache = null;
|
|
289
293
|
}
|
|
290
294
|
|
|
@@ -508,9 +512,9 @@ function compareScoredAccounts(a: ScoredAccount, b: ScoredAccount): number {
|
|
|
508
512
|
return a.score - b.score || a.fiveHourTieBreak - b.fiveHourTieBreak;
|
|
509
513
|
}
|
|
510
514
|
|
|
511
|
-
function pickLowestUsage(config: OcxConfig, excludeId: string | undefined, now: number, decision: AnthropicRouteDecision | null = null, model?: string): string | null {
|
|
515
|
+
function pickLowestUsage(config: OcxConfig, excludeId: string | undefined, now: number, decision: AnthropicRouteDecision | null = null, model?: string, excludedAccountIds?: ReadonlySet<string>): string | null {
|
|
512
516
|
const window = anthropicQuotaWindow(anthropicAccountPoolConfig(config));
|
|
513
|
-
const unfiltered = routeCandidates(getEligibleAnthropicAccounts(now, model), decision).filter(id => id !== excludeId);
|
|
517
|
+
const unfiltered = routeCandidates(getEligibleAnthropicAccounts(now, model), decision).filter(id => id !== excludeId && !excludedAccountIds?.has(id));
|
|
514
518
|
const available = window === "weekly" ? unfiltered.filter(id => !exhausted5h(id)
|
|
515
519
|
|| isAnthropicAccountPoolEnabled(config) && anthropicAccountAutoSwitchThreshold(config, id) === 0) : unfiltered;
|
|
516
520
|
const modelAvailable = model ? available.filter(id => !anthropicModelExhausted(id, model)) : available;
|
|
@@ -589,16 +593,17 @@ export function pickAlternateAnthropicAccount(
|
|
|
589
593
|
now: number,
|
|
590
594
|
decision: AnthropicRouteDecision | null,
|
|
591
595
|
model?: string,
|
|
596
|
+
excludedAccountIds?: ReadonlySet<string>,
|
|
592
597
|
): string | null {
|
|
593
598
|
const strategy = anthropicPoolStrategy(config);
|
|
594
|
-
const eligible = routeCandidates(getEligibleAnthropicAccounts(now, model), decision).filter(id => id !== excludeId);
|
|
599
|
+
const eligible = routeCandidates(getEligibleAnthropicAccounts(now, model), decision).filter(id => id !== excludeId && !excludedAccountIds?.has(id));
|
|
595
600
|
if (strategy === "round-robin") {
|
|
596
601
|
return peekRoundRobinAccount(POOL_KEY_ANTHROPIC, eligible, stickyLimitForPool(config));
|
|
597
602
|
}
|
|
598
603
|
if (strategy === "fill-first") {
|
|
599
604
|
return pickNextFillFirstAnthropicAccount(config, excludeId, eligible, decision, model);
|
|
600
605
|
}
|
|
601
|
-
return pickLowestUsage(config, excludeId, now, decision, model);
|
|
606
|
+
return pickLowestUsage(config, excludeId, now, decision, model, excludedAccountIds);
|
|
602
607
|
}
|
|
603
608
|
|
|
604
609
|
function pruneExpiredAffinity(now: number): void {
|
|
@@ -918,6 +923,7 @@ export function rotateAnthropicAccountOnRefusal(
|
|
|
918
923
|
rateLimitHeaders?: AnthropicRateLimitHeaders | null,
|
|
919
924
|
decision: AnthropicRouteDecision | null = null,
|
|
920
925
|
model?: string,
|
|
926
|
+
excludedAccountIds?: ReadonlySet<string>,
|
|
921
927
|
): string | null {
|
|
922
928
|
if (!recordAnthropicAccountRefusal(config, failedAccountId, status, retryAfterHeader, now, rateLimitHeaders)) return null;
|
|
923
929
|
|
|
@@ -926,8 +932,8 @@ export function rotateAnthropicAccountOnRefusal(
|
|
|
926
932
|
// because those dormant values remain in config. The quota picker is the neutral
|
|
927
933
|
// recovery policy already used by the default strategy.
|
|
928
934
|
const next = isAnthropicAccountPoolEnabled(config)
|
|
929
|
-
? pickAlternateAnthropicAccount(config, failedAccountId, now, decision, model)
|
|
930
|
-
: pickLowestUsage(config, failedAccountId, now, null, model);
|
|
935
|
+
? pickAlternateAnthropicAccount(config, failedAccountId, now, decision, model, excludedAccountIds)
|
|
936
|
+
: pickLowestUsage(config, failedAccountId, now, null, model, excludedAccountIds);
|
|
931
937
|
if (!next) {
|
|
932
938
|
console.warn(`[anthropic-pool] ${decision ? `route:#${decision.position} ` : ""}no eligible replacement; returning ${status}`);
|
|
933
939
|
return null;
|
|
@@ -1070,6 +1076,7 @@ export function commitAnthropicSelectionRouting(
|
|
|
1070
1076
|
*/
|
|
1071
1077
|
export function resetAnthropicRoutingForManualSelection(accountId: string): void {
|
|
1072
1078
|
sessionAffinity.clear();
|
|
1079
|
+
manualSelectionGeneration++;
|
|
1073
1080
|
manualPreference = captureOAuthAccountSelection(PROVIDER);
|
|
1074
1081
|
seedPoolRotationAccount(POOL_KEY_ANTHROPIC, accountId);
|
|
1075
1082
|
// A manual account selection is an operator statement about the roster; do not answer the
|
|
@@ -1172,12 +1179,10 @@ export function anthropicSessionKeyFromParts(input: {
|
|
|
1172
1179
|
/** When true, prompt_cache_key is a shared Desktop cohort — ignore it for affinity. */
|
|
1173
1180
|
promptCacheKeyIsSharedCohort?: boolean;
|
|
1174
1181
|
}): string | null {
|
|
1175
|
-
const preferred = (
|
|
1176
|
-
input.
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
?? ""
|
|
1180
|
-
).trim();
|
|
1182
|
+
const preferred = input.clientThreadId?.trim()
|
|
1183
|
+
|| input.sessionIdHeader?.trim()
|
|
1184
|
+
|| input.threadIdHeader?.trim()
|
|
1185
|
+
|| "";
|
|
1181
1186
|
if (preferred) {
|
|
1182
1187
|
return preferred.length <= 128 ? preferred : createHash("sha256").update(preferred).digest("hex");
|
|
1183
1188
|
}
|
package/src/oauth/anthropic.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { OAuthCallbackFlow } from "./callback-server";
|
|
|
3
3
|
import { bindAnthropicIdentity, resolveAnthropicAccountIdentity } from "./anthropic-identity";
|
|
4
4
|
import { generatePKCE } from "./pkce";
|
|
5
5
|
import type { LocalTokenImportMode, OAuthController, OAuthCredentials } from "./types";
|
|
6
|
+
import { outboundProxyConfigured, startupOutboundProxyConfigured } from "../lib/proxy-env";
|
|
6
7
|
|
|
7
8
|
const CLIENT_ID = atob("OWQxYzI1MGEtZTYxYi00NGQ5LTg4ZWQtNTk0NGQxOTYyZjVl");
|
|
8
9
|
const AUTHORIZE_URL = "https://claude.ai/oauth/authorize";
|
|
@@ -40,6 +41,7 @@ export class AnthropicTokenError extends Error {
|
|
|
40
41
|
message: string,
|
|
41
42
|
readonly httpStatus: number | undefined,
|
|
42
43
|
readonly oauthError: string | undefined,
|
|
44
|
+
readonly requestNotSent = false,
|
|
43
45
|
) {
|
|
44
46
|
super(message);
|
|
45
47
|
this.name = "AnthropicTokenError";
|
|
@@ -47,12 +49,35 @@ export class AnthropicTokenError extends Error {
|
|
|
47
49
|
}
|
|
48
50
|
|
|
49
51
|
async function postJson(url: string, body: Record<string, string | number>): Promise<string> {
|
|
50
|
-
const
|
|
52
|
+
const direct = !startupOutboundProxyConfigured && !outboundProxyConfigured();
|
|
53
|
+
const init: RequestInit & { protocol: "http1.1" } = {
|
|
51
54
|
method: "POST",
|
|
52
55
|
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
|
53
56
|
body: JSON.stringify(body),
|
|
54
57
|
signal: AbortSignal.timeout(30_000),
|
|
55
|
-
|
|
58
|
+
// Pinned Bun 1.4.0: one HTTP/1.1 attempt, no keep-alive reuse or redirect following.
|
|
59
|
+
// Only without an outbound proxy can token-host DNS failure prove no request was sent.
|
|
60
|
+
redirect: "manual",
|
|
61
|
+
keepalive: false,
|
|
62
|
+
protocol: "http1.1",
|
|
63
|
+
};
|
|
64
|
+
let response: Response;
|
|
65
|
+
try {
|
|
66
|
+
response = await fetch(url, init);
|
|
67
|
+
} catch (error) {
|
|
68
|
+
if (direct && typeof error === "object" && error !== null
|
|
69
|
+
&& "code" in error && error.code === "ENOTFOUND"
|
|
70
|
+
&& "syscall" in error && error.syscall === "getaddrinfo"
|
|
71
|
+
&& "hostname" in error && error.hostname === new URL(url).hostname) {
|
|
72
|
+
throw new AnthropicTokenError("Anthropic OAuth DNS lookup failed before connection", undefined, undefined, true);
|
|
73
|
+
}
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
76
|
+
if (response.status >= 300 && response.status < 400) {
|
|
77
|
+
// A redirect can follow a completed POST and token rotation; it is not a rejection.
|
|
78
|
+
await response.body?.cancel().catch(() => {});
|
|
79
|
+
throw new AnthropicTokenError(`Anthropic OAuth redirect HTTP ${response.status}: outcome unknown`, undefined, undefined);
|
|
80
|
+
}
|
|
56
81
|
const responseBody = await response.text();
|
|
57
82
|
if (!response.ok) {
|
|
58
83
|
let oauthError: string | undefined;
|