@bitkyc08/opencodex 2.86.0 → 2.87.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/gui/dist/assets/App-ZfYbLHkB.js +58 -0
- package/gui/dist/assets/App-wT3mXqFz.css +1 -0
- package/gui/dist/assets/{Tray-DyR9yBee.js → Tray-DCd-A1az.js} +1 -1
- package/gui/dist/assets/index-CfYDMj34.js +94 -0
- package/gui/dist/assets/index-DPeB9HpV.css +1 -0
- package/gui/dist/assets/quota-summary-ChvmKGOk.js +1 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/anthropic/beta-allowlist.ts +9 -8
- package/src/adapters/anthropic/beta-body-pairs.ts +93 -0
- package/src/adapters/anthropic/passthrough.ts +7 -23
- package/src/adapters/anthropic-progress.ts +84 -0
- package/src/adapters/anthropic.ts +108 -21
- package/src/adapters/base.ts +2 -0
- package/src/adapters/claude-cli/adapter.ts +111 -23
- package/src/adapters/claude-cli/profiles.ts +2 -1
- package/src/adapters/claude-cli/stable-replay.ts +108 -0
- package/src/adapters/claude-cli/usage-admission.ts +230 -0
- package/src/adapters/coding-agent/protocol.ts +1 -1
- package/src/adapters/coding-agent/turn.ts +18 -2
- package/src/adapters/dsh-account.ts +97 -0
- package/src/adapters/google.ts +35 -8
- package/src/adapters/openai-chat/messages.ts +4 -3
- package/src/adapters/openai-chat/passthrough.ts +8 -2
- package/src/adapters/openai-chat/reasoning-replay.ts +52 -0
- package/src/adapters/openai-responses/compaction-search-history.ts +14 -7
- package/src/adapters/openai-responses/passthrough.ts +57 -125
- package/src/adapters/openai-responses/request-strips.ts +25 -3
- package/src/adapters/openai-responses/transport.ts +81 -0
- package/src/adapters/qoder/adapter.ts +4 -2
- package/src/adapters/registry.ts +5 -0
- package/src/adapters/zed.ts +26 -1
- package/src/advisor/activity.ts +55 -0
- package/src/advisor/budget.ts +62 -0
- package/src/advisor/consult.ts +4 -1
- package/src/advisor/context.ts +16 -4
- package/src/advisor/evidence-tool.ts +21 -0
- package/src/advisor/evidence.ts +117 -0
- package/src/advisor/runtime.ts +79 -31
- package/src/advisor/settings.ts +11 -2
- package/src/bridge/errors.ts +19 -1
- package/src/bridge/internal.ts +3 -1
- package/src/bridge/response-json.ts +18 -3
- package/src/bridge/sse.ts +58 -8
- package/src/bridge/terminal-refusal.ts +59 -0
- package/src/claude/desktop-3p-library.ts +32 -7
- package/src/claude/desktop-3p.ts +55 -13
- package/src/claude/desktop-first-party.ts +1 -1
- package/src/claude/desktop-picker-platform.ts +4 -0
- package/src/claude/desktop-picker.ts +15 -8
- package/src/claude/desktop-system-proxy.ts +50 -4
- package/src/claude/inbound-content-options.ts +11 -0
- package/src/claude/inbound.ts +33 -0
- package/src/claude/intercept/picker-ca-startup.ts +2 -1
- package/src/claude/intercept/picker-runtime.ts +6 -5
- package/src/claude/intercept/picker-trust-windows.ts +338 -0
- package/src/claude/intercept/picker-trust.ts +19 -0
- package/src/claude/intercept/port-policy.ts +30 -0
- package/src/claude/intercept/runtime.ts +97 -94
- package/src/claude/outbound.ts +38 -13
- package/src/cli/account-api.ts +1 -0
- package/src/cli/account-policy-dto.ts +6 -0
- package/src/cli/account-policy.ts +34 -5
- package/src/cli/account-preservation.ts +44 -0
- package/src/cli/account.ts +8 -2
- package/src/cli/advisor.ts +17 -3
- package/src/cli/capabilities-accounts.ts +20 -1
- package/src/cli/capabilities-agents-routing.ts +5 -2
- package/src/cli/capabilities-base.ts +38 -10
- package/src/cli/capabilities-observe-system.ts +63 -3
- package/src/cli/capabilities-provider-models.ts +11 -0
- package/src/cli/codex-cli-update.ts +151 -41
- package/src/cli/codex-provider.ts +44 -0
- package/src/cli/combo-input.ts +10 -1
- package/src/cli/combo-result.ts +1 -1
- package/src/cli/combo.ts +2 -1
- package/src/cli/cross-home-owner.ts +17 -14
- package/src/cli/dispatch.ts +25 -4
- package/src/cli/doctor.ts +6 -1
- package/src/cli/ensure-desktop-cold-start.ts +60 -7
- package/src/cli/help.ts +3 -2
- package/src/cli/index.ts +1 -1
- package/src/cli/integration-plan-dto.ts +1 -0
- package/src/cli/log-history.ts +172 -0
- package/src/cli/models-runtime-subcommands.ts +1 -0
- package/src/cli/models-runtime.ts +72 -8
- package/src/cli/observe-stream.ts +2 -0
- package/src/cli/observe.ts +16 -4
- package/src/cli/provider.ts +27 -5
- package/src/cli/registry.ts +28 -8
- package/src/cli/runtime-api.ts +5 -3
- package/src/cli/system-command.ts +40 -8
- package/src/cli/uninstall-client-state.ts +32 -0
- package/src/cli/usage-compare.ts +309 -0
- package/src/cli/usage-model-search.ts +4 -4
- package/src/cli/usage-report.ts +2 -2
- package/src/cli/v2.ts +1 -1
- package/src/client/machine-listener.ts +1 -3
- package/src/client/runtime.ts +1 -0
- package/src/clients/client-path.ts +51 -0
- package/src/clients/config-export/contracts.ts +2 -1
- package/src/clients/config-export.ts +18 -44
- package/src/clients/gentle-shell-path.ts +87 -0
- package/src/codex/account-auto-switch.ts +2 -0
- package/src/codex/account-identity.ts +29 -0
- package/src/codex/account-label.ts +1 -1
- package/src/codex/account-lifecycle.ts +21 -5
- package/src/codex/account-preservation.ts +28 -0
- package/src/codex/account-use-remaining.ts +23 -0
- package/src/codex/app-server-processes.ts +97 -7
- package/src/codex/app-server-restart-service.ts +55 -4
- package/src/codex/auth-api/account-list.ts +4 -0
- package/src/codex/auth-api/account-pause-group.ts +6 -50
- package/src/codex/auth-api/account-preservation.ts +57 -0
- package/src/codex/auth-api/routes.ts +8 -0
- package/src/codex/auth-api/use-remaining-route.ts +36 -0
- package/src/codex/auth-api.ts +0 -1
- package/src/codex/auth-context.ts +2 -2
- package/src/codex/catalog/auto-review.ts +20 -3
- package/src/codex/catalog/build-entries.ts +9 -1
- package/src/codex/catalog/combo-member.ts +14 -5
- package/src/codex/catalog/configured-tiers.ts +1 -1
- package/src/codex/catalog/gather-capture.ts +2 -0
- package/src/codex/catalog/metadata.ts +11 -9
- package/src/codex/catalog/model-hints.ts +21 -16
- package/src/codex/catalog/model-visibility.ts +3 -1
- package/src/codex/catalog/parsing.ts +30 -86
- package/src/codex/catalog/provider-models.ts +36 -34
- package/src/codex/catalog/routed-gather.ts +12 -13
- package/src/codex/cli-install-provenance.ts +105 -17
- package/src/codex/cli-update-plan.ts +545 -0
- package/src/codex/config-write-lock.ts +23 -2
- package/src/codex/desktop-app/picker-freshness.ts +96 -0
- package/src/codex/desktop-app-restart.ts +21 -0
- package/src/codex/desktop-compatibility/installed-build.ts +24 -6
- package/src/codex/desktop-compatibility/routing-preflight.ts +1 -1
- package/src/codex/desktop-compatibility/runtime.ts +12 -3
- package/src/codex/desktop-compatibility/windows-key-protection.ts +115 -9
- package/src/codex/features.ts +85 -45
- package/src/codex/inject/history-failure.ts +27 -0
- package/src/codex/inject/restore.ts +24 -2
- package/src/codex/inject/routing-classify.ts +4 -3
- package/src/codex/inject.ts +18 -27
- package/src/codex/main-account-hard-lock.ts +30 -1
- package/src/codex/native-profile-startup.ts +1 -1
- package/src/codex/ocx-compaction-history.ts +12 -10
- package/src/codex/paths.ts +86 -1
- package/src/codex/provider-routing.ts +174 -0
- package/src/codex/refresh.ts +2 -2
- package/src/codex/routing/health-store.ts +1 -1
- package/src/codex/routing/selection.ts +4 -3
- package/src/codex/routing/thread-affinity.ts +1 -1
- package/src/codex/signed-in-identity.ts +43 -0
- package/src/codex/subagent-model-fallback.ts +1 -1
- package/src/codex/sync.ts +6 -1
- package/src/codex/user-identity.ts +16 -5
- package/src/codex/windows-local-appdata.ts +84 -0
- package/src/combos/failover.ts +6 -1
- package/src/combos/index.ts +1 -0
- package/src/combos/resolve.ts +46 -10
- package/src/combos/types.ts +15 -0
- package/src/config/diagnostics.ts +33 -6
- package/src/config/live-reconcile.ts +38 -2
- package/src/config/load-degrade.ts +17 -0
- package/src/config/opencode-wire-upgrade.ts +54 -0
- package/src/config/opencode-zen-upgrade.ts +52 -0
- package/src/config/owner-registry.ts +22 -17
- package/src/config/provider-validation.ts +45 -2
- package/src/config/schema/config-schema.ts +22 -0
- package/src/config/schema/leaf-validators.ts +38 -9
- package/src/config/schema/main-account-hard-lock-fallback.ts +26 -0
- package/src/config/schema/native-reasoning-retention.ts +26 -0
- package/src/config.ts +2 -0
- package/src/generated/compatibility-version.json +608 -284
- package/src/grok/sync.ts +1 -1
- package/src/images/loop.ts +46 -8
- package/src/integrations/catalog-refresh.ts +1 -1
- package/src/integrations/mutation-plan.ts +1 -0
- package/src/integrations/registry.ts +8 -0
- package/src/lib/codex-restart-contract.ts +15 -0
- package/src/lib/desktop-upstream-tunnel.ts +18 -53
- package/src/lib/empty-tool-catalog.ts +62 -0
- package/src/lib/errors.ts +19 -3
- package/src/lib/gcp-adc.ts +96 -9
- package/src/lib/gcp-credential-input.ts +12 -0
- package/src/lib/lab-live-route-production.ts +5 -0
- package/src/lib/opaque-proxy-tunnel.ts +217 -0
- package/src/lib/redact.ts +2 -2
- package/src/oauth/account-quota-rank.ts +69 -2
- package/src/oauth/anthropic-account-refusal.ts +20 -2
- package/src/oauth/anthropic-oauth-definitions.ts +3 -1
- package/src/oauth/anthropic-routing.ts +1 -21
- package/src/oauth/anthropic.ts +38 -7
- package/src/oauth/callback-server.ts +27 -1
- package/src/oauth/chatgpt-device.ts +1 -1
- package/src/oauth/chatgpt-shared.ts +83 -0
- package/src/oauth/chatgpt.ts +8 -72
- package/src/oauth/dsh.ts +203 -0
- package/src/oauth/generic-account-failover.ts +33 -7
- package/src/oauth/generic-session-affinity.ts +125 -0
- package/src/oauth/index.ts +184 -109
- package/src/oauth/local-token-detect.ts +21 -1
- package/src/oauth/login-cli-errors.ts +44 -0
- package/src/oauth/login-cli.ts +69 -7
- package/src/oauth/login-flow-state.ts +371 -23
- package/src/oauth/meta-muse-errors.ts +41 -0
- package/src/oauth/meta-muse-keychain.ts +52 -0
- package/src/oauth/meta-muse.ts +51 -84
- package/src/oauth/model-discovery-auth.ts +3 -0
- package/src/oauth/pool-settings-capability.ts +4 -2
- package/src/oauth/refresh-flight-retirement.ts +48 -0
- package/src/oauth/session-affinity-key.ts +23 -0
- package/src/oauth/store-anthropic-instance.ts +9 -2
- package/src/oauth/store.ts +12 -5
- package/src/protocols/encoders/adapter-events.ts +5 -4
- package/src/protocols/encoders/messages.ts +9 -6
- package/src/protocols/provider-summary.ts +2 -1
- package/src/providers/api-key-cooldowns.ts +49 -0
- package/src/providers/api-key-resolve.ts +11 -6
- package/src/providers/api-key-response-ownership.ts +57 -0
- package/src/providers/api-key-selection.ts +31 -6
- package/src/providers/api-keys.ts +214 -3
- package/src/providers/claude-cli-usage.ts +96 -0
- package/src/providers/dsh-account-destination.ts +31 -0
- package/src/providers/dsh-account-models.ts +41 -0
- package/src/providers/gcp-credential-cleanup.ts +59 -0
- package/src/providers/key-failover.ts +54 -50
- package/src/providers/key-store.ts +7 -2
- package/src/providers/model-rename-fields.ts +3 -0
- package/src/providers/model-rename-migration.ts +2 -2
- package/src/providers/openai-tiers-destination.ts +11 -2
- package/src/providers/openai-tiers.ts +5 -12
- package/src/providers/opencode-go-transport.ts +3 -1
- package/src/providers/quota/account-cache.ts +6 -5
- package/src/providers/quota/account-row.ts +34 -0
- package/src/providers/quota/antigravity.ts +28 -7
- package/src/providers/quota/dsh-account.ts +70 -0
- package/src/providers/quota/vendor-probes-oauth.ts +1 -93
- package/src/providers/quota-types.ts +2 -0
- package/src/providers/quota.ts +14 -20
- package/src/providers/registry/entries-core.ts +37 -8
- package/src/providers/registry/types.ts +1 -1
- package/src/providers/request-pacing.ts +77 -19
- package/src/providers/routed-config.ts +339 -0
- package/src/providers/service-tier.ts +2 -1
- package/src/quota/window-mapping.ts +5 -2
- package/src/responses/anthropic-progress.ts +34 -0
- package/src/responses/assistant-replay.ts +35 -0
- package/src/responses/compaction-history-framing.ts +65 -0
- package/src/responses/compaction.ts +6 -1
- package/src/responses/incomplete-reason.ts +31 -0
- package/src/responses/parser.ts +50 -5
- package/src/responses/reasoning-envelope.ts +4 -0
- package/src/responses/reasoning-replay-cache.ts +24 -2
- package/src/responses/reasoning-retention.ts +173 -0
- package/src/responses/retained-compaction.ts +46 -0
- package/src/responses/schema.ts +23 -0
- package/src/router.ts +8 -321
- package/src/routing/health.ts +37 -22
- package/src/routing/history/indexer.ts +6 -5
- package/src/routing/history/ingest.ts +6 -2
- package/src/routing/profile.ts +1 -1
- package/src/server/admission-model-scope.ts +17 -0
- package/src/server/auth-cors.ts +11 -0
- package/src/server/chat-completions.ts +7 -1
- package/src/server/chat-native-reasoning.ts +123 -0
- package/src/server/chat-native-sse.ts +110 -7
- package/src/server/chat-native.ts +76 -17
- package/src/server/claude-messages.ts +55 -414
- package/src/server/effort-policy.ts +1 -1
- package/src/server/images.ts +15 -2
- package/src/server/index/model-alias-id.ts +36 -0
- package/src/server/index/optional-listeners.ts +2 -0
- package/src/server/index/serve-options.ts +9 -1
- package/src/server/index/startup-warnings.ts +2 -0
- package/src/server/index.ts +1 -1
- package/src/server/inference/advisor-diagnostics.ts +31 -0
- package/src/server/management/advisor-routes.ts +15 -4
- package/src/server/management/claude-desktop-picker-routes.ts +1 -1
- package/src/server/management/combo-routes.ts +176 -50
- package/src/server/management/companion-routes.ts +1 -1
- package/src/server/management/config-routes.ts +75 -4
- package/src/server/management/cursor-integration-routes.ts +1 -1
- package/src/server/management/model-routes.ts +11 -6
- package/src/server/management/model-rows.ts +14 -0
- package/src/server/management/native-integration-routes.ts +2 -7
- package/src/server/management/native-reasoning-retention-routes.ts +41 -0
- package/src/server/management/oauth-account-routes.ts +87 -14
- package/src/server/management/provider-gcp-credential-routes.ts +29 -0
- package/src/server/management/provider-key-pacing-routes.ts +31 -0
- package/src/server/management/provider-overwrite-carry.ts +7 -0
- package/src/server/management/provider-routes.ts +25 -30
- package/src/server/management/route-registry.ts +10 -1
- package/src/server/management-api.ts +3 -1
- package/src/server/messages-native-tool-guard.ts +185 -0
- package/src/server/messages-native.ts +101 -21
- package/src/server/messages-shared.ts +431 -0
- package/src/server/relay.ts +1 -1
- package/src/server/request-log-terminal-status.ts +1 -1
- package/src/server/request-log.ts +6 -1
- package/src/server/responses/account-change-state.ts +8 -2
- package/src/server/responses/adapter-continuation.ts +13 -1
- package/src/server/responses/adapter-delivery.ts +19 -6
- package/src/server/responses/adapter-dispatch.ts +19 -2
- package/src/server/responses/advisor-retention.ts +117 -0
- package/src/server/responses/advisor-slot.ts +73 -41
- package/src/server/responses/codex-ws-wire.ts +15 -8
- package/src/server/responses/compact.ts +303 -125
- package/src/server/responses/compaction-recovery.ts +18 -10
- package/src/server/responses/compaction-retention.ts +61 -0
- package/src/server/responses/context-overflow.ts +11 -6
- package/src/server/responses/core-auth.ts +6 -0
- package/src/server/responses/core-codex-account.ts +11 -1
- package/src/server/responses/core-combo-failure.ts +2 -0
- package/src/server/responses/core-combo.ts +120 -36
- package/src/server/responses/core-errors.ts +3 -3
- package/src/server/responses/core-lifetime.ts +5 -2
- package/src/server/responses/core-opaque-recovery.ts +35 -0
- package/src/server/responses/core-options.ts +5 -0
- package/src/server/responses/core-pipeline.ts +141 -0
- package/src/server/responses/core-replay.ts +18 -2
- package/src/server/responses/core.ts +2 -115
- package/src/server/responses/fetch-helpers.ts +73 -19
- package/src/server/responses/main-hard-lock-routing.ts +109 -0
- package/src/server/responses/native-response-control.ts +4 -2
- package/src/server/responses/passthrough-delivery.ts +3 -1
- package/src/server/responses/passthrough-dispatch.ts +15 -13
- package/src/server/responses/request-prepare.ts +76 -12
- package/src/server/responses/request-sidecar-auth.ts +2 -2
- package/src/server/responses/request-transport.ts +104 -27
- package/src/server/responses/response-effects.ts +1 -0
- package/src/server/responses/run-turn-execution.ts +96 -30
- package/src/server/responses/sidecar-execution.ts +20 -2
- package/src/server/responses/terminal-guard.ts +2 -16
- package/src/service/auth-preflight.ts +71 -0
- package/src/service/cli.ts +4 -2
- package/src/service/definition.ts +97 -0
- package/src/service/guards.ts +1 -61
- package/src/service/health-sentinel-linux.ts +112 -0
- package/src/service/health-sentinel-main.ts +76 -57
- package/src/service/health.ts +5 -89
- package/src/service/launchd.ts +27 -7
- package/src/service/logging.ts +72 -9
- package/src/service/repair.ts +2 -1
- package/src/service/state.ts +7 -8
- package/src/service/systemd.ts +1 -1
- package/src/service/windows-guarded-manager.ts +2 -1
- package/src/service/windows-ops.ts +1 -1
- package/src/service/windows-scheduler.ts +2 -7
- package/src/service/windows-taskxml.ts +2 -2
- package/src/service/windows-wscript.ts +14 -0
- package/src/service.ts +4 -2
- package/src/sidecar/auth.ts +2 -0
- package/src/types/config.ts +64 -3
- package/src/types/opencode-go.ts +61 -0
- package/src/types/opencode-zen.ts +29 -0
- package/src/types/provider.ts +32 -6
- package/src/types/request.ts +23 -2
- package/src/types/wire.ts +29 -37
- package/src/types.ts +1 -0
- package/src/update/npm-invocation.mjs +12 -2
- package/src/usage/cost.ts +48 -12
- package/src/usage/expected-prices.ts +3 -0
- package/src/usage/ledger-retention.ts +2 -1
- package/src/usage/ledger-scanner.ts +6 -5
- package/src/usage/log.ts +17 -8
- package/src/usage/summary.ts +22 -9
- package/src/usage/user-cost-overlays.ts +14 -19
- package/src/usage/user-cost-validation.ts +38 -0
- package/src/vision/eligibility.ts +13 -0
- package/src/web-search/loop.ts +55 -5
- package/src/web-search/progress-stream.ts +4 -1
- package/src/web-search/run-turn-loop.ts +9 -1
- package/gui/dist/assets/App-BjlttVLy.js +0 -58
- package/gui/dist/assets/App-CSJvmjNs.css +0 -1
- package/gui/dist/assets/index-BB6uK6tU.css +0 -1
- package/gui/dist/assets/index-BMssP4k3.js +0 -94
- package/gui/dist/assets/quota-summary-D8M1bc_R.js +0 -1
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Structured JSON is never literal key material; malformed GCP text is also refused. */
|
|
2
|
+
export function isGcpCredentialMaterial(value: string): boolean {
|
|
3
|
+
const credentialField = /\b(?:service_account|authorized_user|external_account|private_key|refresh_token|client_secret)\b|-----BEGIN [A-Z ]*PRIVATE KEY-----/;
|
|
4
|
+
if (/[\[\{"']/.test(value) && credentialField.test(value)) return true;
|
|
5
|
+
// Reject structured carriers outright rather than recursively guessing their credential shape.
|
|
6
|
+
// This includes quoted/array/object wrappers with arbitrarily escaped or nested JSON inside.
|
|
7
|
+
try {
|
|
8
|
+
const parsed: unknown = JSON.parse(value);
|
|
9
|
+
return typeof parsed === "string" || (parsed !== null && typeof parsed === "object");
|
|
10
|
+
}
|
|
11
|
+
catch { return false; }
|
|
12
|
+
}
|
|
@@ -72,6 +72,11 @@ export async function buildLabProviderAuthHeaders(
|
|
|
72
72
|
&& configuredAnthropicInstance(config, routeContext.providerId) !== routeContext.providerId) {
|
|
73
73
|
throw new TransportError("auth_blocked", "anthropic instance unavailable");
|
|
74
74
|
}
|
|
75
|
+
// The DSH account token is sent only by its own adapter to its pinned endpoint; the generic
|
|
76
|
+
// probe transport would use another endpoint and auth scheme.
|
|
77
|
+
if (routeContext.providerId === "dsh-account") {
|
|
78
|
+
throw new TransportError("auth_blocked", "dsh-account probes are not supported");
|
|
79
|
+
}
|
|
75
80
|
try {
|
|
76
81
|
const snapshot = await getValidAccessTokenSnapshot(routeContext.providerId);
|
|
77
82
|
headers.Authorization = `Bearer ${snapshot.accessToken}`;
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single-route opaque proxy tunnel. The desktop upstream dialer retains its
|
|
3
|
+
* own fixed-target TLS contract.
|
|
4
|
+
* Shares OpenCodex's MIT SOCKS handshake; the caller owns all post-establishment
|
|
5
|
+
* cancellation and socket cleanup. Never replay or implicitly retry directly.
|
|
6
|
+
*/
|
|
7
|
+
import net, { type Socket } from 'node:net';
|
|
8
|
+
import tls from 'node:tls';
|
|
9
|
+
import { socks5Handshake, socks5Credentials, Socks5HandshakeError } from './socks5-handshake';
|
|
10
|
+
|
|
11
|
+
interface RouteEndpoint { host: string; port: number }
|
|
12
|
+
type RouteCredentials = { username?: undefined; password?: undefined } | { username: string; password: string };
|
|
13
|
+
type PlainProxy = { kind?: 'http'; proxyCa?: never };
|
|
14
|
+
type SecureProxy = { kind: 'https'; proxyCa?: string };
|
|
15
|
+
type SocksProxy = { kind: 'socks5' | 'socks5h'; proxyCa?: never };
|
|
16
|
+
export type OpaqueProxyRoute = RouteEndpoint & RouteCredentials & (PlainProxy | SecureProxy | SocksProxy);
|
|
17
|
+
export interface OpaqueTunnelOptions {
|
|
18
|
+
signal?: AbortSignal;
|
|
19
|
+
timeoutMs?: number;
|
|
20
|
+
maxHeaderBytes?: number;
|
|
21
|
+
}
|
|
22
|
+
interface AttemptOptions {
|
|
23
|
+
signal?: AbortSignal;
|
|
24
|
+
timeoutMs: number;
|
|
25
|
+
maxHeaderBytes: number;
|
|
26
|
+
}
|
|
27
|
+
export class TunnelError extends Error {
|
|
28
|
+
override readonly name = 'TunnelError';
|
|
29
|
+
readonly code: string;
|
|
30
|
+
readonly status?: number;
|
|
31
|
+
constructor(code: string, message: string, { status }: { status?: number } = {}) {
|
|
32
|
+
super(message); this.code = code; this.status = status;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const aborted = () => new TunnelError('ABORTED', 'Tunnel establishment cancelled');
|
|
36
|
+
|
|
37
|
+
function validateAuthority(authority: string): string {
|
|
38
|
+
if (typeof authority !== 'string' || !/^(?:\[[0-9a-fA-F:]+\]|[a-zA-Z0-9._-]+):[0-9]+$/.test(authority)) {
|
|
39
|
+
throw new TunnelError('TARGET', 'An explicit host:port authority is required');
|
|
40
|
+
}
|
|
41
|
+
const port = Number(authority.slice(authority.lastIndexOf(':') + 1));
|
|
42
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new TunnelError('TARGET', 'Invalid target port');
|
|
43
|
+
return authority;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function validateOptions(p: OpaqueProxyRoute, timeoutMs: number, maxHeaderBytes: number): void {
|
|
47
|
+
if (!p || Array.isArray(p) || typeof p.host !== 'string' || !p.host || /[\s/@]/.test(p.host)
|
|
48
|
+
|| !Number.isInteger(p.port) || p.port < 1 || p.port > 65535
|
|
49
|
+
|| !['http', 'https', 'socks5', 'socks5h'].includes(p.kind ?? 'http')) throw new TunnelError('CONFIG', 'Invalid proxy route');
|
|
50
|
+
if ((p.username === undefined) !== (p.password === undefined)
|
|
51
|
+
|| (p.username !== undefined && (typeof p.username !== 'string' || typeof p.password !== 'string'
|
|
52
|
+
|| (!p.kind?.startsWith('socks') && p.username.includes(':')) || Buffer.byteLength(p.username + p.password) > 4096))) throw new TunnelError('CONFIG', 'Invalid proxy credentials');
|
|
53
|
+
if ((p.kind ?? 'http').startsWith('socks') && p.username !== undefined
|
|
54
|
+
&& (Buffer.byteLength(p.username) > 255 || Buffer.byteLength(p.password) > 255)) throw new TunnelError('CONFIG', 'SOCKS credentials must each fit in 255 UTF-8 bytes');
|
|
55
|
+
if (p.proxyCa !== undefined && p.kind !== 'https') throw new TunnelError('CONFIG', 'Proxy TLS settings require HTTPS proxy');
|
|
56
|
+
if (p.proxyCa !== undefined && typeof p.proxyCa !== 'string') throw new TunnelError('CONFIG', 'Invalid proxy CA');
|
|
57
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 30000
|
|
58
|
+
|| !Number.isSafeInteger(maxHeaderBytes) || maxHeaderBytes < 64 || maxHeaderBytes > 65536) {
|
|
59
|
+
throw new TunnelError('CONFIG', 'Invalid deadline or header limit');
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function connectOne(authority: string, proxy: OpaqueProxyRoute, { signal, timeoutMs, maxHeaderBytes }: AttemptOptions): Promise<Socket> {
|
|
64
|
+
if (proxy.kind === 'socks5' || proxy.kind === 'socks5h') return connectSocks(authority, proxy, { signal, timeoutMs, maxHeaderBytes });
|
|
65
|
+
return new Promise<Socket>((resolve, reject) => {
|
|
66
|
+
if (signal?.aborted) return reject(aborted());
|
|
67
|
+
const secure = proxy.kind === 'https';
|
|
68
|
+
const identity = proxy.host;
|
|
69
|
+
const tlsOptions: tls.ConnectionOptions & { allowHalfOpen: boolean } = {
|
|
70
|
+
host: proxy.host, port: proxy.port, allowHalfOpen: true,
|
|
71
|
+
servername: net.isIP(identity) ? undefined : identity, ca: proxy.proxyCa,
|
|
72
|
+
checkServerIdentity: (_name, cert) => tls.checkServerIdentity(identity, cert),
|
|
73
|
+
rejectUnauthorized: true, ALPNProtocols: ['http/1.1'],
|
|
74
|
+
};
|
|
75
|
+
const socket = secure
|
|
76
|
+
? tls.connect(tlsOptions)
|
|
77
|
+
: net.createConnection({ host: proxy.host, port: proxy.port, allowHalfOpen: true });
|
|
78
|
+
// Keep a defensive observer across the promise-to-owner handoff. Errors still
|
|
79
|
+
// settle the establishment promise or are handled by the relay after handoff.
|
|
80
|
+
socket.on('error', () => {});
|
|
81
|
+
let pending: Buffer = Buffer.alloc(0); let settled = false, tlsReady = !secure;
|
|
82
|
+
const connectedEvent = secure ? 'secureConnect' : 'connect';
|
|
83
|
+
const cleanup = () => {
|
|
84
|
+
clearTimeout(timer); signal?.removeEventListener('abort', onAbort);
|
|
85
|
+
socket.removeListener(connectedEvent, onConnect); socket.removeListener('data', onData);
|
|
86
|
+
socket.removeListener('error', onError); socket.removeListener('end', onEnd); socket.removeListener('close', onClose);
|
|
87
|
+
};
|
|
88
|
+
const fail = (error: Error) => {
|
|
89
|
+
if (settled) return; settled = true; cleanup(); socket.destroy(); reject(error);
|
|
90
|
+
};
|
|
91
|
+
const onAbort = () => fail(aborted());
|
|
92
|
+
const establishmentFailure = (code: string, message: string) => fail(new TunnelError(code, message));
|
|
93
|
+
const onError = () => {
|
|
94
|
+
if (secure && !tlsReady) {
|
|
95
|
+
fail(new TunnelError('PROXY_TLS', 'HTTPS proxy establishment failed'));
|
|
96
|
+
} else establishmentFailure('TRANSPORT', 'HTTP proxy connection failed');
|
|
97
|
+
};
|
|
98
|
+
const onEnd = () => establishmentFailure('EARLY_CLOSE', 'HTTP proxy closed before establishing the tunnel');
|
|
99
|
+
const onClose = onEnd;
|
|
100
|
+
const onConnect = () => {
|
|
101
|
+
tlsReady = true;
|
|
102
|
+
const auth = proxy.username === undefined ? '' : `Proxy-Authorization: Basic ${Buffer.from(`${proxy.username}:${proxy.password}`).toString('base64')}\r\n`;
|
|
103
|
+
socket.write(`CONNECT ${authority} HTTP/1.1\r\nHost: ${authority}\r\n${auth}\r\n`);
|
|
104
|
+
};
|
|
105
|
+
const onData = (chunk: Buffer) => {
|
|
106
|
+
pending = Buffer.concat([pending, chunk]);
|
|
107
|
+
const lineEnd = pending.indexOf('\r\n');
|
|
108
|
+
if (lineEnd >= 0) {
|
|
109
|
+
const firstLine = pending.subarray(0, lineEnd).toString('latin1');
|
|
110
|
+
const firstStatus = /^HTTP\/1\.[01] ([0-9]{3})(?: [\t\x20-\x7e\x80-\xff]*)?$/.exec(firstLine);
|
|
111
|
+
if (!firstStatus) return fail(new TunnelError('PROTOCOL', 'Invalid HTTP proxy status line'));
|
|
112
|
+
const status = Number(firstStatus[1]);
|
|
113
|
+
if (status < 200 || status >= 300) return fail(new TunnelError('REFUSED', 'HTTP proxy refused CONNECT', { status }));
|
|
114
|
+
}
|
|
115
|
+
const end = pending.indexOf('\r\n\r\n');
|
|
116
|
+
if (end < 0) {
|
|
117
|
+
if (pending.length > maxHeaderBytes) fail(new TunnelError('PROTOCOL', 'HTTP proxy response headers exceed limit'));
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (end + 4 > maxHeaderBytes) return fail(new TunnelError('PROTOCOL', 'HTTP proxy response headers exceed limit'));
|
|
121
|
+
const lines = pending.subarray(0, end).toString('latin1').split('\r\n');
|
|
122
|
+
const statusLine = lines[0];
|
|
123
|
+
const match = /^HTTP\/1\.[01] ([0-9]{3})(?: [^\r\n]*)?$/.exec(statusLine);
|
|
124
|
+
if (!match) return fail(new TunnelError('PROTOCOL', 'Invalid HTTP proxy status line'));
|
|
125
|
+
const status = Number(match[1]);
|
|
126
|
+
if (lines.slice(1).some(line => !/^[!#$%&'*+.^_`|~0-9A-Za-z-]+:[\t\x20-\x7e\x80-\xff]*$/.test(line))) {
|
|
127
|
+
return fail(new TunnelError('PROTOCOL', 'Invalid HTTP proxy response header'));
|
|
128
|
+
}
|
|
129
|
+
if (status < 200 || status >= 300) return fail(new TunnelError('REFUSED', 'HTTP proxy refused CONNECT', { status }));
|
|
130
|
+
settled = true; socket.pause(); cleanup();
|
|
131
|
+
const head = pending.subarray(end + 4);
|
|
132
|
+
if (head.length) socket.unshift(head);
|
|
133
|
+
resolve(socket);
|
|
134
|
+
};
|
|
135
|
+
const timer = setTimeout(() => establishmentFailure('TIMEOUT', 'HTTP proxy establishment deadline exceeded'), timeoutMs);
|
|
136
|
+
socket.once(connectedEvent, onConnect); socket.on('data', onData);
|
|
137
|
+
socket.once('error', onError); socket.once('end', onEnd); socket.once('close', onClose);
|
|
138
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
139
|
+
if (signal?.aborted) onAbort();
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// The bounded socket reader owns setup cancellation and preserves queued bytes.
|
|
144
|
+
// Framing uses the existing
|
|
145
|
+
// MIT socks5-handshake module; neither shared framing nor its DNS policy changes.
|
|
146
|
+
async function connectSocks(authority: string, proxy: OpaqueProxyRoute, { signal, timeoutMs }: AttemptOptions): Promise<Socket> {
|
|
147
|
+
if (signal?.aborted) throw aborted();
|
|
148
|
+
const socket = net.createConnection({ host: proxy.host, port: proxy.port, allowHalfOpen: true });
|
|
149
|
+
socket.on('error', () => {});
|
|
150
|
+
let buffer: Buffer = Buffer.alloc(0), failure: Error | undefined;
|
|
151
|
+
let waiting: { length: number; resolve: () => void; reject: (error: Error) => void } | undefined;
|
|
152
|
+
const fail = (error: Error) => { if (failure) return; failure = error; const current = waiting; waiting = undefined; current?.reject(error); socket.destroy(); };
|
|
153
|
+
const transportError = (code: string, message: string) => fail(new TunnelError(code, message));
|
|
154
|
+
const onData = (chunk: Buffer) => {
|
|
155
|
+
// Consume at most the bytes needed by a bounded handshake read, then pause.
|
|
156
|
+
// A valid CONNECT reply can share a packet with arbitrary early target data;
|
|
157
|
+
// that remainder is not a malformed/oversized handshake and must survive.
|
|
158
|
+
socket.pause();
|
|
159
|
+
buffer = Buffer.concat([buffer, chunk]);
|
|
160
|
+
if (waiting && buffer.length >= waiting.length) { const current = waiting; waiting = undefined; current.resolve(); }
|
|
161
|
+
else if (waiting) socket.resume();
|
|
162
|
+
};
|
|
163
|
+
const onError = () => transportError('TRANSPORT', 'SOCKS proxy connection failed');
|
|
164
|
+
const onClose = () => transportError('EARLY_CLOSE', 'SOCKS proxy closed before tunnel establishment');
|
|
165
|
+
const onAbort = () => fail(aborted());
|
|
166
|
+
const timer = setTimeout(() => transportError('TIMEOUT', 'SOCKS establishment deadline exceeded'), timeoutMs);
|
|
167
|
+
socket.on('data', onData); socket.once('error', onError); socket.once('end', onClose); socket.once('close', onClose);
|
|
168
|
+
socket.pause();
|
|
169
|
+
signal?.addEventListener('abort', onAbort, { once: true }); if (signal?.aborted) onAbort();
|
|
170
|
+
const reader = {
|
|
171
|
+
write(bytes: Uint8Array): void { if (failure) throw failure; socket.write(bytes); },
|
|
172
|
+
async readExact(length: number): Promise<Buffer> {
|
|
173
|
+
if (failure) throw failure;
|
|
174
|
+
if (!Number.isSafeInteger(length) || length < 0 || length > 1024) throw new TunnelError('PROTOCOL', 'Invalid handshake read size');
|
|
175
|
+
if (buffer.length < length) await new Promise<void>((resolve, reject) => {
|
|
176
|
+
if (waiting) { reject(new TunnelError('PROTOCOL', 'Concurrent handshake read')); return; }
|
|
177
|
+
waiting = { length, resolve, reject };
|
|
178
|
+
socket.resume();
|
|
179
|
+
});
|
|
180
|
+
if (failure) throw failure;
|
|
181
|
+
const result = buffer.subarray(0, length); buffer = buffer.subarray(length); return result;
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
try {
|
|
185
|
+
const credentialsUrl = new URL('socks5://fixture.invalid');
|
|
186
|
+
if (proxy.username !== undefined) { credentialsUrl.username = encodeURIComponent(proxy.username); credentialsUrl.password = encodeURIComponent(proxy.password); }
|
|
187
|
+
const colon = authority.lastIndexOf(':');
|
|
188
|
+
const target = { host: authority.slice(0, colon).replace(/^\[|\]$/g, ''), port: Number(authority.slice(colon + 1)) };
|
|
189
|
+
await socks5Handshake(reader, target, socks5Credentials(credentialsUrl), signal);
|
|
190
|
+
if (failure) throw failure;
|
|
191
|
+
if (signal?.aborted || socket.destroyed) throw aborted();
|
|
192
|
+
socket.pause(); if (buffer.length) socket.unshift(buffer); buffer = Buffer.alloc(0);
|
|
193
|
+
return socket;
|
|
194
|
+
} catch (error) {
|
|
195
|
+
socket.destroy();
|
|
196
|
+
if (error instanceof Socks5HandshakeError) throw new TunnelError('SOCKS_REFUSED', 'SOCKS authentication, policy, or protocol refusal');
|
|
197
|
+
throw error;
|
|
198
|
+
} finally {
|
|
199
|
+
clearTimeout(timer); signal?.removeEventListener('abort', onAbort);
|
|
200
|
+
socket.removeListener('data', onData); socket.removeListener('error', onError);
|
|
201
|
+
socket.removeListener('end', onClose); socket.removeListener('close', onClose);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Establish only. There is no application data, implicit DIRECT, or replay here.
|
|
206
|
+
* HTTP/HTTPS CONNECT and SOCKS5/SOCKS5h yield an opaque raw target tunnel.
|
|
207
|
+
*/
|
|
208
|
+
export async function dialTunnel(authority: string, proxy: OpaqueProxyRoute, options: OpaqueTunnelOptions = {}): Promise<Socket> {
|
|
209
|
+
const { signal, timeoutMs = 10000, maxHeaderBytes = 65536 } = options;
|
|
210
|
+
validateAuthority(authority);
|
|
211
|
+
validateOptions(proxy, timeoutMs, maxHeaderBytes);
|
|
212
|
+
if (signal?.aborted) throw aborted();
|
|
213
|
+
// Snapshot the selected route. No alternate proxy or direct fallback exists.
|
|
214
|
+
const socket = await connectOne(authority, { ...proxy }, { signal, timeoutMs, maxHeaderBytes });
|
|
215
|
+
if (signal?.aborted || socket.destroyed) { socket.destroy(); throw aborted(); }
|
|
216
|
+
return socket;
|
|
217
|
+
}
|
package/src/lib/redact.ts
CHANGED
|
@@ -7,7 +7,7 @@ export const REDACTED_SECRET = "[REDACTED]";
|
|
|
7
7
|
* credentials over an unsafe channel (e.g. plaintext non-loopback HTTP) rather than
|
|
8
8
|
* re-deriving a narrower local list.
|
|
9
9
|
*/
|
|
10
|
-
export const SENSITIVE_KEY_PATTERN = /^(?:authorization|proxy-authorization|cookie|set-cookie|set-cookie2|api[-_]?key|x-api-key|x-goog-api-key|x-amz-security-token|copilot[-_]?session[-_]?token|session[-_]?token|access[-_]?token|refresh[-_]?token|id[-_]?token|token|secret|client[-_]?secret|password|profile[-_]?arn|exa[-_]?api[-_]?key)$/i;
|
|
10
|
+
export const SENSITIVE_KEY_PATTERN = /^(?:authorization|proxy-authorization|cookie|set-cookie|set-cookie2|api[-_]?key|x-api-key|x-goog-api-key|x-amz-security-token|x-dsh-auth-token|copilot[-_]?session[-_]?token|session[-_]?token|access[-_]?token|refresh[-_]?token|id[-_]?token|token|secret|client[-_]?secret|password|profile[-_]?arn|exa[-_]?api[-_]?key)$/i;
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
13
|
* Colon-labelled credential headers echoed back inside an error body
|
|
@@ -40,7 +40,7 @@ export const SENSITIVE_KEY_PATTERN = /^(?:authorization|proxy-authorization|cook
|
|
|
40
40
|
// Every letter position also accepts \u0001, the placeholder the fold emits for
|
|
41
41
|
// an unresolved HTML named reference: `authorⅈzation` is the label with one
|
|
42
42
|
// character we cannot name, and that is still the label.
|
|
43
|
-
const CREDENTIAL_HEADER_LABEL_RAW = "copilot[_-]?session[_-]?token|session[_-]?token|sessionToken|x-api-key|x-goog-api-key|x-amz-security-token|api[_-]?key|apiKey|exa[_-]?api[_-]?key|exaApiKey|access[_-]?token|accessToken|refresh[_-]?token|refreshToken|id[_-]?token|client[_-]?secret|clientSecret|authorization|proxy-authorization|cookie|set-cookie|password|secret|token";
|
|
43
|
+
const CREDENTIAL_HEADER_LABEL_RAW = "copilot[_-]?session[_-]?token|session[_-]?token|sessionToken|x-api-key|x-goog-api-key|x-amz-security-token|x-dsh-auth-token|api[_-]?key|apiKey|exa[_-]?api[_-]?key|exaApiKey|access[_-]?token|accessToken|refresh[_-]?token|refreshToken|id[_-]?token|client[_-]?secret|clientSecret|authorization|proxy-authorization|cookie|set-cookie|password|secret|token";
|
|
44
44
|
|
|
45
45
|
const CREDENTIAL_HEADER_LABEL = CREDENTIAL_HEADER_LABEL_RAW
|
|
46
46
|
.replace(/(?<![\[\\])([A-Za-z])(?![\]\-])/g, "[$1\u0001]");
|
|
@@ -96,7 +96,7 @@ function headroomOf(provider: string, accountId: string, requestedModelId?: stri
|
|
|
96
96
|
const family = classifyModelFamilyForQuota(provider, requestedModelId);
|
|
97
97
|
if (family) {
|
|
98
98
|
const percents = (quota.customWindows ?? [])
|
|
99
|
-
.filter(window => windowMatchesFamily(window.label, family))
|
|
99
|
+
.filter(window => window.modelId === undefined && windowMatchesFamily(window.label, family))
|
|
100
100
|
.map(window => window.percent)
|
|
101
101
|
.filter((value): value is number => typeof value === "number");
|
|
102
102
|
if (percents.length === 0) return null;
|
|
@@ -106,7 +106,7 @@ function headroomOf(provider: string, accountId: string, requestedModelId?: stri
|
|
|
106
106
|
quota.fiveHourPercent,
|
|
107
107
|
quota.weeklyPercent,
|
|
108
108
|
quota.monthlyPercent,
|
|
109
|
-
...(quota.customWindows ?? []).map(window => window.percent),
|
|
109
|
+
...(quota.customWindows ?? []).filter(window => window.modelId === undefined).map(window => window.percent),
|
|
110
110
|
].filter((value): value is number => typeof value === "number");
|
|
111
111
|
if (percents.length === 0) return null;
|
|
112
112
|
return 100 - Math.max(...percents);
|
|
@@ -140,6 +140,73 @@ export function isAccountQuotaExhausted(
|
|
|
140
140
|
return headroom !== null && headroom <= 0;
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
+
/**
|
|
144
|
+
* Earliest future reset timestamp (in ms) for the relevant quota window, or null if unknown.
|
|
145
|
+
* Prefers weekly reset when multiple windows exist so expiring allowance is consumed first.
|
|
146
|
+
*/
|
|
147
|
+
export function accountResetTimestamp(
|
|
148
|
+
provider: string,
|
|
149
|
+
accountId: string,
|
|
150
|
+
requestedModelId?: string | null,
|
|
151
|
+
now = Date.now(),
|
|
152
|
+
): number | null {
|
|
153
|
+
const quota = getCachedProviderAccountQuota(provider, accountId);
|
|
154
|
+
if (!quota) return null;
|
|
155
|
+
const family = classifyModelFamilyForQuota(provider, requestedModelId);
|
|
156
|
+
const windows = (quota.customWindows ?? []).filter(w => !family || windowMatchesFamily(w.label, family));
|
|
157
|
+
const weekly = windows.find(w => /week/i.test(w.label));
|
|
158
|
+
let rawReset = weekly?.resetAt ?? quota.weeklyResetAt;
|
|
159
|
+
if (rawReset === undefined && provider !== "google-antigravity" && provider !== "anthropic") {
|
|
160
|
+
// Providers without a weekly cadence: prefer an explicitly 5h-labeled window before
|
|
161
|
+
// falling back to first-window order.
|
|
162
|
+
const fiveHour = windows.find(w => /5h|five/i.test(w.label)) ?? windows[0];
|
|
163
|
+
rawReset = fiveHour?.resetAt ?? quota.fiveHourResetAt;
|
|
164
|
+
} else if (rawReset === undefined) {
|
|
165
|
+
rawReset = windows[0]?.resetAt ?? quota.fiveHourResetAt;
|
|
166
|
+
}
|
|
167
|
+
if (typeof rawReset !== "number" || !Number.isFinite(rawReset)) return null;
|
|
168
|
+
const ms = rawReset < 1e11 ? rawReset * 1000 : rawReset;
|
|
169
|
+
return ms > now ? ms : null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Rank candidates by soonest reset timestamp first among healthy accounts.
|
|
174
|
+
* Accounts whose quota will reset earliest get priority so allowances do not expire unused.
|
|
175
|
+
*
|
|
176
|
+
* When a passive provider has incomplete quota evidence (some ring members with no
|
|
177
|
+
* cached record), reset timestamps are unreliable: an unknown account converts to
|
|
178
|
+
* Infinity and always loses to a known near-exhausted one. Fall back to headroom
|
|
179
|
+
* order instead of inventing a reset ordering.
|
|
180
|
+
*/
|
|
181
|
+
export function rankAccountsByResetFirst(
|
|
182
|
+
provider: string,
|
|
183
|
+
ring: readonly string[],
|
|
184
|
+
requestedModelId?: string | null,
|
|
185
|
+
now = Date.now(),
|
|
186
|
+
accounts?: ReadonlyMap<string, ProviderAccount>,
|
|
187
|
+
): string[] {
|
|
188
|
+
if (ring.length < 2) return [...ring];
|
|
189
|
+
const ranked = rankAccountsByHeadroom(provider, ring, requestedModelId, accounts);
|
|
190
|
+
const healthy = ranked.filter(id => !isAccountQuotaExhausted(provider, id, requestedModelId, accounts?.get(id)));
|
|
191
|
+
if (healthy.length < 2) return ranked;
|
|
192
|
+
if (
|
|
193
|
+
hasPassiveAccountQuota(provider)
|
|
194
|
+
&& !ring.every(id => headroomOf(provider, id, requestedModelId, accounts?.get(id)) !== null)
|
|
195
|
+
) return ranked;
|
|
196
|
+
|
|
197
|
+
const withReset = healthy.map((id, index) => ({
|
|
198
|
+
id,
|
|
199
|
+
resetAt: accountResetTimestamp(provider, id, requestedModelId, now) ?? Number.POSITIVE_INFINITY,
|
|
200
|
+
headroom: headroomOf(provider, id, requestedModelId, accounts?.get(id)) ?? 0,
|
|
201
|
+
index,
|
|
202
|
+
}));
|
|
203
|
+
|
|
204
|
+
withReset.sort((a, b) => (a.resetAt - b.resetAt) || (b.headroom - a.headroom) || (a.index - b.index));
|
|
205
|
+
const sortedHealthy = withReset.map(r => r.id);
|
|
206
|
+
const exhausted = ranked.filter(id => isAccountQuotaExhausted(provider, id, requestedModelId, accounts?.get(id)));
|
|
207
|
+
return [...sortedHealthy, ...exhausted];
|
|
208
|
+
}
|
|
209
|
+
|
|
143
210
|
/**
|
|
144
211
|
* Order candidates best-first.
|
|
145
212
|
*
|
|
@@ -31,6 +31,22 @@ export function bindAnthropicRefusalCredentialForSend(response: Response, owner:
|
|
|
31
31
|
responseCredentials.set(response, Object.freeze({ ...owner, providerAccountUuid, checkProviderUuid: arguments.length >= 3 }));
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/** Preserve the original send proof across internal error normalization, without renewing it. */
|
|
35
|
+
export function carryAnthropicRefusalCredential(source: Response, target: Response): void {
|
|
36
|
+
const sent = responseCredentials.get(source);
|
|
37
|
+
if (sent) responseCredentials.set(target, sent);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Recheck after body consumption: a late refusal cannot acquire a replacement login's authority. */
|
|
41
|
+
export function anthropicRefusalCredentialIsCurrent(response: Response, instance: AnthropicInstanceId, accountId: string): boolean {
|
|
42
|
+
const sent = responseCredentials.get(response);
|
|
43
|
+
if (!sent || sent.provider !== instance || sent.accountId !== accountId
|
|
44
|
+
|| !anthropicPhysicalSendOwnershipIsCurrent(sent)) return false;
|
|
45
|
+
const row = getAccountCredentialWithStatus(instance, accountId);
|
|
46
|
+
return !!row && !row.paused && !row.needsReauth
|
|
47
|
+
&& (!sent.checkProviderUuid || row.credential.accountId === sent.providerAccountUuid);
|
|
48
|
+
}
|
|
49
|
+
|
|
34
50
|
async function isRevokedOAuthToken(response: Response, signal?: AbortSignal): Promise<boolean> {
|
|
35
51
|
if (response.status !== 401) return false;
|
|
36
52
|
try {
|
|
@@ -59,9 +75,11 @@ async function isAccountRefusal(response: Response, signal?: AbortSignal): Promi
|
|
|
59
75
|
// Whole-message patterns: request-policy/model/resource refusals and quoted diagnostics
|
|
60
76
|
// must not acquire account authority merely by containing entitlement keywords.
|
|
61
77
|
if ("code" in error && error.code != null
|
|
62
|
-
&& (typeof error.code !== "string" || !["subscription_required", "insufficient_quota", "permission_denied"].includes(error.code))) return false;
|
|
78
|
+
&& (typeof error.code !== "string" || !["subscription_required", "insufficient_quota", "permission_denied", "oauth_org_not_allowed"].includes(error.code))) return false;
|
|
79
|
+
// The organization disabled subscription access: an account-scoped verdict, cooled like its siblings.
|
|
80
|
+
if (error.type === "oauth_org_not_allowed" || ("code" in error && error.code === "oauth_org_not_allowed")) return true;
|
|
63
81
|
const message = error.message.trim();
|
|
64
|
-
if (error.type === "permission_error" && /^(?:Your account does not have access to Claude Code|Your (?:Claude )?subscription (?:has expired|is (?:expired|inactive))|Your account does not have an active subscription)[.!]?$/i.test(message)) return true;
|
|
82
|
+
if (error.type === "permission_error" && /^(?:Your account does not have access to Claude Code|Your (?:Claude )?subscription (?:has expired|is (?:expired|inactive))|Your account does not have an active subscription|(?:Your|This) organization has disabled Claude subscription access for Claude Code(?:\. Use an Anthropic API key instead, or ask your admin to enable access)?)[.!]?$/i.test(message)) return true;
|
|
65
83
|
return (error.type === "billing_error" || error.type === "permission_error")
|
|
66
84
|
&& /^Your credit balance is too low to access the Anthropic API\.(?: Please go to Plans & Billing to upgrade or purchase credits\.)?$/i.test(message);
|
|
67
85
|
} catch {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { ConfigMutationLockError } from "../config";
|
|
2
2
|
import { deriveOAuthDefaultModel, deriveOAuthProviderConfig } from "../providers/derive";
|
|
3
3
|
import type { AnthropicInstanceId } from "../providers/anthropic-instance-id";
|
|
4
|
-
import { loginAnthropic, refreshAnthropicToken } from "./anthropic";
|
|
4
|
+
import { ANTHROPIC_CODE_REDIRECT_URI, loginAnthropic, refreshAnthropicToken } from "./anthropic";
|
|
5
5
|
import type { OAuthProviderDef } from "./index";
|
|
6
6
|
import {
|
|
7
7
|
clearOAuthRefreshIntent, clearOAuthRefreshIntentIfMatch, markOAuthRefreshIntentCleanupPending,
|
|
@@ -16,8 +16,10 @@ export function anthropicOAuthDefinition(instance: AnthropicInstanceId): OAuthPr
|
|
|
16
16
|
return {
|
|
17
17
|
login: (ctrl, opts) => loginAnthropic(ctrl, {
|
|
18
18
|
instance, importLocal: instance === "anthropic2" || opts?.forceLogin ? "off" : "fallback",
|
|
19
|
+
codeMode: opts?.codeMode === true,
|
|
19
20
|
}),
|
|
20
21
|
refresh: refreshAnthropicToken,
|
|
22
|
+
codeRedirectUri: ANTHROPIC_CODE_REDIRECT_URI,
|
|
21
23
|
providerConfig,
|
|
22
24
|
defaultModel,
|
|
23
25
|
// Subscription OAuth must never generate unattended traffic by default.
|
|
@@ -19,7 +19,6 @@ import { clearAllAnthropicFamilyQuota, anthropicModelQuotaFor, anthropicModelPer
|
|
|
19
19
|
*/
|
|
20
20
|
import { clearAllAnthropicRatePauses, anthropicRatePolicyFor, classifyAnthropic429, anthropicRetryAfterMs } from "./anthropic-rate-limit-policy";
|
|
21
21
|
import { REFRESH_SKEW_MS } from "./refresh-policy";
|
|
22
|
-
import { createHash } from "node:crypto";
|
|
23
22
|
import { captureOAuthAccountSelection, commitOAuthAccountSelection, credentialGeneration, getAccountSet, getAccountCredential, getAccountCredentialWithStatus } from "./store";
|
|
24
23
|
import type { OAuthAccessSnapshot } from "./index";
|
|
25
24
|
import { getCachedProviderAccountQuota } from "../providers/quota";
|
|
@@ -273,26 +272,7 @@ export function formatAnthropicProviderForLog(
|
|
|
273
272
|
return `${providerName}-${formatAnthropicAccountOrdinal(accountId)}`;
|
|
274
273
|
}
|
|
275
274
|
|
|
276
|
-
export
|
|
277
|
-
sessionIdHeader?: string | null;
|
|
278
|
-
threadIdHeader?: string | null;
|
|
279
|
-
promptCacheKey?: string | null;
|
|
280
|
-
clientThreadId?: string | null;
|
|
281
|
-
/** When true, prompt_cache_key is a shared Desktop cohort — ignore it for affinity. */
|
|
282
|
-
promptCacheKeyIsSharedCohort?: boolean;
|
|
283
|
-
}): string | null {
|
|
284
|
-
const preferred = input.clientThreadId?.trim()
|
|
285
|
-
|| input.sessionIdHeader?.trim()
|
|
286
|
-
|| input.threadIdHeader?.trim()
|
|
287
|
-
|| "";
|
|
288
|
-
if (preferred) {
|
|
289
|
-
return preferred.length <= 128 ? preferred : createHash("sha256").update(preferred).digest("hex");
|
|
290
|
-
}
|
|
291
|
-
if (input.promptCacheKeyIsSharedCohort) return null;
|
|
292
|
-
const cacheKey = input.promptCacheKey?.trim() ?? "";
|
|
293
|
-
if (!cacheKey) return null;
|
|
294
|
-
return cacheKey.length <= 128 ? cacheKey : createHash("sha256").update(cacheKey).digest("hex");
|
|
295
|
-
}
|
|
275
|
+
export { oauthSessionKeyFromParts as anthropicSessionKeyFromParts } from "./session-affinity-key";
|
|
296
276
|
|
|
297
277
|
|
|
298
278
|
export type AnthropicCooldownRecoveryClaim = Readonly<{
|
package/src/oauth/anthropic.ts
CHANGED
|
@@ -15,6 +15,16 @@ const CALLBACK_PATH = "/callback";
|
|
|
15
15
|
/** Profile enrichment budget on token refresh; login paths keep the resolver's own deadline. */
|
|
16
16
|
const REFRESH_PROFILE_TIMEOUT_MS = 2_000;
|
|
17
17
|
const SCOPES = "org:create_api_key user:profile user:inference";
|
|
18
|
+
/**
|
|
19
|
+
* Provider-hosted code-display redirect: Anthropic renders `code#state` for the user to copy
|
|
20
|
+
* instead of redirecting to a local port. This is what makes login possible from a container or
|
|
21
|
+
* a hosted gateway, where no browser can reach this process. Same OAuth client as the callback
|
|
22
|
+
* flow — only the redirect differs, so it must be the redirect_uri used for BOTH the authorize
|
|
23
|
+
* request and the token exchange.
|
|
24
|
+
*/
|
|
25
|
+
export const ANTHROPIC_CODE_REDIRECT_URI = "https://platform.claude.com/oauth/code/callback";
|
|
26
|
+
/** A displayed code is copied by hand across devices; the 300s callback wait is too short. */
|
|
27
|
+
const CODE_MODE_TIMEOUT_MS = 10 * 60_000;
|
|
18
28
|
|
|
19
29
|
// ── OAuth-request requirements applied by the anthropic adapter when authMode==="oauth" ──
|
|
20
30
|
export const ANTHROPIC_OAUTH_BETA = "claude-code-20250219,oauth-2025-04-20";
|
|
@@ -142,10 +152,27 @@ async function withAccountProfile(credential: OAuthCredentials, signal?: AbortSi
|
|
|
142
152
|
}
|
|
143
153
|
|
|
144
154
|
export class AnthropicOAuthFlow extends OAuthCallbackFlow {
|
|
155
|
+
/**
|
|
156
|
+
* PKCE verifier for THIS flow. One `AnthropicOAuthFlow` is constructed per login and
|
|
157
|
+
* `generateAuthUrl` runs once on it, so the verifier is already flow-scoped: a second
|
|
158
|
+
* concurrent login builds a second instance and cannot reach this field.
|
|
159
|
+
*/
|
|
145
160
|
#verifier = "";
|
|
161
|
+
#codeMode: boolean;
|
|
146
162
|
|
|
147
|
-
constructor(ctrl: OAuthController) {
|
|
148
|
-
|
|
163
|
+
constructor(ctrl: OAuthController, opts?: { codeMode?: boolean }) {
|
|
164
|
+
if (opts?.codeMode) {
|
|
165
|
+
super(ctrl, {
|
|
166
|
+
preferredPort: CALLBACK_PORT,
|
|
167
|
+
callbackPath: CALLBACK_PATH,
|
|
168
|
+
redirectUri: ANTHROPIC_CODE_REDIRECT_URI,
|
|
169
|
+
skipCallbackServer: true,
|
|
170
|
+
timeoutMs: CODE_MODE_TIMEOUT_MS,
|
|
171
|
+
});
|
|
172
|
+
} else {
|
|
173
|
+
super(ctrl, CALLBACK_PORT, CALLBACK_PATH);
|
|
174
|
+
}
|
|
175
|
+
this.#codeMode = opts?.codeMode === true;
|
|
149
176
|
}
|
|
150
177
|
|
|
151
178
|
async generateAuthUrl(state: string, redirectUri: string): Promise<{ url: string; instructions?: string }> {
|
|
@@ -163,8 +190,9 @@ export class AnthropicOAuthFlow extends OAuthCallbackFlow {
|
|
|
163
190
|
});
|
|
164
191
|
return {
|
|
165
192
|
url: `${AUTHORIZE_URL}?${authParams.toString()}`,
|
|
166
|
-
instructions:
|
|
167
|
-
"
|
|
193
|
+
instructions: this.#codeMode
|
|
194
|
+
? "Open this URL in any browser, approve access, then paste the code Anthropic displays (it looks like code#state)."
|
|
195
|
+
: "Complete Claude login in your browser. If the browser cannot reach this machine, paste the final redirect URL or authorization code when prompted.",
|
|
168
196
|
};
|
|
169
197
|
}
|
|
170
198
|
|
|
@@ -191,9 +219,12 @@ export class AnthropicOAuthFlow extends OAuthCallbackFlow {
|
|
|
191
219
|
|
|
192
220
|
export async function loginAnthropic(
|
|
193
221
|
ctrl: OAuthController,
|
|
194
|
-
opts?: { importLocal?: LocalTokenImportMode; instance?: AnthropicInstanceId },
|
|
222
|
+
opts?: { importLocal?: LocalTokenImportMode; instance?: AnthropicInstanceId; codeMode?: boolean },
|
|
195
223
|
): Promise<OAuthCredentials> {
|
|
196
|
-
|
|
224
|
+
// A code-mode caller asked for a URL to show and a box to paste into. Importing a local
|
|
225
|
+
// Claude Code token instead would complete WITHOUT ever firing onAuth, so the caller would
|
|
226
|
+
// get no URL at all — a different outcome than the one its contract promises.
|
|
227
|
+
const importLocal = opts?.codeMode ? "off" : opts?.importLocal ?? "off";
|
|
197
228
|
if (opts?.instance === "anthropic2" && importLocal !== "off") throw new AnthropicLocalCliImportError();
|
|
198
229
|
if (importLocal !== "off") {
|
|
199
230
|
const { detectClaudeCodeToken } = await import("./local-token-detect");
|
|
@@ -221,7 +252,7 @@ export async function loginAnthropic(
|
|
|
221
252
|
);
|
|
222
253
|
}
|
|
223
254
|
}
|
|
224
|
-
return new AnthropicOAuthFlow(ctrl).login();
|
|
255
|
+
return new AnthropicOAuthFlow(ctrl, { codeMode: opts?.codeMode === true }).login();
|
|
225
256
|
}
|
|
226
257
|
|
|
227
258
|
export async function refreshAnthropicToken(refreshToken: string): Promise<OAuthCredentials> {
|
|
@@ -66,6 +66,15 @@ export interface OAuthCallbackFlowOptions {
|
|
|
66
66
|
callbackBindHostname?: string;
|
|
67
67
|
/** Exact redirect URI advertised to the provider; disables port fallback. */
|
|
68
68
|
redirectUri?: string;
|
|
69
|
+
/**
|
|
70
|
+
* Code-display mode: the provider renders the authorization code for the user to paste, so
|
|
71
|
+
* this process never receives a redirect and must NOT open a listener. Requires `redirectUri`
|
|
72
|
+
* (the provider-hosted code page) and an `onManualCodeInput` channel — without one the flow
|
|
73
|
+
* would wait for a callback that can never arrive.
|
|
74
|
+
*/
|
|
75
|
+
skipCallbackServer?: boolean;
|
|
76
|
+
/** Overrides the 300s wait for the code; code-display pastes are hand-typed and slower. */
|
|
77
|
+
timeoutMs?: number;
|
|
69
78
|
}
|
|
70
79
|
|
|
71
80
|
type BunServer = ReturnType<typeof Bun.serve>;
|
|
@@ -77,6 +86,8 @@ export abstract class OAuthCallbackFlow {
|
|
|
77
86
|
callbackHostname: string;
|
|
78
87
|
callbackBindHostname: string;
|
|
79
88
|
redirectUri?: string;
|
|
89
|
+
skipCallbackServer: boolean;
|
|
90
|
+
timeoutMs: number;
|
|
80
91
|
#callbackResolve?: (result: CallbackResult) => void;
|
|
81
92
|
#callbackReject?: (error: string) => void;
|
|
82
93
|
|
|
@@ -86,6 +97,8 @@ export abstract class OAuthCallbackFlow {
|
|
|
86
97
|
callbackPath: string = CALLBACK_PATH,
|
|
87
98
|
) {
|
|
88
99
|
this.ctrl = ctrl;
|
|
100
|
+
this.skipCallbackServer = false;
|
|
101
|
+
this.timeoutMs = DEFAULT_TIMEOUT;
|
|
89
102
|
if (typeof preferredPortOrOptions === "number") {
|
|
90
103
|
this.preferredPort = preferredPortOrOptions;
|
|
91
104
|
this.callbackPath = callbackPath;
|
|
@@ -98,6 +111,11 @@ export abstract class OAuthCallbackFlow {
|
|
|
98
111
|
this.callbackHostname = preferredPortOrOptions.callbackHostname ?? DEFAULT_HOSTNAME;
|
|
99
112
|
this.callbackBindHostname = preferredPortOrOptions.callbackBindHostname ?? DEFAULT_BIND_HOSTNAME;
|
|
100
113
|
this.redirectUri = preferredPortOrOptions.redirectUri;
|
|
114
|
+
this.skipCallbackServer = preferredPortOrOptions.skipCallbackServer === true;
|
|
115
|
+
this.timeoutMs = preferredPortOrOptions.timeoutMs ?? DEFAULT_TIMEOUT;
|
|
116
|
+
if (this.skipCallbackServer && !this.redirectUri) {
|
|
117
|
+
throw new Error("skipCallbackServer requires an explicit redirectUri");
|
|
118
|
+
}
|
|
101
119
|
}
|
|
102
120
|
|
|
103
121
|
/** Build provider-specific authorization URL. */
|
|
@@ -132,6 +150,14 @@ export abstract class OAuthCallbackFlow {
|
|
|
132
150
|
}
|
|
133
151
|
|
|
134
152
|
async #startCallbackServer(expectedState: string): Promise<{ servers: BunServer[]; redirectUri: string }> {
|
|
153
|
+
if (this.skipCallbackServer) {
|
|
154
|
+
// The paste channel is the ONLY way this flow can complete. Fail loudly here rather than
|
|
155
|
+
// hanging until the timeout on a controller that can never deliver a code.
|
|
156
|
+
if (!this.ctrl.onManualCodeInput) {
|
|
157
|
+
throw new Error("Code-display login requires a manual code input channel");
|
|
158
|
+
}
|
|
159
|
+
return { servers: [], redirectUri: this.redirectUri! };
|
|
160
|
+
}
|
|
135
161
|
try {
|
|
136
162
|
const servers = this.#createServers(this.preferredPort, expectedState);
|
|
137
163
|
if (this.redirectUri) {
|
|
@@ -226,7 +252,7 @@ export abstract class OAuthCallbackFlow {
|
|
|
226
252
|
}
|
|
227
253
|
|
|
228
254
|
#waitForCallback(expectedState: string): Promise<CallbackResult> {
|
|
229
|
-
const timeoutSignal = AbortSignal.timeout(
|
|
255
|
+
const timeoutSignal = AbortSignal.timeout(this.timeoutMs);
|
|
230
256
|
const signal = this.ctrl.signal ? AbortSignal.any([this.ctrl.signal, timeoutSignal]) : timeoutSignal;
|
|
231
257
|
|
|
232
258
|
const callbackPromise = new Promise<CallbackResult>((resolve, reject) => {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { OAuthController, OAuthCredentials } from "./types";
|
|
2
|
-
import { CHATGPT_CLIENT_ID, CHATGPT_TOKEN_URL, credsFromToken } from "./chatgpt";
|
|
2
|
+
import { CHATGPT_CLIENT_ID, CHATGPT_TOKEN_URL, credsFromToken } from "./chatgpt-shared";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* OpenAI deviceauth (device-code) grant for the ChatGPT/Codex provider.
|