@bitkyc08/opencodex 2.0.2 → 2.1.3

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/open-url.ts CHANGED
@@ -1,9 +1,13 @@
1
- import { exec } from "node:child_process";
1
+ import { spawn } from "node:child_process";
2
2
 
3
3
  export function openUrl(url: string): void {
4
+ if (!/^https?:\/\//i.test(url)) return;
4
5
  const cmd =
5
6
  process.platform === "darwin" ? "open"
6
- : process.platform === "win32" ? 'start ""'
7
+ : process.platform === "win32" ? "rundll32"
7
8
  : "xdg-open";
8
- exec(`${cmd} ${JSON.stringify(url)}`);
9
+ const args = process.platform === "win32"
10
+ ? ["url.dll,FileProtocolHandler", url]
11
+ : [url];
12
+ spawn(cmd, args, { detached: true, stdio: "ignore", shell: false }).unref();
9
13
  }
package/src/ports.ts ADDED
@@ -0,0 +1,30 @@
1
+ import { createServer } from "node:net";
2
+
3
+ export async function isPortAvailable(port: number, hostname = "127.0.0.1"): Promise<boolean> {
4
+ return await new Promise(resolve => {
5
+ const server = createServer();
6
+ server.once("error", () => resolve(false));
7
+ server.once("listening", () => {
8
+ server.close(() => resolve(true));
9
+ });
10
+ server.listen({ port, host: hostname });
11
+ });
12
+ }
13
+
14
+ export async function findAvailablePort(preferredPort: number, hostname = "127.0.0.1"): Promise<number> {
15
+ if (await isPortAvailable(preferredPort, hostname)) return preferredPort;
16
+ return await new Promise((resolve, reject) => {
17
+ const server = createServer();
18
+ server.once("error", reject);
19
+ server.once("listening", () => {
20
+ const address = server.address();
21
+ const port = typeof address === "object" && address ? address.port : 0;
22
+ server.close(() => {
23
+ if (port > 0) resolve(port);
24
+ else reject(new Error("failed to allocate an available port"));
25
+ });
26
+ });
27
+ server.listen({ port: 0, host: hostname });
28
+ });
29
+ }
30
+
@@ -176,7 +176,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
176
176
  { id: "openrouter", label: "OpenRouter", adapter: "openai-chat", baseUrl: "https://openrouter.ai/api/v1", authKind: "key", featured: true, dashboardUrl: "https://openrouter.ai/keys", jawcodeBundle: "openrouter" },
177
177
  { id: "groq", label: "Groq", adapter: "openai-chat", baseUrl: "https://api.groq.com/openai/v1", authKind: "key", featured: true, dashboardUrl: "https://console.groq.com/keys" },
178
178
  { id: "google", label: "Google Gemini", adapter: "google", baseUrl: "https://generativelanguage.googleapis.com", authKind: "key", featured: true, dashboardUrl: "https://aistudio.google.com/apikey", defaultModel: "gemini-3-pro", jawcodeBundle: "google", extraMetadataAliases: ["gemini"] },
179
- { id: "azure-openai", label: "Azure OpenAI", adapter: "azure-openai", baseUrl: "https://{resource}.openai.azure.com/openai/deployments/{deployment}", authKind: "key", featured: true, dashboardUrl: "https://portal.azure.com" },
179
+ { id: "azure-openai", label: "Azure OpenAI", adapter: "azure-openai", baseUrl: "https://{resource}.openai.azure.com/openai", authKind: "key", featured: true, dashboardUrl: "https://portal.azure.com" },
180
180
  { id: "ollama", label: "Ollama (local)", adapter: "openai-chat", baseUrl: "http://localhost:11434/v1", authKind: "local", featured: true, note: "Local — key usually blank" },
181
181
  { id: "vllm", label: "vLLM (local)", adapter: "openai-chat", baseUrl: "http://localhost:8000/v1", authKind: "local", featured: true, note: "Local — key usually blank" },
182
182
  { id: "lm-studio", label: "LM Studio (local)", adapter: "openai-chat", baseUrl: "http://localhost:1234/v1", authKind: "local", featured: true, note: "Local — no key needed" },
@@ -177,15 +177,15 @@ function outputToToolResultContent(output: string | unknown[] | undefined): stri
177
177
  return parts;
178
178
  }
179
179
 
180
- function findToolNameById(messages: OcxMessage[], callId: string): string {
180
+ function findToolById(messages: OcxMessage[], callId: string): { name: string; namespace?: string } {
181
181
  for (let i = messages.length - 1; i >= 0; i--) {
182
182
  const m = messages[i];
183
183
  if (m.role !== "assistant") continue;
184
184
  for (const part of m.content) {
185
- if (part.type === "toolCall" && part.id === callId) return part.name;
185
+ if (part.type === "toolCall" && part.id === callId) return { name: part.name, namespace: part.namespace };
186
186
  }
187
187
  }
188
- return "";
188
+ return { name: "" };
189
189
  }
190
190
 
191
191
  const REASONING_EFFORTS = new Set(["none", "minimal", "low", "medium", "high", "xhigh", "max"]);
@@ -327,9 +327,10 @@ export function parseRequest(body: unknown): OcxParsedRequest {
327
327
 
328
328
  if (effectiveType === "function_call_output") {
329
329
  const output = item as { call_id: string; output?: string | unknown[] };
330
+ const toolInfo = findToolById(messages, output.call_id);
330
331
  messages.push({
331
332
  role: "toolResult", toolCallId: output.call_id,
332
- toolName: findToolNameById(messages, output.call_id),
333
+ toolName: toolInfo.name, toolNamespace: toolInfo.namespace,
333
334
  content: outputToToolResultContent(output.output), isError: false, timestamp: now,
334
335
  });
335
336
  continue;
@@ -337,9 +338,10 @@ export function parseRequest(body: unknown): OcxParsedRequest {
337
338
 
338
339
  if (effectiveType === "custom_tool_call_output") {
339
340
  const output = item as { call_id: string; output: string };
341
+ const toolInfo = findToolById(messages, output.call_id);
340
342
  messages.push({
341
343
  role: "toolResult", toolCallId: output.call_id,
342
- toolName: findToolNameById(messages, output.call_id),
344
+ toolName: toolInfo.name, toolNamespace: toolInfo.namespace,
343
345
  content: output.output ?? "", isError: false, timestamp: now,
344
346
  });
345
347
  }
@@ -373,7 +375,8 @@ export function parseRequest(body: unknown): OcxParsedRequest {
373
375
  if (data.reasoning?.effort && REASONING_EFFORTS.has(data.reasoning.effort)) {
374
376
  options.reasoning = data.reasoning.effort;
375
377
  }
376
- if (data.reasoning?.summary === "none") options.hideThinkingSummary = true;
378
+ const summaryMode = data.reasoning?.summary;
379
+ if (!summaryMode || summaryMode === "none") options.hideThinkingSummary = true;
377
380
  if (data.presence_penalty !== undefined) options.presencePenalty = data.presence_penalty;
378
381
  if (data.frequency_penalty !== undefined) options.frequencyPenalty = data.frequency_penalty;
379
382
 
@@ -50,6 +50,7 @@ const functionCallItemSchema = z.object({
50
50
  id: z.string().optional(),
51
51
  call_id: z.string().min(1),
52
52
  name: z.string().min(1),
53
+ namespace: z.string().optional(),
53
54
  arguments: z.string().optional(),
54
55
  });
55
56
  const functionCallOutputItemSchema = z.object({
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";
@@ -86,6 +86,18 @@ function serveGuiFile(pathname: string): Response | null {
86
86
  });
87
87
  }
88
88
 
89
+ const ANTHROPIC_WIRE_MODELS: Record<string, Set<string>> = {
90
+ "opencode-go": new Set(["minimax-m2.5", "minimax-m2.7", "minimax-m3", "qwen3.5-plus", "qwen3.6-plus", "qwen3.7-max", "qwen3.7-plus"]),
91
+ };
92
+
93
+ function resolveWireProtocolOverride(providerName: string, modelId: string, providerConfig: OcxProviderConfig): OcxProviderConfig {
94
+ const overrideSet = ANTHROPIC_WIRE_MODELS[providerName];
95
+ if (overrideSet?.has(modelId) && providerConfig.adapter !== "anthropic") {
96
+ return { ...providerConfig, adapter: "anthropic" };
97
+ }
98
+ return providerConfig;
99
+ }
100
+
89
101
  export function resolveAdapter(providerConfig: OcxProviderConfig) {
90
102
  switch (providerConfig.adapter) {
91
103
  case "openai-chat":
@@ -161,7 +173,8 @@ async function handleResponses(
161
173
  await describeImagesInPlace(parsed, visionPlan.forwardProvider, req.headers, visionPlan.settings, options.abortSignal);
162
174
  }
163
175
 
164
- const adapter = resolveAdapter(route.provider);
176
+ const adapterProvider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider);
177
+ const adapter = resolveAdapter(adapterProvider);
165
178
 
166
179
  if ("passthrough" in adapter && adapter.passthrough) {
167
180
  const request = adapter.buildRequest(parsed, { headers: req.headers });
@@ -170,20 +183,29 @@ async function handleResponses(
170
183
  // whose cancel() aborts the upstream — preventing leaked connections (RC2, passthrough path).
171
184
  const upstream = new AbortController();
172
185
  linkAbortSignal(upstream, options.abortSignal);
186
+ const connectMs = config.connectTimeoutMs ?? 30_000;
173
187
  let upstreamResponse: Response;
174
188
  try {
175
- upstreamResponse = await fetch(request.url, {
189
+ upstreamResponse = await fetchWithHeaderTimeout(request.url, {
176
190
  method: request.method,
177
191
  headers: request.headers,
178
192
  body: request.body,
179
- signal: upstream.signal,
180
- });
193
+ }, upstream.signal, connectMs);
181
194
  } catch (err) {
182
- 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);
183
200
  }
184
- 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, {
185
207
  status: upstreamResponse.status,
186
- headers: sanitizePassthroughHeaders(upstreamResponse.headers),
208
+ headers,
187
209
  });
188
210
  }
189
211
 
@@ -200,26 +222,27 @@ async function handleResponses(
200
222
  incomingHeaders: req.headers,
201
223
  settings: wsPlan.settings,
202
224
  maxSearches: wsPlan.maxSearches,
225
+ forceEmptyResponseId: true,
203
226
  abortSignal: options.abortSignal,
204
227
  });
205
228
  }
206
229
 
207
- const request = adapter.buildRequest(parsed, { headers: req.headers });
208
-
209
- // Abort the upstream fetch if the client (Codex) disconnects mid-stream, so a cancelled turn does
210
- // not leak the upstream connection or keep draining tokens. The bridge's cancel() fires upstream.abort() (RC2).
211
230
  const upstream = new AbortController();
212
231
  linkAbortSignal(upstream, options.abortSignal);
232
+ const connectMs = config.connectTimeoutMs ?? 30_000;
233
+
234
+ const request = adapter.buildRequest(parsed, { headers: req.headers });
213
235
  let upstreamResponse: Response;
214
236
  try {
215
- upstreamResponse = await fetch(request.url, {
216
- method: request.method,
217
- headers: request.headers,
218
- body: request.body,
219
- signal: upstream.signal,
220
- });
237
+ upstreamResponse = await fetchWithHeaderTimeout(request.url, {
238
+ method: request.method, headers: request.headers, body: request.body,
239
+ }, upstream.signal, connectMs);
221
240
  } catch (err) {
222
- 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);
223
246
  }
224
247
 
225
248
  if (!upstreamResponse.ok) {
@@ -229,8 +252,6 @@ async function handleResponses(
229
252
 
230
253
  if (parsed.stream) {
231
254
  const eventStream = adapter.parseStream(upstreamResponse);
232
- // Map flattened MCP tool names back to {namespace, name} so the bridge can restore the
233
- // namespace field Codex needs to route the call to the right MCP server.
234
255
  const toolNsMap = new Map<string, { namespace: string; name: string }>();
235
256
  const freeformToolNames = new Set<string>();
236
257
  const toolSearchToolNames = new Set<string>();
@@ -240,31 +261,36 @@ async function handleResponses(
240
261
  if (t.toolSearch) toolSearchToolNames.add(t.name);
241
262
  }
242
263
  const sseStream = bridgeToResponsesSSE(
243
- eventStream,
244
- parsed.modelId,
245
- toolNsMap,
246
- freeformToolNames,
247
- toolSearchToolNames,
248
- () => upstream.abort(),
249
- 2_000,
250
- options.forceEmptyResponseId ? { responseId: "" } : undefined,
264
+ eventStream, parsed.modelId, toolNsMap, freeformToolNames, toolSearchToolNames,
265
+ () => upstream.abort(), 2_000,
266
+ {
267
+ ...(options.forceEmptyResponseId ? { responseId: "" } : {}),
268
+ stallTimeoutSec: config.stallTimeoutSec,
269
+ hideThinkingSummary: parsed.options.hideThinkingSummary,
270
+ },
251
271
  );
252
272
  return new Response(sseStream, {
253
- headers: {
254
- "Content-Type": "text/event-stream",
255
- "Cache-Control": "no-cache",
256
- "Connection": "keep-alive",
257
- "X-Accel-Buffering": "no",
258
- },
273
+ headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no" },
259
274
  });
260
275
  }
261
276
 
262
277
  if (adapter.parseResponse) {
263
278
  const events = await adapter.parseResponse(upstreamResponse);
264
- const json = buildResponseJSON(events, parsed.modelId);
265
- return new Response(JSON.stringify(json), {
266
- headers: { "Content-Type": "application/json" },
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,
267
292
  });
293
+ return new Response(JSON.stringify(json), { headers: { "Content-Type": "application/json" } });
268
294
  }
269
295
 
270
296
  return formatErrorResponse(500, "internal_error", "Non-streaming not supported by this adapter");
@@ -279,6 +305,26 @@ export function linkAbortSignal(upstream: AbortController, signal?: AbortSignal)
279
305
  signal.addEventListener("abort", () => upstream.abort(signal.reason), { once: true });
280
306
  }
281
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
+
282
328
  const requestLog: { timestamp: number; model: string; provider: string; status: number; durationMs: number }[] = [];
283
329
  const MAX_LOG_SIZE = 200;
284
330
 
@@ -320,6 +366,56 @@ export function relayWithAbort(
320
366
  });
321
367
  }
322
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
+
323
419
  /**
324
420
  * Bun's fetch auto-decompresses the response body but leaves the upstream `content-encoding`
325
421
  * (and a now-stale `content-length`) on `response.headers`. Relaying those with the already-decoded
@@ -346,9 +442,11 @@ export function sanitizePassthroughHeaders(upstream: Headers): Headers {
346
442
  return out;
347
443
  }
348
444
 
445
+ let _corsOrigin = "http://localhost:10100";
446
+ function setCorsOrigin(port: number): void { _corsOrigin = `http://localhost:${port}`; }
349
447
  function corsHeaders(): Record<string, string> {
350
448
  return {
351
- "Access-Control-Allow-Origin": "*",
449
+ "Access-Control-Allow-Origin": _corsOrigin,
352
450
  "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
353
451
  "Access-Control-Allow-Headers": "Content-Type, Authorization",
354
452
  };
@@ -361,7 +459,18 @@ function jsonResponse(data: unknown, status = 200): Response {
361
459
  });
362
460
  }
363
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
+
364
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
+ }
365
474
  async function refreshCodexCatalogBestEffort(): Promise<void> {
366
475
  try {
367
476
  const { refreshCodexModelCatalog } = await import("./codex-refresh");
@@ -373,6 +482,7 @@ async function handleManagementAPI(req: Request, url: URL, config: OcxConfig): P
373
482
 
374
483
  if (url.pathname === "/api/config" && req.method === "GET") {
375
484
  const safeConfig = JSON.parse(JSON.stringify(config));
485
+ safeConfig.codexAutoStart = codexAutoStartEnabled(config);
376
486
  for (const prov of Object.values(safeConfig.providers as Record<string, OcxProviderConfig>)) {
377
487
  if (prov.apiKey) prov.apiKey = prov.apiKey.slice(0, 8) + "...";
378
488
  }
@@ -380,10 +490,57 @@ async function handleManagementAPI(req: Request, url: URL, config: OcxConfig): P
380
490
  }
381
491
 
382
492
  if (url.pathname === "/api/config" && req.method === "PUT") {
383
- const body = await req.json() as OcxConfig;
384
- const { saveConfig: save } = await import("./config");
385
- save(body);
386
- return jsonResponse({ success: true });
493
+ return jsonResponse({ error: "Full config PUT is disabled. Use /api/providers POST for provider changes." }, 405);
494
+ }
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
+ });
387
544
  }
388
545
 
389
546
  if (url.pathname === "/api/logs" && req.method === "GET") {
@@ -563,9 +720,11 @@ export function startServer(port?: number) {
563
720
  saveConfig(config);
564
721
  }
565
722
  const listenPort = port ?? config.port ?? 10100;
723
+ setCorsOrigin(listenPort);
566
724
 
567
725
  const server = Bun.serve<WsData>({
568
726
  port: listenPort,
727
+ hostname: config.hostname ?? "127.0.0.1",
569
728
  idleTimeout: 255,
570
729
  async fetch(req) {
571
730
  const url = new URL(req.url);
@@ -577,6 +736,9 @@ export function startServer(port?: number) {
577
736
  // Responses WebSocket (phase 120.2). Codex upgrades the same /v1/responses path; auth is
578
737
  // handshake-time only, so capture inbound headers and thread them into the pipeline.
579
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
+ }
580
742
  if (server.upgrade(req, { data: { headers: selectForwardHeaders(req.headers) } })) return undefined as unknown as Response;
581
743
  return formatErrorResponse(426, "upgrade_required", "WebSocket upgrade failed");
582
744
  }
@@ -612,6 +774,9 @@ export function startServer(port?: number) {
612
774
  }
613
775
 
614
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
+ }
615
780
  const start = Date.now();
616
781
  const logCtx = { model: "unknown", provider: "unknown" };
617
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;
@@ -175,8 +177,16 @@ export interface OcxConfig {
175
177
  subagentModels?: string[];
176
178
  /** Routed model ids ("<provider>/<model>") hidden from Codex (excluded from the catalog + /v1/models). */
177
179
  disabledModels?: string[];
180
+ /** Bind hostname. Default "127.0.0.1" (loopback only). Set "0.0.0.0" to expose on all interfaces. */
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;
178
186
  /** Advertise supports_websockets so Codex opens the WS endpoint. Default false; set true to opt in. */
179
187
  websockets?: boolean;
188
+ /** Auto-start/sync the proxy from the Codex shim before launching Codex. Default true. */
189
+ codexAutoStart?: boolean;
180
190
  /** Freshness window (ms) for the per-provider live `/models` cache. Defaults to 5 min. */
181
191
  modelCacheTtlMs?: number;
182
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);
@@ -95,6 +95,7 @@ export interface WebSearchLoopDeps {
95
95
  incomingHeaders: Headers;
96
96
  settings: SidecarSettings;
97
97
  maxSearches: number;
98
+ forceEmptyResponseId?: boolean;
98
99
  abortSignal?: AbortSignal;
99
100
  }
100
101
 
@@ -189,6 +190,13 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
189
190
  if (t.freeform) freeform.add(t.name);
190
191
  if (t.toolSearch) toolSearch.add(t.name);
191
192
  }
192
- const sse = bridgeToResponsesSSE(replay(finalEvents), parsed.modelId, toolNsMap, freeform, toolSearch);
193
+ const sse = bridgeToResponsesSSE(
194
+ replay(finalEvents), parsed.modelId, toolNsMap, freeform, toolSearch,
195
+ undefined, undefined,
196
+ {
197
+ ...(deps.forceEmptyResponseId ? { responseId: "" } : {}),
198
+ hideThinkingSummary: parsed.options.hideThinkingSummary,
199
+ },
200
+ );
193
201
  return new Response(sse, { headers: SSE_HEADERS });
194
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
  }