@bitkyc08/opencodex 2.17.1-preview.20260814 → 2.19.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/gui/dist/assets/{index-DUCH59lJ.css → index-CQ7bIKee.css} +1 -1
- package/gui/dist/assets/{index-ta3-_hgj.js → index-D_JUZLEC.js} +16 -16
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/client-fingerprint.ts +14 -10
- package/src/adapters/cursor/live-transport.ts +17 -5
- package/src/adapters/cursor/protobuf-events.ts +662 -20
- package/src/adapters/cursor/tool-definitions.ts +12 -6
- package/src/adapters/google-antigravity-wire.ts +4 -3
- package/src/adapters/google.ts +22 -3
- package/src/bridge.ts +12 -2
- package/src/chat/inbound.ts +24 -1
- package/src/cli/index.ts +11 -0
- package/src/codex/app-server-processes.ts +3 -3
- package/src/codex/shim.ts +100 -5
- package/src/codex/user-identity.ts +36 -6
- package/src/config.ts +0 -2
- package/src/generated/compatibility-version.json +52 -44
- package/src/lib/errors.ts +27 -0
- package/src/lib/token-estimate.ts +19 -2
- package/src/lib/windows-elevation.ts +37 -0
- package/src/lib/windows-secret-acl.ts +7 -0
- package/src/lib/windows-text.ts +106 -0
- package/src/lib/windows-user-principal.ts +0 -2
- package/src/oauth/index.ts +1 -1
- package/src/oauth/store.ts +32 -18
- package/src/providers/antigravity-models.ts +25 -5
- package/src/providers/free-directory.ts +1 -1
- package/src/providers/registry.ts +6 -3
- package/src/responses/spill-store.ts +20 -1
- package/src/responses/state.ts +159 -3
- package/src/server/chat-completions.ts +4 -2
- package/src/server/effort-policy.ts +18 -0
- package/src/server/index.ts +5 -1
- package/src/server/management/logs-usage-routes.ts +7 -22
- package/src/server/request-log.ts +48 -3
- package/src/server/responses/core.ts +59 -15
- package/src/server/responses/encrypted-payload.ts +58 -38
- package/src/server/responses/fetch-helpers.ts +12 -4
- package/src/server/responses/input-admission.ts +169 -0
- package/src/server/responses/policy-fallback.ts +13 -2
- package/src/server/responses/ws-upstream.ts +115 -6
- package/src/service-manager-probe.ts +23 -37
- package/src/service.ts +233 -25
- package/src/tray/windows.ts +0 -2
- package/src/types.ts +10 -2
- package/src/update/job.ts +2 -2
- package/src/usage/summary.ts +21 -4
- package/src/vision/index.ts +21 -4
- package/src/web-search/index.ts +2 -1
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pre-dispatch input admission (#1412).
|
|
3
|
+
*
|
|
4
|
+
* Refuses a turn whose estimated input cannot plausibly fit the model context window,
|
|
5
|
+
* BEFORE auth resolution, circuit admission, or any upstream I/O. #1412 reported ~127k of
|
|
6
|
+
* real context compounding to 1.3M-1.6M tokens and crashing the proxy; the provider would
|
|
7
|
+
* reject such a turn anyway, so paying for the round trip buys nothing.
|
|
8
|
+
*
|
|
9
|
+
* Deliberately narrow. This is not a context manager and not a compaction trigger: it
|
|
10
|
+
* catches the pathological case and stays out of the way otherwise. Every uncertainty
|
|
11
|
+
* resolves toward admitting.
|
|
12
|
+
*/
|
|
13
|
+
import { nativeOpenAiContextWindow } from "../../codex/catalog/metadata";
|
|
14
|
+
import { estimateTokens } from "../../lib/token-estimate";
|
|
15
|
+
import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers";
|
|
16
|
+
import type { OcxContentPart, OcxParsedRequest, OcxProviderConfig } from "../../types";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Multiplier applied to the ceiling before refusing.
|
|
20
|
+
*
|
|
21
|
+
* 2.5, not something tighter, because `estimateTokens` can overshoot by 1.6x on its own.
|
|
22
|
+
* `cjkRatio` samples every `stride`-th character, so a payload of fixed-width records whose
|
|
23
|
+
* length aligns with the stride samples as 100% CJK while being ~1.6% CJK, firing the
|
|
24
|
+
* 2.5-chars/token clamp instead of the 4.0 default. Measured on Bun 1.3.14: 126,046 chars,
|
|
25
|
+
* true CJK ratio 0.0161, sampled ratio 1.0, estimate inflated 1.6x. Since 4.0 / 2.5 = 1.6
|
|
26
|
+
* is that branch maximum divergence, a threshold at or under 1.6 would convert the
|
|
27
|
+
* estimator error bar into false 413s.
|
|
28
|
+
*
|
|
29
|
+
* 2.5 sits above it with room for the ~10% model-family ratio spread, and still refuses the
|
|
30
|
+
* #1412 shape (10x) four times over.
|
|
31
|
+
*/
|
|
32
|
+
export const ADMISSION_TOLERANCE = 2.5;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Token cost charged for a remote image URL. The bytes are not in this request — the
|
|
36
|
+
* provider fetches them — so the URL own length is not the cost. A small flat charge
|
|
37
|
+
* acknowledges the tiles the image will occupy without pretending to know its dimensions.
|
|
38
|
+
*/
|
|
39
|
+
const REMOTE_IMAGE_TOKENS = 850;
|
|
40
|
+
|
|
41
|
+
/** Decoded image bytes per token. Coarse tile-count proxy, not a provider formula. */
|
|
42
|
+
const IMAGE_BYTES_PER_TOKEN = 750;
|
|
43
|
+
|
|
44
|
+
export interface InputAdmissionResult {
|
|
45
|
+
admitted: boolean;
|
|
46
|
+
estimatedTokens: number;
|
|
47
|
+
/** Resolved ceiling, or null when nothing could be resolved (=> always admitted). */
|
|
48
|
+
ceiling: number | null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function positive(value: unknown): number | null {
|
|
52
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Charge a `data:` URL by its DECODED size rather than its character length: base64 inflates
|
|
57
|
+
* by 4/3, so charging the string would overcount by a third. A remote URL is charged flat.
|
|
58
|
+
*/
|
|
59
|
+
function imageTokens(imageUrl: string): number {
|
|
60
|
+
if (!imageUrl.startsWith("data:")) return REMOTE_IMAGE_TOKENS;
|
|
61
|
+
const comma = imageUrl.indexOf(",");
|
|
62
|
+
if (comma < 0) return REMOTE_IMAGE_TOKENS;
|
|
63
|
+
const payload = imageUrl.length - comma - 1;
|
|
64
|
+
if (payload <= 0) return 0;
|
|
65
|
+
const decoded = Math.floor((payload * 3) / 4);
|
|
66
|
+
return Math.max(1, Math.ceil(decoded / IMAGE_BYTES_PER_TOKEN));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function contentPartTokens(part: OcxContentPart, modelId: string): number {
|
|
70
|
+
return part.type === "image" ? imageTokens(part.imageUrl) : estimateTokens(part.text, modelId);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function contentTokens(content: string | readonly OcxContentPart[], modelId: string): number {
|
|
74
|
+
if (typeof content === "string") return estimateTokens(content, modelId);
|
|
75
|
+
let total = 0;
|
|
76
|
+
for (const part of content) total += contentPartTokens(part, modelId);
|
|
77
|
+
return total;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Estimate the input tokens of a parsed request.
|
|
82
|
+
*
|
|
83
|
+
* Walks the whole `OcxMessage` union rather than user text alone. Assistant turns carry
|
|
84
|
+
* their content as `OcxAssistantContentPart[]` — text, thinking blocks, and tool calls whose
|
|
85
|
+
* JSON arguments are frequently the largest single item in an agent conversation. A walk
|
|
86
|
+
* that counted only `{type:"text"}` would undercount exactly the turns that trigger this
|
|
87
|
+
* gate.
|
|
88
|
+
*/
|
|
89
|
+
export function estimateInputTokens(parsed: OcxParsedRequest, modelId: string): number {
|
|
90
|
+
const { context } = parsed;
|
|
91
|
+
let total = 0;
|
|
92
|
+
|
|
93
|
+
for (const prompt of context.systemPrompt ?? []) total += estimateTokens(prompt, modelId);
|
|
94
|
+
|
|
95
|
+
for (const message of context.messages) {
|
|
96
|
+
if (message.role === "assistant") {
|
|
97
|
+
for (const part of message.content) {
|
|
98
|
+
if (part.type === "text") total += estimateTokens(part.text, modelId);
|
|
99
|
+
else if (part.type === "thinking") total += estimateTokens(part.thinking, modelId);
|
|
100
|
+
else total += estimateTokens(part.name, modelId) + estimateTokens(JSON.stringify(part.arguments), modelId);
|
|
101
|
+
}
|
|
102
|
+
// Opaque provider blob replayed verbatim upstream, so it costs real input tokens.
|
|
103
|
+
if (message.kiroRedactedReasoning) total += estimateTokens(message.kiroRedactedReasoning, modelId);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
total += contentTokens(message.content, modelId);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Tool schemas ride every turn: name, description, and the JSON parameter schema all
|
|
110
|
+
// reach the upstream, and a large MCP catalog can dominate a short conversation.
|
|
111
|
+
for (const tool of context.tools ?? []) {
|
|
112
|
+
total += estimateTokens(tool.name, modelId)
|
|
113
|
+
+ estimateTokens(tool.description, modelId)
|
|
114
|
+
+ estimateTokens(JSON.stringify(tool.parameters), modelId);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return total;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Resolve the admission ceiling. Pure: no filesystem, no catalog, no registry scan.
|
|
122
|
+
*
|
|
123
|
+
* `provider` must be the ROUTED config (`route.provider`), which `routedProviderConfig`
|
|
124
|
+
* has already transport-guarded and merged. Re-deriving from `config.providers[name]` would
|
|
125
|
+
* reject a user-defined provider that merely shares a built-in name using limits that
|
|
126
|
+
* belong to a different service.
|
|
127
|
+
*/
|
|
128
|
+
export function resolveInputCeiling(
|
|
129
|
+
provider: OcxProviderConfig,
|
|
130
|
+
providerName: string,
|
|
131
|
+
modelId: string,
|
|
132
|
+
): number | null {
|
|
133
|
+
const configured = positive(provider.modelContextWindows?.[modelId]) ?? positive(provider.contextWindow);
|
|
134
|
+
|
|
135
|
+
// The canonical `openai` registry entry declares no context fields, so without this the
|
|
136
|
+
// gate would be inert on the default Codex route. All three clauses are load-bearing: a
|
|
137
|
+
// transport-mismatched custom provider named "openai" is preserved verbatim by routing
|
|
138
|
+
// and must not inherit built-in native limits, and a routed `provider/model` id is not a
|
|
139
|
+
// native slug. Static maps only — no catalog read.
|
|
140
|
+
const native = configured === null
|
|
141
|
+
&& providerName === OPENAI_CODEX_PROVIDER_ID
|
|
142
|
+
&& isCanonicalOpenAiForwardProvider(provider)
|
|
143
|
+
&& !modelId.includes("/")
|
|
144
|
+
? positive(nativeOpenAiContextWindow(modelId))
|
|
145
|
+
: null;
|
|
146
|
+
|
|
147
|
+
const window = configured ?? native;
|
|
148
|
+
// modelMaxInputTokens is an input-only cap, so it can only tighten the window.
|
|
149
|
+
const maxInput = positive(provider.modelMaxInputTokens?.[modelId]);
|
|
150
|
+
if (window === null) return maxInput;
|
|
151
|
+
return maxInput === null ? window : Math.min(window, maxInput);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Fail-open when no ceiling is known; refuse only past `ceiling * ADMISSION_TOLERANCE`.
|
|
156
|
+
*
|
|
157
|
+
* The caller is responsible for skipping compaction turns — see the call site in core.ts.
|
|
158
|
+
*/
|
|
159
|
+
export function checkInputAdmission(
|
|
160
|
+
parsed: OcxParsedRequest,
|
|
161
|
+
provider: OcxProviderConfig,
|
|
162
|
+
providerName: string,
|
|
163
|
+
modelId: string,
|
|
164
|
+
): InputAdmissionResult {
|
|
165
|
+
const ceiling = resolveInputCeiling(provider, providerName, modelId);
|
|
166
|
+
if (ceiling === null) return { admitted: true, estimatedTokens: 0, ceiling: null };
|
|
167
|
+
const estimatedTokens = estimateInputTokens(parsed, modelId);
|
|
168
|
+
return { admitted: estimatedTokens <= ceiling * ADMISSION_TOLERANCE, estimatedTokens, ceiling };
|
|
169
|
+
}
|
|
@@ -114,6 +114,17 @@ export async function handleResponsesWithPolicyFallback(
|
|
|
114
114
|
deps: PolicyFallbackDeps = {},
|
|
115
115
|
): Promise<Response> {
|
|
116
116
|
const runCore = deps.runCore ?? handleResponsesCore;
|
|
117
|
+
let requestBodyReadNotified = false;
|
|
118
|
+
const coreOptions: CoreOptions = options.onRequestBodyRead
|
|
119
|
+
? {
|
|
120
|
+
...options,
|
|
121
|
+
onRequestBodyRead: () => {
|
|
122
|
+
if (requestBodyReadNotified) return;
|
|
123
|
+
requestBodyReadNotified = true;
|
|
124
|
+
options.onRequestBodyRead?.();
|
|
125
|
+
},
|
|
126
|
+
}
|
|
127
|
+
: options;
|
|
117
128
|
let rawBody: Record<string, unknown> | null = null;
|
|
118
129
|
try {
|
|
119
130
|
const parsed = await readJsonRequestBody(req.clone());
|
|
@@ -122,7 +133,7 @@ export async function handleResponsesWithPolicyFallback(
|
|
|
122
133
|
// Core owns the client-facing parse/decompression error.
|
|
123
134
|
}
|
|
124
135
|
|
|
125
|
-
let response = await runCore(req, config, logCtx,
|
|
136
|
+
let response = await runCore(req, config, logCtx, coreOptions);
|
|
126
137
|
const initialTrace = logCtx.routeDecision;
|
|
127
138
|
const initialRequestedModel = logCtx.requestedModel;
|
|
128
139
|
if (!rawBody || !isPolicyDecision(initialTrace)) return response;
|
|
@@ -140,7 +151,7 @@ export async function handleResponsesWithPolicyFallback(
|
|
|
140
151
|
finishFailedPolicyAttempt(logCtx, response.status);
|
|
141
152
|
const retryRequest = requestWithCandidate(req, rawBody, next);
|
|
142
153
|
try {
|
|
143
|
-
response = await runCore(retryRequest, config, logCtx,
|
|
154
|
+
response = await runCore(retryRequest, config, logCtx, coreOptions);
|
|
144
155
|
} finally {
|
|
145
156
|
logCtx.requestedModel = initialRequestedModel;
|
|
146
157
|
logCtx.routeDecision = initialTrace;
|
|
@@ -12,14 +12,79 @@
|
|
|
12
12
|
// returned event frames as an SSE byte stream, so every downstream consumer
|
|
13
13
|
// (passthrough relay, adapter parsers, usage sniffing) is unchanged.
|
|
14
14
|
|
|
15
|
+
import { MAX_CLIENT_SSE_FRAME_BYTES } from "../sse-frame-buffer";
|
|
16
|
+
import { compareBunVersions } from "../../lib/bun-stream-caps";
|
|
17
|
+
|
|
15
18
|
const CODEX_RESPONSES_HTTP_URL = "https://chatgpt.com/backend-api/codex/responses";
|
|
16
19
|
const CODEX_RESPONSES_WS_URL = "wss://chatgpt.com/backend-api/codex/responses";
|
|
17
20
|
const WS_BETA = "responses_websockets=2026-02-06";
|
|
18
21
|
// If the 101 never arrives (network black hole), give SSE a chance well before
|
|
19
22
|
// the caller's connect timeout (default 200s) would fire.
|
|
20
23
|
const UPGRADE_DEADLINE_MS = 10_000;
|
|
24
|
+
// Keep the push-based WS transport inside the same memory envelope as the
|
|
25
|
+
// bounded SSE relays that consume this response. Unlike fetch response bodies,
|
|
26
|
+
// a WebSocket cannot be paused when a ReadableStream applies backpressure, so
|
|
27
|
+
// an upstream that outruns the consumer must be disconnected.
|
|
28
|
+
export const MAX_CODEX_WS_FRAME_BYTES = MAX_CLIENT_SSE_FRAME_BYTES;
|
|
29
|
+
export const MAX_CODEX_WS_QUEUE_BYTES = 8 * 1024 * 1024;
|
|
30
|
+
export const MIN_BOUNDED_CODEX_WS_BUN_VERSION = "1.4.0";
|
|
31
|
+
|
|
32
|
+
export type BunRuntimeIdentity = {
|
|
33
|
+
version: string;
|
|
34
|
+
versionWithSha: string;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export type BunRuntimeGateInput = string | BunRuntimeIdentity;
|
|
38
|
+
|
|
39
|
+
const codexWsUpstreamResponses = new WeakSet<Response>();
|
|
40
|
+
|
|
41
|
+
/** True only for a successful Codex WebSocket upgrade, never an HTTP fallback. */
|
|
42
|
+
export function isCodexWsUpstreamResponse(response: Response): boolean {
|
|
43
|
+
return codexWsUpstreamResponses.has(response);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function currentBunRuntimeIdentity(): BunRuntimeIdentity {
|
|
47
|
+
return {
|
|
48
|
+
version: Bun.version,
|
|
49
|
+
versionWithSha: Bun.version_with_sha,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
21
52
|
|
|
22
|
-
|
|
53
|
+
function boundedRelayVersion(input: BunRuntimeGateInput): string | null {
|
|
54
|
+
if (typeof input === "string") return input.trim() || null;
|
|
55
|
+
const numericVersion = input.version.trim();
|
|
56
|
+
const numericMatch = /^(\d+\.\d+\.\d+)$/.exec(numericVersion);
|
|
57
|
+
const detailedMatch = /^v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\s+\([0-9a-fA-F]+\)$/.exec(
|
|
58
|
+
input.versionWithSha.trim(),
|
|
59
|
+
);
|
|
60
|
+
if (!numericMatch || !detailedMatch) return null;
|
|
61
|
+
const detailedNumeric = /^(\d+\.\d+\.\d+)/.exec(detailedMatch[1])?.[1];
|
|
62
|
+
return detailedNumeric === numericMatch[1] ? detailedMatch[1] : null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Bun 1.3.14 does not propagate a stalled HTTP response socket back to a JS
|
|
67
|
+
* ReadableStream producer on Windows. A real raw-TCP slow-client probe drained
|
|
68
|
+
* the entire upstream despite the eager relay queue; Bun 1.4.0-canary.1 stopped
|
|
69
|
+
* below one MiB. Prereleases still fail closed; release builds before 1.4.0
|
|
70
|
+
* fall back to HTTP SSE.
|
|
71
|
+
*/
|
|
72
|
+
export function bunSupportsBoundedCodexWsRelay(
|
|
73
|
+
runtime: BunRuntimeGateInput = currentBunRuntimeIdentity(),
|
|
74
|
+
): boolean {
|
|
75
|
+
const version = boundedRelayVersion(runtime);
|
|
76
|
+
if (!version) return false;
|
|
77
|
+
if (/^\d+\.\d+\.\d+-/.test(version.trim())) return false;
|
|
78
|
+
const comparison = compareBunVersions(version, MIN_BOUNDED_CODEX_WS_BUN_VERSION);
|
|
79
|
+
return comparison !== null && comparison >= 0;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function shouldUseCodexWsUpstream(
|
|
83
|
+
url: string,
|
|
84
|
+
init?: RequestInit,
|
|
85
|
+
runtime: BunRuntimeGateInput = currentBunRuntimeIdentity(),
|
|
86
|
+
): boolean {
|
|
87
|
+
if (!bunSupportsBoundedCodexWsRelay(runtime)) return false;
|
|
23
88
|
if (url !== CODEX_RESPONSES_HTTP_URL) return false;
|
|
24
89
|
if ((init?.method ?? "GET").toUpperCase() !== "POST") return false;
|
|
25
90
|
const body = init?.body;
|
|
@@ -41,7 +106,11 @@ export function codexWsUpstreamFetch(
|
|
|
41
106
|
url: string,
|
|
42
107
|
init: RequestInit,
|
|
43
108
|
sseFallback: typeof globalThis.fetch,
|
|
109
|
+
runtime: BunRuntimeGateInput = currentBunRuntimeIdentity(),
|
|
44
110
|
): Promise<Response> {
|
|
111
|
+
if (!bunSupportsBoundedCodexWsRelay(runtime)) {
|
|
112
|
+
return sseFallback(url, init);
|
|
113
|
+
}
|
|
45
114
|
const signal = init.signal ?? undefined;
|
|
46
115
|
if (signal?.aborted) {
|
|
47
116
|
return Promise.reject(signal.reason ?? new DOMException("The operation was aborted.", "AbortError"));
|
|
@@ -91,6 +160,13 @@ export function codexWsUpstreamFetch(
|
|
|
91
160
|
let controller: ReadableStreamDefaultController<Uint8Array> | null = null;
|
|
92
161
|
const encoder = new TextEncoder();
|
|
93
162
|
|
|
163
|
+
const failStream = (message: string) => {
|
|
164
|
+
if (terminal) return;
|
|
165
|
+
terminal = true;
|
|
166
|
+
try { controller?.error(new Error(message)); } catch { /* stream already done */ }
|
|
167
|
+
try { ws.close(); } catch { /* already closing */ }
|
|
168
|
+
};
|
|
169
|
+
|
|
94
170
|
const upgradeTimer = setTimeout(() => {
|
|
95
171
|
if (opened || settledPreOpen) return;
|
|
96
172
|
settledPreOpen = true;
|
|
@@ -110,12 +186,14 @@ export function codexWsUpstreamFetch(
|
|
|
110
186
|
reject(signal?.reason ?? new DOMException("The operation was aborted.", "AbortError"));
|
|
111
187
|
return;
|
|
112
188
|
}
|
|
113
|
-
try { ws.close(); } catch { /* already closing */ }
|
|
114
189
|
if (controller && !terminal) {
|
|
115
190
|
terminal = true;
|
|
116
191
|
// Mirror an aborted fetch: the body read rejects with the abort reason.
|
|
117
192
|
try { controller.error(signal?.reason ?? new DOMException("The operation was aborted.", "AbortError")); } catch { /* stream already done */ }
|
|
118
193
|
}
|
|
194
|
+
// Error the body before close(): test doubles and some runtimes dispatch
|
|
195
|
+
// close synchronously, and the caller's abort reason must stay authoritative.
|
|
196
|
+
try { ws.close(); } catch { /* already closing */ }
|
|
119
197
|
};
|
|
120
198
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
121
199
|
|
|
@@ -138,19 +216,33 @@ export function codexWsUpstreamFetch(
|
|
|
138
216
|
const stream = new ReadableStream<Uint8Array>({
|
|
139
217
|
start(c) { controller = c; },
|
|
140
218
|
cancel() { try { ws.close(); } catch { /* already closing */ } },
|
|
141
|
-
});
|
|
142
|
-
|
|
219
|
+
}, new ByteLengthQueuingStrategy({ highWaterMark: MAX_CODEX_WS_QUEUE_BYTES }));
|
|
220
|
+
const response = new Response(stream, {
|
|
143
221
|
status: 200,
|
|
144
222
|
// The 101 response headers (x-codex-*-reset-at quota hints) are not
|
|
145
223
|
// exposed by Bun's WebSocket; the periodic quota poller covers those.
|
|
146
224
|
headers: { "content-type": "text/event-stream; charset=utf-8" },
|
|
147
|
-
})
|
|
225
|
+
});
|
|
226
|
+
codexWsUpstreamResponses.add(response);
|
|
227
|
+
resolve(response);
|
|
148
228
|
});
|
|
149
229
|
|
|
150
230
|
ws.addEventListener("message", (event) => {
|
|
151
231
|
if (!controller || terminal) return;
|
|
152
232
|
const text = typeof event.data === "string" ? event.data : "";
|
|
153
233
|
if (!text) return;
|
|
234
|
+
// UTF-8 byte length is always at least the JS string length. Reject this
|
|
235
|
+
// cheap lower bound before parsing so an obviously oversized frame does
|
|
236
|
+
// not create another large object graph.
|
|
237
|
+
if (text.length > MAX_CODEX_WS_FRAME_BYTES) {
|
|
238
|
+
failStream("codex websocket frame exceeds the response size limit");
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
const encodedText = encoder.encode(text);
|
|
242
|
+
if (encodedText.byteLength > MAX_CODEX_WS_FRAME_BYTES) {
|
|
243
|
+
failStream("codex websocket frame exceeds the response size limit");
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
154
246
|
let type: unknown;
|
|
155
247
|
try { type = (JSON.parse(text) as { type?: unknown }).type; } catch { return; }
|
|
156
248
|
if (typeof type !== "string") return;
|
|
@@ -158,9 +250,26 @@ export function codexWsUpstreamFetch(
|
|
|
158
250
|
// frames (codex.rate_limits, responsesapi.websocket_timing) are dropped
|
|
159
251
|
// so downstream clients see exactly the stream shape they always got.
|
|
160
252
|
if (!type.startsWith("response.") && type !== "error") return;
|
|
253
|
+
const prefix = encoder.encode(`event: ${type}\ndata: `);
|
|
254
|
+
const suffix = encoder.encode("\n\n");
|
|
255
|
+
const frameBytes = prefix.byteLength + encodedText.byteLength + suffix.byteLength;
|
|
256
|
+
if (frameBytes > MAX_CLIENT_SSE_FRAME_BYTES) {
|
|
257
|
+
failStream("codex websocket frame exceeds the response size limit");
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
const availableBytes = controller.desiredSize ?? 0;
|
|
261
|
+
if (frameBytes > availableBytes) {
|
|
262
|
+
failStream("codex websocket response exceeded the buffered queue limit");
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
const sseFrame = new Uint8Array(frameBytes);
|
|
266
|
+
sseFrame.set(prefix);
|
|
267
|
+
sseFrame.set(encodedText, prefix.byteLength);
|
|
268
|
+
sseFrame.set(suffix, prefix.byteLength + encodedText.byteLength);
|
|
161
269
|
try {
|
|
162
|
-
controller.enqueue(
|
|
270
|
+
controller.enqueue(sseFrame);
|
|
163
271
|
} catch {
|
|
272
|
+
failStream("codex websocket response stream closed while enqueueing");
|
|
164
273
|
return;
|
|
165
274
|
}
|
|
166
275
|
if (type === "response.completed" || type === "response.failed" || type === "response.incomplete" || type === "error") {
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
resolveTrustedWindowsSchtasksExe,
|
|
27
27
|
resolveTrustedWindowsSystemDirectory,
|
|
28
28
|
} from "./lib/windows-elevation";
|
|
29
|
+
import { decodeWindowsTextBytes } from "./lib/windows-text";
|
|
29
30
|
import { WINSW_SERVICE_ID } from "./lib/winsw";
|
|
30
31
|
|
|
31
32
|
/** Short: this runs inside admission, and a slow answer is the same as none. */
|
|
@@ -119,6 +120,8 @@ export interface ProbeDeps {
|
|
|
119
120
|
readonly configDir?: string;
|
|
120
121
|
/** Test seam for WinSW SCM status. Production uses bounded trusted `sc.exe query`. */
|
|
121
122
|
readonly winswStatus?: () => "started" | "stopped" | "nonexistent" | "unknown";
|
|
123
|
+
/** Test seam for redirected Windows legacy-codepage output. */
|
|
124
|
+
readonly windowsLocale?: string;
|
|
122
125
|
}
|
|
123
126
|
|
|
124
127
|
const LABEL = "com.opencodex.proxy";
|
|
@@ -261,9 +264,8 @@ function inspectSystemd(deps: Required<Pick<ProbeDeps, "run" | "home">>): Servic
|
|
|
261
264
|
"--user", "show", TASK,
|
|
262
265
|
"-p", "LoadState", "-p", "ActiveState", "-p", "FragmentPath", "-p", "NeedDaemonReload",
|
|
263
266
|
]);
|
|
264
|
-
if (shown.spawnFailed
|
|
265
|
-
|
|
266
|
-
}
|
|
267
|
+
if (shown.spawnFailed) return { kind: "absent" };
|
|
268
|
+
if (shown.timedOut) return unknown("systemctl could not be asked: timed out");
|
|
267
269
|
if (shown.status !== 0) {
|
|
268
270
|
// A missing unit still exits ZERO and says not-found; a non-zero status means
|
|
269
271
|
// the question never reached the bus.
|
|
@@ -338,29 +340,6 @@ function windowsConfigDirPath(deps: { home: string; configDir?: string }): strin
|
|
|
338
340
|
return join(deps.home, ".opencodex");
|
|
339
341
|
}
|
|
340
342
|
|
|
341
|
-
/** Decode an on-disk Windows text asset (task XML, VBS), which is UTF-16LE (often BOM-prefixed). */
|
|
342
|
-
function decodeWindowsText(buffer: Buffer): string {
|
|
343
|
-
if (buffer.length === 0) return "";
|
|
344
|
-
const bomUtf16Le = buffer.length >= 2 && buffer[0] === 0xff && buffer[1] === 0xfe;
|
|
345
|
-
const bomUtf16Be = buffer.length >= 2 && buffer[0] === 0xfe && buffer[1] === 0xff;
|
|
346
|
-
const looksUtf16Le = buffer.length >= 4
|
|
347
|
-
&& buffer[1] === 0x00
|
|
348
|
-
&& buffer[3] === 0x00
|
|
349
|
-
&& buffer[0] !== 0x00;
|
|
350
|
-
if (bomUtf16Le || looksUtf16Le) {
|
|
351
|
-
return buffer.toString("utf16le").replace(/^\uFEFF/, "").trim();
|
|
352
|
-
}
|
|
353
|
-
if (bomUtf16Be) {
|
|
354
|
-
const swapped = Buffer.alloc(buffer.length - 2);
|
|
355
|
-
for (let i = 2; i + 1 < buffer.length; i += 2) {
|
|
356
|
-
swapped[i - 2] = buffer[i + 1]!;
|
|
357
|
-
swapped[i - 1] = buffer[i]!;
|
|
358
|
-
}
|
|
359
|
-
return swapped.toString("utf16le").trim();
|
|
360
|
-
}
|
|
361
|
-
return buffer.toString("utf8").replace(/^\uFEFF/, "").trim();
|
|
362
|
-
}
|
|
363
|
-
|
|
364
343
|
/** Decode the XML entities emitted by the service-definition writers. */
|
|
365
344
|
function decodeXmlEntities(value: string): string {
|
|
366
345
|
return value
|
|
@@ -478,7 +457,9 @@ const SCHTASKS_TASK_NOT_FOUND_EN = /cannot find the file specified/i;
|
|
|
478
457
|
* other nonzero responses use a bounded full listing as the locale-neutral
|
|
479
458
|
* fallback, and only a successful list without our task proves absence.
|
|
480
459
|
*/
|
|
481
|
-
function probeWindowsTaskRegistration(
|
|
460
|
+
function probeWindowsTaskRegistration(
|
|
461
|
+
deps: Required<Pick<ProbeDeps, "runRaw">> & Pick<ProbeDeps, "windowsLocale">,
|
|
462
|
+
): {
|
|
482
463
|
registered: "present" | "absent" | "unknown";
|
|
483
464
|
registeredXml: string;
|
|
484
465
|
} {
|
|
@@ -492,13 +473,14 @@ function probeWindowsTaskRegistration(deps: Required<Pick<ProbeDeps, "runRaw">>)
|
|
|
492
473
|
const queried = deps.runRaw(schtasks, ["/query", "/tn", windowsTaskName(), "/xml"]);
|
|
493
474
|
if (queried.spawnFailed || queried.timedOut) return { registered: "unknown", registeredXml: "" };
|
|
494
475
|
if (queried.status === 0) {
|
|
495
|
-
const registeredXml =
|
|
476
|
+
const registeredXml = decodeWindowsTextBytes(queried.stdout, { locale: deps.windowsLocale })
|
|
477
|
+
|| decodeWindowsTextBytes(queried.stderr, { locale: deps.windowsLocale });
|
|
496
478
|
return registeredXml
|
|
497
479
|
? { registered: "present", registeredXml }
|
|
498
480
|
: { registered: "unknown", registeredXml: "" };
|
|
499
481
|
}
|
|
500
482
|
|
|
501
|
-
const queryText = `${
|
|
483
|
+
const queryText = `${decodeWindowsTextBytes(queried.stdout, { locale: deps.windowsLocale })}\n${decodeWindowsTextBytes(queried.stderr, { locale: deps.windowsLocale })}`;
|
|
502
484
|
if (queried.status !== null && SCHTASKS_TASK_NOT_FOUND_EN.test(queryText)) {
|
|
503
485
|
return { registered: "absent", registeredXml: "" };
|
|
504
486
|
}
|
|
@@ -507,7 +489,8 @@ function probeWindowsTaskRegistration(deps: Required<Pick<ProbeDeps, "runRaw">>)
|
|
|
507
489
|
if (listed.spawnFailed || listed.timedOut || listed.status !== 0) {
|
|
508
490
|
return { registered: "unknown", registeredXml: "" };
|
|
509
491
|
}
|
|
510
|
-
const listing =
|
|
492
|
+
const listing = decodeWindowsTextBytes(listed.stdout, { locale: deps.windowsLocale })
|
|
493
|
+
|| decodeWindowsTextBytes(listed.stderr, { locale: deps.windowsLocale });
|
|
511
494
|
return windowsTaskListContains(listing, windowsTaskName())
|
|
512
495
|
? { registered: "unknown", registeredXml: "" }
|
|
513
496
|
: { registered: "absent", registeredXml: "" };
|
|
@@ -545,7 +528,8 @@ function probeWinswRegistration(
|
|
|
545
528
|
}
|
|
546
529
|
|
|
547
530
|
function inspectWindows(
|
|
548
|
-
deps: Required<Pick<ProbeDeps, "runRaw" | "home">>
|
|
531
|
+
deps: Required<Pick<ProbeDeps, "runRaw" | "home">>
|
|
532
|
+
& Pick<ProbeDeps, "configDir" | "winswStatus" | "windowsLocale">,
|
|
549
533
|
): ServiceManagerInstallation {
|
|
550
534
|
const configDir = windowsConfigDirPath(deps);
|
|
551
535
|
const taskXmlPath = join(configDir, "opencodex-service-task.xml");
|
|
@@ -557,7 +541,7 @@ function inspectWindows(
|
|
|
557
541
|
let xml = "";
|
|
558
542
|
if (task !== "absent") {
|
|
559
543
|
try {
|
|
560
|
-
xml =
|
|
544
|
+
xml = decodeWindowsTextBytes(readFileSync(taskXmlPath), { locale: deps.windowsLocale });
|
|
561
545
|
} catch (error) {
|
|
562
546
|
return unknown(`the scheduled-task XML exists but could not be read: ${String(error)}`);
|
|
563
547
|
}
|
|
@@ -659,7 +643,7 @@ function homesEqual(
|
|
|
659
643
|
* generated service-asset directory.
|
|
660
644
|
*/
|
|
661
645
|
function walkWindowsChain(
|
|
662
|
-
deps: Required<Pick<ProbeDeps, "home">> & Pick<ProbeDeps, "configDir">,
|
|
646
|
+
deps: Required<Pick<ProbeDeps, "home">> & Pick<ProbeDeps, "configDir" | "windowsLocale">,
|
|
663
647
|
xml: string,
|
|
664
648
|
definitionPath: string,
|
|
665
649
|
): ServiceManagerInstallation {
|
|
@@ -683,7 +667,7 @@ function walkWindowsChain(
|
|
|
683
667
|
}
|
|
684
668
|
let launcherBody: string;
|
|
685
669
|
try {
|
|
686
|
-
launcherBody =
|
|
670
|
+
launcherBody = decodeWindowsTextBytes(readFileSync(launcherPath), { locale: deps.windowsLocale });
|
|
687
671
|
} catch (error) {
|
|
688
672
|
return unknown(`the scheduled-task launcher could not be read: ${String(error)}`);
|
|
689
673
|
}
|
|
@@ -702,7 +686,7 @@ function walkWindowsChain(
|
|
|
702
686
|
}
|
|
703
687
|
let wrapperBody: string;
|
|
704
688
|
try {
|
|
705
|
-
wrapperBody =
|
|
689
|
+
wrapperBody = decodeWindowsTextBytes(readFileSync(wrapperPath), { locale: deps.windowsLocale });
|
|
706
690
|
} catch (error) {
|
|
707
691
|
return unknown(`the launcher wrapper could not be read: ${String(error)}`);
|
|
708
692
|
}
|
|
@@ -734,7 +718,8 @@ function walkWindowsChain(
|
|
|
734
718
|
* this read-only ownership probe.
|
|
735
719
|
*/
|
|
736
720
|
function walkWinswChain(
|
|
737
|
-
deps: Required<Pick<ProbeDeps, "runRaw" | "home">>
|
|
721
|
+
deps: Required<Pick<ProbeDeps, "runRaw" | "home">>
|
|
722
|
+
& Pick<ProbeDeps, "configDir" | "winswStatus" | "windowsLocale">,
|
|
738
723
|
): ServiceManagerInstallation {
|
|
739
724
|
const configDir = windowsConfigDirPath(deps);
|
|
740
725
|
const exePath = join(configDir, "winsw", `${WINSW_SERVICE_ID}.exe`);
|
|
@@ -753,7 +738,7 @@ function walkWinswChain(
|
|
|
753
738
|
|
|
754
739
|
let body: string;
|
|
755
740
|
try {
|
|
756
|
-
body =
|
|
741
|
+
body = decodeWindowsTextBytes(readFileSync(xmlPath), { locale: deps.windowsLocale });
|
|
757
742
|
} catch (error) {
|
|
758
743
|
return unknown(`the WinSW XML could not be read: ${String(error)}`);
|
|
759
744
|
}
|
|
@@ -801,6 +786,7 @@ export function inspectServiceManagerInstallation(deps: ProbeDeps = {}): Service
|
|
|
801
786
|
home,
|
|
802
787
|
configDir: deps.configDir,
|
|
803
788
|
winswStatus: deps.winswStatus,
|
|
789
|
+
windowsLocale: deps.windowsLocale,
|
|
804
790
|
});
|
|
805
791
|
}
|
|
806
792
|
return unknown(`no service manager probe for platform ${platform}`);
|