@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
package/src/oauth/login-cli.ts
CHANGED
|
@@ -9,12 +9,14 @@ import {
|
|
|
9
9
|
requestBoundLocalProviderReload,
|
|
10
10
|
type LocalProviderReloadResult,
|
|
11
11
|
} from "../server/local-provider-reload-client";
|
|
12
|
-
import { DEPRECATED_OAUTH_PROVIDER_ALIASES, isPublicOAuthProvider, listOAuthProviders, runLogin } from "./index";
|
|
12
|
+
import { DEPRECATED_OAUTH_PROVIDER_ALIASES, isPublicOAuthProvider, listOAuthProviders, publicOAuthAuthenticationErrorMessage, runLogin, supportsCodeLoginMode } from "./index";
|
|
13
|
+
import { classifyLoginCodeError } from "./login-flow-state";
|
|
13
14
|
import { KEY_LOGIN_PROVIDERS, isKeyLoginProvider, validateApiKey, type KeyLoginProvider } from "./key-providers";
|
|
14
15
|
import type { OcxConfig, OcxProviderConfig } from "../types";
|
|
15
16
|
import { configuredAdminToken } from "../lib/admin-secrets";
|
|
16
17
|
import { codexAccountNamespaceProviderCollisionError } from "../codex/account-namespace-match";
|
|
17
18
|
import { assertAnthropicInstanceLoginConfig } from "./store-anthropic-instance";
|
|
19
|
+
import { reportOAuthLoginError } from "./login-cli-errors";
|
|
18
20
|
|
|
19
21
|
/**
|
|
20
22
|
* Seams a test drives in place of a browser, a terminal and a real provider. Production passes
|
|
@@ -113,37 +115,97 @@ export function warnIfLiveReloadSkipped(result: LocalProviderReloadResult | null
|
|
|
113
115
|
* that used to be their only pointer to it.
|
|
114
116
|
*/
|
|
115
117
|
export function loginUsageMessage(): string {
|
|
116
|
-
return `Usage: ocx login <provider
|
|
118
|
+
return `Usage: ocx login <provider> [--headless]\n`
|
|
119
|
+
+ ` --headless: no browser and no local port — approve in any browser, paste the code\n`
|
|
120
|
+
+ ` (${listCodeLoginProviders().join(", ")})\n`
|
|
117
121
|
+ ` Codex / ChatGPT: ocx login codex (account pool, needs a running proxy; 'chatgpt' and\n`
|
|
118
122
|
+ ` 'openai' are the same route. An OpenAI platform key is 'openai-apikey'.)\n`
|
|
119
123
|
+ ` OAuth login: ${listOAuthProviders().join(", ")}\n`
|
|
120
124
|
+ ` API-key login: ${Object.keys(KEY_LOGIN_PROVIDERS).join(", ")}`;
|
|
121
125
|
}
|
|
122
126
|
|
|
123
|
-
export
|
|
127
|
+
export interface LoginCliOptions {
|
|
128
|
+
/** `--headless`: no callback server, no browser launch — print the URL and read back the code. */
|
|
129
|
+
codeMode?: boolean;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export async function handleLogin(provider?: string, deps: LoginCliDeps = {}, opts: LoginCliOptions = {}): Promise<void> {
|
|
124
133
|
const name = (provider ?? "").trim().toLowerCase();
|
|
134
|
+
try {
|
|
135
|
+
// Check raw ownership before tolerant loading can print diagnostics or fall back to defaults.
|
|
136
|
+
if (name === "anthropic2") assertAnthropicInstanceLoginConfig({ providers: {} }, name);
|
|
137
|
+
} catch (error) {
|
|
138
|
+
reportOAuthLoginError(error);
|
|
139
|
+
}
|
|
125
140
|
// A removed provider id reached through its alias still logs in — the merged
|
|
126
141
|
// successor owns the flow. Warn rather than silently reroute so scripts and
|
|
127
142
|
// docs that still say `devin-cli` surface the rename to whoever runs them.
|
|
128
143
|
const alias = DEPRECATED_OAUTH_PROVIDER_ALIASES[name];
|
|
129
144
|
if (alias) {
|
|
130
145
|
console.error(`${name} is deprecated; logging in as ${alias}`);
|
|
131
|
-
return handleOAuthLogin(alias, deps);
|
|
146
|
+
return handleOAuthLogin(alias, deps, opts);
|
|
147
|
+
}
|
|
148
|
+
if (isPublicOAuthProvider(name)) return handleOAuthLogin(name, deps, opts);
|
|
149
|
+
if (opts.codeMode) {
|
|
150
|
+
console.error(`--headless needs a provider with a code-display login: ${listCodeLoginProviders().join(", ")}`);
|
|
151
|
+
process.exit(1);
|
|
132
152
|
}
|
|
133
|
-
if (isPublicOAuthProvider(name)) return handleOAuthLogin(name, deps);
|
|
134
153
|
if (isKeyLoginProvider(name)) return handleKeyLogin(name, deps);
|
|
135
154
|
console.error(loginUsageMessage());
|
|
136
155
|
process.exit(1);
|
|
137
156
|
}
|
|
138
157
|
|
|
139
|
-
|
|
158
|
+
/** OAuth providers that accept `ocx login <provider> --headless`. */
|
|
159
|
+
export function listCodeLoginProviders(): string[] {
|
|
160
|
+
return listOAuthProviders().filter(supportsCodeLoginMode);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* `--headless`: the provider displays the authorization code instead of redirecting anywhere, so
|
|
165
|
+
* nothing is launched and nothing listens. Used where the browser is on another machine.
|
|
166
|
+
*/
|
|
167
|
+
async function handleCodeLogin(name: string, deps: LoginCliDeps): Promise<void> {
|
|
168
|
+
const login = deps.runLogin ?? runLogin;
|
|
169
|
+
if (!supportsCodeLoginMode(name)) {
|
|
170
|
+
console.error(`${name} has no code-display login. Providers that do: ${listCodeLoginProviders().join(", ")}`);
|
|
171
|
+
process.exit(1);
|
|
172
|
+
}
|
|
173
|
+
try {
|
|
174
|
+
await withPrompt(deps.ask, async (ask) => {
|
|
175
|
+
await login(name, {
|
|
176
|
+
onAuth: ({ url, instructions }) => {
|
|
177
|
+
console.log(`\n🔐 Open this URL in any browser to approve ${name}:\n${url}\n`);
|
|
178
|
+
if (instructions) console.log(instructions);
|
|
179
|
+
},
|
|
180
|
+
onProgress: (m) => console.log(` ${m}`),
|
|
181
|
+
onManualCodeInput: async () => await ask("Paste the code shown after approval: "),
|
|
182
|
+
}, { codeMode: true });
|
|
183
|
+
});
|
|
184
|
+
} catch (error) {
|
|
185
|
+
// Never let the raw error reach stderr: a token endpoint's error body can quote the pasted
|
|
186
|
+
// code back. Only fixed, allowlisted text and the machine-readable reason are printed.
|
|
187
|
+
console.error(`\n❌ ${name} login failed (${classifyLoginCodeError(error)}): ${publicOAuthAuthenticationErrorMessage(error)}`);
|
|
188
|
+
process.exit(1);
|
|
189
|
+
}
|
|
190
|
+
const reload = await notifyRunningProxyAfterOAuthLogin(name);
|
|
191
|
+
console.log(`\n✅ Logged in to ${name}. Try: ocx sync`);
|
|
192
|
+
for (const line of modelSelectionGuidance(name)) console.log(line);
|
|
193
|
+
warnIfLiveReloadSkipped(reload);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export async function handleOAuthLogin(name: string, deps: LoginCliDeps = {}, opts: LoginCliOptions = {}): Promise<void> {
|
|
140
197
|
if (name === "anthropic2") assertAnthropicInstanceLoginConfig(loadConfig(), name);
|
|
198
|
+
if (opts.codeMode) return handleCodeLogin(name, deps);
|
|
141
199
|
const login = deps.runLogin ?? runLogin;
|
|
142
200
|
const launch = deps.openUrl ?? openUrl;
|
|
143
201
|
const browser = createBrowserLaunchReport(deps.warn);
|
|
144
202
|
await withPrompt(deps.ask, async (ask) => {
|
|
145
203
|
await login(name, {
|
|
146
204
|
onAuth: ({ url, instructions }) => {
|
|
205
|
+
if (!url) {
|
|
206
|
+
if (instructions) console.log(instructions);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
147
209
|
console.log(`\n🔐 Opening browser for ${name} login...\n${url}\n`);
|
|
148
210
|
if (instructions) console.log(instructions);
|
|
149
211
|
// The controller does not await onAuth, so the launcher's answer cannot be reported from
|
|
@@ -159,7 +221,7 @@ export async function handleOAuthLogin(name: string, deps: LoginCliDeps = {}): P
|
|
|
159
221
|
return await ask("Paste redirect URL or code (or wait for browser): ");
|
|
160
222
|
},
|
|
161
223
|
});
|
|
162
|
-
});
|
|
224
|
+
}).catch(reportOAuthLoginError);
|
|
163
225
|
// A device or polling provider never prompts, so nothing above waited on the launcher. It is
|
|
164
226
|
// still owed an answer before this claims the login worked.
|
|
165
227
|
await browser.settled();
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { AnthropicTokenError } from "./anthropic";
|
|
1
2
|
import { parseCallbackInput } from "./callback-server";
|
|
2
3
|
import { retainedUtf8Bytes } from "../lib/admission";
|
|
3
4
|
import type { GenerationContext } from "../lib/state-store-sweeper";
|
|
@@ -9,6 +10,12 @@ import type { GenerationContext } from "../lib/state-store-sweeper";
|
|
|
9
10
|
* lines. The cut follows a seam rather than a line count: everything here is bookkeeping for
|
|
10
11
|
* a login that has started and not yet settled, and none of it reads or writes a stored
|
|
11
12
|
* credential. `index.ts` re-exports the two public names, so existing importers are unaffected.
|
|
13
|
+
*
|
|
14
|
+
* Every map here is keyed by FLOW ID, not by provider. A code-display flow binds no socket, so
|
|
15
|
+
* a hosted login host can have several in flight for one provider at once; each must have its
|
|
16
|
+
* own paste slot and its own abort scope, or one user's paste settles another's login. The
|
|
17
|
+
* provider-only public APIs (`getLoginStatus`, `submitManualLoginCode`, `cancelLoginFlow`)
|
|
18
|
+
* resolve through `latestLoginFlowKey`, which is the one place that rule is written down.
|
|
12
19
|
*/
|
|
13
20
|
/** Human-facing continuation only: never retain credentials or provider response objects. */
|
|
14
21
|
export interface OAuthLoginHint {
|
|
@@ -16,16 +23,301 @@ export interface OAuthLoginHint {
|
|
|
16
23
|
instructions?: string;
|
|
17
24
|
deviceCode?: string;
|
|
18
25
|
}
|
|
19
|
-
|
|
20
|
-
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Machine-readable outcome of a code-display login, so a caller that only ever sees one HTTP
|
|
29
|
+
* response can tell a bad paste from a dead flow from an unreachable provider. Never carries
|
|
30
|
+
* any part of the code, verifier, state or token.
|
|
31
|
+
*/
|
|
32
|
+
export type LoginCodeError =
|
|
33
|
+
| "state_mismatch"
|
|
34
|
+
| "invalid_or_expired_code"
|
|
35
|
+
| "no_pending_login"
|
|
36
|
+
| "provider_unreachable"
|
|
37
|
+
| "malformed_input"
|
|
38
|
+
| "code_mode_unsupported";
|
|
39
|
+
|
|
40
|
+
export interface LoginFlowState {
|
|
41
|
+
error?: string;
|
|
42
|
+
done: boolean;
|
|
43
|
+
hint?: OAuthLoginHint;
|
|
44
|
+
/** Absent means the default localhost-callback flow; "code" is the code-display flow. */
|
|
45
|
+
mode?: "callback" | "code";
|
|
46
|
+
/**
|
|
47
|
+
* Which provider this flow logs into, since the key no longer says. Absent on a row written
|
|
48
|
+
* straight into this map by a caller that predates flow-id keying, where the key IS the
|
|
49
|
+
* provider name — `loginFlowProvider` resolves both.
|
|
50
|
+
*/
|
|
51
|
+
provider?: string;
|
|
52
|
+
flowId?: string;
|
|
53
|
+
/** Epoch ms after which a paste is refused as `no_pending_login`. */
|
|
54
|
+
expiresAt?: number;
|
|
55
|
+
errorCode?: LoginCodeError;
|
|
56
|
+
/** Account row this flow persisted, once it settled successfully. An id, never a credential. */
|
|
57
|
+
accountId?: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Keyed by flow id. */
|
|
61
|
+
export const loginState = new Map<string, LoginFlowState>();
|
|
62
|
+
/** Keyed by flow id; `provider` is what a provider-scoped caller is checked against. */
|
|
63
|
+
export const loginAbort = new Map<string, { controller: AbortController; provider?: string }>();
|
|
21
64
|
export const kiroLoginSettling = new Set<string>();
|
|
22
65
|
|
|
66
|
+
/** The provider a flow belongs to, or undefined when no such flow is recorded. */
|
|
67
|
+
export function loginFlowProvider(flowKey: string): string | undefined {
|
|
68
|
+
const state = loginState.get(flowKey);
|
|
69
|
+
return state === undefined ? undefined : state.provider ?? flowKey;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Flow ids recorded for a provider, oldest first (Map iteration is insertion order). */
|
|
73
|
+
export function loginFlowKeys(provider: string): string[] {
|
|
74
|
+
// ponytail: linear scan over in-flight logins (bounded by MAX_LOGIN_FLOWS_PER_PROVIDER);
|
|
75
|
+
// add a provider -> keys index if that bound ever grows.
|
|
76
|
+
const keys: string[] = [];
|
|
77
|
+
// `(state.provider ?? key)` must be parenthesised: `===` binds tighter than `??`, so the
|
|
78
|
+
// unparenthesised form reads as `state.provider ?? (key === provider)` and returns the truthy
|
|
79
|
+
// provider string for every flow -- matching every provider against every other.
|
|
80
|
+
for (const [key, state] of loginState) if ((state.provider ?? key) === provider) keys.push(key);
|
|
81
|
+
return keys;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The flow that a provider-only API acts on: the NEWEST flow still in progress, or — when none
|
|
86
|
+
* is — the newest flow overall, so a login that just settled still reports its outcome. With a
|
|
87
|
+
* single flow (every callback login, and every GUI/CLI login) this is that flow, unchanged.
|
|
88
|
+
*/
|
|
89
|
+
export function latestLoginFlowKey(provider: string): string | undefined {
|
|
90
|
+
const keys = loginFlowKeys(provider);
|
|
91
|
+
return keys.findLast(key => loginState.get(key)?.done === false) ?? keys.at(-1);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Rows kept per provider. Concurrent code flows mean an authenticated caller can start logins
|
|
96
|
+
* nobody ever finishes, each holding a PKCE verifier, an AbortController and an open paste
|
|
97
|
+
* promise. Expired flows are reaped first; this is the backstop that bounds the rest.
|
|
98
|
+
*/
|
|
99
|
+
const MAX_LOGIN_FLOWS_PER_PROVIDER = 64;
|
|
100
|
+
|
|
101
|
+
/** Abort a flow, drop its paste slot, and record a terminal outcome for anything awaiting it. */
|
|
102
|
+
export function abandonLoginFlow(flowKey: string, outcome: { error: string; errorCode: LoginCodeError }): void {
|
|
103
|
+
loginAbort.get(flowKey)?.controller.abort("cancelled");
|
|
104
|
+
loginAbort.delete(flowKey);
|
|
105
|
+
clearManualCodeSlot(flowKey);
|
|
106
|
+
settleLoginFlow(flowKey, outcome);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Refuse a login that cannot safely share this process with what is already in flight, after
|
|
111
|
+
* reaping dead rows. A code-display flow binds nothing, so several can run at once for one
|
|
112
|
+
* provider — that is what lets a hosted login host serve more than one user. The callback flow
|
|
113
|
+
* binds the fixed loopback port and genuinely cannot run twice, so any overlap involving one is
|
|
114
|
+
* refused, as is anything past the per-provider row cap.
|
|
115
|
+
*/
|
|
116
|
+
export function admitLoginFlow(provider: string, codeMode: boolean): void {
|
|
117
|
+
reapLoginFlows(provider);
|
|
118
|
+
const inFlight = loginFlowKeys(provider)
|
|
119
|
+
.map(key => loginState.get(key))
|
|
120
|
+
.filter(state => state?.done === false);
|
|
121
|
+
const overlapAllowed = codeMode && inFlight.every(state => state?.mode === "code");
|
|
122
|
+
if ((inFlight.length > 0 && !overlapAllowed) || (provider === "kiro" && kiroLoginSettling.has(provider))) {
|
|
123
|
+
throw new Error(`A login for ${provider} is already in progress`);
|
|
124
|
+
}
|
|
125
|
+
if (loginFlowKeys(provider).length >= MAX_LOGIN_FLOWS_PER_PROVIDER) {
|
|
126
|
+
throw new Error(`Too many logins in progress for ${provider}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Drop a provider's dead rows: code flows past their paste window (they can never complete),
|
|
132
|
+
* then the oldest settled rows once the cap is reached. Called when a flow starts, which is
|
|
133
|
+
* the only moment the count can grow. Never evicts a flow still in progress.
|
|
134
|
+
*/
|
|
135
|
+
function reapLoginFlows(provider: string): void {
|
|
136
|
+
const now = Date.now();
|
|
137
|
+
for (const key of loginFlowKeys(provider)) {
|
|
138
|
+
const state = loginState.get(key);
|
|
139
|
+
if (state && !state.done && state.expiresAt !== undefined && now > state.expiresAt) {
|
|
140
|
+
abandonLoginFlow(key, { error: "Login expired", errorCode: "no_pending_login" });
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
const keys = loginFlowKeys(provider);
|
|
144
|
+
// +1 leaves room for the flow about to be recorded.
|
|
145
|
+
const excess = keys.length - MAX_LOGIN_FLOWS_PER_PROVIDER + 1;
|
|
146
|
+
if (excess <= 0) return;
|
|
147
|
+
for (const key of keys.filter(key => loginState.get(key)?.done === true).slice(0, excess)) {
|
|
148
|
+
clearManualCodeSlot(key);
|
|
149
|
+
dropLoginFlow(key);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** A code-display flow the user never completes must stop accepting pastes. */
|
|
154
|
+
export const CODE_LOGIN_TTL_MS = 10 * 60_000;
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Record a just-admitted flow under its id and return its paste deadline (code mode only).
|
|
158
|
+
* Lives here rather than in `startLoginFlow` to keep `src/oauth/index.ts` under the size ratchet.
|
|
159
|
+
*/
|
|
160
|
+
export function recordStartedLoginFlow(provider: string, flowId: string, codeMode: boolean): number | undefined {
|
|
161
|
+
clearManualCodeSlot(flowId);
|
|
162
|
+
const expiresAt = codeMode ? Date.now() + CODE_LOGIN_TTL_MS : undefined;
|
|
163
|
+
loginState.set(flowId, { done: false, provider, flowId, ...(codeMode ? { mode: "code" as const, expiresAt } : {}) });
|
|
164
|
+
return expiresAt;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export class CodeLoginUnsupportedError extends Error {
|
|
168
|
+
constructor(provider: string) {
|
|
169
|
+
super(`${provider} has no code-display login`);
|
|
170
|
+
this.name = "CodeLoginUnsupportedError";
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Map a login failure onto the code a code-mode caller receives. The error itself is never
|
|
176
|
+
* forwarded: a provider body can quote the submitted code back, and this response is the one
|
|
177
|
+
* surface a hosted caller renders to a user.
|
|
178
|
+
*
|
|
179
|
+
* `AnthropicTokenError` is named because anthropic is the only provider with a code-display
|
|
180
|
+
* redirect today; every other failure falls through the generic branches.
|
|
181
|
+
*/
|
|
182
|
+
export function classifyLoginCodeError(error: unknown): LoginCodeError {
|
|
183
|
+
const message = error instanceof Error ? error.message : "";
|
|
184
|
+
if (message.startsWith("OAuth callback cancelled")) return "no_pending_login";
|
|
185
|
+
if (error instanceof AnthropicTokenError) {
|
|
186
|
+
return error.httpStatus !== undefined && error.httpStatus >= 500
|
|
187
|
+
? "provider_unreachable"
|
|
188
|
+
: "invalid_or_expired_code";
|
|
189
|
+
}
|
|
190
|
+
const name = error instanceof Error ? error.name : "";
|
|
191
|
+
if (name === "TimeoutError" || name === "AbortError") return "provider_unreachable";
|
|
192
|
+
// fetch() reports a dead network as a TypeError, but so does any bug in this code. Bun tags
|
|
193
|
+
// the network kind with a string `code` (`ConnectionRefused`, `ENOTFOUND`, ...) and Node puts
|
|
194
|
+
// one on `cause`; a plain programming TypeError carries neither, and must not be reported to
|
|
195
|
+
// the user as "Anthropic is unreachable".
|
|
196
|
+
if (error instanceof TypeError) {
|
|
197
|
+
const code = (error as { code?: unknown }).code ?? (error as { cause?: { code?: unknown } }).cause?.code;
|
|
198
|
+
if (typeof code === "string" && code !== "") return "provider_unreachable";
|
|
199
|
+
}
|
|
200
|
+
return "invalid_or_expired_code";
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Awaiting a terminal outcome for one flow (code mode answers its POST from this). */
|
|
204
|
+
const loginSettleWaiters = new Map<string, Set<() => void>>();
|
|
205
|
+
|
|
206
|
+
function wakeLoginWaiters(flowKey: string): void {
|
|
207
|
+
const waiters = loginSettleWaiters.get(flowKey);
|
|
208
|
+
if (!waiters) return;
|
|
209
|
+
loginSettleWaiters.delete(flowKey);
|
|
210
|
+
for (const wake of waiters) wake();
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Record a login's terminal outcome and wake anything awaiting it. Flow identity
|
|
215
|
+
* (mode/flowId/expiresAt) is preserved so a settled flow still reports what it was. The hint is
|
|
216
|
+
* dropped: `getLoginStatus` already hides it once done, and a settled flow has no next step.
|
|
217
|
+
*/
|
|
218
|
+
export function settleLoginFlow(
|
|
219
|
+
flowKey: string,
|
|
220
|
+
outcome: { error?: string; errorCode?: LoginCodeError; accountId?: string } = {},
|
|
221
|
+
): void {
|
|
222
|
+
const current = loginState.get(flowKey);
|
|
223
|
+
// A flow already dropped (logout, reap) has nothing left to report; releasing its waiters is
|
|
224
|
+
// still owed, and fabricating a row here would be a flow belonging to no provider.
|
|
225
|
+
if (current) {
|
|
226
|
+
loginState.set(flowKey, {
|
|
227
|
+
...current,
|
|
228
|
+
done: true,
|
|
229
|
+
hint: undefined,
|
|
230
|
+
error: outcome.error,
|
|
231
|
+
errorCode: outcome.errorCode,
|
|
232
|
+
accountId: outcome.accountId,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
wakeLoginWaiters(flowKey);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** True when the named flow (or, without one, the provider's newest flow) is this provider's code flow. */
|
|
239
|
+
export function isCodeLoginFlow(provider: string, flowId?: string): boolean {
|
|
240
|
+
const flowKey = flowId ?? latestLoginFlowKey(provider);
|
|
241
|
+
return flowKey !== undefined && loginFlowProvider(flowKey) === provider && loginState.get(flowKey)?.mode === "code";
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** The account a settled, successful flow persisted; undefined for any other flow or provider. */
|
|
245
|
+
export function loginFlowAccountId(provider: string, flowId: string): string | undefined {
|
|
246
|
+
const state = loginState.get(flowId);
|
|
247
|
+
if (!state?.done || state.error || loginFlowProvider(flowId) !== provider) return undefined;
|
|
248
|
+
return state.accountId;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Drop one flow's state entirely; waiters are released rather than left hanging. */
|
|
252
|
+
export function dropLoginFlow(flowKey: string): void {
|
|
253
|
+
loginState.delete(flowKey);
|
|
254
|
+
wakeLoginWaiters(flowKey);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Resolve true once the login is terminal, false if `timeoutMs` elapses first. `flowId` picks
|
|
259
|
+
* one of several concurrent flows; without it the provider's newest flow is used.
|
|
260
|
+
*/
|
|
261
|
+
export function waitForLoginSettled(provider: string, timeoutMs: number, flowId?: string): Promise<boolean> {
|
|
262
|
+
const flowKey = flowId ?? latestLoginFlowKey(provider);
|
|
263
|
+
const current = flowKey === undefined ? undefined : loginState.get(flowKey);
|
|
264
|
+
if (!current || current.done || loginFlowProvider(flowKey!) !== provider) return Promise.resolve(true);
|
|
265
|
+
return new Promise<boolean>(resolve => {
|
|
266
|
+
let waiters = loginSettleWaiters.get(flowKey!);
|
|
267
|
+
if (!waiters) {
|
|
268
|
+
waiters = new Set();
|
|
269
|
+
loginSettleWaiters.set(flowKey!, waiters);
|
|
270
|
+
}
|
|
271
|
+
let timer: ReturnType<typeof setTimeout>;
|
|
272
|
+
const wake = (): void => {
|
|
273
|
+
clearTimeout(timer);
|
|
274
|
+
resolve(true);
|
|
275
|
+
};
|
|
276
|
+
timer = setTimeout(() => {
|
|
277
|
+
waiters.delete(wake);
|
|
278
|
+
resolve(false);
|
|
279
|
+
}, timeoutMs);
|
|
280
|
+
waiters.add(wake);
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Forget every login flow for a provider (logout): it invalidates all of them, not just the
|
|
286
|
+
* newest. Any code-mode POST awaiting one is released rather than left to time out.
|
|
287
|
+
*/
|
|
288
|
+
export function clearLoginState(provider: string): void {
|
|
289
|
+
for (const flowKey of loginFlowKeys(provider)) {
|
|
290
|
+
loginAbort.get(flowKey)?.controller.abort("cleared");
|
|
291
|
+
loginAbort.delete(flowKey);
|
|
292
|
+
clearManualCodeSlot(flowKey);
|
|
293
|
+
dropLoginFlow(flowKey);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Cancel one login. `flowId` names it exactly; without one the provider's newest in-flight flow
|
|
299
|
+
* is cancelled, which is every single-flow caller (GUI, CLI) unchanged. A flow id belonging to a
|
|
300
|
+
* different provider is refused rather than cancelled.
|
|
301
|
+
*/
|
|
302
|
+
export function cancelLoginFlow(provider: string, flowId?: string): boolean {
|
|
303
|
+
const flowKey = flowId ?? latestLoginFlowKey(provider);
|
|
304
|
+
if (flowKey === undefined) return false;
|
|
305
|
+
const active = loginAbort.get(flowKey);
|
|
306
|
+
const existing = loginState.get(flowKey);
|
|
307
|
+
if ((active?.provider ?? loginFlowProvider(flowKey)) !== provider) return false;
|
|
308
|
+
if (!active && (!existing || existing.done)) return false;
|
|
309
|
+
abandonLoginFlow(flowKey, { error: "Login cancelled", errorCode: "no_pending_login" });
|
|
310
|
+
return true;
|
|
311
|
+
}
|
|
312
|
+
|
|
23
313
|
/** Pending paste for a login in progress: either a waiter or a stashed early submission. */
|
|
24
314
|
export interface ManualCodeSlot {
|
|
25
315
|
pendingInput?: string;
|
|
26
316
|
resolve?: (value: string) => void;
|
|
27
317
|
/** Registered by the callback flow so submits can validate state synchronously. */
|
|
28
318
|
expectedState?: string;
|
|
319
|
+
/** Code-display flows are single-use: set once a paste is handed to the exchange. */
|
|
320
|
+
delivered?: boolean;
|
|
29
321
|
}
|
|
30
322
|
const loginManual = new Map<string, ManualCodeSlot>();
|
|
31
323
|
const OAUTH_PENDING_CODE_MAX_BYTES = 4 * 1024;
|
|
@@ -34,35 +326,41 @@ let lastOAuthFlowReconciledGeneration = 0;
|
|
|
34
326
|
export function reconcileOAuthFlowState(context: GenerationContext): number {
|
|
35
327
|
if (context.generation <= lastOAuthFlowReconciledGeneration) return 0;
|
|
36
328
|
let removed = 0;
|
|
37
|
-
for (const [
|
|
38
|
-
|
|
39
|
-
if (
|
|
40
|
-
if (
|
|
41
|
-
if (
|
|
329
|
+
for (const [flowKey, state] of loginState) {
|
|
330
|
+
const provider = state.provider ?? flowKey!;
|
|
331
|
+
if (context.providerNames.has(provider) || !state.done || loginAbort.has(flowKey)) continue;
|
|
332
|
+
if (loginState.delete(flowKey)) removed += 1;
|
|
333
|
+
if (loginManual.delete(flowKey)) removed += 1;
|
|
334
|
+
if (loginAbort.delete(flowKey)) removed += 1;
|
|
42
335
|
}
|
|
43
336
|
lastOAuthFlowReconciledGeneration = context.generation;
|
|
44
337
|
return removed;
|
|
45
338
|
}
|
|
46
339
|
|
|
47
|
-
|
|
48
|
-
|
|
340
|
+
/** Test-only counterpart to `resetOAuthReauthStateForTests`, for the same leak. */
|
|
341
|
+
export function resetOAuthFlowReconcileStateForTests(): void {
|
|
342
|
+
lastOAuthFlowReconciledGeneration = 0;
|
|
49
343
|
}
|
|
50
344
|
|
|
51
|
-
export function
|
|
52
|
-
|
|
345
|
+
export function clearManualCodeSlot(flowKey: string): void {
|
|
346
|
+
loginManual.delete(flowKey);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export function ensureManualCodeSlot(flowKey: string): ManualCodeSlot {
|
|
350
|
+
let slot = loginManual.get(flowKey);
|
|
53
351
|
if (!slot) {
|
|
54
352
|
slot = {};
|
|
55
|
-
loginManual.set(
|
|
353
|
+
loginManual.set(flowKey, slot);
|
|
56
354
|
}
|
|
57
355
|
return slot;
|
|
58
356
|
}
|
|
59
357
|
|
|
60
358
|
/** Wait for a GUI/CLI paste of the OAuth redirect URL or code (or return a stashed early submit). */
|
|
61
|
-
export function waitForManualLoginCode(
|
|
359
|
+
export function waitForManualLoginCode(flowKey: string, signal: AbortSignal, expectedState?: string): Promise<string> {
|
|
62
360
|
if (signal.aborted) {
|
|
63
361
|
return Promise.reject(new Error(`OAuth callback cancelled: ${signal.reason}`));
|
|
64
362
|
}
|
|
65
|
-
const slot = ensureManualCodeSlot(
|
|
363
|
+
const slot = ensureManualCodeSlot(flowKey);
|
|
66
364
|
if (expectedState !== undefined) slot.expectedState = expectedState;
|
|
67
365
|
if (slot.pendingInput !== undefined) {
|
|
68
366
|
const value = slot.pendingInput;
|
|
@@ -84,17 +382,61 @@ export function waitForManualLoginCode(provider: string, signal: AbortSignal, ex
|
|
|
84
382
|
}
|
|
85
383
|
|
|
86
384
|
/**
|
|
87
|
-
* Feed a pasted redirect URL or authorization code into an in-progress GUI login.
|
|
385
|
+
* Feed a pasted redirect URL or authorization code into an in-progress GUI login. Provider-only,
|
|
386
|
+
* so it feeds the provider's newest in-flight flow — see `latestLoginFlowKey`.
|
|
88
387
|
* Returns ok:false when no login is waiting (or input is empty). Invalid pastes are accepted
|
|
89
388
|
* here and re-prompted by the OAuth callback loop if they cannot be parsed / fail state checks.
|
|
90
389
|
*/
|
|
91
390
|
export function submitManualLoginCode(provider: string, input: string): { ok: true } | { ok: false; error: string } {
|
|
391
|
+
const result = submitLoginCode(provider, input);
|
|
392
|
+
// The machine-readable code is dropped here on purpose: this return shape is the
|
|
393
|
+
// long-standing contract of the callback flow's paste route. Code mode calls
|
|
394
|
+
// submitLoginCode directly because it must answer with the code.
|
|
395
|
+
return result.ok ? { ok: true } : { ok: false, error: result.error };
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Same validation as `submitManualLoginCode`, with the machine-readable reason attached, and
|
|
400
|
+
* the id of the flow the paste was applied to so the caller can await THAT flow rather than
|
|
401
|
+
* whichever one is newest by the time it looks.
|
|
402
|
+
*
|
|
403
|
+
* `expectedFlowId` pins the submission to one flow: a paste from a superseded attempt, or one
|
|
404
|
+
* aimed at another concurrent login, is refused rather than fed to whatever is running now.
|
|
405
|
+
*/
|
|
406
|
+
export function submitLoginCode(
|
|
407
|
+
provider: string,
|
|
408
|
+
input: string,
|
|
409
|
+
expectedFlowId?: string,
|
|
410
|
+
): { ok: true; flowId: string } | { ok: false; error: string; code: LoginCodeError } {
|
|
92
411
|
const trimmed = input.trim();
|
|
93
|
-
if (!trimmed) return { ok: false, error: "empty code" };
|
|
94
|
-
if (retainedUtf8Bytes(trimmed) > OAUTH_PENDING_CODE_MAX_BYTES)
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
412
|
+
if (!trimmed) return { ok: false, error: "empty code", code: "malformed_input" };
|
|
413
|
+
if (retainedUtf8Bytes(trimmed) > OAUTH_PENDING_CODE_MAX_BYTES) {
|
|
414
|
+
return { ok: false, error: "code too large", code: "malformed_input" };
|
|
415
|
+
}
|
|
416
|
+
// The flow id IS the key, so an id belonging to another provider's flow (or to no flow at
|
|
417
|
+
// all) resolves to nothing here rather than reaching a login it does not own.
|
|
418
|
+
const flowKey = expectedFlowId ?? latestLoginFlowKey(provider);
|
|
419
|
+
const st = flowKey === undefined ? undefined : loginState.get(flowKey);
|
|
420
|
+
if (!st || st.done || loginFlowProvider(flowKey!) !== provider) {
|
|
421
|
+
return { ok: false, error: "no login in progress", code: "no_pending_login" };
|
|
422
|
+
}
|
|
423
|
+
if (st.expiresAt !== undefined && Date.now() > st.expiresAt) {
|
|
424
|
+
return { ok: false, error: "login expired", code: "no_pending_login" };
|
|
425
|
+
}
|
|
426
|
+
const slot = ensureManualCodeSlot(flowKey!);
|
|
427
|
+
// A code-display flow exchanges exactly one paste and then settles, success or not. A second
|
|
428
|
+
// paste while that exchange runs would otherwise be stashed and answered with the first
|
|
429
|
+
// paste's outcome, so it is refused instead.
|
|
430
|
+
if (st.mode === "code" && slot.delivered) {
|
|
431
|
+
return { ok: false, error: "login already received a code", code: "no_pending_login" };
|
|
432
|
+
}
|
|
433
|
+
// Without the expected state a paste cannot be checked here, and stashing it would spend the
|
|
434
|
+
// flow's single use on input the callback loop later throws away. The URL a caller needs to
|
|
435
|
+
// obtain a code is published only after the state is registered, so this is only reachable
|
|
436
|
+
// by racing the start.
|
|
437
|
+
if (st.mode === "code" && slot.expectedState === undefined) {
|
|
438
|
+
return { ok: false, error: "login is not ready for a code yet", code: "no_pending_login" };
|
|
439
|
+
}
|
|
98
440
|
// Synchronous validation (validated request/ack): reject un-parseable input and
|
|
99
441
|
// authorization responses (url/query kind) whose state is missing or mismatched
|
|
100
442
|
// once the flow has registered its expected state. Raw codes stay in-session-PKCE
|
|
@@ -106,15 +448,20 @@ export function submitManualLoginCode(provider: string, input: string): { ok: tr
|
|
|
106
448
|
// (`{ apiKey, state, ... }`). Keep that opaque to the generic raw parser so
|
|
107
449
|
// hashes in JSON strings do not become a fake state suffix; its provider parser validates state.
|
|
108
450
|
const isCommandCodeJson = provider === "command-code" && trimmed.startsWith("{");
|
|
109
|
-
if (!parsed.code && !isCommandCodeJson)
|
|
451
|
+
if (!parsed.code && !isCommandCodeJson) {
|
|
452
|
+
return { ok: false, error: "no authorization code found in input", code: "malformed_input" };
|
|
453
|
+
}
|
|
110
454
|
// A raw paste carrying an explicit code#state suffix is state-bearing too: it
|
|
111
455
|
// must match the expected state rather than bypass validation.
|
|
112
456
|
const stateBearing = !isCommandCodeJson && (parsed.kind !== "raw" || parsed.state !== undefined);
|
|
113
457
|
if (stateBearing && slot.expectedState !== undefined) {
|
|
114
|
-
if (parsed.state === undefined)
|
|
458
|
+
if (parsed.state === undefined) {
|
|
459
|
+
return { ok: false, error: "redirect URL is missing the state parameter", code: "state_mismatch" };
|
|
460
|
+
}
|
|
115
461
|
if (parsed.state !== slot.expectedState) {
|
|
116
462
|
return {
|
|
117
463
|
ok: false,
|
|
464
|
+
code: "state_mismatch",
|
|
118
465
|
error: parsed.kind === "raw"
|
|
119
466
|
? "state mismatch — paste the bare code, or the correct code#state from THIS login attempt"
|
|
120
467
|
: "state mismatch — paste the redirect URL from THIS login attempt",
|
|
@@ -129,5 +476,6 @@ export function submitManualLoginCode(provider: string, input: string): { ok: tr
|
|
|
129
476
|
// Race: GUI may POST before the flow reaches onManualCodeInput — stash for the waiter.
|
|
130
477
|
slot.pendingInput = trimmed;
|
|
131
478
|
}
|
|
132
|
-
|
|
479
|
+
if (st.mode === "code") slot.delivered = true;
|
|
480
|
+
return { ok: true, flowId: flowKey! };
|
|
133
481
|
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/** Closed, account-free diagnostics: never project a provider's raw error text. */
|
|
2
|
+
const messages = {
|
|
3
|
+
"cancelled": "Muse Code login cancelled.",
|
|
4
|
+
"pointer-unreadable": "Could not read the Muse Code credential file. Check its permissions or run `muse login` again.",
|
|
5
|
+
"pointer-invalid": "The Muse Code credential file is not valid JSON or has an invalid shape. Run `muse login` to rewrite it.",
|
|
6
|
+
"storage-unsupported": "Muse Code stored its credential with an unsupported backend; only the macOS Keychain import is verified. Retry import or paste an existing Muse Code key when prompted.",
|
|
7
|
+
"keychain-timeout": "The Muse Code Keychain approval timed out after 120s. Unlock the login keychain, approve the prompt and retry, or paste an existing Muse Code key when prompted.",
|
|
8
|
+
"keychain-denied": "Access to the Muse Code Keychain entry was denied or cancelled. Approve the Keychain prompt and retry, or paste an existing Muse Code key when prompted.",
|
|
9
|
+
"keychain-locked": "The Muse Code Keychain entry requires user interaction. Unlock the login keychain and retry from an interactive macOS session, or paste an existing Muse Code key when prompted.",
|
|
10
|
+
"keychain-missing": "The Muse Code Keychain entry was not found. Run `muse login` again, or paste an existing Muse Code key when prompted.",
|
|
11
|
+
"keychain-unreadable": "Could not read the Muse Code credential from the macOS Keychain. Run `muse login` again, or paste an existing Muse Code key when prompted.",
|
|
12
|
+
"keychain-invalid": "The Muse Code Keychain entry is not valid JSON or has an invalid shape. Run `muse login` to rewrite it.",
|
|
13
|
+
"key-missing": "The Muse Code Keychain entry carries no usable API key. Run `muse login` again, or paste an existing Muse Code key when prompted.",
|
|
14
|
+
"key-format": "The Muse Code credential is not in the expected Meta API key format. Run `muse login` again, or paste an existing Muse Code key when prompted.",
|
|
15
|
+
"manual-key-missing": "The pasted Muse Code credential carries no usable API key. Copy it again from https://dev.meta.ai.",
|
|
16
|
+
"manual-key-format": "The pasted Muse Code credential is not in the expected Meta API key format. Copy it again from https://dev.meta.ai.",
|
|
17
|
+
"validation-network": "Could not reach the Meta Model API to validate the Muse Code credential. Check the proxy host's network and retry.",
|
|
18
|
+
"validation-timeout": "The Meta Model API credential validation timed out after 10s. Check the proxy host's network and retry.",
|
|
19
|
+
"validation-http": "The Muse Code credential was rejected by the Meta Model API. Renew it with `muse login` or copy it again from https://dev.meta.ai, then retry.",
|
|
20
|
+
} as const;
|
|
21
|
+
|
|
22
|
+
export type MuseLoginErrorKind = keyof typeof messages;
|
|
23
|
+
|
|
24
|
+
export class MuseLoginError extends Error {
|
|
25
|
+
constructor(readonly kind: MuseLoginErrorKind, readonly status?: number) {
|
|
26
|
+
super(museLoginErrorMessage(kind, status));
|
|
27
|
+
this.name = "MuseLoginError";
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function museLoginErrorMessage(kind: MuseLoginErrorKind, status?: number): string {
|
|
32
|
+
const message = Object.hasOwn(messages, kind) ? messages[kind] : messages["keychain-unreadable"];
|
|
33
|
+
return kind === "validation-http" && Number.isInteger(status) && status! >= 100 && status! <= 599
|
|
34
|
+
? `${message} (HTTP ${status})`
|
|
35
|
+
: message;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function publicMuseLoginErrorMessage(error: unknown): string | undefined {
|
|
39
|
+
// Reconstruct from the closed kind; even a mutated Error.message cannot leak a key.
|
|
40
|
+
return error instanceof MuseLoginError ? museLoginErrorMessage(error.kind, error.status) : undefined;
|
|
41
|
+
}
|