@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.
- package/README.ko.md +2 -1
- package/README.md +2 -1
- package/README.zh-CN.md +2 -1
- package/gui/dist/assets/index-CILVKWmx.css +1 -0
- package/gui/dist/assets/index-c_yXCp9z.js +40 -0
- package/gui/dist/index.html +2 -2
- package/package.json +13 -3
- package/src/adapters/cursor/native-exec-desktop.ts +10 -3
- package/src/claude/agents-inject.ts +33 -2
- package/src/claude/inbound.ts +13 -2
- package/src/cli/claude.ts +21 -2
- package/src/cli/v2.ts +21 -5
- package/src/codex/project-config-warnings.ts +18 -6
- package/src/lib/win-exec.ts +105 -0
- package/src/server/claude-messages.ts +51 -15
- package/src/server/management-api.ts +8 -5
- package/src/server/request-log.ts +1 -0
- package/src/usage/log.ts +2 -0
- package/src/usage/summary.ts +24 -6
- package/gui/dist/assets/index-D8ODGlXj.js +0 -40
- package/gui/dist/assets/index-DbIT5GLo.css +0 -1
package/gui/dist/index.html
CHANGED
|
@@ -16,8 +16,8 @@
|
|
|
16
16
|
} catch (e) {}
|
|
17
17
|
})();
|
|
18
18
|
</script>
|
|
19
|
-
<script type="module" crossorigin src="/assets/index-
|
|
20
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
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.
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
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({
|
|
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 —",
|
package/src/claude/inbound.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
31
|
-
exec(
|
|
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
|
-
|
|
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
|
|
229
|
-
|
|
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
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
|
616
|
-
execFileSync(
|
|
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,
|
package/src/usage/summary.ts
CHANGED
|
@@ -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(
|
|
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
|
|
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
|
|
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,
|
|
288
|
-
models: buildModels(
|
|
289
|
-
providers: buildProviders(
|
|
305
|
+
days: buildDayGrid(range, since, now, filteredEntries),
|
|
306
|
+
models: buildModels(filteredEntries, totals.totalTokens),
|
|
307
|
+
providers: buildProviders(filteredEntries, totals.totalTokens),
|
|
290
308
|
};
|
|
291
309
|
}
|