@bitkyc08/opencodex 2.60.0 → 2.61.0-preview.20260922

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.
Files changed (249) hide show
  1. package/AGENTS_INSTALL.md +64 -0
  2. package/README.md +28 -1
  3. package/bin/ocx.mjs +382 -209
  4. package/gui/dist/assets/App-E64Rzjap.js +50 -0
  5. package/gui/dist/assets/Tray-_nfzD8k4.js +1 -0
  6. package/gui/dist/assets/index-DpdfZWMK.js +86 -0
  7. package/gui/dist/assets/index-_bpvxJu0.css +1 -0
  8. package/gui/dist/assets/usage-companion-chart-DtoK7T6h.js +1 -0
  9. package/gui/dist/favicon.png +0 -0
  10. package/gui/dist/index.html +2 -2
  11. package/gui/dist/provider-icons/stepfun-color.svg +1 -0
  12. package/package.json +5 -1
  13. package/src/adapters/anthropic.ts +16 -0
  14. package/src/adapters/coding-agent/protocol.ts +36 -6
  15. package/src/adapters/coding-agent/turn.ts +10 -2
  16. package/src/adapters/command-code.ts +2 -1
  17. package/src/adapters/cursor/catalog.ts +51 -7
  18. package/src/adapters/cursor/protobuf-request.ts +6 -3
  19. package/src/adapters/cursor/request-builder.ts +13 -3
  20. package/src/adapters/cursor.ts +11 -2
  21. package/src/adapters/declaration-carrier.ts +45 -0
  22. package/src/adapters/devin.ts +75 -23
  23. package/src/adapters/google-antigravity-wire.ts +5 -2
  24. package/src/adapters/google-errors.ts +7 -1
  25. package/src/adapters/google.ts +29 -5
  26. package/src/adapters/image.ts +4 -1
  27. package/src/adapters/input-media-guard.ts +21 -9
  28. package/src/adapters/kiro/usage.ts +3 -2
  29. package/src/adapters/kiro-tool-fallback.ts +1 -1
  30. package/src/adapters/ollama-native.ts +6 -0
  31. package/src/adapters/openai-chat/developer-role.ts +61 -0
  32. package/src/adapters/openai-chat/messages.ts +46 -27
  33. package/src/adapters/openai-chat/parallel-tool-calls.ts +32 -0
  34. package/src/adapters/openai-chat/passthrough.ts +33 -9
  35. package/src/adapters/openai-chat/reasoning-wire.ts +89 -0
  36. package/src/adapters/openai-chat.ts +18 -57
  37. package/src/adapters/openai-responses/passthrough.ts +2 -0
  38. package/src/adapters/registry.ts +3 -2
  39. package/src/adapters/run-turn-queue.ts +178 -29
  40. package/src/adapters/xai-web-search.ts +16 -1
  41. package/src/bridge/errors.ts +8 -2
  42. package/src/bridge/response-json.ts +9 -1
  43. package/src/bridge/sse.ts +10 -0
  44. package/src/chat/inbound.ts +141 -5
  45. package/src/claude/desktop-3p.ts +7 -1
  46. package/src/claude/desktop-first-party.ts +183 -0
  47. package/src/claude/desktop-gateway-state.ts +41 -0
  48. package/src/claude/inbound-content-options.ts +6 -0
  49. package/src/claude/inbound.ts +32 -6
  50. package/src/claude/intercept/connect-proxy.ts +179 -0
  51. package/src/claude/intercept/listener.ts +122 -0
  52. package/src/claude/intercept/local-ca.ts +298 -0
  53. package/src/claude/intercept/runtime.ts +98 -0
  54. package/src/claude/intercept/settings.ts +189 -0
  55. package/src/cli/access.ts +87 -0
  56. package/src/cli/account-auth.ts +19 -0
  57. package/src/cli/capabilities.ts +31 -0
  58. package/src/cli/claude-desktop.ts +206 -16
  59. package/src/cli/codex-shim-autorestore.ts +3 -0
  60. package/src/cli/companion.ts +56 -0
  61. package/src/cli/dispatch.ts +43 -4
  62. package/src/cli/ensure-desired-integrations.ts +43 -5
  63. package/src/cli/help.ts +7 -9
  64. package/src/cli/index.ts +200 -61
  65. package/src/cli/init.ts +8 -0
  66. package/src/cli/integrations.ts +7 -1
  67. package/src/cli/registry.ts +41 -2
  68. package/src/cli/resolve.ts +230 -0
  69. package/src/cli/root.ts +24 -1
  70. package/src/cli/start-ownership-publication.ts +56 -0
  71. package/src/cli/status-probes.ts +2 -18
  72. package/src/cli/status.ts +62 -0
  73. package/src/cli/stop-report.ts +143 -0
  74. package/src/cli/uninstall-plan.ts +9 -0
  75. package/src/client/machine-listener.ts +2 -5
  76. package/src/clients/aside-profiles.ts +4 -0
  77. package/src/clients/config-export/zcode-store.ts +157 -0
  78. package/src/clients/config-export.ts +36 -0
  79. package/src/codex/app-server-processes.ts +72 -40
  80. package/src/codex/auth-api/login-flow.ts +6 -1
  81. package/src/codex/autostart-health.ts +28 -0
  82. package/src/codex/catalog/build-entries.ts +2 -2
  83. package/src/codex/catalog/effort.ts +3 -3
  84. package/src/codex/catalog/provider-models.ts +24 -15
  85. package/src/codex/catalog/retained-sync.ts +2 -2
  86. package/src/codex/convergence.ts +2 -2
  87. package/src/codex/history-provider.ts +12 -1
  88. package/src/codex/inject/config-toml.ts +41 -6
  89. package/src/codex/inject/paginated-openai-compat.ts +90 -0
  90. package/src/codex/inject.ts +18 -15
  91. package/src/codex/injected-marker.ts +18 -0
  92. package/src/codex/main-account.ts +6 -0
  93. package/src/codex/model-cache.ts +52 -6
  94. package/src/codex/model-entitlement-admission.ts +59 -0
  95. package/src/codex/model-entitlements.ts +87 -44
  96. package/src/codex/native-main-admission.ts +83 -0
  97. package/src/codex/routing/health-store.ts +39 -0
  98. package/src/codex/routing/selection.ts +37 -1
  99. package/src/codex/routing.ts +5 -41
  100. package/src/codex/shim-templates.ts +29 -3
  101. package/src/companion/settings.ts +132 -0
  102. package/src/config/atomic-write.ts +117 -5
  103. package/src/config/load-degrade.ts +34 -7
  104. package/src/config/process-state.ts +1 -1
  105. package/src/config/schema/config-schema.ts +27 -1
  106. package/src/config/schema/leaf-validators.ts +47 -0
  107. package/src/config.ts +1 -1
  108. package/src/generated/compatibility-version.json +418 -174
  109. package/src/integrations/config-io.ts +44 -10
  110. package/src/integrations/merge.ts +120 -13
  111. package/src/integrations/mutation-plan.ts +124 -18
  112. package/src/integrations/registry.ts +38 -0
  113. package/src/integrations/state.ts +78 -45
  114. package/src/integrations/target.ts +208 -0
  115. package/src/integrations/writer.ts +49 -11
  116. package/src/lab/conformance/fixture-provider.ts +5 -0
  117. package/src/lib/browser-launch-notice.ts +59 -0
  118. package/src/lib/bun-runtime.ts +6 -2
  119. package/src/lib/debug.ts +40 -0
  120. package/src/lib/open-url.ts +51 -7
  121. package/src/lib/package-tree-integrity.ts +2 -1
  122. package/src/lib/package-version.ts +8 -0
  123. package/src/lib/provider-egress.ts +310 -0
  124. package/src/lib/provider-outbound.ts +59 -14
  125. package/src/lib/proxy-env.ts +82 -7
  126. package/src/lib/request-execution-budget.ts +72 -0
  127. package/src/lib/request-failure-attribution.ts +183 -0
  128. package/src/lib/request-failure-model.ts +236 -0
  129. package/src/lib/request-resend-gate.ts +138 -0
  130. package/src/lib/standalone.ts +16 -0
  131. package/src/lib/upstream-retry.ts +167 -16
  132. package/src/lib/winsw.ts +2 -2
  133. package/src/oauth/index.ts +24 -1
  134. package/src/oauth/login-cli.ts +80 -29
  135. package/src/providers/api-key-resolve.ts +133 -0
  136. package/src/providers/api-key-selection.ts +5 -1
  137. package/src/providers/key-failover.ts +31 -1
  138. package/src/providers/key-store.ts +34 -110
  139. package/src/providers/model-rename-fields.ts +147 -0
  140. package/src/providers/model-rename-migration.ts +124 -37
  141. package/src/providers/quota/vendor-probes-key.ts +37 -22
  142. package/src/providers/reasoning-metadata.ts +43 -18
  143. package/src/providers/registry/entries-core.ts +9 -4
  144. package/src/providers/registry/entries-extended.ts +29 -4
  145. package/src/providers/registry/model-seeds.ts +47 -10
  146. package/src/providers/xai-transport.ts +12 -1
  147. package/src/reasoning-effort.ts +8 -0
  148. package/src/responses/function-call-compat.ts +38 -1
  149. package/src/responses/inline-document.ts +65 -0
  150. package/src/responses/input-media.ts +42 -8
  151. package/src/responses/muse-tool-name-alias.ts +19 -0
  152. package/src/responses/parser-content.ts +8 -2
  153. package/src/responses/parser-tools.ts +3 -0
  154. package/src/responses/parser.ts +3 -1
  155. package/src/responses/schema.ts +3 -0
  156. package/src/router.ts +17 -2
  157. package/src/server/admission-model-scope.ts +219 -0
  158. package/src/server/audio-live.ts +9 -3
  159. package/src/server/audio-upstream.ts +18 -0
  160. package/src/server/auth-cors.ts +26 -0
  161. package/src/server/chat-completions.ts +55 -2
  162. package/src/server/chat-native.ts +19 -4
  163. package/src/server/claude-messages.ts +55 -17
  164. package/src/server/grok-responses-snapshot-repair.ts +113 -11
  165. package/src/server/gui-freshness.ts +103 -0
  166. package/src/server/gui-static.ts +7 -9
  167. package/src/server/images.ts +59 -6
  168. package/src/server/index/claude-intercept-lifecycle.ts +49 -0
  169. package/src/server/index/serve-options.ts +56 -10
  170. package/src/server/index/spend-ledger-lifecycle.ts +34 -8
  171. package/src/server/index/startup-warnings.ts +24 -0
  172. package/src/server/index.ts +21 -28
  173. package/src/server/lifecycle.ts +4 -4
  174. package/src/server/live-call-bindings.ts +6 -0
  175. package/src/server/live.ts +88 -3
  176. package/src/server/management/agent-settings-routes.ts +121 -36
  177. package/src/server/management/companion-routes.ts +77 -0
  178. package/src/server/management/logs-usage-routes.ts +19 -0
  179. package/src/server/management/native-integration-routes.ts +103 -6
  180. package/src/server/management/oauth-account-routes.ts +45 -7
  181. package/src/server/management/route-registry.ts +6 -0
  182. package/src/server/management/shared.ts +18 -1
  183. package/src/server/management/usage-timeline-routes.ts +44 -0
  184. package/src/server/management-api.ts +8 -9
  185. package/src/server/proxy-liveness.ts +75 -0
  186. package/src/server/relay.ts +19 -2
  187. package/src/server/request-log-failure-attribution.ts +99 -0
  188. package/src/server/request-log.ts +114 -0
  189. package/src/server/request-metrics.ts +92 -30
  190. package/src/server/responses/codex-ws-wire.ts +34 -8
  191. package/src/server/responses/combo-stream-preflight.ts +168 -6
  192. package/src/server/responses/compact.ts +11 -0
  193. package/src/server/responses/core-opaque-recovery.ts +90 -0
  194. package/src/server/responses/fetch-helpers.ts +124 -8
  195. package/src/server/responses/input-admission.ts +10 -0
  196. package/src/server/responses/passthrough-delivery.ts +14 -1
  197. package/src/server/responses/passthrough-dispatch.ts +179 -35
  198. package/src/server/responses/passthrough-error.ts +27 -8
  199. package/src/server/responses/request-prepare.ts +42 -1
  200. package/src/server/responses/request-send-budget.ts +12 -0
  201. package/src/server/responses/request-transport.ts +24 -4
  202. package/src/server/responses/reset-replay.ts +108 -0
  203. package/src/server/responses-request-tool-scope.ts +214 -0
  204. package/src/server/responses-undeclared-tool-guard.ts +4 -1
  205. package/src/server/search.ts +25 -1
  206. package/src/server/usage-ledger-retention.ts +73 -0
  207. package/src/service/cli.ts +48 -2
  208. package/src/service/health.ts +3 -2
  209. package/src/service/install-state-contract.d.mts +27 -0
  210. package/src/service/install-state-contract.mjs +34 -0
  211. package/src/service/launchd.ts +1 -1
  212. package/src/service/orchestration.ts +2 -4
  213. package/src/service/ownership-compatibility.ts +164 -0
  214. package/src/service/ownership-mutation-lease.d.mts +32 -0
  215. package/src/service/ownership-mutation-lease.mjs +211 -0
  216. package/src/service/repair.ts +45 -1
  217. package/src/service/state-lock.ts +269 -0
  218. package/src/service/state-record.d.mts +36 -0
  219. package/src/service/state-record.mjs +138 -0
  220. package/src/service/state.ts +582 -68
  221. package/src/service/windows-taskxml.ts +11 -10
  222. package/src/service.ts +7 -3
  223. package/src/tray/windows-tray.ps1 +1 -1
  224. package/src/types/config.ts +37 -0
  225. package/src/types/provider.ts +73 -0
  226. package/src/types/request.ts +28 -2
  227. package/src/types/tools.ts +19 -0
  228. package/src/types.ts +3 -0
  229. package/src/update/index.ts +207 -63
  230. package/src/update/job.ts +9 -5
  231. package/src/update/ownership-transaction.ts +47 -0
  232. package/src/update/restart-ownership.ts +54 -0
  233. package/src/update/runtime-ownership.d.mts +40 -0
  234. package/src/update/runtime-ownership.mjs +122 -0
  235. package/src/usage/attempt-delivery.ts +198 -0
  236. package/src/usage/cache-diagnostic.ts +305 -0
  237. package/src/usage/failure-fingerprint.ts +118 -0
  238. package/src/usage/failure-projection-cache.ts +174 -0
  239. package/src/usage/failure-projection.ts +174 -0
  240. package/src/usage/ledger-retention.ts +165 -0
  241. package/src/usage/log.ts +126 -79
  242. package/src/usage/request-outcome.ts +150 -0
  243. package/src/usage/retention-contract.ts +28 -0
  244. package/src/usage/summary.ts +2 -2
  245. package/src/usage/telemetry-contract.ts +237 -0
  246. package/src/usage/timeline.ts +236 -0
  247. package/src/web-search/alpha-search.ts +21 -1
  248. package/gui/dist/assets/index-BTuCbqQd.css +0 -1
  249. 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. Mid-stream SSE resets are
12
- * out of scope — the response has already resolved by then.
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 !== true) {
532
- // Return evidence instead of throwing a generic transport error: outer catches
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
- // The WeakSet protects in-process recovery; the code survives JSON re-wrapping.
535
- // Never expose the raw exception, which can contain credentials or request data.
536
- const response = new Response(JSON.stringify({ error: {
537
- type: "upstream_error",
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})` : ""} — retrying (${attempt + 2}/${attempts})`,
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">
@@ -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
  }
@@ -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 rl = readline.createInterface({ input: process.stdin, output: process.stdout });
104
- try {
105
- await runLogin(name, {
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
- openUrl(url);
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
- new Promise((res) => rl.question("Paste redirect URL or code (or wait for browser): ", res)),
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
- } finally {
116
- rl.close();
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
- openUrl(def.dashboardUrl);
205
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
206
- const key = (await new Promise<string>((res) => rl.question(`Paste your ${def.label} API key: `, res))).trim();
207
- // Template URL with placeholders needs resolution before saving.
208
- let baseUrl = def.baseUrl;
209
- if (/\{[^}]*\}/.test(baseUrl)) {
210
- const resolved = (await new Promise<string>((res) => rl.question(`Your endpoint URL (${baseUrl}): `, res))).trim();
211
- if (!resolved) {
212
- rl.close();
213
- console.error("A resolved URL is required — replace the {placeholder} with your actual value.");
214
- process.exit(1);
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 = resolved;
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);