Compare commits

...

1 Commits

Author SHA1 Message Date
pakrym-oai
58f0fa50ab Add exec wrapper 2025-09-25 17:39:58 -07:00
8 changed files with 1863 additions and 2 deletions

View File

@@ -194,12 +194,31 @@ jobs:
env:
RUST_BACKTRACE: 1
# Run the Node-based codex-cli library tests on every platform in the matrix
- name: Setup Node.js for codex-cli tests
if: ${{ matrix.profile != 'release' }}
uses: actions/setup-node@v5
with:
node-version: 22
- name: codex-cli library tests
id: node_tests
if: ${{ matrix.profile != 'release' }}
continue-on-error: true
run: |
BIN_PATH="${{ github.workspace }}/codex-rs/target/${{ matrix.target }}/${{ matrix.profile }}/codex"
npm ci
npm test
working-directory: codex-cli
# Fail the job if any of the previous steps failed.
- name: verify all steps passed
if: |
steps.clippy.outcome == 'failure' ||
steps.cargo_check_all_crates.outcome == 'failure' ||
steps.test.outcome == 'failure'
steps.test.outcome == 'failure' ||
steps.node_tests.outcome == 'failure'
run: |
echo "One or more checks failed (clippy, cargo_check_all_crates, or test). See logs for details."
exit 1

View File

@@ -1 +1,2 @@
/vendor/
.npm-cache/

File diff suppressed because it is too large Load Diff

View File

@@ -11,8 +11,29 @@
},
"files": [
"bin",
"vendor"
"vendor",
"dist"
],
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"build": "tsc -p tsconfig.json",
"clean": "rm -rf dist",
"pretest": "npm run build",
"test": "vitest run"
},
"devDependencies": {
"@types/node": "^22.7.7",
"typescript": "^5.6.3",
"vitest": "^2.1.8"
},
"repository": {
"type": "git",
"url": "git+https://github.com/openai/codex.git",

View File

@@ -76,6 +76,7 @@ def main() -> int:
staging_dir, created_temp = prepare_staging_dir(args.staging_dir)
try:
ensure_built_library()
stage_sources(staging_dir, version)
workflow_url = args.workflow_url
@@ -145,6 +146,12 @@ def stage_sources(staging_dir: Path, version: str) -> None:
if rg_manifest.exists():
shutil.copy2(rg_manifest, bin_dir / "rg")
# If a built library is present, include it in the package.
dist_src = CODEX_CLI_ROOT / "dist"
if dist_src.exists():
dist_dst = staging_dir / "dist"
shutil.copytree(dist_src, dist_dst)
readme_src = REPO_ROOT / "README.md"
if readme_src.exists():
shutil.copy2(readme_src, staging_dir / "README.md")
@@ -158,6 +165,23 @@ def stage_sources(staging_dir: Path, version: str) -> None:
out.write("\n")
def ensure_built_library() -> None:
"""Ensure the TypeScript library is built before staging.
Attempts a no-op when the dist/ directory already exists. Otherwise,
runs `npm ci` and `npm run build` in the codex-cli directory.
"""
dist_dir = CODEX_CLI_ROOT / "dist"
if dist_dir.exists():
return
try:
subprocess.check_call(["npm", "ci"], cwd=CODEX_CLI_ROOT)
except Exception:
# Fallback to `npm install` for environments without a lockfile update.
subprocess.check_call(["npm", "install"], cwd=CODEX_CLI_ROOT)
subprocess.check_call(["npm", "run", "build"], cwd=CODEX_CLI_ROOT)
def install_native_binaries(staging_dir: Path, workflow_url: str | None) -> None:
cmd = ["./scripts/install_native_deps.py"]
if workflow_url:

289
codex-cli/src/index.ts Normal file
View File

@@ -0,0 +1,289 @@
import { existsSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { spawn, type SpawnOptions, type ChildProcess } from "node:child_process";
import { Readable } from "node:stream";
import readline from "node:readline";
// __dirname equivalent in ESM context
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export type CodexSpawnOptions = Omit<SpawnOptions, "env"> & {
env?: NodeJS.ProcessEnv;
/**
* Additional directories to prepend to PATH when launching codex.
* By default, the appropriate vendor path is added automatically.
*/
extraPathDirs?: string[];
/** Override the path to the codex binary (for testing/custom installs). */
binaryPath?: string;
/** Optional override for the model provider base URL (sets OPENAI_BASE_URL). */
baseUrl?: string;
};
export type CodexResult = {
type: "code" | "signal";
exitCode?: number;
signal?: NodeJS.Signals;
};
// ------------------------
// Exec JSON event types
// ------------------------
export type CommandExecutionStatus = "in_progress" | "completed" | "failed";
export type PatchApplyStatus = "completed" | "failed";
export type PatchChangeKind = "add" | "delete" | "update";
export type AssistantMessageItem = {
item_type: "assistant_message";
id: string;
text: string;
};
export type ReasoningItem = {
item_type: "reasoning";
id: string;
text: string;
};
export type CommandExecutionItem = {
item_type: "command_execution";
id: string;
command: string;
aggregated_output: string;
exit_code?: number;
status: CommandExecutionStatus;
};
export type FileUpdateChange = {
path: string;
kind: PatchChangeKind;
};
export type FileChangeItem = {
item_type: "file_change";
id: string;
changes: FileUpdateChange[];
status: PatchApplyStatus;
};
export type McpToolCallItem = {
item_type: "mcp_tool_call";
id: string;
server: string;
tool: string;
status: CommandExecutionStatus;
};
export type WebSearchItem = {
item_type: "web_search";
id: string;
query: string;
};
export type ErrorItem = {
item_type: "error";
id: string;
message: string;
};
export type ConversationItem =
| AssistantMessageItem
| ReasoningItem
| CommandExecutionItem
| FileChangeItem
| McpToolCallItem
| WebSearchItem
| ErrorItem;
export type ConversationEvent =
| { type: "session.created"; session_id: string } & Record<string, never>
| { type: "item.started"; item: ConversationItem }
| { type: "item.completed"; item: ConversationItem }
| { type: "error"; message: string };
/** Resolve the target triple for the current platform/arch. */
function resolveTargetTriple(
platform: NodeJS.Platform = process.platform,
arch: string = process.arch,
): string | null {
switch (platform) {
case "linux":
case "android":
switch (arch) {
case "x64":
return "x86_64-unknown-linux-musl";
case "arm64":
return "aarch64-unknown-linux-musl";
default:
return null;
}
case "darwin":
switch (arch) {
case "x64":
return "x86_64-apple-darwin";
case "arm64":
return "aarch64-apple-darwin";
default:
return null;
}
case "win32":
switch (arch) {
case "x64":
return "x86_64-pc-windows-msvc";
case "arm64":
return "aarch64-pc-windows-msvc";
default:
return null;
}
default:
return null;
}
}
/**
* Get the absolute path to the packaged native codex binary for the current platform.
*/
function getCodexBinaryPath(): string {
const targetTriple = resolveTargetTriple();
if (!targetTriple) {
throw new Error(`Unsupported platform: ${process.platform} (${process.arch})`);
}
const vendorRoot = path.join(__dirname, "..", "vendor");
const archRoot = path.join(vendorRoot, targetTriple);
const codexBinaryName = process.platform === "win32" ? "codex.exe" : "codex";
const binaryPath = path.join(archRoot, "codex", codexBinaryName);
return binaryPath;
}
/** Build an updated PATH including any vendor-provided path helpers. */
function buildCodexPath(extraDirs: string[] = []): string {
const targetTriple = resolveTargetTriple();
if (!targetTriple) {
throw new Error(`Unsupported platform: ${process.platform} (${process.arch})`);
}
const vendorRoot = path.join(__dirname, "..", "vendor");
const archRoot = path.join(vendorRoot, targetTriple);
const toPrepend = [...extraDirs];
const pathDir = path.join(archRoot, "path");
if (existsSync(pathDir)) {
toPrepend.push(pathDir);
}
const sep = process.platform === "win32" ? ";" : ":";
const existing = process.env.PATH || "";
return [...toPrepend, ...existing.split(sep).filter(Boolean)].join(sep);
}
/**
* Spawn the packaged codex binary.
*
* The default behavior mirrors the CLI wrapper: `stdio: \"inherit\"`, and PATH
* is augmented with any vendor-provided shims.
*/
/**
* Execute the codex binary with provided arguments/options and await completion.
* Defaults to the packaged binary unless `binaryPath` is provided in options.
*/
function resolveCodexCommand(binaryPath?: string): string {
if (binaryPath) return binaryPath;
const candidate = getCodexBinaryPath();
if (existsSync(candidate)) return candidate;
return process.platform === "win32" ? "codex.exe" : "codex";
}
export async function execCodex(
args: string[] = [],
options: CodexSpawnOptions = {},
): Promise<CodexResult> {
const binaryPath = resolveCodexCommand(options.binaryPath);
const { extraPathDirs = [], env, stdio = "inherit", ...rest } = options;
const childEnv: NodeJS.ProcessEnv = {
...process.env,
...env,
PATH: buildCodexPath(extraPathDirs),
CODEX_MANAGED_BY_NPM: "1",
};
const child = spawn(binaryPath, args, { stdio, env: childEnv, ...rest });
return await new Promise<CodexResult>((resolve, reject) => {
child.on("error", (err) => reject(err));
child.on("exit", (code, signal) => {
if (signal) {
resolve({ type: "signal", signal });
} else {
resolve({ type: "code", exitCode: code ?? 1 });
}
});
});
}
/** Parse newline-delimited JSON ConversationEvent objects from a readable stream. */
export async function* parseExecEvents(stream: Readable): AsyncIterable<ConversationEvent> {
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
for await (const line of rl) {
const trimmed = String(line).trim();
if (!trimmed) continue;
try {
const obj = JSON.parse(trimmed) as ConversationEvent;
// Basic shape check: must have a type
if (obj && typeof obj === "object" && "type" in obj) {
yield obj;
}
} catch {
// Ignore malformed lines to keep the stream resilient.
}
}
}
export type RunExecReturn = {
child: ChildProcess;
events: AsyncIterable<ConversationEvent>;
done: Promise<CodexResult>;
};
/**
* Run `codex exec` with JSON output enabled and return a live stream of events.
* Always injects `exec` and `--json-experimental` ahead of provided args.
*/
export function runExec(execArgs: string[] = [], options: CodexSpawnOptions = {}): RunExecReturn {
const binaryPath = resolveCodexCommand(options.binaryPath);
const { extraPathDirs = [], env, baseUrl } = options;
const childEnv: NodeJS.ProcessEnv = {
...process.env,
...env,
PATH: buildCodexPath(extraPathDirs),
...(baseUrl ? { OPENAI_BASE_URL: baseUrl } : {}),
};
const args = ["exec", "--experimental-json", ...execArgs]
// Force stdout to be piped so we can parse events; let stderr inherit by default.
const child = spawn(binaryPath, args, {
stdio: ["inherit", "pipe", "inherit"],
env: childEnv
});
const done = new Promise<CodexResult>((resolve, reject) => {
child.on("error", (err) => reject(err));
child.on("exit", (code, signal) => {
if (signal) {
resolve({ type: "signal", signal });
} else {
resolve({ type: "code", exitCode: code ?? 1 });
}
});
});
const events = child.stdout ? parseExecEvents(child.stdout) : (async function* () {})();
return { child, events, done };
}

View File

@@ -0,0 +1,89 @@
import { describe, expect, test } from 'vitest';
import { execCodex, parseExecEvents, runExec } from '@openai/codex';
import { PassThrough } from 'node:stream';
import http from 'node:http';
import os from 'node:os';
describe('execCodex', () => {
test('invokes the configured binary with args and returns exit code', async () => {
const result = await execCodex(['-e', 'process.exit(0)'], {
binaryPath: process.execPath,
stdio: 'pipe',
});
expect(result.type).toBe('code');
expect(result.exitCode).toBe(0);
});
test('parses newline-delimited JSON conversation events', async () => {
const stream = new PassThrough();
const events = [
{ type: 'session.created', session_id: 'abc' },
{
type: 'item.completed',
item: {
id: 'item_1',
item_type: 'assistant_message',
text: 'hello',
},
},
];
const iter = parseExecEvents(stream);
// Write two JSONL lines, plus some whitespace noise
for (const ev of events) {
stream.write(JSON.stringify(ev) + '\n');
}
stream.write('\n \n');
stream.end();
const out: unknown[] = [];
for await (const ev of iter) out.push(ev);
expect(out).toEqual(events);
});
test('runExec streams events and respects baseUrl via OPENAI_BASE_URL', async () => {
// Start a mock responses server
let received = 0;
const server = http.createServer((req, res) => {
if (req.method === 'POST' && req.url?.endsWith('/v1/responses')) {
received++;
res.statusCode = 200;
res.setHeader('content-type', 'text/event-stream');
res.end('event: completed\n' + 'data: {"type":"response.completed"}\n\n');
return;
}
res.statusCode = 404;
res.end();
});
await new Promise<void>((resolve) => server.listen(0, resolve));
const addr = server.address();
if (!addr || typeof addr !== 'object') throw new Error('server address');
const baseUrl = `http://127.0.0.1:${addr.port}/v1`;
// If CODEX_BIN is provided, run the real codex binary with --experimental-json.
const codexBin = process.env.CODEX_BIN;
if (!codexBin) {
// Skip if the binary isn't available (e.g., local dev without Rust build).
return;
}
const { events, done } = runExec([
'--skip-git-repo-check',
'-s',
'danger-full-access',
'hello world',
], { binaryPath: codexBin, baseUrl, env: { OPENAI_API_KEY: 'dummy', CODEX_HOME: os.tmpdir() } });
const collected: any[] = [];
for await (const ev of events) collected.push(ev);
const result = await done;
expect(result.type).toBe('code');
expect(result.exitCode).toBe(0);
expect(received).toBe(1);
// Just assert we received at least one event and the server saw a request.
expect(collected.length).toBeGreaterThan(0);
expect(received).toBe(1);
await new Promise<void>((resolve, reject) => server.close((err) => (err ? reject(err) : resolve())));
});
});

20
codex-cli/tsconfig.json Normal file
View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "Bundler",
"outDir": "dist",
"rootDir": "src",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["dist", "node_modules"]
}