@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
@@ -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);
@@ -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
+ }
package/src/lib/errors.ts CHANGED
@@ -74,6 +74,75 @@ export function isCyberPolicyMessage(text: string): boolean {
74
74
  return false;
75
75
  }
76
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
+
77
146
  function isSubscriptionGateMessage(text: string): boolean {
78
147
  return (
79
148
  text.includes("requires a subscription") ||
@@ -187,6 +256,16 @@ export function isClientClosedMessage(text: string): boolean {
187
256
  );
188
257
  }
189
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
+
190
269
  export function classifyError(status: number, type: string, message: string): OcxErrorPayload {
191
270
  const text = message.toLowerCase();
192
271
  if (type === "previous_response_not_found") {
@@ -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
+ }
@@ -1,10 +1,32 @@
1
- import { PinnedHttpError, pinnedHttpGet, pinnedHttpPost } from "./pinned-http";
1
+ import { PinnedHttpError, pinnedHttpGet, pinnedHttpPost, type PinnedHttpErrorCode } from "./pinned-http";
2
2
  import { TransportError } from "../lab/live/transport";
3
- import type { LabCredentialLeaseV1, LabPinnedSender } from "../lab/live/types";
3
+ import type { LabCredentialLeaseV1, LabPinnedSender, TransportErrorCode } from "../lab/live/types";
4
4
 
5
5
  /** Only response metadata required by current live assertions crosses into Lab. */
6
6
  const LAB_RESPONSE_HEADER_ALLOWLIST = ["content-type"] as const;
7
7
 
8
+ /**
9
+ * Every pinned-transport failure code, mapped to the Lab transport taxonomy or to nothing.
10
+ *
11
+ * A switch over the codes that existed when it was written silently let later ones fall through
12
+ * to a raw rethrow. That is not the same as leaving them unclassified: Lab's executor turns an
13
+ * unrecognized error into `harness_failure` / `execution_error`, so a response the peer coded
14
+ * in a format this transport cannot undo was reported as a fault of the runner. Both unreadable
15
+ * answers therefore carry `unreadable_response`, which Lab classifies as a protocol failure.
16
+ * A total map keeps a future code from acquiring that misattribution by default.
17
+ */
18
+ const LAB_TRANSPORT_FAILURES = {
19
+ connect_timeout: { code: "connect_timeout", message: "pinned provider connection timed out" },
20
+ first_byte_timeout: { code: "first_byte_timeout", message: "pinned provider first byte timed out" },
21
+ inactivity_timeout: { code: "inactivity_timeout", message: "pinned provider response stalled" },
22
+ output_byte_limit: { code: "output_byte_limit", message: "pinned provider response exceeded byte budget" },
23
+ content_decode_failed: { code: "unreadable_response", message: "pinned provider response did not decode" },
24
+ unsupported_content_encoding: {
25
+ code: "unreadable_response",
26
+ message: "pinned provider used a content-encoding this transport cannot decode",
27
+ },
28
+ } satisfies Record<PinnedHttpErrorCode, { code: TransportErrorCode; message: string }>;
29
+
8
30
  /**
9
31
  * Trusted credential/transport owner. Secret headers exist only in this non-Lab module and are
10
32
  * consumed directly by the pinned HTTP primitive; they are never returned to Lab code.
@@ -33,16 +55,8 @@ export function createLabAuthorizedPinnedSender(
33
55
  body = await response.text();
34
56
  } catch (error) {
35
57
  if (error instanceof PinnedHttpError) {
36
- switch (error.code) {
37
- case "connect_timeout":
38
- throw new TransportError("connect_timeout", "pinned provider connection timed out");
39
- case "first_byte_timeout":
40
- throw new TransportError("first_byte_timeout", "pinned provider first byte timed out");
41
- case "inactivity_timeout":
42
- throw new TransportError("inactivity_timeout", "pinned provider response stalled");
43
- case "output_byte_limit":
44
- throw new TransportError("output_byte_limit", "pinned provider response exceeded byte budget");
45
- }
58
+ const mapped = LAB_TRANSPORT_FAILURES[error.code];
59
+ throw new TransportError(mapped.code, mapped.message);
46
60
  }
47
61
  throw error;
48
62
  }