@bitkyc08/opencodex 2.83.0 → 2.84.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 (216) hide show
  1. package/gui/dist/assets/App-CZtqiGcw.js +56 -0
  2. package/gui/dist/assets/Tray-C9TGtwad.js +1 -0
  3. package/gui/dist/assets/{index-BAbcj51J.css → index-BB6uK6tU.css} +1 -1
  4. package/gui/dist/assets/index-CTm4l-HQ.js +94 -0
  5. package/gui/dist/assets/{quota-summary-DiJO6sYY.js → quota-summary-BbEeRfPY.js} +1 -1
  6. package/gui/dist/index.html +2 -2
  7. package/package.json +2 -2
  8. package/src/adapters/coding-agent/protocol.ts +15 -8
  9. package/src/adapters/ollama-native.ts +29 -1
  10. package/src/adapters/openai-chat/messages.ts +8 -4
  11. package/src/adapters/openai-responses/empty-assistant.ts +32 -0
  12. package/src/adapters/openai-responses/passthrough.ts +3 -0
  13. package/src/adapters/openai-responses/request-strips.ts +10 -2
  14. package/src/adapters/qoder/adapter.ts +16 -14
  15. package/src/bridge/errors.ts +19 -0
  16. package/src/bridge.ts +1 -1
  17. package/src/claude/context-windows.ts +54 -1
  18. package/src/claude/intercept/connect-proxy.ts +10 -3
  19. package/src/claude/intercept/picker-ca-persistence.ts +3 -1
  20. package/src/claude/intercept/picker-listener.ts +49 -1
  21. package/src/claude/intercept/settings-no-proxy.ts +146 -0
  22. package/src/claude/intercept/settings.ts +101 -9
  23. package/src/cli/account-api.ts +65 -0
  24. package/src/cli/account-auth.ts +26 -2
  25. package/src/cli/account.ts +41 -5
  26. package/src/cli/capabilities-access-remote.ts +8 -0
  27. package/src/cli/capabilities-base.ts +13 -1
  28. package/src/cli/catalog-command-result.ts +2 -1
  29. package/src/cli/claude.ts +15 -6
  30. package/src/cli/config-command.ts +8 -2
  31. package/src/cli/debug.ts +8 -0
  32. package/src/cli/dispatch.ts +16 -3
  33. package/src/cli/export-command.ts +14 -1
  34. package/src/cli/hub-disable.ts +99 -0
  35. package/src/cli/hub.ts +4 -2
  36. package/src/cli/index.ts +13 -4
  37. package/src/cli/link-child.ts +104 -0
  38. package/src/cli/link-enrollment.ts +96 -0
  39. package/src/cli/link.ts +39 -24
  40. package/src/cli/observe-stream.ts +6 -1
  41. package/src/cli/observe.ts +10 -2
  42. package/src/cli/provider.ts +12 -0
  43. package/src/cli/registry.ts +5 -4
  44. package/src/cli/runtime-api.ts +27 -2
  45. package/src/cli/status.ts +104 -20
  46. package/src/client/connect.ts +39 -4
  47. package/src/client/hub-client.ts +14 -7
  48. package/src/client/hub-state.ts +25 -14
  49. package/src/client/link-home.ts +248 -0
  50. package/src/client/link-management-relay.ts +61 -0
  51. package/src/client/link-relay.ts +6 -1
  52. package/src/client/link-status.ts +14 -0
  53. package/src/client/machine-api.ts +14 -7
  54. package/src/client/machine-listener.ts +29 -4
  55. package/src/clients/config-export.ts +46 -16
  56. package/src/clients/omo-role-models.ts +6 -3
  57. package/src/codex/agent-role-models.ts +26 -8
  58. package/src/codex/auth-api/account-list.ts +17 -2
  59. package/src/codex/auth-api/account-selection.ts +17 -7
  60. package/src/codex/auth-api/routes.ts +5 -1
  61. package/src/codex/autostart-health.ts +3 -1
  62. package/src/codex/catalog/bundled.ts +5 -1
  63. package/src/codex/catalog/effort.ts +2 -2
  64. package/src/codex/catalog/metadata.ts +4 -2
  65. package/src/codex/catalog/native-models.ts +16 -2
  66. package/src/codex/native-profile-startup.ts +4 -1
  67. package/src/codex/prompt-journal.ts +20 -2
  68. package/src/codex/prompt-layers/encoding.ts +49 -0
  69. package/src/codex/prompt-layers/import-source.ts +30 -0
  70. package/src/codex/prompt-layers/toml-edit.ts +269 -37
  71. package/src/codex/prompt-layers/toml-read.ts +52 -4
  72. package/src/codex/prompt-layers.ts +383 -20
  73. package/src/codex/prompt-lock-claim.ts +13 -3
  74. package/src/codex/prompt-lock-evidence.ts +3 -3
  75. package/src/codex/prompt-lock-owner.ts +14 -6
  76. package/src/codex/prompt-text-probe.ts +34 -5
  77. package/src/codex/quota.ts +13 -6
  78. package/src/codex/role-auto-assign.ts +2 -5
  79. package/src/codex/routing/active-account.ts +21 -0
  80. package/src/codex/routing.ts +1 -1
  81. package/src/codex/runtime.ts +196 -8
  82. package/src/codex/shim-overlay.ts +8 -4
  83. package/src/codex/shim.ts +1 -1
  84. package/src/combos/failover.ts +3 -0
  85. package/src/config/api-key-reconcile.ts +34 -0
  86. package/src/config/diagnostics.ts +16 -0
  87. package/src/config/feature-flags.ts +5 -0
  88. package/src/config/live-reconcile.ts +43 -29
  89. package/src/config/live-topology.ts +29 -0
  90. package/src/config/load-degrade.ts +2 -1
  91. package/src/config/paths.ts +3 -0
  92. package/src/config/salvage.ts +3 -1
  93. package/src/config/schema/config-schema.ts +2 -0
  94. package/src/generated/compatibility-version.json +311 -171
  95. package/src/grok/grpc-web.ts +23 -1
  96. package/src/grok/reset-coupon-ledger.ts +75 -5
  97. package/src/grok/reset-coupons.ts +4 -2
  98. package/src/integrations/mutation-plan.ts +13 -7
  99. package/src/integrations/registry.ts +45 -11
  100. package/src/integrations/state.ts +19 -7
  101. package/src/integrations/target.ts +42 -5
  102. package/src/integrations/writer.ts +14 -6
  103. package/src/lib/app-owned-memory-stores.ts +4 -0
  104. package/src/lib/app-owned-memory.ts +11 -0
  105. package/src/lib/json-byte-size.ts +31 -0
  106. package/src/lib/optional-shutdown-hooks.ts +35 -0
  107. package/src/lib/outbound-credential-registry.ts +210 -0
  108. package/src/lib/passive-read.ts +35 -0
  109. package/src/lib/shadow-call.ts +44 -5
  110. package/src/link/codex-settings.ts +155 -0
  111. package/src/link/management-routes.ts +89 -0
  112. package/src/link/remote-probe.ts +38 -0
  113. package/src/link/ssh-argv.ts +26 -5
  114. package/src/link/ssh-runner.ts +58 -27
  115. package/src/link/status-projection.ts +22 -4
  116. package/src/link/supervisor.ts +144 -49
  117. package/src/link/tunnel-state.ts +11 -4
  118. package/src/oauth/anthropic-continuity.ts +4 -3
  119. package/src/oauth/anthropic-identity.ts +13 -2
  120. package/src/oauth/anthropic-model-quota.ts +13 -1
  121. package/src/oauth/anthropic-routing.ts +230 -23
  122. package/src/oauth/anthropic.ts +18 -4
  123. package/src/oauth/google-antigravity.ts +24 -7
  124. package/src/oauth/index.ts +24 -15
  125. package/src/oauth/kiro-account-identity.ts +106 -0
  126. package/src/oauth/kiro.ts +48 -5
  127. package/src/oauth/plan.ts +29 -0
  128. package/src/oauth/store.ts +40 -3
  129. package/src/oauth/types.ts +2 -0
  130. package/src/plugins/loader.ts +11 -0
  131. package/src/providers/anthropic-billing-ownership.ts +74 -0
  132. package/src/providers/anthropic-billing-recovery.ts +31 -0
  133. package/src/providers/anthropic-billing-refusal.ts +55 -0
  134. package/src/providers/anthropic-reset-admission.ts +62 -0
  135. package/src/providers/anthropic-reset-grant-ledger.ts +28 -6
  136. package/src/providers/api-key-resolve.ts +17 -2
  137. package/src/providers/key-failover.ts +52 -5
  138. package/src/providers/model-rename-startup.ts +5 -3
  139. package/src/providers/quota/account-cache.ts +11 -0
  140. package/src/providers/quota/anthropic-account-quota.ts +4 -2
  141. package/src/providers/quota/report-cache.ts +14 -0
  142. package/src/providers/quota/vendor-probes-oauth.ts +3 -3
  143. package/src/providers/quota.ts +4 -1
  144. package/src/providers/registry/entries-core.ts +14 -14
  145. package/src/providers/registry/entries-extended.ts +0 -1
  146. package/src/providers/registry/model-seeds.ts +17 -0
  147. package/src/providers/request-pacing.ts +9 -0
  148. package/src/providers/stale-no-vision-seed-migration.ts +72 -0
  149. package/src/responses/compaction.ts +14 -0
  150. package/src/responses/parser.ts +7 -1
  151. package/src/server/auth-cors.ts +42 -0
  152. package/src/server/configured-credentials.ts +194 -0
  153. package/src/server/index/link-management.ts +58 -0
  154. package/src/server/index/optional-listeners.ts +5 -1
  155. package/src/server/index/serve-options.ts +13 -0
  156. package/src/server/lifecycle.ts +2 -1
  157. package/src/server/management/agent-settings-routes.ts +28 -10
  158. package/src/server/management/anthropic-reset-grant-routes.ts +17 -1
  159. package/src/server/management/codex-agent-role-routes.ts +19 -6
  160. package/src/server/management/codex-prompt-routes.ts +136 -5
  161. package/src/server/management/codex-role-auto-assign.ts +1 -1
  162. package/src/server/management/grok-coupon-routes.ts +78 -27
  163. package/src/server/management/link-enrollment.ts +192 -0
  164. package/src/server/management/link-route-state.ts +185 -0
  165. package/src/server/management/link-routes.ts +82 -402
  166. package/src/server/management/model-rows.ts +3 -1
  167. package/src/server/management/provider-overwrite-carry.ts +10 -0
  168. package/src/server/management/provider-routes.ts +1 -0
  169. package/src/server/management/route-registry.ts +5 -3
  170. package/src/server/management/shared.ts +3 -0
  171. package/src/server/management-api.ts +3 -1
  172. package/src/server/messages-native.ts +68 -42
  173. package/src/server/relay-eager.ts +2 -0
  174. package/src/server/request-log-filter.ts +6 -1
  175. package/src/server/request-log-terminal-status.ts +63 -6
  176. package/src/server/responses/adapter-dispatch.ts +232 -79
  177. package/src/server/responses/code-mode-collab-surface.ts +205 -0
  178. package/src/server/responses/codex-ws-exchange.ts +147 -6
  179. package/src/server/responses/codex-ws-wire.ts +203 -0
  180. package/src/server/responses/collaboration.ts +36 -7
  181. package/src/server/responses/compact.ts +126 -48
  182. package/src/server/responses/context-overflow.ts +13 -1
  183. package/src/server/responses/core-codex-account.ts +105 -1
  184. package/src/server/responses/core-combo-failure.ts +14 -0
  185. package/src/server/responses/core-combo.ts +7 -2
  186. package/src/server/responses/core-errors.ts +29 -1
  187. package/src/server/responses/core-options.ts +2 -0
  188. package/src/server/responses/deferred-collab-surface.ts +158 -0
  189. package/src/server/responses/deferred-send-reporter.ts +25 -0
  190. package/src/server/responses/fetch-helpers.ts +12 -1
  191. package/src/server/responses/passthrough-delivery.ts +4 -1
  192. package/src/server/responses/passthrough-dispatch.ts +15 -4
  193. package/src/server/responses/request-prepare.ts +6 -4
  194. package/src/server/responses/request-transport.ts +6 -8
  195. package/src/server/responses/sidecar-execution.ts +11 -2
  196. package/src/server/responses/ws-upstream.ts +9 -1
  197. package/src/server/startup-action-control.ts +22 -2
  198. package/src/server/startup-health-cache.ts +4 -0
  199. package/src/server/system-env-shell.ts +7 -2
  200. package/src/server/system-env.ts +11 -1
  201. package/src/service/cli.ts +5 -1
  202. package/src/service/desktop-startup.ts +147 -34
  203. package/src/service/desktop-supervision.d.mts +27 -1
  204. package/src/service/desktop-supervision.mjs +111 -5
  205. package/src/service/health-sentinel-launch.ts +176 -0
  206. package/src/service/health-sentinel-main.ts +447 -0
  207. package/src/service/health-sentinel-policy.ts +241 -0
  208. package/src/service/health-sentinel-probe.ts +54 -0
  209. package/src/service/health-sentinel-windows.ts +87 -0
  210. package/src/service/linux-linger.ts +20 -0
  211. package/src/types/config.ts +5 -0
  212. package/src/usage/expected-prices.ts +4 -0
  213. package/src/usage/log.ts +19 -1
  214. package/gui/dist/assets/App-Ba8uG696.js +0 -54
  215. package/gui/dist/assets/Tray-IImbNPRy.js +0 -1
  216. package/gui/dist/assets/index-BHRZdJuv.js +0 -94
@@ -113,12 +113,19 @@ export function isLoopbackTarget(host: string): boolean {
113
113
  return /^(0x[0-9a-f]+|\d+)(\.(0x[0-9a-f]+|\d+))*$/.test(host);
114
114
  }
115
115
 
116
- function respond(socket: Socket, status: number, reason: string, headers: Readonly<Record<string, string>> = {}): void {
116
+ function respond(
117
+ socket: Socket, status: number, reason: string, headers: Readonly<Record<string, string>> = {}, body = "",
118
+ ): void {
117
119
  if (socket.destroyed) return;
118
120
  const extra = Object.entries(headers).map(([name, value]) => `${name}: ${value}\r\n`).join("");
119
- socket.end(`HTTP/1.1 ${status} ${reason}\r\n${extra}Connection: close\r\nContent-Length: 0\r\n\r\n`);
121
+ const type = body ? "Content-Type: text/plain; charset=utf-8\r\n" : "";
122
+ socket.end(`HTTP/1.1 ${status} ${reason}\r\n${extra}${type}Connection: close\r\nContent-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`);
120
123
  }
121
124
 
125
+ // Plain proxied HTTP (often loopback MCP traffic missing a NO_PROXY bypass) lands here; say why.
126
+ const CONNECT_ONLY_BODY = "OpenCodex Claude intercept proxy accepts only CONNECT tunnels. "
127
+ + "Exclude this host via NO_PROXY (loopback and unspecified hosts: 127.0.0.1,localhost,::1,0.0.0.0).\n";
128
+
122
129
  function splice(client: Socket, upstream: Socket, pending: Uint8Array): void {
123
130
  const teardown = () => {
124
131
  client.destroy();
@@ -173,7 +180,7 @@ function handleConnection(socket: Socket, options: ResolvedConnectProxyOptions):
173
180
  // Bytes after the head belong to the tunnel (a client may pipeline its TLS ClientHello).
174
181
  let pending = head.subarray(end + 4);
175
182
  if (!target) {
176
- respond(socket, 405, "Method Not Allowed");
183
+ respond(socket, 405, "Method Not Allowed", { Allow: "CONNECT" }, CONNECT_ONLY_BODY);
177
184
  return;
178
185
  }
179
186
  if (options.authToken !== undefined) {
@@ -22,6 +22,7 @@ export function canonicalPickerConfigDir(configDir: string): string {
22
22
  /** Public records still cannot follow symlinks or accept oversized/malformed recovery data. */
23
23
  function readState(path: string, configId: string, initialization: boolean): Initialization | AuthorityMetadata | null {
24
24
  // BigInt stats: a Windows file ID exceeds 2^53, so Number ino loses low bits and two files can compare equal.
25
+ // Refuse zero identity or metadata changes between the path snapshot and opened descriptor.
25
26
  let expected: BigIntStats;
26
27
  try { expected = lstatSync(path, { bigint: true }); }
27
28
  catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; throw new Error("picker_ca_metadata_unsafe"); }
@@ -31,7 +32,8 @@ function readState(path: string, configId: string, initialization: boolean): Ini
31
32
  catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; throw new Error("picker_ca_metadata_unsafe"); }
32
33
  try {
33
34
  const stat = fstatSync(fd, { bigint: true });
34
- if (stat.dev !== expected.dev || stat.ino !== expected.ino) throw new Error("picker_ca_metadata_unsafe");
35
+ if (stat.dev !== expected.dev || stat.ino !== expected.ino || stat.ino === 0n
36
+ || stat.ctimeNs !== expected.ctimeNs) throw new Error("picker_ca_metadata_unsafe");
35
37
  if (!stat.isFile() || stat.nlink !== 1n || stat.size > BigInt(MAX_PUBLIC_STATE_BYTES)
36
38
  || (process.platform !== "win32" && stat.uid !== BigInt(process.getuid!()))) throw new Error();
37
39
  const bytes = Buffer.alloc(MAX_PUBLIC_STATE_BYTES + 1);
@@ -34,6 +34,11 @@ export interface PickerListenerOptions {
34
34
  maxEncodedBytes?: number;
35
35
  /** Test seam: concurrent upstream requests; production uses PICKER_MAX_ACTIVE_UPSTREAMS. */
36
36
  maxActiveUpstreams?: number;
37
+ /**
38
+ * Test seam: arms the drop of a connection whose backend ended, after PICKER_ABANDONED_INPUT_LINGER_MS;
39
+ * returns a cancel function. Production uses a timer.
40
+ */
41
+ scheduleAbandonedInputDrop?: (drop: () => void, ms: number) => () => void;
37
42
  log?: (line: string) => void;
38
43
  }
39
44
  export interface PickerListenerHandle { port: number; close(): Promise<void> }
@@ -51,6 +56,12 @@ export const PICKER_MAX_ACTIVE_UPSTREAMS = 256;
51
56
  export const PICKER_MAX_CONCURRENT_STREAMS = 100;
52
57
  /** A connection that has not sent a complete ClientHello by then is dropped. */
53
58
  const CLIENT_HELLO_TIMEOUT_MS = 10_000;
59
+ /**
60
+ * Once the backend has ended a connection and its reply has been flushed, input the client still
61
+ * sends is discarded for at most this long before the connection is dropped. A cooperative client
62
+ * closes as soon as it reads a Connection: close reply; this only bounds one that keeps uploading.
63
+ */
64
+ export const PICKER_ABANDONED_INPUT_LINGER_MS = 30_000;
54
65
 
55
66
  const HOP_HEADERS = new Set([
56
67
  "connection", "keep-alive", "proxy-connection", "proxy-authenticate", "proxy-authorization",
@@ -152,6 +163,10 @@ export async function startPickerListener(options: PickerListenerOptions): Promi
152
163
  const upstream = options.upstream ?? { host: "claude.ai", port: 443, servername: "claude.ai" };
153
164
  const cap = options.maxEncodedBytes ?? BOOTSTRAP_MAX_ENCODED_BYTES;
154
165
  const maxActiveUpstreams = options.maxActiveUpstreams ?? PICKER_MAX_ACTIVE_UPSTREAMS;
166
+ const scheduleAbandonedInputDrop = options.scheduleAbandonedInputDrop ?? ((drop: () => void, ms: number) => {
167
+ const timer = setTimeout(drop, ms);
168
+ return () => clearTimeout(timer);
169
+ });
155
170
  let activeUpstreams = 0;
156
171
  const upgrades = new Set<Duplex>();
157
172
  const spliced = new Set<Socket>();
@@ -205,8 +220,18 @@ export async function startPickerListener(options: PickerListenerOptions): Promi
205
220
  } else req.destroy();
206
221
  }
207
222
  };
223
+ // An HTTP/1.1 reply sent while the upload is unfinished advertises Connection: close. Bun 1.4.3
224
+ // ends such a response with socket.destroySoon() before our finish listeners run, which resets
225
+ // the splice before the reply reaches the client; keep it a graceful FIN that discards input.
226
+ // Only that closing socket is changed, and destroy() stays available for errors and shutdown.
227
+ const closesUnfinishedUpload = () => !h2 && uploading && !req.complete;
228
+ const keepClosingReplyIntact = () => {
229
+ const socket = req.socket;
230
+ socket.destroySoon = () => { req.resume(); socket.end(); return socket; };
231
+ };
208
232
  // Refusals answer with an empty response and a fixed log line that carries no request data.
209
233
  const refuse = (status: 400 | 503) => {
234
+ if (closesUnfinishedUpload()) keepClosingReplyIntact();
210
235
  // Do not leave a refused, still-uploading stream behind after delivering its empty reply.
211
236
  if (uploading && !req.complete) {
212
237
  req.once("error", () => res.destroy());
@@ -236,6 +261,7 @@ export async function startPickerListener(options: PickerListenerOptions): Promi
236
261
  if (clientGone) return;
237
262
  if (res.headersSent) res.destroy();
238
263
  else {
264
+ if (closesUnfinishedUpload()) keepClosingReplyIntact();
239
265
  res.writeHead(502, { "Content-Length": "0", ...(!h2 && uploading && !req.complete ? { Connection: "close" } : {}) });
240
266
  res.end(); log(502);
241
267
  }
@@ -260,7 +286,10 @@ export async function startPickerListener(options: PickerListenerOptions): Promi
260
286
  // HTTP/2 has no reason phrase; its compat writeHead takes the same flat raw header array.
261
287
  if (h2) (relayRes as Http2ServerResponse).writeHead(status, raw as unknown as Record<string, string>);
262
288
  else {
263
- if (uploading && !req.complete) raw.push("Connection", "close");
289
+ if (uploading && !req.complete) {
290
+ keepClosingReplyIntact();
291
+ raw.push("Connection", "close");
292
+ }
264
293
  res.writeHead(status, upRes.statusMessage, raw);
265
294
  }
266
295
  log(status);
@@ -438,6 +467,8 @@ export async function startPickerListener(options: PickerListenerOptions): Promi
438
467
  const offersH2 = clientHelloOffersH2(head);
439
468
  if (offersH2 === null) return;
440
469
  client.off("data", onData);
470
+ // Keep reading an aborted upload after the backend reply FIN.
471
+ client.allowHalfOpen = true;
441
472
  // Paused, a FIN or later bytes wait in the readable buffer and follow `head` once piped.
442
473
  client.pause();
443
474
  clearTimeout(deadline);
@@ -450,6 +481,23 @@ export async function startPickerListener(options: PickerListenerOptions): Promi
450
481
  // its peer (reset, dial failure) tears the peer down instead of leaving it open.
451
482
  inner.once("close", () => { spliced.delete(inner); if (!client.writableEnded) client.destroy(); });
452
483
  client.once("close", () => { if (!inner.writableEnded) inner.destroy(); });
484
+ // The backend reply ended; discard ciphertext still coming from the aborted upload.
485
+ // Unpipe before resuming, so backpressure cannot hide the peer FIN. The discard is bounded
486
+ // from the moment the reply has been flushed to the socket (finish), never before, so the
487
+ // drop cannot cut a reply still being written; finish is not peer receipt, so a peer that
488
+ // also stops reading for the whole budget can lose unread bytes. A peer that keeps sending
489
+ // past the budget is dropped instead of holding the connection until shutdown (#6984).
490
+ inner.once("end", () => {
491
+ client.unpipe(inner);
492
+ client.resume();
493
+ const linger = () => {
494
+ if (client.destroyed) return;
495
+ const cancel = scheduleAbandonedInputDrop(() => client.destroy(), PICKER_ABANDONED_INPUT_LINGER_MS);
496
+ client.once("close", cancel);
497
+ };
498
+ if (client.writableFinished) linger();
499
+ else client.once("finish", linger);
500
+ });
453
501
  inner.once("connect", () => {
454
502
  inner.setNoDelay(true);
455
503
  client.setNoDelay(true);
@@ -0,0 +1,146 @@
1
+ import { readFileSync, unlinkSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { atomicWriteFile } from "../../config/atomic-write";
4
+ import { assertNotRealClaudeConfigUnderTest } from "../../lib/test-home-guard";
5
+
6
+ /**
7
+ * The loopback bypass opencodex keeps in Claude Code's settings env (#6936), and the receipt
8
+ * that proves which part of `NO_PROXY` opencodex wrote.
9
+ *
10
+ * `NO_PROXY` is shared with the user, so a value alone cannot say which entries are ours. Apply
11
+ * records `{ hadKey, previous, written }` in `<claudeConfigDir>/opencodex-no-proxy.json` and
12
+ * removal restores `previous` only while `NO_PROXY` still equals `written`; anything else is
13
+ * preserved. The receipt lives exactly as long as the ownership episode that wrote it: an apply
14
+ * that starts a new episode, and every removal, invalidates it first and fails closed when it
15
+ * cannot. It holds only `NO_PROXY` values, never the proxy token.
16
+ */
17
+
18
+ /**
19
+ * Local hosts Claude Code must reach directly, in the order opencodex appends them. The proxy
20
+ * refuses every loopback and unspecified target (connect-proxy.ts isLoopbackTarget); these are the
21
+ * forms a NO_PROXY entry can name. `localhost` also covers `*.localhost` because Claude Code's
22
+ * client suffix-matches entries. Other 127/8 addresses cannot be listed (NO_PROXY has no CIDR) and
23
+ * need their own entry; the proxy's 405 body says so.
24
+ */
25
+ export const CLAUDE_INTERCEPT_LOOPBACK_NO_PROXY = ["127.0.0.1", "localhost", "::1", "0.0.0.0"] as const;
26
+
27
+ export const NO_PROXY_RECEIPT_FILE = "opencodex-no-proxy.json";
28
+
29
+ export interface NoProxyReceipt {
30
+ version: 1;
31
+ /** Whether settings env held a NO_PROXY key before the apply. */
32
+ hadKey: boolean;
33
+ /** The value before the apply (null when the key was absent or held null). */
34
+ previous: string | null;
35
+ /** The value the apply wrote. */
36
+ written: string;
37
+ }
38
+
39
+ /** Canonical index of a loopback bypass entry, or -1. `[::1]` is the bracketed form of `::1`. */
40
+ function loopbackEntryIndex(entry: string): number {
41
+ const normalized = entry.trim().toLowerCase();
42
+ return CLAUDE_INTERCEPT_LOOPBACK_NO_PROXY.indexOf(
43
+ (normalized === "[::1]" ? "::1" : normalized) as typeof CLAUDE_INTERCEPT_LOOPBACK_NO_PROXY[number],
44
+ );
45
+ }
46
+
47
+ function noProxyEntries(value: string): string[] {
48
+ return value.split(/[,\s]+/).filter(entry => entry.length > 0);
49
+ }
50
+
51
+ /** True when the value already sends every loopback host around the proxy. */
52
+ export function noProxyCoversLoopback(value: unknown): boolean {
53
+ if (typeof value !== "string") return false;
54
+ const entries = noProxyEntries(value);
55
+ if (entries.includes("*")) return true;
56
+ const present = new Set(entries.map(loopbackEntryIndex));
57
+ return CLAUDE_INTERCEPT_LOOPBACK_NO_PROXY.every((_, index) => present.has(index));
58
+ }
59
+
60
+ /** A NO_PROXY value opencodex can extend: absent, null, or a string. */
61
+ export function noProxyMergeable(value: unknown): boolean {
62
+ return value === undefined || value === null || typeof value === "string";
63
+ }
64
+
65
+ /**
66
+ * The NO_PROXY value apply writes, or null when nothing needs writing: the value already covers
67
+ * loopback, or it is not one opencodex can extend.
68
+ */
69
+ export function mergeClaudeInterceptNoProxy(current: unknown): string | null {
70
+ if (!noProxyMergeable(current) || noProxyCoversLoopback(current)) return null;
71
+ const base = typeof current === "string" ? current : "";
72
+ const present = new Set(noProxyEntries(base).map(loopbackEntryIndex));
73
+ const missing = CLAUDE_INTERCEPT_LOOPBACK_NO_PROXY.filter((_, index) => !present.has(index));
74
+ const trimmed = base.replace(/[,\s]+$/, "");
75
+ return trimmed.length === 0 ? missing.join(",") : `${trimmed},${missing.join(",")}`;
76
+ }
77
+
78
+ export function noProxyReceiptPath(configDir: string): string {
79
+ return join(configDir, NO_PROXY_RECEIPT_FILE);
80
+ }
81
+
82
+ interface ReceiptIo {
83
+ write(path: string, content: string): void;
84
+ unlink(path: string): void;
85
+ }
86
+
87
+ const defaultReceiptIo: ReceiptIo = {
88
+ write: (path, content) => atomicWriteFile(path, content),
89
+ unlink: path => unlinkSync(path),
90
+ };
91
+ let receiptIo: ReceiptIo = defaultReceiptIo;
92
+
93
+ /** Replace receipt write/unlink in tests; the real-home guard still runs. Returns a restore. */
94
+ export function setNoProxyReceiptIoForTests(io: Partial<ReceiptIo>): () => void {
95
+ const before = receiptIo;
96
+ receiptIo = { ...defaultReceiptIo, ...io };
97
+ return () => { receiptIo = before; };
98
+ }
99
+
100
+ /** The receipt, or null when it is missing or malformed (both mean: no provenance). */
101
+ export function readNoProxyReceipt(configDir: string): NoProxyReceipt | null {
102
+ let parsed: unknown;
103
+ try {
104
+ parsed = JSON.parse(readFileSync(noProxyReceiptPath(configDir), "utf8"));
105
+ } catch { // no-excuse-ok: catch -- a missing or corrupt receipt is the no-provenance state.
106
+ return null;
107
+ }
108
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
109
+ const receipt = parsed as Record<string, unknown>;
110
+ if (receipt.version !== 1 || typeof receipt.hadKey !== "boolean" || typeof receipt.written !== "string") return null;
111
+ if (receipt.previous !== null && typeof receipt.previous !== "string") return null;
112
+ return { version: 1, hadKey: receipt.hadKey, previous: receipt.previous, written: receipt.written };
113
+ }
114
+
115
+ /** Raw receipt bytes for rollback capture, or null when there is no readable file. */
116
+ export function readNoProxyReceiptBytes(configDir: string): string | null {
117
+ try {
118
+ return readFileSync(noProxyReceiptPath(configDir), "utf8");
119
+ } catch { // no-excuse-ok: catch -- an unreadable receipt is captured as absent.
120
+ return null;
121
+ }
122
+ }
123
+
124
+ /** Delete the receipt. Returns false when it exists but could not be removed. */
125
+ export function invalidateNoProxyReceipt(configDir: string): boolean {
126
+ const path = noProxyReceiptPath(configDir);
127
+ // Guard first and outside the catch, so a refusal is visible and nothing is touched.
128
+ assertNotRealClaudeConfigUnderTest(configDir, path);
129
+ try {
130
+ receiptIo.unlink(path);
131
+ return true;
132
+ } catch (error) { // no-excuse-ok: catch -- ENOENT is success; anything else is reported to the caller.
133
+ return (error as NodeJS.ErrnoException).code === "ENOENT";
134
+ }
135
+ }
136
+
137
+ /** Write raw receipt bytes. Throws on failure; callers decide whether that is fatal. */
138
+ export function writeNoProxyReceiptBytes(configDir: string, content: string): void {
139
+ const path = noProxyReceiptPath(configDir);
140
+ assertNotRealClaudeConfigUnderTest(configDir, path);
141
+ receiptIo.write(path, content);
142
+ }
143
+
144
+ export function serializeNoProxyReceipt(receipt: NoProxyReceipt): string {
145
+ return `${JSON.stringify(receipt, null, 2)}\n`;
146
+ }
@@ -4,6 +4,19 @@ import { atomicWriteFile } from "../../config/atomic-write";
4
4
  import { assertNotRealClaudeConfigUnderTest } from "../../lib/test-home-guard";
5
5
  import { claudeConfigDir } from "../auth-detect";
6
6
  import { invalidateClaudeCodeServedCatalog } from "./cli-catalog";
7
+ import {
8
+ invalidateNoProxyReceipt,
9
+ mergeClaudeInterceptNoProxy,
10
+ noProxyCoversLoopback,
11
+ noProxyMergeable,
12
+ noProxyReceiptPath,
13
+ readNoProxyReceipt,
14
+ readNoProxyReceiptBytes,
15
+ serializeNoProxyReceipt,
16
+ writeNoProxyReceiptBytes,
17
+ } from "./settings-no-proxy";
18
+
19
+ export { CLAUDE_INTERCEPT_LOOPBACK_NO_PROXY, mergeClaudeInterceptNoProxy } from "./settings-no-proxy";
7
20
 
8
21
  /**
9
22
  * Claude Code `settings.json` env block for intercept mode.
@@ -16,9 +29,21 @@ import { invalidateClaudeCodeServedCatalog } from "./cli-catalog";
16
29
  * env.HTTPS_PROXY = http://opencodex:<token>@127.0.0.1:<proxy port>
17
30
  * env.NODE_EXTRA_CA_CERTS = <configDir>/claude-intercept/ca.pem
18
31
  *
32
+ * A third key keeps loopback off that proxy. Claude Code otherwise sends local HTTP MCP traffic
33
+ * (`POST http://127.0.0.1:<port>/mcp`) through it, and the proxy only tunnels CONNECT to
34
+ * non-loopback hosts, so every loopback MCP server failed with a 405 (#6936):
35
+ *
36
+ * env.NO_PROXY = <user entries>,127.0.0.1,localhost,::1,0.0.0.0
37
+ *
19
38
  * Ownership is tracked by value, never by a marker key. The CA path is the anchor: it lives
20
39
  * under opencodex's own config directory, so only a block whose `NODE_EXTRA_CA_CERTS` names
21
40
  * that file is treated as ours. A user's own proxy or CA setting is left alone.
41
+ *
42
+ * `NO_PROXY` is shared with the user. Apply appends only the missing loopback entries and leaves
43
+ * a covering value (including `*`) alone; removal puts back exactly what was there before, using
44
+ * the receipt described in ./settings-no-proxy.ts, and otherwise preserves the current value.
45
+ * A non-string `NO_PROXY` other than null is not one opencodex can extend: the block stays stale
46
+ * and apply refuses with `foreign_env` instead of rewriting it.
22
47
  */
23
48
 
24
49
  export const CLAUDE_INTERCEPT_MANAGED_ENV = ["HTTPS_PROXY", "NODE_EXTRA_CA_CERTS"] as const;
@@ -79,6 +104,11 @@ function isOwnedCaPath(value: unknown, ownedCaPath: string): value is string {
79
104
  return typeof value === "string" && value.trim() === ownedCaPath;
80
105
  }
81
106
 
107
+ function setOrDelete(env: Record<string, unknown>, key: string, value: unknown): void {
108
+ if (value === undefined) delete env[key];
109
+ else env[key] = value;
110
+ }
111
+
82
112
  /** Classify the current settings env against the values this router would write. */
83
113
  export function inspectClaudeInterceptSettings(
84
114
  expected: ClaudeInterceptEnv,
@@ -91,7 +121,11 @@ export function inspectClaudeInterceptSettings(
91
121
  const proxy = env.HTTPS_PROXY;
92
122
  const ca = env.NODE_EXTRA_CA_CERTS;
93
123
  if (proxy === undefined && ca === undefined) return { kind: "absent" };
94
- if (proxy === expected.HTTPS_PROXY && ca === expected.NODE_EXTRA_CA_CERTS) return { kind: "applied", env: expected };
124
+ // An owned block that still routes loopback into the proxy is stale, so the startup migrate
125
+ // and every reconcile repair installs written before the bypass existed.
126
+ if (proxy === expected.HTTPS_PROXY && ca === expected.NODE_EXTRA_CA_CERTS && noProxyCoversLoopback(env.NO_PROXY)) {
127
+ return { kind: "applied", env: expected };
128
+ }
95
129
  const caOurs = isOwnedCaPath(ca, expected.NODE_EXTRA_CA_CERTS);
96
130
  const proxyOurs = proxy === undefined || isClaudeInterceptProxyUrl(proxy);
97
131
  if (caOurs && proxyOurs) {
@@ -112,13 +146,28 @@ export function inspectClaudeInterceptSettings(
112
146
  };
113
147
  }
114
148
 
115
- function writeSettings(path: string, doc: SettingsDoc): void {
149
+ const defaultPersist = (path: string, content: string): void => atomicWriteFile(path, content);
150
+ let persistFile = defaultPersist;
151
+
152
+ /** Replace the settings.json write in tests; the real-home guard still runs first. Returns a restore. */
153
+ export function setClaudeSettingsPersistForTests(persist: (path: string, content: string) => void): () => void {
154
+ const before = persistFile;
155
+ persistFile = persist;
156
+ return () => { persistFile = before; };
157
+ }
158
+
159
+ /** Commit settings.json. Once this returns, the file on disk holds the new document. */
160
+ function persistSettings(path: string, doc: SettingsDoc): void {
116
161
  // An armed test process must not rewrite the real Claude settings.json (#6775).
117
162
  assertNotRealClaudeConfigUnderTest(dirname(path), path);
118
163
  mkdirSync(dirname(path), { recursive: true });
119
164
  // The managed env embeds the proxy token, so the file must stay owner-only:
120
165
  // atomicWriteFile applies the real NTFS ACL on Windows where chmod is a no-op.
121
- atomicWriteFile(path, `${JSON.stringify(doc, null, 2)}\n`);
166
+ persistFile(path, `${JSON.stringify(doc, null, 2)}\n`);
167
+ }
168
+
169
+ function writeSettings(path: string, doc: SettingsDoc): void {
170
+ persistSettings(path, doc);
122
171
  // Whether Claude Code reaches the proxy just changed, so its cached /model list is stale.
123
172
  invalidateClaudeCodeServedCatalog(dirname(path));
124
173
  }
@@ -137,20 +186,31 @@ export function captureClaudeInterceptSettingsRollback(
137
186
  const before = readSettings(path);
138
187
  if ("error" in before && before.error !== "missing") return () => false;
139
188
  const previous = "doc" in before ? { ...envRecord(before.doc) } : {};
189
+ const previousReceipt = readNoProxyReceiptBytes(configDir);
190
+ // What apply writes over the captured value; rollback restores NO_PROXY only while it still
191
+ // holds exactly that, so a user edit made after the apply survives. The receipt is restored
192
+ // with it, because it describes that value.
193
+ const appliedNoProxy = mergeClaudeInterceptNoProxy(previous.NO_PROXY) ?? previous.NO_PROXY;
140
194
  return () => {
141
195
  try {
196
+ assertNotRealClaudeConfigUnderTest(configDir, path, noProxyReceiptPath(configDir));
142
197
  const target = typeof expected === "function" ? expected() : expected;
143
198
  const current = readSettings(path);
144
199
  if (!("doc" in current)) return false;
145
200
  const env = envRecord(current.doc);
146
201
  if (CLAUDE_INTERCEPT_MANAGED_ENV.some(key => env[key] !== target[key])) return false;
147
202
  for (const key of CLAUDE_INTERCEPT_MANAGED_ENV) {
148
- if (previous[key] === undefined) delete env[key];
149
- else env[key] = previous[key];
203
+ setOrDelete(env, key, previous[key]);
150
204
  }
205
+ const restoreNoProxy = env.NO_PROXY === appliedNoProxy;
206
+ if (restoreNoProxy) setOrDelete(env, "NO_PROXY", previous.NO_PROXY);
151
207
  if (Object.keys(env).length === 0) delete current.doc.env;
152
208
  else current.doc.env = env;
153
209
  writeSettings(path, current.doc);
210
+ if (restoreNoProxy) {
211
+ if (previousReceipt === null) return invalidateNoProxyReceipt(configDir);
212
+ writeNoProxyReceiptBytes(configDir, previousReceipt);
213
+ }
154
214
  return true;
155
215
  } catch { return false; }
156
216
  };
@@ -158,7 +218,8 @@ export function captureClaudeInterceptSettingsRollback(
158
218
 
159
219
  /**
160
220
  * Write the intercept env into `settings.json`. Refuses when a managed key already holds a
161
- * value opencodex did not write (a user-configured corporate proxy, for instance).
221
+ * value opencodex did not write (a user-configured corporate proxy, for instance), or when
222
+ * NO_PROXY holds a non-string value opencodex cannot extend.
162
223
  */
163
224
  export function applyClaudeInterceptSettings(
164
225
  env: ClaudeInterceptEnv,
@@ -171,8 +232,21 @@ export function applyClaudeInterceptSettings(
171
232
  if (state.kind === "applied") return { ok: true, changed: false, path };
172
233
  const read = readSettings(path);
173
234
  const doc: SettingsDoc = "doc" in read ? read.doc : {};
174
- doc.env = { ...envRecord(doc), ...env };
235
+ const current = envRecord(doc);
236
+ if (!noProxyMergeable(current.NO_PROXY)) return { ok: false, reason: "foreign_env", path };
237
+ const noProxy = mergeClaudeInterceptNoProxy(current.NO_PROXY);
238
+ // A new ownership episode, or a new NO_PROXY value, makes any existing receipt describe the
239
+ // wrong value. Drop it before writing, and write nothing when it cannot be dropped.
240
+ if ((state.kind === "absent" || noProxy !== null) && !invalidateNoProxyReceipt(configDir)) {
241
+ return { ok: false, reason: "unreadable", path: noProxyReceiptPath(configDir) };
242
+ }
243
+ doc.env = { ...current, ...env, ...(noProxy === null ? {} : { NO_PROXY: noProxy }) };
175
244
  writeSettings(path, doc);
245
+ if (noProxy !== null) {
246
+ const receipt = { version: 1 as const, hadKey: "NO_PROXY" in current, previous: typeof current.NO_PROXY === "string" ? current.NO_PROXY : null, written: noProxy };
247
+ // Without a receipt, removal keeps the appended entries: the harmless direction.
248
+ try { writeNoProxyReceiptBytes(configDir, serializeNoProxyReceipt(receipt)); } catch { /* no provenance */ }
249
+ }
176
250
  return { ok: true, changed: true, path };
177
251
  }
178
252
 
@@ -195,7 +269,11 @@ export function migrateClaudeInterceptSettings(
195
269
  return applyClaudeInterceptSettings(env, configDir);
196
270
  }
197
271
 
198
- /** Remove the managed keys, but only the values opencodex owns. */
272
+ /**
273
+ * Remove the managed keys and put NO_PROXY back as it was, but only the values opencodex owns.
274
+ * The receipt is dropped before the settings write; when that fails nothing is changed, so a
275
+ * receipt can never outlive the episode it describes.
276
+ */
199
277
  export function removeClaudeInterceptSettings(
200
278
  ownedCaPath: string,
201
279
  configDir = claudeConfigDir(),
@@ -207,10 +285,24 @@ export function removeClaudeInterceptSettings(
207
285
  }
208
286
  const env = envRecord(read.doc);
209
287
  if (!isOwnedCaPath(env.NODE_EXTRA_CA_CERTS, ownedCaPath)) return { ok: true, changed: false, path };
288
+ const receipt = readNoProxyReceipt(configDir);
289
+ const receiptBytes = readNoProxyReceiptBytes(configDir);
210
290
  delete env.NODE_EXTRA_CA_CERTS;
211
291
  if (isClaudeInterceptProxyUrl(env.HTTPS_PROXY)) delete env.HTTPS_PROXY;
292
+ if (receipt && env.NO_PROXY === receipt.written) setOrDelete(env, "NO_PROXY", receipt.hadKey ? receipt.previous : undefined);
293
+ if (!invalidateNoProxyReceipt(configDir)) return { ok: false, reason: "unreadable", path: noProxyReceiptPath(configDir) };
212
294
  if (Object.keys(env).length === 0) delete read.doc.env;
213
295
  else read.doc.env = env;
214
- writeSettings(path, read.doc);
296
+ try {
297
+ persistSettings(path, read.doc);
298
+ } catch (error) {
299
+ // A removal that was not committed has not ended the episode, so its receipt stays valid. Only the
300
+ // persist step is covered: once settings.json changed, the receipt must not come back.
301
+ if (receiptBytes !== null) {
302
+ try { writeNoProxyReceiptBytes(configDir, receiptBytes); } catch { /* keep the original error */ }
303
+ }
304
+ throw error;
305
+ }
306
+ invalidateClaudeCodeServedCatalog(dirname(path));
215
307
  return { ok: true, changed: true, path };
216
308
  }
@@ -137,6 +137,7 @@ export async function apiJson(
137
137
  body?: unknown,
138
138
  options: { signal?: AbortSignal; redirect?: RequestRedirect } = {},
139
139
  ): Promise<ApiResult> {
140
+ if (baseUrl === LINK_HOME_BASE) return linkHomeApiJson(deps, method, path);
140
141
  const fetchImpl = deps.fetchImpl ?? fetch;
141
142
  try {
142
143
  const res = await fetchImpl(`${baseUrl}${path}`, {
@@ -163,9 +164,73 @@ export async function resolveBaseUrl(deps: AccountDeps): Promise<string | null>
163
164
  if (deps.baseUrl) return deps.baseUrl;
164
165
  const live = await findLiveProxy();
165
166
  if (!live) return null;
167
+ if (live.role === "client") {
168
+ const { readLinkChildContext } = await import("./link-child");
169
+ if (readLinkChildContext()) return LINK_HOME_BASE;
170
+ }
166
171
  return `http://${probeHostname(live.hostname)}:${live.port}`;
167
172
  }
168
173
 
174
+ /**
175
+ * Remote Link Child (#6957): account reads come from the Home over the link, and every account
176
+ * change is refused with the Child refusal. The marker is never a URL and never reaches fetch.
177
+ */
178
+ export const LINK_HOME_BASE = "ocx-link-home:";
179
+
180
+ async function linkHomeApiJson(deps: AccountDeps, method: string, path: string): Promise<ApiResult> {
181
+ const { readLinkChildContext, linkChildReadable, linkChildRefusal, readLinkHomeJson } = await import("./link-child");
182
+ const link = readLinkChildContext();
183
+ if (!link) return { status: 503, json: { error: "The Remote Link connection changed while the command ran; retry." } };
184
+ const pathname = new URL(path, "http://cli.invalid").pathname;
185
+ if (!linkChildReadable(method, path)) {
186
+ return { status: 403, json: { error: linkChildRefusal(link, `${method} ${pathname}`) } };
187
+ }
188
+ try {
189
+ const { status, body } = await readLinkHomeJson(link, path, deps.fetchImpl ? { fetchImpl: deps.fetchImpl } : {});
190
+ return { status, json: (body && typeof body === "object" ? body : { error: String(body ?? "") }) as Record<string, unknown> };
191
+ } catch (error) {
192
+ return { status: 503, json: { error: error instanceof Error ? error.message : String(error) } };
193
+ }
194
+ }
195
+
196
+ /**
197
+ * The config account commands classify providers against. On a Remote Link Child the local
198
+ * providers are inactive, so the Home's `/api/providers` projection (presence flags only) takes
199
+ * their place. Anywhere else the local config is returned. When the Home cannot be read the
200
+ * result is an error: classifying against the inactive local providers would answer for the
201
+ * wrong machine.
202
+ */
203
+ export async function accountClassificationConfig(
204
+ deps: AccountDeps,
205
+ local: OcxConfig,
206
+ ): Promise<{ config: OcxConfig } | { error: string }> {
207
+ if (deps.baseUrl) return { config: local };
208
+ const { readLinkChildContext, readLinkHomeJson } = await import("./link-child");
209
+ const link = readLinkChildContext();
210
+ if (!link) return { config: local };
211
+ const unavailable = (detail: string) => ({ error: `Could not read the provider list from ${link.homePhrase} over Remote Link (${link.linkId}): ${detail}.` });
212
+ try {
213
+ const { status, body } = await readLinkHomeJson(link, "/api/providers", deps.fetchImpl ? { fetchImpl: deps.fetchImpl } : {});
214
+ if (status !== 200) {
215
+ // Keep the Home's own sentence (for example the upgrade instruction an older Home yields).
216
+ const message = body && typeof body === "object" && typeof (body as { error?: unknown }).error === "string"
217
+ ? (body as { error: string }).error : null;
218
+ return unavailable(message ?? `the Home answered HTTP ${status}`);
219
+ }
220
+ if (!Array.isArray(body)) return unavailable("the Home returned an unreadable provider list");
221
+ const providers: Record<string, unknown> = {};
222
+ for (const row of body) {
223
+ if (!row || typeof row !== "object" || typeof (row as { name?: unknown }).name !== "string") continue;
224
+ const { name, hasApiKey, ...rest } = row as Record<string, unknown> & { name: string };
225
+ // A placeholder marks presence for classification; no credential value crosses the link.
226
+ providers[name] = { ...rest, ...(hasApiKey === true ? { apiKey: "home-credential" } : {}) };
227
+ }
228
+ return { config: { ...local, providers: providers as OcxConfig["providers"] } };
229
+ } catch (error) {
230
+ return { error: error instanceof Error ? error.message : String(error) };
231
+ }
232
+ }
233
+
169
234
  export function proxyUnreachable(transportError?: string): number {
170
235
  console.error("Proxy not reachable. Start it with 'ocx start' or 'ocx ensure'.");
171
236
  // Naming the transport cause distinguishes "nothing is listening" from a refused
@@ -441,6 +441,18 @@ async function resetCredits(argv: string[], deps: RuntimeApiDeps): Promise<void>
441
441
  printData(result, wantsJson);
442
442
  }
443
443
 
444
+ /** Codes that leave a coupon operation unconfirmed: the caller must keep its operationId. */
445
+ const UNCONFIRMED_COUPON_CODES: ReadonlySet<string> = new Set([
446
+ "attempt_unresolved", "attempt_in_progress", "attempt_reconcile_failed", "operation_state_changed",
447
+ "redeem_failed", "attempt_mark_failed", "operation_token_mismatch",
448
+ ]);
449
+ /** Pre-dispatch ledger refusals: unconfirmed when anchored to an operation, never a settled code. */
450
+ const LEDGER_REFUSAL_CODES: ReadonlySet<string> = new Set(["ledger_unavailable", "capacity"]);
451
+ /** The only codes the ledger records as a definitive outcome; any other settled code is unconfirmed. */
452
+ const DEFINITIVE_SETTLED_CODES: ReadonlySet<string> = new Set([
453
+ "redeemed", "auth_failed", "fetch_resets_failed", "no_coupons_available", "coupon_unavailable", "token_unresolved",
454
+ ]);
455
+
444
456
  async function grokResetCoupons(argv: string[], deps: RuntimeApiDeps): Promise<void> {
445
457
  const args = [...argv];
446
458
  // The account id is optional here (the server falls back to the selected xAI
@@ -467,6 +479,7 @@ async function grokResetCoupons(argv: string[], deps: RuntimeApiDeps): Promise<v
467
479
  rejectArgs(args, USAGE);
468
480
  const accountId = rawId ? (rawId === "main" ? "__main__" : rawId) : undefined;
469
481
  const effectiveOperationId = consume ? operationId ?? crypto.randomUUID() : undefined;
482
+ const unconfirmed = () => new RuntimeApiError(`Coupon redemption outcome is unconfirmed. Preserve --operation-id ${effectiveOperationId}; reuse it to inspect this attempt and do not create a replacement operation.`, 503, null);
470
483
  let result: unknown;
471
484
  try {
472
485
  result = consume
@@ -476,15 +489,26 @@ async function grokResetCoupons(argv: string[], deps: RuntimeApiDeps): Promise<v
476
489
  : await runtimeRequest(`/api/grok/reset-coupons${accountId ? `?accountId=${encodeURIComponent(accountId)}` : ""}`, {}, deps);
477
490
  } catch (error) {
478
491
  const apiError = error instanceof RuntimeApiError ? error : undefined;
479
- const body = apiError?.body as { error?: { code?: string } } | null | undefined;
492
+ const body = apiError?.body as { operationId?: unknown; error?: { code?: string } } | null | undefined;
480
493
  const code = body && typeof body === "object" ? body.error?.code : undefined;
494
+ const carriesOperation = !!body && typeof body === "object" && typeof body.operationId === "string";
481
495
  if (consume && (!apiError || (!apiError.code && (
482
- (!code && apiError.status >= 500) || ["attempt_unresolved", "attempt_in_progress", "attempt_reconcile_failed", "operation_state_changed", "redeem_failed"].includes(code ?? "")
496
+ (!code && apiError.status >= 500) || UNCONFIRMED_COUPON_CODES.has(code ?? "")
497
+ || (LEDGER_REFUSAL_CODES.has(code ?? "") && carriesOperation)
483
498
  )))) {
484
499
  throw new RuntimeApiError(`Coupon redemption outcome is unconfirmed. Preserve --operation-id ${effectiveOperationId}; reuse it to inspect this attempt and do not create a replacement operation.`, apiError?.status ?? 503, apiError?.body ?? null);
485
500
  }
486
501
  throw error;
487
502
  }
503
+ if (consume) {
504
+ // A 200 can replay a recorded refusal; only "redeemed" is a successful spend.
505
+ const settled = result && typeof result === "object" ? (result as { code?: unknown }).code : undefined;
506
+ if (typeof settled !== "string" || !DEFINITIVE_SETTLED_CODES.has(settled)) throw unconfirmed();
507
+ if (settled !== "redeemed") {
508
+ printData(result, wantsJson);
509
+ throw new RuntimeApiError(`Coupon was not redeemed (${settled}).`, 409, result);
510
+ }
511
+ }
488
512
  printData(result, wantsJson);
489
513
  }
490
514