From fde26c345908cffe0493a0534dcf55050e635fd6 Mon Sep 17 00:00:00 2001 From: "wangpeng.1426" Date: Sat, 24 Oct 2026 13:36:38 +0800 Subject: [PATCH 1/2] fix(macos): use Desktop-signed Node for native app tools --- crates/launcher/src/main.rs | 91 ++++++++++++++++++---- docs/platforms/macos/macos-native-tools.md | 10 +++ scripts/release/prepare-npm.mjs | 4 +- tests/release/npm-package.test.mjs | 28 +++++++ 4 files changed, 119 insertions(+), 14 deletions(-) diff --git a/crates/launcher/src/main.rs b/crates/launcher/src/main.rs index 64cfa35cb..93cf66b5e 100644 --- a/crates/launcher/src/main.rs +++ b/crates/launcher/src/main.rs @@ -305,7 +305,9 @@ fn discover_desktop( ) -> Result> { 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()?), @@ -343,7 +345,6 @@ struct ResolvedLaunchOptions { desktop_controller: PathBuf, renderer_extension: PathBuf, pi: Option, - custom_install_root: Option, } fn required_path(arguments: &[String], index: &mut usize, option: &str) -> Result { @@ -454,14 +455,37 @@ fn resolve_resource_path( } } +#[cfg(target_os = "macos")] +fn macos_default_node( + desktop_root: &Path, + bundled: PathBuf, + npm_node: Option, +) -> PathBuf { + // Native tools authenticate the peer, parent and grandparent, including the Host. + let desktop_node = desktop_root.join("Contents/Resources/cua_node/bin/node"); + if desktop_node.is_file() { + desktop_node + } else if bundled.is_file() { + bundled + } else { + npm_node.map(PathBuf::from).unwrap_or(bundled) + } +} + impl LaunchOptions { - fn resolve(self) -> Result> { + fn resolve(self, desktop_root: &Path) -> Result> { 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", )?, @@ -487,10 +511,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()?, }) } } @@ -1052,9 +1072,9 @@ fn launch( ) -> Result<(), Box> { startup_trace("launch requested"); start_launch_console(&options); - let options = options.resolve()?; - startup_trace("resources resolved"); let installation = discover_desktop(options.custom_install_root.as_deref())?; + let options = options.resolve(&installation.install_root)?; + startup_trace("resources resolved"); startup_record::desktop( &installation.version, &installation.build, @@ -1189,9 +1209,9 @@ fn launch( ) -> Result<(), Box> { startup_trace("launch requested"); start_launch_console(&options); - let options = options.resolve()?; - startup_trace("resources resolved"); let installation = discover_desktop(options.custom_install_root.as_deref())?; + let options = options.resolve(&installation.install_root)?; + startup_trace("resources resolved"); startup_record::desktop( &installation.version, &installation.build, @@ -1575,6 +1595,52 @@ 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(&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(&missing, executable.clone(), Some(missing.clone().into())), + executable + ); + assert_eq!( + super::macos_default_node(&missing, missing.clone(), Some(executable.clone().into())), + executable + ); + assert_eq!( + super::macos_default_node(&missing, missing.clone(), None), + missing + ); + } + #[test] fn explicit_development_paths_remain_supported() { let options = parse_launch_options(&[ @@ -1659,7 +1725,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, } } diff --git a/docs/platforms/macos/macos-native-tools.md b/docs/platforms/macos/macos-native-tools.md index 8de348074..16924b066 100644 --- a/docs/platforms/macos/macos-native-tools.md +++ b/docs/platforms/macos/macos-native-tools.md @@ -4,6 +4,16 @@ 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. + Three independent macOS helper paths need different handling: - Browser helpers may preserve only `CODEX_CLI_PATH`. When that path resolves to diff --git a/scripts/release/prepare-npm.mjs b/scripts/release/prepare-npm.mjs index d7583c8a1..0c32ee57b 100644 --- a/scripts/release/prepare-npm.mjs +++ b/scripts/release/prepare-npm.mjs @@ -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") { + 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")) { diff --git a/tests/release/npm-package.test.mjs b/tests/release/npm-package.test.mjs index 061bd61f8..bcce9bac2 100644 --- a/tests/release/npm-package.test.mjs +++ b/tests/release/npm-package.test.mjs @@ -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" }); @@ -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; @@ -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, From 394c371362aea32d4dca7c079c050389e547fd21 Mon Sep 17 00:00:00 2001 From: "wangpeng.1426" Date: Sat, 24 Oct 2026 15:56:26 +0800 Subject: [PATCH 2/2] fix: preserve startup diagnostics and npm console recovery --- crates/launcher/src/main.rs | 54 ++++++++++++++----- crates/launcher/tests/cli.rs | 61 ++++++++++++++++++++-- docs/platforms/macos/macos-native-tools.md | 2 + 3 files changed, 100 insertions(+), 17 deletions(-) diff --git a/crates/launcher/src/main.rs b/crates/launcher/src/main.rs index 93cf66b5e..2fe3f2414 100644 --- a/crates/launcher/src/main.rs +++ b/crates/launcher/src/main.rs @@ -457,13 +457,15 @@ fn resolve_resource_path( #[cfg(target_os = "macos")] fn macos_default_node( - desktop_root: &Path, + desktop_root: Option<&Path>, bundled: PathBuf, npm_node: Option, ) -> PathBuf { // Native tools authenticate the peer, parent and grandparent, including the Host. - let desktop_node = desktop_root.join("Contents/Resources/cua_node/bin/node"); - if desktop_node.is_file() { + 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 @@ -473,7 +475,7 @@ fn macos_default_node( } impl LaunchOptions { - fn resolve(self, desktop_root: &Path) -> Result> { + fn resolve(self, desktop_root: Option<&Path>) -> Result> { let installed = InstalledResources::from_current_executable()?; let default_node = installed.node; #[cfg(target_os = "macos")] @@ -1072,8 +1074,15 @@ fn launch( ) -> Result<(), Box> { startup_trace("launch requested"); start_launch_console(&options); - let installation = discover_desktop(options.custom_install_root.as_deref())?; - let options = options.resolve(&installation.install_root)?; + 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"); startup_record::desktop( &installation.version, @@ -1209,8 +1218,15 @@ fn launch( ) -> Result<(), Box> { startup_trace("launch requested"); start_launch_console(&options); - let installation = discover_desktop(options.custom_install_root.as_deref())?; - let options = options.resolve(&installation.install_root)?; + 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"); startup_record::desktop( &installation.version, @@ -1291,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() @@ -1616,7 +1636,7 @@ mod tests { pi: None, custom_install_root: None, }; - let resolved = options.resolve(&root).unwrap(); + let resolved = options.resolve(Some(&root)).unwrap(); assert_eq!(resolved.node, expected.canonicalize().unwrap()); } std::fs::remove_dir_all(root).unwrap(); @@ -1628,15 +1648,23 @@ mod tests { let executable = std::env::current_exe().unwrap().canonicalize().unwrap(); let missing = executable.with_file_name("missing-desktop-node"); assert_eq!( - super::macos_default_node(&missing, executable.clone(), Some(missing.clone().into())), + super::macos_default_node( + Some(&missing), + executable.clone(), + Some(missing.clone().into()) + ), executable ); assert_eq!( - super::macos_default_node(&missing, missing.clone(), Some(executable.clone().into())), + super::macos_default_node( + Some(&missing), + missing.clone(), + Some(executable.clone().into()) + ), executable ); assert_eq!( - super::macos_default_node(&missing, missing.clone(), None), + super::macos_default_node(Some(&missing), missing.clone(), None), missing ); } diff --git a/crates/launcher/tests/cli.rs b/crates/launcher/tests/cli.rs index 82fa69638..902eaa581 100644 --- a/crates/launcher/tests/cli.rs +++ b/crates/launcher/tests/cli.rs @@ -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")); @@ -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/") + ); +} diff --git a/docs/platforms/macos/macos-native-tools.md b/docs/platforms/macos/macos-native-tools.md index 16924b066..6d5299756 100644 --- a/docs/platforms/macos/macos-native-tools.md +++ b/docs/platforms/macos/macos-native-tools.md @@ -13,6 +13,8 @@ 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: