@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.
- package/README.md +49 -21
- package/gui/dist/assets/index-BHe2rl_C.js +112 -0
- package/gui/dist/assets/index-CJSb3HPe.css +1 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/cursor/catalog.ts +140 -7
- package/src/adapters/cursor/discovery.ts +79 -87
- package/src/adapters/cursor/effort-map.ts +11 -0
- package/src/adapters/cursor/request-builder.ts +31 -4
- package/src/adapters/cursor.ts +17 -0
- package/src/adapters/openai-chat.ts +95 -9
- package/src/adapters/openai-responses.ts +73 -9
- package/src/bridge.ts +58 -13
- package/src/claude/auth-mode.ts +6 -9
- package/src/claude/gateway-cache.ts +23 -3
- package/src/claude/model-info.ts +13 -2
- package/src/cli/access.ts +30 -0
- package/src/cli/account-extended.ts +19 -10
- package/src/cli/capabilities.ts +40 -1
- package/src/cli/claude-agent-startup-sync.ts +3 -0
- package/src/cli/claude.ts +134 -36
- package/src/cli/connect.ts +226 -0
- package/src/cli/dispatch.ts +126 -21
- package/src/cli/gui-pair-client.ts +170 -0
- package/src/cli/gui.ts +87 -0
- package/src/cli/help.ts +4 -1
- package/src/cli/index.ts +76 -22
- package/src/cli/models-runtime-subcommands.ts +34 -0
- package/src/cli/models-runtime.ts +4 -0
- package/src/cli/models.ts +2 -1
- package/src/cli/opencode.ts +2 -1
- package/src/cli/provider-runtime.ts +31 -1
- package/src/cli/registry.ts +32 -2
- package/src/cli/runtime-api.ts +10 -1
- package/src/cli/status.ts +26 -0
- package/src/cli/system-command.ts +9 -3
- package/src/client/connect.ts +650 -0
- package/src/client/hub-client.ts +457 -0
- package/src/client/hub-relay.ts +288 -0
- package/src/client/machine-api.ts +139 -0
- package/src/client/machine-auth.ts +54 -0
- package/src/client/machine-listener.ts +143 -0
- package/src/client/runtime.ts +93 -0
- package/src/client/state.ts +175 -0
- package/src/clients/config-export.ts +16 -5
- package/src/codex/auth-api.ts +169 -15
- package/src/codex/auth-context.ts +61 -6
- package/src/codex/catalog/aggregation.ts +17 -13
- package/src/codex/catalog/metadata.ts +28 -0
- package/src/codex/catalog/provider-fetch.ts +38 -5
- package/src/codex/catalog.ts +1 -1
- package/src/codex/desired-state.ts +13 -3
- package/src/codex/inject.ts +212 -31
- package/src/codex/journal.ts +54 -6
- package/src/codex/main-account.ts +105 -33
- package/src/codex/native-main-claim.ts +23 -1
- package/src/codex/native-residue.ts +7 -0
- package/src/codex/reset-credit-auto-redeem.ts +237 -0
- package/src/combos/request.ts +15 -3
- package/src/combos/types.ts +12 -0
- package/src/config/paths.ts +15 -1
- package/src/config/provider-validation.ts +40 -0
- package/src/config.ts +447 -3
- package/src/generated/compatibility-version.json +224 -108
- package/src/images/artifacts.ts +73 -37
- package/src/images/fulfill.ts +6 -1
- package/src/images/index.ts +1 -1
- package/src/images/plan.ts +16 -2
- package/src/images/synthetic-tool.ts +5 -0
- package/src/images/xai-client.ts +34 -1
- package/src/integrations/cursor-detect.ts +133 -0
- package/src/integrations/cursor-seen.ts +31 -0
- package/src/lib/errors.ts +18 -0
- package/src/lib/gui-pair-capability.ts +104 -0
- package/src/lib/lab-live-route-production.ts +2 -1
- package/src/lib/process-control.ts +4 -3
- package/src/lib/service-secrets.ts +161 -1
- package/src/lib/windows-system-proxy.ts +115 -0
- package/src/oauth/index.ts +3 -2
- package/src/oauth/pool-settings-capability.ts +55 -0
- package/src/providers/api-keys.ts +3 -1
- package/src/providers/codex-capacity.ts +34 -4
- package/src/providers/derive.ts +10 -0
- package/src/providers/fastwire.ts +10 -2
- package/src/providers/key-store.ts +197 -0
- package/src/providers/model-rename-migration.ts +3 -0
- package/src/providers/openai-sidecar.ts +3 -3
- package/src/providers/quota.ts +91 -42
- package/src/providers/registry.ts +33 -3
- package/src/reasoning-effort.ts +32 -0
- package/src/remote/protocol.ts +109 -0
- package/src/responses/citation-markers.ts +101 -0
- package/src/responses/parser.ts +43 -3
- package/src/responses/spill-store.ts +9 -4
- package/src/router.ts +72 -8
- package/src/server/auth-cors.ts +37 -1
- package/src/server/gui-session.ts +449 -0
- package/src/server/gui-static.ts +24 -7
- package/src/server/images.ts +237 -10
- package/src/server/index.ts +356 -30
- package/src/server/management/agent-settings-routes.ts +7 -1
- package/src/server/management/api-key-rotation.ts +74 -0
- package/src/server/management/combo-routes.ts +18 -6
- package/src/server/management/config-routes.ts +22 -3
- package/src/server/management/context.ts +5 -1
- package/src/server/management/cursor-integration-routes.ts +98 -0
- package/src/server/management/logs-usage-routes.ts +2 -1
- package/src/server/management/model-routes.ts +79 -1
- package/src/server/management/model-rows.ts +23 -1
- package/src/server/management/oauth-account-routes.ts +135 -2
- package/src/server/management/provider-routes.ts +143 -9
- package/src/server/management/route-registry.ts +10 -0
- package/src/server/management/session-routes.ts +13 -0
- package/src/server/management/system-routes.ts +13 -1
- package/src/server/management-api.ts +8 -3
- package/src/server/management-auth.ts +124 -82
- package/src/server/models-capabilities.ts +124 -0
- package/src/server/ports.ts +7 -0
- package/src/server/proxy-liveness.ts +7 -0
- package/src/server/request-log.ts +11 -5
- package/src/server/responses/agent-task-recovery.ts +1 -0
- package/src/server/responses/codex-auth-error.ts +3 -1
- package/src/server/responses/collaboration.ts +26 -1
- package/src/server/responses/combo-stream-preflight.ts +25 -6
- package/src/server/responses/compact.ts +32 -12
- package/src/server/responses/context-overflow.ts +49 -0
- package/src/server/responses/core.ts +196 -12
- package/src/server/responses/fetch-helpers.ts +2 -1
- package/src/server/responses/outbound-body-guard.ts +110 -0
- package/src/server/responses/ws-upstream.ts +87 -6
- package/src/server/responses-self-named-namespace-scrub.ts +181 -0
- package/src/server/system-env.ts +78 -25
- package/src/service.ts +218 -42
- package/src/types/config.ts +134 -0
- package/src/types/provider.ts +54 -1
- package/src/types/request.ts +2 -0
- package/src/types.ts +5 -0
- package/src/update/job.ts +33 -5
- package/src/usage/cost.ts +32 -1
- package/src/usage/expected-prices.ts +16 -0
- package/src/usage/log.ts +6 -2
- package/src/usage/summary.ts +48 -6
- package/gui/dist/assets/index-D-lchsPw.js +0 -112
- 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(
|
|
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
|
|
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
|
+
}
|
package/src/server/system-env.ts
CHANGED
|
@@ -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
|
-
|
|
25
|
-
|
|
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(
|
|
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
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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(
|
|
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
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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
|