@bitkyc08/opencodex 2.7.19 → 2.7.21-preview.20260716

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.
@@ -16,8 +16,8 @@
16
16
  } catch (e) {}
17
17
  })();
18
18
  </script>
19
- <script type="module" crossorigin src="/assets/index-D8ODGlXj.js"></script>
20
- <link rel="stylesheet" crossorigin href="/assets/index-DbIT5GLo.css">
19
+ <script type="module" crossorigin src="/assets/index-c_yXCp9z.js"></script>
20
+ <link rel="stylesheet" crossorigin href="/assets/index-CILVKWmx.css">
21
21
  </head>
22
22
  <body>
23
23
  <div id="root"></div>
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@bitkyc08/opencodex",
3
- "version": "2.7.19",
4
- "description": "Universal provider proxy for OpenAI Codex — use any LLM with Codex CLI/App/SDK",
3
+ "version": "2.7.21-preview.20260716",
4
+ "description": "Universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code",
5
5
  "type": "module",
6
6
  "main": "./bin/package-main.mjs",
7
7
  "exports": {
@@ -66,7 +66,17 @@
66
66
  "llm",
67
67
  "ollama",
68
68
  "anthropic",
69
- "responses-api"
69
+ "responses-api",
70
+ "claude-code",
71
+ "claude",
72
+ "codex-cli",
73
+ "gemini",
74
+ "grok",
75
+ "deepseek",
76
+ "chatgpt",
77
+ "llm-proxy",
78
+ "ai-gateway",
79
+ "openrouter"
70
80
  ],
71
81
  "repository": {
72
82
  "type": "git",
@@ -1,4 +1,5 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { shellInvocation } from "../../lib/win-exec";
2
3
  import { create } from "@bufbuild/protobuf";
3
4
  import {
4
5
  ComputerUseErrorSchema,
@@ -25,7 +26,7 @@ const DEFAULT_DESKTOP_TIMEOUT_MS = 30_000;
25
26
  * command receives the request as JSON on stdin and must print a JSON result on stdout.
26
27
  */
27
28
  export interface DesktopExecutorConfig {
28
- /** Command (run via `sh -c`) handling computer-use. Receives `{toolCallId, actions}` on stdin. */
29
+ /** Command (run via the platform shell) handling computer-use. Receives `{toolCallId, actions}` on stdin. */
29
30
  computerUseCommand?: string;
30
31
  /** Command handling record-screen. Receives `{mode, toolCallId, saveAsFilename?}` on stdin. */
31
32
  recordScreenCommand?: string;
@@ -121,14 +122,20 @@ function recordScreenFailure(error: string): RecordScreenResult {
121
122
  });
122
123
  }
123
124
 
124
- /** Spawn `command` via the shell, write `payload` as JSON to stdin, return parsed stdout JSON. */
125
+ /**
126
+ * Spawn `command` via the platform shell (sh -c on POSIX, cmd.exe /d /s /c on win32 —
127
+ * the configured command is platform-native shell syntax; devlog
128
+ * 260715_cross_platform_audit/020), write `payload` as JSON to stdin, return parsed stdout JSON.
129
+ */
125
130
  function runExternalJson(command: string, payload: unknown, config: DesktopExecutorConfig): Promise<unknown> {
126
131
  const timeoutMs = config.timeoutMs ?? DEFAULT_DESKTOP_TIMEOUT_MS;
127
132
  return new Promise((resolve, reject) => {
128
- const child = spawn("sh", ["-c", command], {
133
+ const inv = shellInvocation(command);
134
+ const child = spawn(inv.file, inv.args, {
129
135
  cwd: config.cwd,
130
136
  env: config.env ? { ...process.env, ...config.env } : process.env,
131
137
  stdio: ["pipe", "pipe", "pipe"],
138
+ ...inv.options,
132
139
  });
133
140
  let stdout = "";
134
141
  let stderr = "";
@@ -15,15 +15,17 @@ import { lstatSync, mkdirSync, readdirSync, readFileSync, renameSync, unlinkSync
15
15
  import { join } from "node:path";
16
16
  import type { OcxConfig } from "../types";
17
17
  import { claudeCodeAlias, claudeCodeNativeAlias } from "./alias";
18
- import { resolveAutoContext, withOneMillionMarker } from "./context-windows";
18
+ import { resolveAutoContext, stripOneMillionMarker, withOneMillionMarker } from "./context-windows";
19
19
  import { claudeConfigDir } from "./gateway-cache";
20
20
  import { DEFAULT_SUBAGENT_MODELS } from "../config";
21
+ import { effectiveBlockedSkillNames, resolveInboundModel } from "./inbound";
21
22
 
22
23
  export interface ClaudeAgentDef {
23
24
  file: string;
24
25
  name: string;
25
26
  model: string;
26
27
  description: string;
28
+ blockedSkills: readonly string[];
27
29
  }
28
30
 
29
31
  const OWNED_PREFIX = "ocx-";
@@ -63,6 +65,15 @@ function entryParts(entry: string): { alias: string; id: string; provider: strin
63
65
 
64
66
  export function buildClaudeAgentDefs(config: OcxConfig, windows: Record<string, number>, configDir = claudeConfigDir()): ClaudeAgentDef[] {
65
67
  const auto = resolveAutoContext(config.claudeCode);
68
+ const blockedSkills = effectiveBlockedSkillNames(config.claudeCode);
69
+ const blockedSkillsFor = (model: string): readonly string[] => {
70
+ const unmarked = stripOneMillionMarker(model);
71
+ const nativePassthrough = config.claudeCode?.nativePassthrough !== false
72
+ && !unmarked.includes("/")
73
+ && /^(claude|anthropic)(?:-|$)/i.test(unmarked)
74
+ && resolveInboundModel(unmarked, config.claudeCode) === unmarked;
75
+ return nativePassthrough ? [] : blockedSkills;
76
+ };
66
77
  const defs: ClaudeAgentDef[] = [];
67
78
  const usedNames = new Set<string>();
68
79
  const coveredModels = new Set<string>();
@@ -76,7 +87,13 @@ export function buildClaudeAgentDefs(config: OcxConfig, windows: Record<string,
76
87
  let unique = name;
77
88
  for (let i = 2; usedNames.has(unique); i++) unique = `${name}-${i}`;
78
89
  usedNames.add(unique);
79
- defs.push({ file: `${OWNED_PREFIX}${unique}.md`, name: `${OWNED_PREFIX}${unique}`, model, description });
90
+ defs.push({
91
+ file: `${OWNED_PREFIX}${unique}.md`,
92
+ name: `${OWNED_PREFIX}${unique}`,
93
+ model,
94
+ description,
95
+ blockedSkills: blockedSkillsFor(model),
96
+ });
80
97
  };
81
98
 
82
99
  // Default roster applies only when the field is UNSET — an explicit [] is
@@ -100,12 +117,25 @@ export function buildClaudeAgentDefs(config: OcxConfig, windows: Record<string,
100
117
  name: `${OWNED_PREFIX}self`,
101
118
  model: marked,
102
119
  description: `Self-clone: delegate to your default main model (${marked}), synced from the /model picker at launch. ${NO_MODEL_ARG}`,
120
+ blockedSkills: blockedSkillsFor(marked),
103
121
  });
104
122
  }
105
123
  return defs;
106
124
  }
107
125
 
126
+ function skillNameLiteral(name: string): string {
127
+ return JSON.stringify(name)
128
+ .replaceAll("`", "\\u0060")
129
+ .replaceAll("<", "\\u003c")
130
+ .replaceAll(">", "\\u003e");
131
+ }
132
+
108
133
  function renderAgentDef(def: ClaudeAgentDef): string {
134
+ const blockedSkillGuard = def.blockedSkills.length === 0 ? [] : [
135
+ "",
136
+ `Do not invoke blocked Claude Code skills: ${def.blockedSkills.map(skillNameLiteral).join(", ")}.`,
137
+ "Their document bundles are intentionally omitted for routed models; continue without loading them.",
138
+ ];
109
139
  // YAML frontmatter: model ids carry dots/brackets — always double-quote scalars.
110
140
  return [
111
141
  "---",
@@ -125,6 +155,7 @@ function renderAgentDef(def: ClaudeAgentDef): string {
125
155
  `IDENTITY: your ACTUAL underlying model is \`${def.model}\` — the opencodex proxy routes this`,
126
156
  "session there regardless of what model name the Claude Code harness displays or claims.",
127
157
  "If asked which model you are, answer with the id above; do not guess a Claude model name.",
158
+ ...blockedSkillGuard,
128
159
  "",
129
160
  "Complete the dispatched task directly and report results concisely. This file is",
130
161
  "auto-generated by opencodex (`ocx claude`) from the featured subagent roster —",
@@ -130,6 +130,15 @@ function pushUserMessage(input: Rec[], blocks: Rec[]): void {
130
130
  */
131
131
  export const DEFAULT_BLOCKED_SKILLS = ["claude-api"];
132
132
 
133
+ /** Shared effective policy for proxy elision and generated routed-agent guards. */
134
+ export function effectiveBlockedSkillNames(cc?: Pick<OcxClaudeCodeConfig, "blockedSkills">): string[] {
135
+ const names = cc?.blockedSkills ?? DEFAULT_BLOCKED_SKILLS;
136
+ return [...new Set(names
137
+ .filter((name): name is string => typeof name === "string")
138
+ .map(name => name.trim().toLowerCase())
139
+ .filter(name => name.length > 0))];
140
+ }
141
+
133
142
  /**
134
143
  * ocx-route directive (devlog 072): injected agent-definition bodies carry
135
144
  * `<!-- ocx-route: <model> -->` because Claude Code 2.1.207 ignores custom
@@ -180,7 +189,9 @@ function maybeElideSkillText(text: string, names: readonly string[]): string {
180
189
  if (!text.startsWith(SKILL_TEXT_MARKER)) return text;
181
190
  const firstLineEnd = text.indexOf("\n");
182
191
  const dir = text.slice(SKILL_TEXT_MARKER.length, firstLineEnd === -1 ? text.length : firstLineEnd).trim();
183
- const base = dir.split("/").filter(Boolean).pop()?.toLowerCase() ?? "";
192
+ // Windows clients send `C:\Users\...\claude-api`; normalize separators before
193
+ // basenaming (repo precedent: src/codex/inject.ts isOpencodexCatalogPath).
194
+ const base = dir.replace(/\\/g, "/").split("/").filter(Boolean).pop()?.toLowerCase() ?? "";
184
195
  if (!names.includes(base)) return text;
185
196
  return `[opencodex] '${base}' skill document bundle (${text.length} chars) elided for routed models `
186
197
  + "(claudeCode.blockedSkills). The skill is loaded; answer from general knowledge instead of citing the bundle.";
@@ -393,7 +404,7 @@ export function anthropicToResponsesTranslation(raw: unknown, cc?: OcxClaudeCode
393
404
  const systemParts: string[] = [];
394
405
  const topLevelSystem = systemToInstructions(raw.system);
395
406
  if (topLevelSystem !== undefined) systemParts.push(topLevelSystem);
396
- const blockedNames = (cc?.blockedSkills ?? DEFAULT_BLOCKED_SKILLS).map(n => n.toLowerCase()).filter(n => n.length > 0);
407
+ const blockedNames = effectiveBlockedSkillNames(cc);
397
408
  const elide: SkillElisionContext = {
398
409
  callIds: blockedSkillCallIds(raw.messages, blockedNames),
399
410
  names: blockedNames,
package/src/cli/claude.ts CHANGED
@@ -11,6 +11,7 @@ import { loadConfig } from "../config";
11
11
  import { injectClaudeAgentDefs } from "../claude/agents-inject";
12
12
  import { effectiveModelEnv, resolveAutoContext } from "../claude/context-windows";
13
13
  import { refreshGatewayModelCacheFromProxy } from "../claude/gateway-cache";
14
+ import { commandInvocation } from "../lib/win-exec";
14
15
  import { findLiveProxy } from "../server/proxy-liveness";
15
16
  import type { OcxConfig } from "../types";
16
17
 
@@ -141,6 +142,21 @@ async function ensureProxyForClaude(): Promise<number | null> {
141
142
  return null;
142
143
  }
143
144
 
145
+ const CLAUDE_INSTALL_HINT = "❌ `claude` CLI not found. Install it first: npm install -g @anthropic-ai/claude-code";
146
+
147
+ /**
148
+ * cmd.exe reports command-not-found as exit 9009 (the win32 launcher routes `.cmd`
149
+ * shims through cmd.exe, so ENOENT never fires there). Signal exits are not hints.
150
+ * Devlog 260715_cross_platform_audit/020.
151
+ */
152
+ export function claudeNotFoundHint(
153
+ code: number | null,
154
+ signal: NodeJS.Signals | null,
155
+ platform: NodeJS.Platform = process.platform,
156
+ ): string | null {
157
+ return platform === "win32" && code === 9009 && !signal ? CLAUDE_INSTALL_HINT : null;
158
+ }
159
+
144
160
  export async function cmdClaude(args: string[]): Promise<number> {
145
161
  const config = loadConfig();
146
162
  if (config.claudeCode?.enabled === false) {
@@ -176,16 +192,19 @@ export async function cmdClaude(args: string[]): Promise<number> {
176
192
  console.error(`⚠ Claude agent definitions could not be synced: ${message}`);
177
193
  }
178
194
  return await new Promise<number>(resolve => {
179
- const child = spawn("claude", args, { stdio: "inherit", env: env as NodeJS.ProcessEnv });
195
+ const inv = commandInvocation("claude", args);
196
+ const child = spawn(inv.file, inv.args, { stdio: "inherit", env: env as NodeJS.ProcessEnv, ...inv.options });
180
197
  child.on("error", (err: NodeJS.ErrnoException) => {
181
198
  if (err.code === "ENOENT") {
182
- console.error("❌ `claude` CLI not found. Install it first: npm install -g @anthropic-ai/claude-code");
199
+ console.error(CLAUDE_INSTALL_HINT);
183
200
  } else {
184
201
  console.error(`❌ Failed to launch claude: ${err.message}`);
185
202
  }
186
203
  resolve(1);
187
204
  });
188
205
  child.on("exit", (code, signal) => {
206
+ const hint = claudeNotFoundHint(code, signal);
207
+ if (hint) console.error(hint);
189
208
  resolve(signal ? 1 : code ?? 0);
190
209
  });
191
210
  });
package/src/cli/v2.ts CHANGED
@@ -13,22 +13,38 @@
13
13
  import { execFileSync } from "node:child_process";
14
14
  import { getLogicalMaxThreads, hasAgentsMaxThreads, isMultiAgentV2Enabled, transitionMultiAgentV2 } from "../codex/features";
15
15
 
16
+ import { commandInvocation, type SpawnInvocation } from "../lib/win-exec";
16
17
  import { loadConfig, saveConfig } from "../config";
17
18
 
18
19
  export interface V2CliDeps {
19
- execFile?: (file: string, args: string[]) => void;
20
+ execFile?: (file: string, args: string[], options?: SpawnInvocation["options"]) => void;
20
21
  isEnabled?: typeof isMultiAgentV2Enabled;
21
22
  hasMaxThreads?: typeof hasAgentsMaxThreads;
22
23
  sync?: (port?: number) => Promise<unknown>;
23
24
  log?: Pick<Console, "log" | "error">;
24
25
  }
25
26
 
27
+ /**
28
+ * Shared invocation for `codex features enable|disable multi_agent_v2` — the single
29
+ * source of truth for the CLI and the management API fallback. Windows npm installs
30
+ * expose `codex` as a `.cmd` shim, which needs the win-exec launcher
31
+ * (devlog 260715_cross_platform_audit/020).
32
+ */
33
+ export function codexFeaturesInvocation(
34
+ action: "enable" | "disable",
35
+ platform: NodeJS.Platform = process.platform,
36
+ deps: Parameters<typeof commandInvocation>[3] = {},
37
+ ): SpawnInvocation {
38
+ const command = (deps.env ?? process.env).CODEX_CLI_PATH?.trim() || "codex";
39
+ return commandInvocation(command, ["features", action, "multi_agent_v2"], platform, deps);
40
+ }
41
+
26
42
  function runCodexFeatures(action: "enable" | "disable", deps: V2CliDeps): void {
27
- const exec = deps.execFile ?? ((file: string, args: string[]) => {
28
- execFileSync(file, args, { stdio: ["ignore", "pipe", "pipe"], timeout: 15_000, windowsHide: true });
43
+ const exec = deps.execFile ?? ((file: string, args: string[], options?: SpawnInvocation["options"]) => {
44
+ execFileSync(file, args, { stdio: ["ignore", "pipe", "pipe"], timeout: 15_000, windowsHide: true, ...options });
29
45
  });
30
- const command = process.env.CODEX_CLI_PATH?.trim() || "codex";
31
- exec(command, ["features", action, "multi_agent_v2"]);
46
+ const inv = codexFeaturesInvocation(action);
47
+ exec(inv.file, inv.args, inv.options);
32
48
  }
33
49
 
34
50
  export function v2StatusLine(enabled: boolean): string {
@@ -1,5 +1,5 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
- import { dirname, join, resolve } from "node:path";
2
+ import path, { dirname, join, resolve } from "node:path";
3
3
  import { expandUserPath } from "../config";
4
4
  import { defaultCodexHome } from "./home";
5
5
  import { readRootTomlString } from "./paths";
@@ -223,12 +223,24 @@ export function dedupeRelatedProjectCodexWarnings(
223
223
  });
224
224
  }
225
225
 
226
- function relPath(abs: string): string {
226
+ /**
227
+ * Render a path under the user's home as `~/...` for warning display.
228
+ * Platform-correct containment (devlog 260715_cross_platform_audit/030): the old
229
+ * lowercase prefix match had no component boundary (`C:\Users\bob2` rendered as
230
+ * inside `~` for home `C:\Users\bob`) and case-folded on case-sensitive POSIX
231
+ * filesystems. `relative()` carries the right case semantics per platform; reject
232
+ * parent (`..`, `..\x`) and cross-drive (absolute) results.
233
+ */
234
+ export function relPath(
235
+ abs: string,
236
+ pathApi: Pick<typeof path, "relative" | "sep" | "isAbsolute"> = path,
237
+ ): string {
227
238
  const home = process.env.USERPROFILE ?? process.env.HOME ?? "";
228
- if (home && abs.toLowerCase().startsWith(home.toLowerCase())) {
229
- return `~${abs.slice(home.length).replace(/\\/g, "/")}`;
230
- }
231
- return abs;
239
+ if (!home) return abs;
240
+ const rel = pathApi.relative(home, abs);
241
+ if (rel === "") return "~";
242
+ if (rel === ".." || rel.startsWith(`..${pathApi.sep}`) || pathApi.isAbsolute(rel)) return abs;
243
+ return `~/${rel.replace(/\\/g, "/")}`;
232
244
  }
233
245
 
234
246
  export function discoverProjectCodexConfigPaths(options: {
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Cross-platform command launching (devlog 260715_cross_platform_audit/020).
3
+ *
4
+ * Windows npm installs expose CLIs as `.cmd` shims, and Node/Bun refuse shell-less
5
+ * `.cmd` spawns (CVE-2024-27980 hardening). Bare names like `spawn("claude")` also
6
+ * skip PATHEXT resolution entirely, so they ENOENT even when `claude.cmd` is on PATH.
7
+ * This module mirrors the battle-tested cross-spawn approach: resolve the real target
8
+ * via PATH×PATHEXT, launch `.exe` targets directly (argument boundaries preserved by
9
+ * the normal shell-less spawn), and route `.cmd`/`.bat` targets through
10
+ * `cmd.exe /d /s /c "<escaped line>"` with `windowsVerbatimArguments: true`.
11
+ */
12
+ import { existsSync } from "node:fs";
13
+ import { win32 } from "node:path";
14
+
15
+ const CMD_META = /([()\][%!^"`<>&|;, *?])/g;
16
+ /** cross-spawn parse.js: only npm local-bin shims get double escaping. */
17
+ const IS_CMD_SHIM = /node_modules[\\/].bin[\\/][^\\/]+\.cmd$/i;
18
+
19
+ /** cross-spawn escape.js argument(): quote + escape one argument for cmd.exe /d /s /c. */
20
+ export function escapeCmdArg(arg: string, doubleEscape = false): string {
21
+ let out = String(arg).replace(/(\\*)"/g, '$1$1\\"').replace(/(\\*)$/, "$1$1");
22
+ out = `"${out}"`.replace(CMD_META, "^$1");
23
+ return doubleEscape ? out.replace(CMD_META, "^$1") : out;
24
+ }
25
+
26
+ /** cross-spawn escape.js command(): escape the command token itself (no quoting). */
27
+ export function escapeCmdCommand(command: string): string {
28
+ return command.replace(CMD_META, "^$1");
29
+ }
30
+
31
+ export interface ResolveDeps {
32
+ env?: Record<string, string | undefined>;
33
+ exists?: (path: string) => boolean;
34
+ }
35
+
36
+ /**
37
+ * Resolve a bare command name to its first PATH×PATHEXT hit (win32 semantics).
38
+ * Commands that already carry an extension, a separator, or an absolute prefix are
39
+ * returned unchanged; unresolvable names fall back unchanged (spawn will surface it).
40
+ */
41
+ export function resolveWindowsCommand(command: string, deps: ResolveDeps = {}): string {
42
+ const env = deps.env ?? process.env;
43
+ const exists = deps.exists ?? existsSync;
44
+ if (win32.extname(command) || command.includes("\\") || command.includes("/") || win32.isAbsolute(command)) {
45
+ return command;
46
+ }
47
+ const exts = (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean);
48
+ for (const dir of (env.PATH ?? env.Path ?? "").split(win32.delimiter).filter(Boolean)) {
49
+ for (const ext of exts) {
50
+ const candidate = win32.join(dir, command + ext.toLowerCase());
51
+ if (exists(candidate)) return candidate;
52
+ }
53
+ }
54
+ return command;
55
+ }
56
+
57
+ export interface SpawnInvocation {
58
+ file: string;
59
+ args: string[];
60
+ options: { windowsVerbatimArguments?: boolean };
61
+ }
62
+
63
+ /**
64
+ * Platform-safe invocation preserving argument boundaries (cross-spawn parse.js).
65
+ * POSIX: passthrough. win32 `.exe`: resolved direct spawn. win32 `.cmd`/`.bat`:
66
+ * `ComSpec /d /s /c "<escaped command line>"` with verbatim args; npm local-bin
67
+ * shims get cross-spawn's double escaping, all other batch targets single.
68
+ */
69
+ export function commandInvocation(
70
+ command: string,
71
+ args: readonly string[],
72
+ platform: NodeJS.Platform = process.platform,
73
+ deps: ResolveDeps = {},
74
+ ): SpawnInvocation {
75
+ if (platform !== "win32") return { file: command, args: [...args], options: {} };
76
+ const resolved = resolveWindowsCommand(command, deps);
77
+ if (!/\.(cmd|bat)$/i.test(resolved)) return { file: resolved, args: [...args], options: {} };
78
+ const env = deps.env ?? process.env;
79
+ const doubleEscape = IS_CMD_SHIM.test(resolved);
80
+ const line = [escapeCmdCommand(resolved), ...args.map(a => escapeCmdArg(a, doubleEscape))].join(" ");
81
+ return {
82
+ file: env.ComSpec ?? "cmd.exe",
83
+ args: ["/d", "/s", "/c", `"${line}"`],
84
+ options: { windowsVerbatimArguments: true },
85
+ };
86
+ }
87
+
88
+ /**
89
+ * `sh -c <command>` analog per platform. The configured command string is passed
90
+ * VERBATIM in content; on win32 it gets the outer quotes `/s` requires, so
91
+ * `"C:\Program Files\x.exe" --json` runs as `cmd.exe /d /s /c ""C:\Program Files\x.exe" --json"`.
92
+ * Contract: the command is platform-native shell syntax (sh on POSIX, CMD on Windows).
93
+ */
94
+ export function shellInvocation(
95
+ command: string,
96
+ platform: NodeJS.Platform = process.platform,
97
+ env: Record<string, string | undefined> = process.env,
98
+ ): SpawnInvocation {
99
+ if (platform !== "win32") return { file: "sh", args: ["-c", command], options: {} };
100
+ return {
101
+ file: env.ComSpec ?? "cmd.exe",
102
+ args: ["/d", "/s", "/c", `"${command}"`],
103
+ options: { windowsVerbatimArguments: true },
104
+ };
105
+ }
@@ -19,6 +19,7 @@ import {
19
19
  responsesJsonToAnthropicMessage,
20
20
  responsesSseToAnthropicSse,
21
21
  } from "../claude/outbound";
22
+ import { clearableDeadline } from "../lib/abort";
22
23
  import { estimateTokens } from "../lib/token-estimate";
23
24
  import { routeModel } from "../router";
24
25
  import type { OcxConfig } from "../types";
@@ -204,24 +205,22 @@ async function anthropicNativePassthrough(
204
205
  });
205
206
  headers.set("content-type", "application/json");
206
207
 
207
- const timeoutSignal = AbortSignal.timeout(config.connectTimeoutMs ?? 120_000);
208
- const upstreamSignal = AbortSignal.any([req.signal, timeoutSignal]);
209
- let upstream: Response;
210
- try {
211
- upstream = await fetch(`${base}${pathname}${search}`, {
212
- method: "POST",
213
- headers,
214
- body: JSON.stringify(body),
215
- signal: upstreamSignal,
216
- });
217
- } catch (err) {
218
- if (timeoutSignal.aborted && upstreamSignal.reason === timeoutSignal.reason) {
219
- finalize(504, { closeReason: "non_stream" });
220
- return anthropicErrorResponse(504, "anthropic passthrough timed out waiting for response headers", "timeout_error");
221
- }
208
+ const result = await fetchWithHeaderDeadline(
209
+ `${base}${pathname}${search}`,
210
+ { method: "POST", headers, body: JSON.stringify(body) },
211
+ config.connectTimeoutMs ?? 120_000,
212
+ req.signal,
213
+ );
214
+ if (result.kind === "timeout") {
215
+ finalize(504, { closeReason: "non_stream" });
216
+ return anthropicErrorResponse(504, "anthropic passthrough timed out waiting for response headers", "timeout_error");
217
+ }
218
+ if (result.kind === "error") {
219
+ const err = result.error;
222
220
  finalize(502, { closeReason: "non_stream" });
223
221
  return anthropicErrorResponse(502, `anthropic passthrough failed: ${err instanceof Error ? err.message : String(err)}`, "api_error");
224
222
  }
223
+ const upstream = result.upstream;
225
224
 
226
225
  const contentType = upstream.headers.get("content-type") ?? "application/json";
227
226
  if (upstream.ok && contentType.includes("text/event-stream") && upstream.body) {
@@ -250,6 +249,43 @@ async function anthropicNativePassthrough(
250
249
  });
251
250
  }
252
251
 
252
+ /**
253
+ * Header-phase fetch guarded by a clearable deadline (PR #136 follow-up hardening).
254
+ *
255
+ * The deadline covers ONLY the wait for response headers; once `fetch` settles —
256
+ * fulfilled OR rejected — the timer must die. The `finally` block guarantees
257
+ * `clear()` on every path (success, upstream reject, deadline expiry), fixing the
258
+ * timer leak where a rejected fetch left the deadline running until expiry.
259
+ * `didExpire()` stays truthful after `clear()` (see src/lib/abort.ts), so timeout
260
+ * classification inside the catch is unaffected by the finally cleanup.
261
+ *
262
+ * `makeDeadline`/`fetchImpl` are injectable for deterministic unit tests.
263
+ */
264
+ export type HeaderDeadlineFetchResult =
265
+ | { kind: "response"; upstream: Response }
266
+ | { kind: "timeout" }
267
+ | { kind: "error"; error: unknown };
268
+
269
+ export async function fetchWithHeaderDeadline(
270
+ input: string | URL,
271
+ init: RequestInit,
272
+ timeoutMs: number,
273
+ parent?: AbortSignal,
274
+ makeDeadline: typeof clearableDeadline = clearableDeadline,
275
+ fetchImpl: typeof fetch = fetch,
276
+ ): Promise<HeaderDeadlineFetchResult> {
277
+ const deadline = makeDeadline(timeoutMs, parent);
278
+ try {
279
+ const upstream = await fetchImpl(input, { ...init, signal: deadline.signal });
280
+ return { kind: "response", upstream };
281
+ } catch (error) {
282
+ if (deadline.didExpire()) return { kind: "timeout" };
283
+ return { kind: "error", error };
284
+ } finally {
285
+ deadline.clear();
286
+ }
287
+ }
288
+
253
289
  export async function handleClaudeMessages(
254
290
  req: Request,
255
291
  config: OcxConfig,
@@ -27,7 +27,7 @@ import { fetchProviderQuotaReports } from "../providers/quota";
27
27
  import { DEFAULT_PROVIDER_CONTEXT_CAP, globalContextCapValue, providerContextCap, providerContextCaps, setAllProviderContextCaps, setGlobalContextCapValue, setProviderContextCap } from "../providers/context-cap";
28
28
  import { readUsageEntries } from "../usage/log";
29
29
  import { getUsageDebugLogEntries } from "../usage/debug";
30
- import { parseRange, summarizeUsage } from "../usage/summary";
30
+ import { parseRange, parseUsageSurface, summarizeUsage } from "../usage/summary";
31
31
  import { stripCodexRuntimeProviderFields } from "../codex/auth-context";
32
32
  import { getProviderRegistryEntry } from "../providers/registry";
33
33
  import { getDebugLogEntries } from "../lib/debug-log-buffer";
@@ -358,12 +358,14 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
358
358
 
359
359
  if (url.pathname === "/api/usage" && req.method === "GET") {
360
360
  const range = parseRange(url.searchParams.get("range"));
361
+ const surface = parseUsageSurface(url.searchParams.get("surface"));
361
362
  const now = Date.now();
362
363
  try {
363
- return jsonResponse(summarizeUsage(readUsageEntries(), range, now));
364
+ return jsonResponse(summarizeUsage(readUsageEntries(), range, now, surface));
364
365
  } catch {
365
366
  return jsonResponse({
366
367
  range,
368
+ surface,
367
369
  since: null,
368
370
  generatedAt: now,
369
371
  summary: {
@@ -611,10 +613,11 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
611
613
  let toggle = deps.toggleCodexMultiAgentV2;
612
614
  if (!toggle) {
613
615
  const { execFileSync } = await import("node:child_process");
616
+ const { codexFeaturesInvocation } = await import("../cli/v2");
614
617
  toggle = (enabled: boolean) => {
615
- const command = process.env.CODEX_CLI_PATH?.trim() || "codex";
616
- execFileSync(command, ["features", enabled ? "enable" : "disable", "multi_agent_v2"],
617
- { stdio: ["ignore", "pipe", "pipe"], timeout: 15_000, windowsHide: true });
618
+ const inv = codexFeaturesInvocation(enabled ? "enable" : "disable");
619
+ execFileSync(inv.file, inv.args,
620
+ { stdio: ["ignore", "pipe", "pipe"], timeout: 15_000, windowsHide: true, ...inv.options });
618
621
  };
619
622
  }
620
623
  const result = transitionMultiAgentV2(targetFlag, toggle, {
@@ -89,6 +89,7 @@ export function addRequestLog(entry: RequestLogEntry) {
89
89
  timestamp: entry.timestamp,
90
90
  provider: entry.provider,
91
91
  model: entry.model,
92
+ ...(entry.surface === "claude" ? { surface: entry.surface } : {}),
92
93
  ...(entry.resolvedModel ? { resolvedModel: entry.resolvedModel } : {}),
93
94
  status: entry.status,
94
95
  durationMs: entry.durationMs,
package/src/usage/log.ts CHANGED
@@ -11,6 +11,7 @@ export interface PersistedUsageEntry {
11
11
  timestamp: number;
12
12
  provider: string;
13
13
  model: string;
14
+ surface?: "claude";
14
15
  resolvedModel?: string;
15
16
  status: number;
16
17
  durationMs: number;
@@ -68,6 +69,7 @@ function normalizeUsageEntry(entry: PersistedUsageEntry): PersistedUsageEntry {
68
69
  timestamp: entry.timestamp,
69
70
  provider: entry.provider,
70
71
  model: entry.model,
72
+ ...(entry.surface === "claude" ? { surface: entry.surface } : {}),
71
73
  ...(entry.resolvedModel ? { resolvedModel: entry.resolvedModel } : {}),
72
74
  status: entry.status,
73
75
  durationMs: entry.durationMs,
@@ -3,6 +3,7 @@ import { usageDisplayTotalTokens } from "./totals";
3
3
  import type { PersistedUsageEntry, UsageStatus } from "./log";
4
4
 
5
5
  export type UsageRange = "7d" | "30d" | "all";
6
+ export type UsageSurface = "all" | "codex" | "claude";
6
7
 
7
8
  export interface UsageSummaryTotals {
8
9
  requests: number;
@@ -63,6 +64,7 @@ export interface UsageProvider {
63
64
 
64
65
  export interface UsageSummary {
65
66
  range: UsageRange;
67
+ surface: UsageSurface;
66
68
  since: number | null;
67
69
  generatedAt: number;
68
70
  summary: UsageSummaryTotals;
@@ -78,6 +80,11 @@ export function parseRange(input: string | null | undefined): UsageRange {
78
80
  return "30d";
79
81
  }
80
82
 
83
+ export function parseUsageSurface(input: string | null | undefined): UsageSurface {
84
+ if (input === "codex" || input === "claude") return input;
85
+ return "all";
86
+ }
87
+
81
88
  function rangeWindow(range: UsageRange, now: number): { since: number | null; days: number } {
82
89
  if (range === "7d") return { since: now - 7 * DAY_MS, days: 7 };
83
90
  if (range === "30d") return { since: now - 30 * DAY_MS, days: 30 };
@@ -270,22 +277,33 @@ function buildProviders(entries: PersistedUsageEntry[], totalTokens: number): Us
270
277
  return providers.sort((a, b) => b.requests - a.requests);
271
278
  }
272
279
 
273
- export function summarizeUsage(entries: PersistedUsageEntry[], range: UsageRange, now: number): UsageSummary {
280
+ export function summarizeUsage(
281
+ entries: PersistedUsageEntry[],
282
+ range: UsageRange,
283
+ now: number,
284
+ surface: UsageSurface = "all",
285
+ ): UsageSummary {
274
286
  const { since } = rangeWindow(range, now);
275
- const inRange = since === null ? entries : entries.filter(e => e.timestamp >= since);
287
+ const filteredEntries = entries.filter(entry => {
288
+ if (since !== null && entry.timestamp < since) return false;
289
+ if (surface === "claude") return entry.surface === "claude";
290
+ if (surface === "codex") return entry.surface !== "claude";
291
+ return true;
292
+ });
276
293
  const totals = blankTotals();
277
- for (const entry of inRange) {
294
+ for (const entry of filteredEntries) {
278
295
  bumpStatus(totals, entry.usageStatus);
279
296
  addTokens(totals, entry);
280
297
  }
281
298
  finalizeCoverage(totals);
282
299
  return {
283
300
  range,
301
+ surface,
284
302
  since,
285
303
  generatedAt: now,
286
304
  summary: totals,
287
- days: buildDayGrid(range, since, now, inRange),
288
- models: buildModels(inRange, totals.totalTokens),
289
- providers: buildProviders(inRange, totals.totalTokens),
305
+ days: buildDayGrid(range, since, now, filteredEntries),
306
+ models: buildModels(filteredEntries, totals.totalTokens),
307
+ providers: buildProviders(filteredEntries, totals.totalTokens),
290
308
  };
291
309
  }