@bitkyc08/opencodex 2.60.0 → 2.61.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/AGENTS_INSTALL.md +64 -0
- package/README.md +28 -1
- package/bin/ocx.mjs +382 -209
- package/gui/dist/assets/App-CH6C5H7x.js +50 -0
- package/gui/dist/assets/Tray-CLZh48fM.js +1 -0
- package/gui/dist/assets/index-_bpvxJu0.css +1 -0
- package/gui/dist/assets/index-wpTOyepx.js +86 -0
- package/gui/dist/assets/usage-companion-chart-a0N58rRI.js +1 -0
- package/gui/dist/favicon.png +0 -0
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/stepfun-color.svg +1 -0
- package/package.json +5 -1
- package/src/adapters/anthropic.ts +16 -0
- package/src/adapters/coding-agent/protocol.ts +36 -6
- package/src/adapters/coding-agent/turn.ts +10 -2
- package/src/adapters/command-code.ts +2 -1
- package/src/adapters/cursor/catalog.ts +51 -7
- package/src/adapters/cursor/protobuf-request.ts +6 -3
- package/src/adapters/cursor/request-builder.ts +13 -3
- package/src/adapters/cursor.ts +11 -2
- package/src/adapters/declaration-carrier.ts +45 -0
- package/src/adapters/devin.ts +75 -23
- package/src/adapters/google-antigravity-wire.ts +5 -2
- package/src/adapters/google-errors.ts +7 -1
- package/src/adapters/google.ts +29 -5
- package/src/adapters/image.ts +4 -1
- package/src/adapters/input-media-guard.ts +21 -9
- package/src/adapters/kiro/usage.ts +3 -2
- package/src/adapters/kiro-tool-fallback.ts +1 -1
- package/src/adapters/ollama-native.ts +6 -0
- package/src/adapters/openai-chat/developer-role.ts +61 -0
- package/src/adapters/openai-chat/messages.ts +46 -27
- package/src/adapters/openai-chat/parallel-tool-calls.ts +32 -0
- package/src/adapters/openai-chat/passthrough.ts +33 -9
- package/src/adapters/openai-chat/reasoning-wire.ts +89 -0
- package/src/adapters/openai-chat.ts +18 -57
- package/src/adapters/openai-responses/passthrough.ts +2 -0
- package/src/adapters/registry.ts +3 -2
- package/src/adapters/run-turn-queue.ts +178 -29
- package/src/adapters/xai-web-search.ts +16 -1
- package/src/bridge/errors.ts +8 -2
- package/src/bridge/response-json.ts +9 -1
- package/src/bridge/sse.ts +10 -0
- package/src/chat/inbound.ts +141 -5
- package/src/claude/desktop-3p.ts +7 -1
- package/src/claude/desktop-first-party.ts +183 -0
- package/src/claude/desktop-gateway-state.ts +41 -0
- package/src/claude/inbound-content-options.ts +6 -0
- package/src/claude/inbound.ts +32 -6
- package/src/claude/intercept/connect-proxy.ts +179 -0
- package/src/claude/intercept/listener.ts +122 -0
- package/src/claude/intercept/local-ca.ts +298 -0
- package/src/claude/intercept/runtime.ts +98 -0
- package/src/claude/intercept/settings.ts +189 -0
- package/src/cli/access.ts +87 -0
- package/src/cli/account-auth.ts +19 -0
- package/src/cli/capabilities.ts +31 -0
- package/src/cli/claude-desktop.ts +206 -16
- package/src/cli/codex-shim-autorestore.ts +3 -0
- package/src/cli/companion.ts +56 -0
- package/src/cli/dispatch.ts +43 -4
- package/src/cli/ensure-desired-integrations.ts +43 -5
- package/src/cli/help.ts +7 -9
- package/src/cli/index.ts +200 -61
- package/src/cli/init.ts +8 -0
- package/src/cli/integrations.ts +7 -1
- package/src/cli/registry.ts +41 -2
- package/src/cli/resolve.ts +230 -0
- package/src/cli/root.ts +24 -1
- package/src/cli/start-ownership-publication.ts +56 -0
- package/src/cli/status-probes.ts +2 -18
- package/src/cli/status.ts +62 -0
- package/src/cli/stop-report.ts +143 -0
- package/src/cli/uninstall-plan.ts +9 -0
- package/src/client/machine-listener.ts +2 -5
- package/src/clients/aside-profiles.ts +4 -0
- package/src/clients/config-export/zcode-store.ts +157 -0
- package/src/clients/config-export.ts +36 -0
- package/src/codex/app-server-processes.ts +72 -40
- package/src/codex/auth-api/login-flow.ts +6 -1
- package/src/codex/autostart-health.ts +28 -0
- package/src/codex/catalog/build-entries.ts +2 -2
- package/src/codex/catalog/effort.ts +3 -3
- package/src/codex/catalog/provider-models.ts +24 -15
- package/src/codex/catalog/retained-sync.ts +2 -2
- package/src/codex/convergence.ts +2 -2
- package/src/codex/history-provider.ts +12 -1
- package/src/codex/inject/config-toml.ts +41 -6
- package/src/codex/inject/paginated-openai-compat.ts +90 -0
- package/src/codex/inject.ts +18 -15
- package/src/codex/injected-marker.ts +18 -0
- package/src/codex/main-account.ts +6 -0
- package/src/codex/model-cache.ts +52 -6
- package/src/codex/model-entitlement-admission.ts +59 -0
- package/src/codex/model-entitlements.ts +87 -44
- package/src/codex/native-main-admission.ts +83 -0
- package/src/codex/routing/health-store.ts +39 -0
- package/src/codex/routing/selection.ts +37 -1
- package/src/codex/routing.ts +5 -41
- package/src/codex/shim-templates.ts +29 -3
- package/src/companion/settings.ts +132 -0
- package/src/config/atomic-write.ts +117 -5
- package/src/config/load-degrade.ts +34 -7
- package/src/config/process-state.ts +1 -1
- package/src/config/schema/config-schema.ts +27 -1
- package/src/config/schema/leaf-validators.ts +47 -0
- package/src/config.ts +1 -1
- package/src/generated/compatibility-version.json +418 -174
- package/src/integrations/config-io.ts +44 -10
- package/src/integrations/merge.ts +120 -13
- package/src/integrations/mutation-plan.ts +124 -18
- package/src/integrations/registry.ts +38 -0
- package/src/integrations/state.ts +78 -45
- package/src/integrations/target.ts +208 -0
- package/src/integrations/writer.ts +49 -11
- package/src/lab/conformance/fixture-provider.ts +5 -0
- package/src/lib/browser-launch-notice.ts +59 -0
- package/src/lib/bun-runtime.ts +6 -2
- package/src/lib/debug.ts +40 -0
- package/src/lib/open-url.ts +51 -7
- package/src/lib/package-tree-integrity.ts +2 -1
- package/src/lib/package-version.ts +8 -0
- package/src/lib/provider-egress.ts +310 -0
- package/src/lib/provider-outbound.ts +59 -14
- package/src/lib/proxy-env.ts +82 -7
- package/src/lib/request-execution-budget.ts +72 -0
- package/src/lib/request-failure-attribution.ts +183 -0
- package/src/lib/request-failure-model.ts +236 -0
- package/src/lib/request-resend-gate.ts +138 -0
- package/src/lib/standalone.ts +16 -0
- package/src/lib/upstream-retry.ts +167 -16
- package/src/lib/winsw.ts +2 -2
- package/src/oauth/index.ts +24 -1
- package/src/oauth/login-cli.ts +80 -29
- package/src/providers/api-key-resolve.ts +133 -0
- package/src/providers/api-key-selection.ts +5 -1
- package/src/providers/key-failover.ts +31 -1
- package/src/providers/key-store.ts +34 -110
- package/src/providers/model-rename-fields.ts +147 -0
- package/src/providers/model-rename-migration.ts +124 -37
- package/src/providers/quota/vendor-probes-key.ts +37 -22
- package/src/providers/reasoning-metadata.ts +43 -18
- package/src/providers/registry/entries-core.ts +9 -4
- package/src/providers/registry/entries-extended.ts +29 -4
- package/src/providers/registry/model-seeds.ts +47 -10
- package/src/providers/xai-transport.ts +12 -1
- package/src/reasoning-effort.ts +8 -0
- package/src/responses/function-call-compat.ts +38 -1
- package/src/responses/inline-document.ts +65 -0
- package/src/responses/input-media.ts +42 -8
- package/src/responses/muse-tool-name-alias.ts +19 -0
- package/src/responses/parser-content.ts +8 -2
- package/src/responses/parser-tools.ts +3 -0
- package/src/responses/parser.ts +3 -1
- package/src/responses/schema.ts +3 -0
- package/src/router.ts +17 -2
- package/src/server/admission-model-scope.ts +219 -0
- package/src/server/audio-live.ts +9 -3
- package/src/server/audio-upstream.ts +18 -0
- package/src/server/auth-cors.ts +26 -0
- package/src/server/chat-completions.ts +55 -2
- package/src/server/chat-native.ts +19 -4
- package/src/server/claude-messages.ts +55 -17
- package/src/server/grok-responses-snapshot-repair.ts +113 -11
- package/src/server/gui-freshness.ts +103 -0
- package/src/server/gui-static.ts +7 -9
- package/src/server/images.ts +59 -6
- package/src/server/index/claude-intercept-lifecycle.ts +49 -0
- package/src/server/index/serve-options.ts +56 -10
- package/src/server/index/spend-ledger-lifecycle.ts +34 -8
- package/src/server/index/startup-warnings.ts +24 -0
- package/src/server/index.ts +21 -28
- package/src/server/lifecycle.ts +4 -4
- package/src/server/live-call-bindings.ts +6 -0
- package/src/server/live.ts +88 -3
- package/src/server/management/agent-settings-routes.ts +121 -36
- package/src/server/management/companion-routes.ts +77 -0
- package/src/server/management/logs-usage-routes.ts +19 -0
- package/src/server/management/native-integration-routes.ts +103 -6
- package/src/server/management/oauth-account-routes.ts +45 -7
- package/src/server/management/route-registry.ts +6 -0
- package/src/server/management/shared.ts +18 -1
- package/src/server/management/usage-timeline-routes.ts +44 -0
- package/src/server/management-api.ts +8 -9
- package/src/server/proxy-liveness.ts +75 -0
- package/src/server/relay.ts +19 -2
- package/src/server/request-log-failure-attribution.ts +99 -0
- package/src/server/request-log.ts +114 -0
- package/src/server/request-metrics.ts +92 -30
- package/src/server/responses/codex-ws-wire.ts +34 -8
- package/src/server/responses/combo-stream-preflight.ts +168 -6
- package/src/server/responses/compact.ts +11 -0
- package/src/server/responses/core-opaque-recovery.ts +90 -0
- package/src/server/responses/fetch-helpers.ts +124 -8
- package/src/server/responses/input-admission.ts +10 -0
- package/src/server/responses/passthrough-delivery.ts +14 -1
- package/src/server/responses/passthrough-dispatch.ts +179 -35
- package/src/server/responses/passthrough-error.ts +27 -8
- package/src/server/responses/request-prepare.ts +42 -1
- package/src/server/responses/request-send-budget.ts +12 -0
- package/src/server/responses/request-transport.ts +24 -4
- package/src/server/responses/reset-replay.ts +108 -0
- package/src/server/responses-request-tool-scope.ts +214 -0
- package/src/server/responses-undeclared-tool-guard.ts +4 -1
- package/src/server/search.ts +25 -1
- package/src/server/usage-ledger-retention.ts +73 -0
- package/src/service/cli.ts +48 -2
- package/src/service/health.ts +3 -2
- package/src/service/install-state-contract.d.mts +27 -0
- package/src/service/install-state-contract.mjs +34 -0
- package/src/service/launchd.ts +1 -1
- package/src/service/orchestration.ts +2 -4
- package/src/service/ownership-compatibility.ts +164 -0
- package/src/service/ownership-mutation-lease.d.mts +32 -0
- package/src/service/ownership-mutation-lease.mjs +211 -0
- package/src/service/repair.ts +45 -1
- package/src/service/state-lock.ts +269 -0
- package/src/service/state-record.d.mts +36 -0
- package/src/service/state-record.mjs +138 -0
- package/src/service/state.ts +582 -68
- package/src/service/windows-taskxml.ts +11 -10
- package/src/service.ts +7 -3
- package/src/tray/windows-tray.ps1 +1 -1
- package/src/types/config.ts +37 -0
- package/src/types/provider.ts +73 -0
- package/src/types/request.ts +28 -2
- package/src/types/tools.ts +19 -0
- package/src/types.ts +3 -0
- package/src/update/index.ts +207 -63
- package/src/update/job.ts +9 -5
- package/src/update/ownership-transaction.ts +47 -0
- package/src/update/restart-ownership.ts +54 -0
- package/src/update/runtime-ownership.d.mts +40 -0
- package/src/update/runtime-ownership.mjs +122 -0
- package/src/usage/attempt-delivery.ts +198 -0
- package/src/usage/cache-diagnostic.ts +305 -0
- package/src/usage/failure-fingerprint.ts +118 -0
- package/src/usage/failure-projection-cache.ts +174 -0
- package/src/usage/failure-projection.ts +174 -0
- package/src/usage/ledger-retention.ts +165 -0
- package/src/usage/log.ts +126 -79
- package/src/usage/request-outcome.ts +150 -0
- package/src/usage/retention-contract.ts +28 -0
- package/src/usage/summary.ts +2 -2
- package/src/usage/telemetry-contract.ts +237 -0
- package/src/usage/timeline.ts +236 -0
- package/src/web-search/alpha-search.ts +21 -1
- package/gui/dist/assets/index-BTuCbqQd.css +0 -1
- package/gui/dist/assets/index-DoBVdPHP.js +0 -134
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether one leg of a logical request may send it again, asked in the #5266 vocabulary.
|
|
3
|
+
*
|
|
4
|
+
* Two pull requests arrived at this question from opposite sides of the response head. #4942
|
|
5
|
+
* asked it for a connection that died before any head; #4989 asked it for an SSE body that
|
|
6
|
+
* died after the head while carrying only control events. Both are the same row of the stage
|
|
7
|
+
* table: a stage the caller observed nothing at, with a cause that cannot prove the origin did
|
|
8
|
+
* not run the turn. `resendPermission` answers `refused-ambiguous` for both, and
|
|
9
|
+
* request-failure-model.ts already names the only thing that may override that answer -- a
|
|
10
|
+
* narrowly scoped recovery a maintainer opted into and bounded.
|
|
11
|
+
*
|
|
12
|
+
* One override, not two. The reason this module exists rather than a boolean in each caller is
|
|
13
|
+
* that a request which resets before the head and again after it would otherwise buy a
|
|
14
|
+
* replacement send on each side, and the second one is exactly the duplicated inference the
|
|
15
|
+
* refusal exists to prevent. The allowance is claimed HERE, at the moment of authorisation, so
|
|
16
|
+
* a caller cannot ask without paying.
|
|
17
|
+
*
|
|
18
|
+
* MUST stay a leaf. It imports the vocabulary as values and everything else as types, so it
|
|
19
|
+
* reaches no request path that did not already have it.
|
|
20
|
+
*/
|
|
21
|
+
import {
|
|
22
|
+
causeForRecoveryKind,
|
|
23
|
+
permitsResend,
|
|
24
|
+
resendPermission,
|
|
25
|
+
resendSendClass,
|
|
26
|
+
type RequestFailureCause,
|
|
27
|
+
type RequestFailureStage,
|
|
28
|
+
type ResendPermission,
|
|
29
|
+
} from "./request-failure-model";
|
|
30
|
+
import type { SendClass } from "./request-execution-budget";
|
|
31
|
+
import type { AttemptRecoveryKind } from "../usage/telemetry-contract";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Why an authorisation was refused.
|
|
35
|
+
*
|
|
36
|
+
* The three ambiguous members are separate because they need different operator responses: no
|
|
37
|
+
* policy is a configuration choice, a request the proxy cannot judge is a property of the turn,
|
|
38
|
+
* and a spent allowance means the replacement already went somewhere else in this request.
|
|
39
|
+
*/
|
|
40
|
+
export const RESEND_REFUSALS = Object.freeze([
|
|
41
|
+
/** The caller already observed output, an effect, or the delivered answer. */
|
|
42
|
+
"committed",
|
|
43
|
+
/** Identical bytes would get the identical answer. */
|
|
44
|
+
"futile",
|
|
45
|
+
/** The origin's execution state is unknown and no operator policy overrides that. */
|
|
46
|
+
"ambiguous-no-policy",
|
|
47
|
+
/** An operator policy exists, but this request's second send could do more than re-infer. */
|
|
48
|
+
"ambiguous-request-not-replayable",
|
|
49
|
+
/** The operator policy exists and its replacement was already spent by this request. */
|
|
50
|
+
"ambiguous-allowance-spent",
|
|
51
|
+
] as const);
|
|
52
|
+
|
|
53
|
+
export type ResendRefusal = typeof RESEND_REFUSALS[number];
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The operator-granted replacement for ONE logical request.
|
|
57
|
+
*
|
|
58
|
+
* `claim` is the single counter both stages draw on. It is a method rather than a number
|
|
59
|
+
* because the holder is the request's send ledger, which a combo child shares with its parent;
|
|
60
|
+
* a number passed down per leg is what let each leg hold its own.
|
|
61
|
+
*/
|
|
62
|
+
export interface AmbiguousResendAllowance {
|
|
63
|
+
/** True when a second send of this request's body can only repeat the inference. */
|
|
64
|
+
readonly selfContained: boolean;
|
|
65
|
+
/** Spend one replacement. False once the request has none left. */
|
|
66
|
+
claim(): boolean;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface ResendDecisionBase {
|
|
70
|
+
readonly stage: RequestFailureStage;
|
|
71
|
+
readonly cause: RequestFailureCause;
|
|
72
|
+
readonly permission: ResendPermission;
|
|
73
|
+
/**
|
|
74
|
+
* The recovery this send will be recorded as, when the caller asked in those terms. Carried
|
|
75
|
+
* back rather than re-chosen at the call site: the cause was derived from it, so recording a
|
|
76
|
+
* different kind would describe the send by a reason the gate never evaluated.
|
|
77
|
+
*/
|
|
78
|
+
readonly recoveryKind?: AttemptRecoveryKind;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export type ResendDecision =
|
|
82
|
+
| ResendDecisionBase & {
|
|
83
|
+
readonly allowed: true;
|
|
84
|
+
/** Which request-wide send class funds it, or null when the cause funds no resend. */
|
|
85
|
+
readonly sendClass: SendClass | null;
|
|
86
|
+
/** True when the table refused and an operator allowance was spent to proceed. */
|
|
87
|
+
readonly spentOperatorAllowance: boolean;
|
|
88
|
+
}
|
|
89
|
+
| ResendDecisionBase & { readonly allowed: false; readonly refusal: ResendRefusal };
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Decide whether this proxy may send the request again after a failure at `stage` caused by
|
|
93
|
+
* `cause`, spending `allowance` when the table refuses only because the upstream state is
|
|
94
|
+
* unknown.
|
|
95
|
+
*
|
|
96
|
+
* The allowance is touched on exactly one path: a decision the table would otherwise refuse as
|
|
97
|
+
* ambiguous, for a request whose body the caller has judged replayable. A committed or futile
|
|
98
|
+
* failure never reaches it, so a turn that already produced output cannot quietly drain the
|
|
99
|
+
* replacement a later ambiguous reset would have been entitled to.
|
|
100
|
+
*/
|
|
101
|
+
export function authorizeResend(
|
|
102
|
+
stage: RequestFailureStage,
|
|
103
|
+
cause: RequestFailureCause,
|
|
104
|
+
allowance?: AmbiguousResendAllowance,
|
|
105
|
+
recoveryKind?: AttemptRecoveryKind,
|
|
106
|
+
): ResendDecision {
|
|
107
|
+
const permission = resendPermission(stage, cause);
|
|
108
|
+
const base = { stage, cause, permission, ...(recoveryKind ? { recoveryKind } : {}) };
|
|
109
|
+
if (permitsResend(permission)) {
|
|
110
|
+
return { ...base, allowed: true, sendClass: resendSendClass(cause), spentOperatorAllowance: false };
|
|
111
|
+
}
|
|
112
|
+
if (permission === "refused-committed") return { ...base, allowed: false, refusal: "committed" };
|
|
113
|
+
if (permission === "refused-futile") return { ...base, allowed: false, refusal: "futile" };
|
|
114
|
+
if (!allowance) return { ...base, allowed: false, refusal: "ambiguous-no-policy" };
|
|
115
|
+
if (!allowance.selfContained) {
|
|
116
|
+
return { ...base, allowed: false, refusal: "ambiguous-request-not-replayable" };
|
|
117
|
+
}
|
|
118
|
+
// Claimed last, and only here. Asking earlier would spend the request's one replacement on a
|
|
119
|
+
// question whose answer was already no.
|
|
120
|
+
if (!allowance.claim()) return { ...base, allowed: false, refusal: "ambiguous-allowance-spent" };
|
|
121
|
+
return { ...base, allowed: true, sendClass: resendSendClass(cause), spentOperatorAllowance: true };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The same decision, asked in terms of the recovery this proxy will RECORD for the send.
|
|
126
|
+
*
|
|
127
|
+
* Deriving the cause from the recorded kind is what keeps the log honest: the reason an
|
|
128
|
+
* operator reads beside a send count is the reason the gate weighed, because it is the same
|
|
129
|
+
* value. A call site that recorded one kind and reasoned about another is how a send count
|
|
130
|
+
* stops meaning anything.
|
|
131
|
+
*/
|
|
132
|
+
export function authorizeResendForRecovery(
|
|
133
|
+
stage: RequestFailureStage,
|
|
134
|
+
kind: AttemptRecoveryKind,
|
|
135
|
+
allowance?: AmbiguousResendAllowance,
|
|
136
|
+
): ResendDecision {
|
|
137
|
+
return authorizeResend(stage, causeForRecoveryKind(kind), allowance, kind);
|
|
138
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { realpathSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
|
|
4
|
+
/** Compiled Bun binaries expose their bundled module tree through the `$bunfs` marker. */
|
|
5
|
+
export function isStandaloneBinary(): boolean {
|
|
6
|
+
return isStandaloneModuleUrl(import.meta.url);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function isStandaloneModuleUrl(url: string): boolean {
|
|
10
|
+
return url.includes("/$bunfs/") || /^file:\/\/\/[A-Za-z]:\/~BUN\//.test(url);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Directory containing the compiled executable and its copied runtime assets. */
|
|
14
|
+
export function standaloneRoot(): string {
|
|
15
|
+
return dirname(realpathSync(process.execPath));
|
|
16
|
+
}
|
|
@@ -8,13 +8,15 @@
|
|
|
8
8
|
* becomes a terminal, non-replayable response unless the operation is explicitly safe.
|
|
9
9
|
*
|
|
10
10
|
* Deliberately narrow: timeouts, aborts, ECONNREFUSED/DNS/TLS failures, and HTTP error
|
|
11
|
-
* statuses (returned as Response, never thrown) are NOT retried.
|
|
12
|
-
* out of scope
|
|
11
|
+
* statuses (returned as Response, never thrown) are NOT retried. A reset after the response
|
|
12
|
+
* head is out of scope here, because the response has already resolved by then; the Responses
|
|
13
|
+
* transport asks the same question at that stage through the shared resend gate.
|
|
13
14
|
*
|
|
14
15
|
* MUST stay a leaf module: imports nothing from server.ts or adapters (kiro-retry imports
|
|
15
16
|
* the shared abort helpers from here).
|
|
16
17
|
*/
|
|
17
18
|
import { clearableDeadline } from "./abort";
|
|
19
|
+
import { redactSecretString } from "./redact";
|
|
18
20
|
|
|
19
21
|
/**
|
|
20
22
|
* Responses the origin may already be executing. RFC 9110 §9.2.2 forbids an intermediary
|
|
@@ -96,6 +98,55 @@ export function isReplayRefusalCode(code: unknown): boolean {
|
|
|
96
98
|
/** Client-facing status for {@link UPSTREAM_RESET_REPLAY_REFUSED_CODE}. */
|
|
97
99
|
export const REPLAY_REFUSED_STATUS = 429;
|
|
98
100
|
|
|
101
|
+
/**
|
|
102
|
+
* The header every surface attaches to a replay refusal, and its only accepted value.
|
|
103
|
+
*
|
|
104
|
+
* Dropping `Retry-After` is necessary and not sufficient. The Stainless-generated clients --
|
|
105
|
+
* `openai` and `anthropic` for both Python and Node, which is what most callers of this proxy
|
|
106
|
+
* actually are -- decide from a status table (408, 409, 429 and every 5xx) and compute their own
|
|
107
|
+
* backoff when no wait is named, so a 429 with no header is still resent. `x-should-retry` is
|
|
108
|
+
* the one signal each of them reads BEFORE that table, and `"false"` is the exact string they
|
|
109
|
+
* compare against.
|
|
110
|
+
*/
|
|
111
|
+
export const REPLAY_REFUSAL_NO_RETRY_HEADER = "x-should-retry";
|
|
112
|
+
export const REPLAY_REFUSAL_NO_RETRY_VALUE = "false";
|
|
113
|
+
|
|
114
|
+
/** Spreadable form for the surfaces that build their headers as an object literal. */
|
|
115
|
+
export const REPLAY_REFUSAL_CLIENT_HEADERS: Readonly<Record<string, string>> = Object.freeze({
|
|
116
|
+
[REPLAY_REFUSAL_NO_RETRY_HEADER]: REPLAY_REFUSAL_NO_RETRY_VALUE,
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Apply the one client-facing retry policy a refusal carries: no wait, and no automatic resend.
|
|
121
|
+
*
|
|
122
|
+
* Kept as a single function rather than two rules each surface repeats, because the two halves
|
|
123
|
+
* are only correct together -- a surface that removed the wait but not the suppression still
|
|
124
|
+
* hands a retrying client a turn it may already have run.
|
|
125
|
+
*/
|
|
126
|
+
export function applyReplayRefusalClientHeaders(headers: Headers): void {
|
|
127
|
+
headers.delete("retry-after");
|
|
128
|
+
headers.set(REPLAY_REFUSAL_NO_RETRY_HEADER, REPLAY_REFUSAL_NO_RETRY_VALUE);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Mark a response that re-wraps a refusal as the same refusal.
|
|
133
|
+
*
|
|
134
|
+
* The verdict has to be a property of the result the surfaces pass around, because the thing it
|
|
135
|
+
* would otherwise be read from is the status, and 429 is exactly what a refusal and a real rate
|
|
136
|
+
* limit have in common. Every formatter between the helper that made the refusal and the client
|
|
137
|
+
* builds a new Response, so each of them restates the verdict rather than dropping it.
|
|
138
|
+
*/
|
|
139
|
+
export function retainReplayRefusal<T extends Response>(response: T): T {
|
|
140
|
+
markResponseNonReplayable(response);
|
|
141
|
+
markReplayRefusalResponse(response);
|
|
142
|
+
return response;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Carry the verdict from a response onto the one that replaces it. */
|
|
146
|
+
export function carryReplayRefusal<T extends Response>(source: Response, rewrapped: T): T {
|
|
147
|
+
return isReplayRefusalResponse(source) ? retainReplayRefusal(rewrapped) : rewrapped;
|
|
148
|
+
}
|
|
149
|
+
|
|
99
150
|
// 1 initial + 2 retries: the pool may hold more than one stale socket.
|
|
100
151
|
const RESET_RETRY_MAX_ATTEMPTS = 3;
|
|
101
152
|
const RESET_RETRY_BASE_DELAY_MS = 150;
|
|
@@ -421,6 +472,20 @@ export interface ResetRetryOptions {
|
|
|
421
472
|
* uncounted but UNCOUNTABLE: the callback existed on a type those call sites never reach.
|
|
422
473
|
*/
|
|
423
474
|
onSendsConsumed?: (sends: number) => void;
|
|
475
|
+
/**
|
|
476
|
+
* Spend one operator-granted replacement for a pre-header reset this helper would otherwise
|
|
477
|
+
* refuse. Absent means no operator policy, which is the fail-closed answer.
|
|
478
|
+
*
|
|
479
|
+
* A callback rather than a count, and the difference is the whole point. A count handed to
|
|
480
|
+
* each leg of a request is a count each leg holds: a rotation leg, a refresh leg and a
|
|
481
|
+
* same-target 429 leg carry the same turn, so three numbers is three replacements of one
|
|
482
|
+
* possibly-executed inference. The callback draws on ONE allowance held by the logical
|
|
483
|
+
* request, which the post-header protocol gate draws on too.
|
|
484
|
+
*
|
|
485
|
+
* It never widens the send budget. A claimed replacement still has to fit inside
|
|
486
|
+
* `attempts`, exactly like every other send this leg makes.
|
|
487
|
+
*/
|
|
488
|
+
claimAmbiguousResend?: () => boolean;
|
|
424
489
|
}
|
|
425
490
|
|
|
426
491
|
export interface TransientRetryOptions extends ResetRetryOptions {
|
|
@@ -494,6 +559,24 @@ export function applyUpstreamRecoveryInit<T extends RequestInit>(
|
|
|
494
559
|
return { ...init, headers, keepalive: false };
|
|
495
560
|
}
|
|
496
561
|
|
|
562
|
+
/**
|
|
563
|
+
* The refusal this proxy returns for an ambiguous reset it will not replace. The WeakSet
|
|
564
|
+
* markers protect in-process recovery and the code survives JSON re-wrapping, so a combo or
|
|
565
|
+
* account-recovery layer downstream cannot read it as a replayable upstream fault. The raw
|
|
566
|
+
* exception is never exposed: it can carry credentials or request data.
|
|
567
|
+
*/
|
|
568
|
+
export function replayRefusalResponse(): Response {
|
|
569
|
+
const response = new Response(JSON.stringify({ error: {
|
|
570
|
+
type: "upstream_error",
|
|
571
|
+
code: UPSTREAM_RESET_REPLAY_REFUSED_CODE,
|
|
572
|
+
message: "The upstream connection closed before a response was received. The request may already have been processed; automatic replay was stopped.",
|
|
573
|
+
} }), {
|
|
574
|
+
status: REPLAY_REFUSED_STATUS,
|
|
575
|
+
headers: { "content-type": "application/json", ...REPLAY_REFUSAL_CLIENT_HEADERS },
|
|
576
|
+
});
|
|
577
|
+
return retainReplayRefusal(response);
|
|
578
|
+
}
|
|
579
|
+
|
|
497
580
|
/**
|
|
498
581
|
* Run `doFetch` within one send budget. Connection-reset-shaped rejections are
|
|
499
582
|
* terminal by default; only an explicitly replay-safe operation receives reset retries
|
|
@@ -511,6 +594,10 @@ export async function fetchWithResetRetry(
|
|
|
511
594
|
if (attempts === 0) throw new SendBudgetExhaustedError(opts.label);
|
|
512
595
|
let lastError: unknown;
|
|
513
596
|
let sawReset = false;
|
|
597
|
+
// True once this leg has spent the request's operator allowance. From that point the leg can
|
|
598
|
+
// only settle as the refusal: a second send of a possibly-executed turn is already out, and
|
|
599
|
+
// handing the client anything it would retry compounds it.
|
|
600
|
+
let spentOperatorReplacement = false;
|
|
514
601
|
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
515
602
|
if (opts.abortSignal?.aborted) throw abortError(opts.abortSignal);
|
|
516
603
|
// Reported before the await, one physical send at a time: a send that rejects has still
|
|
@@ -522,31 +609,37 @@ export async function fetchWithResetRetry(
|
|
|
522
609
|
} catch (err) {
|
|
523
610
|
if (opts.abortSignal?.aborted) throw err;
|
|
524
611
|
if (!isConnectionResetError(err)) {
|
|
612
|
+
// Whatever ended the leg, an operator replacement already went out, so the first send
|
|
613
|
+
// may have run the turn. Settle it as the refusal instead of throwing into a caller
|
|
614
|
+
// whose transport-failure path answers with a client-retryable 502.
|
|
615
|
+
if (spentOperatorReplacement) return replayRefusalResponse();
|
|
525
616
|
// A reset that already reached the origin is credential-visible
|
|
526
617
|
// evidence: keep it attached so the terminal rejection cannot be
|
|
527
618
|
// downgraded to the pre-connection neutral class (#914 review).
|
|
528
619
|
if (sawReset) throw new UpstreamRetryEvidenceError([], err, true);
|
|
529
620
|
throw err;
|
|
530
621
|
}
|
|
531
|
-
if (opts.replaySafe
|
|
532
|
-
//
|
|
622
|
+
if (opts.replaySafe === true) {
|
|
623
|
+
// Repeating this operation cannot duplicate anything, so an exhausted budget rethrows
|
|
624
|
+
// and the caller's own error path takes over.
|
|
625
|
+
if (attempt === attempts - 1) throw err;
|
|
626
|
+
} else {
|
|
627
|
+
// The stage table refuses an ambiguous pre-header reset. The only thing that overrides
|
|
628
|
+
// it is an operator allowance, and claiming it here is what keeps the grant single --
|
|
629
|
+
// the post-header protocol gate spends the same counter for the same logical request.
|
|
630
|
+
// Return evidence rather than throwing a generic transport error: outer catches
|
|
533
631
|
// otherwise turn it into a replayable 502 and a combo/account recovery resends it.
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
code: UPSTREAM_RESET_REPLAY_REFUSED_CODE,
|
|
539
|
-
message: "The upstream connection closed before a response was received. The request may already have been processed; automatic replay was stopped.",
|
|
540
|
-
} }), { status: REPLAY_REFUSED_STATUS, headers: { "content-type": "application/json" } });
|
|
541
|
-
markResponseNonReplayable(response);
|
|
542
|
-
markReplayRefusalResponse(response);
|
|
543
|
-
return response;
|
|
632
|
+
if (attempt + 1 >= attempts || opts.claimAmbiguousResend?.() !== true) {
|
|
633
|
+
return replayRefusalResponse();
|
|
634
|
+
}
|
|
635
|
+
spentOperatorReplacement = true;
|
|
544
636
|
}
|
|
545
|
-
if (attempt === attempts - 1) throw err;
|
|
546
637
|
sawReset = true;
|
|
547
638
|
lastError = err;
|
|
548
639
|
console.warn(
|
|
549
|
-
`[upstream-retry] connection reset${opts.label ? ` (${opts.label})` : ""} —
|
|
640
|
+
`[upstream-retry] connection reset${opts.label ? ` (${opts.label})` : ""} — ${
|
|
641
|
+
spentOperatorReplacement ? "replacing" : "retrying"
|
|
642
|
+
} (${attempt + 2}/${attempts})`,
|
|
550
643
|
);
|
|
551
644
|
await sleepWithAbort(retryBackoffDelayMs(attempt, {
|
|
552
645
|
baseDelayMs: RESET_RETRY_BASE_DELAY_MS,
|
|
@@ -661,3 +754,61 @@ export async function fetchWithTransientRetry(
|
|
|
661
754
|
opts.onSendsConsumed?.(sent);
|
|
662
755
|
}
|
|
663
756
|
}
|
|
757
|
+
|
|
758
|
+
export type ProtocolSafeRefetch = (signal?: AbortSignal) => Promise<Response>;
|
|
759
|
+
|
|
760
|
+
export interface ProtocolSafeRefetchOptions extends ResetRetryOptions {
|
|
761
|
+
/** The replacement must match the response contract already selected for the client. */
|
|
762
|
+
acceptResponse?: (response: Response) => boolean;
|
|
763
|
+
/**
|
|
764
|
+
* Spend the logical request's allowance, immediately before the replacement send.
|
|
765
|
+
*
|
|
766
|
+
* Asked here and not earlier so a failure this helper would refuse on its own terms -- a
|
|
767
|
+
* non-reset error, a cancelled caller, a spent send budget -- cannot drain the one
|
|
768
|
+
* replacement a later ambiguous reset was entitled to. False refuses the replacement.
|
|
769
|
+
*/
|
|
770
|
+
authorize?: () => boolean;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/**
|
|
774
|
+
* Attempt ONE caller-authorized replacement of a stream that died after the response head.
|
|
775
|
+
*
|
|
776
|
+
* The caller owns the proof that nothing was observed -- it comes from protocol inspection,
|
|
777
|
+
* not from this module -- and owns the physical-send budget. What lives here is the part that
|
|
778
|
+
* is easy to get wrong: a replacement is only usable if it is a fresh, unlocked, unread body
|
|
779
|
+
* that matches the contract already promised to the client, and anything else has to be
|
|
780
|
+
* cancelled and the original failure preserved.
|
|
781
|
+
*/
|
|
782
|
+
export async function refetchAfterProtocolSafeReset(
|
|
783
|
+
doFetch: ProtocolSafeRefetch,
|
|
784
|
+
err: unknown,
|
|
785
|
+
opts: ProtocolSafeRefetchOptions = {},
|
|
786
|
+
): Promise<Response | null> {
|
|
787
|
+
if (!isConnectionResetError(err) || opts.abortSignal?.aborted || opts.attempts === 0) return null;
|
|
788
|
+
const label = opts.label
|
|
789
|
+
? " (" + redactSecretString(opts.label).replace(/[\r\n\u0000-\u001f\u007f]/g, "").slice(0, 128) + ")"
|
|
790
|
+
: "";
|
|
791
|
+
if (opts.authorize && !opts.authorize()) {
|
|
792
|
+
console.warn("[upstream-retry] post-header reset replacement refused" + label + "; preserving original stream error");
|
|
793
|
+
return null;
|
|
794
|
+
}
|
|
795
|
+
let replacement: Response;
|
|
796
|
+
try {
|
|
797
|
+
replacement = await doFetch(opts.abortSignal);
|
|
798
|
+
} catch {
|
|
799
|
+
console.warn("[upstream-retry] protocol-safe refetch failed" + label + "; preserving original stream error");
|
|
800
|
+
return null;
|
|
801
|
+
}
|
|
802
|
+
const body = replacement.body;
|
|
803
|
+
let accepted = !opts.abortSignal?.aborted && replacement.ok && body !== null
|
|
804
|
+
&& !replacement.bodyUsed && !body.locked && !isNonReplayableResponse(replacement);
|
|
805
|
+
try { if (accepted && opts.acceptResponse) accepted = opts.acceptResponse(replacement); }
|
|
806
|
+
catch { accepted = false; }
|
|
807
|
+
if (!accepted || opts.abortSignal?.aborted || body?.locked) {
|
|
808
|
+
try { void body?.cancel().catch(() => {}); } catch { /* already locked or closed */ }
|
|
809
|
+
console.warn("[upstream-retry] protocol-safe refetch rejected" + label + "; preserving original stream error");
|
|
810
|
+
return null;
|
|
811
|
+
}
|
|
812
|
+
console.warn("[upstream-retry] pre-output Responses reset" + label + "; using one replacement stream");
|
|
813
|
+
return replacement;
|
|
814
|
+
}
|
package/src/lib/winsw.ts
CHANGED
|
@@ -72,7 +72,7 @@ export interface WinswEntry {
|
|
|
72
72
|
bun: string;
|
|
73
73
|
/** Provenance of `bun`, resolved together with it so the two can never disagree. */
|
|
74
74
|
bunRuntimeSource: BunRuntimeSource;
|
|
75
|
-
cli: string;
|
|
75
|
+
cli: string | null;
|
|
76
76
|
}
|
|
77
77
|
|
|
78
78
|
/**
|
|
@@ -118,7 +118,7 @@ export function buildWinswXml(entry: WinswEntry, env: NodeJS.ProcessEnv = proces
|
|
|
118
118
|
<name>OpenCodex Proxy (native)</name>
|
|
119
119
|
<description>OpenCodex proxy running as a native Windows service (windowless, starts at boot).</description>
|
|
120
120
|
<executable>${xmlEscape(entry.bun)}</executable>
|
|
121
|
-
<arguments>${xmlEscape(`"${entry.cli}" start --port ${safeListenPort}`)}</arguments>
|
|
121
|
+
<arguments>${xmlEscape(`${entry.cli ? `"${entry.cli}" ` : ""}start --port ${safeListenPort}`)}</arguments>
|
|
122
122
|
${envLines.join("\n")}
|
|
123
123
|
<logpath>${xmlEscape(winswLogDir())}</logpath>
|
|
124
124
|
<log mode="roll-by-size">
|
package/src/oauth/index.ts
CHANGED
|
@@ -1273,6 +1273,29 @@ const OAUTH_RECONCILE_FIELDS: (keyof OcxProviderConfig)[] = [
|
|
|
1273
1273
|
// existing rows through enrichProviderFromRegistry, which is fill-only and
|
|
1274
1274
|
// preserves explicit saved values.
|
|
1275
1275
|
|
|
1276
|
+
/**
|
|
1277
|
+
* Output-budget fields an OAuth preset may refresh but must never erase.
|
|
1278
|
+
*
|
|
1279
|
+
* These stay on the reconcile list so a preset that does declare a budget still
|
|
1280
|
+
* refreshes the saved row. What changes is the other branch: when the preset
|
|
1281
|
+
* declares nothing, the operator's value survives instead of being deleted.
|
|
1282
|
+
*
|
|
1283
|
+
* Without that, the fields behaved as if they could not be configured at all.
|
|
1284
|
+
* No OAuth preset seeds either one, so the delete branch was the only branch
|
|
1285
|
+
* these two ever took, and a hand-edited `defaultMaxOutputTokens` was gone
|
|
1286
|
+
* before the first turn of the next startup — leaving the adapter's own
|
|
1287
|
+
* fallback as the only reachable output cap (#5190).
|
|
1288
|
+
*
|
|
1289
|
+
* Scoped to the output budget on purpose. The input side (`contextWindow`,
|
|
1290
|
+
* `modelContextWindows`) describes what the account's models are, which the
|
|
1291
|
+
* preset and live discovery do own; an output budget is a spend decision the
|
|
1292
|
+
* operator makes.
|
|
1293
|
+
*/
|
|
1294
|
+
const OAUTH_PRESERVE_WHEN_PRESET_UNSET: ReadonlySet<keyof OcxProviderConfig> = new Set([
|
|
1295
|
+
"defaultMaxOutputTokens",
|
|
1296
|
+
"modelMaxOutputTokens",
|
|
1297
|
+
]);
|
|
1298
|
+
|
|
1276
1299
|
const GOOGLE_ANTIGRAVITY_PROVIDER = "google-antigravity";
|
|
1277
1300
|
const GOOGLE_ANTIGRAVITY_LIVE_DISCOVERY_VERSION = 2 as const;
|
|
1278
1301
|
|
|
@@ -1312,7 +1335,7 @@ function applyOAuthPresetCatalog(
|
|
|
1312
1335
|
if (JSON.stringify(provider[field]) === JSON.stringify(preset[field])) continue;
|
|
1313
1336
|
if (preset[field] !== undefined) {
|
|
1314
1337
|
provider[field] = cloneProviderField(preset[field]) as never;
|
|
1315
|
-
} else {
|
|
1338
|
+
} else if (!OAUTH_PRESERVE_WHEN_PRESET_UNSET.has(field)) {
|
|
1316
1339
|
delete provider[field];
|
|
1317
1340
|
}
|
|
1318
1341
|
}
|
package/src/oauth/login-cli.ts
CHANGED
|
@@ -2,6 +2,7 @@ import * as readline from "node:readline";
|
|
|
2
2
|
import { modelSelectionGuidance } from "../cli/model-selection-guidance";
|
|
3
3
|
import { initializeProviderModelSelection } from "../providers/initial-model-selection";
|
|
4
4
|
import { openUrl } from "../lib/open-url";
|
|
5
|
+
import { createBrowserLaunchReport } from "../lib/browser-launch-notice";
|
|
5
6
|
import { loadConfig, saveConfig } from "../config";
|
|
6
7
|
import { findLiveProxy } from "../server/proxy-liveness";
|
|
7
8
|
import {
|
|
@@ -14,6 +15,41 @@ import type { OcxConfig, OcxProviderConfig } from "../types";
|
|
|
14
15
|
import { configuredAdminToken } from "../lib/admin-secrets";
|
|
15
16
|
import { codexAccountNamespaceProviderCollisionError } from "../codex/account-namespace-match";
|
|
16
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Seams a test drives in place of a browser, a terminal and a real provider. Production passes
|
|
20
|
+
* none of them.
|
|
21
|
+
*
|
|
22
|
+
* They exist because the thing worth proving here is an ORDER — that a browser which did not
|
|
23
|
+
* open is on screen before the question that assumes it did — and an order is only observable
|
|
24
|
+
* from something that records both events. Spawning a launcher and attaching to stdin to find
|
|
25
|
+
* that out would test the operating system instead.
|
|
26
|
+
*/
|
|
27
|
+
export interface LoginCliDeps {
|
|
28
|
+
runLogin?: typeof runLogin;
|
|
29
|
+
openUrl?: typeof openUrl;
|
|
30
|
+
warn?: (message: string) => void;
|
|
31
|
+
/** Ask one question, read one line. Defaults to a readline prompt that owns its own lifetime. */
|
|
32
|
+
ask?: (question: string) => Promise<string>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Run `body` with a line reader, creating and closing a real one only when the caller did not
|
|
37
|
+
* supply its own. A readline interface attaches to stdin, so building one that nothing will ask
|
|
38
|
+
* a question keeps the process alive for no reason.
|
|
39
|
+
*/
|
|
40
|
+
async function withPrompt<T>(
|
|
41
|
+
supplied: ((question: string) => Promise<string>) | undefined,
|
|
42
|
+
body: (ask: (question: string) => Promise<string>) => Promise<T>,
|
|
43
|
+
): Promise<T> {
|
|
44
|
+
if (supplied) return await body(supplied);
|
|
45
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
46
|
+
try {
|
|
47
|
+
return await body(question => new Promise<string>(resolve => rl.question(question, resolve)));
|
|
48
|
+
} finally {
|
|
49
|
+
rl.close();
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
17
53
|
const LIVE_RELOAD_PROVIDERS = new Set<string>([
|
|
18
54
|
...listOAuthProviders(),
|
|
19
55
|
...Object.keys(KEY_LOGIN_PROVIDERS),
|
|
@@ -83,7 +119,7 @@ export function loginUsageMessage(): string {
|
|
|
83
119
|
+ ` API-key login: ${Object.keys(KEY_LOGIN_PROVIDERS).join(", ")}`;
|
|
84
120
|
}
|
|
85
121
|
|
|
86
|
-
export async function handleLogin(provider?: string): Promise<void> {
|
|
122
|
+
export async function handleLogin(provider?: string, deps: LoginCliDeps = {}): Promise<void> {
|
|
87
123
|
const name = (provider ?? "").trim().toLowerCase();
|
|
88
124
|
// A removed provider id reached through its alias still logs in — the merged
|
|
89
125
|
// successor owns the flow. Warn rather than silently reroute so scripts and
|
|
@@ -91,30 +127,40 @@ export async function handleLogin(provider?: string): Promise<void> {
|
|
|
91
127
|
const alias = DEPRECATED_OAUTH_PROVIDER_ALIASES[name];
|
|
92
128
|
if (alias) {
|
|
93
129
|
console.error(`${name} is deprecated; logging in as ${alias}`);
|
|
94
|
-
return handleOAuthLogin(alias);
|
|
130
|
+
return handleOAuthLogin(alias, deps);
|
|
95
131
|
}
|
|
96
|
-
if (isPublicOAuthProvider(name)) return handleOAuthLogin(name);
|
|
97
|
-
if (isKeyLoginProvider(name)) return handleKeyLogin(name);
|
|
132
|
+
if (isPublicOAuthProvider(name)) return handleOAuthLogin(name, deps);
|
|
133
|
+
if (isKeyLoginProvider(name)) return handleKeyLogin(name, deps);
|
|
98
134
|
console.error(loginUsageMessage());
|
|
99
135
|
process.exit(1);
|
|
100
136
|
}
|
|
101
137
|
|
|
102
|
-
async function handleOAuthLogin(name: string): Promise<void> {
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
138
|
+
export async function handleOAuthLogin(name: string, deps: LoginCliDeps = {}): Promise<void> {
|
|
139
|
+
const login = deps.runLogin ?? runLogin;
|
|
140
|
+
const launch = deps.openUrl ?? openUrl;
|
|
141
|
+
const browser = createBrowserLaunchReport(deps.warn);
|
|
142
|
+
await withPrompt(deps.ask, async (ask) => {
|
|
143
|
+
await login(name, {
|
|
106
144
|
onAuth: ({ url, instructions }) => {
|
|
107
145
|
console.log(`\n🔐 Opening browser for ${name} login...\n${url}\n`);
|
|
108
146
|
if (instructions) console.log(instructions);
|
|
109
|
-
|
|
147
|
+
// The controller does not await onAuth, so the launcher's answer cannot be reported from
|
|
148
|
+
// here — this returns long before it arrives. It reports itself instead, and the one
|
|
149
|
+
// thing that could collide with it waits below (#5261).
|
|
150
|
+
browser.track(launch(url));
|
|
110
151
|
},
|
|
111
152
|
onProgress: (m) => console.log(` ${m}`),
|
|
112
|
-
onManualCodeInput: () =>
|
|
113
|
-
|
|
153
|
+
onManualCodeInput: async () => {
|
|
154
|
+
// "or wait for browser" is a lie if nothing opened, and a warning printed after readline
|
|
155
|
+
// has drawn the prompt lands on the line the user is typing on.
|
|
156
|
+
await browser.settled();
|
|
157
|
+
return await ask("Paste redirect URL or code (or wait for browser): ");
|
|
158
|
+
},
|
|
114
159
|
});
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
|
|
160
|
+
});
|
|
161
|
+
// A device or polling provider never prompts, so nothing above waited on the launcher. It is
|
|
162
|
+
// still owed an answer before this claims the login worked.
|
|
163
|
+
await browser.settled();
|
|
118
164
|
const reload = await notifyRunningProxyAfterOAuthLogin(name);
|
|
119
165
|
console.log(`\n✅ Logged in to ${name}. Try: ocx sync`);
|
|
120
166
|
for (const line of modelSelectionGuidance(name)) console.log(line);
|
|
@@ -192,8 +238,9 @@ export async function commitKeyLoginProvider(
|
|
|
192
238
|
return mergedProvider;
|
|
193
239
|
}
|
|
194
240
|
|
|
195
|
-
async function handleKeyLogin(name: string): Promise<void> {
|
|
241
|
+
export async function handleKeyLogin(name: string, deps: LoginCliDeps = {}): Promise<void> {
|
|
196
242
|
const def = KEY_LOGIN_PROVIDERS[name];
|
|
243
|
+
const launch = deps.openUrl ?? openUrl;
|
|
197
244
|
const preflightConfig = loadConfig();
|
|
198
245
|
const namespaceCollision = codexAccountNamespaceProviderCollisionError(preflightConfig.codexAccountNamespaces, name);
|
|
199
246
|
if (namespaceCollision) {
|
|
@@ -201,21 +248,25 @@ async function handleKeyLogin(name: string): Promise<void> {
|
|
|
201
248
|
process.exit(1);
|
|
202
249
|
}
|
|
203
250
|
console.log(`\n🔑 ${def.label} — opening ${def.dashboardUrl} so you can create/copy an API key...`);
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
//
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
const
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
251
|
+
const browser = createBrowserLaunchReport(deps.warn);
|
|
252
|
+
browser.track(launch(def.dashboardUrl));
|
|
253
|
+
// The next question asks for a key the user gets FROM that page, so a page that never opened
|
|
254
|
+
// has to be on screen before the question rather than underneath it (#5261).
|
|
255
|
+
await browser.settled();
|
|
256
|
+
const { key, baseUrl } = await withPrompt(deps.ask, async (ask) => {
|
|
257
|
+
const entered = (await ask(`Paste your ${def.label} API key: `)).trim();
|
|
258
|
+
// Template URL with placeholders needs resolution before saving.
|
|
259
|
+
let resolvedBaseUrl = def.baseUrl;
|
|
260
|
+
if (/\{[^}]*\}/.test(resolvedBaseUrl)) {
|
|
261
|
+
const resolved = (await ask(`Your endpoint URL (${resolvedBaseUrl}): `)).trim();
|
|
262
|
+
if (!resolved) {
|
|
263
|
+
console.error("A resolved URL is required — replace the {placeholder} with your actual value.");
|
|
264
|
+
process.exit(1);
|
|
265
|
+
}
|
|
266
|
+
resolvedBaseUrl = resolved;
|
|
215
267
|
}
|
|
216
|
-
baseUrl
|
|
217
|
-
}
|
|
218
|
-
rl.close();
|
|
268
|
+
return { key: entered, baseUrl: resolvedBaseUrl };
|
|
269
|
+
});
|
|
219
270
|
if (!key) {
|
|
220
271
|
console.error("No key entered.");
|
|
221
272
|
process.exit(1);
|