@coseung2/opencodex 2.8.0-cs.13 → 2.8.0-cs.15

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 (60) hide show
  1. package/gui/dist/assets/index-BhXIu7c0.js +67 -0
  2. package/gui/dist/index.html +1 -1
  3. package/package.json +3 -3
  4. package/packages/ocx-notch/README.md +2 -1
  5. package/src/adapters/cursor/discovery.ts +6 -2
  6. package/src/adapters/cursor/effort-map.ts +3 -0
  7. package/src/adapters/google-antigravity-replay.ts +24 -0
  8. package/src/adapters/google.ts +16 -11
  9. package/src/chat/inbound.ts +5 -11
  10. package/src/cli/account-api.ts +9 -1
  11. package/src/cli/account-extended.ts +4 -1
  12. package/src/codex/account-label.ts +14 -1
  13. package/src/codex/account-lifecycle.ts +12 -1
  14. package/src/codex/account-namespaces.ts +21 -0
  15. package/src/codex/account-priority.ts +49 -0
  16. package/src/codex/account-store.ts +2 -1
  17. package/src/codex/auth-api.ts +108 -17
  18. package/src/codex/auth-context.ts +61 -16
  19. package/src/codex/catalog/metadata.ts +34 -12
  20. package/src/codex/catalog/parsing.ts +8 -1
  21. package/src/codex/catalog/provider-fetch.ts +24 -6
  22. package/src/codex/catalog.ts +1 -1
  23. package/src/codex/pool-rotation.ts +51 -4
  24. package/src/codex/quota.ts +154 -35
  25. package/src/codex/routing.ts +139 -33
  26. package/src/codex/warmup.ts +193 -85
  27. package/src/config.ts +84 -1
  28. package/src/lib/bounded-body.ts +13 -6
  29. package/src/lib/bun-stream-caps.ts +5 -6
  30. package/src/lib/redact.ts +13 -0
  31. package/src/oauth/index.ts +79 -12
  32. package/src/oauth/log.ts +3 -1
  33. package/src/oauth/store.ts +31 -8
  34. package/src/providers/antigravity-models.ts +53 -24
  35. package/src/providers/codex-capacity.ts +303 -0
  36. package/src/providers/model-rename-migration.ts +147 -0
  37. package/src/providers/model-rename-startup.ts +29 -0
  38. package/src/providers/quota.ts +126 -16
  39. package/src/providers/registry.ts +258 -38
  40. package/src/responses/parser.ts +19 -12
  41. package/src/responses/spill-store.ts +14 -1
  42. package/src/responses/state.ts +108 -14
  43. package/src/server/index.ts +9 -1
  44. package/src/server/management/logs-usage-routes.ts +1 -0
  45. package/src/server/management/oauth-account-routes.ts +8 -1
  46. package/src/server/relay.ts +10 -42
  47. package/src/server/request-log.ts +42 -1
  48. package/src/server/responses/compact.ts +16 -4
  49. package/src/server/responses/core.ts +217 -59
  50. package/src/server/responses/empty-completion-guard.ts +275 -0
  51. package/src/server/responses/encrypted-payload.ts +54 -39
  52. package/src/server/responses/fetch-helpers.ts +24 -3
  53. package/src/server/responses/ws-upstream.ts +318 -0
  54. package/src/server/sse-frame-buffer.ts +292 -0
  55. package/src/server/ws-bridge.ts +17 -11
  56. package/src/types.ts +8 -0
  57. package/src/usage/log.ts +24 -0
  58. package/src/usage/summary.ts +152 -2
  59. package/vendor/ocx-notch/win32-x64/ocx-notch.exe +0 -0
  60. package/gui/dist/assets/index-BucjyD4I.js +0 -67
@@ -1,19 +1,21 @@
1
+ import { readBoundedResponseBody } from "../lib/bounded-body";
2
+
1
3
  export class CodexWarmupError extends Error {
2
- code: "http_status" | "missing_body" | "stream_failed" | "stream_incomplete" | "stream_error" | "invalid_sse" | "no_terminal" | "transport";
4
+ code: "http_status" | "missing_body" | "stream_failed" | "stream_incomplete" | "stream_error" | "stream_too_large" | "invalid_sse" | "no_terminal" | "transport";
3
5
  status?: number;
4
- /** Upstream error detail extracted from the response body (truncated to 512 chars). */
5
- upstreamDetail?: string;
6
+ /** Internal-only classification used to preserve exhausted-account registration. */
7
+ quotaLike?: boolean;
6
8
 
7
9
  constructor(
8
10
  code: CodexWarmupError["code"],
9
11
  message = "Codex warmup failed",
10
- options: { status?: number; cause?: unknown; upstreamDetail?: string } = {},
12
+ options: { status?: number; cause?: unknown; quotaLike?: boolean } = {},
11
13
  ) {
12
14
  super(message);
13
15
  this.name = "CodexWarmupError";
14
16
  this.code = code;
15
17
  this.status = options.status;
16
- this.upstreamDetail = options.upstreamDetail;
18
+ this.quotaLike = options.quotaLike;
17
19
  if (options.cause !== undefined) this.cause = options.cause;
18
20
  }
19
21
  }
@@ -29,37 +31,42 @@ const CODEX_RESPONSES_URL = "https://chatgpt.com/backend-api/codex/responses";
29
31
  const DEFAULT_MODEL = "gpt-5.4-mini";
30
32
  const FALLBACK_MODELS = ["gpt-5.5"];
31
33
  const DEFAULT_TIMEOUT_MS = 30_000;
34
+ const MAX_TIMEOUT_MS = 0x7fff_ffff;
32
35
  const MAX_ERROR_BODY_BYTES = 2048;
36
+ const MAX_WARMUP_STREAM_BYTES = 1024 * 1024;
33
37
 
34
- /** Read the first MAX_ERROR_BODY_BYTES of a response body and extract an error message. */
35
- async function readErrorDetail(res: Response): Promise<string | undefined> {
38
+ /** Classify a bounded structured error without retaining or exposing upstream text. */
39
+ async function readErrorQuotaClassification(res: Response, signal: AbortSignal): Promise<boolean> {
36
40
  try {
37
- const text = await res.text();
38
- const trimmed = text.slice(0, MAX_ERROR_BODY_BYTES);
39
- try {
40
- const json = JSON.parse(trimmed) as Record<string, unknown>;
41
- // ChatGPT backend error shape: { error: { message: "..." } } or { detail: "..." }
42
- const nested = json.error;
43
- if (nested && typeof nested === "object" && typeof (nested as Record<string, unknown>).message === "string") {
44
- return ((nested as Record<string, unknown>).message as string).slice(0, 512);
45
- }
46
- if (typeof json.detail === "string") return json.detail.slice(0, 512);
47
- if (typeof json.error === "string") return (json.error as string).slice(0, 512);
48
- if (typeof json.message === "string") return json.message.slice(0, 512);
49
- } catch {
50
- // Non-JSON response body may contain sensitive data (tokens, credentials).
51
- // Only surface structured error messages, never raw text.
41
+ const bounded = await readBoundedResponseBody(res, {
42
+ signal,
43
+ maxBytes: MAX_ERROR_BODY_BYTES,
44
+ fatalUtf8: true,
45
+ });
46
+ if (!bounded.displaySafe) return false;
47
+ const json = JSON.parse(bounded.text) as Record<string, unknown>;
48
+ const nested = json.error;
49
+ const detail = nested && typeof nested === "object" && typeof (nested as Record<string, unknown>).message === "string"
50
+ ? (nested as Record<string, unknown>).message
51
+ : typeof json.detail === "string"
52
+ ? json.detail
53
+ : typeof json.error === "string"
54
+ ? json.error
55
+ : typeof json.message === "string"
56
+ ? json.message
57
+ : "";
58
+ return /\b(?:quota|rate[\s_-]*limit|usage[\s_-]*limit)\b/i.test(String(detail));
59
+ } catch (error) {
60
+ if (signal.aborted) {
61
+ throw new CodexWarmupError("transport", "Codex warmup request failed", { cause: error });
52
62
  }
53
- return undefined;
54
- } catch {
55
- return undefined;
63
+ return false;
56
64
  }
57
65
  }
58
66
 
59
67
  function safeWarmupReason(err: unknown): string {
60
68
  if (err instanceof CodexWarmupError) {
61
- const base = err.status ? `${err.code}:${err.status}` : err.code;
62
- return err.upstreamDetail ? `${base} — ${err.upstreamDetail}` : base;
69
+ return err.status ? `${err.code}:${err.status}` : err.code;
63
70
  }
64
71
  return "transport";
65
72
  }
@@ -77,9 +84,7 @@ export function codexWarmupFailureReason(err: unknown): string {
77
84
  export function isCodexWarmupQuotaFailure(err: unknown): boolean {
78
85
  if (!(err instanceof CodexWarmupError) || err.code !== "http_status") return false;
79
86
  if (err.status === 429) return true;
80
- return err.status === 403
81
- && typeof err.upstreamDetail === "string"
82
- && /\b(?:quota|rate[\s_-]*limit|usage[\s_-]*limit)\b/i.test(err.upstreamDetail);
87
+ return err.status === 403 && err.quotaLike === true;
83
88
  }
84
89
 
85
90
  function eventTypeFromData(data: unknown): string | undefined {
@@ -103,83 +108,186 @@ function parseSseFrame(frame: string): unknown | null {
103
108
  }
104
109
  }
105
110
 
106
- async function drainWarmupSse(body: ReadableStream<Uint8Array>): Promise<void> {
111
+ async function drainWarmupSse(body: ReadableStream<Uint8Array>, signal: AbortSignal): Promise<void> {
107
112
  const reader = body.getReader();
108
113
  const decoder = new TextDecoder();
109
- let buffer = "";
114
+ let buffer = new Uint8Array(Math.min(MAX_WARMUP_STREAM_BYTES, 64 * 1024));
115
+ let bufferedBytes = 0;
116
+ let scanOffset = 0;
117
+ let bytesRead = 0;
118
+ const abortError = () => new CodexWarmupError("transport", "Codex warmup request failed", {
119
+ cause: signal.reason,
120
+ });
121
+ const cancelReader = () => {
122
+ try {
123
+ void reader.cancel(signal.reason).catch(() => {});
124
+ } catch {
125
+ // Custom streams may throw synchronously from cancel().
126
+ }
127
+ };
128
+ const readWithSignal = (): Promise<Awaited<ReturnType<typeof reader.read>>> => {
129
+ if (signal.aborted) {
130
+ cancelReader();
131
+ return Promise.reject(abortError());
132
+ }
133
+ const read = reader.read();
134
+ void read.catch(() => {});
135
+ return new Promise((resolve, reject) => {
136
+ let settled = false;
137
+ const finish = (action: () => void) => {
138
+ if (settled) return;
139
+ settled = true;
140
+ signal.removeEventListener("abort", onAbort);
141
+ action();
142
+ };
143
+ const onAbort = () => {
144
+ cancelReader();
145
+ finish(() => reject(abortError()));
146
+ };
147
+ signal.addEventListener("abort", onAbort, { once: true });
148
+ if (signal.aborted) {
149
+ onAbort();
150
+ return;
151
+ }
152
+ read.then(
153
+ result => finish(() => resolve(result)),
154
+ error => finish(() => reject(error)),
155
+ );
156
+ });
157
+ };
158
+ const ensureCapacity = (requiredBytes: number) => {
159
+ if (requiredBytes <= buffer.byteLength) return;
160
+ const grown = new Uint8Array(Math.min(
161
+ MAX_WARMUP_STREAM_BYTES,
162
+ Math.max(requiredBytes, buffer.byteLength * 2),
163
+ ));
164
+ grown.set(buffer.subarray(0, bufferedBytes));
165
+ buffer = grown;
166
+ };
167
+ const findFrameDelimiter = (start: number): { index: number; length: 2 | 3 | 4 } | undefined => {
168
+ for (let index = start; index < bufferedBytes - 1; index += 1) {
169
+ const firstLength = buffer[index] === 10
170
+ ? 1
171
+ : buffer[index] === 13 && buffer[index + 1] === 10 ? 2 : 0;
172
+ if (firstLength === 0) continue;
173
+ const secondStart = index + firstLength;
174
+ const secondLength = buffer[secondStart] === 10
175
+ ? 1
176
+ : buffer[secondStart] === 13 && buffer[secondStart + 1] === 10 ? 2 : 0;
177
+ if (secondLength > 0) return { index, length: (firstLength + secondLength) as 2 | 3 | 4 };
178
+ }
179
+ return undefined;
180
+ };
181
+ const acceptFrame = (frame: Uint8Array): boolean => {
182
+ const parsed = parseSseFrame(decoder.decode(frame));
183
+ const type = eventTypeFromData(parsed);
184
+ if (type === "response.completed") return true;
185
+ if (type === "response.failed") throw new CodexWarmupError("stream_failed");
186
+ if (type === "response.incomplete") throw new CodexWarmupError("stream_incomplete");
187
+ if (type === "error") throw new CodexWarmupError("stream_error");
188
+ return false;
189
+ };
110
190
 
111
191
  try {
192
+ if (signal.aborted) throw abortError();
112
193
  for (;;) {
113
- const { done, value } = await reader.read();
194
+ const { done, value } = await readWithSignal();
195
+ if (signal.aborted) throw abortError();
114
196
  if (done) break;
115
- buffer += decoder.decode(value, { stream: true });
197
+ if (value.byteLength > MAX_WARMUP_STREAM_BYTES - bytesRead) {
198
+ throw new CodexWarmupError("stream_too_large", "Codex warmup stream exceeded the size limit");
199
+ }
200
+ bytesRead += value.byteLength;
201
+ ensureCapacity(bufferedBytes + value.byteLength);
202
+ buffer.set(value, bufferedBytes);
203
+ bufferedBytes += value.byteLength;
116
204
 
205
+ let consumedBytes = 0;
117
206
  for (;;) {
118
- const frameEnd = buffer.search(/\r?\n\r?\n/);
119
- if (frameEnd < 0) break;
120
- const frame = buffer.slice(0, frameEnd);
121
- const delimiterLength = buffer[frameEnd] === "\r" ? 4 : 2;
122
- buffer = buffer.slice(frameEnd + delimiterLength);
123
- const parsed = parseSseFrame(frame);
124
- const type = eventTypeFromData(parsed);
125
- if (type === "response.completed") return;
126
- if (type === "response.failed") throw new CodexWarmupError("stream_failed");
127
- if (type === "response.incomplete") throw new CodexWarmupError("stream_incomplete");
128
- if (type === "error") throw new CodexWarmupError("stream_error");
207
+ const delimiter = findFrameDelimiter(scanOffset);
208
+ if (!delimiter) {
209
+ scanOffset = Math.max(consumedBytes, bufferedBytes - 3);
210
+ break;
211
+ }
212
+ if (acceptFrame(buffer.subarray(consumedBytes, delimiter.index))) return;
213
+ consumedBytes = delimiter.index + delimiter.length;
214
+ scanOffset = consumedBytes;
215
+ }
216
+ if (consumedBytes > 0) {
217
+ buffer.copyWithin(0, consumedBytes, bufferedBytes);
218
+ bufferedBytes -= consumedBytes;
219
+ scanOffset = Math.max(0, scanOffset - consumedBytes);
129
220
  }
130
221
  }
131
222
 
132
- if (buffer.trim()) {
133
- const parsed = parseSseFrame(buffer);
134
- const type = eventTypeFromData(parsed);
135
- if (type === "response.completed") return;
136
- if (type === "response.failed") throw new CodexWarmupError("stream_failed");
137
- if (type === "response.incomplete") throw new CodexWarmupError("stream_incomplete");
138
- if (type === "error") throw new CodexWarmupError("stream_error");
139
- }
140
-
223
+ if (bufferedBytes > 0 && acceptFrame(buffer.subarray(0, bufferedBytes))) return;
141
224
  throw new CodexWarmupError("no_terminal", "Codex warmup ended before completion");
142
225
  } finally {
143
- reader.releaseLock();
226
+ try {
227
+ reader.releaseLock();
228
+ } catch {
229
+ // A non-settling cancellation can keep the pending read locked briefly.
230
+ }
144
231
  }
145
232
  }
146
233
 
147
234
  async function tryWarmup(options: CodexWarmupOptions, model: string): Promise<void> {
148
- let res: Response;
149
- try {
150
- res = await fetch(CODEX_RESPONSES_URL, {
151
- method: "POST",
152
- headers: {
153
- Authorization: `Bearer ${options.accessToken}`,
154
- "ChatGPT-Account-Id": options.chatgptAccountId,
155
- "Content-Type": "application/json",
156
- },
157
- body: JSON.stringify({
158
- model,
159
- instructions: "Reply with OK.",
160
- input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] }],
161
- stream: true,
162
- store: false,
163
- }),
164
- signal: AbortSignal.timeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS),
165
- });
166
- } catch (err) {
167
- throw new CodexWarmupError("transport", "Codex warmup request failed", { cause: err });
168
- }
169
-
170
- if (!res.ok) {
171
- const upstreamDetail = await readErrorDetail(res);
172
- throw new CodexWarmupError("http_status", "Codex warmup was rejected", {
173
- status: res.status,
174
- upstreamDetail,
235
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
236
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 0 || timeoutMs > MAX_TIMEOUT_MS) {
237
+ throw new CodexWarmupError("transport", "Codex warmup request failed", {
238
+ cause: new RangeError("Codex warmup timeout is outside the supported range"),
175
239
  });
176
240
  }
177
- if (!res.body) throw new CodexWarmupError("missing_body");
241
+ const deadline = new AbortController();
242
+ const signal = deadline.signal;
243
+ const timer = setTimeout(() => {
244
+ deadline.abort(new DOMException("Codex warmup timed out", "TimeoutError"));
245
+ }, timeoutMs);
178
246
 
179
247
  try {
180
- await drainWarmupSse(res.body);
248
+ let res: Response;
249
+ try {
250
+ res = await fetch(CODEX_RESPONSES_URL, {
251
+ method: "POST",
252
+ headers: {
253
+ Authorization: `Bearer ${options.accessToken}`,
254
+ "ChatGPT-Account-Id": options.chatgptAccountId,
255
+ "Content-Type": "application/json",
256
+ },
257
+ body: JSON.stringify({
258
+ model,
259
+ instructions: "Reply with OK.",
260
+ input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] }],
261
+ stream: true,
262
+ store: false,
263
+ }),
264
+ signal,
265
+ });
266
+ } catch (err) {
267
+ throw new CodexWarmupError("transport", "Codex warmup request failed", { cause: err });
268
+ }
269
+
270
+ if (!res.ok) {
271
+ const quotaLike = await readErrorQuotaClassification(res, signal);
272
+ throw new CodexWarmupError("http_status", "Codex warmup was rejected", {
273
+ status: res.status,
274
+ quotaLike,
275
+ });
276
+ }
277
+ const body = res.body;
278
+ if (!body) throw new CodexWarmupError("missing_body");
279
+
280
+ try {
281
+ await drainWarmupSse(body, signal);
282
+ } finally {
283
+ try {
284
+ void body.cancel().catch(() => {});
285
+ } catch {
286
+ // Some custom streams throw synchronously from cancel().
287
+ }
288
+ }
181
289
  } finally {
182
- await res.body?.cancel().catch(() => {});
290
+ clearTimeout(timer);
183
291
  }
184
292
  }
185
293
 
package/src/config.ts CHANGED
@@ -12,6 +12,8 @@ import {
12
12
  isValidCodexAccountNamespaceTarget,
13
13
  MAIN_CODEX_ACCOUNT_NAMESPACE_TARGET,
14
14
  } from "./codex/account-namespace-match";
15
+ import { isCodexAccountPriorityKey } from "./codex/account-priority";
16
+ import { parseAccountPriority } from "./codex/pool-rotation";
15
17
  import { COMBO_NAMESPACE, comboConfigIssues } from "./combos/types";
16
18
  import {
17
19
  forgetHardenedSecretPath,
@@ -712,6 +714,25 @@ const codexAccountNamespacesSchema = z.custom<Record<string, unknown>>(
712
714
  }
713
715
  }).pipe(z.record(z.string(), z.string()));
714
716
 
717
+ const codexAccountPrioritiesSchema = z.custom<Record<string, unknown>>(
718
+ (value): value is Record<string, unknown> => !!value
719
+ && typeof value === "object"
720
+ && !Array.isArray(value)
721
+ && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null),
722
+ { error: "codexAccountPriorities must be a plain object mapping account ids to integers" },
723
+ ).superRefine((priorities, ctx) => {
724
+ for (const [accountId, priority] of Object.entries(priorities)) {
725
+ if (!isCodexAccountPriorityKey(accountId)) {
726
+ ctx.addIssue({ code: "custom", path: [accountId], message: "invalid Codex account id" });
727
+ }
728
+ if (parseAccountPriority(priority) === null) {
729
+ ctx.addIssue({ code: "custom", path: [accountId], message: "selection order must be an integer between -100 and 100" });
730
+ }
731
+ }
732
+ }).pipe(z.record(z.string(), z.number().int()));
733
+
734
+ const CODEX_ACCOUNT_PIN_PATTERN = /^[a-zA-Z0-9._-]{1,64}$/;
735
+
715
736
  /**
716
737
  * Deliberately permissive. A user's config is not ours to invalidate: a strict
717
738
  * entry fails the whole parse, and loadConfig's fallback then backs the file up
@@ -779,13 +800,18 @@ const configSchema = z.object({
779
800
  // hashes, UUIDs), so validate only length, never charset: a foreign id must not
780
801
  // drop the whole paused set on config load.
781
802
  pausedOauthAccountIds: z.record(z.string(), z.array(z.string().min(1).max(128))).optional(),
803
+ codexAccountPriorities: codexAccountPrioritiesSchema.optional().catch(undefined),
804
+ activeCodexAccountPinned: z.string().regex(CODEX_ACCOUNT_PIN_PATTERN).optional().catch(undefined),
782
805
  codexAccountNamespaces: codexAccountNamespacesSchema.optional(),
806
+ codexAccountPickerEnabled: z.boolean().optional().catch(false),
783
807
  // Model ids excluded from the Grok Build managed block (dashboard switches).
784
808
  grokExcludedModels: z.array(z.string()).optional(),
785
809
  // Invalid values degrade to undefined ("auto") instead of failing the whole
786
810
  // parse: a hand-edited typo must never trip the backup-and-defaults repair
787
811
  // path below and wipe providers/pool accounts. Warning emitted in loadConfig.
788
812
  streamMode: z.enum(["auto", "legacy-tee", "eager-relay"]).optional().catch(undefined),
813
+ // A retry can be billable, so absence and malformed hand edits both stay off.
814
+ emptyCompletionRetry: z.boolean().optional().catch(false),
789
815
  // Same degrade-don't-reject rationale as the fields above: a hand-edited
790
816
  // non-string must not trip the backup-and-defaults repair path. Unset then
791
817
  // takes the canonical sideband path (src/server/live.ts normalizeSidebandRoot).
@@ -1258,6 +1284,29 @@ function rawConfigRecord(rawParsed: unknown): Record<string, unknown> | null {
1258
1284
  : null;
1259
1285
  }
1260
1286
 
1287
+ function degradedCodexAccountRoutingWarnings(rawParsed: unknown, validated: OcxConfig): string[] {
1288
+ const raw = rawConfigRecord(rawParsed);
1289
+ if (!raw) return [];
1290
+ const warnings: string[] = [];
1291
+ if (raw.codexAccountPriorities !== undefined && validated.codexAccountPriorities === undefined) {
1292
+ warnings.push("codexAccountPriorities ignored: expected account ids mapped to integers between -100 and 100");
1293
+ }
1294
+ if (raw.activeCodexAccountPinned !== undefined && validated.activeCodexAccountPinned === undefined) {
1295
+ warnings.push("activeCodexAccountPinned ignored: expected a Codex account id");
1296
+ }
1297
+ if (raw.codexAccountPickerEnabled !== undefined
1298
+ && typeof raw.codexAccountPickerEnabled !== "boolean") {
1299
+ warnings.push("codexAccountPickerEnabled ignored: expected a boolean");
1300
+ }
1301
+ return warnings;
1302
+ }
1303
+
1304
+ function warnDegradedCodexAccountRouting(rawParsed: unknown, validated: OcxConfig): void {
1305
+ for (const warning of degradedCodexAccountRoutingWarnings(rawParsed, validated)) {
1306
+ console.warn(`⚠️ config.json ${warning}. Other settings were preserved.`);
1307
+ }
1308
+ }
1309
+
1261
1310
  function malformedNativeSubagentFields(rawParsed: unknown): NativeSubagentPersistedField[] {
1262
1311
  const raw = rawConfigRecord(rawParsed);
1263
1312
  if (!raw) return [];
@@ -1325,6 +1374,7 @@ export function loadConfig(): OcxConfig {
1325
1374
  const config = normalizeApiKeyIds(result.data as OcxConfig);
1326
1375
  warnDegradedStreamMode(parsed, config);
1327
1376
  warnDegradedHostname(parsed, config);
1377
+ warnDegradedCodexAccountRouting(parsed, config);
1328
1378
  warnDegradedApiKeys(parsed, config);
1329
1379
  warnDegradedClaudeSubagentEffort(parsed);
1330
1380
  warnDegradedNativeSubagentConfig(parsed, config);
@@ -1344,6 +1394,7 @@ export function loadConfig(): OcxConfig {
1344
1394
  warnConfigRepaired(configPath, result.error);
1345
1395
  const config = normalizeApiKeyIds(retryResult.data as OcxConfig);
1346
1396
  warnDegradedHostname(parsed, config);
1397
+ warnDegradedCodexAccountRouting(parsed, config);
1347
1398
  warnDegradedApiKeys(parsed, config);
1348
1399
  warnDegradedClaudeSubagentEffort(parsed);
1349
1400
  warnDegradedNativeSubagentConfig(parsed, config);
@@ -1391,6 +1442,7 @@ function validFileConfigDiagnostics(config: OcxConfig, rawParsed: unknown): Conf
1391
1442
  const rawEffort = rawClaudeSubagentEffort(rawParsed);
1392
1443
  const normalized = normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, rawParsed), rawParsed);
1393
1444
  const warnings = configPlaceholderWarnings(normalized);
1445
+ warnings.push(...degradedCodexAccountRoutingWarnings(rawParsed, normalized));
1394
1446
  if (rawEffort !== undefined && !isClaudeSubagentEffort(rawEffort)) {
1395
1447
  warnings.push(`claudeCode.subagentEffort ignored: expected one of ${CLAUDE_SUBAGENT_EFFORTS.join(", ")}`);
1396
1448
  }
@@ -1472,12 +1524,42 @@ function googleAntigravityStaticCatalogVersionError(value: unknown): string | nu
1472
1524
  return "schema_invalid: googleAntigravityStaticCatalogVersion: must be 1 or omitted";
1473
1525
  }
1474
1526
 
1527
+ function codexAccountRoutingPreferenceError(value: unknown): string | null {
1528
+ const raw = rawConfigRecord(value);
1529
+ if (!raw) return null;
1530
+ if (raw.codexAccountPriorities !== undefined) {
1531
+ const parsed = codexAccountPrioritiesSchema.safeParse(raw.codexAccountPriorities);
1532
+ if (!parsed.success) {
1533
+ return schemaDiagnosticsError(parsed.error).replace("schema_invalid: ", "schema_invalid: codexAccountPriorities.");
1534
+ }
1535
+ }
1536
+ const pin = raw.activeCodexAccountPinned;
1537
+ if (pin !== undefined && (typeof pin !== "string" || !CODEX_ACCOUNT_PIN_PATTERN.test(pin))) {
1538
+ return "schema_invalid: activeCodexAccountPinned: must be an account id";
1539
+ }
1540
+ const picker = raw.codexAccountPickerEnabled;
1541
+ if (picker !== undefined && typeof picker !== "boolean") {
1542
+ return "schema_invalid: codexAccountPickerEnabled: must be a boolean";
1543
+ }
1544
+ return null;
1545
+ }
1546
+
1547
+ function emptyCompletionRetryError(value: unknown): string | null {
1548
+ const raw = rawConfigRecord(value);
1549
+ if (!raw || !Object.hasOwn(raw, "emptyCompletionRetry")) return null;
1550
+ return raw.emptyCompletionRetry === undefined || typeof raw.emptyCompletionRetry === "boolean"
1551
+ ? null
1552
+ : "schema_invalid: emptyCompletionRetry: must be a boolean or omitted";
1553
+ }
1554
+
1475
1555
  /** Validate an in-memory config candidate without touching disk. Used by headless CLI import/set. */
1476
1556
  export function validateConfigCandidate(value: unknown): { ok: true; config: OcxConfig } | { ok: false; error: string } {
1477
1557
  const boundaryError = blankHostnameError(value)
1478
1558
  ?? claudeSubagentEffortError(value)
1479
1559
  ?? appOwnedMemoryBudgetError(value)
1480
- ?? googleAntigravityStaticCatalogVersionError(value);
1560
+ ?? googleAntigravityStaticCatalogVersionError(value)
1561
+ ?? codexAccountRoutingPreferenceError(value)
1562
+ ?? emptyCompletionRetryError(value);
1481
1563
  if (boundaryError) return { ok: false, error: boundaryError };
1482
1564
  const result = configSchema.safeParse(value);
1483
1565
  if (result.success) return { ok: true, config: normalizeApiKeyIds(result.data as OcxConfig) };
@@ -2009,6 +2091,7 @@ export function getDefaultConfig(): OcxConfig {
2009
2091
  // Adding extra providers (e.g. opencode-go) and switching defaultProvider is a user/runtime choice.
2010
2092
  return {
2011
2093
  port: 10100,
2094
+ emptyCompletionRetry: false,
2012
2095
  managementUsageMaxReadBytes: 64 * 1024 * 1024,
2013
2096
  appOwnedMemoryBudgetMb: DEFAULT_APP_OWNED_MEMORY_BUDGET_BYTES / (1024 * 1024),
2014
2097
  // Fresh/re-initialized configs are already written in the current three-tier
@@ -7,10 +7,16 @@ export const BOUNDED_BODY_TIMEOUT_MS = 5_000;
7
7
  export interface BoundedBodyOptions {
8
8
  /** Abort the read with this signal. Its reason is rethrown by identity. */
9
9
  signal?: AbortSignal;
10
+ /** Reject malformed or truncated UTF-8 instead of replacing invalid bytes. */
11
+ fatalUtf8?: boolean;
12
+ /** Per-call retained byte ceiling. Defaults to BOUNDED_BODY_MAX_BYTES. */
13
+ maxBytes?: number;
10
14
  /** Total wall-clock deadline. Exposed for focused tests. */
11
15
  totalTimeoutMs?: number;
12
16
  /** Deadline between non-empty raw chunks. Exposed for focused tests. */
13
17
  inactivityTimeoutMs?: number;
18
+ /** Deadline for the first non-empty chunk. Defaults to inactivityTimeoutMs. */
19
+ firstByteTimeoutMs?: number;
14
20
  }
15
21
 
16
22
  export interface BoundedBodyResult {
@@ -56,8 +62,8 @@ function cancelWithoutWaiting(reader: ReadableStreamDefaultReader<Uint8Array>, r
56
62
  }
57
63
  }
58
64
 
59
- function decodeUtf8(chunks: readonly Uint8Array[]): string {
60
- const decoder = new TextDecoder();
65
+ function decodeUtf8(chunks: readonly Uint8Array[], fatal: boolean): string {
66
+ const decoder = new TextDecoder("utf-8", { fatal });
61
67
  let text = "";
62
68
  for (const chunk of chunks) text += decoder.decode(chunk, { stream: true });
63
69
  // Flush an incomplete trailing UTF-8 sequence deterministically.
@@ -93,13 +99,14 @@ export async function readBoundedResponseBody(
93
99
  }
94
100
 
95
101
  const reader = body.getReader();
102
+ const maxBytes = options.maxBytes ?? BOUNDED_BODY_MAX_BYTES;
96
103
  const chunks: Uint8Array[] = [];
97
104
  let retainedBytes = 0;
98
105
  let mustCancel = false;
99
106
  let cancelReason: unknown;
100
107
  const total = timeoutPromise(options.totalTimeoutMs ?? BOUNDED_BODY_TIMEOUT_MS, TOTAL_TIMEOUT);
101
108
  let inactivity = timeoutPromise(
102
- options.inactivityTimeoutMs ?? BOUNDED_BODY_TIMEOUT_MS,
109
+ options.firstByteTimeoutMs ?? options.inactivityTimeoutMs ?? BOUNDED_BODY_TIMEOUT_MS,
103
110
  INACTIVITY_TIMEOUT,
104
111
  );
105
112
 
@@ -134,7 +141,7 @@ export async function readBoundedResponseBody(
134
141
  "TimeoutError",
135
142
  );
136
143
  return {
137
- text: decodeUtf8(chunks),
144
+ text: decodeUtf8(chunks, options.fatalUtf8 === true),
138
145
  truncated: true,
139
146
  timedOut: true,
140
147
  totalTimedOut: outcome === TOTAL_TIMEOUT,
@@ -147,7 +154,7 @@ export async function readBoundedResponseBody(
147
154
  const { value, done } = outcome as ReadableStreamReadResult<Uint8Array>;
148
155
  if (done) {
149
156
  return {
150
- text: decodeUtf8(chunks),
157
+ text: decodeUtf8(chunks, options.fatalUtf8 === true),
151
158
  truncated: false,
152
159
  timedOut: false,
153
160
  totalTimedOut: false,
@@ -165,7 +172,7 @@ export async function readBoundedResponseBody(
165
172
  INACTIVITY_TIMEOUT,
166
173
  );
167
174
 
168
- if (value.byteLength > BOUNDED_BODY_MAX_BYTES - retainedBytes) {
175
+ if (value.byteLength > maxBytes - retainedBytes) {
169
176
  mustCancel = true;
170
177
  cancelReason = new DOMException("Error body size limit reached", "QuotaExceededError");
171
178
  chunks.length = 0;
@@ -3,9 +3,8 @@
3
3
  *
4
4
  * The eager bounded relay (src/server/relay-eager.ts) uses a JS async producer
5
5
  * loop — the exact shape of the Bun#32111 use-after-free (fixed upstream by Bun
6
- * PR #32120, merged 2026-06-21). No RELEASED Bun version is proven to carry
7
- * that fix yet, so `MIN_FIXED_BUN_VERSION` is null: every runtime is
8
- * "known-bad" until a bundle-bump commit sets it. Windows no-rewrite traffic
6
+ * PR #32120, merged 2026-06-21). Bun 1.4.0 is the first released version proven
7
+ * to carry that fix, so older runtimes remain "known-bad". Windows no-rewrite traffic
9
8
  * follows this runtime/config decision. Darwin no-rewrite traffic stays on tee
10
9
  * for `auto` regardless of runtime capability and reaches eager relay only via
11
10
  * explicit `streamMode: "eager-relay"` opt-in (see
@@ -18,10 +17,10 @@
18
17
  */
19
18
 
20
19
  /**
21
- * Bump in the SAME commit that bumps package.json's bundled Bun to a version
22
- * verified to include Bun PR #32120. null = no released version is known-fixed.
20
+ * Keep this in sync with the first bundled stable release verified to include
21
+ * Bun PR #32120. null = no released version is known-fixed.
23
22
  */
24
- export const MIN_FIXED_BUN_VERSION: string | null = null;
23
+ export const MIN_FIXED_BUN_VERSION: string | null = "1.4.0";
25
24
 
26
25
  export type StreamMode = "auto" | "legacy-tee" | "eager-relay";
27
26
 
package/src/lib/redact.ts CHANGED
@@ -38,6 +38,19 @@ export function redactSecretString(value: string): string {
38
38
  return redacted;
39
39
  }
40
40
 
41
+ /** Shared bounded single-line representation for caller-controlled log metadata. */
42
+ export function sanitizeLogMetadataString(value: unknown, maxLength = 64): string | undefined {
43
+ if (typeof value !== "string" || !Number.isInteger(maxLength) || maxLength < 1) return undefined;
44
+ // Replace record separators with spaces so two adjacent fragments cannot be
45
+ // concatenated into a token that evades boundary-aware secret redaction.
46
+ const filtered = value.trim()
47
+ .replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, " ")
48
+ .replace(/\s+/g, " ");
49
+ if (!filtered) return undefined;
50
+ const redacted = redactSecretString(filtered).trim();
51
+ return redacted ? redacted.slice(0, maxLength) : undefined;
52
+ }
53
+
41
54
  export function redactSecrets(value: unknown): unknown {
42
55
  if (typeof value === "string") return redactSecretString(value);
43
56
  if (Array.isArray(value)) return value.map(item => redactSecrets(item));