@bitkyc08/opencodex 2.39.0 → 2.40.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 (144) hide show
  1. package/README.md +49 -21
  2. package/gui/dist/assets/index-BHe2rl_C.js +112 -0
  3. package/gui/dist/assets/index-CJSb3HPe.css +1 -0
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +1 -1
  6. package/src/adapters/cursor/catalog.ts +140 -7
  7. package/src/adapters/cursor/discovery.ts +79 -87
  8. package/src/adapters/cursor/effort-map.ts +11 -0
  9. package/src/adapters/cursor/request-builder.ts +31 -4
  10. package/src/adapters/cursor.ts +17 -0
  11. package/src/adapters/openai-chat.ts +95 -9
  12. package/src/adapters/openai-responses.ts +73 -9
  13. package/src/bridge.ts +58 -13
  14. package/src/claude/auth-mode.ts +6 -9
  15. package/src/claude/gateway-cache.ts +23 -3
  16. package/src/claude/model-info.ts +13 -2
  17. package/src/cli/access.ts +30 -0
  18. package/src/cli/account-extended.ts +19 -10
  19. package/src/cli/capabilities.ts +40 -1
  20. package/src/cli/claude-agent-startup-sync.ts +3 -0
  21. package/src/cli/claude.ts +134 -36
  22. package/src/cli/connect.ts +226 -0
  23. package/src/cli/dispatch.ts +126 -21
  24. package/src/cli/gui-pair-client.ts +170 -0
  25. package/src/cli/gui.ts +87 -0
  26. package/src/cli/help.ts +4 -1
  27. package/src/cli/index.ts +76 -22
  28. package/src/cli/models-runtime-subcommands.ts +34 -0
  29. package/src/cli/models-runtime.ts +4 -0
  30. package/src/cli/models.ts +2 -1
  31. package/src/cli/opencode.ts +2 -1
  32. package/src/cli/provider-runtime.ts +31 -1
  33. package/src/cli/registry.ts +32 -2
  34. package/src/cli/runtime-api.ts +10 -1
  35. package/src/cli/status.ts +26 -0
  36. package/src/cli/system-command.ts +9 -3
  37. package/src/client/connect.ts +650 -0
  38. package/src/client/hub-client.ts +457 -0
  39. package/src/client/hub-relay.ts +288 -0
  40. package/src/client/machine-api.ts +139 -0
  41. package/src/client/machine-auth.ts +54 -0
  42. package/src/client/machine-listener.ts +143 -0
  43. package/src/client/runtime.ts +93 -0
  44. package/src/client/state.ts +175 -0
  45. package/src/clients/config-export.ts +16 -5
  46. package/src/codex/auth-api.ts +169 -15
  47. package/src/codex/auth-context.ts +61 -6
  48. package/src/codex/catalog/aggregation.ts +17 -13
  49. package/src/codex/catalog/metadata.ts +28 -0
  50. package/src/codex/catalog/provider-fetch.ts +38 -5
  51. package/src/codex/catalog.ts +1 -1
  52. package/src/codex/desired-state.ts +13 -3
  53. package/src/codex/inject.ts +212 -31
  54. package/src/codex/journal.ts +54 -6
  55. package/src/codex/main-account.ts +105 -33
  56. package/src/codex/native-main-claim.ts +23 -1
  57. package/src/codex/native-residue.ts +7 -0
  58. package/src/codex/reset-credit-auto-redeem.ts +237 -0
  59. package/src/combos/request.ts +15 -3
  60. package/src/combos/types.ts +12 -0
  61. package/src/config/paths.ts +15 -1
  62. package/src/config/provider-validation.ts +40 -0
  63. package/src/config.ts +447 -3
  64. package/src/generated/compatibility-version.json +224 -108
  65. package/src/images/artifacts.ts +73 -37
  66. package/src/images/fulfill.ts +6 -1
  67. package/src/images/index.ts +1 -1
  68. package/src/images/plan.ts +16 -2
  69. package/src/images/synthetic-tool.ts +5 -0
  70. package/src/images/xai-client.ts +34 -1
  71. package/src/integrations/cursor-detect.ts +133 -0
  72. package/src/integrations/cursor-seen.ts +31 -0
  73. package/src/lib/errors.ts +18 -0
  74. package/src/lib/gui-pair-capability.ts +104 -0
  75. package/src/lib/lab-live-route-production.ts +2 -1
  76. package/src/lib/process-control.ts +4 -3
  77. package/src/lib/service-secrets.ts +161 -1
  78. package/src/lib/windows-system-proxy.ts +115 -0
  79. package/src/oauth/index.ts +3 -2
  80. package/src/oauth/pool-settings-capability.ts +55 -0
  81. package/src/providers/api-keys.ts +3 -1
  82. package/src/providers/codex-capacity.ts +34 -4
  83. package/src/providers/derive.ts +10 -0
  84. package/src/providers/fastwire.ts +10 -2
  85. package/src/providers/key-store.ts +197 -0
  86. package/src/providers/model-rename-migration.ts +3 -0
  87. package/src/providers/openai-sidecar.ts +3 -3
  88. package/src/providers/quota.ts +91 -42
  89. package/src/providers/registry.ts +33 -3
  90. package/src/reasoning-effort.ts +32 -0
  91. package/src/remote/protocol.ts +109 -0
  92. package/src/responses/citation-markers.ts +101 -0
  93. package/src/responses/parser.ts +43 -3
  94. package/src/responses/spill-store.ts +9 -4
  95. package/src/router.ts +72 -8
  96. package/src/server/auth-cors.ts +37 -1
  97. package/src/server/gui-session.ts +449 -0
  98. package/src/server/gui-static.ts +24 -7
  99. package/src/server/images.ts +237 -10
  100. package/src/server/index.ts +356 -30
  101. package/src/server/management/agent-settings-routes.ts +7 -1
  102. package/src/server/management/api-key-rotation.ts +74 -0
  103. package/src/server/management/combo-routes.ts +18 -6
  104. package/src/server/management/config-routes.ts +22 -3
  105. package/src/server/management/context.ts +5 -1
  106. package/src/server/management/cursor-integration-routes.ts +98 -0
  107. package/src/server/management/logs-usage-routes.ts +2 -1
  108. package/src/server/management/model-routes.ts +79 -1
  109. package/src/server/management/model-rows.ts +23 -1
  110. package/src/server/management/oauth-account-routes.ts +135 -2
  111. package/src/server/management/provider-routes.ts +143 -9
  112. package/src/server/management/route-registry.ts +10 -0
  113. package/src/server/management/session-routes.ts +13 -0
  114. package/src/server/management/system-routes.ts +13 -1
  115. package/src/server/management-api.ts +8 -3
  116. package/src/server/management-auth.ts +124 -82
  117. package/src/server/models-capabilities.ts +124 -0
  118. package/src/server/ports.ts +7 -0
  119. package/src/server/proxy-liveness.ts +7 -0
  120. package/src/server/request-log.ts +11 -5
  121. package/src/server/responses/agent-task-recovery.ts +1 -0
  122. package/src/server/responses/codex-auth-error.ts +3 -1
  123. package/src/server/responses/collaboration.ts +26 -1
  124. package/src/server/responses/combo-stream-preflight.ts +25 -6
  125. package/src/server/responses/compact.ts +32 -12
  126. package/src/server/responses/context-overflow.ts +49 -0
  127. package/src/server/responses/core.ts +196 -12
  128. package/src/server/responses/fetch-helpers.ts +2 -1
  129. package/src/server/responses/outbound-body-guard.ts +110 -0
  130. package/src/server/responses/ws-upstream.ts +87 -6
  131. package/src/server/responses-self-named-namespace-scrub.ts +181 -0
  132. package/src/server/system-env.ts +78 -25
  133. package/src/service.ts +218 -42
  134. package/src/types/config.ts +134 -0
  135. package/src/types/provider.ts +54 -1
  136. package/src/types/request.ts +2 -0
  137. package/src/types.ts +5 -0
  138. package/src/update/job.ts +33 -5
  139. package/src/usage/cost.ts +32 -1
  140. package/src/usage/expected-prices.ts +16 -0
  141. package/src/usage/log.ts +6 -2
  142. package/src/usage/summary.ts +48 -6
  143. package/gui/dist/assets/index-D-lchsPw.js +0 -112
  144. package/gui/dist/assets/index-uvENYLin.css +0 -1
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Measure a built passthrough body before it is sent, so an operator can turn an opaque
3
+ * upstream failure into a local, actionable refusal.
4
+ *
5
+ * There is deliberately no default limit. The one measured ceiling in this codebase belongs
6
+ * to the WebSocket transport (`MAX_CODEX_WS_CREATE_FRAME_BYTES` in `ws-upstream.ts`), and the
7
+ * comment recording that measurement says the same body still succeeds over HTTP SSE — #2426
8
+ * observed an 18.2 MB HTTP 200. An implicit HTTP ceiling inferred from the WS number would
9
+ * refuse requests that work today, on every passthrough destination including Azure and
10
+ * custom Responses gateways whose limits were never measured at all. The operator who hit a
11
+ * wall knows where their wall is; this guard is off until they say so.
12
+ */
13
+
14
+ export interface OutboundBodyGuardResult {
15
+ admitted: boolean;
16
+ /** Serialized UTF-8 bytes. Zero when the guard is disabled before measurement. */
17
+ bytes: number;
18
+ /** The configured limit, or 0 when the guard is disabled. */
19
+ limit: number;
20
+ imageCount: number;
21
+ /** Approximate decoded bytes represented by embedded `input_image` data URIs. */
22
+ imageBytes: number;
23
+ }
24
+
25
+ const MAX_DIAGNOSTIC_DEPTH = 64;
26
+
27
+ function decodedDataUriBytes(value: unknown): number {
28
+ if (typeof value !== "string" || !value.startsWith("data:")) return 0;
29
+ const comma = value.indexOf(",");
30
+ if (comma < 0) return 0;
31
+ const payload = value.length - comma - 1;
32
+ return payload > 0 ? Math.floor((payload * 3) / 4) : 0;
33
+ }
34
+
35
+ /**
36
+ * Walk the parsed body for `input_image` items. Bounded by depth and a seen-set because this
37
+ * runs on a body that already failed the size check, which is exactly when a pathological
38
+ * shape is most likely.
39
+ */
40
+ function imageDiagnostics(value: unknown): { imageCount: number; imageBytes: number } {
41
+ let imageCount = 0;
42
+ let imageBytes = 0;
43
+ const seen = new WeakSet<object>();
44
+
45
+ const visit = (entry: unknown, depth: number): void => {
46
+ if (depth > MAX_DIAGNOSTIC_DEPTH || entry === null || typeof entry !== "object") return;
47
+ if (seen.has(entry)) return;
48
+ seen.add(entry);
49
+
50
+ if (!Array.isArray(entry) && (entry as Record<string, unknown>).type === "input_image") {
51
+ imageCount += 1;
52
+ imageBytes += decodedDataUriBytes((entry as Record<string, unknown>).image_url);
53
+ return;
54
+ }
55
+
56
+ if (Array.isArray(entry)) {
57
+ for (const item of entry) visit(item, depth + 1);
58
+ return;
59
+ }
60
+ for (const item of Object.values(entry)) visit(item, depth + 1);
61
+ };
62
+
63
+ visit(value, 0);
64
+ return { imageCount, imageBytes };
65
+ }
66
+
67
+ /**
68
+ * `limitBytes` undefined (unconfigured) or 0 (explicitly disabled) both admit without
69
+ * measuring, so an unconfigured proxy does no work and sends exactly what it sends today.
70
+ */
71
+ export function checkOutboundBodySize(
72
+ body: string,
73
+ limitBytes: number | undefined,
74
+ ): OutboundBodyGuardResult {
75
+ if (limitBytes === undefined || limitBytes === 0) {
76
+ return { admitted: true, bytes: 0, limit: 0, imageCount: 0, imageBytes: 0 };
77
+ }
78
+
79
+ const bytes = Buffer.byteLength(body, "utf8");
80
+ if (bytes <= limitBytes) {
81
+ return { admitted: true, bytes, limit: limitBytes, imageCount: 0, imageBytes: 0 };
82
+ }
83
+
84
+ try {
85
+ const diagnostics = imageDiagnostics(JSON.parse(body) as unknown);
86
+ return { admitted: false, bytes, limit: limitBytes, ...diagnostics };
87
+ } catch {
88
+ return { admitted: false, bytes, limit: limitBytes, imageCount: 0, imageBytes: 0 };
89
+ }
90
+ }
91
+
92
+ function megabytes(bytes: number): string {
93
+ return (bytes / (1024 * 1024)).toFixed(1);
94
+ }
95
+
96
+ /**
97
+ * Name the likely cause rather than only the number. Accumulated replayed images are the
98
+ * common way a thread crosses a byte ceiling while its token count still looks healthy, and
99
+ * the remedy is not something the user can guess from a size alone.
100
+ */
101
+ export function describeOutboundBodyRefusal(result: OutboundBodyGuardResult): string {
102
+ const imageDetail = result.imageCount > 0
103
+ ? ` It contains ${result.imageCount} input_image item${result.imageCount === 1 ? "" : "s"} `
104
+ + `representing about ${megabytes(result.imageBytes)} MB of decoded embedded image data; `
105
+ + "accumulated replayed images are the likely cause."
106
+ : " Large inputs accumulated across replayed turns can cause this.";
107
+ return `The serialized outbound request is ${megabytes(result.bytes)} MB, `
108
+ + `above the configured ${megabytes(result.limit)} MB limit.${imageDetail} `
109
+ + "Start a new session or compact the conversation before retrying.";
110
+ }
@@ -18,6 +18,36 @@ import { compareBunVersions } from "../../lib/bun-stream-caps";
18
18
  const CODEX_RESPONSES_HTTP_URL = "https://chatgpt.com/backend-api/codex/responses";
19
19
  const CODEX_RESPONSES_WS_URL = "wss://chatgpt.com/backend-api/codex/responses";
20
20
  const WS_BETA = "responses_websockets=2026-02-06";
21
+
22
+ /**
23
+ * Dial URL for a request URL. The canonical ChatGPT backend keeps its constant;
24
+ * an operator-opted OpenAI-compatible upstream swaps https for wss on the same
25
+ * path so gateways that serve the Responses WebSocket protocol on their
26
+ * /v1/responses path get the same fast lane. Plain HTTP remains on SSE because
27
+ * a provider WS handshake would otherwise send credentials and request data
28
+ * without transport encryption.
29
+ */
30
+ function wsUpstreamUrlFor(httpUrl: string): string {
31
+ if (httpUrl === CODEX_RESPONSES_HTTP_URL) return CODEX_RESPONSES_WS_URL;
32
+ return httpUrl.replace(/^http(s?):/, "ws$1:");
33
+ }
34
+
35
+ /**
36
+ * An operator-opted OpenAI-compatible upstream only joins the WS lane for
37
+ * Responses endpoints: the WebSocket path speaks the Responses event protocol,
38
+ * and every downstream consumer (adapter parsers, usage sniffing, SSE relay)
39
+ * assumes that wire. Other paths (chat completions, images, search) stay HTTP.
40
+ */
41
+ function isResponsesWebsocketEligibleUrl(url: string): boolean {
42
+ let parsed: URL;
43
+ try {
44
+ parsed = new URL(url);
45
+ } catch {
46
+ return false;
47
+ }
48
+ return parsed.protocol === "https:"
49
+ && parsed.pathname.endsWith("/responses");
50
+ }
21
51
  // If the 101 never arrives (network black hole), give SSE a chance well before
22
52
  // the caller's connect timeout (default 200s) would fire.
23
53
  const UPGRADE_DEADLINE_MS = 10_000;
@@ -102,9 +132,11 @@ export function shouldUseCodexWsUpstream(
102
132
  url: string,
103
133
  init?: RequestInit,
104
134
  runtime: BunRuntimeGateInput = currentBunRuntimeIdentity(),
135
+ upstreamWebsocketConfigured = false,
105
136
  ): boolean {
106
137
  if (!bunSupportsBoundedCodexWsRelay(runtime)) return false;
107
- if (url !== CODEX_RESPONSES_HTTP_URL) return false;
138
+ if (url !== CODEX_RESPONSES_HTTP_URL && !upstreamWebsocketConfigured) return false;
139
+ if (upstreamWebsocketConfigured && !isResponsesWebsocketEligibleUrl(url)) return false;
108
140
  if ((init?.method ?? "GET").toUpperCase() !== "POST") return false;
109
141
  const body = init?.body;
110
142
  if (typeof body !== "string") return false;
@@ -123,6 +155,50 @@ export function shouldUseCodexWsUpstream(
123
155
 
124
156
  const CLOSED_BEFORE_TERMINAL = "codex websocket closed before a Responses terminal event";
125
157
 
158
+ type ResponsesWsRelayEvent = {
159
+ type: string;
160
+ text: string;
161
+ };
162
+
163
+ /**
164
+ * Responses WebSocket uses `response.done` as its terminal event, while the
165
+ * SSE Responses surface uses status-specific terminal events. Normalize the
166
+ * WS-only discriminator before relaying so the existing SSE consumers can
167
+ * settle the turn and the socket close cannot be mistaken for a drop. Unknown
168
+ * or missing status values fail closed instead of being reported as success.
169
+ */
170
+ function normalizeResponsesWsRelayEvent(text: string): ResponsesWsRelayEvent | null {
171
+ let payload: unknown;
172
+ try {
173
+ payload = JSON.parse(text);
174
+ } catch {
175
+ return null;
176
+ }
177
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null;
178
+ const record = payload as Record<string, unknown>;
179
+ if (typeof record.type !== "string") return null;
180
+ if (record.type !== "response.done") return { type: record.type, text };
181
+
182
+ const response = record.response;
183
+ const status = response && typeof response === "object" && !Array.isArray(response)
184
+ ? (response as Record<string, unknown>).status
185
+ : undefined;
186
+ const type = status === "completed"
187
+ ? "response.completed"
188
+ : status === "failed"
189
+ ? "response.failed"
190
+ : status === "incomplete" || status === "cancelled"
191
+ ? "response.incomplete"
192
+ : "response.failed";
193
+ const normalizedRecord: Record<string, unknown> = { ...record, type };
194
+ if (type === "response.failed" && status !== "failed") {
195
+ normalizedRecord.response = response && typeof response === "object" && !Array.isArray(response)
196
+ ? { ...(response as Record<string, unknown>), status: "failed" }
197
+ : { status: "failed" };
198
+ }
199
+ return { type, text: JSON.stringify(normalizedRecord) };
200
+ }
201
+
126
202
  /**
127
203
  * The close code is the only thing that separates "the backend refused this
128
204
  * payload" from "the network dropped", and both used to reach the caller as the
@@ -221,7 +297,7 @@ export function codexWsUpstreamFetch(
221
297
  let ws: WebSocket;
222
298
  try {
223
299
  // Bun accepts per-handshake headers; the DOM lib types only list protocol arrays.
224
- ws = new WebSocket(CODEX_RESPONSES_WS_URL, { headers } as unknown as string[]);
300
+ ws = new WebSocket(wsUpstreamUrlFor(url), { headers } as unknown as string[]);
225
301
  } catch {
226
302
  resolve(sseFallback(url, init));
227
303
  return;
@@ -311,14 +387,19 @@ export function codexWsUpstreamFetch(
311
387
  failStream("codex websocket frame exceeds the response size limit");
312
388
  return;
313
389
  }
314
- const encodedText = encoder.encode(text);
390
+ const rawEncodedText = encoder.encode(text);
391
+ if (rawEncodedText.byteLength > MAX_CODEX_WS_FRAME_BYTES) {
392
+ failStream("codex websocket frame exceeds the response size limit");
393
+ return;
394
+ }
395
+ const normalized = normalizeResponsesWsRelayEvent(text);
396
+ if (!normalized) return;
397
+ const { type } = normalized;
398
+ const encodedText = normalized.text === text ? rawEncodedText : encoder.encode(normalized.text);
315
399
  if (encodedText.byteLength > MAX_CODEX_WS_FRAME_BYTES) {
316
400
  failStream("codex websocket frame exceeds the response size limit");
317
401
  return;
318
402
  }
319
- let type: unknown;
320
- try { type = (JSON.parse(text) as { type?: unknown }).type; } catch { return; }
321
- if (typeof type !== "string") return;
322
403
  // Relay only the event surface the SSE path produces today. WS-only
323
404
  // frames (codex.rate_limits, responsesapi.websocket_timing) are dropped
324
405
  // so downstream clients see exactly the stream shape they always got.
@@ -0,0 +1,181 @@
1
+ import type { SsePayloadRewrite } from "./sse-payload-rewrite";
2
+
3
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
4
+ return !!value && typeof value === "object" && !Array.isArray(value);
5
+ }
6
+
7
+ export interface SelfNamedNamespaceScrubAuthorization {
8
+ customToolCallNames: ReadonlySet<string>;
9
+ functionCallNames: ReadonlySet<string>;
10
+ }
11
+
12
+ function collectBareToolSpecs(
13
+ bareCustomNames: Set<string>,
14
+ bareFunctionNames: Set<string>,
15
+ sameNameNamespacedCustomNames: Set<string>,
16
+ sameNameNamespacedFunctionNames: Set<string>,
17
+ specs: unknown,
18
+ ): void {
19
+ if (!Array.isArray(specs)) return;
20
+ for (const spec of specs) {
21
+ if (!isPlainObject(spec)) continue;
22
+ if (spec.type === "namespace" && Array.isArray(spec.tools)) {
23
+ const namespace = typeof spec.name === "string" ? spec.name : undefined;
24
+ for (const inner of spec.tools) {
25
+ if (!isPlainObject(inner) || typeof inner.name !== "string") continue;
26
+ if (namespace !== "functions" && namespace === inner.name) {
27
+ if (inner.type === "custom") sameNameNamespacedCustomNames.add(inner.name);
28
+ else if (inner.type === "function") sameNameNamespacedFunctionNames.add(inner.name);
29
+ }
30
+ if (namespace === "functions") {
31
+ if (inner.type === "custom") bareCustomNames.add(inner.name);
32
+ else if (inner.type === "function") bareFunctionNames.add(inner.name);
33
+ }
34
+ }
35
+ continue;
36
+ }
37
+ // `buildTools` (parser.ts) also accepts the Chat-shaped `{ type: "function", function: { name } }`
38
+ // declaration, and the undeclared-tool guard authorizes it the same way. Reading only
39
+ // `spec.name` here left such a function out of the raw-body set, so the intersection dropped
40
+ // it and a self-named echo for it reached Codex again.
41
+ const nestedFunction = spec.type === "function" && isPlainObject(spec.function) ? spec.function : undefined;
42
+ const name = typeof spec.name === "string" && spec.name.length > 0
43
+ ? spec.name
44
+ : typeof nestedFunction?.name === "string" && nestedFunction.name.length > 0
45
+ ? nestedFunction.name
46
+ : undefined;
47
+ if (!name) continue;
48
+ const namespace = typeof spec.namespace === "string" ? spec.namespace : undefined;
49
+ if (namespace !== "functions" && namespace === name) {
50
+ if (spec.type === "custom") sameNameNamespacedCustomNames.add(name);
51
+ else if (spec.type === "function") sameNameNamespacedFunctionNames.add(name);
52
+ }
53
+ if (!namespace || namespace === "functions") {
54
+ if (spec.type === "custom") bareCustomNames.add(name);
55
+ else if (spec.type === "function") bareFunctionNames.add(name);
56
+ }
57
+ }
58
+ }
59
+
60
+ /** Bare custom tools authorized by this turn, scoped to each response call type. */
61
+ export function collectSelfNamedNamespaceScrubAuthorization(
62
+ body: unknown,
63
+ authorizedBareCustomToolNames: ReadonlySet<string>,
64
+ authorizedBareFunctionToolNames: ReadonlySet<string>,
65
+ ): SelfNamedNamespaceScrubAuthorization {
66
+ const bareCustomNames = new Set<string>();
67
+ const bareFunctionNames = new Set<string>();
68
+ const sameNameNamespacedCustomNames = new Set<string>();
69
+ const sameNameNamespacedFunctionNames = new Set<string>();
70
+ if (isPlainObject(body)) {
71
+ collectBareToolSpecs(
72
+ bareCustomNames,
73
+ bareFunctionNames,
74
+ sameNameNamespacedCustomNames,
75
+ sameNameNamespacedFunctionNames,
76
+ body.tools,
77
+ );
78
+ if (Array.isArray(body.input)) {
79
+ for (const item of body.input) {
80
+ if (
81
+ isPlainObject(item)
82
+ && (item.type === "additional_tools" || item.type === "tool_search_output")
83
+ ) {
84
+ collectBareToolSpecs(
85
+ bareCustomNames,
86
+ bareFunctionNames,
87
+ sameNameNamespacedCustomNames,
88
+ sameNameNamespacedFunctionNames,
89
+ item.tools,
90
+ );
91
+ }
92
+ }
93
+ }
94
+ }
95
+ const authorizedCustomNames = [...bareCustomNames]
96
+ .filter(name => authorizedBareCustomToolNames.has(name));
97
+ const authorizedFunctionNames = new Set([
98
+ ...authorizedCustomNames,
99
+ ...[...bareFunctionNames].filter(name => authorizedBareFunctionToolNames.has(name)),
100
+ ]);
101
+ return {
102
+ customToolCallNames: new Set(
103
+ authorizedCustomNames.filter(name => !sameNameNamespacedCustomNames.has(name)),
104
+ ),
105
+ functionCallNames: new Set(
106
+ [...authorizedFunctionNames].filter(name => !sameNameNamespacedFunctionNames.has(name)),
107
+ ),
108
+ };
109
+ }
110
+
111
+ /**
112
+ * Drop a tool-call `namespace` that merely repeats the call's own `name` (#3217).
113
+ *
114
+ * codex-rs resolves a client tool call as `ToolName::new(namespace, name)` and treats only
115
+ * `None | "" | "functions"` as the default namespace; anything else is concatenated into a flat
116
+ * name before routing. A backend answer of `{ name: "exec", namespace: "exec" }` therefore
117
+ * becomes `execexec`, which no client tool matches, and Codex re-issues the same call forever.
118
+ * The malformed Spark shape is scrubbed only when the current turn authorized a bare custom tool
119
+ * with that name. A genuine namespaced tool may intentionally use the same namespace and name.
120
+ * The adapter fix that stops provoking the answer lives in `stripSparkCompatibility`; this is the
121
+ * belt to that suspender.
122
+ */
123
+ export function scrubSelfNamedToolCallNamespace(
124
+ value: unknown,
125
+ authorization: SelfNamedNamespaceScrubAuthorization,
126
+ ): { value: unknown; changed: boolean } {
127
+ if (Array.isArray(value)) {
128
+ let changed = false;
129
+ const out = value.map(entry => {
130
+ const result = scrubSelfNamedToolCallNamespace(entry, authorization);
131
+ changed ||= result.changed;
132
+ return result.value;
133
+ });
134
+ return changed ? { value: out, changed: true } : { value, changed: false };
135
+ }
136
+ if (!isPlainObject(value)) return { value, changed: false };
137
+ let changed = false;
138
+ const out: Record<string, unknown> = {};
139
+ for (const [key, entry] of Object.entries(value)) {
140
+ const result = scrubSelfNamedToolCallNamespace(entry, authorization);
141
+ out[key] = result.value;
142
+ changed ||= result.changed;
143
+ }
144
+ const authorizedNames = value.type === "custom_tool_call"
145
+ ? authorization.customToolCallNames
146
+ : value.type === "function_call"
147
+ ? authorization.functionCallNames
148
+ : undefined;
149
+ if (
150
+ authorizedNames
151
+ && typeof value.name === "string"
152
+ && value.name.length > 0
153
+ && value.namespace === value.name
154
+ && authorizedNames.has(value.name)
155
+ ) {
156
+ delete out.namespace;
157
+ changed = true;
158
+ }
159
+ return changed ? { value: out, changed: true } : { value, changed: false };
160
+ }
161
+
162
+ export function scrubSelfNamedToolCallNamespaceInJson(
163
+ text: string,
164
+ authorization: SelfNamedNamespaceScrubAuthorization,
165
+ ): string {
166
+ if (!text.includes("\"namespace\"")) return text;
167
+ let payload: unknown;
168
+ try {
169
+ payload = JSON.parse(text);
170
+ } catch {
171
+ return text;
172
+ }
173
+ const result = scrubSelfNamedToolCallNamespace(payload, authorization);
174
+ return result.changed ? JSON.stringify(result.value) : text;
175
+ }
176
+
177
+ export function createSelfNamedToolCallNamespaceScrubRewrite(
178
+ authorization: SelfNamedNamespaceScrubAuthorization,
179
+ ): SsePayloadRewrite {
180
+ return payload => scrubSelfNamedToolCallNamespaceInJson(payload, authorization);
181
+ }
@@ -3,8 +3,10 @@ import { accessSync, constants, readFileSync, writeFileSync, unlinkSync, mkdirSy
3
3
  import { delimiter, join } from "node:path";
4
4
  import { getConfigDir } from "../config";
5
5
  import { resolveAutoContext, type AutoContextMode } from "../claude/context-windows";
6
- import { PROXY_MARKER, defaultAuthDetectDeps, detectClaudeAuth, ownAdmissionTokens } from "../claude/auth-detect";
6
+ import { PROXY_MARKER, defaultAuthDetectDeps, detectClaudeAuth, ownAdmissionTokens, type AuthDetectDeps } from "../claude/auth-detect";
7
7
  import { resolveClaudeAuthMode } from "../claude/auth-mode";
8
+ import { ANTHROPIC_PARENT_ENV_SLOTS, trustedNodeLauncherContext, type AnthropicParentEnvSlot } from "../cli/launcher-context";
9
+ import { isProxyAdmissionSecret } from "./auth-cors";
8
10
  import type { OcxConfig } from "../types";
9
11
  import { recordOwnedConfigPath } from "../lib/config-ownership";
10
12
  import { providerContextCap } from "../providers/context-cap";
@@ -21,8 +23,43 @@ import { OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers";
21
23
  * NOTE this is a SNAPSHOT: the file only changes when this runs (proxy start, `ocx
22
24
  * ensure`, or a settings save). `ocx claude` re-resolves live on every launch.
23
25
  */
24
- function systemEnvMarkerMode(config: OcxConfig): "proxy" | "subscription" {
25
- return resolveClaudeAuthMode(config, detectClaudeAuth(defaultAuthDetectDeps(process.env, ownAdmissionTokens(config)))).markerMode;
26
+ export type SystemEnvDeps = {
27
+ /** Test seam; production uses the authenticated Node-launcher context. */
28
+ preBunAnthropicSlots?: readonly AnthropicParentEnvSlot[] | null;
29
+ /** Test seam for auth sources; `env` and `ownTokens` stay bound below. */
30
+ authDetect?: Omit<Partial<AuthDetectDeps>, "env" | "ownTokens">;
31
+ };
32
+
33
+ /**
34
+ * Bun may synthesize Anthropic variables from a project `.env` before this module runs.
35
+ * Only values recorded by the plain-Node launcher are trusted as parent exports. Direct
36
+ * Bun/service launches have no proof-bound slot list, so they fail closed and let the
37
+ * file/keychain auth sources decide instead of allowing dotenv to select subscription mode.
38
+ */
39
+ function systemEnvAnthropicEnv(
40
+ env: NodeJS.ProcessEnv,
41
+ preBunAnthropicSlots: readonly AnthropicParentEnvSlot[] | null | undefined,
42
+ ): NodeJS.ProcessEnv {
43
+ const trustedSlots = preBunAnthropicSlots === undefined
44
+ ? trustedNodeLauncherContext()?.anthropicEnvSlots ?? []
45
+ : preBunAnthropicSlots ?? [];
46
+ const exported = new Set<AnthropicParentEnvSlot>(trustedSlots);
47
+ const sanitized = { ...env };
48
+ for (const name of ANTHROPIC_PARENT_ENV_SLOTS) {
49
+ if (sanitized[name] !== undefined && !exported.has(name)) delete sanitized[name];
50
+ }
51
+ return sanitized;
52
+ }
53
+
54
+ function systemEnvMarkerMode(config: OcxConfig, deps: SystemEnvDeps = {}): "proxy" | "subscription" {
55
+ const env = systemEnvAnthropicEnv(process.env, deps.preBunAnthropicSlots);
56
+ const ownTokens = ownAdmissionTokens(config);
57
+ return resolveClaudeAuthMode(config, detectClaudeAuth({
58
+ ...defaultAuthDetectDeps(env, ownTokens),
59
+ ...(deps.authDetect ?? {}),
60
+ env: () => env,
61
+ ownTokens,
62
+ })).markerMode;
26
63
  }
27
64
 
28
65
  // ---------------------------------------------------------------------------
@@ -39,7 +76,13 @@ function shellValue(value: string): string {
39
76
  return `'${value.replaceAll("'", `'\\''`)}'`;
40
77
  }
41
78
 
42
- function writeShellEnvFile(port: number, config: OcxConfig, modelEnv: Record<string, string> = {}, auto?: AutoContextMode): void {
79
+ function writeShellEnvFile(
80
+ port: number,
81
+ config: OcxConfig,
82
+ modelEnv: Record<string, string> = {},
83
+ auto?: AutoContextMode,
84
+ deps: SystemEnvDeps = {},
85
+ ): void {
43
86
  const lines = [
44
87
  `# Generated by opencodex — do not edit manually`,
45
88
  `export ANTHROPIC_BASE_URL=${shellValue(`http://127.0.0.1:${port}`)}`,
@@ -49,10 +92,12 @@ function writeShellEnvFile(port: number, config: OcxConfig, modelEnv: Record<str
49
92
  // exported in their shell wins even though launchctl knows nothing about it.
50
93
  const conditional = (name: string, value: string) =>
51
94
  `[ -z "\${${name}+x}" ] && export ${name}=${shellValue(value)}`;
52
- if (config.apiKeys?.length) {
53
- lines.push(`export ANTHROPIC_AUTH_TOKEN=${shellValue(config.apiKeys[0].key)}`);
54
- } else if (systemEnvMarkerMode(config) === "proxy") {
55
- lines.push(conditional("ANTHROPIC_AUTH_TOKEN", PROXY_MARKER));
95
+ if (systemEnvMarkerMode(config, deps) === "proxy") {
96
+ if (config.apiKeys?.length) {
97
+ lines.push(`export ANTHROPIC_AUTH_TOKEN=${shellValue(config.apiKeys[0].key)}`);
98
+ } else {
99
+ lines.push(conditional("ANTHROPIC_AUTH_TOKEN", PROXY_MARKER));
100
+ }
56
101
  }
57
102
  // Model slots (default + tiers + legacy small-fast) with [1m] applied (devlog 260712 B2).
58
103
  if (modelEnv.ANTHROPIC_MODEL) {
@@ -319,7 +364,11 @@ async function computeEffectiveModelEnv(config: OcxConfig, auto?: AutoContextMod
319
364
  return { modelEnv: effectiveModelEnv(config.claudeCode, windows ?? {}, auto), windows: windows ?? {} };
320
365
  }
321
366
 
322
- export async function injectSystemEnv(port: number, config: OcxConfig): Promise<SystemEnvResult> {
367
+ export async function injectSystemEnv(
368
+ port: number,
369
+ config: OcxConfig,
370
+ deps: SystemEnvDeps = {},
371
+ ): Promise<SystemEnvResult> {
323
372
  if (process.platform !== "darwin") return { injected: false, reason: "not macOS" };
324
373
  if (config.claudeCode?.enabled === false) return { injected: false, reason: "claude disabled" };
325
374
 
@@ -351,21 +400,25 @@ export async function injectSystemEnv(port: number, config: OcxConfig): Promise<
351
400
  try {
352
401
  inject("ANTHROPIC_BASE_URL", ownedBaseUrl(port));
353
402
  inject("CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY", "1");
354
- if (config.apiKeys?.length) {
355
- inject("ANTHROPIC_AUTH_TOKEN", config.apiKeys[0].key);
356
- } else if (systemEnvMarkerMode(config) === "proxy" && launchctlGetenv("ANTHROPIC_AUTH_TOKEN") === undefined) {
357
- inject("ANTHROPIC_AUTH_TOKEN", PROXY_MARKER);
358
- } else if (systemEnvMarkerMode(config) !== "proxy"
359
- && injectedKeys.includes("ANTHROPIC_AUTH_TOKEN")
360
- && launchctlGetenv("ANTHROPIC_AUTH_TOKEN") === PROXY_MARKER) {
361
- // Subscription switch-back (devlog 260720_claude_authmode_persist): remove ONLY
362
- // the opencodex-owned dummy token so a launchd-started Claude regains its own
363
- // claude.ai OAuth. User-set tokens (not tracked in injectedKeys, or carrying a
364
- // different value) are never touched.
365
- unsetLaunchctlEnv("ANTHROPIC_AUTH_TOKEN");
366
- const dummyIdx = injectedKeys.indexOf("ANTHROPIC_AUTH_TOKEN");
367
- if (dummyIdx >= 0) injectedKeys.splice(dummyIdx, 1);
368
- writeTracking(port, injectedKeys);
403
+ const markerMode = systemEnvMarkerMode(config, deps);
404
+ if (markerMode === "proxy") {
405
+ if (config.apiKeys?.length) {
406
+ inject("ANTHROPIC_AUTH_TOKEN", config.apiKeys[0].key);
407
+ } else if (launchctlGetenv("ANTHROPIC_AUTH_TOKEN") === undefined) {
408
+ inject("ANTHROPIC_AUTH_TOKEN", PROXY_MARKER);
409
+ }
410
+ } else if (injectedKeys.includes("ANTHROPIC_AUTH_TOKEN")) {
411
+ const currentToken = launchctlGetenv("ANTHROPIC_AUTH_TOKEN");
412
+ if (currentToken
413
+ && (currentToken === PROXY_MARKER || isProxyAdmissionSecret(currentToken, config))) {
414
+ // Subscription switch-back (devlog 260720_claude_authmode_persist): remove
415
+ // opencodex-owned dummy or admission tokens so a launchd-started Claude regains
416
+ // its own claude.ai OAuth. User-set tokens are never touched.
417
+ unsetLaunchctlEnv("ANTHROPIC_AUTH_TOKEN");
418
+ const tokenIdx = injectedKeys.indexOf("ANTHROPIC_AUTH_TOKEN");
419
+ if (tokenIdx >= 0) injectedKeys.splice(tokenIdx, 1);
420
+ writeTracking(port, injectedKeys);
421
+ }
369
422
  }
370
423
  // Lever keys (devlog 136 B6): user-wins — skip any key the user already set in the
371
424
  // launchd domain, and track ONLY the keys we actually injected so revert cannot
@@ -399,7 +452,7 @@ export async function injectSystemEnv(port: number, config: OcxConfig): Promise<
399
452
  }
400
453
 
401
454
  // Shell-hook env file: works for new shells in already-running Terminal.app.
402
- writeShellEnvFile(port, config, modelEnv, auto);
455
+ writeShellEnvFile(port, config, modelEnv, auto, deps);
403
456
 
404
457
  // Gateway-model cache pre-write (devlog 030): plain `claude` sessions read the
405
458
  // picker list from ~/.claude/cache/gateway-models.json and cannot refresh it