Skip to content
Open
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
121 changes: 107 additions & 14 deletions crates/launcher/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -305,7 +305,9 @@ fn discover_desktop(
) -> Result<DesktopInstallation, Box<dyn Error>> {
match custom_install_root {
#[cfg(target_os = "windows")]
Some(root) => Ok(codexhost_platform::discover_codex_desktop_from_root(root)?),
Some(root) => Ok(codexhost_platform::discover_codex_desktop_from_root(
&absolute_directory(root, "--custom-install")?,
)?),
#[cfg(not(target_os = "windows"))]
Some(_) => Err("--custom-install is supported on Windows only".into()),
None => Ok(discover_codex_desktop()?),
Expand Down Expand Up @@ -343,7 +345,6 @@ struct ResolvedLaunchOptions {
desktop_controller: PathBuf,
renderer_extension: PathBuf,
pi: Option<PathBuf>,
custom_install_root: Option<PathBuf>,
}

fn required_path(arguments: &[String], index: &mut usize, option: &str) -> Result<PathBuf, String> {
Expand Down Expand Up @@ -454,14 +455,39 @@ fn resolve_resource_path(
}
}

#[cfg(target_os = "macos")]
fn macos_default_node(
desktop_root: Option<&Path>,
bundled: PathBuf,
npm_node: Option<OsString>,
) -> PathBuf {
// Native tools authenticate the peer, parent and grandparent, including the Host.
let desktop_node = desktop_root
.map(|root| root.join("Contents/Resources/cua_node/bin/node"))
.filter(|node| node.is_file());
if let Some(desktop_node) = desktop_node {
desktop_node
} else if bundled.is_file() {
bundled
} else {
npm_node.map(PathBuf::from).unwrap_or(bundled)
}
}

impl LaunchOptions {
fn resolve(self) -> Result<ResolvedLaunchOptions, Box<dyn Error>> {
fn resolve(self, desktop_root: Option<&Path>) -> Result<ResolvedLaunchOptions, Box<dyn Error>> {
let installed = InstalledResources::from_current_executable()?;
let default_node = installed.node;
#[cfg(target_os = "macos")]
let default_node =
macos_default_node(desktop_root, default_node, env::var_os(NPM_NODE_PATH_ENV));
#[cfg(not(target_os = "macos"))]
let _ = desktop_root;
Ok(ResolvedLaunchOptions {
shim: resolve_resource_path(self.shim, &installed.shim, "--shim", "bundled Shim")?,
node: resolve_resource_path(
self.node,
&installed.node,
&default_node,
"--node",
"bundled Node.js runtime",
)?,
Expand All @@ -487,10 +513,6 @@ impl LaunchOptions {
.pi
.map(|path| absolute_file(&path, "--pi"))
.transpose()?,
custom_install_root: self
.custom_install_root
.map(|path| absolute_directory(&path, "--custom-install"))
.transpose()?,
})
}
}
Expand Down Expand Up @@ -1052,9 +1074,16 @@ fn launch(
) -> Result<(), Box<dyn Error>> {
startup_trace("launch requested");
start_launch_console(&options);
let options = options.resolve()?;
let installation = discover_desktop(options.custom_install_root.as_deref());
// 优先报告安装资源缺失,避免 Desktop 探测错误遮住损坏的 Host 安装。
let options = options.resolve(
installation
.as_ref()
.ok()
.map(|desktop| desktop.install_root.as_path()),
)?;
let installation = installation?;
startup_trace("resources resolved");
let installation = discover_desktop(options.custom_install_root.as_deref())?;
startup_record::desktop(
&installation.version,
&installation.build,
Expand Down Expand Up @@ -1189,9 +1218,16 @@ fn launch(
) -> Result<(), Box<dyn Error>> {
startup_trace("launch requested");
start_launch_console(&options);
let options = options.resolve()?;
let installation = discover_desktop(options.custom_install_root.as_deref());
// 优先报告安装资源缺失,避免 Desktop 探测错误遮住损坏的 Host 安装。
let options = options.resolve(
installation
.as_ref()
.ok()
.map(|desktop| desktop.install_root.as_path()),
)?;
let installation = installation?;
startup_trace("resources resolved");
let installation = discover_desktop(options.custom_install_root.as_deref())?;
startup_record::desktop(
&installation.version,
&installation.build,
Expand Down Expand Up @@ -1271,8 +1307,12 @@ fn start_launch_console(options: &LaunchOptions) {
// Recovery needs only Node and the console entrypoint, not a complete
// Desktop resource set. Resolve those paths before validating the rest.
if let Ok(installed) = InstalledResources::from_current_executable() {
#[cfg(target_os = "macos")]
let default_node = macos_default_node(None, installed.node, env::var_os(NPM_NODE_PATH_ENV));
#[cfg(not(target_os = "macos"))]
let default_node = installed.node;
console::start_for_launch(console::console_command_for(
options.node.as_deref().unwrap_or(&installed.node),
options.node.as_deref().unwrap_or(&default_node),
options
.host_runtime
.as_deref()
Expand Down Expand Up @@ -1575,6 +1615,60 @@ mod tests {
assert!(options.renderer_extension.is_none());
}

#[cfg(target_os = "macos")]
#[test]
fn macos_host_uses_the_selected_desktop_node_and_preserves_explicit_overrides() {
let executable = std::env::current_exe().unwrap().canonicalize().unwrap();
let root =
std::env::temp_dir().join(format!("codexhost-signed-node-{}", std::process::id()));
let desktop_node = root.join("Contents/Resources/cua_node/bin/node");
std::fs::create_dir_all(desktop_node.parent().unwrap()).unwrap();
std::fs::write(&desktop_node, b"Desktop Node fixture").unwrap();

for node in [None, Some(executable.clone())] {
let expected = node.clone().unwrap_or_else(|| desktop_node.clone());
let options = super::LaunchOptions {
shim: Some(executable.clone()),
node,
host_runtime: Some(executable.clone()),
desktop_controller: Some(executable.clone()),
renderer_extension: Some(executable.clone()),
pi: None,
custom_install_root: None,
};
let resolved = options.resolve(Some(&root)).unwrap();
assert_eq!(resolved.node, expected.canonicalize().unwrap());
}
std::fs::remove_dir_all(root).unwrap();
}

#[cfg(target_os = "macos")]
#[test]
fn macos_node_fallback_preserves_bundled_and_npm_runtimes() {
let executable = std::env::current_exe().unwrap().canonicalize().unwrap();
let missing = executable.with_file_name("missing-desktop-node");
assert_eq!(
super::macos_default_node(
Some(&missing),
executable.clone(),
Some(missing.clone().into())
),
executable
);
assert_eq!(
super::macos_default_node(
Some(&missing),
missing.clone(),
Some(executable.clone().into())
),
executable
);
assert_eq!(
super::macos_default_node(Some(&missing), missing.clone(), None),
missing
);
}

#[test]
fn explicit_development_paths_remain_supported() {
let options = parse_launch_options(&[
Expand Down Expand Up @@ -1659,7 +1753,6 @@ mod tests {
desktop_controller: PathBuf::from("/opt/desktop-controller.mjs"),
renderer_extension: PathBuf::from("/opt/renderer-extension.js"),
pi: None,
custom_install_root: None,
}
}

Expand Down
61 changes: 57 additions & 4 deletions crates/launcher/tests/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,14 +84,16 @@ fn production_launcher_resolves_resources_beside_its_installed_location() {
let output = output_of_copied_binary(
Command::new(&installed)
.args(["launch"])
.env("CODEXHOST_DATA_DIR", root.join("data")),
.env("CODEXHOST_DATA_DIR", root.join("data"))
.env("CODEXHOST_CONSOLE", "0")
.env("CODEXHOST_INSTALL_ROOT", root.join("missing-desktop")),
)
.expect("run installed launcher");
fs::remove_dir_all(&root).expect("remove release layout");

assert!(!output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("bundled Shim"));
assert!(stderr.contains("bundled Shim"), "{stderr}");
assert!(stderr.contains("libexec"));
assert!(stderr.contains("codexhost-shim"));
assert!(!stderr.contains("--shim is required"));
Expand All @@ -114,14 +116,65 @@ fn finder_launch_resolves_standard_app_resources_and_defaults_to_codex() {
fs::copy(launcher_path(), &installed).expect("copy app launcher");

let output = output_of_copied_binary(
Command::new(&installed).env("CODEXHOST_DATA_DIR", root.join("data")),
Command::new(&installed)
.env("CODEXHOST_DATA_DIR", root.join("data"))
.env("CODEXHOST_CONSOLE", "0")
.env("CODEXHOST_INSTALL_ROOT", root.join("missing-desktop")),
)
.expect("run Finder-style launcher");
fs::remove_dir_all(&root).expect("remove app layout");

assert!(!output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("bundled Shim"));
assert!(stderr.contains("bundled Shim"), "{stderr}");
assert!(stderr.contains("Contents/Resources/libexec/codexhost-shim"));
assert!(!stderr.contains("invalid launcher arguments"));
}

#[cfg(target_os = "macos")]
#[test]
fn npm_launch_starts_recovery_console_without_desktop_or_bundled_node() {
use std::os::unix::fs::PermissionsExt;

let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let root = std::env::temp_dir().join(format!(
"codexhost npm console {} {unique}",
std::process::id()
));
let bin = root.join("bin");
fs::create_dir_all(&bin).unwrap();
let installed = bin.join("codexhost");
fs::copy(launcher_path(), &installed).unwrap();
let host_runtime = root.join("host-runtime.mjs");
fs::write(&host_runtime, "fixture").unwrap();
fs::write(root.join("console-server.mjs"), "fixture").unwrap();
let node = root.join("npm-node");
let calls = root.join("calls");
fs::write(&node, "#!/bin/sh\nprintf '%s\n' \"$3\" >> \"$TEST_CONSOLE_CALLS\"\necho 'codexhost console: http://127.0.0.1:4399/'\n").unwrap();
fs::set_permissions(&node, fs::Permissions::from_mode(0o755)).unwrap();

let output = output_of_copied_binary(
Command::new(&installed)
.args(["launch", "--host-runtime"])
.arg(&host_runtime)
.env("CODEXHOST_DATA_DIR", root.join("data"))
.env("CODEXHOST_INSTALL_ROOT", root.join("missing-desktop"))
.env("CODEXHOST_CONSOLE", "1")
.env("CODEXHOST_NPM_NODE_PATH", &node)
.env("TEST_CONSOLE_CALLS", &calls),
)
.unwrap();
let calls = fs::read_to_string(calls).unwrap_or_default();
fs::remove_dir_all(&root).unwrap();

assert!(!output.status.success());
assert!(calls.lines().any(|action| action == "ensure"), "{calls}");
assert!(String::from_utf8_lossy(&output.stderr).contains("bundled Shim"));
assert!(
String::from_utf8_lossy(&output.stderr)
.contains("codexhost console: http://127.0.0.1:4399/")
);
}
12 changes: 12 additions & 0 deletions docs/platforms/macos/macos-native-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,18 @@ codexhost keeps the Desktop's main app-server on the Host Runtime while routing
private native-tool app-servers to the official CLI. Tool policy, authentication,
and approvals remain owned by the official CLI and Desktop.

When the selected Desktop bundles `Contents/Resources/cua_node/bin/node`, the
Launcher uses that signed Node for the Host and Desktop Controller by default.
The Desktop authenticates the native-tool peer, its parent and its grandparent;
an ad-hoc-signed Host Node breaks that chain even when the tool itself uses the
official runtime. The Launcher resolves the Node from the selected Desktop on
each start. An explicit `--node` still takes precedence. The npm wrapper leaves
the automatic choice to the Launcher instead of overriding it with its own Node;
older Desktops without a bundled runtime keep the installer Node or npm Node.
The Desktop's peer authorization remains enabled and rejects untrusted runtimes.
The recovery console starts before Desktop discovery and uses the explicit Node
override, installer Node, or npm Node so it can report an incomplete installation.

Three independent macOS helper paths need different handling:

- Browser helpers may preserve only `CODEX_CLI_PATH`. When that path resolves to
Expand Down
4 changes: 3 additions & 1 deletion scripts/release/prepare-npm.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -587,7 +587,9 @@ if (launchArguments?.[0] === "launch") {
}
}
const extras = [];
if (!injected.has("--node")) extras.push("--node", process.execPath);
if (!injected.has("--node") && process.platform !== "darwin") {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
extras.push("--node", process.execPath);
}
if (!injected.has("--shim")) extras.push("--shim", shim);
if (!injected.has("--host-runtime")) extras.push("--host-runtime", hostRuntime);
if (!injected.has("--desktop-controller")) {
Expand Down
28 changes: 28 additions & 0 deletions tests/release/npm-package.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,7 @@ async function runGeneratedWrapperLifecycle(platform, userArguments, exitCodes)
import { appendFileSync } from "node:fs";
import { EventEmitter } from "node:events";
import { syncBuiltinESMExports } from "node:module";
import { PassThrough } from "node:stream";

Object.defineProperty(process, "platform", { configurable: true, value: process.env.CODEXHOST_TEST_PLATFORM });
Object.defineProperty(process, "arch", { configurable: true, value: "x64" });
Expand All @@ -290,9 +291,11 @@ childProcess.spawn = (command, args, options) => {
appendFileSync(process.env.CODEXHOST_TEST_CALLS, JSON.stringify({
command,
args,
npmNodePath: options?.env?.CODEXHOST_NPM_NODE_PATH,
stdoutFd: Array.isArray(options?.stdio) ? options.stdio[1]?.fd : null,
}) + "\\n");
const child = new EventEmitter();
child.stdout = new PassThrough();
const exitCode = exitCodes[call++] ?? 1;
setTimeout(() => child.emit("exit", exitCode, null), 5);
return child;
Expand Down Expand Up @@ -525,6 +528,31 @@ describe("npm package release", () => {
expect(source).not.toContain("runtime/node");
});

it.each(["darwin", "linux", "win32"])(
"keeps automatic Node selection compatible on %s",
async (platform) => {
const { result, calls } = await runGeneratedWrapperLifecycle(platform, ["launch"], [0]);
expect(result.status, result.stderr).toBe(0);
expect(calls).toHaveLength(1);
expect(calls[0].npmNodePath).toBe(process.execPath);
const nodeIndex = calls[0].args.indexOf("--node");
if (platform === "darwin") expect(nodeIndex).toBe(-1);
else expect(calls[0].args[nodeIndex + 1]).toBe(process.execPath);
},
);

it("preserves an explicit macOS Node override", async () => {
const customNode = "/custom/node";
const { result, calls } = await runGeneratedWrapperLifecycle(
"darwin",
["launch", "--node", customNode],
[0],
);
expect(result.status, result.stderr).toBe(0);
expect(calls[0].args.filter((argument) => argument === "--node")).toHaveLength(1);
expect(calls[0].args[calls[0].args.indexOf("--node") + 1]).toBe(customNode);
});

const brokerTail = [
"--node",
process.execPath,
Expand Down
Loading