@bitkyc08/opencodex 2.58.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 (303) hide show
  1. package/README.md +28 -10
  2. package/gui/dist/assets/index-BTuCbqQd.css +1 -0
  3. package/gui/dist/assets/index-DoBVdPHP.js +134 -0
  4. package/gui/dist/index.html +2 -2
  5. package/gui/dist/provider-icons/crusoe.svg +1 -0
  6. package/gui/dist/provider-icons/opper.svg +3 -0
  7. package/package.json +4 -1
  8. package/src/adapters/anthropic-image-codec.ts +16 -2
  9. package/src/adapters/anthropic-image-normalize.ts +49 -2
  10. package/src/adapters/anthropic.ts +4 -1
  11. package/src/adapters/base.ts +34 -1
  12. package/src/adapters/coding-agent/turn.ts +22 -2
  13. package/src/adapters/command-code.ts +50 -3
  14. package/src/adapters/cursor/catalog.ts +11 -0
  15. package/src/adapters/cursor/checkpoint-store.ts +3 -0
  16. package/src/adapters/cursor/discovery.ts +11 -8
  17. package/src/adapters/cursor/effort-map.ts +16 -2
  18. package/src/adapters/cursor/envelope-echo.ts +55 -2
  19. package/src/adapters/cursor/live-transport.ts +26 -9
  20. package/src/adapters/cursor/message-mapper.ts +3 -2
  21. package/src/adapters/cursor/protobuf-request.ts +8 -5
  22. package/src/adapters/cursor/request-builder.ts +21 -4
  23. package/src/adapters/cursor/thread-continuity.ts +105 -31
  24. package/src/adapters/cursor/tool-guidance.ts +5 -4
  25. package/src/adapters/cursor/transport.ts +19 -0
  26. package/src/adapters/cursor.ts +45 -2
  27. package/src/adapters/devin/cloud-direct/chat.ts +14 -3
  28. package/src/adapters/devin/cloud-direct/index.ts +7 -0
  29. package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +140 -0
  30. package/src/adapters/devin.ts +125 -26
  31. package/src/adapters/google-antigravity-replay.ts +1 -1
  32. package/src/adapters/google-antigravity-wire.ts +55 -4
  33. package/src/adapters/google-http.ts +57 -11
  34. package/src/adapters/google-tool-schema.ts +595 -31
  35. package/src/adapters/google-wire-compiler.ts +93 -10
  36. package/src/adapters/google-wire-shape.ts +461 -0
  37. package/src/adapters/google.ts +60 -10
  38. package/src/adapters/openai-chat/response-events.ts +61 -0
  39. package/src/adapters/openai-chat-images.ts +3 -1
  40. package/src/adapters/openai-chat.ts +10 -11
  41. package/src/adapters/openai-responses/image-gen.ts +8 -6
  42. package/src/adapters/openai-responses/passthrough.ts +25 -4
  43. package/src/adapters/openai-responses/reasoning.ts +7 -0
  44. package/src/adapters/openai-responses/tool-output-recovery.ts +75 -0
  45. package/src/adapters/openai-responses/tool-schema.ts +19 -7
  46. package/src/adapters/opencode-go-additional-tools.ts +12 -2
  47. package/src/adapters/responses-tool-schema.ts +76 -46
  48. package/src/adapters/run-turn-queue.ts +17 -4
  49. package/src/bridge/response-json.ts +1 -1
  50. package/src/bridge/sse.ts +28 -35
  51. package/src/claude/context-windows.ts +22 -0
  52. package/src/claude/outbound.ts +35 -4
  53. package/src/cli/account-api.ts +4 -3
  54. package/src/cli/account-extended.ts +26 -6
  55. package/src/cli/account-orca-import.ts +63 -0
  56. package/src/cli/account.ts +32 -4
  57. package/src/cli/capabilities.ts +40 -0
  58. package/src/cli/claude.ts +29 -1
  59. package/src/cli/codex-cli-update.ts +97 -2
  60. package/src/cli/dispatch.ts +57 -3
  61. package/src/cli/doctor.ts +218 -4
  62. package/src/cli/help.ts +4 -1
  63. package/src/cli/hub.ts +3 -2
  64. package/src/cli/index.ts +95 -22
  65. package/src/cli/models-runtime.ts +33 -4
  66. package/src/cli/opencode.ts +2 -2
  67. package/src/cli/provider.ts +13 -1
  68. package/src/cli/registry.ts +11 -1
  69. package/src/cli/runtime-api.ts +44 -0
  70. package/src/cli/start-args.ts +94 -0
  71. package/src/cli/system-command.ts +2 -0
  72. package/src/client/machine-api.ts +6 -5
  73. package/src/client/machine-listener.ts +16 -3
  74. package/src/client/runtime.ts +26 -2
  75. package/src/clients/config-export/constants.ts +2 -3
  76. package/src/clients/config-export.ts +5 -5
  77. package/src/codex/account-store.ts +146 -5
  78. package/src/codex/auth-api/account-list.ts +19 -11
  79. package/src/codex/auth-api/pool-quota-probe.ts +44 -10
  80. package/src/codex/auth-api/routes.ts +17 -2
  81. package/src/codex/auth-context.ts +16 -12
  82. package/src/codex/catalog/build-entries.ts +25 -4
  83. package/src/codex/catalog/derive-entry.ts +8 -1
  84. package/src/codex/catalog/effort.ts +10 -6
  85. package/src/codex/catalog/gather-capture.ts +22 -2
  86. package/src/codex/catalog/model-hints.ts +66 -33
  87. package/src/codex/catalog/parsing.ts +90 -5
  88. package/src/codex/catalog/provider-models.ts +19 -2
  89. package/src/codex/catalog/reserve-warn.ts +96 -0
  90. package/src/codex/catalog/retained-sync.ts +41 -26
  91. package/src/codex/catalog/routed-gather.ts +61 -3
  92. package/src/codex/cli-installation-identity.ts +210 -0
  93. package/src/codex/cli-installation-targets.ts +158 -0
  94. package/src/codex/context-compat.ts +5 -2
  95. package/src/codex/convergence.ts +5 -0
  96. package/src/codex/desired-state.ts +4 -1
  97. package/src/codex/history-job.ts +6 -6
  98. package/src/codex/history-provider.ts +24 -167
  99. package/src/codex/history-rollout-read.ts +174 -0
  100. package/src/codex/history-state-open.ts +105 -0
  101. package/src/codex/inject/config-toml.ts +44 -2
  102. package/src/codex/inject.ts +3 -2
  103. package/src/codex/internal/catalog-writer.ts +33 -1
  104. package/src/codex/lineage.ts +83 -32
  105. package/src/codex/loopback-target.ts +31 -0
  106. package/src/codex/main-account-hard-lock.ts +2 -1
  107. package/src/codex/main-account.ts +10 -3
  108. package/src/codex/main-device-reauth.ts +17 -9
  109. package/src/codex/model-cache.ts +47 -0
  110. package/src/codex/model-entitlements.ts +80 -2
  111. package/src/codex/observed-model-denials.ts +230 -0
  112. package/src/codex/orca-auth-source.ts +94 -0
  113. package/src/codex/orca-import.ts +219 -0
  114. package/src/codex/prompt-text-probe.ts +289 -16
  115. package/src/codex/quota-401-recovery.ts +12 -0
  116. package/src/codex/quota-types.ts +65 -0
  117. package/src/codex/quota.ts +24 -19
  118. package/src/codex/routing/cooldown-math.ts +8 -47
  119. package/src/codex/routing/pin-drain.ts +57 -0
  120. package/src/codex/routing.ts +20 -16
  121. package/src/codex/shim.ts +1 -1
  122. package/src/codex/subagent-model-fallback.ts +114 -2
  123. package/src/codex/windows-installation-files.ts +224 -0
  124. package/src/combos/failover.ts +125 -5
  125. package/src/config/admitted-identity.ts +222 -0
  126. package/src/config/diagnostics.ts +43 -1
  127. package/src/config/feature-flags.ts +5 -0
  128. package/src/config/load-degrade.ts +33 -0
  129. package/src/config/pending-teardown.ts +8 -0
  130. package/src/config/process-state.ts +36 -3
  131. package/src/config/provider-relative-send-path.ts +16 -0
  132. package/src/config/proxy-env.ts +31 -7
  133. package/src/config/schema/compaction-triggers.ts +11 -0
  134. package/src/config/schema/config-schema.ts +25 -0
  135. package/src/config/schema/leaf-validators.ts +76 -17
  136. package/src/config.ts +2 -2
  137. package/src/generated/compatibility-version.json +410 -258
  138. package/src/generated/model-metadata.ts +1 -1
  139. package/src/grok/reset-coupons.ts +38 -19
  140. package/src/images/loop.ts +6 -1
  141. package/src/integrations/aside-profile-context.ts +37 -3
  142. package/src/integrations/aside-profile-journal.ts +68 -3
  143. package/src/integrations/aside-profiles.ts +128 -3
  144. package/src/integrations/mutation-plan.ts +815 -0
  145. package/src/integrations/writer.ts +85 -99
  146. package/src/lab/live/transport.ts +4 -0
  147. package/src/lab/live/types.ts +5 -0
  148. package/src/lab/subject/behavior-fingerprint.ts +1 -1
  149. package/src/lib/admin-secrets.ts +9 -1
  150. package/src/lib/bounded-body.ts +4 -2
  151. package/src/lib/debug-log-buffer.ts +6 -1
  152. package/src/lib/debug.ts +23 -0
  153. package/src/lib/destination-policy.ts +48 -6
  154. package/src/lib/errors.ts +82 -15
  155. package/src/lib/http-response-semantics.ts +57 -0
  156. package/src/lib/lab-live-pinned-sender.ts +26 -12
  157. package/src/lib/local-destinations.ts +32 -5
  158. package/src/lib/pinned-http.ts +142 -2
  159. package/src/lib/plain-data.ts +103 -0
  160. package/src/lib/process-control.ts +13 -5
  161. package/src/lib/provider-outbound.ts +53 -5
  162. package/src/lib/proxy-env.ts +70 -3
  163. package/src/lib/request-execution-budget.ts +11 -3
  164. package/src/lib/response-body-inactivity.ts +193 -0
  165. package/src/lib/retry-delay.ts +69 -0
  166. package/src/lib/socks5-fetch.ts +741 -0
  167. package/src/lib/spend-ledger-owner.ts +364 -0
  168. package/src/lib/spend-reservation-ledger.ts +332 -35
  169. package/src/lib/windows-system-proxy.ts +16 -11
  170. package/src/lib/workflow-budget.ts +145 -8
  171. package/src/oauth/account-quota-rank.ts +72 -15
  172. package/src/oauth/callback-server.ts +4 -3
  173. package/src/oauth/generic-account-failover.ts +41 -27
  174. package/src/oauth/health.ts +12 -1
  175. package/src/oauth/index.ts +3 -107
  176. package/src/oauth/login-flow-state.ts +127 -0
  177. package/src/oauth/orcarouter.ts +15 -2
  178. package/src/oauth/store.ts +8 -0
  179. package/src/providers/codex-capacity.ts +9 -0
  180. package/src/providers/derive.ts +34 -17
  181. package/src/providers/devin-cli-authmode-migration.ts +14 -10
  182. package/src/providers/devin-provider-merge-migration.ts +33 -12
  183. package/src/providers/free-directory.ts +20 -2
  184. package/src/providers/key-failover.ts +327 -25
  185. package/src/providers/model-rename-migration.ts +56 -1
  186. package/src/providers/model-rename-startup.ts +7 -5
  187. package/src/providers/openai-sidecar.ts +4 -0
  188. package/src/providers/openai-virtual-models.ts +42 -2
  189. package/src/providers/opencode-go-transport.ts +14 -5
  190. package/src/providers/quota/antigravity.ts +22 -2
  191. package/src/providers/quota/report-cache.ts +3 -0
  192. package/src/providers/quota/vendor-probes-key.ts +1 -1
  193. package/src/providers/registry/entries-core.ts +39 -17
  194. package/src/providers/registry/entries-extended.ts +131 -1
  195. package/src/providers/registry/model-ids.ts +168 -0
  196. package/src/providers/registry/model-seeds.ts +87 -21
  197. package/src/providers/registry/types.ts +2 -0
  198. package/src/providers/resolved-model-policy-merge.ts +167 -0
  199. package/src/providers/resolved-model-policy.ts +406 -0
  200. package/src/providers/stale-vision-classification-migration.ts +137 -0
  201. package/src/responses/apply-patch-envelope.ts +32 -11
  202. package/src/responses/bridge-search-replay-cache.ts +152 -0
  203. package/src/responses/code-mode-helper-compat.ts +26 -16
  204. package/src/responses/custom-tool-compat.ts +1 -1
  205. package/src/responses/freeform-wrapper-scan.ts +279 -0
  206. package/src/responses/hosted-tool-policy.ts +85 -2
  207. package/src/responses/legacy-dotted-tool-name-repair.ts +134 -0
  208. package/src/responses/progressive-freeform-input.ts +130 -0
  209. package/src/responses/reasoning-envelope.ts +30 -0
  210. package/src/responses/schema.ts +9 -2
  211. package/src/responses/state.ts +5 -12
  212. package/src/responses/tool-name-aliases.ts +15 -1
  213. package/src/router.ts +91 -115
  214. package/src/routing/compatibility/behavior.ts +9 -0
  215. package/src/routing/compatibility/subject.ts +16 -1
  216. package/src/server/adapter-resolve.ts +9 -0
  217. package/src/server/auth-cors.ts +29 -0
  218. package/src/server/chat-completions.ts +13 -5
  219. package/src/server/chat-native-sse.ts +26 -9
  220. package/src/server/chat-native.ts +10 -4
  221. package/src/server/claude-messages.ts +30 -5
  222. package/src/server/effort-row.ts +11 -3
  223. package/src/server/grok-responses-control-frame.ts +160 -1
  224. package/src/server/gui-static.ts +36 -2
  225. package/src/server/inbound-body-admission.ts +187 -0
  226. package/src/server/index/serve-options.ts +86 -29
  227. package/src/server/index/spend-ledger-lifecycle.ts +66 -0
  228. package/src/server/index/websocket-handler.ts +6 -1
  229. package/src/server/index.ts +24 -25
  230. package/src/server/management/api-access.ts +3 -4
  231. package/src/server/management/aside-profile-routes.ts +266 -7
  232. package/src/server/management/config-routes.ts +48 -7
  233. package/src/server/management/context.ts +3 -0
  234. package/src/server/management/integration-routes.ts +287 -5
  235. package/src/server/management/metrics-routes.ts +20 -0
  236. package/src/server/management/model-rows.ts +224 -12
  237. package/src/server/management/provider-capability-config.ts +35 -7
  238. package/src/server/management/provider-routes.ts +70 -18
  239. package/src/server/management/route-registry.ts +14 -0
  240. package/src/server/management/shared.ts +10 -3
  241. package/src/server/management/system-restart.ts +7 -2
  242. package/src/server/management/system-routes.ts +2 -0
  243. package/src/server/management/usage-aggregate-cache.ts +4 -0
  244. package/src/server/management-api.ts +2 -0
  245. package/src/server/management-auth.ts +15 -1
  246. package/src/server/proxy-liveness.ts +97 -2
  247. package/src/server/readiness.ts +29 -10
  248. package/src/server/relay-eager.ts +24 -2
  249. package/src/server/relay.ts +136 -34
  250. package/src/server/request-log.ts +80 -3
  251. package/src/server/request-metrics.ts +236 -0
  252. package/src/server/responses/adapter-continuation.ts +74 -30
  253. package/src/server/responses/adapter-delivery.ts +39 -8
  254. package/src/server/responses/adapter-dispatch.ts +63 -30
  255. package/src/server/responses/compact.ts +89 -18
  256. package/src/server/responses/compaction-routing.ts +111 -0
  257. package/src/server/responses/core-codex-account.ts +90 -24
  258. package/src/server/responses/core-combo.ts +7 -7
  259. package/src/server/responses/core-normalize.ts +16 -15
  260. package/src/server/responses/core-opaque-recovery.ts +1 -0
  261. package/src/server/responses/core-options.ts +4 -0
  262. package/src/server/responses/encrypted-payload.ts +20 -2
  263. package/src/server/responses/fetch-helpers.ts +68 -2
  264. package/src/server/responses/passthrough-delivery.ts +41 -15
  265. package/src/server/responses/passthrough-dispatch.ts +145 -53
  266. package/src/server/responses/passthrough-execution.ts +11 -1
  267. package/src/server/responses/policy-fallback.ts +5 -13
  268. package/src/server/responses/request-prepare.ts +96 -17
  269. package/src/server/responses/request-send-budget.ts +89 -8
  270. package/src/server/responses/request-sidecar-auth.ts +17 -9
  271. package/src/server/responses/request-spend.ts +38 -9
  272. package/src/server/responses/request-transport.ts +15 -12
  273. package/src/server/responses/run-turn-execution.ts +45 -8
  274. package/src/server/responses/sidecar-execution.ts +19 -2
  275. package/src/server/responses/ws-upstream.ts +16 -28
  276. package/src/server/responses-custom-tool-repair.ts +29 -56
  277. package/src/server/responses-undeclared-tool-guard.ts +31 -1
  278. package/src/server/sse-frame-buffer.ts +12 -10
  279. package/src/server/sse-payload-rewrite.ts +37 -10
  280. package/src/server/system-env-shell.ts +5 -1
  281. package/src/server/system-env.ts +7 -1
  282. package/src/server/workflow-refusal.ts +56 -2
  283. package/src/service/cli.ts +16 -6
  284. package/src/service/guards.ts +10 -0
  285. package/src/service/health.ts +43 -0
  286. package/src/service/state.ts +7 -2
  287. package/src/tray/windows-tray.ps1 +155 -3
  288. package/src/types/accounts.ts +4 -0
  289. package/src/types/config.ts +111 -6
  290. package/src/types/provider.ts +37 -0
  291. package/src/types/request.ts +9 -1
  292. package/src/types/tools.ts +14 -0
  293. package/src/types/wire.ts +9 -1
  294. package/src/types.ts +1 -0
  295. package/src/usage/expected-prices.ts +28 -0
  296. package/src/usage/log.ts +87 -4
  297. package/src/vision/eligibility.ts +88 -9
  298. package/src/vision/plan.ts +34 -10
  299. package/src/web-search/executor.ts +41 -2
  300. package/src/web-search/loop.ts +6 -1
  301. package/src/web-search/passthrough-bridge.ts +39 -5
  302. package/gui/dist/assets/index-BbrHOIY0.js +0 -128
  303. package/gui/dist/assets/index-C5-RdDmD.css +0 -1
@@ -11,12 +11,14 @@
11
11
  */
12
12
  import { homedir } from "node:os";
13
13
  import { createClineIO, ClineTransactionError } from "./cline-io";
14
- import { parseClineDocument, serializeClineDocument, preserveClineSelection } from "./cline-document";
14
+ import { serializeClineDocument, preserveClineSelection } from "./cline-document";
15
15
  import { dirname } from "node:path";
16
16
  import { EXPORT_CLIENTS, type ExportModel, type ManagedContribution } from "../clients/config-export";
17
17
  import { shouldInjectApiAuthHeader } from "../codex/inject";
18
+ import { detachedConfigSnapshot } from "../config/admitted-identity";
19
+ import { copyPlainData } from "../lib/plain-data";
18
20
  import type { OcxConfig } from "../types";
19
- import { PARSE_FAILED, defaultIntegrationIO, loadTarget, parseConfig, type IntegrationIO } from "./config-io";
21
+ import { defaultIntegrationIO, loadTarget, type IntegrationIO } from "./config-io";
20
22
  import {
21
23
  fingerprint,
22
24
  canonicalContribution,
@@ -31,11 +33,12 @@ import {
31
33
  } from "./ownership-policy";
32
34
  import { AmbiguousSelectorError, createdContainerPaths, mergeContribution, removeFragments } from "./merge";
33
35
  import { INTEGRATION_CLIENTS, isLoopbackOnly, resolveIntegrationPaths, type IntegrationClientId } from "./registry";
34
- import { classifyIntegration, exportContextOf } from "./state";
36
+ import { exportContextOf } from "./state";
35
37
  import type { IntegrationState } from "./state";
36
38
  import { serializeDocument, UnserializableValueError } from "./serialize";
37
39
  import { ClientPathError } from "../clients/config-export";
38
40
  import { matchesOperationResult, newOpId, type JournalEntry } from "./journal";
41
+ import { observeIntegration, type IntegrationWriteInput, type RefusalReason } from "./mutation-plan";
39
42
  import { createIntegrationStateStore, type IntegrationStateStore } from "./store";
40
43
  import {
41
44
  patchYamlFragmentSource,
@@ -62,14 +65,12 @@ function yamlRefusalReason(
62
65
  }
63
66
  import { withIntegrationWriterLock, type IntegrationWriterLockSeams } from "./writer-lock";
64
67
 
65
- export type RefusalReason =
66
- | "not_installed"
67
- | "conflict"
68
- | "unsafe"
69
- | "non_loopback"
70
- | "drift_requires_confirm"
71
- | "snapshot_expired"
72
- | "write_failed";
68
+ /**
69
+ * Owned by the planner so a plan can report a refusal without depending on the writer, and
70
+ * re-exported here because this module was its original home and every caller imports it from
71
+ * the writer.
72
+ */
73
+ export type { IntegrationWriteInput, RefusalReason };
73
74
 
74
75
  export interface WriteOk {
75
76
  ok: true;
@@ -94,19 +95,6 @@ export interface WriteRefused {
94
95
 
95
96
  export type WriteOutcome = WriteOk | WriteRefused;
96
97
 
97
- export interface IntegrationWriteInput {
98
- clientId: IntegrationClientId;
99
- models: readonly ExportModel[];
100
- config: OcxConfig;
101
- port: number;
102
- env?: NodeJS.ProcessEnv;
103
- home?: string;
104
- store?: IntegrationStateStore;
105
- io?: IntegrationIO;
106
- /** Frozen once by the async coordinator; synchronous callers may omit it. */
107
- resolvedPaths?: { configPath: string; detectDir: string };
108
- }
109
-
110
98
  export interface IntegrationRestoreInput extends IntegrationWriteInput {
111
99
  opId: string;
112
100
  confirmDrift?: boolean;
@@ -227,76 +215,20 @@ function sourcePreservingFragmentValue(
227
215
  return fragment?.value;
228
216
  }
229
217
 
230
- /** Shared preflight: detect, gate, read, parse and classify. */
218
+ /**
219
+ * Mutation's view of the shared observation.
220
+ *
221
+ * The read, parse and classify live in the planner so a preview and the mutation it authorizes
222
+ * cannot disagree. Mutation keeps the effects a preview must not have: pending-prune maintenance
223
+ * and Cline transaction recovery both write. Translating the planner's refusal into WriteRefused
224
+ * here keeps the planner free of any dependency on this module's result type.
225
+ */
231
226
  function preflight(input: IntegrationWriteInput) {
232
- const store = input.store ?? createIntegrationStateStore();
233
- let io = input.io ?? defaultIntegrationIO(store);
234
- const clientId = input.clientId;
235
- const spec = INTEGRATION_CLIENTS[clientId];
236
- const exportSpec = EXPORT_CLIENTS[clientId];
237
- /*
238
- * Resolution itself can refuse: a relative OPENCLAW_* selector is rejected
239
- * because we cannot know the gateway's working directory. That is a refusal
240
- * about the user's configuration, not an internal fault, so it must not
241
- * escape as an exception — the collection route would answer 500 for the
242
- * whole Integrations page because one client is misconfigured.
243
- */
244
- let configPath: string;
245
- let detectDir: string;
246
- try {
247
- /*
248
- * Resolve the PAIR, never one half.
249
- *
250
- * The coordinated path hands us a frozen pair, but applyIntegration,
251
- * refreshIntegration and disableIntegration are public and may be called
252
- * without one. Resolving configPath here and detectDir separately later let
253
- * an Aside account switch land between the two, so a direct apply could
254
- * verify account 1 was installed and then write account 0's catalog.
255
- */
256
- const resolved = input.resolvedPaths ?? resolveIntegrationPaths(clientId, input.env, input.home);
257
- configPath = resolved.configPath;
258
- detectDir = resolved.detectDir;
259
- if (clientId === "cline") io = createClineIO(io, configPath, store, true);
260
- } catch (error) {
261
- if (error instanceof ClineTransactionError) {
262
- return { failed: { ...refuse(clientId, "unsafe", "unsafe", error.message, error.snapshotPath), residual: true } } as const;
263
- }
264
- if (!(error instanceof ClientPathError)) throw error;
265
- return { failed: refuse(clientId, "unsafe", "unsafe", error.message) } as const;
266
- }
267
- store.retryPendingPrunes();
268
-
269
- const target = loadTarget(io, configPath);
270
- if (!target.ok) {
271
- return {
272
- failed: refuse(clientId, "unsafe", "unsafe",
273
- target.why === "read-failed"
274
- ? `${configPath} exists but could not be read`
275
- : `${configPath} is not a regular file`),
276
- } as const;
277
- }
278
- const before = target.before;
279
- const parsed = clientId === "cline" ? parseClineDocument(before) : parseConfig(before, exportSpec.format);
280
- if (parsed === PARSE_FAILED) {
281
- return { failed: refuse(clientId, "unsafe", "unsafe",
282
- `${configPath} could not be parsed, or holds something opencodex cannot rewrite without changing it (a non-finite number, a large integer or a tiny one a rewrite would round, -0, a duplicate member, or nesting deeper than 1000 levels)`) } as const;
283
- }
284
- const contribution = exportSpec.buildContribution(exportContextOf(input));
285
- // A record proves ownership of the file it was written FOR. Matching only by
286
- // client id let a record for one home authorize a write to another whose
287
- // bytes happened to hash the same — which deleted a config we never touched.
288
- const stored = store.readRecords()[clientId] ?? null;
289
- const record = stored && stored.clientId === clientId && stored.configPath === configPath
290
- ? stored
291
- : null;
292
- // `configPath`/`clientId` are load-bearing, not decoration: a record proves
293
- // ownership of ONE file, and the writer mutates whatever path resolves NOW.
294
- // Without them a record written for another home directory would grant
295
- // ownership here and disable would delete fragments it never wrote.
296
- const classified = classifyIntegration({
297
- fileText: before, fileIsRegular: true, parsed, record, contribution, configPath, clientId,
298
- });
299
- return { failed: undefined, store, io, clientId, spec, exportSpec, configPath, detectDir, before, parsed, contribution, record, classified } as const;
227
+ const observed = observeIntegration(input, { maintenance: true, recover: true });
228
+ if (!observed.failed) return observed;
229
+ const { reason, state, message, snapshotPath, residual } = observed.failed;
230
+ const refused = refuse(input.clientId, reason, state, message, snapshotPath);
231
+ return { failed: residual ? { ...refused, residual: true } : refused } as const;
300
232
  }
301
233
 
302
234
  /**
@@ -748,6 +680,15 @@ export function restoreIntegration(input: IntegrationRestoreInput): WriteOutcome
748
680
 
749
681
  export interface CoordinatedIntegrationOptions {
750
682
  lockSeams?: IntegrationWriterLockSeams;
683
+ /**
684
+ * Checked after the input is frozen and the writer lock is held, before any side effect.
685
+ *
686
+ * A confirmation is a statement about state the operator was shown. Checking it out here, where
687
+ * the lock already excludes cooperating writers, is what makes it a decision about the same
688
+ * state the mutation is about to change rather than about state from a moment earlier. A
689
+ * non-null result refuses without writing anything.
690
+ */
691
+ revalidate?: (frozen: IntegrationWriteInput) => Promise<WriteOutcome | null>;
751
692
  }
752
693
 
753
694
  /** Freeze all mutable resolution seams before the first lock await. */
@@ -759,6 +700,16 @@ type FrozenIntegrationInput = IntegrationWriteInput & {
759
700
  resolvedPaths: { configPath: string; detectDir: string };
760
701
  };
761
702
 
703
+ /**
704
+ * An input this write cannot hold still.
705
+ *
706
+ * A coordinated write checks a plan and then writes a document from the same input, with an await
707
+ * in between. Anything it cannot copy would have to be read from the caller's object twice, and
708
+ * the second read is not the one that was checked. Refusing is bounded and says so; carrying the
709
+ * reference and calling it a frozen input would not be.
710
+ */
711
+ class UncopyableIntegrationInputError extends Error {}
712
+
762
713
  function freezeIntegrationInput(input: IntegrationWriteInput): FrozenIntegrationInput {
763
714
  const env = { ...(input.env ?? process.env) };
764
715
  const home = input.home ?? homedir();
@@ -773,7 +724,23 @@ function freezeIntegrationInput(input: IntegrationWriteInput): FrozenIntegration
773
724
  const resolvedPaths = input.resolvedPaths
774
725
  ? { ...input.resolvedPaths }
775
726
  : resolveIntegrationPaths(input.clientId, env, home);
776
- return { ...input, env, home, store, io, resolvedPaths };
727
+ /*
728
+ * The configuration and the roster are seams like the others, and they were the two still held
729
+ * by reference. A coordinated write plans from this input, awaits the writer lock and a
730
+ * revalidation, and only then serializes the document from it. A management route editing the
731
+ * live configuration, or a caller editing the model objects it passed in, would have been
732
+ * checked in one state and written from another, which is the substitution the fingerprint
733
+ * exists to prevent. Copying both here gives the plan and the document one input.
734
+ */
735
+ const config = detachedConfigSnapshot(input.config);
736
+ if (config === null) {
737
+ throw new UncopyableIntegrationInputError("the proxy configuration could not be captured for this write");
738
+ }
739
+ const models = copyPlainData(input.models);
740
+ if (!models.ok) {
741
+ throw new UncopyableIntegrationInputError("the model roster could not be captured for this write");
742
+ }
743
+ return { ...input, config, models: models.value, env, home, store, io, resolvedPaths };
777
744
  }
778
745
 
779
746
  function tryFreezeIntegrationInput(input: IntegrationWriteInput):
@@ -782,6 +749,9 @@ function tryFreezeIntegrationInput(input: IntegrationWriteInput):
782
749
  try {
783
750
  return { ok: true, value: freezeIntegrationInput(input) };
784
751
  } catch (error) {
752
+ if (error instanceof UncopyableIntegrationInputError) {
753
+ return { ok: false, refusal: refuse(input.clientId, "unsafe", "unsafe", error.message) };
754
+ }
785
755
  if (!(error instanceof ClientPathError)) throw error;
786
756
  return {
787
757
  ok: false,
@@ -799,15 +769,22 @@ async function coordinatedWrite(
799
769
  if (!prepared.ok) return prepared.refusal;
800
770
  const frozen = prepared.value;
801
771
  const spec = INTEGRATION_CLIENTS[frozen.clientId];
802
- if (!spec.writerLock) return operation(frozen);
772
+ if (!spec.writerLock) {
773
+ const refused = await options?.revalidate?.(frozen);
774
+ return refused ?? operation(frozen);
775
+ }
803
776
 
804
777
  // An absent client home is not created merely to acquire a sibling lock.
805
778
  if (frozen.io.statKind(frozen.resolvedPaths.detectDir) !== "dir") {
806
- return operation(frozen);
779
+ const refused = await options?.revalidate?.(frozen);
780
+ return refused ?? operation(frozen);
807
781
  }
808
782
  return withIntegrationWriterLock(
809
783
  frozen.resolvedPaths.configPath,
810
- async () => operation(frozen),
784
+ async () => {
785
+ const refused = await options?.revalidate?.(frozen);
786
+ return refused ?? operation(frozen);
787
+ },
811
788
  options?.lockSeams,
812
789
  spec.writerLock.suffix,
813
790
  );
@@ -849,7 +826,11 @@ export async function restoreIntegrationCoordinated(
849
826
  if (!prepared.ok) return prepared.refusal;
850
827
  const frozen = prepared.value;
851
828
  const spec = INTEGRATION_CLIENTS[frozen.clientId];
852
- if (!spec.writerLock) return restoreIntegration({ ...frozen, opId: input.opId, confirmDrift: input.confirmDrift });
829
+ const run = () => restoreIntegration({ ...frozen, opId: input.opId, confirmDrift: input.confirmDrift });
830
+ if (!spec.writerLock) {
831
+ const refused = await options?.revalidate?.(frozen);
832
+ return refused ?? run();
833
+ }
853
834
  if (frozen.io.statKind(frozen.resolvedPaths.detectDir) !== "dir") {
854
835
  return refuse(
855
836
  frozen.clientId,
@@ -860,7 +841,12 @@ export async function restoreIntegrationCoordinated(
860
841
  }
861
842
  return withIntegrationWriterLock(
862
843
  frozen.resolvedPaths.configPath,
863
- async () => restoreIntegration({ ...frozen, opId: input.opId, confirmDrift: input.confirmDrift }),
844
+ async () => {
845
+ // An undo is bound like any other confirmation, and this is the only place where that check
846
+ // happens with the lock held and before the snapshot, the write and the journal row.
847
+ const refused = await options?.revalidate?.(frozen);
848
+ return refused ?? run();
849
+ },
864
850
  options?.lockSeams,
865
851
  spec.writerLock.suffix,
866
852
  );
@@ -104,6 +104,10 @@ export function classifyTransportError(error: unknown): { classification: Failur
104
104
  case "network_blocked": return { classification: "network_failure", secondaryCode: code };
105
105
  case "region_blocked": return { classification: "region_blocked", secondaryCode: code };
106
106
  case "provider_transient": return { classification: "provider_transient", secondaryCode: code };
107
+ // A response this transport cannot read is an upstream protocol failure. It is deterministic
108
+ // rather than transient, and attributing it to the harness would blame the runner for what
109
+ // the peer sent.
110
+ case "unreadable_response": return { classification: "protocol_failure", secondaryCode: code };
107
111
  case "connect_timeout": case "first_byte_timeout": case "total_timeout":
108
112
  return { classification: "timeout", secondaryCode: code };
109
113
  case "inactivity_timeout":
@@ -89,6 +89,11 @@ export type TransportErrorCode =
89
89
  | "output_byte_limit"
90
90
  | "output_token_limit"
91
91
  | "tool_call_limit"
92
+ // The peer answered, and the answer cannot be read: a content coding this transport cannot
93
+ // undo, or coded bytes that did not decode. Neither is a timeout, a budget, or a fault of
94
+ // the harness, so none of those codes describes it and every one of them would send an
95
+ // operator looking in the wrong place.
96
+ | "unreadable_response"
92
97
  | "artifact_byte_limit"
93
98
  | "memory_limit"
94
99
  | "child_process_limit"
@@ -9,7 +9,7 @@ const CLOSED_KEYS = new Set([
9
9
  "limits.contextWindow", "limits.maxInputTokens", "limits.maxOutputTokens",
10
10
  "modalities.input",
11
11
  "sampling.omitTemperature", "sampling.omitTopP", "sampling.omitPenalties",
12
- "reasoning.supported", "reasoning.efforts", "reasoning.defaultEffort", "reasoning.effortMap", "reasoning.wireFormat",
12
+ "reasoning.supported", "reasoning.efforts", "reasoning.effortsAuthoritative", "reasoning.defaultEffort", "reasoning.effortMap", "reasoning.wireFormat",
13
13
  "reasoning.summaryMode", "reasoning.replayMode", "reasoning.splitMode", "reasoning.toggleMode", "reasoning.budgetMode",
14
14
  "tools.choiceRestrictions", "tools.parallel", "tools.hostedPreference", "tools.customFreeform", "tools.builtinNameEscaping",
15
15
  "cache.forwarding", "cache.retention", "anthropic.eofPolicy", "openai-chat.eofPolicy",
@@ -1,4 +1,4 @@
1
- import { timingSafeEqual } from "node:crypto";
1
+ import { createHmac, timingSafeEqual } from "node:crypto";
2
2
  import { lstatSync, readFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { getConfigDir } from "../config";
@@ -27,6 +27,14 @@ export function configuredAdminToken(configDir = getConfigDir(), env: NodeJS.Pro
27
27
 
28
28
  export const ADMIN_TOKEN_PREFIX = "ocx_admin_";
29
29
 
30
+ /**
31
+ * Derive the bearer used by the OpenCode launcher for its one management read.
32
+ * A listener that captures this value cannot recover or replay the administrator token.
33
+ */
34
+ export function opencodeCatalogToken(adminToken: string): string {
35
+ return `ocx_catalog_${createHmac("sha256", adminToken).update("opencode:/api/models:v1").digest("base64url")}`;
36
+ }
37
+
30
38
  function secretTextEquals(left: string, right: string): boolean {
31
39
  const a = Buffer.from(left);
32
40
  const b = Buffer.from(right);
@@ -129,9 +129,11 @@ export async function readBoundedResponseBytes(
129
129
  options: BoundedBytesOptions,
130
130
  ): Promise<BoundedBytesResult> {
131
131
  const signal = options.signal;
132
- if (signal?.aborted) throw signal.reason;
133
-
134
132
  const body = response.body;
133
+ if (signal?.aborted) {
134
+ if (body) cancelBodyWithoutWaiting(body, signal.reason);
135
+ throw signal.reason;
136
+ }
135
137
  if (!body) return { bytes: new Uint8Array(0), oversized: false };
136
138
 
137
139
  const reader = body.getReader();
@@ -9,7 +9,12 @@ export interface DebugLogEntry {
9
9
 
10
10
  const MAX_LINES = 2_000;
11
11
  const MAX_DEBUG_SUBSCRIBERS = 64;
12
- const MAX_DEBUG_LINE_BYTES = 16 * 1024;
12
+ /**
13
+ * Per-line retention cap. Exported because a producer of a long structured line has to budget
14
+ * BELOW it: this buffer truncates at a byte boundary, so a JSON line cut here stops being
15
+ * parseable while its retained prefix still reads as complete.
16
+ */
17
+ export const MAX_DEBUG_LINE_BYTES = 16 * 1024;
13
18
  const buffer: DebugLogEntry[] = [];
14
19
  interface DebugSubscriberRegistration { lease: AdmissionLease }
15
20
  const listeners = new Map<(entry: DebugLogEntry) => void, DebugSubscriberRegistration>();
package/src/lib/debug.ts CHANGED
@@ -29,3 +29,26 @@ export function debugProviderDiagnostic(adapter: string, event: string, details:
29
29
  /* diagnostics must never affect request handling */
30
30
  }
31
31
  }
32
+
33
+ /**
34
+ * The same diagnostic for details that are expensive to compute or read live request state.
35
+ *
36
+ * The eager form takes an already-built object, so the build runs in ARGUMENT position: outside
37
+ * this module's gate, and outside its try/catch. For a projection that walks a whole request
38
+ * that is wrong twice over — it pays the walk while debug is off, and a throw inside it reaches
39
+ * the caller, which for an adapter means a built request becomes a rejected one and a send that
40
+ * would have happened does not. Pass the builder instead: it runs only when debug is on, and a
41
+ * throw inside it is swallowed exactly like a throw in the emit.
42
+ */
43
+ export function debugProviderDiagnosticLazy(
44
+ adapter: string,
45
+ event: string,
46
+ details: () => Record<string, unknown>,
47
+ ): void {
48
+ if (!isDebugEnabled()) return;
49
+ try {
50
+ emitDebugLine(`[ocx:${adapter}:${event}] ${JSON.stringify(redactSecrets(details()))}`);
51
+ } catch {
52
+ /* diagnostics must never affect request handling */
53
+ }
54
+ }
@@ -240,6 +240,43 @@ function registryAllowsPrivateNetwork(name: string): boolean {
240
240
  return getProviderRegistryEntry(name)?.allowPrivateNetworkByDefault === true;
241
241
  }
242
242
 
243
+ function normalizedCanonicalEndpoint(value: string): string | null {
244
+ try {
245
+ const parsed = new URL(value.trim());
246
+ if (parsed.username || parsed.password || parsed.hash) return null;
247
+ parsed.pathname = parsed.pathname.replace(/\/+$/, "") || "/";
248
+ return parsed.toString().replace(/\/$/, "");
249
+ } catch {
250
+ return null;
251
+ }
252
+ }
253
+
254
+ /**
255
+ * Config-time Fake-IP allowance for the one overridable OAuth provider whose canonical
256
+ * Google endpoint is known to sit behind Clash/Mihomo transparent DNS in the field.
257
+ *
258
+ * Antigravity permits a custom base URL, so provider identity alone is not authority:
259
+ * adapter, auth mode, and the normalized final base URL must still match the registry seed.
260
+ * Custom destinations therefore keep the ordinary fail-closed SSRF policy.
261
+ */
262
+ function registryAllowsBenchmarkDnsAtWriteTime(
263
+ name: string,
264
+ provider: Pick<OcxProviderConfig, "baseUrl"> & Partial<Pick<OcxProviderConfig, "adapter" | "authMode">>,
265
+ ): boolean {
266
+ if (name !== "google-antigravity") return false;
267
+ const entry = getProviderRegistryEntry(name);
268
+ if (
269
+ !entry
270
+ || entry.authKind !== "oauth"
271
+ || entry.allowBaseUrlOverride !== true
272
+ || provider.adapter !== entry.adapter
273
+ || provider.authMode !== "oauth"
274
+ ) return false;
275
+ const configured = normalizedCanonicalEndpoint(provider.baseUrl);
276
+ const canonical = normalizedCanonicalEndpoint(entry.baseUrl);
277
+ return configured !== null && configured === canonical;
278
+ }
279
+
243
280
  /**
244
281
  * OAuth registry entries that opt into `allowBaseUrlOverride` send bearer credentials to a
245
282
  * user-configured endpoint (review findings, PR #2109 / PR #2110): a cleartext `http:`
@@ -328,13 +365,16 @@ export function assertProviderDestinationAllowed(name: string, provider: Pick<Oc
328
365
  * advisory and must not hard-fail offline startups. DNS rebinding after validation is
329
366
  * a recorded residual for this loopback proxy (devlog 260712_pr_batch_landing 000).
330
367
  *
331
- * `allowBenchmarkAddresses` is only for the exact canonical ChatGPT Codex seed under
332
- * Clash fake-IP DNS (198.18.0.0/15). Every other non-public answer — including mixed
333
- * benchmark + private/metadata sets — still fails.
368
+ * `allowBenchmarkAddresses` remains the explicit caller opt-in used by canonical ChatGPT
369
+ * Codex. Canonical Google Antigravity receives the same 198.18.0.0/15 DNS-answer exception
370
+ * only when its adapter/auth/base URL still match the registry seed. Every other non-public
371
+ * answer — including custom Antigravity destinations and mixed benchmark + private/metadata
372
+ * sets — still fails.
334
373
  */
335
374
  export async function providerDestinationResolvedError(
336
375
  name: string,
337
- provider: Pick<OcxProviderConfig, "baseUrl" | "allowPrivateNetwork">,
376
+ provider: Pick<OcxProviderConfig, "baseUrl" | "allowPrivateNetwork">
377
+ & Partial<Pick<OcxProviderConfig, "adapter" | "authMode">>,
338
378
  options?: { allowBenchmarkAddresses?: boolean },
339
379
  ): Promise<string | null> {
340
380
  const syncError = providerDestinationConfigError(name, provider);
@@ -349,6 +389,8 @@ export async function providerDestinationResolvedError(
349
389
  return null; // literals and localhost are fully handled by the sync path
350
390
  }
351
391
  if (providerAllowsPrivateNetwork(name, provider)) return null;
392
+ const allowBenchmarkAddresses = options?.allowBenchmarkAddresses === true
393
+ || registryAllowsBenchmarkDnsAtWriteTime(name, provider);
352
394
  let addresses: { address: string }[];
353
395
  try {
354
396
  addresses = await lookup(hostname, { all: true, verbatim: true });
@@ -360,7 +402,7 @@ export async function providerDestinationResolvedError(
360
402
  const assessment = ipKind === 4 ? classifyIpv4(address) : ipKind === 6 ? classifyIpv6(normalizeHostname(address)) : null;
361
403
  if (!assessment || assessment.kind === "public") continue;
362
404
  // Clash fake-IP only: 198.18/19 benchmark detail. Mixed dangerous sets still reject.
363
- if (options?.allowBenchmarkAddresses && isBenchmarkDnsAnswer(address, assessment)) {
405
+ if (allowBenchmarkAddresses && isBenchmarkDnsAnswer(address, assessment)) {
364
406
  continue;
365
407
  }
366
408
  if (assessment.kind === "metadata") return `baseUrl hostname ${hostname} resolves to a blocked metadata endpoint (${address})`;
@@ -492,4 +534,4 @@ export async function resolvePublicAddresses(
492
534
  */
493
535
  export async function assertUrlResolvesPublic(url: string): Promise<void> {
494
536
  await resolvePublicAddresses(url);
495
- }
537
+ }
package/src/lib/errors.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { parseRetryAfterFromMessage } from "./retry-delay";
2
+
1
3
  export interface OcxErrorPayload {
2
4
  message: string;
3
5
  type: string;
@@ -72,6 +74,75 @@ export function isCyberPolicyMessage(text: string): boolean {
72
74
  return false;
73
75
  }
74
76
 
77
+ /**
78
+ * Refusal codes Codex ends a turn on.
79
+ *
80
+ * Its Responses parser classifies a `response.failed` terminal by `error.code`
81
+ * alone (codex-rs/codex-api/src/sse/responses.rs:423-462). These four become a
82
+ * fatal `ApiError` the client reports instead of reconnecting. A code outside
83
+ * this set is never read as a refusal: `server_is_overloaded` and `slow_down`
84
+ * take the overload arm, `rate_limit_exceeded` takes the rate-limit arm, and
85
+ * everything else falls through to `ApiError::Retryable` and is reconnected up
86
+ * to `stream_max_retries`.
87
+ *
88
+ * Deliberately scoped to content refusals. `insufficient_quota` and
89
+ * `context_length_exceeded` are terminal for Codex too, but they are separate
90
+ * failure families this proxy already classifies through its own quota and
91
+ * context paths, and pulling them in here would change their retry behavior
92
+ * with no evidence asking for it.
93
+ */
94
+ const TERMINAL_REFUSAL_CODES = new Set<string>([
95
+ CYBER_POLICY_ERROR_CODE,
96
+ "misalignment_policy_violation",
97
+ "invalid_prompt",
98
+ "bio_policy",
99
+ ]);
100
+
101
+ /** True when an upstream error code is a refusal the client must not retry. */
102
+ export function isTerminalRefusalCode(code: string | null | undefined): boolean {
103
+ return typeof code === "string" && TERMINAL_REFUSAL_CODES.has(code);
104
+ }
105
+
106
+ /** Stand-in copy for a refusal the upstream sent without a message of its own. */
107
+ export const TERMINAL_REFUSAL_FALLBACK_MESSAGE = "The upstream refused this request.";
108
+
109
+ /** Readable copy for a refusal code, used when the upstream carried no message. */
110
+ export function terminalRefusalFallbackMessage(code: string): string {
111
+ return code === CYBER_POLICY_ERROR_CODE
112
+ ? CYBER_POLICY_FALLBACK_MESSAGE
113
+ : TERMINAL_REFUSAL_FALLBACK_MESSAGE;
114
+ }
115
+
116
+ /**
117
+ * Name the refusal code behind upstream safety copy when the structured code
118
+ * was not carried on the wire.
119
+ *
120
+ * Same discipline as {@link isCyberPolicyMessage}: whole distinctive phrases
121
+ * only. A loose match here is the mirror-image defect — it would end a turn
122
+ * that a genuine transient failure would have retried successfully — so a
123
+ * message that merely mentions safety or blocking does not qualify. Callers
124
+ * must consult this only when the upstream carried no structured code at all,
125
+ * so that a transport failure quoting a refusal in its diagnostic text cannot
126
+ * be promoted past the verdict the upstream actually gave.
127
+ *
128
+ * Copy provenance: "limited access to this content for safety reasons" is the
129
+ * prefix Codex itself matches (tui/src/chatwidget/turn_runtime.rs:8-15) and
130
+ * pairs with `invalid_prompt` in its parser fixture (sse/responses.rs:1358-1366),
131
+ * which also pairs "flagged for possible biological risk" with `bio_policy`.
132
+ * "blocked by our safety systems" is the copy reported in #5176; pairing it
133
+ * with `invalid_prompt` is an inference from the two verified pairings above,
134
+ * not something the upstream source states.
135
+ */
136
+ export function safetyRefusalCodeFromMessage(text: string): string | undefined {
137
+ const lower = String(text ?? "").toLowerCase();
138
+ if (lower.includes("flagged for possible biological risk")) return "bio_policy";
139
+ if (
140
+ lower.includes("blocked by our safety systems")
141
+ || lower.includes("limited access to this content for safety reasons")
142
+ ) return "invalid_prompt";
143
+ return undefined;
144
+ }
145
+
75
146
  function isSubscriptionGateMessage(text: string): boolean {
76
147
  return (
77
148
  text.includes("requires a subscription") ||
@@ -185,6 +256,16 @@ export function isClientClosedMessage(text: string): boolean {
185
256
  );
186
257
  }
187
258
 
259
+ /**
260
+ * Ambiguous-reset refusal wording owned by this proxy (src/lib/upstream-retry.ts):
261
+ * the upstream connection closed before any response arrived, so the request may
262
+ * already have been processed and automatic replay was stopped. Matched narrowly
263
+ * so a provider-sent message is never relabeled by it.
264
+ */
265
+ export function isUpstreamResetReplayRefusedMessage(text: string): boolean {
266
+ return text.toLowerCase().includes("connection closed before a response was received");
267
+ }
268
+
188
269
  export function classifyError(status: number, type: string, message: string): OcxErrorPayload {
189
270
  const text = message.toLowerCase();
190
271
  if (type === "previous_response_not_found") {
@@ -376,21 +457,7 @@ export function isRateLimitOrQuotaFailureMessage(message: string): boolean {
376
457
  return normalized.toLowerCase().includes("usage limit");
377
458
  }
378
459
 
379
- /** Best-effort parse of a retry delay embedded in an upstream error message. */
380
- export function parseRetryAfterFromMessage(message: string): number | undefined {
381
- const patterns = [
382
- /try again in (\d+(?:\.\d+)?)\s*s(?:ec(?:ond)?s?)?/i,
383
- /retry after (\d+(?:\.\d+)?)\s*s(?:ec(?:ond)?s?)?/i,
384
- /retry[- ]after[:\s]+(\d+)/i,
385
- ];
386
- for (const pattern of patterns) {
387
- const match = message.match(pattern);
388
- if (!match?.[1]) continue;
389
- const seconds = Number.parseFloat(match[1]);
390
- if (Number.isFinite(seconds) && seconds > 0) return Math.ceil(seconds);
391
- }
392
- return undefined;
393
- }
460
+ export { parseRetryAfterFromMessage };
394
461
 
395
462
  /** Infer HTTP status from adapter terminal error text (provider-agnostic keyword matching). */
396
463
  export function inferHttpStatusFromAdapterMessage(message: string): number {
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Response rules both raw outbound transports have to apply themselves.
3
+ *
4
+ * `fetch` applies them below the Response constructor. `src/lib/pinned-http.ts` and
5
+ * `src/lib/socks5-fetch.ts` assemble a Response from a socket instead, so each one answers the
6
+ * same questions on its own — and a rule written twice is a rule that drifts. It already had:
7
+ * the SOCKS helper excluded 204 and the pinned helper excluded nothing, so the same no-content
8
+ * upstream answer behaved differently depending on which transport carried the request.
9
+ */
10
+
11
+ /**
12
+ * Statuses the Fetch specification defines as null-body.
13
+ *
14
+ * `new Response(body, { status })` throws a TypeError for a non-null body on any of these, so a
15
+ * transport that attaches its stream unconditionally converts a valid no-content answer into a
16
+ * construction failure. There are no body bytes to wait for either, so a transport that streams
17
+ * one of these holds the caller until the peer closes a connection it is entitled to keep alive.
18
+ *
19
+ * 101 and 103 are null-body statuses too, but neither is a final Response status these helpers
20
+ * support. 103 and every other informational head is consumed while looking for the final one,
21
+ * and 101 hands the connection to a protocol neither helper speaks, which is outside what they
22
+ * construct a Response for at all. The three below are the statuses these transports actually
23
+ * have to answer for.
24
+ *
25
+ * https://fetch.spec.whatwg.org/#null-body-status
26
+ */
27
+ export function isNullBodyStatus(status: number): boolean {
28
+ return status === 204 || status === 205 || status === 304;
29
+ }
30
+
31
+ /** What a transport must do with the coding a response declares. */
32
+ export type ContentCoding =
33
+ | { kind: "identity" }
34
+ | { kind: "decodable"; format: "gzip" | "deflate" }
35
+ | { kind: "unsupported"; coding: string };
36
+
37
+ /**
38
+ * Classify the `content-encoding` a raw transport received.
39
+ *
40
+ * `fetch` undoes a content-coding below the Response constructor. A transport that assembles a
41
+ * body from a socket hands the coded bytes to whatever reads them instead: `response.json()`
42
+ * throws a SyntaxError on the gzip magic number and an SSE reader sees noise rather than frames.
43
+ * Only `gzip` and `deflate` can be undone with `DecompressionStream`, so anything else is
44
+ * reported as unsupported and each transport refuses it under its own error type rather than
45
+ * surfacing bytes no caller can parse.
46
+ *
47
+ * This reads the coding the response actually carries and nothing else. A caller whose
48
+ * `accept-encoding` names a coding this code cannot undo is not the problem; what the peer
49
+ * chose to send is.
50
+ */
51
+ export function classifyContentCoding(headers: Headers): ContentCoding {
52
+ const coding = (headers.get("content-encoding") ?? "").trim().toLowerCase();
53
+ if (coding === "" || coding === "identity") return { kind: "identity" };
54
+ if (coding === "gzip" || coding === "x-gzip") return { kind: "decodable", format: "gzip" };
55
+ if (coding === "deflate") return { kind: "decodable", format: "deflate" };
56
+ return { kind: "unsupported", coding };
57
+ }