@bitkyc08/opencodex 2.59.0 → 2.60.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.
Files changed (194) hide show
  1. package/gui/dist/assets/index-BTuCbqQd.css +1 -0
  2. package/gui/dist/assets/index-DoBVdPHP.js +134 -0
  3. package/gui/dist/index.html +2 -2
  4. package/package.json +4 -1
  5. package/src/adapters/anthropic-image-codec.ts +16 -2
  6. package/src/adapters/anthropic-image-normalize.ts +49 -2
  7. package/src/adapters/anthropic.ts +4 -1
  8. package/src/adapters/base.ts +23 -0
  9. package/src/adapters/coding-agent/turn.ts +22 -2
  10. package/src/adapters/command-code.ts +50 -3
  11. package/src/adapters/cursor/checkpoint-store.ts +3 -0
  12. package/src/adapters/cursor/discovery.ts +11 -8
  13. package/src/adapters/cursor/live-transport.ts +26 -9
  14. package/src/adapters/cursor/request-builder.ts +7 -1
  15. package/src/adapters/cursor/transport.ts +19 -0
  16. package/src/adapters/cursor.ts +3 -1
  17. package/src/adapters/devin/cloud-direct/chat.ts +3 -1
  18. package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +42 -5
  19. package/src/adapters/devin.ts +50 -13
  20. package/src/adapters/google-antigravity-replay.ts +1 -1
  21. package/src/adapters/google-antigravity-wire.ts +29 -5
  22. package/src/adapters/google-http.ts +49 -10
  23. package/src/adapters/google-tool-schema.ts +595 -31
  24. package/src/adapters/google-wire-compiler.ts +93 -10
  25. package/src/adapters/google-wire-shape.ts +461 -0
  26. package/src/adapters/google.ts +37 -6
  27. package/src/adapters/openai-chat-images.ts +3 -1
  28. package/src/adapters/openai-chat.ts +5 -1
  29. package/src/adapters/openai-responses/image-gen.ts +8 -6
  30. package/src/adapters/openai-responses/passthrough.ts +15 -3
  31. package/src/adapters/openai-responses/reasoning.ts +7 -0
  32. package/src/adapters/opencode-go-additional-tools.ts +12 -2
  33. package/src/bridge/sse.ts +3 -151
  34. package/src/cli/account-extended.ts +4 -4
  35. package/src/cli/dispatch.ts +3 -3
  36. package/src/cli/doctor.ts +28 -9
  37. package/src/cli/hub.ts +3 -2
  38. package/src/cli/index.ts +7 -2
  39. package/src/cli/opencode.ts +2 -2
  40. package/src/cli/provider.ts +13 -1
  41. package/src/client/machine-api.ts +2 -2
  42. package/src/client/machine-listener.ts +2 -2
  43. package/src/client/runtime.ts +26 -2
  44. package/src/codex/account-store.ts +65 -0
  45. package/src/codex/auth-api/account-list.ts +19 -11
  46. package/src/codex/auth-api/pool-quota-probe.ts +30 -7
  47. package/src/codex/catalog/gather-capture.ts +21 -2
  48. package/src/codex/catalog/model-hints.ts +29 -28
  49. package/src/codex/catalog/parsing.ts +7 -0
  50. package/src/codex/catalog/provider-models.ts +19 -2
  51. package/src/codex/catalog/retained-sync.ts +22 -26
  52. package/src/codex/catalog/routed-gather.ts +19 -0
  53. package/src/codex/context-compat.ts +5 -2
  54. package/src/codex/desired-state.ts +4 -1
  55. package/src/codex/history-job.ts +6 -6
  56. package/src/codex/history-provider.ts +20 -166
  57. package/src/codex/history-rollout-read.ts +174 -0
  58. package/src/codex/internal/catalog-writer.ts +33 -1
  59. package/src/codex/model-cache.ts +47 -0
  60. package/src/codex/model-entitlements.ts +29 -10
  61. package/src/codex/observed-model-denials.ts +101 -8
  62. package/src/codex/prompt-text-probe.ts +9 -6
  63. package/src/codex/routing.ts +7 -1
  64. package/src/codex/shim.ts +1 -1
  65. package/src/codex/subagent-model-fallback.ts +22 -4
  66. package/src/combos/failover.ts +3 -0
  67. package/src/config/admitted-identity.ts +222 -0
  68. package/src/config/diagnostics.ts +22 -1
  69. package/src/config/feature-flags.ts +5 -0
  70. package/src/config/load-degrade.ts +18 -0
  71. package/src/config/proxy-env.ts +8 -2
  72. package/src/config/schema/compaction-triggers.ts +11 -0
  73. package/src/config/schema/config-schema.ts +4 -0
  74. package/src/config/schema/leaf-validators.ts +12 -0
  75. package/src/config.ts +2 -2
  76. package/src/generated/compatibility-version.json +249 -169
  77. package/src/grok/reset-coupons.ts +38 -19
  78. package/src/images/loop.ts +6 -1
  79. package/src/integrations/aside-profile-context.ts +37 -3
  80. package/src/integrations/aside-profile-journal.ts +68 -3
  81. package/src/integrations/aside-profiles.ts +128 -3
  82. package/src/integrations/mutation-plan.ts +815 -0
  83. package/src/integrations/writer.ts +85 -99
  84. package/src/lab/live/transport.ts +4 -0
  85. package/src/lab/live/types.ts +5 -0
  86. package/src/lab/subject/behavior-fingerprint.ts +1 -1
  87. package/src/lib/admin-secrets.ts +9 -1
  88. package/src/lib/debug-log-buffer.ts +6 -1
  89. package/src/lib/debug.ts +23 -0
  90. package/src/lib/errors.ts +79 -0
  91. package/src/lib/http-response-semantics.ts +57 -0
  92. package/src/lib/lab-live-pinned-sender.ts +26 -12
  93. package/src/lib/pinned-http.ts +142 -2
  94. package/src/lib/plain-data.ts +103 -0
  95. package/src/lib/process-control.ts +13 -5
  96. package/src/lib/provider-outbound.ts +50 -2
  97. package/src/lib/socks5-fetch.ts +136 -26
  98. package/src/lib/spend-ledger-owner.ts +364 -0
  99. package/src/lib/spend-reservation-ledger.ts +218 -27
  100. package/src/lib/windows-system-proxy.ts +16 -11
  101. package/src/oauth/callback-server.ts +4 -3
  102. package/src/oauth/generic-account-failover.ts +1 -0
  103. package/src/oauth/health.ts +12 -1
  104. package/src/oauth/index.ts +3 -107
  105. package/src/oauth/login-flow-state.ts +127 -0
  106. package/src/providers/derive.ts +34 -17
  107. package/src/providers/devin-cli-authmode-migration.ts +14 -10
  108. package/src/providers/key-failover.ts +66 -18
  109. package/src/providers/model-rename-migration.ts +55 -1
  110. package/src/providers/model-rename-startup.ts +7 -5
  111. package/src/providers/openai-virtual-models.ts +42 -2
  112. package/src/providers/quota/antigravity.ts +22 -2
  113. package/src/providers/quota/vendor-probes-key.ts +1 -1
  114. package/src/providers/registry/entries-core.ts +39 -17
  115. package/src/providers/registry/entries-extended.ts +35 -1
  116. package/src/providers/registry/model-ids.ts +168 -0
  117. package/src/providers/registry/model-seeds.ts +9 -0
  118. package/src/providers/registry/types.ts +2 -0
  119. package/src/providers/resolved-model-policy-merge.ts +167 -0
  120. package/src/providers/resolved-model-policy.ts +406 -0
  121. package/src/providers/stale-vision-classification-migration.ts +137 -0
  122. package/src/responses/apply-patch-envelope.ts +0 -12
  123. package/src/responses/freeform-wrapper-scan.ts +279 -0
  124. package/src/responses/legacy-dotted-tool-name-repair.ts +134 -0
  125. package/src/responses/progressive-freeform-input.ts +130 -0
  126. package/src/responses/reasoning-envelope.ts +30 -0
  127. package/src/responses/state.ts +5 -12
  128. package/src/responses/tool-name-aliases.ts +15 -1
  129. package/src/router.ts +91 -115
  130. package/src/routing/compatibility/behavior.ts +9 -0
  131. package/src/routing/compatibility/subject.ts +16 -1
  132. package/src/server/adapter-resolve.ts +9 -0
  133. package/src/server/auth-cors.ts +3 -0
  134. package/src/server/chat-completions.ts +5 -2
  135. package/src/server/claude-messages.ts +6 -3
  136. package/src/server/effort-row.ts +11 -3
  137. package/src/server/grok-responses-control-frame.ts +160 -1
  138. package/src/server/index/serve-options.ts +86 -29
  139. package/src/server/index/spend-ledger-lifecycle.ts +66 -0
  140. package/src/server/index/websocket-handler.ts +6 -1
  141. package/src/server/index.ts +16 -13
  142. package/src/server/management/aside-profile-routes.ts +266 -7
  143. package/src/server/management/config-routes.ts +18 -2
  144. package/src/server/management/context.ts +3 -0
  145. package/src/server/management/integration-routes.ts +287 -5
  146. package/src/server/management/metrics-routes.ts +20 -0
  147. package/src/server/management/model-rows.ts +224 -12
  148. package/src/server/management/route-registry.ts +14 -0
  149. package/src/server/management/shared.ts +10 -3
  150. package/src/server/management/system-restart.ts +7 -2
  151. package/src/server/management/system-routes.ts +2 -0
  152. package/src/server/management/usage-aggregate-cache.ts +4 -0
  153. package/src/server/management-api.ts +2 -0
  154. package/src/server/management-auth.ts +15 -1
  155. package/src/server/readiness.ts +29 -10
  156. package/src/server/relay-eager.ts +24 -2
  157. package/src/server/relay.ts +119 -10
  158. package/src/server/request-log.ts +55 -2
  159. package/src/server/request-metrics.ts +236 -0
  160. package/src/server/responses/adapter-continuation.ts +3 -3
  161. package/src/server/responses/adapter-dispatch.ts +11 -6
  162. package/src/server/responses/compact.ts +32 -10
  163. package/src/server/responses/compaction-routing.ts +111 -0
  164. package/src/server/responses/core-codex-account.ts +8 -3
  165. package/src/server/responses/core-combo.ts +7 -7
  166. package/src/server/responses/core-normalize.ts +6 -12
  167. package/src/server/responses/core-opaque-recovery.ts +1 -0
  168. package/src/server/responses/core-options.ts +4 -0
  169. package/src/server/responses/encrypted-payload.ts +20 -2
  170. package/src/server/responses/passthrough-delivery.ts +31 -14
  171. package/src/server/responses/passthrough-dispatch.ts +36 -9
  172. package/src/server/responses/policy-fallback.ts +5 -13
  173. package/src/server/responses/request-prepare.ts +67 -17
  174. package/src/server/responses/request-send-budget.ts +5 -1
  175. package/src/server/responses/request-sidecar-auth.ts +1 -1
  176. package/src/server/responses/request-transport.ts +2 -2
  177. package/src/server/responses/run-turn-execution.ts +25 -3
  178. package/src/server/responses/sidecar-execution.ts +17 -2
  179. package/src/server/responses/ws-upstream.ts +14 -27
  180. package/src/server/responses-custom-tool-repair.ts +27 -54
  181. package/src/server/responses-undeclared-tool-guard.ts +31 -1
  182. package/src/server/sse-payload-rewrite.ts +1 -1
  183. package/src/tray/windows-tray.ps1 +155 -3
  184. package/src/types/config.ts +11 -3
  185. package/src/types/provider.ts +18 -0
  186. package/src/types/request.ts +2 -0
  187. package/src/types/tools.ts +14 -0
  188. package/src/types.ts +1 -0
  189. package/src/vision/eligibility.ts +88 -9
  190. package/src/vision/plan.ts +34 -10
  191. package/src/web-search/executor.ts +41 -2
  192. package/src/web-search/loop.ts +6 -1
  193. package/gui/dist/assets/index-C5IebErG.js +0 -136
  194. package/gui/dist/assets/index-OESInAjC.css +0 -1
@@ -1,5 +1,6 @@
1
1
  import http, { type ClientRequest, type IncomingMessage, type RequestOptions } from "node:http";
2
2
  import https from "node:https";
3
+ import { classifyContentCoding, isNullBodyStatus } from "./http-response-semantics";
3
4
 
4
5
  export type PinnedAddress = { address: string; family: number };
5
6
 
@@ -7,7 +8,9 @@ export type PinnedHttpErrorCode =
7
8
  | "connect_timeout"
8
9
  | "first_byte_timeout"
9
10
  | "inactivity_timeout"
10
- | "output_byte_limit";
11
+ | "output_byte_limit"
12
+ | "unsupported_content_encoding"
13
+ | "content_decode_failed";
11
14
 
12
15
  export class PinnedHttpError extends Error {
13
16
  override readonly name = "PinnedHttpError";
@@ -32,6 +35,99 @@ export interface PinnedHttpRequestOptions {
32
35
  /** @deprecated Use {@link PinnedHttpRequestOptions}. */
33
36
  export type PinnedHttpGetOptions = PinnedHttpRequestOptions;
34
37
 
38
+ /**
39
+ * Undo the content-coding this transport has to undo itself, under the caller's byte ceiling.
40
+ *
41
+ * `maxBytes` keeps its existing meaning for the bytes that arrive on the socket, and gains the
42
+ * same meaning for the bytes the caller ends up reading. Bounding only the coded side would let
43
+ * a small compressed response expand past a ceiling the caller set precisely so it would not
44
+ * have to hold an unbounded body in memory.
45
+ *
46
+ * A completed body is not torn down here. The response already ended, so there is nothing to
47
+ * release, and destroying it would take a connection the agent is entitled to reuse. That
48
+ * matches the identity path, which also only closes. Teardown belongs to the paths that end a
49
+ * response early: a decode failure, an exceeded ceiling, and a caller that cancels.
50
+ *
51
+ * Only a decoder failure is renamed. A mid-body reset, a stalled response and an exceeded
52
+ * socket-byte ceiling all reach this pipeline as "the stream failed", and calling any of them a
53
+ * decode failure would tell the caller the peer sent unreadable bytes when the truth is that the
54
+ * connection died. The source failure is recorded as it passes so the original error survives;
55
+ * what is left after that is the decompressor's own, and that one is named because the caller's
56
+ * alternative is a bare TypeError from a stream it never constructed.
57
+ */
58
+ function decodedBody(
59
+ source: ReadableStream<Uint8Array>,
60
+ format: "gzip" | "deflate",
61
+ maxBytes: number | undefined,
62
+ context: string,
63
+ release: () => void,
64
+ ): ReadableStream<Uint8Array> {
65
+ // Interposed purely to attribute failures. Once bytes enter the decompressor, a transport
66
+ // error and a corrupt trailer are indistinguishable from the far side of the pipe.
67
+ let sourceFailure: { error: unknown } | undefined;
68
+ const sourceReader = source.getReader();
69
+ const attributed = new ReadableStream<Uint8Array>({
70
+ async pull(controller) {
71
+ try {
72
+ const next = await sourceReader.read();
73
+ if (next.done) {
74
+ controller.close();
75
+ return;
76
+ }
77
+ controller.enqueue(next.value);
78
+ } catch (error) {
79
+ sourceFailure = { error };
80
+ controller.error(error);
81
+ }
82
+ },
83
+ cancel(reason) {
84
+ return sourceReader.cancel(reason);
85
+ },
86
+ });
87
+ // `DecompressionStream` declares its writable side as `WritableStream<BufferSource>`, and
88
+ // TypeScript measures `WritableStream` as invariant in its chunk type, so the pair is not
89
+ // assignable to `ReadableWritablePair<Uint8Array, Uint8Array>` even though every chunk this
90
+ // body produces is a valid `BufferSource`. The conversion states that relationship and
91
+ // nothing else; it does not widen what is actually written.
92
+ const decompressor = new DecompressionStream(format) as unknown as ReadableWritablePair<Uint8Array, Uint8Array>;
93
+ const reader = attributed.pipeThrough(decompressor).getReader();
94
+ let decoded = 0;
95
+ return new ReadableStream<Uint8Array>({
96
+ async pull(controller) {
97
+ try {
98
+ const next = await reader.read();
99
+ if (next.done) {
100
+ controller.close();
101
+ return;
102
+ }
103
+ decoded += next.value.byteLength;
104
+ if (maxBytes !== undefined && decoded > maxBytes) {
105
+ throw new PinnedHttpError("output_byte_limit", `${context} exceeds ${maxBytes} byte cap`);
106
+ }
107
+ controller.enqueue(next.value);
108
+ } catch (error) {
109
+ // A failure the socket stream raised is the caller's answer, whatever shape it has.
110
+ // Only what the decompressor itself rejected is renamed.
111
+ const named = sourceFailure !== undefined
112
+ ? sourceFailure.error
113
+ : error instanceof PinnedHttpError
114
+ ? error
115
+ : new PinnedHttpError("content_decode_failed", `${context} could not decode its ${format} body`);
116
+ // Cancelling the decoded reader propagates back through the decompressor to the socket
117
+ // stream's own `cancel`, which destroys the request; `release` covers the case where
118
+ // that propagation is already finished.
119
+ await reader.cancel(named).catch(() => { /* already torn down */ });
120
+ controller.error(named);
121
+ release();
122
+ }
123
+ },
124
+ async cancel(reason) {
125
+ await reader.cancel(reason).catch(() => { /* already torn down */ });
126
+ release();
127
+ },
128
+ });
129
+ }
130
+
35
131
  function pinnedHttpRequest(
36
132
  url: string,
37
133
  pinned: PinnedAddress,
@@ -55,6 +151,10 @@ function pinnedHttpRequest(
55
151
  const maxBytes = options?.maxBytes;
56
152
  const headers = new Headers(options?.headers);
57
153
  headers.set("host", parsed.host);
154
+ // This transport assembles the response itself, so a coding it did not ask for becomes its own
155
+ // problem to undo. Ask for none by default and leave an explicit caller choice alone, which is
156
+ // the same rule `src/lib/socks5-fetch.ts` applies to the other raw route.
157
+ if (!headers.has("accept-encoding")) headers.set("accept-encoding", "identity");
58
158
  if (body !== undefined && !headers.has("content-length")) {
59
159
  headers.set("content-length", String(Buffer.byteLength(body)));
60
160
  }
@@ -150,6 +250,39 @@ function pinnedHttpRequest(
150
250
  return;
151
251
  }
152
252
 
253
+ // A success status can still be null-body. `new Response(stream, { status: 204 })` throws a
254
+ // TypeError, so attaching the body below would turn a correct no-content answer into a
255
+ // construction failure raised inside this event handler rather than a resolved response.
256
+ // Nothing is coming on the socket either, so streaming one of these would hold the caller
257
+ // until the peer closed a connection it is entitled to keep alive. The headers still
258
+ // describe the representation the peer would have sent and are preserved as they arrived.
259
+ if (isNullBodyStatus(status)) {
260
+ try { response.destroy(); } catch { /* ignore */ }
261
+ try { req?.destroy(); } catch { /* ignore */ }
262
+ if (settled) return;
263
+ settled = true;
264
+ resolve(new Response(null, { status, headers: responseHeaders }));
265
+ return;
266
+ }
267
+
268
+ // The peer may have coded the body whatever this request asked for. Classify before the
269
+ // stream takes the socket so a coding this transport cannot undo fails on the ordinary
270
+ // error path rather than reaching the caller as bytes its parser cannot read.
271
+ const coding = classifyContentCoding(responseHeaders);
272
+ if (coding.kind === "unsupported") {
273
+ try { response.destroy(); } catch { /* ignore */ }
274
+ fail(new PinnedHttpError(
275
+ "unsupported_content_encoding",
276
+ `${context} returned an unsupported content-encoding: ${coding.coding}`,
277
+ ));
278
+ return;
279
+ }
280
+ if (coding.kind === "decodable") {
281
+ responseHeaders.delete("content-encoding");
282
+ // The declared length counted the coded bytes, not what the caller now reads.
283
+ responseHeaders.delete("content-length");
284
+ }
285
+
153
286
  let received = 0;
154
287
  const stream = new ReadableStream<Uint8Array>({
155
288
  start(controller) {
@@ -191,7 +324,14 @@ function pinnedHttpRequest(
191
324
 
192
325
  if (settled) return;
193
326
  settled = true;
194
- resolve(new Response(stream, { status, headers: responseHeaders }));
327
+ const release = () => {
328
+ try { response.destroy(); } catch { /* ignore */ }
329
+ try { req?.destroy(); } catch { /* ignore */ }
330
+ };
331
+ const payload = coding.kind === "decodable"
332
+ ? decodedBody(stream, coding.format, maxBytes, context, release)
333
+ : stream;
334
+ resolve(new Response(payload, { status, headers: responseHeaders }));
195
335
  };
196
336
 
197
337
  const requestFn = parsed.protocol === "https:" ? https.request : http.request;
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Copying a value the way a consumer that must not observe later edits needs it copied.
3
+ *
4
+ * Serializing with JSON is not a copier and using it as one is a quiet hole: it drops functions
5
+ * without saying so, and it invokes getters and toJSON, so the object being copied gets to decide
6
+ * what the copy contains. Every read here goes through a property descriptor instead, including
7
+ * array elements, which an exotic array can define as accessors just as an object can.
8
+ *
9
+ * The contract is narrow on purpose. This copies what JSON could have produced and refuses
10
+ * everything else rather than approximating it: an accessor, a cycle, a function, a class instance,
11
+ * a Map, a Date, a symbol value, a bigint, a non-finite number. A caller that is refused has to
12
+ * decide what to do about it, which is the point; a copier that silently degraded would leave the
13
+ * caller believing it held a snapshot of something it never read.
14
+ *
15
+ * Symbol-keyed properties are skipped rather than refused. Process bookkeeping rides on symbols by
16
+ * convention here, and none of it is the data a consumer is being given a copy of.
17
+ */
18
+
19
+ /** A copy, or a refusal. A union rather than a nullable value, because null is copyable data. */
20
+ export type PlainDataCopy<T> = { readonly ok: true; readonly value: T } | { readonly ok: false };
21
+
22
+ const REFUSED = Symbol("refused");
23
+
24
+ export function copyPlainData<T>(value: T): PlainDataCopy<T> {
25
+ const copied = copyValue(value, new Set());
26
+ return copied === REFUSED ? { ok: false } : { ok: true, value: copied as T };
27
+ }
28
+
29
+ /**
30
+ * A stable string for comparing two plain-data values.
31
+ *
32
+ * Key-sorted entry pairs rather than objects, because property order is observable through
33
+ * serialization and two values that differ only in it are the same value.
34
+ */
35
+ export function canonicalPlainData(value: unknown): string {
36
+ return JSON.stringify(sorted(value));
37
+ }
38
+
39
+ /** Own enumerable string keys, with null in the position of any key that is an accessor. */
40
+ export function ownDataKeys(value: Record<string, unknown>): Array<string | null> {
41
+ return Object.keys(value).map(key => {
42
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
43
+ return descriptor !== undefined && descriptor.get === undefined && descriptor.set === undefined ? key : null;
44
+ });
45
+ }
46
+
47
+ export function isPlainObject(value: unknown): value is Record<string, unknown> {
48
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
49
+ const prototype = Object.getPrototypeOf(value);
50
+ return prototype === Object.prototype || prototype === null;
51
+ }
52
+
53
+ function copyValue(value: unknown, seen: Set<object>): unknown {
54
+ if (value === null) return null;
55
+ const type = typeof value;
56
+ if (type === "string" || type === "boolean") return value;
57
+ if (type === "number") return Number.isFinite(value as number) ? value : REFUSED;
58
+ if (type !== "object") return REFUSED;
59
+ const object = value as object;
60
+ if (seen.has(object)) return REFUSED;
61
+ seen.add(object);
62
+ try {
63
+ if (Array.isArray(object)) return copyArray(object, seen);
64
+ if (!isPlainObject(object)) return REFUSED;
65
+ const copied: Record<string, unknown> = {};
66
+ for (const key of ownDataKeys(object)) {
67
+ if (key === null) return REFUSED;
68
+ const entry = (object as Record<string, unknown>)[key];
69
+ if (entry === undefined) continue;
70
+ const item = copyValue(entry, seen);
71
+ if (item === REFUSED) return REFUSED;
72
+ copied[key] = item;
73
+ }
74
+ return copied;
75
+ } finally {
76
+ seen.delete(object);
77
+ }
78
+ }
79
+
80
+ function copyArray(source: unknown[], seen: Set<object>): unknown {
81
+ const copied: unknown[] = [];
82
+ for (let index = 0; index < source.length; index += 1) {
83
+ const descriptor = Object.getOwnPropertyDescriptor(source, index);
84
+ // A hole is written as null, which is what a reader of the serialized value would find there.
85
+ if (descriptor === undefined) { copied.push(null); continue; }
86
+ if (descriptor.get !== undefined || descriptor.set !== undefined) return REFUSED;
87
+ if (descriptor.value === undefined) { copied.push(null); continue; }
88
+ const item = copyValue(descriptor.value, seen);
89
+ if (item === REFUSED) return REFUSED;
90
+ copied.push(item);
91
+ }
92
+ return copied;
93
+ }
94
+
95
+ function sorted(value: unknown): unknown {
96
+ if (Array.isArray(value)) return value.map(sorted);
97
+ if (value !== null && typeof value === "object") {
98
+ return Object.keys(value as Record<string, unknown>)
99
+ .sort()
100
+ .map(key => [key, sorted((value as Record<string, unknown>)[key])]);
101
+ }
102
+ return value;
103
+ }
@@ -1,4 +1,5 @@
1
1
  import { execFileSync } from "node:child_process";
2
+ import { isIP } from "node:net";
2
3
  import { loadConfig } from "../config";
3
4
  import { readRuntimePort } from "../config/process-state";
4
5
  import { configuredAdminToken } from "./admin-secrets";
@@ -50,9 +51,11 @@ export interface GracefulStopIo {
50
51
  }
51
52
 
52
53
  /**
53
- * Host to POST /api/stop against: follow the recorded bind hostname when it names a
54
- * concrete address (a proxy bound to ::1 or a LAN IP is unreachable on 127.0.0.1);
55
- * loopback aliases and wildcard binds all answer on IPv4 loopback.
54
+ * Host to POST /api/stop against: follow the recorded bind hostname only when it is
55
+ * an IP literal (a proxy bound to ::1 or a LAN IP is unreachable on 127.0.0.1).
56
+ * DNS names are not safe here because resolution may change between bind and stop,
57
+ * while this request carries the management token. Loopback aliases, DNS names, and
58
+ * wildcard binds therefore all fall back to IPv4 loopback.
56
59
  */
57
60
  export function gracefulStopHost(hostname: string | undefined): string {
58
61
  const trimmed = (hostname ?? "").trim();
@@ -61,8 +64,13 @@ export function gracefulStopHost(hostname: string | undefined): string {
61
64
  return "127.0.0.1";
62
65
  }
63
66
  if (lower === "::1" || lower === "[::1]") return "[::1]";
64
- if (trimmed.startsWith("[") && trimmed.endsWith("]")) return trimmed;
65
- return trimmed.includes(":") ? `[${trimmed}]` : trimmed;
67
+ const unbracketed = trimmed.startsWith("[") && trimmed.endsWith("]")
68
+ ? trimmed.slice(1, -1)
69
+ : trimmed;
70
+ const ipVersion = isIP(unbracketed);
71
+ if (ipVersion === 6) return `[${unbracketed}]`;
72
+ if (ipVersion === 4) return unbracketed;
73
+ return "127.0.0.1";
66
74
  }
67
75
 
68
76
  /**
@@ -104,19 +104,67 @@ export async function providerRedirectError(response: Response, requestUrl: stri
104
104
  return `provider returned ${response.status} redirect to ${target}; configure the final provider URL directly`;
105
105
  }
106
106
 
107
+ /**
108
+ * Default client identity for proxy-originated provider outbound requests.
109
+ *
110
+ * Every request through the provider outbound wrapper — connection tests, model
111
+ * discovery, quota probes — is initiated by the proxy itself, so there is no
112
+ * client request to inherit a User-Agent from, and the pinned Node-style
113
+ * transport sends none. WAF/CDN front ends commonly answer UA-less requests
114
+ * with a 403 that surfaced as "provider added but no models" (#5104). A caller
115
+ * that materializes its own User-Agent — registry static headers, provider
116
+ * `headers`, or a vendor-specific client fingerprint — keeps that value and is
117
+ * never given a second User-Agent; this only fills the name nobody claimed.
118
+ * The value is what survives, not its spelling: both the pinned transport and
119
+ * the SOCKS transport rebuild the set through `new Headers()`, which lowercases
120
+ * every name before it reaches the wire. Inference traffic never uses this
121
+ * wrapper, so the client-fingerprint rationale of #1751 is unaffected.
122
+ */
123
+ const PROVIDER_OUTBOUND_DEFAULT_USER_AGENT = "opencodex";
124
+
125
+ function hasUserAgentHeader(headers: HeadersInit | null | undefined): boolean {
126
+ if (!headers) return false;
127
+ if (headers instanceof Headers) return headers.has("user-agent");
128
+ if (Array.isArray(headers)) return headers.some(([name]) => name.toLowerCase() === "user-agent");
129
+ return Object.keys(headers).some(name => name.toLowerCase() === "user-agent");
130
+ }
131
+
132
+ function withDefaultOutboundUserAgent(
133
+ init: ProviderGetInit | ProviderPostInit,
134
+ ): ProviderGetInit | ProviderPostInit {
135
+ const headers = init.headers;
136
+ if (hasUserAgentHeader(headers)) return init;
137
+ if (headers instanceof Headers) {
138
+ const merged = new Headers(headers);
139
+ merged.set("User-Agent", PROVIDER_OUTBOUND_DEFAULT_USER_AGENT);
140
+ return { ...init, headers: merged };
141
+ }
142
+ if (Array.isArray(headers)) {
143
+ return { ...init, headers: [...headers, ["User-Agent", PROVIDER_OUTBOUND_DEFAULT_USER_AGENT]] };
144
+ }
145
+ return { ...init, headers: { ...(headers ?? {}), "User-Agent": PROVIDER_OUTBOUND_DEFAULT_USER_AGENT } };
146
+ }
147
+
107
148
  async function providerOutboundRequest(
108
149
  name: string,
109
150
  provider: ProviderOutboundConfig,
110
151
  url: string,
111
152
  method: "GET" | "POST",
112
- init: ProviderGetInit | ProviderPostInit,
153
+ rawInit: ProviderGetInit | ProviderPostInit,
113
154
  dependencies: ProviderOutboundDependencies = {},
114
155
  ): Promise<Response> {
156
+ // See PROVIDER_OUTBOUND_DEFAULT_USER_AGENT: this wrapper only carries proxy-originated
157
+ // diagnostic traffic, so it identifies itself unless the caller already did.
158
+ const init = withDefaultOutboundUserAgent(rawInit);
115
159
  const postUrl = method === "POST" ? new URL(url) : undefined;
116
160
  if (postUrl?.protocol !== undefined && postUrl.protocol !== "https:") {
117
161
  throw new ProviderOutboundPolicyError("provider POST URL must use HTTPS");
118
162
  }
119
- if (provider.fetch) {
163
+ // A provider entry keeps unknown configuration keys, so `fetch` can arrive as a value the
164
+ // operator wrote into the file rather than an executor a caller attached. Calling that would
165
+ // throw inside discovery and fail the provider for a reason nothing in its configuration
166
+ // explains; the built-in transport is what a configured value means.
167
+ if (typeof provider.fetch === "function") {
120
168
  // A caller-owned executor cannot be peer-pinned here. This branch keeps literal/config
121
169
  // checks and redirect blocking, but does not provide the resolved-address guarantees of
122
170
  // the built-in transport. Main-request migration must define that executor contract first.
@@ -1,5 +1,6 @@
1
1
  import net, { type Socket } from "node:net";
2
2
  import tls, { type TLSSocket } from "node:tls";
3
+ import { classifyContentCoding, isNullBodyStatus } from "./http-response-semantics";
3
4
 
4
5
  const DEFAULT_SOCKS5_PORT = 1080;
5
6
  const SOCKS5_CONNECT_TIMEOUT_MS = 30_000;
@@ -510,7 +511,9 @@ function responseBody(
510
511
 
511
512
  /** Statuses and methods that carry no response body, whatever the headers say about one. */
512
513
  function bodylessResponse(method: string, status: number): boolean {
513
- return method === "HEAD" || status === 204 || status === 304 || (status >= 100 && status < 200);
514
+ // 205 belongs here for the same reason 204 does, and leaving it out was not a judgement call:
515
+ // a peer that resets the caller's view sends no body, so the Response constructor rejects one.
516
+ return method === "HEAD" || isNullBodyStatus(status) || (status >= 100 && status < 200);
514
517
  }
515
518
 
516
519
  /**
@@ -528,11 +531,121 @@ function bodylessResponse(method: string, status: number): boolean {
528
531
  * the coded path would fail responses that succeed uncompressed.
529
532
  */
530
533
  function contentCodingFormat(headers: Headers): "gzip" | "deflate" | undefined {
531
- const coding = (headers.get("content-encoding") ?? "").trim().toLowerCase();
532
- if (coding === "" || coding === "identity") return undefined;
533
- if (coding === "gzip" || coding === "x-gzip") return "gzip";
534
- if (coding === "deflate") return "deflate";
535
- throw new Socks5FetchError("SOCKS5 upstream returned an unsupported content-encoding: " + coding);
534
+ const coding = classifyContentCoding(headers);
535
+ if (coding.kind === "identity") return undefined;
536
+ if (coding.kind === "decodable") return coding.format;
537
+ throw new Socks5FetchError("SOCKS5 upstream returned an unsupported content-encoding: " + coding.coding);
538
+ }
539
+
540
+ /** Read response heads until the final one, consuming the interim informational answers. */
541
+ async function finalResponseHead(
542
+ reader: SocketReader,
543
+ signal: AbortSignal,
544
+ ): Promise<{ status: number; statusText: string; headers: Headers }> {
545
+ let head = parseResponseHead(await reader.readUntil(HEADER_END, MAX_RESPONSE_HEADER_BYTES, signal));
546
+ while (head.status >= 100 && head.status < 200 && head.status !== 101) {
547
+ head = parseResponseHead(await reader.readUntil(HEADER_END, MAX_RESPONSE_HEADER_BYTES, signal));
548
+ }
549
+ return head;
550
+ }
551
+
552
+ /**
553
+ * A promise that rejects with the caller's abort reason, and a way to stop listening.
554
+ *
555
+ * Destroying the socket is not enough to end a pending `bodyReader.read()`: that promise belongs
556
+ * to the caller's body stream, which knows nothing about this socket. Racing it against this one
557
+ * is what turns an abort into a settled fetch instead of a permanently pending one.
558
+ */
559
+ function abortRejection(signal: AbortSignal): { promise: Promise<never>; dispose: () => void } {
560
+ let onAbort = (): void => { /* replaced below */ };
561
+ const promise = new Promise<never>((_resolve, reject) => {
562
+ // Reject with the caller's reason exactly, including `null` and `false`. A real
563
+ // `AbortSignal` always has one — `abort()` with no argument supplies an AbortError — so
564
+ // the fallback covers only a hand-built signal that exposes none.
565
+ onAbort = () => reject(signal.reason === undefined ? new Error("The operation was aborted") : signal.reason);
566
+ if (signal.aborted) onAbort();
567
+ else signal.addEventListener("abort", onAbort, { once: true });
568
+ });
569
+ // The race below may settle on another branch first; this rejection must not be reported as
570
+ // unhandled when nobody is left waiting for it.
571
+ promise.catch(() => { /* surfaced by whoever loses the race */ });
572
+ return { promise, dispose: () => signal.removeEventListener("abort", onAbort) };
573
+ }
574
+
575
+ /**
576
+ * The two races below have different possible outcomes, so they have different result types.
577
+ * One union covering both would let the compiler accept reading `value` off a drain result,
578
+ * which is the kind of mistake a type is supposed to catch rather than describe.
579
+ */
580
+ type Answered = { kind: "answered" };
581
+ type BodyStep = { kind: "chunk"; value: Uint8Array } | { kind: "end" } | Answered;
582
+ type DrainStep = { kind: "drained" } | Answered;
583
+
584
+ /**
585
+ * Send the request body, settling on caller abort or on an answer that arrives first.
586
+ *
587
+ * A peer is entitled to answer a request it has not finished receiving — a 413 or a 401 lands
588
+ * while a large body is still going out — and a body stream is entitled to stall. Waiting for
589
+ * the upload to finish before looking at the socket made those two facts combine into a fetch
590
+ * that never settles, with the answer already sitting in the receive buffer.
591
+ *
592
+ * `answered` covers the head settling either way: a final response means the peer has stopped
593
+ * reading, and a terminal socket failure means nothing more can be written. Which of the two it
594
+ * was belongs to whoever awaits the head, so this only needs to know that it happened.
595
+ */
596
+ async function uploadRequestBody(
597
+ socket: Socket,
598
+ request: Request,
599
+ chunked: boolean,
600
+ head: Promise<unknown>,
601
+ ): Promise<void> {
602
+ const body = request.body;
603
+ if (!body) return;
604
+ const signal = request.signal;
605
+ const bodyReader = body.getReader();
606
+ const abort = abortRejection(signal);
607
+ const answered: Promise<Answered> = head.then(
608
+ (): Answered => ({ kind: "answered" }),
609
+ (): Answered => ({ kind: "answered" }),
610
+ );
611
+ let completed = false;
612
+ try {
613
+ for (;;) {
614
+ const read: Promise<BodyStep> = bodyReader.read().then(
615
+ (result): BodyStep => (result.done ? { kind: "end" } : { kind: "chunk", value: result.value }),
616
+ );
617
+ // Releasing the lock below rejects a read still waiting on a source that stopped
618
+ // producing. That rejection is this function's own doing, not a failure to report.
619
+ read.catch(() => { /* settled by releasing the reader */ });
620
+ const step = await Promise.race([read, answered, abort.promise]);
621
+ if (step.kind === "answered") break;
622
+ if (step.kind === "end") {
623
+ completed = true;
624
+ break;
625
+ }
626
+ const payload = chunked
627
+ ? Buffer.concat([Buffer.from(`${step.value.byteLength.toString(16)}\r\n`), Buffer.from(step.value), CRLF])
628
+ : step.value;
629
+ if (socket.write(payload)) continue;
630
+ const drain: Promise<DrainStep> = waitForDrain(socket, signal).then((): DrainStep => ({ kind: "drained" }));
631
+ if ((await Promise.race([drain, answered, abort.promise])).kind === "answered") break;
632
+ }
633
+ // A terminating chunk written after the peer has already answered goes into a conversation
634
+ // that is over, and a peer that has moved on may read it as the head of the next request.
635
+ if (completed && chunked && !socket.write("0\r\n\r\n")) {
636
+ const drain: Promise<DrainStep> = waitForDrain(socket, signal).then((): DrainStep => ({ kind: "drained" }));
637
+ await Promise.race([drain, answered, abort.promise]);
638
+ }
639
+ } finally {
640
+ abort.dispose();
641
+ if (!completed) {
642
+ // Cancelling runs the caller's own cancel algorithm, which is free to never settle, so it
643
+ // is started rather than awaited. Releasing the lock is what actually frees a read still
644
+ // waiting on a body that stopped producing.
645
+ void bodyReader.cancel().catch(() => { /* the caller's stream owns this outcome */ });
646
+ }
647
+ try { bodyReader.releaseLock(); } catch { /* already released */ }
648
+ }
536
649
  }
537
650
 
538
651
  export async function socks5Fetch(
@@ -570,26 +683,16 @@ export async function socks5Fetch(
570
683
  const headers = requestHeaders(request, target);
571
684
  const head = `${request.method} ${target.pathname}${target.search} HTTP/1.1\r\n${headers.text}\r\n`;
572
685
  socket.write(head);
573
- if (request.body) {
574
- const bodyReader = request.body.getReader();
575
- try {
576
- while (true) {
577
- const next = await bodyReader.read();
578
- if (next.done) break;
579
- const body = headers.chunked
580
- ? Buffer.concat([Buffer.from(`${next.value.byteLength.toString(16)}\r\n`), Buffer.from(next.value), CRLF])
581
- : next.value;
582
- if (!socket.write(body)) await waitForDrain(socket, request.signal);
583
- }
584
- if (headers.chunked && !socket.write("0\r\n\r\n")) await waitForDrain(socket, request.signal);
585
- } finally {
586
- bodyReader.releaseLock();
587
- }
588
- }
589
- let responseHead = parseResponseHead(await reader.readUntil(HEADER_END, MAX_RESPONSE_HEADER_BYTES, request.signal));
590
- while (responseHead.status >= 100 && responseHead.status < 200 && responseHead.status !== 101) {
591
- responseHead = parseResponseHead(await reader.readUntil(HEADER_END, MAX_RESPONSE_HEADER_BYTES, request.signal));
592
- }
686
+ // Start reading the answer before the upload finishes, and keep one consumer of the socket
687
+ // reader for the whole exchange. The upload races this promise so an early final response or
688
+ // a terminal socket failure ends it, instead of leaving it waiting on a body chunk that is
689
+ // never coming while the answer sits in the receive buffer.
690
+ const pendingHead = finalResponseHead(reader, request.signal);
691
+ // The upload observes this promise through a handler of its own; this one keeps a rejection
692
+ // from being reported as unhandled in the window before the await below.
693
+ pendingHead.catch(() => { /* rethrown by the await below */ });
694
+ await uploadRequestBody(socket, request, headers.chunked, pendingHead);
695
+ const responseHead = await pendingHead;
593
696
  // A bodyless response has no coded bytes to undo, so its `content-encoding` describes the
594
697
  // representation it would have sent and must neither be decoded nor refused — a HEAD whose
595
698
  // peer advertises brotli is a correct answer, not an unreadable body. For everything else,
@@ -626,6 +729,13 @@ export async function socks5Fetch(
626
729
  request.signal.removeEventListener("abort", onAbort);
627
730
  reader?.dispose();
628
731
  socket.destroy();
732
+ // An aborted caller gets its own reason back. Several waiters inside this transport can win
733
+ // the race that settles an abort, and they do not agree on what to do with a reason they
734
+ // consider absent: the socket reader substitutes an Error for a null reason, others coerce
735
+ // anything that is not an Error. Without this, the same `abort(null)` surfaces as an Error
736
+ // or as null depending on scheduling. `undefined` is left alone so a hand-built signal
737
+ // still reaches the thrown cause below.
738
+ if (request.signal.aborted && request.signal.reason !== undefined) throw request.signal.reason;
629
739
  throw error;
630
740
  }
631
741
  }