Command: let CreateProcessW resolve the program via PATH (#12387)

Windows users often set bare command names in the Ghostty config
(`command = bash`) or pass them via `-e`, matching how they would on
Linux/macOS. Today that fails because `CreateProcessW` does not do
program search for `lpApplicationName` on its own.

Thanks to @qwerasd205 for pointing out that passing `NULL` for
`lpApplicationName` is exactly how Windows docs say to get program
search for free. This PR does that: drop the explicit utf16 conversion
for `lpApplicationName`, pass `null`, and make sure the program name is
the first token of `lpCommandLine`. Windows then walks parent-app dir,
CWD, system dirs, and PATH (and appends `.exe` for extensionless names).
The child also sees its `argv[0]` exactly as we wrote it rather than a
resolved absolute path, which is less surprising.

Net change is +15 / -7 in `src/Command.zig`; no new helpers, no changes
outside that file. The earlier version of this PR (which added
PATH/PATHEXT handling in `internal_os.path.expand`) is obsoleted by this
approach and has been force-pushed away.

---

AI usage disclosure: developed with Claude Code (Claude Opus 4.7).
Claude drafted the implementation based on my direction after
@qwerasd205's review suggested the NULL-lpApplicationName approach. I
reviewed the diff, built and verified it on the Windows GNU-ABI target,
and am responsible for the code landing here.

Part of the Win32 apprt upstreaming series (see discussion #2563 /
mattn/ghostty#1).
This commit is contained in:
Mitchell Hashimoto
2026-04-23 08:44:23 -07:00
committed by GitHub

View File

@@ -258,12 +258,20 @@ fn startPosix(self: *Command, arena: Allocator) !void {
}
fn startWindows(self: *Command, arena: Allocator) !void {
const application_w = try std.unicode.utf8ToUtf16LeAllocZ(arena, self.path);
const cwd_w = if (self.cwd) |cwd| try std.unicode.utf8ToUtf16LeAllocZ(arena, cwd) else null;
const command_line_w = if (self.args.len > 0) b: {
const command_line = try windowsCreateCommandLine(arena, self.args);
break :b try std.unicode.utf8ToUtf16LeAllocZ(arena, command_line);
} else null;
// Pass null for lpApplicationName and put the program as the first
// token of lpCommandLine. This lets CreateProcessW perform the
// standard program search (parent-app dir, CWD, system dirs, PATH)
// and append ".exe" when the name has no extension, which is what
// users expect for bare commands like `wsl ~` or `pwsh.exe`.
// It also preserves the child's argv[0] as written by the caller
// rather than replacing it with the resolved absolute path.
const command_line = if (self.args.len > 0)
try windowsCreateCommandLine(arena, self.args)
else
try windowsCreateCommandLine(arena, &.{self.path});
const command_line_w = try std.unicode.utf8ToUtf16LeAllocZ(arena, command_line);
const env_w = if (self.env) |env_map| try createWindowsEnvBlock(arena, env_map) else null;
const any_null_fd = self.stdin == null or self.stdout == null or self.stderr == null;
@@ -345,8 +353,8 @@ fn startWindows(self: *Command, arena: Allocator) !void {
var process_information: windows.PROCESS_INFORMATION = undefined;
if (windows.exp.kernel32.CreateProcessW(
application_w.ptr,
if (command_line_w) |w| w.ptr else null,
null,
command_line_w.ptr,
null,
null,
windows.TRUE,