diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2518fb49..b7eaa4f3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,6 +59,33 @@ jobs: - run: npm run build:typescript-deps - run: npm run test --workspaces --if-present + windows-codex-runtime: + name: Managed Windows Codex runtime + runs-on: windows-2025 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version-file: .node-version + - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c + with: + toolchain: 1.97.1 + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + - name: Install the pinned managed runtime + working-directory: openaide-rs/app-server/assets/codex-acp-runtime + run: npm ci --ignore-scripts --omit=dev --no-audit --no-fund + - name: Smoke-test adapter and native Codex process + run: >- + node scripts/smoke-managed-codex-acp.mjs + openaide-rs/app-server/assets/codex-acp-runtime + - name: Build the App Server boundary + run: cargo build --locked -p openaide-app-server + - name: Smoke-test App Server managed Codex launch + run: >- + node scripts/smoke-packaged-codex-acp.mjs + target/debug/openaide-app-server.exe + ${{ github.workspace }} + smoke-tests: name: Task Chat smoke tests runs-on: ubuntu-24.04 diff --git a/openaide-rs/app-server/src/agent/codex_acp_provisioner.rs b/openaide-rs/app-server/src/agent/codex_acp_provisioner.rs index 68934bb0..c6d265e9 100644 --- a/openaide-rs/app-server/src/agent/codex_acp_provisioner.rs +++ b/openaide-rs/app-server/src/agent/codex_acp_provisioner.rs @@ -229,16 +229,18 @@ impl CodexAcpProvisioner { let entrypoint = package_root(version_root).join("dist/index.js"); let mut config = config; config.command = resolved_command_or_name("node"); - config.args = vec![entrypoint.to_string_lossy().into_owned()]; + // Rust may preserve Windows' `\\?\` verbatim prefix after the managed + // installation is published. Node interprets the slash-normalized + // `//?/C:/...` form as a drive-relative `C:` entrypoint, so hand this + // process boundary an ordinary Node-compatible Windows path. + config.args = vec![process_path_argument(&entrypoint, self.windows)]; if self.windows { // The @openai/codex Node launcher can terminate when nested under // codex-acp on Windows. Use its pinned native binary directly. config.env.retain(|(name, _)| name != "CODEX_PATH"); config.env.push(( "CODEX_PATH".to_string(), - windows_codex_binary(version_root) - .to_string_lossy() - .into_owned(), + process_path_argument(&windows_codex_binary(version_root), true), )); } Ok(PreparedCodexAcpLaunch { @@ -342,6 +344,23 @@ fn windows_codex_binary(version_root: &Path) -> PathBuf { .join("vendor/x86_64-pc-windows-msvc/bin/codex.exe") } +fn process_path_argument(path: &Path, windows: bool) -> String { + let value = path.to_string_lossy(); + if windows { + let normalized = value.replace('\\', "/"); + if let Some(path) = normalized.strip_prefix("//?/UNC/") { + format!("//{path}") + } else { + normalized + .strip_prefix("//?/") + .unwrap_or(&normalized) + .to_string() + } + } else { + value.into_owned() + } +} + fn validate_platform_runtime(version_root: &Path, windows: bool) -> Result<(), RuntimeError> { if windows && !windows_codex_binary(version_root).is_file() { return Err(provisioning_error( diff --git a/openaide-rs/app-server/src/agent/codex_acp_provisioner_tests.rs b/openaide-rs/app-server/src/agent/codex_acp_provisioner_tests.rs index 11077d11..eee78b3d 100644 --- a/openaide-rs/app-server/src/agent/codex_acp_provisioner_tests.rs +++ b/openaide-rs/app-server/src/agent/codex_acp_provisioner_tests.rs @@ -5,7 +5,7 @@ use std::time::Duration; use fs2::FileExt; use tempfile::TempDir; -use super::{CodexAcpInstaller, CodexAcpProvisioner, CODEX_ACP_VERSION}; +use super::{process_path_argument, CodexAcpInstaller, CodexAcpProvisioner, CODEX_ACP_VERSION}; use crate::agent::acp_agent_config::AcpAgentConfig; use crate::agent::status_cache::AgentStatusCache; use crate::logging::capture_test_logs; @@ -177,13 +177,31 @@ fn windows_launch_uses_the_managed_native_codex_binary() { .iter() .find(|(name, _)| name == "CODEX_PATH") .map(|(_, value)| value.as_str()), - Some(expected_codex.to_string_lossy().as_ref()), + Some(process_path_argument(&expected_codex, true).as_str()), ); assert_eq!(launch.config.args.len(), 1); assert!(launch.config.args[0].ends_with("node_modules/@openaide/codex-acp/dist/index.js")); assert!(!launch.config.command.ends_with(".cmd")); } +#[test] +fn windows_process_paths_are_unambiguous_to_node() { + let path = + std::path::Path::new(r"\\?\C:\Users\runneradmin\agent-runtimes\codex-acp\dist\index.js"); + + assert_eq!( + process_path_argument(path, true), + "C:/Users/runneradmin/agent-runtimes/codex-acp/dist/index.js" + ); + assert_eq!( + process_path_argument( + std::path::Path::new(r"\\?\UNC\server\share\codex-acp\dist\index.js"), + true, + ), + "//server/share/codex-acp/dist/index.js" + ); +} + #[test] fn passive_discovery_can_detect_an_unprovisioned_integration_without_installing_it() { let storage = TempDir::new().expect("temporary storage root"); diff --git a/scripts/smoke-managed-codex-acp.mjs b/scripts/smoke-managed-codex-acp.mjs new file mode 100644 index 00000000..955cccda --- /dev/null +++ b/scripts/smoke-managed-codex-acp.mjs @@ -0,0 +1,113 @@ +import { spawn } from "node:child_process"; +import { access } from "node:fs/promises"; +import path from "node:path"; + +const [runtimeRoot] = process.argv.slice(2); +if (!runtimeRoot) { + throw new Error("Usage: node scripts/smoke-managed-codex-acp.mjs "); +} +if (process.platform !== "win32" || process.arch !== "x64") { + throw new Error("The managed Windows Codex smoke requires Windows x64"); +} + +const adapterPath = path.resolve( + runtimeRoot, + "node_modules/@openaide/codex-acp/dist/index.js", +); +const codexPath = path.resolve( + runtimeRoot, + "node_modules/@openai/codex-win32-x64/vendor/x86_64-pc-windows-msvc/bin/codex.exe", +); +await Promise.all([access(adapterPath), access(codexPath)]); + +const child = spawn(process.execPath, [adapterPath], { + env: { + ...process.env, + CODEX_PATH: codexPath, + NO_BROWSER: "1", + }, + stdio: "pipe", + windowsHide: true, +}); + +let stderr = ""; +let stdoutBuffer = ""; +let exitDescription; +const pending = new Map(); +child.stderr.setEncoding("utf8").on("data", (chunk) => { stderr += chunk; }); +child.stdout.setEncoding("utf8").on("data", (chunk) => { + stdoutBuffer += chunk; + for (;;) { + const newline = stdoutBuffer.indexOf("\n"); + if (newline < 0) break; + const line = stdoutBuffer.slice(0, newline).trim(); + stdoutBuffer = stdoutBuffer.slice(newline + 1); + if (!line) continue; + let message; + try { message = JSON.parse(line); } catch { continue; } + const waiter = pending.get(String(message.id)); + if (!waiter) continue; + pending.delete(String(message.id)); + waiter.resolve(message); + } +}); + +const closed = new Promise((resolve) => { + child.once("close", (code, signal) => { + exitDescription = `code=${code}, signal=${signal}`; + for (const waiter of pending.values()) { + waiter.reject(new Error(`Codex ACP exited before responding (${exitDescription})`)); + } + pending.clear(); + resolve(); + }); +}); +child.once("error", (error) => { + for (const waiter of pending.values()) waiter.reject(error); + pending.clear(); +}); +child.stdin.on("error", () => {}); + +let nextRequestId = 1; +function request(method, params, timeoutMs = 20_000) { + const id = String(nextRequestId++); + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + pending.delete(id); + reject(new Error(`${method} timed out after ${timeoutMs}ms`)); + }, timeoutMs); + pending.set(id, { + resolve: (value) => { clearTimeout(timeout); resolve(value); }, + reject: (error) => { clearTimeout(timeout); reject(error); }, + }); + child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`); + }); +} + +try { + const initialized = await request("initialize", { + protocolVersion: 1, + clientCapabilities: {}, + }); + if (initialized.error || initialized.result?.protocolVersion !== 1) { + throw new Error(`Codex ACP initialization failed: ${JSON.stringify(initialized.error)}`); + } + + // Authentication may legitimately reject listing on a clean runner. The + // release invariant is that the managed native Codex process stays alive and + // returns an ACP response instead of disappearing at this boundary. + await request("session/list", { cwd: process.cwd() }); + if (exitDescription) { + throw new Error(`Codex ACP exited during session listing (${exitDescription})`); + } + console.log("Verified the managed Windows Codex ACP process through session listing."); +} catch (error) { + throw new Error(`${error.message}; Codex ACP stderr: ${stderr.slice(0, 4_000)}`); +} finally { + if (!child.stdin.destroyed) child.stdin.end(); + await Promise.race([ + closed, + new Promise((resolve) => setTimeout(resolve, 2_000)), + ]); + if (child.exitCode === null) child.kill(); +}