Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 23 additions & 4 deletions openaide-rs/app-server/src/agent/codex_acp_provisioner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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(
Expand Down
22 changes: 20 additions & 2 deletions openaide-rs/app-server/src/agent/codex_acp_provisioner_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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");
Expand Down
113 changes: 113 additions & 0 deletions scripts/smoke-managed-codex-acp.mjs
Original file line number Diff line number Diff line change
@@ -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 <runtime-root>");
}
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();
}