@bitkyc08/opencodex 2.1.1 → 2.1.5

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/src/server.ts CHANGED
@@ -16,7 +16,7 @@ import {
16
16
  type WsData,
17
17
  } from "./ws-bridge";
18
18
  import type { ServerWebSocket } from "bun";
19
- import { DEFAULT_SUBAGENT_MODELS, loadConfig, saveConfig, websocketsEnabled } from "./config";
19
+ import { DEFAULT_SUBAGENT_MODELS, codexAutoStartEnabled, loadConfig, saveConfig, websocketsEnabled } from "./config";
20
20
  import { parseRequest } from "./responses/parser";
21
21
  import { routeModel } from "./router";
22
22
  import { namespacedToolName } from "./types";
@@ -183,20 +183,29 @@ async function handleResponses(
183
183
  // whose cancel() aborts the upstream — preventing leaked connections (RC2, passthrough path).
184
184
  const upstream = new AbortController();
185
185
  linkAbortSignal(upstream, options.abortSignal);
186
+ const connectMs = config.connectTimeoutMs ?? 30_000;
186
187
  let upstreamResponse: Response;
187
188
  try {
188
- upstreamResponse = await fetch(request.url, {
189
+ upstreamResponse = await fetchWithHeaderTimeout(request.url, {
189
190
  method: request.method,
190
191
  headers: request.headers,
191
192
  body: request.body,
192
- signal: upstream.signal,
193
- });
193
+ }, upstream.signal, connectMs);
194
194
  } catch (err) {
195
- return formatErrorResponse(502, "upstream_error", `Provider unreachable: ${err instanceof Error ? err.message : String(err)}`);
195
+ upstream.abort();
196
+ const msg = err instanceof Error && err.name === "TimeoutError"
197
+ ? `Provider connect timeout after ${connectMs}ms`
198
+ : `Provider unreachable: ${err instanceof Error ? err.message : String(err)}`;
199
+ return formatErrorResponse(502, "upstream_error", msg);
196
200
  }
197
- return new Response(relayWithAbort(upstreamResponse.body, upstream), {
201
+ const headers = sanitizePassthroughHeaders(upstreamResponse.headers);
202
+ const isEventStream = headers.get("content-type")?.toLowerCase().includes("text/event-stream") ?? false;
203
+ const body = isEventStream
204
+ ? relaySseWithHeartbeat(upstreamResponse.body, upstream)
205
+ : relayWithAbort(upstreamResponse.body, upstream);
206
+ return new Response(body, {
198
207
  status: upstreamResponse.status,
199
- headers: sanitizePassthroughHeaders(upstreamResponse.headers),
208
+ headers,
200
209
  });
201
210
  }
202
211
 
@@ -220,15 +229,20 @@ async function handleResponses(
220
229
 
221
230
  const upstream = new AbortController();
222
231
  linkAbortSignal(upstream, options.abortSignal);
232
+ const connectMs = config.connectTimeoutMs ?? 30_000;
223
233
 
224
234
  const request = adapter.buildRequest(parsed, { headers: req.headers });
225
235
  let upstreamResponse: Response;
226
236
  try {
227
- upstreamResponse = await fetch(request.url, {
228
- method: request.method, headers: request.headers, body: request.body, signal: upstream.signal,
229
- });
237
+ upstreamResponse = await fetchWithHeaderTimeout(request.url, {
238
+ method: request.method, headers: request.headers, body: request.body,
239
+ }, upstream.signal, connectMs);
230
240
  } catch (err) {
231
- return formatErrorResponse(502, "upstream_error", `Provider unreachable: ${err instanceof Error ? err.message : String(err)}`);
241
+ upstream.abort();
242
+ const msg = err instanceof Error && err.name === "TimeoutError"
243
+ ? `Provider connect timeout after ${connectMs}ms`
244
+ : `Provider unreachable: ${err instanceof Error ? err.message : String(err)}`;
245
+ return formatErrorResponse(502, "upstream_error", msg);
232
246
  }
233
247
 
234
248
  if (!upstreamResponse.ok) {
@@ -249,7 +263,11 @@ async function handleResponses(
249
263
  const sseStream = bridgeToResponsesSSE(
250
264
  eventStream, parsed.modelId, toolNsMap, freeformToolNames, toolSearchToolNames,
251
265
  () => upstream.abort(), 2_000,
252
- options.forceEmptyResponseId ? { responseId: "" } : undefined,
266
+ {
267
+ ...(options.forceEmptyResponseId ? { responseId: "" } : {}),
268
+ stallTimeoutSec: config.stallTimeoutSec,
269
+ hideThinkingSummary: parsed.options.hideThinkingSummary,
270
+ },
253
271
  );
254
272
  return new Response(sseStream, {
255
273
  headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no" },
@@ -258,7 +276,20 @@ async function handleResponses(
258
276
 
259
277
  if (adapter.parseResponse) {
260
278
  const events = await adapter.parseResponse(upstreamResponse);
261
- const json = buildResponseJSON(events, parsed.modelId);
279
+ const toolNsMap = new Map<string, { namespace: string; name: string }>();
280
+ const freeformToolNames = new Set<string>();
281
+ const toolSearchToolNames = new Set<string>();
282
+ for (const t of parsed.context.tools ?? []) {
283
+ if (t.namespace) toolNsMap.set(namespacedToolName(t.namespace, t.name), { namespace: t.namespace, name: t.name });
284
+ if (t.freeform) freeformToolNames.add(t.name);
285
+ if (t.toolSearch) toolSearchToolNames.add(t.name);
286
+ }
287
+ const json = buildResponseJSON(events, parsed.modelId, {
288
+ hideThinkingSummary: parsed.options.hideThinkingSummary,
289
+ toolNsMap,
290
+ freeformToolNames,
291
+ toolSearchToolNames,
292
+ });
262
293
  return new Response(JSON.stringify(json), { headers: { "Content-Type": "application/json" } });
263
294
  }
264
295
 
@@ -274,6 +305,26 @@ export function linkAbortSignal(upstream: AbortController, signal?: AbortSignal)
274
305
  signal.addEventListener("abort", () => upstream.abort(signal.reason), { once: true });
275
306
  }
276
307
 
308
+ async function fetchWithHeaderTimeout(
309
+ url: string,
310
+ init: Omit<RequestInit, "signal">,
311
+ abortSignal: AbortSignal,
312
+ timeoutMs: number,
313
+ ): Promise<Response> {
314
+ const timeout = new AbortController();
315
+ const timer = setTimeout(() => {
316
+ if (!timeout.signal.aborted) timeout.abort(new DOMException("Timeout elapsed", "TimeoutError"));
317
+ }, timeoutMs);
318
+ try {
319
+ return await fetch(url, {
320
+ ...init,
321
+ signal: AbortSignal.any([abortSignal, timeout.signal]),
322
+ });
323
+ } finally {
324
+ clearTimeout(timer);
325
+ }
326
+ }
327
+
277
328
  const requestLog: { timestamp: number; model: string; provider: string; status: number; durationMs: number }[] = [];
278
329
  const MAX_LOG_SIZE = 200;
279
330
 
@@ -315,6 +366,56 @@ export function relayWithAbort(
315
366
  });
316
367
  }
317
368
 
369
+ export function relaySseWithHeartbeat(
370
+ body: ReadableStream<Uint8Array> | null,
371
+ upstream: AbortController,
372
+ heartbeatMs = 15_000,
373
+ ): ReadableStream<Uint8Array> | null {
374
+ if (!body) return null;
375
+ const reader = body.getReader();
376
+ const heartbeat = new TextEncoder().encode(": opencodex keepalive\n\n");
377
+ let timer: ReturnType<typeof setInterval> | undefined;
378
+ let closed = false;
379
+
380
+ const cleanup = () => {
381
+ closed = true;
382
+ if (timer) clearInterval(timer);
383
+ timer = undefined;
384
+ };
385
+
386
+ return new ReadableStream<Uint8Array>({
387
+ start(controller) {
388
+ timer = setInterval(() => {
389
+ if (closed) return;
390
+ try {
391
+ controller.enqueue(heartbeat);
392
+ } catch {
393
+ cleanup();
394
+ }
395
+ }, heartbeatMs);
396
+ },
397
+ async pull(controller) {
398
+ try {
399
+ const { done, value } = await reader.read();
400
+ if (done) {
401
+ cleanup();
402
+ controller.close();
403
+ return;
404
+ }
405
+ controller.enqueue(value);
406
+ } catch (err) {
407
+ cleanup();
408
+ try { controller.error(err); } catch { /* already torn down */ }
409
+ }
410
+ },
411
+ cancel(reason) {
412
+ cleanup();
413
+ upstream.abort(reason);
414
+ reader.cancel(reason).catch(() => {});
415
+ },
416
+ });
417
+ }
418
+
318
419
  /**
319
420
  * Bun's fetch auto-decompresses the response body but leaves the upstream `content-encoding`
320
421
  * (and a now-stale `content-length`) on `response.headers`. Relaying those with the already-decoded
@@ -358,7 +459,18 @@ function jsonResponse(data: unknown, status = 200): Response {
358
459
  });
359
460
  }
360
461
 
462
+ function isLocalOrigin(req: Request): boolean {
463
+ const origin = req.headers.get("Origin");
464
+ if (!origin) return true;
465
+ const localhostOrigin = _corsOrigin;
466
+ const loopbackOrigin = _corsOrigin.replace("localhost", "127.0.0.1");
467
+ return origin === localhostOrigin || origin === loopbackOrigin;
468
+ }
469
+
361
470
  async function handleManagementAPI(req: Request, url: URL, config: OcxConfig): Promise<Response | null> {
471
+ if ((req.method === "POST" || req.method === "PUT" || req.method === "DELETE") && !isLocalOrigin(req)) {
472
+ return jsonResponse({ error: "cross-origin request blocked" }, 403);
473
+ }
362
474
  async function refreshCodexCatalogBestEffort(): Promise<void> {
363
475
  try {
364
476
  const { refreshCodexModelCatalog } = await import("./codex-refresh");
@@ -370,6 +482,7 @@ async function handleManagementAPI(req: Request, url: URL, config: OcxConfig): P
370
482
 
371
483
  if (url.pathname === "/api/config" && req.method === "GET") {
372
484
  const safeConfig = JSON.parse(JSON.stringify(config));
485
+ safeConfig.codexAutoStart = codexAutoStartEnabled(config);
373
486
  for (const prov of Object.values(safeConfig.providers as Record<string, OcxProviderConfig>)) {
374
487
  if (prov.apiKey) prov.apiKey = prov.apiKey.slice(0, 8) + "...";
375
488
  }
@@ -380,6 +493,56 @@ async function handleManagementAPI(req: Request, url: URL, config: OcxConfig): P
380
493
  return jsonResponse({ error: "Full config PUT is disabled. Use /api/providers POST for provider changes." }, 405);
381
494
  }
382
495
 
496
+ if (url.pathname === "/api/settings" && req.method === "GET") {
497
+ return jsonResponse({
498
+ codexAutoStart: codexAutoStartEnabled(config),
499
+ port: config.port,
500
+ hostname: config.hostname ?? "127.0.0.1",
501
+ });
502
+ }
503
+
504
+ if (url.pathname === "/api/settings" && req.method === "PUT") {
505
+ let body: { codexAutoStart?: unknown };
506
+ try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
507
+ if (typeof body.codexAutoStart !== "boolean") {
508
+ return jsonResponse({ error: "codexAutoStart boolean is required" }, 400);
509
+ }
510
+ config.codexAutoStart = body.codexAutoStart;
511
+ saveConfig(config);
512
+ return jsonResponse({ ok: true, codexAutoStart: codexAutoStartEnabled(config) });
513
+ }
514
+
515
+ if (url.pathname === "/api/sidecar-settings" && req.method === "GET") {
516
+ const ws = config.webSearchSidecar ?? {};
517
+ const vs = config.visionSidecar ?? {};
518
+ return jsonResponse({
519
+ webSearch: { model: ws.model ?? "gpt-5.4-mini", reasoning: ws.reasoning ?? "low" },
520
+ vision: { model: vs.model ?? "gpt-5.4-mini" },
521
+ });
522
+ }
523
+
524
+ if (url.pathname === "/api/sidecar-settings" && req.method === "PUT") {
525
+ let body: { webSearch?: { model?: string; reasoning?: string }; vision?: { model?: string } };
526
+ try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
527
+ if (body.webSearch) {
528
+ config.webSearchSidecar = { ...config.webSearchSidecar };
529
+ if (typeof body.webSearch.model === "string") config.webSearchSidecar.model = body.webSearch.model;
530
+ if (typeof body.webSearch.reasoning === "string") config.webSearchSidecar.reasoning = body.webSearch.reasoning;
531
+ }
532
+ if (body.vision) {
533
+ config.visionSidecar = { ...config.visionSidecar };
534
+ if (typeof body.vision.model === "string") config.visionSidecar.model = body.vision.model;
535
+ }
536
+ saveConfig(config);
537
+ const ws = config.webSearchSidecar ?? {};
538
+ const vs = config.visionSidecar ?? {};
539
+ return jsonResponse({
540
+ ok: true,
541
+ webSearch: { model: ws.model ?? "gpt-5.4-mini", reasoning: ws.reasoning ?? "low" },
542
+ vision: { model: vs.model ?? "gpt-5.4-mini" },
543
+ });
544
+ }
545
+
383
546
  if (url.pathname === "/api/logs" && req.method === "GET") {
384
547
  return jsonResponse(requestLog);
385
548
  }
@@ -573,6 +736,9 @@ export function startServer(port?: number) {
573
736
  // Responses WebSocket (phase 120.2). Codex upgrades the same /v1/responses path; auth is
574
737
  // handshake-time only, so capture inbound headers and thread them into the pipeline.
575
738
  if (url.pathname === "/v1/responses" && req.headers.get("upgrade")?.toLowerCase() === "websocket") {
739
+ if (!isLocalOrigin(req)) {
740
+ return formatErrorResponse(403, "origin_rejected", "WebSocket upgrade blocked: non-local Origin");
741
+ }
576
742
  if (server.upgrade(req, { data: { headers: selectForwardHeaders(req.headers) } })) return undefined as unknown as Response;
577
743
  return formatErrorResponse(426, "upgrade_required", "WebSocket upgrade failed");
578
744
  }
@@ -608,6 +774,9 @@ export function startServer(port?: number) {
608
774
  }
609
775
 
610
776
  if (url.pathname === "/v1/responses" && req.method === "POST") {
777
+ if (!isLocalOrigin(req)) {
778
+ return formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked");
779
+ }
611
780
  const start = Date.now();
612
781
  const logCtx = { model: "unknown", provider: "unknown" };
613
782
  const response = await handleResponses(req, config, logCtx);
package/src/service.ts CHANGED
@@ -85,6 +85,12 @@ function systemdEnvironmentAssignment(name: string, value: string | undefined):
85
85
  return `Environment=${systemdQuote(`${name}=${value}`)}`;
86
86
  }
87
87
 
88
+ function systemdOutputTarget(value: string): string {
89
+ // StandardOutput/StandardError use output specifiers such as append:/path.
90
+ // Quoting the full specifier makes systemd reject it as an invalid output target.
91
+ return value.replace(/%/g, "%%").replace(/\n/g, "\\n");
92
+ }
93
+
88
94
  function sh(cmd: string): string {
89
95
  return execSync(cmd, { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }).trim();
90
96
  }
@@ -183,8 +189,8 @@ ExecStart=${systemdQuote(bun)} ${systemdQuote(cli)} start
183
189
  Restart=on-failure
184
190
  RestartSec=5
185
191
  ${envLines}
186
- StandardOutput=${systemdQuote(`append:${log}`)}
187
- StandardError=${systemdQuote(`append:${log}`)}
192
+ StandardOutput=${systemdOutputTarget(`append:${log}`)}
193
+ StandardError=${systemdOutputTarget(`append:${log}`)}
188
194
 
189
195
  [Install]
190
196
  WantedBy=default.target
@@ -256,6 +262,27 @@ export function stopServiceIfInstalled(): boolean {
256
262
  return false;
257
263
  }
258
264
 
265
+ /**
266
+ * Best-effort service removal for full uninstall. Unlike `ocx service uninstall`, this is quiet
267
+ * when no service exists and never exits the process just because the platform has no service
268
+ * manager.
269
+ */
270
+ export function uninstallServiceIfInstalled(): boolean {
271
+ if (process.platform === "darwin") {
272
+ if (existsSync(plistPath())) {
273
+ try { uninstallLaunchd(); return true; } catch { return false; }
274
+ }
275
+ } else if (process.platform === "win32") {
276
+ try {
277
+ const q = sh(`schtasks /query /tn ${TASK} 2>nul`);
278
+ if (q.includes(TASK)) { uninstallWindows(); return true; }
279
+ } catch { /* task not found */ }
280
+ } else if (process.platform === "linux" && isSystemd() && existsSync(unitPath())) {
281
+ try { uninstallSystemd(); return true; } catch { return false; }
282
+ }
283
+ return false;
284
+ }
285
+
259
286
  export function serviceCommand(sub?: string): void {
260
287
  const ops = platformOps();
261
288
  if (!ops) {
package/src/types.ts CHANGED
@@ -54,6 +54,8 @@ export interface OcxToolResultMessage {
54
54
  role: "toolResult";
55
55
  toolCallId: string;
56
56
  toolName: string;
57
+ /** MCP namespace from the originating tool call, if any. */
58
+ toolNamespace?: string;
57
59
  /** Text, or content parts when a tool (e.g. Codex view_image) returns an image in its output. */
58
60
  content: string | OcxContentPart[];
59
61
  isError: boolean;
@@ -177,8 +179,14 @@ export interface OcxConfig {
177
179
  disabledModels?: string[];
178
180
  /** Bind hostname. Default "127.0.0.1" (loopback only). Set "0.0.0.0" to expose on all interfaces. */
179
181
  hostname?: string;
182
+ /** Upstream stall timeout (seconds). After this many seconds of no upstream data, emits response.incomplete. Default 90. Min 1. */
183
+ stallTimeoutSec?: number;
184
+ /** Connect timeout (ms) for upstream fetch — covers DNS, TCP, TLS, and response header. Default 30000. */
185
+ connectTimeoutMs?: number;
180
186
  /** Advertise supports_websockets so Codex opens the WS endpoint. Default false; set true to opt in. */
181
187
  websockets?: boolean;
188
+ /** Auto-start/sync the proxy from the Codex shim before launching Codex. Default true. */
189
+ codexAutoStart?: boolean;
182
190
  /** Freshness window (ms) for the per-provider live `/models` cache. Defaults to 5 min. */
183
191
  modelCacheTtlMs?: number;
184
192
  /** Web-search sidecar: route web_search for non-OpenAI models through a gpt-mini via ChatGPT passthrough. */
package/src/update.ts CHANGED
@@ -32,7 +32,7 @@ function latestVersion(): string | null {
32
32
  * `ocx update` — self-update opencodex to the latest published version, using the same package
33
33
  * manager it was installed with (bun or npm global). A source checkout is told to `git pull` instead.
34
34
  */
35
- export function runUpdate(): void {
35
+ export async function runUpdate(): Promise<void> {
36
36
  const installer = detectInstall();
37
37
  const current = currentVersion();
38
38
  console.log(`opencodex v${current} (installed via ${installer})`);
@@ -56,7 +56,17 @@ export function runUpdate(): void {
56
56
 
57
57
  const r = spawnSync(bin, cmdArgs, { stdio: "inherit", timeout: 180000, windowsHide: true });
58
58
  if (r.status === 0) {
59
- console.log(`\n✅ Updated${latest ? ` to v${latest}` : ""}. Restart the proxy: ocx stop && ocx start`);
59
+ console.log(`\n✅ Updated${latest ? ` to v${latest}` : ""}.`);
60
+ if (process.platform === "win32") {
61
+ try {
62
+ const { installCodexShim } = await import("./codex-shim");
63
+ const result = installCodexShim();
64
+ if (result.installed) console.log(`🔧 ${result.message}`);
65
+ } catch (e) {
66
+ console.warn(`⚠️ Shim repair skipped: ${e instanceof Error ? e.message : e}`);
67
+ }
68
+ }
69
+ console.log("Restart the proxy: ocx stop && ocx start");
60
70
  } else {
61
71
  console.error(`\n⚠️ Update failed (${bin} exit ${r.status ?? "?"}). Try manually: ${bin} ${cmdArgs.join(" ")}`);
62
72
  process.exit(1);
@@ -193,7 +193,10 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
193
193
  const sse = bridgeToResponsesSSE(
194
194
  replay(finalEvents), parsed.modelId, toolNsMap, freeform, toolSearch,
195
195
  undefined, undefined,
196
- deps.forceEmptyResponseId ? { responseId: "" } : undefined,
196
+ {
197
+ ...(deps.forceEmptyResponseId ? { responseId: "" } : {}),
198
+ hideThinkingSummary: parsed.options.hideThinkingSummary,
199
+ },
197
200
  );
198
201
  return new Response(sse, { headers: SSE_HEADERS });
199
202
  }
package/src/ws-bridge.ts CHANGED
@@ -223,7 +223,7 @@ export function sendResponsesJsonAsEvents(
223
223
  ? response.status
224
224
  : "completed";
225
225
  sendJsonFrame(ws, {
226
- type: finalStatus === "failed" ? "response.failed" : "response.completed",
226
+ type: `response.${finalStatus}` as "response.completed" | "response.failed" | "response.incomplete",
227
227
  response: { ...response, status: finalStatus },
228
228
  });
229
229
  }