fix(core): generate bare short-path commands for Windows hook installs

This commit is contained in:
shihuaidexianyu
2026-08-02 09:14:13 +08:00
committed by l0ng-ai
parent ee386d3de3
commit a0f5cf1ade
2 changed files with 56 additions and 0 deletions
+1
View File
@@ -141,6 +141,7 @@ getrandom = "0.3"
# tree on hangup (ConPTY's `kill` only reaches the shell itself).
windows-sys = { version = "0.59", features = [
"Win32_Foundation",
"Win32_Storage_FileSystem",
"Win32_System_Console",
"Win32_System_Diagnostics_ToolHelp",
"Win32_System_Threading",
+55
View File
@@ -337,6 +337,9 @@ impl<'a> HookTarget<'a> {
}
fn hook_command(&self, agent: HookAgent, event: &str) -> String {
if let Some(exe) = self.hook_command_exe() {
return format!("{exe} agent-hook {} {event}", agent.slug());
}
format!(
"\"{}\" agent-hook {} {event}",
self.exe.display(),
@@ -344,6 +347,28 @@ impl<'a> HookTarget<'a> {
)
}
/// A shell-safe executable path for generated hook commands.
///
/// On Windows, Codex runs hook commands through the session's shell, which
/// is frequently PowerShell. `pwsh -Command` drops the quotes around a
/// path containing spaces, so the usual `"C:\Program Files\..."` form is
/// parsed as `C:\Program` and fails — and even a quoted path without
/// spaces is a syntax error in PowerShell (invoking a quoted path requires
/// the `&` call operator). Resolving the space-free 8.3 short path lets us
/// emit the path bare, which both `cmd.exe` and PowerShell execute.
///
/// Returns `None` when no space-free short path is available; callers then
/// fall back to the quoted long path.
fn hook_command_exe(&self) -> Option<String> {
#[cfg(windows)]
if self.is_local() {
if let Some(short) = short_path(&self.exe) {
return Some(short);
}
}
None
}
fn read(&self, p: &Path) -> io::Result<String> {
let bytes = self.host.read_file(p, MAX_CONFIG_BYTES)?;
String::from_utf8(bytes).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
@@ -655,6 +680,32 @@ fn marker_command<'a>(matcher: &'a serde_json::Value, marker: &str) -> Option<&'
})
}
/// Resolves `path` to its Windows 8.3 short form when one exists and is free
/// of spaces, so generated hook commands survive PowerShell's `-Command`
/// quoting. Returns `None` when short names are unavailable or the short path
/// still contains spaces; callers then fall back to the long path.
#[cfg(windows)]
fn short_path(path: &Path) -> Option<String> {
use std::os::windows::ffi::OsStrExt;
use windows_sys::Win32::Storage::FileSystem::GetShortPathNameW;
let wide: Vec<u16> = path.as_os_str().encode_wide().chain(Some(0)).collect();
unsafe {
let len = GetShortPathNameW(wide.as_ptr(), std::ptr::null_mut(), 0);
if len == 0 {
return None;
}
let mut buf = vec![0u16; len as usize];
let written = GetShortPathNameW(wide.as_ptr(), buf.as_mut_ptr(), len);
if written == 0 {
return None;
}
let short = String::from_utf16(&buf[..written as usize]).ok()?;
let short = short.trim_end_matches('\0');
(!short.contains(' ')).then(|| short.to_string())
}
}
fn enable_codex_hooks_feature() -> Result<(), String> {
let candidates = [
PathBuf::from("/opt/homebrew/bin/codex"),
@@ -1100,6 +1151,10 @@ mod tests {
let host = local_host();
let target = HookTarget::local(&*host).expect("home resolves in tests");
let cmd = target.hook_command(HookAgent::Claude, "stop");
// On Windows the executable may be rewritten to a space-free 8.3 short
// path, which must be emitted bare: PowerShell cannot invoke a quoted
// path without the `&` call operator.
#[cfg(not(windows))]
assert!(cmd.starts_with('"'));
assert!(cmd.ends_with("agent-hook claude stop"));
}