From 06e57421e2f9116addc87e057fa7d6927a6e01ff Mon Sep 17 00:00:00 2001 From: Loongphy Date: Fri, 5 Jun 2026 20:14:05 +0800 Subject: [PATCH 01/20] fix(login): resolve Windows codex launchers --- fallback.md | 6 ++ src/cli/login.zig | 177 ++++++++++++++++++++++++++++++++- tests/cli_behavior_test.zig | 67 +++++++++++++ tests/cli_integration_test.zig | 130 ++++++++++++++++++++++++ 4 files changed, 379 insertions(+), 1 deletion(-) create mode 100644 fallback.md diff --git a/fallback.md b/fallback.md new file mode 100644 index 0000000..865c231 --- /dev/null +++ b/fallback.md @@ -0,0 +1,6 @@ +# Fallbacks + +- Windows `codex-auth login` accepts `codex.ps1` after `codex.exe` and `codex.cmd`. + Reason: real Windows Codex installs can expose npm wrapper layouts with `codex`, `codex.cmd`, and `codex.ps1`, while the extensionless `codex` file is a POSIX shell script that cannot be launched directly by a Windows process. + Protected callers or data: Windows users whose PATH exposes only the PowerShell Codex wrapper after npm-style installation or wrapper cleanup. + Removal conditions: remove this fallback once the supported Codex Windows launcher contract guarantees a single directly spawnable entry point, or once this repo intentionally narrows support to `codex.exe` and `codex.cmd` only. diff --git a/src/cli/login.zig b/src/cli/login.zig index e45d97b..c3f8cb4 100644 --- a/src/cli/login.zig +++ b/src/cli/login.zig @@ -1,9 +1,43 @@ +const builtin = @import("builtin"); const std = @import("std"); +const http_env = @import("../api/http_env.zig"); +const http_executable = @import("../api/http_executable.zig"); const app_runtime = @import("../core/runtime.zig"); const io_util = @import("../core/io_util.zig"); const types = @import("types.zig"); const output = @import("output.zig"); +pub const WindowsCodexPathKind = enum { + exe, + cmd, + ps1, +}; + +pub const WindowsCodexPath = struct { + path: []u8, + kind: WindowsCodexPathKind, + + pub fn deinit(self: *WindowsCodexPath, allocator: std.mem.Allocator) void { + allocator.free(self.path); + } +}; + +const CodexLaunch = struct { + owned_paths: [2]?[]u8 = .{ null, null }, + argv_storage: [9][]const u8 = undefined, + argv_len: usize = 0, + + fn argv(self: *const CodexLaunch) []const []const u8 { + return self.argv_storage[0..self.argv_len]; + } + + fn deinit(self: *CodexLaunch, allocator: std.mem.Allocator) void { + for (self.owned_paths) |maybe_path| { + if (maybe_path) |path| allocator.free(path); + } + } +}; + pub fn codexLoginArgs(opts: types.LoginOptions) []const []const u8 { return if (opts.device_auth) &[_][]const u8{ "codex", "login", "--device-auth" } @@ -11,6 +45,144 @@ pub fn codexLoginArgs(opts: types.LoginOptions) []const []const u8 { &[_][]const u8{ "codex", "login" }; } +pub fn resolveWindowsCodexPathEntryAlloc( + allocator: std.mem.Allocator, + entry: []const u8, +) !?WindowsCodexPath { + const candidates = [_]struct { + name: []const u8, + kind: WindowsCodexPathKind, + }{ + .{ .name = "codex.exe", .kind = .exe }, + .{ .name = "codex.cmd", .kind = .cmd }, + .{ .name = "codex.ps1", .kind = .ps1 }, + }; + + for (candidates) |candidate| { + if (try resolvePathEntryCandidateAlloc(allocator, entry, candidate.name)) |path| { + return .{ .path = path, .kind = candidate.kind }; + } + } + + return null; +} + +pub fn resolveWindowsCodexPathEntriesAlloc( + allocator: std.mem.Allocator, + entries: []const []const u8, +) !?WindowsCodexPath { + for (entries) |entry| { + if (entry.len == 0) continue; + if (try resolveWindowsCodexPathEntryAlloc(allocator, entry)) |resolved| return resolved; + } + return null; +} + +fn resolvePathEntryCandidateAlloc( + allocator: std.mem.Allocator, + entry: []const u8, + candidate_name: []const u8, +) !?[]u8 { + const candidate = try std.fs.path.join(allocator, &[_][]const u8{ entry, candidate_name }); + errdefer allocator.free(candidate); + + if (!accessPath(candidate)) { + allocator.free(candidate); + return null; + } + + return candidate; +} + +fn accessPath(path: []const u8) bool { + if (std.fs.path.isAbsolute(path)) { + std.Io.Dir.accessAbsolute(app_runtime.io(), path, .{}) catch return false; + return true; + } + + std.Io.Dir.cwd().access(app_runtime.io(), path, .{}) catch return false; + return true; +} + +fn resolveWindowsCodexPathAlloc(allocator: std.mem.Allocator) !?WindowsCodexPath { + const path_value = http_env.getEnvVarOwned(allocator, "PATH") catch |err| switch (err) { + error.EnvironmentVariableNotFound => return null, + else => return err, + }; + defer allocator.free(path_value); + + var path_it = std.mem.splitScalar(u8, path_value, std.fs.path.delimiter); + while (path_it.next()) |entry| { + if (entry.len == 0) continue; + if (try resolveWindowsCodexPathEntryAlloc(allocator, entry)) |resolved| return resolved; + } + + return null; +} + +fn resolveOptionalExecutableAlloc( + allocator: std.mem.Allocator, + executable: []const u8, +) !?[]u8 { + return http_executable.ensureExecutableAvailableAlloc(allocator, executable) catch |err| switch (err) { + error.ExecutableRequired => null, + else => return err, + }; +} + +fn resolveWindowsPowerShellExecutableAlloc(allocator: std.mem.Allocator) ![]u8 { + if (try resolveOptionalExecutableAlloc(allocator, "powershell.exe")) |path| return path; + if (try resolveOptionalExecutableAlloc(allocator, "pwsh.exe")) |path| return path; + return error.FileNotFound; +} + +fn buildCodexLaunchAlloc(allocator: std.mem.Allocator, opts: types.LoginOptions) !CodexLaunch { + if (builtin.os.tag != .windows) { + var launch = CodexLaunch{}; + const args = codexLoginArgs(opts); + @memcpy(launch.argv_storage[0..args.len], args); + launch.argv_len = args.len; + return launch; + } + + var resolved = (try resolveWindowsCodexPathAlloc(allocator)) orelse return error.FileNotFound; + errdefer resolved.deinit(allocator); + + switch (resolved.kind) { + .exe, .cmd => { + var launch = CodexLaunch{ .owned_paths = .{ resolved.path, null } }; + launch.argv_storage[0] = resolved.path; + launch.argv_storage[1] = "login"; + launch.argv_len = 2; + if (opts.device_auth) { + launch.argv_storage[2] = "--device-auth"; + launch.argv_len = 3; + } + return launch; + }, + .ps1 => { + const powershell = try resolveWindowsPowerShellExecutableAlloc(allocator); + errdefer allocator.free(powershell); + + var launch = CodexLaunch{ .owned_paths = .{ powershell, resolved.path } }; + launch.argv_storage[0] = powershell; + launch.argv_storage[1] = "-NoLogo"; + launch.argv_storage[2] = "-NoProfile"; + launch.argv_storage[3] = "-ExecutionPolicy"; + launch.argv_storage[4] = "Bypass"; + launch.argv_storage[5] = "-File"; + launch.argv_storage[6] = resolved.path; + launch.argv_storage[7] = "login"; + launch.argv_len = 8; + if (opts.device_auth) { + launch.argv_storage[8] = "--device-auth"; + launch.argv_len = 9; + } + return launch; + }, + } +} + fn ensureCodexLoginSucceeded(term: std.process.Child.Term) !void { switch (term) { .exited => |code| { @@ -34,8 +206,11 @@ pub fn runCodexLogin(opts: types.LoginOptions, codex_home: []const u8) !void { defer env_map.deinit(); try env_map.put("CODEX_HOME", codex_home); + var launch = try buildCodexLaunchAlloc(std.heap.page_allocator, opts); + defer launch.deinit(std.heap.page_allocator); + var child = std.process.spawn(app_runtime.io(), .{ - .argv = codexLoginArgs(opts), + .argv = launch.argv(), .environ_map = &env_map, .stdin = .inherit, .stdout = .inherit, diff --git a/tests/cli_behavior_test.zig b/tests/cli_behavior_test.zig index f5ce30d..222678b 100644 --- a/tests/cli_behavior_test.zig +++ b/tests/cli_behavior_test.zig @@ -1,5 +1,6 @@ const std = @import("std"); const cli = @import("codex_auth").cli; +const fs = @import("codex_auth").core.compat_fs; const registry = @import("codex_auth").registry; const ansi = struct { @@ -824,6 +825,72 @@ test "Scenario: Given login options when building codex argv then device auth is try expectArgv(cli.login.codexLoginArgs(.{ .device_auth = true }), &[_][]const u8{ "codex", "login", "--device-auth" }); } +test "Scenario: Given winget and npm Windows launchers when resolving then PATH entry order is preserved" { + const gpa = std.testing.allocator; + var tmp = fs.tmpDir(.{}); + defer tmp.cleanup(); + + try tmp.dir.makePath("winget-bin"); + try tmp.dir.makePath("npm-bin"); + try tmp.dir.writeFile(.{ .sub_path = "winget-bin/codex.exe", .data = "" }); + try tmp.dir.writeFile(.{ .sub_path = "npm-bin/codex", .data = "#!/bin/sh\nexit 1\n" }); + try tmp.dir.writeFile(.{ .sub_path = "npm-bin/codex.cmd", .data = "@echo off\r\nexit /b 0\r\n" }); + try tmp.dir.writeFile(.{ .sub_path = "npm-bin/codex.ps1", .data = "exit 0\n" }); + + const root_dir = try tmp.dir.realpathAlloc(gpa, "."); + defer gpa.free(root_dir); + const winget_dir = try std.fs.path.join(gpa, &[_][]const u8{ root_dir, "winget-bin" }); + defer gpa.free(winget_dir); + const npm_dir = try std.fs.path.join(gpa, &[_][]const u8{ root_dir, "npm-bin" }); + defer gpa.free(npm_dir); + + var exe_first = (try cli.login.resolveWindowsCodexPathEntriesAlloc(gpa, &[_][]const u8{ winget_dir, npm_dir })) orelse return error.TestUnexpectedResult; + defer exe_first.deinit(gpa); + try std.testing.expectEqual(cli.login.WindowsCodexPathKind.exe, exe_first.kind); + try std.testing.expect(std.mem.endsWith(u8, exe_first.path, "codex.exe")); + + var cmd_first = (try cli.login.resolveWindowsCodexPathEntriesAlloc(gpa, &[_][]const u8{ npm_dir, winget_dir })) orelse return error.TestUnexpectedResult; + defer cmd_first.deinit(gpa); + try std.testing.expectEqual(cli.login.WindowsCodexPathKind.cmd, cmd_first.kind); + try std.testing.expect(std.mem.endsWith(u8, cmd_first.path, "codex.cmd")); +} + +test "Scenario: Given only PowerShell Windows launcher when resolving then ps1 is used after cmd is absent" { + const gpa = std.testing.allocator; + var tmp = fs.tmpDir(.{}); + defer tmp.cleanup(); + + try tmp.dir.makePath("npm-bin"); + try tmp.dir.writeFile(.{ .sub_path = "npm-bin/codex", .data = "#!/bin/sh\nexit 1\n" }); + try tmp.dir.writeFile(.{ .sub_path = "npm-bin/codex.ps1", .data = "exit 0\n" }); + + const root_dir = try tmp.dir.realpathAlloc(gpa, "."); + defer gpa.free(root_dir); + const npm_dir = try std.fs.path.join(gpa, &[_][]const u8{ root_dir, "npm-bin" }); + defer gpa.free(npm_dir); + + var resolved = (try cli.login.resolveWindowsCodexPathEntriesAlloc(gpa, &[_][]const u8{npm_dir})) orelse return error.TestUnexpectedResult; + defer resolved.deinit(gpa); + try std.testing.expectEqual(cli.login.WindowsCodexPathKind.ps1, resolved.kind); + try std.testing.expect(std.mem.endsWith(u8, resolved.path, "codex.ps1")); +} + +test "Scenario: Given only the bare npm shell launcher on Windows when resolving then it is ignored" { + const gpa = std.testing.allocator; + var tmp = fs.tmpDir(.{}); + defer tmp.cleanup(); + + try tmp.dir.makePath("npm-bin"); + try tmp.dir.writeFile(.{ .sub_path = "npm-bin/codex", .data = "#!/bin/sh\nexit 1\n" }); + + const root_dir = try tmp.dir.realpathAlloc(gpa, "."); + defer gpa.free(root_dir); + const npm_dir = try std.fs.path.join(gpa, &[_][]const u8{ root_dir, "npm-bin" }); + defer gpa.free(npm_dir); + + try std.testing.expect((try cli.login.resolveWindowsCodexPathEntriesAlloc(gpa, &[_][]const u8{npm_dir})) == null); +} + test "Scenario: Given switch with positional query when parsing then non-interactive target is preserved" { const gpa = std.testing.allocator; const args = [_][:0]const u8{ "codex-auth", "switch", "user@example.com" }; diff --git a/tests/cli_integration_test.zig b/tests/cli_integration_test.zig index 4dbc500..68f8679 100644 --- a/tests/cli_integration_test.zig +++ b/tests/cli_integration_test.zig @@ -138,6 +138,14 @@ fn fakeCodexCommandPath() []const u8 { return if (builtin.os.tag == .windows) "fake-bin/codex.cmd" else "fake-bin/codex"; } +fn fakeCodexPowerShellPath() []const u8 { + return "fake-bin/codex.ps1"; +} + +fn fakeBareWindowsCodexPath() []const u8 { + return "fake-bin/codex"; +} + fn writeFailingFakeCodex(dir: fs.Dir, exit_code: u8) !void { var script_buf: [128]u8 = undefined; const script = if (builtin.os.tag == .windows) @@ -158,6 +166,7 @@ fn writeSuccessfulFakeCodex(dir: fs.Dir) !void { const script = if (builtin.os.tag == .windows) "@echo off\r\n" ++ + ">\"%HOME%\\fake-codex-launcher.txt\" echo cmd\r\n" ++ ">\"%HOME%\\fake-codex-argv.txt\" echo %*\r\n" ++ ">\"%HOME%\\fake-codex-home.txt\" echo %CODEX_HOME%\r\n" ++ "set \"CODEX_HOME_DIR=%CODEX_HOME%\"\r\n" ++ @@ -167,6 +176,7 @@ fn writeSuccessfulFakeCodex(dir: fs.Dir) !void { "exit /b 0\r\n" else "#!/bin/sh\n" ++ + "printf '%s\\n' 'posix' > \"$HOME/fake-codex-launcher.txt\"\n" ++ "printf '%s\\n' \"$*\" > \"$HOME/fake-codex-argv.txt\"\n" ++ "printf '%s\\n' \"$CODEX_HOME\" > \"$HOME/fake-codex-home.txt\"\n" ++ "CODEX_HOME_DIR=\"${CODEX_HOME:-$HOME/.codex}\"\n" ++ @@ -187,6 +197,7 @@ fn writeStrictExistingCodexHomeFakeCodex(dir: fs.Dir) !void { const script = if (builtin.os.tag == .windows) "@echo off\r\n" ++ + ">\"%HOME%\\fake-codex-launcher.txt\" echo cmd\r\n" ++ ">\"%HOME%\\fake-codex-argv.txt\" echo %*\r\n" ++ ">\"%HOME%\\fake-codex-home.txt\" echo %CODEX_HOME%\r\n" ++ "set \"CODEX_HOME_DIR=%CODEX_HOME%\"\r\n" ++ @@ -196,6 +207,7 @@ fn writeStrictExistingCodexHomeFakeCodex(dir: fs.Dir) !void { "exit /b 0\r\n" else "#!/bin/sh\n" ++ + "printf '%s\\n' 'posix' > \"$HOME/fake-codex-launcher.txt\"\n" ++ "printf '%s\\n' \"$*\" > \"$HOME/fake-codex-argv.txt\"\n" ++ "printf '%s\\n' \"$CODEX_HOME\" > \"$HOME/fake-codex-home.txt\"\n" ++ "CODEX_HOME_DIR=\"${CODEX_HOME:-$HOME/.codex}\"\n" ++ @@ -212,6 +224,30 @@ fn writeStrictExistingCodexHomeFakeCodex(dir: fs.Dir) !void { } } +fn writeBrokenBareWindowsCodex(dir: fs.Dir) !void { + if (builtin.os.tag != .windows) return; + try dir.writeFile(.{ + .sub_path = fakeBareWindowsCodexPath(), + .data = "#!/bin/sh\nexit 99\n", + }); +} + +fn writeSuccessfulFakeCodexPowerShell(dir: fs.Dir) !void { + if (builtin.os.tag != .windows) return; + + const script = + "$homePath = $env:HOME\r\n" ++ + "[System.IO.File]::WriteAllText((Join-Path $homePath 'fake-codex-launcher.txt'), \"ps1`n\")\r\n" ++ + "[System.IO.File]::WriteAllText((Join-Path $homePath 'fake-codex-argv.txt'), (($args -join ' ') + \"`n\"))\r\n" ++ + "[System.IO.File]::WriteAllText((Join-Path $homePath 'fake-codex-home.txt'), ($env:CODEX_HOME + \"`n\"))\r\n" ++ + "$codexHomeDir = $env:CODEX_HOME\r\n" ++ + "if ([string]::IsNullOrEmpty($codexHomeDir)) { $codexHomeDir = Join-Path $homePath '.codex' }\r\n" ++ + "if (-not (Test-Path -LiteralPath $codexHomeDir)) { New-Item -ItemType Directory -Path $codexHomeDir | Out-Null }\r\n" ++ + "Copy-Item -Force (Join-Path $homePath 'fake-auth.json') (Join-Path $codexHomeDir 'auth.json')\r\n"; + + try dir.writeFile(.{ .sub_path = fakeCodexPowerShellPath(), .data = script }); +} + fn fakeCurlCommandPath() []const u8 { return if (builtin.os.tag == .windows) "fake-curl-bin/curl.exe" else "fake-curl-bin/curl"; } @@ -874,6 +910,100 @@ test "Scenario: Given strict codex login when running login then scratch CODEX_H try std.testing.expect(std.mem.eql(u8, loaded.accounts.items[0].email, expected_email)); } +test "Scenario: Given npm-style Windows codex wrappers when running login then the bare script is ignored and codex.cmd is launched" { + if (builtin.os.tag != .windows) return error.SkipZigTest; + + const gpa = std.testing.allocator; + const project_root = try projectRootAlloc(gpa); + defer gpa.free(project_root); + try buildCliBinary(gpa, project_root); + + var tmp = fs.tmpDir(.{}); + defer tmp.cleanup(); + + const home_root = try tmp.dir.realpathAlloc(gpa, "."); + defer gpa.free(home_root); + try tmp.dir.makePath(".codex"); + try tmp.dir.makePath("fake-bin"); + + const expected_email = "windows-cmd@example.com"; + const fake_auth = try fixtures.authJsonWithEmailPlan(gpa, expected_email, "plus"); + defer gpa.free(fake_auth); + try tmp.dir.writeFile(.{ .sub_path = "fake-auth.json", .data = fake_auth }); + try writeBrokenBareWindowsCodex(tmp.dir); + try writeStrictExistingCodexHomeFakeCodex(tmp.dir); + + const fake_bin_path = try fs.path.join(gpa, &[_][]const u8{ home_root, "fake-bin" }); + defer gpa.free(fake_bin_path); + const path_override = try prependPathEntryAlloc(gpa, fake_bin_path); + defer gpa.free(path_override); + + const result = try runCliWithIsolatedHomeAndPath( + gpa, + project_root, + home_root, + path_override, + &[_][]const u8{ "login", "--device-auth" }, + ); + defer gpa.free(result.stdout); + defer gpa.free(result.stderr); + + try expectSuccess(result); + + const launcher_path = try fs.path.join(gpa, &[_][]const u8{ home_root, "fake-codex-launcher.txt" }); + defer gpa.free(launcher_path); + const launcher_data = try fixtures.readFileAlloc(gpa, launcher_path); + defer gpa.free(launcher_data); + try std.testing.expectEqualStrings("cmd", std.mem.trim(u8, launcher_data, " \r\n")); +} + +test "Scenario: Given only a PowerShell Windows codex launcher when running login then codex.ps1 is launched via PowerShell" { + if (builtin.os.tag != .windows) return error.SkipZigTest; + + const gpa = std.testing.allocator; + const project_root = try projectRootAlloc(gpa); + defer gpa.free(project_root); + try buildCliBinary(gpa, project_root); + + var tmp = fs.tmpDir(.{}); + defer tmp.cleanup(); + + const home_root = try tmp.dir.realpathAlloc(gpa, "."); + defer gpa.free(home_root); + try tmp.dir.makePath(".codex"); + try tmp.dir.makePath("fake-bin"); + + const expected_email = "windows-ps1@example.com"; + const fake_auth = try fixtures.authJsonWithEmailPlan(gpa, expected_email, "plus"); + defer gpa.free(fake_auth); + try tmp.dir.writeFile(.{ .sub_path = "fake-auth.json", .data = fake_auth }); + try writeBrokenBareWindowsCodex(tmp.dir); + try writeSuccessfulFakeCodexPowerShell(tmp.dir); + + const fake_bin_path = try fs.path.join(gpa, &[_][]const u8{ home_root, "fake-bin" }); + defer gpa.free(fake_bin_path); + const path_override = try prependPathEntryAlloc(gpa, fake_bin_path); + defer gpa.free(path_override); + + const result = try runCliWithIsolatedHomeAndPath( + gpa, + project_root, + home_root, + path_override, + &[_][]const u8{ "login", "--device-auth" }, + ); + defer gpa.free(result.stdout); + defer gpa.free(result.stderr); + + try expectSuccess(result); + + const launcher_path = try fs.path.join(gpa, &[_][]const u8{ home_root, "fake-codex-launcher.txt" }); + defer gpa.free(launcher_path); + const launcher_data = try fixtures.readFileAlloc(gpa, launcher_path); + defer gpa.free(launcher_data); + try std.testing.expectEqualStrings("ps1", std.mem.trim(u8, launcher_data, " \r\n")); +} + test "Scenario: Given refreshed active auth before login when running login then old account snapshot is synced first" { const gpa = std.testing.allocator; const project_root = try projectRootAlloc(gpa); From c18c9c355f0fc789c9116978afd2ffcd5b80847c Mon Sep 17 00:00:00 2001 From: Loongphy Date: Fri, 5 Jun 2026 20:21:07 +0800 Subject: [PATCH 02/20] fix(login): preserve missing launcher hint --- src/cli/login.zig | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/cli/login.zig b/src/cli/login.zig index c3f8cb4..615199d 100644 --- a/src/cli/login.zig +++ b/src/cli/login.zig @@ -206,7 +206,10 @@ pub fn runCodexLogin(opts: types.LoginOptions, codex_home: []const u8) !void { defer env_map.deinit(); try env_map.put("CODEX_HOME", codex_home); - var launch = try buildCodexLaunchAlloc(std.heap.page_allocator, opts); + var launch = buildCodexLaunchAlloc(std.heap.page_allocator, opts) catch |err| { + writeCodexLoginLaunchFailureHint(@errorName(err)) catch {}; + return err; + }; defer launch.deinit(std.heap.page_allocator); var child = std.process.spawn(app_runtime.io(), .{ From a82e3f70df76c61f6f428bd762e206367d8f0386 Mon Sep 17 00:00:00 2001 From: Loongphy Date: Fri, 5 Jun 2026 20:35:07 +0800 Subject: [PATCH 03/20] fix(login): preserve Windows launcher resolution order --- src/cli/login.zig | 240 ++++++++++++++++++++++++++++++------ src/cli/output.zig | 4 + tests/cli_behavior_test.zig | 39 ++++++ 3 files changed, 243 insertions(+), 40 deletions(-) diff --git a/src/cli/login.zig b/src/cli/login.zig index 615199d..d10e5b5 100644 --- a/src/cli/login.zig +++ b/src/cli/login.zig @@ -23,7 +23,7 @@ pub const WindowsCodexPath = struct { }; const CodexLaunch = struct { - owned_paths: [2]?[]u8 = .{ null, null }, + owned_paths: [1]?[]u8 = .{null}, argv_storage: [9][]const u8 = undefined, argv_len: usize = 0, @@ -38,6 +38,8 @@ const CodexLaunch = struct { } }; +const WindowsCodexPathList = std.ArrayList(WindowsCodexPath); + pub fn codexLoginArgs(opts: types.LoginOptions) []const []const u8 { return if (opts.device_auth) &[_][]const u8{ "codex", "login", "--device-auth" } @@ -49,35 +51,35 @@ pub fn resolveWindowsCodexPathEntryAlloc( allocator: std.mem.Allocator, entry: []const u8, ) !?WindowsCodexPath { - const candidates = [_]struct { - name: []const u8, - kind: WindowsCodexPathKind, - }{ - .{ .name = "codex.exe", .kind = .exe }, - .{ .name = "codex.cmd", .kind = .cmd }, - .{ .name = "codex.ps1", .kind = .ps1 }, - }; - - for (candidates) |candidate| { - if (try resolvePathEntryCandidateAlloc(allocator, entry, candidate.name)) |path| { - return .{ .path = path, .kind = candidate.kind }; - } - } + const path_ext = try resolveWindowsCodexPathExtAlloc(allocator); + defer allocator.free(path_ext); - return null; + return resolveWindowsCodexPathEntryWithPathExtAlloc(allocator, entry, path_ext); } -pub fn resolveWindowsCodexPathEntriesAlloc( +pub fn resolveWindowsCodexPathEntriesWithPathExtAlloc( allocator: std.mem.Allocator, entries: []const []const u8, + path_ext: []const u8, ) !?WindowsCodexPath { for (entries) |entry| { if (entry.len == 0) continue; - if (try resolveWindowsCodexPathEntryAlloc(allocator, entry)) |resolved| return resolved; + if (try resolveWindowsCodexPathEntryWithPathExtAlloc(allocator, entry, path_ext)) |resolved| return resolved; } + return null; } +pub fn resolveWindowsCodexPathEntriesAlloc( + allocator: std.mem.Allocator, + entries: []const []const u8, +) !?WindowsCodexPath { + const path_ext = try resolveWindowsCodexPathExtAlloc(allocator); + defer allocator.free(path_ext); + + return resolveWindowsCodexPathEntriesWithPathExtAlloc(allocator, entries, path_ext); +} + fn resolvePathEntryCandidateAlloc( allocator: std.mem.Allocator, entry: []const u8, @@ -94,6 +96,89 @@ fn resolvePathEntryCandidateAlloc( return candidate; } +fn windowsCodexCandidateName(kind: WindowsCodexPathKind) []const u8 { + return switch (kind) { + .exe => "codex.exe", + .cmd => "codex.cmd", + .ps1 => "codex.ps1", + }; +} + +fn windowsCodexPathExtKind(ext: []const u8) ?WindowsCodexPathKind { + if (std.ascii.eqlIgnoreCase(ext, ".exe")) return .exe; + if (std.ascii.eqlIgnoreCase(ext, ".cmd")) return .cmd; + return null; +} + +fn resolveWindowsCodexPathExtAlloc(allocator: std.mem.Allocator) ![]u8 { + return http_env.getEnvVarOwned(allocator, "PATHEXT") catch |err| switch (err) { + error.EnvironmentVariableNotFound => try allocator.dupe(u8, ".EXE;.CMD"), + else => return err, + }; +} + +fn appendWindowsCodexPathCandidateIfAvailable( + allocator: std.mem.Allocator, + candidates: *WindowsCodexPathList, + entry: []const u8, + kind: WindowsCodexPathKind, +) !void { + if (try resolvePathEntryCandidateAlloc(allocator, entry, windowsCodexCandidateName(kind))) |path| { + try candidates.append(allocator, .{ .path = path, .kind = kind }); + } +} + +fn appendWindowsCodexPathEntryCandidatesAlloc( + allocator: std.mem.Allocator, + candidates: *WindowsCodexPathList, + entry: []const u8, + path_ext: []const u8, +) !void { + var seen_exe = false; + var seen_cmd = false; + + var ext_it = std.mem.splitScalar(u8, path_ext, ';'); + while (ext_it.next()) |raw_ext| { + const ext = std.mem.trim(u8, raw_ext, " \t"); + const kind = windowsCodexPathExtKind(ext) orelse continue; + switch (kind) { + .exe => { + if (seen_exe) continue; + seen_exe = true; + }, + .cmd => { + if (seen_cmd) continue; + seen_cmd = true; + }, + .ps1 => unreachable, + } + try appendWindowsCodexPathCandidateIfAvailable(allocator, candidates, entry, kind); + } + + try appendWindowsCodexPathCandidateIfAvailable(allocator, candidates, entry, .ps1); +} + +fn deinitWindowsCodexPathList(allocator: std.mem.Allocator, candidates: *WindowsCodexPathList) void { + for (candidates.items) |*candidate| candidate.deinit(allocator); + candidates.deinit(allocator); +} + +fn resolveWindowsCodexPathEntryWithPathExtAlloc( + allocator: std.mem.Allocator, + entry: []const u8, + path_ext: []const u8, +) !?WindowsCodexPath { + var candidates: WindowsCodexPathList = .empty; + errdefer deinitWindowsCodexPathList(allocator, &candidates); + + try appendWindowsCodexPathEntryCandidatesAlloc(allocator, &candidates, entry, path_ext); + if (candidates.items.len == 0) return null; + + const resolved = candidates.orderedRemove(0); + deinitWindowsCodexPathList(allocator, &candidates); + return resolved; +} + fn accessPath(path: []const u8) bool { if (std.fs.path.isAbsolute(path)) { std.Io.Dir.accessAbsolute(app_runtime.io(), path, .{}) catch return false; @@ -104,20 +189,36 @@ fn accessPath(path: []const u8) bool { return true; } -fn resolveWindowsCodexPathAlloc(allocator: std.mem.Allocator) !?WindowsCodexPath { - const path_value = http_env.getEnvVarOwned(allocator, "PATH") catch |err| switch (err) { - error.EnvironmentVariableNotFound => return null, - else => return err, - }; - defer allocator.free(path_value); - +fn resolveWindowsCodexPathValueAlloc( + allocator: std.mem.Allocator, + path_value: []const u8, + path_ext: []const u8, + candidates: *WindowsCodexPathList, +) !void { var path_it = std.mem.splitScalar(u8, path_value, std.fs.path.delimiter); while (path_it.next()) |entry| { if (entry.len == 0) continue; - if (try resolveWindowsCodexPathEntryAlloc(allocator, entry)) |resolved| return resolved; + try appendWindowsCodexPathEntryCandidatesAlloc(allocator, candidates, entry, path_ext); } +} - return null; +fn collectWindowsCodexPathsAlloc(allocator: std.mem.Allocator) !WindowsCodexPathList { + const path_ext = try resolveWindowsCodexPathExtAlloc(allocator); + defer allocator.free(path_ext); + + var candidates: WindowsCodexPathList = .empty; + errdefer deinitWindowsCodexPathList(allocator, &candidates); + + try appendWindowsCodexPathEntryCandidatesAlloc(allocator, &candidates, ".", path_ext); + + const path_value = http_env.getEnvVarOwned(allocator, "PATH") catch |err| switch (err) { + error.EnvironmentVariableNotFound => return candidates, + else => return err, + }; + defer allocator.free(path_value); + + try resolveWindowsCodexPathValueAlloc(allocator, path_value, path_ext, &candidates); + return candidates; } fn resolveOptionalExecutableAlloc( @@ -133,24 +234,26 @@ fn resolveOptionalExecutableAlloc( fn resolveWindowsPowerShellExecutableAlloc(allocator: std.mem.Allocator) ![]u8 { if (try resolveOptionalExecutableAlloc(allocator, "powershell.exe")) |path| return path; if (try resolveOptionalExecutableAlloc(allocator, "pwsh.exe")) |path| return path; - return error.FileNotFound; + return error.PowerShellNotFound; } fn buildCodexLaunchAlloc(allocator: std.mem.Allocator, opts: types.LoginOptions) !CodexLaunch { - if (builtin.os.tag != .windows) { - var launch = CodexLaunch{}; - const args = codexLoginArgs(opts); - @memcpy(launch.argv_storage[0..args.len], args); - launch.argv_len = args.len; - return launch; - } - - var resolved = (try resolveWindowsCodexPathAlloc(allocator)) orelse return error.FileNotFound; - errdefer resolved.deinit(allocator); + _ = allocator; + var launch = CodexLaunch{}; + const args = codexLoginArgs(opts); + @memcpy(launch.argv_storage[0..args.len], args); + launch.argv_len = args.len; + return launch; +} +fn buildWindowsCodexLaunchAlloc( + allocator: std.mem.Allocator, + resolved: *const WindowsCodexPath, + opts: types.LoginOptions, +) !CodexLaunch { switch (resolved.kind) { .exe, .cmd => { - var launch = CodexLaunch{ .owned_paths = .{ resolved.path, null } }; + var launch = CodexLaunch{}; launch.argv_storage[0] = resolved.path; launch.argv_storage[1] = "login"; launch.argv_len = 2; @@ -164,7 +267,7 @@ fn buildCodexLaunchAlloc(allocator: std.mem.Allocator, opts: types.LoginOptions) const powershell = try resolveWindowsPowerShellExecutableAlloc(allocator); errdefer allocator.free(powershell); - var launch = CodexLaunch{ .owned_paths = .{ powershell, resolved.path } }; + var launch = CodexLaunch{ .owned_paths = .{powershell} }; launch.argv_storage[0] = powershell; launch.argv_storage[1] = "-NoLogo"; launch.argv_storage[2] = "-NoProfile"; @@ -201,11 +304,68 @@ fn writeCodexLoginLaunchFailureHint(err_name: []const u8) !void { try out.flush(); } +fn shouldRetryWindowsCodexLaunch(err: std.process.SpawnError, kind: WindowsCodexPathKind) bool { + return switch (err) { + error.FileNotFound, error.AccessDenied => true, + error.InvalidExe => kind != .exe, + else => false, + }; +} + pub fn runCodexLogin(opts: types.LoginOptions, codex_home: []const u8) !void { var env_map = try app_runtime.currentEnviron().createMap(std.heap.page_allocator); defer env_map.deinit(); try env_map.put("CODEX_HOME", codex_home); + if (builtin.os.tag == .windows) { + var candidates = try collectWindowsCodexPathsAlloc(std.heap.page_allocator); + defer deinitWindowsCodexPathList(std.heap.page_allocator, &candidates); + + if (candidates.items.len == 0) { + writeCodexLoginLaunchFailureHint("FileNotFound") catch {}; + return error.FileNotFound; + } + + var last_retryable_error: ?std.process.SpawnError = null; + for (candidates.items) |*candidate| { + var launch = buildWindowsCodexLaunchAlloc(std.heap.page_allocator, candidate, opts) catch |err| { + writeCodexLoginLaunchFailureHint(@errorName(err)) catch {}; + return err; + }; + + var child = std.process.spawn(app_runtime.io(), .{ + .argv = launch.argv(), + .environ_map = &env_map, + .stdin = .inherit, + .stdout = .inherit, + .stderr = .inherit, + }) catch |err| { + launch.deinit(std.heap.page_allocator); + if (shouldRetryWindowsCodexLaunch(err, candidate.kind)) { + last_retryable_error = err; + continue; + } + writeCodexLoginLaunchFailureHint(@errorName(err)) catch {}; + return err; + }; + launch.deinit(std.heap.page_allocator); + + const term = child.wait(app_runtime.io()) catch |err| { + writeCodexLoginLaunchFailureHint(@errorName(err)) catch {}; + return err; + }; + return ensureCodexLoginSucceeded(term); + } + + if (last_retryable_error) |err| { + writeCodexLoginLaunchFailureHint(@errorName(err)) catch {}; + return err; + } + + writeCodexLoginLaunchFailureHint("FileNotFound") catch {}; + return error.FileNotFound; + } + var launch = buildCodexLaunchAlloc(std.heap.page_allocator, opts) catch |err| { writeCodexLoginLaunchFailureHint(@errorName(err)) catch {}; return err; diff --git a/src/cli/output.zig b/src/cli/output.zig index 97e71a9..0158d80 100644 --- a/src/cli/output.zig +++ b/src/cli/output.zig @@ -457,6 +457,10 @@ pub fn writeCodexLoginLaunchFailureHintTo(out: *std.Io.Writer, err_name: []const try writeHintPrefixTo(out, use_color); try out.writeAll(" Ensure the Codex CLI is installed and available in your environment.\n"); try out.writeAll(" Then run `codex login` manually and retry your command.\n"); + } else if (std.mem.eql(u8, err_name, "PowerShellNotFound")) { + try out.writeAll(" the `codex.ps1` launcher requires PowerShell, but neither `powershell.exe` nor `pwsh.exe` was found in your PATH.\n\n"); + try writeHintPrefixTo(out, use_color); + try out.writeAll(" Install PowerShell, or use a Codex CLI installation that provides `codex.exe` or `codex.cmd`, then retry your command.\n"); } else { try out.writeAll(" failed to launch the `codex login` process.\n\n"); try writeHintPrefixTo(out, use_color); diff --git a/tests/cli_behavior_test.zig b/tests/cli_behavior_test.zig index 222678b..e4f564b 100644 --- a/tests/cli_behavior_test.zig +++ b/tests/cli_behavior_test.zig @@ -808,6 +808,19 @@ test "Scenario: Given codex login client missing when rendering then detection h try std.testing.expect(std.mem.indexOf(u8, hint, "Ensure the Codex CLI is installed and available in your environment.") != null); } +test "Scenario: Given PowerShell is missing for the codex ps1 launcher when rendering then the hint names PowerShell" { + const gpa = std.testing.allocator; + var aw: std.Io.Writer.Allocating = .init(gpa); + defer aw.deinit(); + + try cli.output.writeCodexLoginLaunchFailureHintTo(&aw.writer, "PowerShellNotFound", false); + + const hint = aw.written(); + try std.testing.expect(std.mem.indexOf(u8, hint, "the `codex.ps1` launcher requires PowerShell") != null); + try std.testing.expect(std.mem.indexOf(u8, hint, "Install PowerShell, or use a Codex CLI installation that provides `codex.exe` or `codex.cmd`, then retry your command.") != null); + try std.testing.expect(std.mem.indexOf(u8, hint, "the `codex` executable was not found in your PATH.") == null); +} + test "Scenario: Given login help when rendering then device auth usage is included" { const gpa = std.testing.allocator; var aw: std.Io.Writer.Allocating = .init(gpa); @@ -855,6 +868,32 @@ test "Scenario: Given winget and npm Windows launchers when resolving then PATH try std.testing.expect(std.mem.endsWith(u8, cmd_first.path, "codex.cmd")); } +test "Scenario: Given both exe and cmd in one Windows directory when resolving then PATHEXT order decides which one wins" { + const gpa = std.testing.allocator; + var tmp = fs.tmpDir(.{}); + defer tmp.cleanup(); + + try tmp.dir.makePath("mixed-bin"); + try tmp.dir.writeFile(.{ .sub_path = "mixed-bin/codex.exe", .data = "" }); + try tmp.dir.writeFile(.{ .sub_path = "mixed-bin/codex.cmd", .data = "@echo off\r\nexit /b 0\r\n" }); + try tmp.dir.writeFile(.{ .sub_path = "mixed-bin/codex.ps1", .data = "exit 0\n" }); + + const root_dir = try tmp.dir.realpathAlloc(gpa, "."); + defer gpa.free(root_dir); + const mixed_dir = try std.fs.path.join(gpa, &[_][]const u8{ root_dir, "mixed-bin" }); + defer gpa.free(mixed_dir); + + var cmd_first = (try cli.login.resolveWindowsCodexPathEntriesWithPathExtAlloc(gpa, &[_][]const u8{mixed_dir}, ".CMD;.EXE")) orelse return error.TestUnexpectedResult; + defer cmd_first.deinit(gpa); + try std.testing.expectEqual(cli.login.WindowsCodexPathKind.cmd, cmd_first.kind); + try std.testing.expect(std.mem.endsWith(u8, cmd_first.path, "codex.cmd")); + + var exe_first = (try cli.login.resolveWindowsCodexPathEntriesWithPathExtAlloc(gpa, &[_][]const u8{mixed_dir}, ".EXE;.CMD")) orelse return error.TestUnexpectedResult; + defer exe_first.deinit(gpa); + try std.testing.expectEqual(cli.login.WindowsCodexPathKind.exe, exe_first.kind); + try std.testing.expect(std.mem.endsWith(u8, exe_first.path, "codex.exe")); +} + test "Scenario: Given only PowerShell Windows launcher when resolving then ps1 is used after cmd is absent" { const gpa = std.testing.allocator; var tmp = fs.tmpDir(.{}); From 65551924070d8d8c346fcedb17a092bd98cf2cf3 Mon Sep 17 00:00:00 2001 From: Loongphy Date: Fri, 5 Jun 2026 21:20:12 +0800 Subject: [PATCH 04/20] fix(login): keep PowerShell as fallback --- src/cli/login.zig | 104 +++++++++++++++++++++++++++++------- tests/cli_behavior_test.zig | 29 ++++++++++ 2 files changed, 115 insertions(+), 18 deletions(-) diff --git a/src/cli/login.zig b/src/cli/login.zig index d10e5b5..19ede8c 100644 --- a/src/cli/login.zig +++ b/src/cli/login.zig @@ -62,12 +62,14 @@ pub fn resolveWindowsCodexPathEntriesWithPathExtAlloc( entries: []const []const u8, path_ext: []const u8, ) !?WindowsCodexPath { - for (entries) |entry| { - if (entry.len == 0) continue; - if (try resolveWindowsCodexPathEntryWithPathExtAlloc(allocator, entry, path_ext)) |resolved| return resolved; - } + var candidates = try collectWindowsCodexPathEntriesWithPathExtAlloc(allocator, entries, path_ext); + errdefer deinitWindowsCodexPathList(allocator, &candidates); - return null; + if (candidates.items.len == 0) return null; + + const resolved = candidates.orderedRemove(0); + deinitWindowsCodexPathList(allocator, &candidates); + return resolved; } pub fn resolveWindowsCodexPathEntriesAlloc( @@ -130,7 +132,8 @@ fn appendWindowsCodexPathCandidateIfAvailable( fn appendWindowsCodexPathEntryCandidatesAlloc( allocator: std.mem.Allocator, - candidates: *WindowsCodexPathList, + native_candidates: *WindowsCodexPathList, + ps1_candidates: *WindowsCodexPathList, entry: []const u8, path_ext: []const u8, ) !void { @@ -152,10 +155,10 @@ fn appendWindowsCodexPathEntryCandidatesAlloc( }, .ps1 => unreachable, } - try appendWindowsCodexPathCandidateIfAvailable(allocator, candidates, entry, kind); + try appendWindowsCodexPathCandidateIfAvailable(allocator, native_candidates, entry, kind); } - try appendWindowsCodexPathCandidateIfAvailable(allocator, candidates, entry, .ps1); + try appendWindowsCodexPathCandidateIfAvailable(allocator, ps1_candidates, entry, .ps1); } fn deinitWindowsCodexPathList(allocator: std.mem.Allocator, candidates: *WindowsCodexPathList) void { @@ -163,15 +166,53 @@ fn deinitWindowsCodexPathList(allocator: std.mem.Allocator, candidates: *Windows candidates.deinit(allocator); } +fn appendWindowsCodexPathLists( + allocator: std.mem.Allocator, + dst: *WindowsCodexPathList, + src: *WindowsCodexPathList, +) !void { + try dst.appendSlice(allocator, src.items); + src.clearRetainingCapacity(); +} + +fn collectWindowsCodexPathEntriesWithPathExtAlloc( + allocator: std.mem.Allocator, + entries: []const []const u8, + path_ext: []const u8, +) !WindowsCodexPathList { + var native_candidates: WindowsCodexPathList = .empty; + errdefer deinitWindowsCodexPathList(allocator, &native_candidates); + var ps1_candidates: WindowsCodexPathList = .empty; + errdefer deinitWindowsCodexPathList(allocator, &ps1_candidates); + + for (entries) |entry| { + if (entry.len == 0) continue; + try appendWindowsCodexPathEntryCandidatesAlloc( + allocator, + &native_candidates, + &ps1_candidates, + entry, + path_ext, + ); + } + + try appendWindowsCodexPathLists(allocator, &native_candidates, &ps1_candidates); + ps1_candidates.deinit(allocator); + return native_candidates; +} + fn resolveWindowsCodexPathEntryWithPathExtAlloc( allocator: std.mem.Allocator, entry: []const u8, path_ext: []const u8, ) !?WindowsCodexPath { - var candidates: WindowsCodexPathList = .empty; + var candidates = try collectWindowsCodexPathEntriesWithPathExtAlloc( + allocator, + &[_][]const u8{entry}, + path_ext, + ); errdefer deinitWindowsCodexPathList(allocator, &candidates); - try appendWindowsCodexPathEntryCandidatesAlloc(allocator, &candidates, entry, path_ext); if (candidates.items.len == 0) return null; const resolved = candidates.orderedRemove(0); @@ -193,12 +234,19 @@ fn resolveWindowsCodexPathValueAlloc( allocator: std.mem.Allocator, path_value: []const u8, path_ext: []const u8, - candidates: *WindowsCodexPathList, + native_candidates: *WindowsCodexPathList, + ps1_candidates: *WindowsCodexPathList, ) !void { var path_it = std.mem.splitScalar(u8, path_value, std.fs.path.delimiter); while (path_it.next()) |entry| { if (entry.len == 0) continue; - try appendWindowsCodexPathEntryCandidatesAlloc(allocator, candidates, entry, path_ext); + try appendWindowsCodexPathEntryCandidatesAlloc( + allocator, + native_candidates, + ps1_candidates, + entry, + path_ext, + ); } } @@ -206,19 +254,39 @@ fn collectWindowsCodexPathsAlloc(allocator: std.mem.Allocator) !WindowsCodexPath const path_ext = try resolveWindowsCodexPathExtAlloc(allocator); defer allocator.free(path_ext); - var candidates: WindowsCodexPathList = .empty; - errdefer deinitWindowsCodexPathList(allocator, &candidates); + var native_candidates: WindowsCodexPathList = .empty; + errdefer deinitWindowsCodexPathList(allocator, &native_candidates); + var ps1_candidates: WindowsCodexPathList = .empty; + errdefer deinitWindowsCodexPathList(allocator, &ps1_candidates); - try appendWindowsCodexPathEntryCandidatesAlloc(allocator, &candidates, ".", path_ext); + try appendWindowsCodexPathEntryCandidatesAlloc( + allocator, + &native_candidates, + &ps1_candidates, + ".", + path_ext, + ); const path_value = http_env.getEnvVarOwned(allocator, "PATH") catch |err| switch (err) { - error.EnvironmentVariableNotFound => return candidates, + error.EnvironmentVariableNotFound => { + try appendWindowsCodexPathLists(allocator, &native_candidates, &ps1_candidates); + ps1_candidates.deinit(allocator); + return native_candidates; + }, else => return err, }; defer allocator.free(path_value); - try resolveWindowsCodexPathValueAlloc(allocator, path_value, path_ext, &candidates); - return candidates; + try resolveWindowsCodexPathValueAlloc( + allocator, + path_value, + path_ext, + &native_candidates, + &ps1_candidates, + ); + try appendWindowsCodexPathLists(allocator, &native_candidates, &ps1_candidates); + ps1_candidates.deinit(allocator); + return native_candidates; } fn resolveOptionalExecutableAlloc( diff --git a/tests/cli_behavior_test.zig b/tests/cli_behavior_test.zig index e4f564b..8d29e4b 100644 --- a/tests/cli_behavior_test.zig +++ b/tests/cli_behavior_test.zig @@ -894,6 +894,35 @@ test "Scenario: Given both exe and cmd in one Windows directory when resolving t try std.testing.expect(std.mem.endsWith(u8, exe_first.path, "codex.exe")); } +test "Scenario: Given an earlier PowerShell launcher and a later native Windows launcher when resolving then ps1 stays a global fallback" { + const gpa = std.testing.allocator; + var tmp = fs.tmpDir(.{}); + defer tmp.cleanup(); + + try tmp.dir.makePath("npm-bin"); + try tmp.dir.makePath("winget-bin"); + try tmp.dir.writeFile(.{ .sub_path = "npm-bin/codex", .data = "#!/bin/sh\nexit 1\n" }); + try tmp.dir.writeFile(.{ .sub_path = "npm-bin/codex.ps1", .data = "exit 0\n" }); + try tmp.dir.writeFile(.{ .sub_path = "winget-bin/codex.exe", .data = "" }); + + const root_dir = try tmp.dir.realpathAlloc(gpa, "."); + defer gpa.free(root_dir); + const npm_dir = try std.fs.path.join(gpa, &[_][]const u8{ root_dir, "npm-bin" }); + defer gpa.free(npm_dir); + const winget_dir = try std.fs.path.join(gpa, &[_][]const u8{ root_dir, "winget-bin" }); + defer gpa.free(winget_dir); + + var resolved = (try cli.login.resolveWindowsCodexPathEntriesWithPathExtAlloc( + gpa, + &[_][]const u8{ npm_dir, winget_dir }, + ".EXE;.CMD", + )) orelse return error.TestUnexpectedResult; + defer resolved.deinit(gpa); + + try std.testing.expectEqual(cli.login.WindowsCodexPathKind.exe, resolved.kind); + try std.testing.expect(std.mem.endsWith(u8, resolved.path, "codex.exe")); +} + test "Scenario: Given only PowerShell Windows launcher when resolving then ps1 is used after cmd is absent" { const gpa = std.testing.allocator; var tmp = fs.tmpDir(.{}); From 54c12ba4c1ff958b3d8a3618920aea40d7b31041 Mon Sep 17 00:00:00 2001 From: Loongphy Date: Fri, 5 Jun 2026 21:28:49 +0800 Subject: [PATCH 05/20] fix(login): harden Windows launcher fallback --- fallback.md | 4 ++-- src/cli/login.zig | 47 +++++++++++++++++++++++++++---------- tests/cli_behavior_test.zig | 25 ++++++++++++++++++++ 3 files changed, 62 insertions(+), 14 deletions(-) diff --git a/fallback.md b/fallback.md index 865c231..a01aa1d 100644 --- a/fallback.md +++ b/fallback.md @@ -1,6 +1,6 @@ # Fallbacks -- Windows `codex-auth login` accepts `codex.ps1` after `codex.exe` and `codex.cmd`. +- Windows `codex-auth login` accepts `codex.ps1` only after exhausting native Windows launchers from the current directory and PATH. Reason: real Windows Codex installs can expose npm wrapper layouts with `codex`, `codex.cmd`, and `codex.ps1`, while the extensionless `codex` file is a POSIX shell script that cannot be launched directly by a Windows process. Protected callers or data: Windows users whose PATH exposes only the PowerShell Codex wrapper after npm-style installation or wrapper cleanup. - Removal conditions: remove this fallback once the supported Codex Windows launcher contract guarantees a single directly spawnable entry point, or once this repo intentionally narrows support to `codex.exe` and `codex.cmd` only. + Removal conditions: remove this fallback once the supported Codex Windows launcher contract guarantees a single directly spawnable entry point, or once this repo intentionally narrows support to native Windows launchers only. diff --git a/src/cli/login.zig b/src/cli/login.zig index 19ede8c..2aea2ea 100644 --- a/src/cli/login.zig +++ b/src/cli/login.zig @@ -8,7 +8,9 @@ const types = @import("types.zig"); const output = @import("output.zig"); pub const WindowsCodexPathKind = enum { + com, exe, + bat, cmd, ps1, }; @@ -100,21 +102,25 @@ fn resolvePathEntryCandidateAlloc( fn windowsCodexCandidateName(kind: WindowsCodexPathKind) []const u8 { return switch (kind) { + .com => "codex.com", .exe => "codex.exe", + .bat => "codex.bat", .cmd => "codex.cmd", .ps1 => "codex.ps1", }; } fn windowsCodexPathExtKind(ext: []const u8) ?WindowsCodexPathKind { + if (std.ascii.eqlIgnoreCase(ext, ".com")) return .com; if (std.ascii.eqlIgnoreCase(ext, ".exe")) return .exe; + if (std.ascii.eqlIgnoreCase(ext, ".bat")) return .bat; if (std.ascii.eqlIgnoreCase(ext, ".cmd")) return .cmd; return null; } fn resolveWindowsCodexPathExtAlloc(allocator: std.mem.Allocator) ![]u8 { return http_env.getEnvVarOwned(allocator, "PATHEXT") catch |err| switch (err) { - error.EnvironmentVariableNotFound => try allocator.dupe(u8, ".EXE;.CMD"), + error.EnvironmentVariableNotFound => try allocator.dupe(u8, ".COM;.EXE;.BAT;.CMD"), else => return err, }; } @@ -136,8 +142,11 @@ fn appendWindowsCodexPathEntryCandidatesAlloc( ps1_candidates: *WindowsCodexPathList, entry: []const u8, path_ext: []const u8, + allow_ps1: bool, ) !void { + var seen_com = false; var seen_exe = false; + var seen_bat = false; var seen_cmd = false; var ext_it = std.mem.splitScalar(u8, path_ext, ';'); @@ -145,10 +154,18 @@ fn appendWindowsCodexPathEntryCandidatesAlloc( const ext = std.mem.trim(u8, raw_ext, " \t"); const kind = windowsCodexPathExtKind(ext) orelse continue; switch (kind) { + .com => { + if (seen_com) continue; + seen_com = true; + }, .exe => { if (seen_exe) continue; seen_exe = true; }, + .bat => { + if (seen_bat) continue; + seen_bat = true; + }, .cmd => { if (seen_cmd) continue; seen_cmd = true; @@ -158,7 +175,9 @@ fn appendWindowsCodexPathEntryCandidatesAlloc( try appendWindowsCodexPathCandidateIfAvailable(allocator, native_candidates, entry, kind); } - try appendWindowsCodexPathCandidateIfAvailable(allocator, ps1_candidates, entry, .ps1); + if (allow_ps1) { + try appendWindowsCodexPathCandidateIfAvailable(allocator, ps1_candidates, entry, .ps1); + } } fn deinitWindowsCodexPathList(allocator: std.mem.Allocator, candidates: *WindowsCodexPathList) void { @@ -193,6 +212,7 @@ fn collectWindowsCodexPathEntriesWithPathExtAlloc( &ps1_candidates, entry, path_ext, + true, ); } @@ -246,6 +266,7 @@ fn resolveWindowsCodexPathValueAlloc( ps1_candidates, entry, path_ext, + true, ); } } @@ -265,6 +286,7 @@ fn collectWindowsCodexPathsAlloc(allocator: std.mem.Allocator) !WindowsCodexPath &ps1_candidates, ".", path_ext, + false, ); const path_value = http_env.getEnvVarOwned(allocator, "PATH") catch |err| switch (err) { @@ -320,7 +342,7 @@ fn buildWindowsCodexLaunchAlloc( opts: types.LoginOptions, ) !CodexLaunch { switch (resolved.kind) { - .exe, .cmd => { + .com, .exe, .bat, .cmd => { var launch = CodexLaunch{}; launch.argv_storage[0] = resolved.path; launch.argv_storage[1] = "login"; @@ -339,15 +361,13 @@ fn buildWindowsCodexLaunchAlloc( launch.argv_storage[0] = powershell; launch.argv_storage[1] = "-NoLogo"; launch.argv_storage[2] = "-NoProfile"; - launch.argv_storage[3] = "-ExecutionPolicy"; - launch.argv_storage[4] = "Bypass"; - launch.argv_storage[5] = "-File"; - launch.argv_storage[6] = resolved.path; - launch.argv_storage[7] = "login"; - launch.argv_len = 8; + launch.argv_storage[3] = "-File"; + launch.argv_storage[4] = resolved.path; + launch.argv_storage[5] = "login"; + launch.argv_len = 6; if (opts.device_auth) { - launch.argv_storage[8] = "--device-auth"; - launch.argv_len = 9; + launch.argv_storage[6] = "--device-auth"; + launch.argv_len = 7; } return launch; }, @@ -375,7 +395,10 @@ fn writeCodexLoginLaunchFailureHint(err_name: []const u8) !void { fn shouldRetryWindowsCodexLaunch(err: std.process.SpawnError, kind: WindowsCodexPathKind) bool { return switch (err) { error.FileNotFound, error.AccessDenied => true, - error.InvalidExe => kind != .exe, + error.InvalidExe => switch (kind) { + .com, .exe => false, + .bat, .cmd, .ps1 => true, + }, else => false, }; } diff --git a/tests/cli_behavior_test.zig b/tests/cli_behavior_test.zig index 8d29e4b..be4a7d0 100644 --- a/tests/cli_behavior_test.zig +++ b/tests/cli_behavior_test.zig @@ -894,6 +894,31 @@ test "Scenario: Given both exe and cmd in one Windows directory when resolving t try std.testing.expect(std.mem.endsWith(u8, exe_first.path, "codex.exe")); } +test "Scenario: Given com and bat Windows launchers when resolving then legacy PATHEXT-native launchers stay supported" { + const gpa = std.testing.allocator; + var tmp = fs.tmpDir(.{}); + defer tmp.cleanup(); + + try tmp.dir.makePath("legacy-bin"); + try tmp.dir.writeFile(.{ .sub_path = "legacy-bin/codex.com", .data = "" }); + try tmp.dir.writeFile(.{ .sub_path = "legacy-bin/codex.bat", .data = "@echo off\r\nexit /b 0\r\n" }); + + const root_dir = try tmp.dir.realpathAlloc(gpa, "."); + defer gpa.free(root_dir); + const legacy_dir = try std.fs.path.join(gpa, &[_][]const u8{ root_dir, "legacy-bin" }); + defer gpa.free(legacy_dir); + + var com_first = (try cli.login.resolveWindowsCodexPathEntriesWithPathExtAlloc(gpa, &[_][]const u8{legacy_dir}, ".COM;.BAT")) orelse return error.TestUnexpectedResult; + defer com_first.deinit(gpa); + try std.testing.expectEqual(cli.login.WindowsCodexPathKind.com, com_first.kind); + try std.testing.expect(std.mem.endsWith(u8, com_first.path, "codex.com")); + + var bat_first = (try cli.login.resolveWindowsCodexPathEntriesWithPathExtAlloc(gpa, &[_][]const u8{legacy_dir}, ".BAT;.COM")) orelse return error.TestUnexpectedResult; + defer bat_first.deinit(gpa); + try std.testing.expectEqual(cli.login.WindowsCodexPathKind.bat, bat_first.kind); + try std.testing.expect(std.mem.endsWith(u8, bat_first.path, "codex.bat")); +} + test "Scenario: Given an earlier PowerShell launcher and a later native Windows launcher when resolving then ps1 stays a global fallback" { const gpa = std.testing.allocator; var tmp = fs.tmpDir(.{}); From b82f3b37081233490eaa03cc25d9bad33cc6725c Mon Sep 17 00:00:00 2001 From: Loongphy Date: Fri, 5 Jun 2026 21:47:16 +0800 Subject: [PATCH 06/20] fix(login): narrow Windows launcher support --- fallback.md | 4 ++-- src/cli/login.zig | 24 ++++-------------------- tests/cli_behavior_test.zig | 25 ------------------------- 3 files changed, 6 insertions(+), 47 deletions(-) diff --git a/fallback.md b/fallback.md index a01aa1d..1043218 100644 --- a/fallback.md +++ b/fallback.md @@ -1,6 +1,6 @@ # Fallbacks -- Windows `codex-auth login` accepts `codex.ps1` only after exhausting native Windows launchers from the current directory and PATH. +- Windows `codex-auth login` accepts `codex.ps1` only after exhausting `codex.exe` and `codex.cmd` candidates from the current directory and PATH. Reason: real Windows Codex installs can expose npm wrapper layouts with `codex`, `codex.cmd`, and `codex.ps1`, while the extensionless `codex` file is a POSIX shell script that cannot be launched directly by a Windows process. Protected callers or data: Windows users whose PATH exposes only the PowerShell Codex wrapper after npm-style installation or wrapper cleanup. - Removal conditions: remove this fallback once the supported Codex Windows launcher contract guarantees a single directly spawnable entry point, or once this repo intentionally narrows support to native Windows launchers only. + Removal conditions: remove this fallback once the supported Codex Windows launcher contract guarantees a single directly spawnable entry point, or once this repo intentionally narrows support to `codex.exe` and `codex.cmd` only. diff --git a/src/cli/login.zig b/src/cli/login.zig index 2aea2ea..0b7b916 100644 --- a/src/cli/login.zig +++ b/src/cli/login.zig @@ -8,9 +8,7 @@ const types = @import("types.zig"); const output = @import("output.zig"); pub const WindowsCodexPathKind = enum { - com, exe, - bat, cmd, ps1, }; @@ -102,25 +100,21 @@ fn resolvePathEntryCandidateAlloc( fn windowsCodexCandidateName(kind: WindowsCodexPathKind) []const u8 { return switch (kind) { - .com => "codex.com", .exe => "codex.exe", - .bat => "codex.bat", .cmd => "codex.cmd", .ps1 => "codex.ps1", }; } fn windowsCodexPathExtKind(ext: []const u8) ?WindowsCodexPathKind { - if (std.ascii.eqlIgnoreCase(ext, ".com")) return .com; if (std.ascii.eqlIgnoreCase(ext, ".exe")) return .exe; - if (std.ascii.eqlIgnoreCase(ext, ".bat")) return .bat; if (std.ascii.eqlIgnoreCase(ext, ".cmd")) return .cmd; return null; } fn resolveWindowsCodexPathExtAlloc(allocator: std.mem.Allocator) ![]u8 { return http_env.getEnvVarOwned(allocator, "PATHEXT") catch |err| switch (err) { - error.EnvironmentVariableNotFound => try allocator.dupe(u8, ".COM;.EXE;.BAT;.CMD"), + error.EnvironmentVariableNotFound => try allocator.dupe(u8, ".EXE;.CMD"), else => return err, }; } @@ -144,9 +138,7 @@ fn appendWindowsCodexPathEntryCandidatesAlloc( path_ext: []const u8, allow_ps1: bool, ) !void { - var seen_com = false; var seen_exe = false; - var seen_bat = false; var seen_cmd = false; var ext_it = std.mem.splitScalar(u8, path_ext, ';'); @@ -154,18 +146,10 @@ fn appendWindowsCodexPathEntryCandidatesAlloc( const ext = std.mem.trim(u8, raw_ext, " \t"); const kind = windowsCodexPathExtKind(ext) orelse continue; switch (kind) { - .com => { - if (seen_com) continue; - seen_com = true; - }, .exe => { if (seen_exe) continue; seen_exe = true; }, - .bat => { - if (seen_bat) continue; - seen_bat = true; - }, .cmd => { if (seen_cmd) continue; seen_cmd = true; @@ -342,7 +326,7 @@ fn buildWindowsCodexLaunchAlloc( opts: types.LoginOptions, ) !CodexLaunch { switch (resolved.kind) { - .com, .exe, .bat, .cmd => { + .exe, .cmd => { var launch = CodexLaunch{}; launch.argv_storage[0] = resolved.path; launch.argv_storage[1] = "login"; @@ -396,8 +380,8 @@ fn shouldRetryWindowsCodexLaunch(err: std.process.SpawnError, kind: WindowsCodex return switch (err) { error.FileNotFound, error.AccessDenied => true, error.InvalidExe => switch (kind) { - .com, .exe => false, - .bat, .cmd, .ps1 => true, + .exe => false, + .cmd, .ps1 => true, }, else => false, }; diff --git a/tests/cli_behavior_test.zig b/tests/cli_behavior_test.zig index be4a7d0..8d29e4b 100644 --- a/tests/cli_behavior_test.zig +++ b/tests/cli_behavior_test.zig @@ -894,31 +894,6 @@ test "Scenario: Given both exe and cmd in one Windows directory when resolving t try std.testing.expect(std.mem.endsWith(u8, exe_first.path, "codex.exe")); } -test "Scenario: Given com and bat Windows launchers when resolving then legacy PATHEXT-native launchers stay supported" { - const gpa = std.testing.allocator; - var tmp = fs.tmpDir(.{}); - defer tmp.cleanup(); - - try tmp.dir.makePath("legacy-bin"); - try tmp.dir.writeFile(.{ .sub_path = "legacy-bin/codex.com", .data = "" }); - try tmp.dir.writeFile(.{ .sub_path = "legacy-bin/codex.bat", .data = "@echo off\r\nexit /b 0\r\n" }); - - const root_dir = try tmp.dir.realpathAlloc(gpa, "."); - defer gpa.free(root_dir); - const legacy_dir = try std.fs.path.join(gpa, &[_][]const u8{ root_dir, "legacy-bin" }); - defer gpa.free(legacy_dir); - - var com_first = (try cli.login.resolveWindowsCodexPathEntriesWithPathExtAlloc(gpa, &[_][]const u8{legacy_dir}, ".COM;.BAT")) orelse return error.TestUnexpectedResult; - defer com_first.deinit(gpa); - try std.testing.expectEqual(cli.login.WindowsCodexPathKind.com, com_first.kind); - try std.testing.expect(std.mem.endsWith(u8, com_first.path, "codex.com")); - - var bat_first = (try cli.login.resolveWindowsCodexPathEntriesWithPathExtAlloc(gpa, &[_][]const u8{legacy_dir}, ".BAT;.COM")) orelse return error.TestUnexpectedResult; - defer bat_first.deinit(gpa); - try std.testing.expectEqual(cli.login.WindowsCodexPathKind.bat, bat_first.kind); - try std.testing.expect(std.mem.endsWith(u8, bat_first.path, "codex.bat")); -} - test "Scenario: Given an earlier PowerShell launcher and a later native Windows launcher when resolving then ps1 stays a global fallback" { const gpa = std.testing.allocator; var tmp = fs.tmpDir(.{}); From 73a701841be3aa99ef553845503650ae6ea3b1b5 Mon Sep 17 00:00:00 2001 From: Loongphy Date: Fri, 5 Jun 2026 21:52:09 +0800 Subject: [PATCH 07/20] fix(login): run Windows cmd launchers via cmd.exe --- fallback.md | 6 -- src/cli/login.zig | 119 ++++++++++----------------------- src/cli/output.zig | 4 -- tests/cli_behavior_test.zig | 56 +--------------- tests/cli_integration_test.zig | 67 ------------------- 5 files changed, 40 insertions(+), 212 deletions(-) delete mode 100644 fallback.md diff --git a/fallback.md b/fallback.md deleted file mode 100644 index 1043218..0000000 --- a/fallback.md +++ /dev/null @@ -1,6 +0,0 @@ -# Fallbacks - -- Windows `codex-auth login` accepts `codex.ps1` only after exhausting `codex.exe` and `codex.cmd` candidates from the current directory and PATH. - Reason: real Windows Codex installs can expose npm wrapper layouts with `codex`, `codex.cmd`, and `codex.ps1`, while the extensionless `codex` file is a POSIX shell script that cannot be launched directly by a Windows process. - Protected callers or data: Windows users whose PATH exposes only the PowerShell Codex wrapper after npm-style installation or wrapper cleanup. - Removal conditions: remove this fallback once the supported Codex Windows launcher contract guarantees a single directly spawnable entry point, or once this repo intentionally narrows support to `codex.exe` and `codex.cmd` only. diff --git a/src/cli/login.zig b/src/cli/login.zig index 0b7b916..1d1f1f6 100644 --- a/src/cli/login.zig +++ b/src/cli/login.zig @@ -10,7 +10,6 @@ const output = @import("output.zig"); pub const WindowsCodexPathKind = enum { exe, cmd, - ps1, }; pub const WindowsCodexPath = struct { @@ -102,7 +101,6 @@ fn windowsCodexCandidateName(kind: WindowsCodexPathKind) []const u8 { return switch (kind) { .exe => "codex.exe", .cmd => "codex.cmd", - .ps1 => "codex.ps1", }; } @@ -132,11 +130,9 @@ fn appendWindowsCodexPathCandidateIfAvailable( fn appendWindowsCodexPathEntryCandidatesAlloc( allocator: std.mem.Allocator, - native_candidates: *WindowsCodexPathList, - ps1_candidates: *WindowsCodexPathList, + candidates: *WindowsCodexPathList, entry: []const u8, path_ext: []const u8, - allow_ps1: bool, ) !void { var seen_exe = false; var seen_cmd = false; @@ -154,13 +150,8 @@ fn appendWindowsCodexPathEntryCandidatesAlloc( if (seen_cmd) continue; seen_cmd = true; }, - .ps1 => unreachable, } - try appendWindowsCodexPathCandidateIfAvailable(allocator, native_candidates, entry, kind); - } - - if (allow_ps1) { - try appendWindowsCodexPathCandidateIfAvailable(allocator, ps1_candidates, entry, .ps1); + try appendWindowsCodexPathCandidateIfAvailable(allocator, candidates, entry, kind); } } @@ -169,40 +160,25 @@ fn deinitWindowsCodexPathList(allocator: std.mem.Allocator, candidates: *Windows candidates.deinit(allocator); } -fn appendWindowsCodexPathLists( - allocator: std.mem.Allocator, - dst: *WindowsCodexPathList, - src: *WindowsCodexPathList, -) !void { - try dst.appendSlice(allocator, src.items); - src.clearRetainingCapacity(); -} - fn collectWindowsCodexPathEntriesWithPathExtAlloc( allocator: std.mem.Allocator, entries: []const []const u8, path_ext: []const u8, ) !WindowsCodexPathList { - var native_candidates: WindowsCodexPathList = .empty; - errdefer deinitWindowsCodexPathList(allocator, &native_candidates); - var ps1_candidates: WindowsCodexPathList = .empty; - errdefer deinitWindowsCodexPathList(allocator, &ps1_candidates); + var candidates: WindowsCodexPathList = .empty; + errdefer deinitWindowsCodexPathList(allocator, &candidates); for (entries) |entry| { if (entry.len == 0) continue; try appendWindowsCodexPathEntryCandidatesAlloc( allocator, - &native_candidates, - &ps1_candidates, + &candidates, entry, path_ext, - true, ); } - try appendWindowsCodexPathLists(allocator, &native_candidates, &ps1_candidates); - ps1_candidates.deinit(allocator); - return native_candidates; + return candidates; } fn resolveWindowsCodexPathEntryWithPathExtAlloc( @@ -238,19 +214,16 @@ fn resolveWindowsCodexPathValueAlloc( allocator: std.mem.Allocator, path_value: []const u8, path_ext: []const u8, - native_candidates: *WindowsCodexPathList, - ps1_candidates: *WindowsCodexPathList, + candidates: *WindowsCodexPathList, ) !void { var path_it = std.mem.splitScalar(u8, path_value, std.fs.path.delimiter); while (path_it.next()) |entry| { if (entry.len == 0) continue; try appendWindowsCodexPathEntryCandidatesAlloc( allocator, - native_candidates, - ps1_candidates, + candidates, entry, path_ext, - true, ); } } @@ -259,26 +232,18 @@ fn collectWindowsCodexPathsAlloc(allocator: std.mem.Allocator) !WindowsCodexPath const path_ext = try resolveWindowsCodexPathExtAlloc(allocator); defer allocator.free(path_ext); - var native_candidates: WindowsCodexPathList = .empty; - errdefer deinitWindowsCodexPathList(allocator, &native_candidates); - var ps1_candidates: WindowsCodexPathList = .empty; - errdefer deinitWindowsCodexPathList(allocator, &ps1_candidates); + var candidates: WindowsCodexPathList = .empty; + errdefer deinitWindowsCodexPathList(allocator, &candidates); try appendWindowsCodexPathEntryCandidatesAlloc( allocator, - &native_candidates, - &ps1_candidates, + &candidates, ".", path_ext, - false, ); const path_value = http_env.getEnvVarOwned(allocator, "PATH") catch |err| switch (err) { - error.EnvironmentVariableNotFound => { - try appendWindowsCodexPathLists(allocator, &native_candidates, &ps1_candidates); - ps1_candidates.deinit(allocator); - return native_candidates; - }, + error.EnvironmentVariableNotFound => return candidates, else => return err, }; defer allocator.free(path_value); @@ -287,12 +252,9 @@ fn collectWindowsCodexPathsAlloc(allocator: std.mem.Allocator) !WindowsCodexPath allocator, path_value, path_ext, - &native_candidates, - &ps1_candidates, + &candidates, ); - try appendWindowsCodexPathLists(allocator, &native_candidates, &ps1_candidates); - ps1_candidates.deinit(allocator); - return native_candidates; + return candidates; } fn resolveOptionalExecutableAlloc( @@ -305,10 +267,11 @@ fn resolveOptionalExecutableAlloc( }; } -fn resolveWindowsPowerShellExecutableAlloc(allocator: std.mem.Allocator) ![]u8 { - if (try resolveOptionalExecutableAlloc(allocator, "powershell.exe")) |path| return path; - if (try resolveOptionalExecutableAlloc(allocator, "pwsh.exe")) |path| return path; - return error.PowerShellNotFound; +fn resolveWindowsCmdExecutableAlloc(allocator: std.mem.Allocator) ![]u8 { + return http_executable.ensureExecutableAvailableAlloc(allocator, "cmd.exe") catch |err| switch (err) { + error.ExecutableRequired => error.CommandProcessorNotFound, + else => err, + }; } fn buildCodexLaunchAlloc(allocator: std.mem.Allocator, opts: types.LoginOptions) !CodexLaunch { @@ -326,7 +289,7 @@ fn buildWindowsCodexLaunchAlloc( opts: types.LoginOptions, ) !CodexLaunch { switch (resolved.kind) { - .exe, .cmd => { + .exe => { var launch = CodexLaunch{}; launch.argv_storage[0] = resolved.path; launch.argv_storage[1] = "login"; @@ -337,21 +300,20 @@ fn buildWindowsCodexLaunchAlloc( } return launch; }, - .ps1 => { - const powershell = try resolveWindowsPowerShellExecutableAlloc(allocator); - errdefer allocator.free(powershell); - - var launch = CodexLaunch{ .owned_paths = .{powershell} }; - launch.argv_storage[0] = powershell; - launch.argv_storage[1] = "-NoLogo"; - launch.argv_storage[2] = "-NoProfile"; - launch.argv_storage[3] = "-File"; - launch.argv_storage[4] = resolved.path; - launch.argv_storage[5] = "login"; - launch.argv_len = 6; + .cmd => { + const cmd_exe = try resolveWindowsCmdExecutableAlloc(allocator); + errdefer allocator.free(cmd_exe); + + var launch = CodexLaunch{ .owned_paths = .{cmd_exe} }; + launch.argv_storage[0] = cmd_exe; + launch.argv_storage[1] = "/D"; + launch.argv_storage[2] = "/C"; + launch.argv_storage[3] = resolved.path; + launch.argv_storage[4] = "login"; + launch.argv_len = 5; if (opts.device_auth) { - launch.argv_storage[6] = "--device-auth"; - launch.argv_len = 7; + launch.argv_storage[5] = "--device-auth"; + launch.argv_len = 6; } return launch; }, @@ -377,12 +339,9 @@ fn writeCodexLoginLaunchFailureHint(err_name: []const u8) !void { } fn shouldRetryWindowsCodexLaunch(err: std.process.SpawnError, kind: WindowsCodexPathKind) bool { + _ = kind; return switch (err) { error.FileNotFound, error.AccessDenied => true, - error.InvalidExe => switch (kind) { - .exe => false, - .cmd, .ps1 => true, - }, else => false, }; } @@ -432,13 +391,9 @@ pub fn runCodexLogin(opts: types.LoginOptions, codex_home: []const u8) !void { return ensureCodexLoginSucceeded(term); } - if (last_retryable_error) |err| { - writeCodexLoginLaunchFailureHint(@errorName(err)) catch {}; - return err; - } - - writeCodexLoginLaunchFailureHint("FileNotFound") catch {}; - return error.FileNotFound; + const err = last_retryable_error orelse unreachable; + writeCodexLoginLaunchFailureHint(@errorName(err)) catch {}; + return err; } var launch = buildCodexLaunchAlloc(std.heap.page_allocator, opts) catch |err| { diff --git a/src/cli/output.zig b/src/cli/output.zig index 0158d80..97e71a9 100644 --- a/src/cli/output.zig +++ b/src/cli/output.zig @@ -457,10 +457,6 @@ pub fn writeCodexLoginLaunchFailureHintTo(out: *std.Io.Writer, err_name: []const try writeHintPrefixTo(out, use_color); try out.writeAll(" Ensure the Codex CLI is installed and available in your environment.\n"); try out.writeAll(" Then run `codex login` manually and retry your command.\n"); - } else if (std.mem.eql(u8, err_name, "PowerShellNotFound")) { - try out.writeAll(" the `codex.ps1` launcher requires PowerShell, but neither `powershell.exe` nor `pwsh.exe` was found in your PATH.\n\n"); - try writeHintPrefixTo(out, use_color); - try out.writeAll(" Install PowerShell, or use a Codex CLI installation that provides `codex.exe` or `codex.cmd`, then retry your command.\n"); } else { try out.writeAll(" failed to launch the `codex login` process.\n\n"); try writeHintPrefixTo(out, use_color); diff --git a/tests/cli_behavior_test.zig b/tests/cli_behavior_test.zig index 8d29e4b..1fa940a 100644 --- a/tests/cli_behavior_test.zig +++ b/tests/cli_behavior_test.zig @@ -808,19 +808,6 @@ test "Scenario: Given codex login client missing when rendering then detection h try std.testing.expect(std.mem.indexOf(u8, hint, "Ensure the Codex CLI is installed and available in your environment.") != null); } -test "Scenario: Given PowerShell is missing for the codex ps1 launcher when rendering then the hint names PowerShell" { - const gpa = std.testing.allocator; - var aw: std.Io.Writer.Allocating = .init(gpa); - defer aw.deinit(); - - try cli.output.writeCodexLoginLaunchFailureHintTo(&aw.writer, "PowerShellNotFound", false); - - const hint = aw.written(); - try std.testing.expect(std.mem.indexOf(u8, hint, "the `codex.ps1` launcher requires PowerShell") != null); - try std.testing.expect(std.mem.indexOf(u8, hint, "Install PowerShell, or use a Codex CLI installation that provides `codex.exe` or `codex.cmd`, then retry your command.") != null); - try std.testing.expect(std.mem.indexOf(u8, hint, "the `codex` executable was not found in your PATH.") == null); -} - test "Scenario: Given login help when rendering then device auth usage is included" { const gpa = std.testing.allocator; var aw: std.Io.Writer.Allocating = .init(gpa); @@ -848,8 +835,6 @@ test "Scenario: Given winget and npm Windows launchers when resolving then PATH try tmp.dir.writeFile(.{ .sub_path = "winget-bin/codex.exe", .data = "" }); try tmp.dir.writeFile(.{ .sub_path = "npm-bin/codex", .data = "#!/bin/sh\nexit 1\n" }); try tmp.dir.writeFile(.{ .sub_path = "npm-bin/codex.cmd", .data = "@echo off\r\nexit /b 0\r\n" }); - try tmp.dir.writeFile(.{ .sub_path = "npm-bin/codex.ps1", .data = "exit 0\n" }); - const root_dir = try tmp.dir.realpathAlloc(gpa, "."); defer gpa.free(root_dir); const winget_dir = try std.fs.path.join(gpa, &[_][]const u8{ root_dir, "winget-bin" }); @@ -876,8 +861,6 @@ test "Scenario: Given both exe and cmd in one Windows directory when resolving t try tmp.dir.makePath("mixed-bin"); try tmp.dir.writeFile(.{ .sub_path = "mixed-bin/codex.exe", .data = "" }); try tmp.dir.writeFile(.{ .sub_path = "mixed-bin/codex.cmd", .data = "@echo off\r\nexit /b 0\r\n" }); - try tmp.dir.writeFile(.{ .sub_path = "mixed-bin/codex.ps1", .data = "exit 0\n" }); - const root_dir = try tmp.dir.realpathAlloc(gpa, "."); defer gpa.free(root_dir); const mixed_dir = try std.fs.path.join(gpa, &[_][]const u8{ root_dir, "mixed-bin" }); @@ -894,42 +877,28 @@ test "Scenario: Given both exe and cmd in one Windows directory when resolving t try std.testing.expect(std.mem.endsWith(u8, exe_first.path, "codex.exe")); } -test "Scenario: Given an earlier PowerShell launcher and a later native Windows launcher when resolving then ps1 stays a global fallback" { +test "Scenario: Given only the bare npm shell launcher on Windows when resolving then it is ignored" { const gpa = std.testing.allocator; var tmp = fs.tmpDir(.{}); defer tmp.cleanup(); try tmp.dir.makePath("npm-bin"); - try tmp.dir.makePath("winget-bin"); try tmp.dir.writeFile(.{ .sub_path = "npm-bin/codex", .data = "#!/bin/sh\nexit 1\n" }); - try tmp.dir.writeFile(.{ .sub_path = "npm-bin/codex.ps1", .data = "exit 0\n" }); - try tmp.dir.writeFile(.{ .sub_path = "winget-bin/codex.exe", .data = "" }); const root_dir = try tmp.dir.realpathAlloc(gpa, "."); defer gpa.free(root_dir); const npm_dir = try std.fs.path.join(gpa, &[_][]const u8{ root_dir, "npm-bin" }); defer gpa.free(npm_dir); - const winget_dir = try std.fs.path.join(gpa, &[_][]const u8{ root_dir, "winget-bin" }); - defer gpa.free(winget_dir); - - var resolved = (try cli.login.resolveWindowsCodexPathEntriesWithPathExtAlloc( - gpa, - &[_][]const u8{ npm_dir, winget_dir }, - ".EXE;.CMD", - )) orelse return error.TestUnexpectedResult; - defer resolved.deinit(gpa); - try std.testing.expectEqual(cli.login.WindowsCodexPathKind.exe, resolved.kind); - try std.testing.expect(std.mem.endsWith(u8, resolved.path, "codex.exe")); + try std.testing.expect((try cli.login.resolveWindowsCodexPathEntriesAlloc(gpa, &[_][]const u8{npm_dir})) == null); } -test "Scenario: Given only PowerShell Windows launcher when resolving then ps1 is used after cmd is absent" { +test "Scenario: Given only a PowerShell Codex launcher on Windows when resolving then it is ignored" { const gpa = std.testing.allocator; var tmp = fs.tmpDir(.{}); defer tmp.cleanup(); try tmp.dir.makePath("npm-bin"); - try tmp.dir.writeFile(.{ .sub_path = "npm-bin/codex", .data = "#!/bin/sh\nexit 1\n" }); try tmp.dir.writeFile(.{ .sub_path = "npm-bin/codex.ps1", .data = "exit 0\n" }); const root_dir = try tmp.dir.realpathAlloc(gpa, "."); @@ -937,25 +906,6 @@ test "Scenario: Given only PowerShell Windows launcher when resolving then ps1 i const npm_dir = try std.fs.path.join(gpa, &[_][]const u8{ root_dir, "npm-bin" }); defer gpa.free(npm_dir); - var resolved = (try cli.login.resolveWindowsCodexPathEntriesAlloc(gpa, &[_][]const u8{npm_dir})) orelse return error.TestUnexpectedResult; - defer resolved.deinit(gpa); - try std.testing.expectEqual(cli.login.WindowsCodexPathKind.ps1, resolved.kind); - try std.testing.expect(std.mem.endsWith(u8, resolved.path, "codex.ps1")); -} - -test "Scenario: Given only the bare npm shell launcher on Windows when resolving then it is ignored" { - const gpa = std.testing.allocator; - var tmp = fs.tmpDir(.{}); - defer tmp.cleanup(); - - try tmp.dir.makePath("npm-bin"); - try tmp.dir.writeFile(.{ .sub_path = "npm-bin/codex", .data = "#!/bin/sh\nexit 1\n" }); - - const root_dir = try tmp.dir.realpathAlloc(gpa, "."); - defer gpa.free(root_dir); - const npm_dir = try std.fs.path.join(gpa, &[_][]const u8{ root_dir, "npm-bin" }); - defer gpa.free(npm_dir); - try std.testing.expect((try cli.login.resolveWindowsCodexPathEntriesAlloc(gpa, &[_][]const u8{npm_dir})) == null); } diff --git a/tests/cli_integration_test.zig b/tests/cli_integration_test.zig index 68f8679..423b585 100644 --- a/tests/cli_integration_test.zig +++ b/tests/cli_integration_test.zig @@ -138,10 +138,6 @@ fn fakeCodexCommandPath() []const u8 { return if (builtin.os.tag == .windows) "fake-bin/codex.cmd" else "fake-bin/codex"; } -fn fakeCodexPowerShellPath() []const u8 { - return "fake-bin/codex.ps1"; -} - fn fakeBareWindowsCodexPath() []const u8 { return "fake-bin/codex"; } @@ -232,22 +228,6 @@ fn writeBrokenBareWindowsCodex(dir: fs.Dir) !void { }); } -fn writeSuccessfulFakeCodexPowerShell(dir: fs.Dir) !void { - if (builtin.os.tag != .windows) return; - - const script = - "$homePath = $env:HOME\r\n" ++ - "[System.IO.File]::WriteAllText((Join-Path $homePath 'fake-codex-launcher.txt'), \"ps1`n\")\r\n" ++ - "[System.IO.File]::WriteAllText((Join-Path $homePath 'fake-codex-argv.txt'), (($args -join ' ') + \"`n\"))\r\n" ++ - "[System.IO.File]::WriteAllText((Join-Path $homePath 'fake-codex-home.txt'), ($env:CODEX_HOME + \"`n\"))\r\n" ++ - "$codexHomeDir = $env:CODEX_HOME\r\n" ++ - "if ([string]::IsNullOrEmpty($codexHomeDir)) { $codexHomeDir = Join-Path $homePath '.codex' }\r\n" ++ - "if (-not (Test-Path -LiteralPath $codexHomeDir)) { New-Item -ItemType Directory -Path $codexHomeDir | Out-Null }\r\n" ++ - "Copy-Item -Force (Join-Path $homePath 'fake-auth.json') (Join-Path $codexHomeDir 'auth.json')\r\n"; - - try dir.writeFile(.{ .sub_path = fakeCodexPowerShellPath(), .data = script }); -} - fn fakeCurlCommandPath() []const u8 { return if (builtin.os.tag == .windows) "fake-curl-bin/curl.exe" else "fake-curl-bin/curl"; } @@ -957,53 +937,6 @@ test "Scenario: Given npm-style Windows codex wrappers when running login then t try std.testing.expectEqualStrings("cmd", std.mem.trim(u8, launcher_data, " \r\n")); } -test "Scenario: Given only a PowerShell Windows codex launcher when running login then codex.ps1 is launched via PowerShell" { - if (builtin.os.tag != .windows) return error.SkipZigTest; - - const gpa = std.testing.allocator; - const project_root = try projectRootAlloc(gpa); - defer gpa.free(project_root); - try buildCliBinary(gpa, project_root); - - var tmp = fs.tmpDir(.{}); - defer tmp.cleanup(); - - const home_root = try tmp.dir.realpathAlloc(gpa, "."); - defer gpa.free(home_root); - try tmp.dir.makePath(".codex"); - try tmp.dir.makePath("fake-bin"); - - const expected_email = "windows-ps1@example.com"; - const fake_auth = try fixtures.authJsonWithEmailPlan(gpa, expected_email, "plus"); - defer gpa.free(fake_auth); - try tmp.dir.writeFile(.{ .sub_path = "fake-auth.json", .data = fake_auth }); - try writeBrokenBareWindowsCodex(tmp.dir); - try writeSuccessfulFakeCodexPowerShell(tmp.dir); - - const fake_bin_path = try fs.path.join(gpa, &[_][]const u8{ home_root, "fake-bin" }); - defer gpa.free(fake_bin_path); - const path_override = try prependPathEntryAlloc(gpa, fake_bin_path); - defer gpa.free(path_override); - - const result = try runCliWithIsolatedHomeAndPath( - gpa, - project_root, - home_root, - path_override, - &[_][]const u8{ "login", "--device-auth" }, - ); - defer gpa.free(result.stdout); - defer gpa.free(result.stderr); - - try expectSuccess(result); - - const launcher_path = try fs.path.join(gpa, &[_][]const u8{ home_root, "fake-codex-launcher.txt" }); - defer gpa.free(launcher_path); - const launcher_data = try fixtures.readFileAlloc(gpa, launcher_path); - defer gpa.free(launcher_data); - try std.testing.expectEqualStrings("ps1", std.mem.trim(u8, launcher_data, " \r\n")); -} - test "Scenario: Given refreshed active auth before login when running login then old account snapshot is synced first" { const gpa = std.testing.allocator; const project_root = try projectRootAlloc(gpa); From 7c41e2b37ba74c50854117fddb4f9c750add6c42 Mon Sep 17 00:00:00 2001 From: Loongphy Date: Fri, 5 Jun 2026 22:15:37 +0800 Subject: [PATCH 08/20] fix(login): support Windows PowerShell wrappers --- fallback.md | 6 +++ src/cli/login.zig | 90 ++++++++++++++++++++++++++++------ src/cli/output.zig | 4 ++ tests/cli_behavior_test.zig | 52 +++++++++++++++++++- tests/cli_integration_test.zig | 67 +++++++++++++++++++++++++ 5 files changed, 203 insertions(+), 16 deletions(-) create mode 100644 fallback.md diff --git a/fallback.md b/fallback.md new file mode 100644 index 0000000..2b7ccfd --- /dev/null +++ b/fallback.md @@ -0,0 +1,6 @@ +# Fallbacks + +- `src/cli/login.zig`: Windows `codex-auth login` checks `codex.ps1` only after exhausting `codex.exe` and `codex.cmd`. + Reason: real npm installs provide `codex.ps1`, and manual testing confirmed `powershell.exe -File codex.ps1 login --help` works. + Protected callers/data: Windows users whose Codex CLI install exposes only the PowerShell wrapper. + Remove when: supported Windows Codex installs are guaranteed to provide `codex.exe` or `codex.cmd`. diff --git a/src/cli/login.zig b/src/cli/login.zig index 1d1f1f6..befa3b9 100644 --- a/src/cli/login.zig +++ b/src/cli/login.zig @@ -10,6 +10,7 @@ const output = @import("output.zig"); pub const WindowsCodexPathKind = enum { exe, cmd, + ps1, }; pub const WindowsCodexPath = struct { @@ -101,6 +102,7 @@ fn windowsCodexCandidateName(kind: WindowsCodexPathKind) []const u8 { return switch (kind) { .exe => "codex.exe", .cmd => "codex.cmd", + .ps1 => "codex.ps1", }; } @@ -130,9 +132,11 @@ fn appendWindowsCodexPathCandidateIfAvailable( fn appendWindowsCodexPathEntryCandidatesAlloc( allocator: std.mem.Allocator, - candidates: *WindowsCodexPathList, + native_candidates: *WindowsCodexPathList, + ps1_candidates: *WindowsCodexPathList, entry: []const u8, path_ext: []const u8, + allow_ps1: bool, ) !void { var seen_exe = false; var seen_cmd = false; @@ -150,8 +154,13 @@ fn appendWindowsCodexPathEntryCandidatesAlloc( if (seen_cmd) continue; seen_cmd = true; }, + .ps1 => unreachable, } - try appendWindowsCodexPathCandidateIfAvailable(allocator, candidates, entry, kind); + try appendWindowsCodexPathCandidateIfAvailable(allocator, native_candidates, entry, kind); + } + + if (allow_ps1) { + try appendWindowsCodexPathCandidateIfAvailable(allocator, ps1_candidates, entry, .ps1); } } @@ -160,25 +169,40 @@ fn deinitWindowsCodexPathList(allocator: std.mem.Allocator, candidates: *Windows candidates.deinit(allocator); } +fn appendWindowsCodexPathLists( + allocator: std.mem.Allocator, + dst: *WindowsCodexPathList, + src: *WindowsCodexPathList, +) !void { + try dst.appendSlice(allocator, src.items); + src.clearRetainingCapacity(); +} + fn collectWindowsCodexPathEntriesWithPathExtAlloc( allocator: std.mem.Allocator, entries: []const []const u8, path_ext: []const u8, ) !WindowsCodexPathList { - var candidates: WindowsCodexPathList = .empty; - errdefer deinitWindowsCodexPathList(allocator, &candidates); + var native_candidates: WindowsCodexPathList = .empty; + errdefer deinitWindowsCodexPathList(allocator, &native_candidates); + var ps1_candidates: WindowsCodexPathList = .empty; + errdefer deinitWindowsCodexPathList(allocator, &ps1_candidates); for (entries) |entry| { if (entry.len == 0) continue; try appendWindowsCodexPathEntryCandidatesAlloc( allocator, - &candidates, + &native_candidates, + &ps1_candidates, entry, path_ext, + true, ); } - return candidates; + try appendWindowsCodexPathLists(allocator, &native_candidates, &ps1_candidates); + ps1_candidates.deinit(allocator); + return native_candidates; } fn resolveWindowsCodexPathEntryWithPathExtAlloc( @@ -214,16 +238,19 @@ fn resolveWindowsCodexPathValueAlloc( allocator: std.mem.Allocator, path_value: []const u8, path_ext: []const u8, - candidates: *WindowsCodexPathList, + native_candidates: *WindowsCodexPathList, + ps1_candidates: *WindowsCodexPathList, ) !void { var path_it = std.mem.splitScalar(u8, path_value, std.fs.path.delimiter); while (path_it.next()) |entry| { if (entry.len == 0) continue; try appendWindowsCodexPathEntryCandidatesAlloc( allocator, - candidates, + native_candidates, + ps1_candidates, entry, path_ext, + true, ); } } @@ -232,18 +259,26 @@ fn collectWindowsCodexPathsAlloc(allocator: std.mem.Allocator) !WindowsCodexPath const path_ext = try resolveWindowsCodexPathExtAlloc(allocator); defer allocator.free(path_ext); - var candidates: WindowsCodexPathList = .empty; - errdefer deinitWindowsCodexPathList(allocator, &candidates); + var native_candidates: WindowsCodexPathList = .empty; + errdefer deinitWindowsCodexPathList(allocator, &native_candidates); + var ps1_candidates: WindowsCodexPathList = .empty; + errdefer deinitWindowsCodexPathList(allocator, &ps1_candidates); try appendWindowsCodexPathEntryCandidatesAlloc( allocator, - &candidates, + &native_candidates, + &ps1_candidates, ".", path_ext, + false, ); const path_value = http_env.getEnvVarOwned(allocator, "PATH") catch |err| switch (err) { - error.EnvironmentVariableNotFound => return candidates, + error.EnvironmentVariableNotFound => { + try appendWindowsCodexPathLists(allocator, &native_candidates, &ps1_candidates); + ps1_candidates.deinit(allocator); + return native_candidates; + }, else => return err, }; defer allocator.free(path_value); @@ -252,9 +287,12 @@ fn collectWindowsCodexPathsAlloc(allocator: std.mem.Allocator) !WindowsCodexPath allocator, path_value, path_ext, - &candidates, + &native_candidates, + &ps1_candidates, ); - return candidates; + try appendWindowsCodexPathLists(allocator, &native_candidates, &ps1_candidates); + ps1_candidates.deinit(allocator); + return native_candidates; } fn resolveOptionalExecutableAlloc( @@ -274,6 +312,12 @@ fn resolveWindowsCmdExecutableAlloc(allocator: std.mem.Allocator) ![]u8 { }; } +fn resolveWindowsPowerShellExecutableAlloc(allocator: std.mem.Allocator) ![]u8 { + if (try resolveOptionalExecutableAlloc(allocator, "powershell.exe")) |path| return path; + if (try resolveOptionalExecutableAlloc(allocator, "pwsh.exe")) |path| return path; + return error.PowerShellNotFound; +} + fn buildCodexLaunchAlloc(allocator: std.mem.Allocator, opts: types.LoginOptions) !CodexLaunch { _ = allocator; var launch = CodexLaunch{}; @@ -317,6 +361,24 @@ fn buildWindowsCodexLaunchAlloc( } return launch; }, + .ps1 => { + const powershell = try resolveWindowsPowerShellExecutableAlloc(allocator); + errdefer allocator.free(powershell); + + var launch = CodexLaunch{ .owned_paths = .{powershell} }; + launch.argv_storage[0] = powershell; + launch.argv_storage[1] = "-NoLogo"; + launch.argv_storage[2] = "-NoProfile"; + launch.argv_storage[3] = "-File"; + launch.argv_storage[4] = resolved.path; + launch.argv_storage[5] = "login"; + launch.argv_len = 6; + if (opts.device_auth) { + launch.argv_storage[6] = "--device-auth"; + launch.argv_len = 7; + } + return launch; + }, } } diff --git a/src/cli/output.zig b/src/cli/output.zig index 97e71a9..0158d80 100644 --- a/src/cli/output.zig +++ b/src/cli/output.zig @@ -457,6 +457,10 @@ pub fn writeCodexLoginLaunchFailureHintTo(out: *std.Io.Writer, err_name: []const try writeHintPrefixTo(out, use_color); try out.writeAll(" Ensure the Codex CLI is installed and available in your environment.\n"); try out.writeAll(" Then run `codex login` manually and retry your command.\n"); + } else if (std.mem.eql(u8, err_name, "PowerShellNotFound")) { + try out.writeAll(" the `codex.ps1` launcher requires PowerShell, but neither `powershell.exe` nor `pwsh.exe` was found in your PATH.\n\n"); + try writeHintPrefixTo(out, use_color); + try out.writeAll(" Install PowerShell, or use a Codex CLI installation that provides `codex.exe` or `codex.cmd`, then retry your command.\n"); } else { try out.writeAll(" failed to launch the `codex login` process.\n\n"); try writeHintPrefixTo(out, use_color); diff --git a/tests/cli_behavior_test.zig b/tests/cli_behavior_test.zig index 1fa940a..43c5cf7 100644 --- a/tests/cli_behavior_test.zig +++ b/tests/cli_behavior_test.zig @@ -808,6 +808,19 @@ test "Scenario: Given codex login client missing when rendering then detection h try std.testing.expect(std.mem.indexOf(u8, hint, "Ensure the Codex CLI is installed and available in your environment.") != null); } +test "Scenario: Given PowerShell is missing for the codex ps1 launcher when rendering then the hint names PowerShell" { + const gpa = std.testing.allocator; + var aw: std.Io.Writer.Allocating = .init(gpa); + defer aw.deinit(); + + try cli.output.writeCodexLoginLaunchFailureHintTo(&aw.writer, "PowerShellNotFound", false); + + const hint = aw.written(); + try std.testing.expect(std.mem.indexOf(u8, hint, "the `codex.ps1` launcher requires PowerShell") != null); + try std.testing.expect(std.mem.indexOf(u8, hint, "Install PowerShell, or use a Codex CLI installation that provides `codex.exe` or `codex.cmd`, then retry your command.") != null); + try std.testing.expect(std.mem.indexOf(u8, hint, "the `codex` executable was not found in your PATH.") == null); +} + test "Scenario: Given login help when rendering then device auth usage is included" { const gpa = std.testing.allocator; var aw: std.Io.Writer.Allocating = .init(gpa); @@ -835,6 +848,7 @@ test "Scenario: Given winget and npm Windows launchers when resolving then PATH try tmp.dir.writeFile(.{ .sub_path = "winget-bin/codex.exe", .data = "" }); try tmp.dir.writeFile(.{ .sub_path = "npm-bin/codex", .data = "#!/bin/sh\nexit 1\n" }); try tmp.dir.writeFile(.{ .sub_path = "npm-bin/codex.cmd", .data = "@echo off\r\nexit /b 0\r\n" }); + try tmp.dir.writeFile(.{ .sub_path = "npm-bin/codex.ps1", .data = "exit 0\n" }); const root_dir = try tmp.dir.realpathAlloc(gpa, "."); defer gpa.free(root_dir); const winget_dir = try std.fs.path.join(gpa, &[_][]const u8{ root_dir, "winget-bin" }); @@ -861,6 +875,7 @@ test "Scenario: Given both exe and cmd in one Windows directory when resolving t try tmp.dir.makePath("mixed-bin"); try tmp.dir.writeFile(.{ .sub_path = "mixed-bin/codex.exe", .data = "" }); try tmp.dir.writeFile(.{ .sub_path = "mixed-bin/codex.cmd", .data = "@echo off\r\nexit /b 0\r\n" }); + try tmp.dir.writeFile(.{ .sub_path = "mixed-bin/codex.ps1", .data = "exit 0\n" }); const root_dir = try tmp.dir.realpathAlloc(gpa, "."); defer gpa.free(root_dir); const mixed_dir = try std.fs.path.join(gpa, &[_][]const u8{ root_dir, "mixed-bin" }); @@ -893,20 +908,53 @@ test "Scenario: Given only the bare npm shell launcher on Windows when resolving try std.testing.expect((try cli.login.resolveWindowsCodexPathEntriesAlloc(gpa, &[_][]const u8{npm_dir})) == null); } -test "Scenario: Given only a PowerShell Codex launcher on Windows when resolving then it is ignored" { +test "Scenario: Given an earlier PowerShell launcher and a later native Windows launcher when resolving then ps1 stays a global fallback" { const gpa = std.testing.allocator; var tmp = fs.tmpDir(.{}); defer tmp.cleanup(); try tmp.dir.makePath("npm-bin"); + try tmp.dir.makePath("winget-bin"); + try tmp.dir.writeFile(.{ .sub_path = "npm-bin/codex", .data = "#!/bin/sh\nexit 1\n" }); try tmp.dir.writeFile(.{ .sub_path = "npm-bin/codex.ps1", .data = "exit 0\n" }); + try tmp.dir.writeFile(.{ .sub_path = "winget-bin/codex.exe", .data = "" }); const root_dir = try tmp.dir.realpathAlloc(gpa, "."); defer gpa.free(root_dir); const npm_dir = try std.fs.path.join(gpa, &[_][]const u8{ root_dir, "npm-bin" }); defer gpa.free(npm_dir); + const winget_dir = try std.fs.path.join(gpa, &[_][]const u8{ root_dir, "winget-bin" }); + defer gpa.free(winget_dir); - try std.testing.expect((try cli.login.resolveWindowsCodexPathEntriesAlloc(gpa, &[_][]const u8{npm_dir})) == null); + var resolved = (try cli.login.resolveWindowsCodexPathEntriesWithPathExtAlloc( + gpa, + &[_][]const u8{ npm_dir, winget_dir }, + ".EXE;.CMD", + )) orelse return error.TestUnexpectedResult; + defer resolved.deinit(gpa); + + try std.testing.expectEqual(cli.login.WindowsCodexPathKind.exe, resolved.kind); + try std.testing.expect(std.mem.endsWith(u8, resolved.path, "codex.exe")); +} + +test "Scenario: Given only PowerShell Windows launcher when resolving then ps1 is used after cmd is absent" { + const gpa = std.testing.allocator; + var tmp = fs.tmpDir(.{}); + defer tmp.cleanup(); + + try tmp.dir.makePath("npm-bin"); + try tmp.dir.writeFile(.{ .sub_path = "npm-bin/codex", .data = "#!/bin/sh\nexit 1\n" }); + try tmp.dir.writeFile(.{ .sub_path = "npm-bin/codex.ps1", .data = "exit 0\n" }); + + const root_dir = try tmp.dir.realpathAlloc(gpa, "."); + defer gpa.free(root_dir); + const npm_dir = try std.fs.path.join(gpa, &[_][]const u8{ root_dir, "npm-bin" }); + defer gpa.free(npm_dir); + + var resolved = (try cli.login.resolveWindowsCodexPathEntriesAlloc(gpa, &[_][]const u8{npm_dir})) orelse return error.TestUnexpectedResult; + defer resolved.deinit(gpa); + try std.testing.expectEqual(cli.login.WindowsCodexPathKind.ps1, resolved.kind); + try std.testing.expect(std.mem.endsWith(u8, resolved.path, "codex.ps1")); } test "Scenario: Given switch with positional query when parsing then non-interactive target is preserved" { diff --git a/tests/cli_integration_test.zig b/tests/cli_integration_test.zig index 423b585..2dd0659 100644 --- a/tests/cli_integration_test.zig +++ b/tests/cli_integration_test.zig @@ -138,6 +138,10 @@ fn fakeCodexCommandPath() []const u8 { return if (builtin.os.tag == .windows) "fake-bin/codex.cmd" else "fake-bin/codex"; } +fn fakeCodexPowerShellPath() []const u8 { + return "fake-bin/codex.ps1"; +} + fn fakeBareWindowsCodexPath() []const u8 { return "fake-bin/codex"; } @@ -228,6 +232,22 @@ fn writeBrokenBareWindowsCodex(dir: fs.Dir) !void { }); } +fn writeSuccessfulFakeCodexPowerShell(dir: fs.Dir) !void { + if (builtin.os.tag != .windows) return; + + const script = + "$homePath = $env:HOME\r\n" ++ + "[System.IO.File]::WriteAllText((Join-Path $homePath 'fake-codex-launcher.txt'), \"ps1`n\")\r\n" ++ + "[System.IO.File]::WriteAllText((Join-Path $homePath 'fake-codex-argv.txt'), (($args -join ' ') + \"`n\"))\r\n" ++ + "[System.IO.File]::WriteAllText((Join-Path $homePath 'fake-codex-home.txt'), ($env:CODEX_HOME + \"`n\"))\r\n" ++ + "$codexHomeDir = $env:CODEX_HOME\r\n" ++ + "if ([string]::IsNullOrEmpty($codexHomeDir)) { $codexHomeDir = Join-Path $homePath '.codex' }\r\n" ++ + "if (-not (Test-Path -LiteralPath $codexHomeDir)) { New-Item -ItemType Directory -Path $codexHomeDir | Out-Null }\r\n" ++ + "Copy-Item -Force (Join-Path $homePath 'fake-auth.json') (Join-Path $codexHomeDir 'auth.json')\r\n"; + + try dir.writeFile(.{ .sub_path = fakeCodexPowerShellPath(), .data = script }); +} + fn fakeCurlCommandPath() []const u8 { return if (builtin.os.tag == .windows) "fake-curl-bin/curl.exe" else "fake-curl-bin/curl"; } @@ -937,6 +957,53 @@ test "Scenario: Given npm-style Windows codex wrappers when running login then t try std.testing.expectEqualStrings("cmd", std.mem.trim(u8, launcher_data, " \r\n")); } +test "Scenario: Given only a PowerShell Windows codex wrapper when running login then codex.ps1 is launched" { + if (builtin.os.tag != .windows) return error.SkipZigTest; + + const gpa = std.testing.allocator; + const project_root = try projectRootAlloc(gpa); + defer gpa.free(project_root); + try buildCliBinary(gpa, project_root); + + var tmp = fs.tmpDir(.{}); + defer tmp.cleanup(); + + const home_root = try tmp.dir.realpathAlloc(gpa, "."); + defer gpa.free(home_root); + try tmp.dir.makePath(".codex"); + try tmp.dir.makePath("fake-bin"); + + const expected_email = "windows-ps1@example.com"; + const fake_auth = try fixtures.authJsonWithEmailPlan(gpa, expected_email, "plus"); + defer gpa.free(fake_auth); + try tmp.dir.writeFile(.{ .sub_path = "fake-auth.json", .data = fake_auth }); + try writeBrokenBareWindowsCodex(tmp.dir); + try writeSuccessfulFakeCodexPowerShell(tmp.dir); + + const fake_bin_path = try fs.path.join(gpa, &[_][]const u8{ home_root, "fake-bin" }); + defer gpa.free(fake_bin_path); + const path_override = try prependPathEntryAlloc(gpa, fake_bin_path); + defer gpa.free(path_override); + + const result = try runCliWithIsolatedHomeAndPath( + gpa, + project_root, + home_root, + path_override, + &[_][]const u8{ "login", "--device-auth" }, + ); + defer gpa.free(result.stdout); + defer gpa.free(result.stderr); + + try expectSuccess(result); + + const launcher_path = try fs.path.join(gpa, &[_][]const u8{ home_root, "fake-codex-launcher.txt" }); + defer gpa.free(launcher_path); + const launcher_data = try fixtures.readFileAlloc(gpa, launcher_path); + defer gpa.free(launcher_data); + try std.testing.expectEqualStrings("ps1", std.mem.trim(u8, launcher_data, " \r\n")); +} + test "Scenario: Given refreshed active auth before login when running login then old account snapshot is synced first" { const gpa = std.testing.allocator; const project_root = try projectRootAlloc(gpa); From 0fa4559807ca7b41ae5dad0f9685b234b0c3af08 Mon Sep 17 00:00:00 2001 From: Loongphy Date: Fri, 5 Jun 2026 22:23:11 +0800 Subject: [PATCH 09/20] fix(login): narrow Windows retry behavior --- src/cli/login.zig | 60 +++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 53 insertions(+), 7 deletions(-) diff --git a/src/cli/login.zig b/src/cli/login.zig index befa3b9..aacacbf 100644 --- a/src/cli/login.zig +++ b/src/cli/login.zig @@ -40,6 +40,11 @@ const CodexLaunch = struct { const WindowsCodexPathList = std.ArrayList(WindowsCodexPath); +const RetryableWindowsCodexBuildError = enum { + command_processor_not_found, + powershell_not_found, +}; + pub fn codexLoginArgs(opts: types.LoginOptions) []const []const u8 { return if (opts.device_auth) &[_][]const u8{ "codex", "login", "--device-auth" } @@ -400,10 +405,41 @@ fn writeCodexLoginLaunchFailureHint(err_name: []const u8) !void { try out.flush(); } +fn retryableWindowsCodexBuildErrorName(err: RetryableWindowsCodexBuildError) []const u8 { + return switch (err) { + .command_processor_not_found => "CommandProcessorNotFound", + .powershell_not_found => "PowerShellNotFound", + }; +} + +fn retryableWindowsCodexBuildErrorValue(err: RetryableWindowsCodexBuildError) anyerror { + return switch (err) { + .command_processor_not_found => error.CommandProcessorNotFound, + .powershell_not_found => error.PowerShellNotFound, + }; +} + +fn shouldRetryWindowsCodexBuild(err: anyerror, kind: WindowsCodexPathKind) ?RetryableWindowsCodexBuildError { + return switch (err) { + error.CommandProcessorNotFound => switch (kind) { + .cmd => .command_processor_not_found, + else => null, + }, + error.PowerShellNotFound => switch (kind) { + .ps1 => .powershell_not_found, + else => null, + }, + else => null, + }; +} + fn shouldRetryWindowsCodexLaunch(err: std.process.SpawnError, kind: WindowsCodexPathKind) bool { - _ = kind; return switch (err) { - error.FileNotFound, error.AccessDenied => true, + error.FileNotFound => true, + error.AccessDenied => switch (kind) { + .cmd, .ps1 => true, + .exe => false, + }, else => false, }; } @@ -422,9 +458,14 @@ pub fn runCodexLogin(opts: types.LoginOptions, codex_home: []const u8) !void { return error.FileNotFound; } - var last_retryable_error: ?std.process.SpawnError = null; + var last_retryable_spawn_error: ?std.process.SpawnError = null; + var last_retryable_build_error: ?RetryableWindowsCodexBuildError = null; for (candidates.items) |*candidate| { var launch = buildWindowsCodexLaunchAlloc(std.heap.page_allocator, candidate, opts) catch |err| { + if (shouldRetryWindowsCodexBuild(err, candidate.kind)) |retryable_err| { + last_retryable_build_error = retryable_err; + continue; + } writeCodexLoginLaunchFailureHint(@errorName(err)) catch {}; return err; }; @@ -438,7 +479,7 @@ pub fn runCodexLogin(opts: types.LoginOptions, codex_home: []const u8) !void { }) catch |err| { launch.deinit(std.heap.page_allocator); if (shouldRetryWindowsCodexLaunch(err, candidate.kind)) { - last_retryable_error = err; + last_retryable_spawn_error = err; continue; } writeCodexLoginLaunchFailureHint(@errorName(err)) catch {}; @@ -453,9 +494,14 @@ pub fn runCodexLogin(opts: types.LoginOptions, codex_home: []const u8) !void { return ensureCodexLoginSucceeded(term); } - const err = last_retryable_error orelse unreachable; - writeCodexLoginLaunchFailureHint(@errorName(err)) catch {}; - return err; + if (last_retryable_spawn_error) |err| { + writeCodexLoginLaunchFailureHint(@errorName(err)) catch {}; + return err; + } + + const build_err = last_retryable_build_error orelse unreachable; + writeCodexLoginLaunchFailureHint(retryableWindowsCodexBuildErrorName(build_err)) catch {}; + return retryableWindowsCodexBuildErrorValue(build_err); } var launch = buildCodexLaunchAlloc(std.heap.page_allocator, opts) catch |err| { From 6d5cce69ba5d72e335b2494516362568df00fbdd Mon Sep 17 00:00:00 2001 From: Loongphy Date: Fri, 5 Jun 2026 22:29:27 +0800 Subject: [PATCH 10/20] fix(login): clarify Windows launcher hints --- src/cli/output.zig | 4 ++++ tests/cli_behavior_test.zig | 13 +++++++++++++ 2 files changed, 17 insertions(+) diff --git a/src/cli/output.zig b/src/cli/output.zig index 0158d80..e6be2a5 100644 --- a/src/cli/output.zig +++ b/src/cli/output.zig @@ -457,6 +457,10 @@ pub fn writeCodexLoginLaunchFailureHintTo(out: *std.Io.Writer, err_name: []const try writeHintPrefixTo(out, use_color); try out.writeAll(" Ensure the Codex CLI is installed and available in your environment.\n"); try out.writeAll(" Then run `codex login` manually and retry your command.\n"); + } else if (std.mem.eql(u8, err_name, "CommandProcessorNotFound")) { + try out.writeAll(" the `codex.cmd` launcher requires `cmd.exe`, but it was not found in your PATH.\n\n"); + try writeHintPrefixTo(out, use_color); + try out.writeAll(" Restore `cmd.exe` to your PATH, or use a Codex CLI installation that provides `codex.exe`, then retry your command.\n"); } else if (std.mem.eql(u8, err_name, "PowerShellNotFound")) { try out.writeAll(" the `codex.ps1` launcher requires PowerShell, but neither `powershell.exe` nor `pwsh.exe` was found in your PATH.\n\n"); try writeHintPrefixTo(out, use_color); diff --git a/tests/cli_behavior_test.zig b/tests/cli_behavior_test.zig index 43c5cf7..1844269 100644 --- a/tests/cli_behavior_test.zig +++ b/tests/cli_behavior_test.zig @@ -821,6 +821,19 @@ test "Scenario: Given PowerShell is missing for the codex ps1 launcher when rend try std.testing.expect(std.mem.indexOf(u8, hint, "the `codex` executable was not found in your PATH.") == null); } +test "Scenario: Given cmd is missing for the codex cmd launcher when rendering then the hint names cmd.exe" { + const gpa = std.testing.allocator; + var aw: std.Io.Writer.Allocating = .init(gpa); + defer aw.deinit(); + + try cli.output.writeCodexLoginLaunchFailureHintTo(&aw.writer, "CommandProcessorNotFound", false); + + const hint = aw.written(); + try std.testing.expect(std.mem.indexOf(u8, hint, "the `codex.cmd` launcher requires `cmd.exe`") != null); + try std.testing.expect(std.mem.indexOf(u8, hint, "Restore `cmd.exe` to your PATH, or use a Codex CLI installation that provides `codex.exe`, then retry your command.") != null); + try std.testing.expect(std.mem.indexOf(u8, hint, "failed to launch the `codex login` process.") == null); +} + test "Scenario: Given login help when rendering then device auth usage is included" { const gpa = std.testing.allocator; var aw: std.Io.Writer.Allocating = .init(gpa); From 2c1d49c86e0c334fa18989472a81c9776c22e461 Mon Sep 17 00:00:00 2001 From: Loongphy Date: Fri, 5 Jun 2026 22:38:17 +0800 Subject: [PATCH 11/20] fix(login): prefer actionable Windows launch errors --- src/cli/login.zig | 51 ++++++++++++++++++++++++++++++------- tests/cli_behavior_test.zig | 20 +++++++++++++++ 2 files changed, 62 insertions(+), 9 deletions(-) diff --git a/src/cli/login.zig b/src/cli/login.zig index aacacbf..9bf1885 100644 --- a/src/cli/login.zig +++ b/src/cli/login.zig @@ -40,11 +40,16 @@ const CodexLaunch = struct { const WindowsCodexPathList = std.ArrayList(WindowsCodexPath); -const RetryableWindowsCodexBuildError = enum { +pub const RetryableWindowsCodexBuildError = enum { command_processor_not_found, powershell_not_found, }; +pub const WindowsCodexLaunchFailure = struct { + hint_name: []const u8, + err: anyerror, +}; + pub fn codexLoginArgs(opts: types.LoginOptions) []const []const u8 { return if (opts.device_auth) &[_][]const u8{ "codex", "login", "--device-auth" } @@ -433,6 +438,36 @@ fn shouldRetryWindowsCodexBuild(err: anyerror, kind: WindowsCodexPathKind) ?Retr }; } +pub fn finalRetryableWindowsCodexLaunchFailure( + last_retryable_spawn_error: ?std.process.SpawnError, + last_retryable_build_error: ?RetryableWindowsCodexBuildError, +) ?WindowsCodexLaunchFailure { + if (last_retryable_build_error) |build_err| { + if (last_retryable_spawn_error) |spawn_err| { + if (spawn_err != error.FileNotFound) { + return .{ + .hint_name = @errorName(spawn_err), + .err = spawn_err, + }; + } + } + + return .{ + .hint_name = retryableWindowsCodexBuildErrorName(build_err), + .err = retryableWindowsCodexBuildErrorValue(build_err), + }; + } + + if (last_retryable_spawn_error) |spawn_err| { + return .{ + .hint_name = @errorName(spawn_err), + .err = spawn_err, + }; + } + + return null; +} + fn shouldRetryWindowsCodexLaunch(err: std.process.SpawnError, kind: WindowsCodexPathKind) bool { return switch (err) { error.FileNotFound => true, @@ -494,14 +529,12 @@ pub fn runCodexLogin(opts: types.LoginOptions, codex_home: []const u8) !void { return ensureCodexLoginSucceeded(term); } - if (last_retryable_spawn_error) |err| { - writeCodexLoginLaunchFailureHint(@errorName(err)) catch {}; - return err; - } - - const build_err = last_retryable_build_error orelse unreachable; - writeCodexLoginLaunchFailureHint(retryableWindowsCodexBuildErrorName(build_err)) catch {}; - return retryableWindowsCodexBuildErrorValue(build_err); + const failure = finalRetryableWindowsCodexLaunchFailure( + last_retryable_spawn_error, + last_retryable_build_error, + ) orelse unreachable; + writeCodexLoginLaunchFailureHint(failure.hint_name) catch {}; + return failure.err; } var launch = buildCodexLaunchAlloc(std.heap.page_allocator, opts) catch |err| { diff --git a/tests/cli_behavior_test.zig b/tests/cli_behavior_test.zig index 1844269..e236253 100644 --- a/tests/cli_behavior_test.zig +++ b/tests/cli_behavior_test.zig @@ -970,6 +970,26 @@ test "Scenario: Given only PowerShell Windows launcher when resolving then ps1 i try std.testing.expect(std.mem.endsWith(u8, resolved.path, "codex.ps1")); } +test "Scenario: Given retryable Windows build and spawn failures when selecting the final hint then build failure beats generic FileNotFound" { + const failure = cli.login.finalRetryableWindowsCodexLaunchFailure( + error.FileNotFound, + .powershell_not_found, + ) orelse return error.TestUnexpectedResult; + + try std.testing.expectEqualStrings("PowerShellNotFound", failure.hint_name); + try std.testing.expect(failure.err == error.PowerShellNotFound); +} + +test "Scenario: Given retryable Windows build and spawn failures when selecting the final hint then non-generic spawn failure still wins" { + const failure = cli.login.finalRetryableWindowsCodexLaunchFailure( + error.AccessDenied, + .powershell_not_found, + ) orelse return error.TestUnexpectedResult; + + try std.testing.expectEqualStrings("AccessDenied", failure.hint_name); + try std.testing.expect(failure.err == error.AccessDenied); +} + test "Scenario: Given switch with positional query when parsing then non-interactive target is preserved" { const gpa = std.testing.allocator; const args = [_][:0]const u8{ "codex-auth", "switch", "user@example.com" }; From 86ed0514e1b0ea50b9c0bc6b4a9c5f8778dd14ba Mon Sep 17 00:00:00 2001 From: Loongphy Date: Sat, 6 Jun 2026 18:02:08 +0800 Subject: [PATCH 12/20] fix(login): simplify Windows Codex PATH resolution --- build.zig | 20 ++++ src/cli/login.zig | 167 +++++---------------------------- tests/cli_behavior_test.zig | 27 ++---- tests/cli_integration_test.zig | 139 +++++++++++++++++++++++++++ tests/fake_codex.zig | 46 +++++++++ 5 files changed, 238 insertions(+), 161 deletions(-) create mode 100644 tests/fake_codex.zig diff --git a/build.zig b/build.zig index d898c8d..c21e269 100644 --- a/build.zig +++ b/build.zig @@ -42,10 +42,30 @@ pub fn build(b: *std.Build) void { .root_module = fake_curl_fail_module, }); const install_fake_curl_fail = b.addInstallArtifact(fake_curl_fail_exe, .{}); + const fake_codex_module = b.createModule(.{ + .root_source_file = b.path("tests/fake_codex.zig"), + .target = target, + .optimize = optimize, + .link_libc = true, + .imports = &.{ + .{ .name = "app_runtime", .module = b.createModule(.{ + .root_source_file = b.path("src/core/runtime.zig"), + .target = target, + .optimize = optimize, + .link_libc = true, + }) }, + }, + }); + const fake_codex_exe = b.addExecutable(.{ + .name = "fake-codex", + .root_module = fake_codex_module, + }); + const install_fake_codex = b.addInstallArtifact(fake_codex_exe, .{}); const test_helpers_step = b.step("test-helpers", "Install test helper binaries"); test_helpers_step.dependOn(b.getInstallStep()); test_helpers_step.dependOn(&install_fake_curl.step); test_helpers_step.dependOn(&install_fake_curl_fail.step); + test_helpers_step.dependOn(&install_fake_codex.step); const run_cmd = b.addRunArtifact(exe); if (b.args) |args| { diff --git a/src/cli/login.zig b/src/cli/login.zig index 9bf1885..e64b4e6 100644 --- a/src/cli/login.zig +++ b/src/cli/login.zig @@ -61,18 +61,7 @@ pub fn resolveWindowsCodexPathEntryAlloc( allocator: std.mem.Allocator, entry: []const u8, ) !?WindowsCodexPath { - const path_ext = try resolveWindowsCodexPathExtAlloc(allocator); - defer allocator.free(path_ext); - - return resolveWindowsCodexPathEntryWithPathExtAlloc(allocator, entry, path_ext); -} - -pub fn resolveWindowsCodexPathEntriesWithPathExtAlloc( - allocator: std.mem.Allocator, - entries: []const []const u8, - path_ext: []const u8, -) !?WindowsCodexPath { - var candidates = try collectWindowsCodexPathEntriesWithPathExtAlloc(allocator, entries, path_ext); + var candidates = try collectWindowsCodexPathEntriesAlloc(allocator, &[_][]const u8{entry}); errdefer deinitWindowsCodexPathList(allocator, &candidates); if (candidates.items.len == 0) return null; @@ -86,10 +75,14 @@ pub fn resolveWindowsCodexPathEntriesAlloc( allocator: std.mem.Allocator, entries: []const []const u8, ) !?WindowsCodexPath { - const path_ext = try resolveWindowsCodexPathExtAlloc(allocator); - defer allocator.free(path_ext); + var candidates = try collectWindowsCodexPathEntriesAlloc(allocator, entries); + errdefer deinitWindowsCodexPathList(allocator, &candidates); + + if (candidates.items.len == 0) return null; - return resolveWindowsCodexPathEntriesWithPathExtAlloc(allocator, entries, path_ext); + const resolved = candidates.orderedRemove(0); + deinitWindowsCodexPathList(allocator, &candidates); + return resolved; } fn resolvePathEntryCandidateAlloc( @@ -116,19 +109,6 @@ fn windowsCodexCandidateName(kind: WindowsCodexPathKind) []const u8 { }; } -fn windowsCodexPathExtKind(ext: []const u8) ?WindowsCodexPathKind { - if (std.ascii.eqlIgnoreCase(ext, ".exe")) return .exe; - if (std.ascii.eqlIgnoreCase(ext, ".cmd")) return .cmd; - return null; -} - -fn resolveWindowsCodexPathExtAlloc(allocator: std.mem.Allocator) ![]u8 { - return http_env.getEnvVarOwned(allocator, "PATHEXT") catch |err| switch (err) { - error.EnvironmentVariableNotFound => try allocator.dupe(u8, ".EXE;.CMD"), - else => return err, - }; -} - fn appendWindowsCodexPathCandidateIfAvailable( allocator: std.mem.Allocator, candidates: *WindowsCodexPathList, @@ -142,36 +122,12 @@ fn appendWindowsCodexPathCandidateIfAvailable( fn appendWindowsCodexPathEntryCandidatesAlloc( allocator: std.mem.Allocator, - native_candidates: *WindowsCodexPathList, - ps1_candidates: *WindowsCodexPathList, + candidates: *WindowsCodexPathList, entry: []const u8, - path_ext: []const u8, - allow_ps1: bool, ) !void { - var seen_exe = false; - var seen_cmd = false; - - var ext_it = std.mem.splitScalar(u8, path_ext, ';'); - while (ext_it.next()) |raw_ext| { - const ext = std.mem.trim(u8, raw_ext, " \t"); - const kind = windowsCodexPathExtKind(ext) orelse continue; - switch (kind) { - .exe => { - if (seen_exe) continue; - seen_exe = true; - }, - .cmd => { - if (seen_cmd) continue; - seen_cmd = true; - }, - .ps1 => unreachable, - } - try appendWindowsCodexPathCandidateIfAvailable(allocator, native_candidates, entry, kind); - } - - if (allow_ps1) { - try appendWindowsCodexPathCandidateIfAvailable(allocator, ps1_candidates, entry, .ps1); - } + try appendWindowsCodexPathCandidateIfAvailable(allocator, candidates, entry, .exe); + try appendWindowsCodexPathCandidateIfAvailable(allocator, candidates, entry, .cmd); + try appendWindowsCodexPathCandidateIfAvailable(allocator, candidates, entry, .ps1); } fn deinitWindowsCodexPathList(allocator: std.mem.Allocator, candidates: *WindowsCodexPathList) void { @@ -179,59 +135,19 @@ fn deinitWindowsCodexPathList(allocator: std.mem.Allocator, candidates: *Windows candidates.deinit(allocator); } -fn appendWindowsCodexPathLists( - allocator: std.mem.Allocator, - dst: *WindowsCodexPathList, - src: *WindowsCodexPathList, -) !void { - try dst.appendSlice(allocator, src.items); - src.clearRetainingCapacity(); -} - -fn collectWindowsCodexPathEntriesWithPathExtAlloc( +fn collectWindowsCodexPathEntriesAlloc( allocator: std.mem.Allocator, entries: []const []const u8, - path_ext: []const u8, ) !WindowsCodexPathList { - var native_candidates: WindowsCodexPathList = .empty; - errdefer deinitWindowsCodexPathList(allocator, &native_candidates); - var ps1_candidates: WindowsCodexPathList = .empty; - errdefer deinitWindowsCodexPathList(allocator, &ps1_candidates); + var candidates: WindowsCodexPathList = .empty; + errdefer deinitWindowsCodexPathList(allocator, &candidates); for (entries) |entry| { if (entry.len == 0) continue; - try appendWindowsCodexPathEntryCandidatesAlloc( - allocator, - &native_candidates, - &ps1_candidates, - entry, - path_ext, - true, - ); + try appendWindowsCodexPathEntryCandidatesAlloc(allocator, &candidates, entry); } - try appendWindowsCodexPathLists(allocator, &native_candidates, &ps1_candidates); - ps1_candidates.deinit(allocator); - return native_candidates; -} - -fn resolveWindowsCodexPathEntryWithPathExtAlloc( - allocator: std.mem.Allocator, - entry: []const u8, - path_ext: []const u8, -) !?WindowsCodexPath { - var candidates = try collectWindowsCodexPathEntriesWithPathExtAlloc( - allocator, - &[_][]const u8{entry}, - path_ext, - ); - errdefer deinitWindowsCodexPathList(allocator, &candidates); - - if (candidates.items.len == 0) return null; - - const resolved = candidates.orderedRemove(0); - deinitWindowsCodexPathList(allocator, &candidates); - return resolved; + return candidates; } fn accessPath(path: []const u8) bool { @@ -247,62 +163,27 @@ fn accessPath(path: []const u8) bool { fn resolveWindowsCodexPathValueAlloc( allocator: std.mem.Allocator, path_value: []const u8, - path_ext: []const u8, - native_candidates: *WindowsCodexPathList, - ps1_candidates: *WindowsCodexPathList, + candidates: *WindowsCodexPathList, ) !void { var path_it = std.mem.splitScalar(u8, path_value, std.fs.path.delimiter); while (path_it.next()) |entry| { if (entry.len == 0) continue; - try appendWindowsCodexPathEntryCandidatesAlloc( - allocator, - native_candidates, - ps1_candidates, - entry, - path_ext, - true, - ); + try appendWindowsCodexPathEntryCandidatesAlloc(allocator, candidates, entry); } } fn collectWindowsCodexPathsAlloc(allocator: std.mem.Allocator) !WindowsCodexPathList { - const path_ext = try resolveWindowsCodexPathExtAlloc(allocator); - defer allocator.free(path_ext); - - var native_candidates: WindowsCodexPathList = .empty; - errdefer deinitWindowsCodexPathList(allocator, &native_candidates); - var ps1_candidates: WindowsCodexPathList = .empty; - errdefer deinitWindowsCodexPathList(allocator, &ps1_candidates); - - try appendWindowsCodexPathEntryCandidatesAlloc( - allocator, - &native_candidates, - &ps1_candidates, - ".", - path_ext, - false, - ); + var candidates: WindowsCodexPathList = .empty; + errdefer deinitWindowsCodexPathList(allocator, &candidates); const path_value = http_env.getEnvVarOwned(allocator, "PATH") catch |err| switch (err) { - error.EnvironmentVariableNotFound => { - try appendWindowsCodexPathLists(allocator, &native_candidates, &ps1_candidates); - ps1_candidates.deinit(allocator); - return native_candidates; - }, + error.EnvironmentVariableNotFound => return candidates, else => return err, }; defer allocator.free(path_value); - try resolveWindowsCodexPathValueAlloc( - allocator, - path_value, - path_ext, - &native_candidates, - &ps1_candidates, - ); - try appendWindowsCodexPathLists(allocator, &native_candidates, &ps1_candidates); - ps1_candidates.deinit(allocator); - return native_candidates; + try resolveWindowsCodexPathValueAlloc(allocator, path_value, &candidates); + return candidates; } fn resolveOptionalExecutableAlloc( diff --git a/tests/cli_behavior_test.zig b/tests/cli_behavior_test.zig index e236253..53a329c 100644 --- a/tests/cli_behavior_test.zig +++ b/tests/cli_behavior_test.zig @@ -880,7 +880,7 @@ test "Scenario: Given winget and npm Windows launchers when resolving then PATH try std.testing.expect(std.mem.endsWith(u8, cmd_first.path, "codex.cmd")); } -test "Scenario: Given both exe and cmd in one Windows directory when resolving then PATHEXT order decides which one wins" { +test "Scenario: Given exe cmd and ps1 in one Windows directory when resolving then the fixed launcher priority wins" { const gpa = std.testing.allocator; var tmp = fs.tmpDir(.{}); defer tmp.cleanup(); @@ -894,15 +894,10 @@ test "Scenario: Given both exe and cmd in one Windows directory when resolving t const mixed_dir = try std.fs.path.join(gpa, &[_][]const u8{ root_dir, "mixed-bin" }); defer gpa.free(mixed_dir); - var cmd_first = (try cli.login.resolveWindowsCodexPathEntriesWithPathExtAlloc(gpa, &[_][]const u8{mixed_dir}, ".CMD;.EXE")) orelse return error.TestUnexpectedResult; - defer cmd_first.deinit(gpa); - try std.testing.expectEqual(cli.login.WindowsCodexPathKind.cmd, cmd_first.kind); - try std.testing.expect(std.mem.endsWith(u8, cmd_first.path, "codex.cmd")); - - var exe_first = (try cli.login.resolveWindowsCodexPathEntriesWithPathExtAlloc(gpa, &[_][]const u8{mixed_dir}, ".EXE;.CMD")) orelse return error.TestUnexpectedResult; - defer exe_first.deinit(gpa); - try std.testing.expectEqual(cli.login.WindowsCodexPathKind.exe, exe_first.kind); - try std.testing.expect(std.mem.endsWith(u8, exe_first.path, "codex.exe")); + var resolved = (try cli.login.resolveWindowsCodexPathEntriesAlloc(gpa, &[_][]const u8{mixed_dir})) orelse return error.TestUnexpectedResult; + defer resolved.deinit(gpa); + try std.testing.expectEqual(cli.login.WindowsCodexPathKind.exe, resolved.kind); + try std.testing.expect(std.mem.endsWith(u8, resolved.path, "codex.exe")); } test "Scenario: Given only the bare npm shell launcher on Windows when resolving then it is ignored" { @@ -921,7 +916,7 @@ test "Scenario: Given only the bare npm shell launcher on Windows when resolving try std.testing.expect((try cli.login.resolveWindowsCodexPathEntriesAlloc(gpa, &[_][]const u8{npm_dir})) == null); } -test "Scenario: Given an earlier PowerShell launcher and a later native Windows launcher when resolving then ps1 stays a global fallback" { +test "Scenario: Given an earlier PowerShell launcher and a later native Windows launcher when resolving then PATH order still wins" { const gpa = std.testing.allocator; var tmp = fs.tmpDir(.{}); defer tmp.cleanup(); @@ -939,15 +934,11 @@ test "Scenario: Given an earlier PowerShell launcher and a later native Windows const winget_dir = try std.fs.path.join(gpa, &[_][]const u8{ root_dir, "winget-bin" }); defer gpa.free(winget_dir); - var resolved = (try cli.login.resolveWindowsCodexPathEntriesWithPathExtAlloc( - gpa, - &[_][]const u8{ npm_dir, winget_dir }, - ".EXE;.CMD", - )) orelse return error.TestUnexpectedResult; + var resolved = (try cli.login.resolveWindowsCodexPathEntriesAlloc(gpa, &[_][]const u8{ npm_dir, winget_dir })) orelse return error.TestUnexpectedResult; defer resolved.deinit(gpa); - try std.testing.expectEqual(cli.login.WindowsCodexPathKind.exe, resolved.kind); - try std.testing.expect(std.mem.endsWith(u8, resolved.path, "codex.exe")); + try std.testing.expectEqual(cli.login.WindowsCodexPathKind.ps1, resolved.kind); + try std.testing.expect(std.mem.endsWith(u8, resolved.path, "codex.ps1")); } test "Scenario: Given only PowerShell Windows launcher when resolving then ps1 is used after cmd is absent" { diff --git a/tests/cli_integration_test.zig b/tests/cli_integration_test.zig index 2dd0659..f19f9bc 100644 --- a/tests/cli_integration_test.zig +++ b/tests/cli_integration_test.zig @@ -142,6 +142,10 @@ fn fakeCodexPowerShellPath() []const u8 { return "fake-bin/codex.ps1"; } +fn fakeCodexExePath() []const u8 { + return "fake-bin/codex.exe"; +} + fn fakeBareWindowsCodexPath() []const u8 { return "fake-bin/codex"; } @@ -248,6 +252,20 @@ fn writeSuccessfulFakeCodexPowerShell(dir: fs.Dir) !void { try dir.writeFile(.{ .sub_path = fakeCodexPowerShellPath(), .data = script }); } +fn writeSuccessfulFakeCodexExe( + allocator: std.mem.Allocator, + dir: fs.Dir, + project_root: []const u8, +) !void { + if (builtin.os.tag != .windows) return; + + const built_fake_codex = try builtFakeCodexPathAlloc(allocator, project_root); + defer allocator.free(built_fake_codex); + const fake_codex_data = try fixtures.readFileAlloc(allocator, built_fake_codex); + defer allocator.free(fake_codex_data); + try dir.writeFile(.{ .sub_path = fakeCodexExePath(), .data = fake_codex_data }); +} + fn fakeCurlCommandPath() []const u8 { return if (builtin.os.tag == .windows) "fake-curl-bin/curl.exe" else "fake-curl-bin/curl"; } @@ -331,6 +349,18 @@ fn builtFakeCurlFailPathAlloc(allocator: std.mem.Allocator, project_root: []cons return fs.path.join(allocator, &[_][]const u8{ prefix, "bin", exe_name }); } +fn builtFakeCodexPathAlloc(allocator: std.mem.Allocator, project_root: []const u8) ![]u8 { + const exe_name = if (builtin.os.tag == .windows) "fake-codex.exe" else "fake-codex"; + const install_prefix = getEnvVarOwned(allocator, cli_integration_install_prefix_env) catch |err| switch (err) { + error.EnvironmentVariableNotFound => null, + else => return err, + }; + defer if (install_prefix) |dir| allocator.free(dir); + + const prefix = install_prefix orelse return fs.path.join(allocator, &[_][]const u8{ project_root, "zig-out", "bin", exe_name }); + return fs.path.join(allocator, &[_][]const u8{ prefix, "bin", exe_name }); +} + fn prependPathEntryAlloc(allocator: std.mem.Allocator, entry: []const u8) ![]u8 { var env_map = try getEnvMap(allocator); defer env_map.deinit(); @@ -1004,6 +1034,115 @@ test "Scenario: Given only a PowerShell Windows codex wrapper when running login try std.testing.expectEqualStrings("ps1", std.mem.trim(u8, launcher_data, " \r\n")); } +test "Scenario: Given a winget-style Windows codex launcher when running login then codex.exe is launched" { + if (builtin.os.tag != .windows) return error.SkipZigTest; + + const gpa = std.testing.allocator; + const project_root = try projectRootAlloc(gpa); + defer gpa.free(project_root); + try buildCliBinary(gpa, project_root); + + var tmp = fs.tmpDir(.{}); + defer tmp.cleanup(); + + const home_root = try tmp.dir.realpathAlloc(gpa, "."); + defer gpa.free(home_root); + try tmp.dir.makePath(".codex"); + try tmp.dir.makePath("fake-bin"); + + const expected_email = "windows-exe@example.com"; + const fake_auth = try fixtures.authJsonWithEmailPlan(gpa, expected_email, "plus"); + defer gpa.free(fake_auth); + try tmp.dir.writeFile(.{ .sub_path = "fake-auth.json", .data = fake_auth }); + try writeSuccessfulFakeCodexExe(gpa, tmp.dir, project_root); + + const fake_bin_path = try fs.path.join(gpa, &[_][]const u8{ home_root, "fake-bin" }); + defer gpa.free(fake_bin_path); + const path_override = try prependPathEntryAlloc(gpa, fake_bin_path); + defer gpa.free(path_override); + + const result = try runCliWithIsolatedHomeAndPath( + gpa, + project_root, + home_root, + path_override, + &[_][]const u8{ "login", "--device-auth" }, + ); + defer gpa.free(result.stdout); + defer gpa.free(result.stderr); + + try expectSuccess(result); + + const launcher_path = try fs.path.join(gpa, &[_][]const u8{ home_root, "fake-codex-launcher.txt" }); + defer gpa.free(launcher_path); + const launcher_data = try fixtures.readFileAlloc(gpa, launcher_path); + defer gpa.free(launcher_data); + try std.testing.expectEqualStrings("exe", std.mem.trim(u8, launcher_data, " \r\n")); +} + +test "Scenario: Given an earlier PowerShell launcher and a later exe launcher when running login then PATH order still picks ps1" { + if (builtin.os.tag != .windows) return error.SkipZigTest; + + const gpa = std.testing.allocator; + const project_root = try projectRootAlloc(gpa); + defer gpa.free(project_root); + try buildCliBinary(gpa, project_root); + + var tmp = fs.tmpDir(.{}); + defer tmp.cleanup(); + + const home_root = try tmp.dir.realpathAlloc(gpa, "."); + defer gpa.free(home_root); + try tmp.dir.makePath(".codex"); + try tmp.dir.makePath("ps1-bin"); + try tmp.dir.makePath("exe-bin"); + + const expected_email = "windows-ps1-first@example.com"; + const fake_auth = try fixtures.authJsonWithEmailPlan(gpa, expected_email, "plus"); + defer gpa.free(fake_auth); + try tmp.dir.writeFile(.{ .sub_path = "fake-auth.json", .data = fake_auth }); + try tmp.dir.writeFile(.{ .sub_path = "ps1-bin/codex", .data = "#!/bin/sh\nexit 99\n" }); + + const ps1_dir = try tmp.dir.openDir("ps1-bin", .{}); + defer ps1_dir.close(); + try writeSuccessfulFakeCodexPowerShell(ps1_dir); + + const exe_dir = try tmp.dir.openDir("exe-bin", .{}); + defer exe_dir.close(); + try writeSuccessfulFakeCodexExe(gpa, exe_dir, project_root); + + const ps1_bin_path = try fs.path.join(gpa, &[_][]const u8{ home_root, "ps1-bin" }); + defer gpa.free(ps1_bin_path); + const exe_bin_path = try fs.path.join(gpa, &[_][]const u8{ home_root, "exe-bin" }); + defer gpa.free(exe_bin_path); + const exe_then_inherited_path = try prependPathEntryAlloc(gpa, exe_bin_path); + defer gpa.free(exe_then_inherited_path); + const path_override = try std.fmt.allocPrint(gpa, "{s}{c}{s}", .{ + ps1_bin_path, + fs.path.delimiter, + exe_then_inherited_path, + }); + defer gpa.free(path_override); + + const result = try runCliWithIsolatedHomeAndPath( + gpa, + project_root, + home_root, + path_override, + &[_][]const u8{ "login", "--device-auth" }, + ); + defer gpa.free(result.stdout); + defer gpa.free(result.stderr); + + try expectSuccess(result); + + const launcher_path = try fs.path.join(gpa, &[_][]const u8{ home_root, "fake-codex-launcher.txt" }); + defer gpa.free(launcher_path); + const launcher_data = try fixtures.readFileAlloc(gpa, launcher_path); + defer gpa.free(launcher_data); + try std.testing.expectEqualStrings("ps1", std.mem.trim(u8, launcher_data, " \r\n")); +} + test "Scenario: Given refreshed active auth before login when running login then old account snapshot is synced first" { const gpa = std.testing.allocator; const project_root = try projectRootAlloc(gpa); diff --git a/tests/fake_codex.zig b/tests/fake_codex.zig new file mode 100644 index 0000000..94ee98e --- /dev/null +++ b/tests/fake_codex.zig @@ -0,0 +1,46 @@ +const std = @import("std"); +const app_runtime = @import("app_runtime"); + +fn getEnvVarOwned(allocator: std.mem.Allocator, name: []const u8) ![]u8 { + var env_map = try app_runtime.currentEnviron().createMap(allocator); + defer env_map.deinit(); + + const value = env_map.get(name) orelse return error.EnvironmentVariableNotFound; + return try allocator.dupe(u8, value); +} + +pub fn main(init: std.process.Init) !void { + const io = init.io; + const arena = init.arena.allocator(); + const args = try init.minimal.args.toSlice(arena); + + const home_root = try getEnvVarOwned(arena, "HOME"); + const codex_home = getEnvVarOwned(arena, "CODEX_HOME") catch |err| switch (err) { + error.EnvironmentVariableNotFound => try std.fs.path.join(arena, &[_][]const u8{ home_root, ".codex" }), + else => return err, + }; + + var home_dir = try std.Io.Dir.openDirAbsolute(io, home_root, .{}); + defer home_dir.close(io); + + var argv_buf = std.ArrayList(u8).empty; + defer argv_buf.deinit(arena); + for (args[1..], 0..) |arg, i| { + if (i != 0) try argv_buf.append(arena, ' '); + try argv_buf.appendSlice(arena, arg); + } + try argv_buf.append(arena, '\n'); + + try home_dir.writeFile(io, .{ .sub_path = "fake-codex-launcher.txt", .data = "exe\n" }); + try home_dir.writeFile(io, .{ .sub_path = "fake-codex-argv.txt", .data = argv_buf.items }); + + const codex_home_with_newline = try std.mem.concat(arena, u8, &[_][]const u8{ codex_home, "\n" }); + try home_dir.writeFile(io, .{ .sub_path = "fake-codex-home.txt", .data = codex_home_with_newline }); + + try std.Io.Dir.cwd().createDirPath(io, codex_home); + var codex_home_dir = try std.Io.Dir.openDirAbsolute(io, codex_home, .{}); + defer codex_home_dir.close(io); + + const auth_data = try home_dir.readFileAlloc(io, "fake-auth.json", arena, .limited(1024 * 1024)); + try codex_home_dir.writeFile(io, .{ .sub_path = "auth.json", .data = auth_data }); +} From 577bb9f3a02d7a187e6373383e0d1424115c1566 Mon Sep 17 00:00:00 2001 From: Loongphy Date: Sat, 6 Jun 2026 18:12:54 +0800 Subject: [PATCH 13/20] fix(login): restore native Windows cmd launch --- src/cli/login.zig | 33 +-------------------------------- src/cli/output.zig | 4 ---- tests/cli_behavior_test.zig | 13 ------------- 3 files changed, 1 insertion(+), 49 deletions(-) diff --git a/src/cli/login.zig b/src/cli/login.zig index e64b4e6..fd86ae5 100644 --- a/src/cli/login.zig +++ b/src/cli/login.zig @@ -41,7 +41,6 @@ const CodexLaunch = struct { const WindowsCodexPathList = std.ArrayList(WindowsCodexPath); pub const RetryableWindowsCodexBuildError = enum { - command_processor_not_found, powershell_not_found, }; @@ -196,13 +195,6 @@ fn resolveOptionalExecutableAlloc( }; } -fn resolveWindowsCmdExecutableAlloc(allocator: std.mem.Allocator) ![]u8 { - return http_executable.ensureExecutableAvailableAlloc(allocator, "cmd.exe") catch |err| switch (err) { - error.ExecutableRequired => error.CommandProcessorNotFound, - else => err, - }; -} - fn resolveWindowsPowerShellExecutableAlloc(allocator: std.mem.Allocator) ![]u8 { if (try resolveOptionalExecutableAlloc(allocator, "powershell.exe")) |path| return path; if (try resolveOptionalExecutableAlloc(allocator, "pwsh.exe")) |path| return path; @@ -224,7 +216,7 @@ fn buildWindowsCodexLaunchAlloc( opts: types.LoginOptions, ) !CodexLaunch { switch (resolved.kind) { - .exe => { + .exe, .cmd => { var launch = CodexLaunch{}; launch.argv_storage[0] = resolved.path; launch.argv_storage[1] = "login"; @@ -235,23 +227,6 @@ fn buildWindowsCodexLaunchAlloc( } return launch; }, - .cmd => { - const cmd_exe = try resolveWindowsCmdExecutableAlloc(allocator); - errdefer allocator.free(cmd_exe); - - var launch = CodexLaunch{ .owned_paths = .{cmd_exe} }; - launch.argv_storage[0] = cmd_exe; - launch.argv_storage[1] = "/D"; - launch.argv_storage[2] = "/C"; - launch.argv_storage[3] = resolved.path; - launch.argv_storage[4] = "login"; - launch.argv_len = 5; - if (opts.device_auth) { - launch.argv_storage[5] = "--device-auth"; - launch.argv_len = 6; - } - return launch; - }, .ps1 => { const powershell = try resolveWindowsPowerShellExecutableAlloc(allocator); errdefer allocator.free(powershell); @@ -293,24 +268,18 @@ fn writeCodexLoginLaunchFailureHint(err_name: []const u8) !void { fn retryableWindowsCodexBuildErrorName(err: RetryableWindowsCodexBuildError) []const u8 { return switch (err) { - .command_processor_not_found => "CommandProcessorNotFound", .powershell_not_found => "PowerShellNotFound", }; } fn retryableWindowsCodexBuildErrorValue(err: RetryableWindowsCodexBuildError) anyerror { return switch (err) { - .command_processor_not_found => error.CommandProcessorNotFound, .powershell_not_found => error.PowerShellNotFound, }; } fn shouldRetryWindowsCodexBuild(err: anyerror, kind: WindowsCodexPathKind) ?RetryableWindowsCodexBuildError { return switch (err) { - error.CommandProcessorNotFound => switch (kind) { - .cmd => .command_processor_not_found, - else => null, - }, error.PowerShellNotFound => switch (kind) { .ps1 => .powershell_not_found, else => null, diff --git a/src/cli/output.zig b/src/cli/output.zig index e6be2a5..0158d80 100644 --- a/src/cli/output.zig +++ b/src/cli/output.zig @@ -457,10 +457,6 @@ pub fn writeCodexLoginLaunchFailureHintTo(out: *std.Io.Writer, err_name: []const try writeHintPrefixTo(out, use_color); try out.writeAll(" Ensure the Codex CLI is installed and available in your environment.\n"); try out.writeAll(" Then run `codex login` manually and retry your command.\n"); - } else if (std.mem.eql(u8, err_name, "CommandProcessorNotFound")) { - try out.writeAll(" the `codex.cmd` launcher requires `cmd.exe`, but it was not found in your PATH.\n\n"); - try writeHintPrefixTo(out, use_color); - try out.writeAll(" Restore `cmd.exe` to your PATH, or use a Codex CLI installation that provides `codex.exe`, then retry your command.\n"); } else if (std.mem.eql(u8, err_name, "PowerShellNotFound")) { try out.writeAll(" the `codex.ps1` launcher requires PowerShell, but neither `powershell.exe` nor `pwsh.exe` was found in your PATH.\n\n"); try writeHintPrefixTo(out, use_color); diff --git a/tests/cli_behavior_test.zig b/tests/cli_behavior_test.zig index 53a329c..cba6706 100644 --- a/tests/cli_behavior_test.zig +++ b/tests/cli_behavior_test.zig @@ -821,19 +821,6 @@ test "Scenario: Given PowerShell is missing for the codex ps1 launcher when rend try std.testing.expect(std.mem.indexOf(u8, hint, "the `codex` executable was not found in your PATH.") == null); } -test "Scenario: Given cmd is missing for the codex cmd launcher when rendering then the hint names cmd.exe" { - const gpa = std.testing.allocator; - var aw: std.Io.Writer.Allocating = .init(gpa); - defer aw.deinit(); - - try cli.output.writeCodexLoginLaunchFailureHintTo(&aw.writer, "CommandProcessorNotFound", false); - - const hint = aw.written(); - try std.testing.expect(std.mem.indexOf(u8, hint, "the `codex.cmd` launcher requires `cmd.exe`") != null); - try std.testing.expect(std.mem.indexOf(u8, hint, "Restore `cmd.exe` to your PATH, or use a Codex CLI installation that provides `codex.exe`, then retry your command.") != null); - try std.testing.expect(std.mem.indexOf(u8, hint, "failed to launch the `codex login` process.") == null); -} - test "Scenario: Given login help when rendering then device auth usage is included" { const gpa = std.testing.allocator; var aw: std.Io.Writer.Allocating = .init(gpa); From d59c16db0da6e626a619c0303e3ee571aa1d7577 Mon Sep 17 00:00:00 2001 From: Loongphy Date: Sat, 6 Jun 2026 18:22:19 +0800 Subject: [PATCH 14/20] fix(login): keep PowerShell as Windows fallback --- src/cli/login.zig | 53 ++++++++++++++++++++++++---------- src/workflows/preflight.zig | 1 + tests/cli_behavior_test.zig | 6 ++-- tests/cli_integration_test.zig | 4 +-- tests/workflows_live_test.zig | 4 +++ 5 files changed, 48 insertions(+), 20 deletions(-) diff --git a/src/cli/login.zig b/src/cli/login.zig index fd86ae5..5d318ff 100644 --- a/src/cli/login.zig +++ b/src/cli/login.zig @@ -121,12 +121,13 @@ fn appendWindowsCodexPathCandidateIfAvailable( fn appendWindowsCodexPathEntryCandidatesAlloc( allocator: std.mem.Allocator, - candidates: *WindowsCodexPathList, + native_candidates: *WindowsCodexPathList, + ps1_candidates: *WindowsCodexPathList, entry: []const u8, ) !void { - try appendWindowsCodexPathCandidateIfAvailable(allocator, candidates, entry, .exe); - try appendWindowsCodexPathCandidateIfAvailable(allocator, candidates, entry, .cmd); - try appendWindowsCodexPathCandidateIfAvailable(allocator, candidates, entry, .ps1); + try appendWindowsCodexPathCandidateIfAvailable(allocator, native_candidates, entry, .exe); + try appendWindowsCodexPathCandidateIfAvailable(allocator, native_candidates, entry, .cmd); + try appendWindowsCodexPathCandidateIfAvailable(allocator, ps1_candidates, entry, .ps1); } fn deinitWindowsCodexPathList(allocator: std.mem.Allocator, candidates: *WindowsCodexPathList) void { @@ -134,19 +135,32 @@ fn deinitWindowsCodexPathList(allocator: std.mem.Allocator, candidates: *Windows candidates.deinit(allocator); } +fn appendWindowsCodexPathLists( + allocator: std.mem.Allocator, + dst: *WindowsCodexPathList, + src: *WindowsCodexPathList, +) !void { + try dst.appendSlice(allocator, src.items); + src.clearRetainingCapacity(); +} + fn collectWindowsCodexPathEntriesAlloc( allocator: std.mem.Allocator, entries: []const []const u8, ) !WindowsCodexPathList { - var candidates: WindowsCodexPathList = .empty; - errdefer deinitWindowsCodexPathList(allocator, &candidates); + var native_candidates: WindowsCodexPathList = .empty; + errdefer deinitWindowsCodexPathList(allocator, &native_candidates); + var ps1_candidates: WindowsCodexPathList = .empty; + errdefer deinitWindowsCodexPathList(allocator, &ps1_candidates); for (entries) |entry| { if (entry.len == 0) continue; - try appendWindowsCodexPathEntryCandidatesAlloc(allocator, &candidates, entry); + try appendWindowsCodexPathEntryCandidatesAlloc(allocator, &native_candidates, &ps1_candidates, entry); } - return candidates; + try appendWindowsCodexPathLists(allocator, &native_candidates, &ps1_candidates); + ps1_candidates.deinit(allocator); + return native_candidates; } fn accessPath(path: []const u8) bool { @@ -162,27 +176,36 @@ fn accessPath(path: []const u8) bool { fn resolveWindowsCodexPathValueAlloc( allocator: std.mem.Allocator, path_value: []const u8, - candidates: *WindowsCodexPathList, + native_candidates: *WindowsCodexPathList, + ps1_candidates: *WindowsCodexPathList, ) !void { var path_it = std.mem.splitScalar(u8, path_value, std.fs.path.delimiter); while (path_it.next()) |entry| { if (entry.len == 0) continue; - try appendWindowsCodexPathEntryCandidatesAlloc(allocator, candidates, entry); + try appendWindowsCodexPathEntryCandidatesAlloc(allocator, native_candidates, ps1_candidates, entry); } } fn collectWindowsCodexPathsAlloc(allocator: std.mem.Allocator) !WindowsCodexPathList { - var candidates: WindowsCodexPathList = .empty; - errdefer deinitWindowsCodexPathList(allocator, &candidates); + var native_candidates: WindowsCodexPathList = .empty; + errdefer deinitWindowsCodexPathList(allocator, &native_candidates); + var ps1_candidates: WindowsCodexPathList = .empty; + errdefer deinitWindowsCodexPathList(allocator, &ps1_candidates); const path_value = http_env.getEnvVarOwned(allocator, "PATH") catch |err| switch (err) { - error.EnvironmentVariableNotFound => return candidates, + error.EnvironmentVariableNotFound => { + try appendWindowsCodexPathLists(allocator, &native_candidates, &ps1_candidates); + ps1_candidates.deinit(allocator); + return native_candidates; + }, else => return err, }; defer allocator.free(path_value); - try resolveWindowsCodexPathValueAlloc(allocator, path_value, &candidates); - return candidates; + try resolveWindowsCodexPathValueAlloc(allocator, path_value, &native_candidates, &ps1_candidates); + try appendWindowsCodexPathLists(allocator, &native_candidates, &ps1_candidates); + ps1_candidates.deinit(allocator); + return native_candidates; } fn resolveOptionalExecutableAlloc( diff --git a/src/workflows/preflight.zig b/src/workflows/preflight.zig index 05bb89e..f646286 100644 --- a/src/workflows/preflight.zig +++ b/src/workflows/preflight.zig @@ -38,6 +38,7 @@ pub fn isHandledCliError(err: anyerror) bool { err == error.CodexCliPathNotAccessible or err == error.CodexCliPathNotFile or err == error.AppLaunchFailed or + err == error.PowerShellNotFound or err == error.UnsupportedRegistryVersion or err == error.WindowsAppLaunchRequiresWindows or err == error.WindowsAppPlatformRequiresWindows or diff --git a/tests/cli_behavior_test.zig b/tests/cli_behavior_test.zig index cba6706..aa507c2 100644 --- a/tests/cli_behavior_test.zig +++ b/tests/cli_behavior_test.zig @@ -903,7 +903,7 @@ test "Scenario: Given only the bare npm shell launcher on Windows when resolving try std.testing.expect((try cli.login.resolveWindowsCodexPathEntriesAlloc(gpa, &[_][]const u8{npm_dir})) == null); } -test "Scenario: Given an earlier PowerShell launcher and a later native Windows launcher when resolving then PATH order still wins" { +test "Scenario: Given an earlier PowerShell launcher and a later native Windows launcher when resolving then ps1 stays a global fallback" { const gpa = std.testing.allocator; var tmp = fs.tmpDir(.{}); defer tmp.cleanup(); @@ -924,8 +924,8 @@ test "Scenario: Given an earlier PowerShell launcher and a later native Windows var resolved = (try cli.login.resolveWindowsCodexPathEntriesAlloc(gpa, &[_][]const u8{ npm_dir, winget_dir })) orelse return error.TestUnexpectedResult; defer resolved.deinit(gpa); - try std.testing.expectEqual(cli.login.WindowsCodexPathKind.ps1, resolved.kind); - try std.testing.expect(std.mem.endsWith(u8, resolved.path, "codex.ps1")); + try std.testing.expectEqual(cli.login.WindowsCodexPathKind.exe, resolved.kind); + try std.testing.expect(std.mem.endsWith(u8, resolved.path, "codex.exe")); } test "Scenario: Given only PowerShell Windows launcher when resolving then ps1 is used after cmd is absent" { diff --git a/tests/cli_integration_test.zig b/tests/cli_integration_test.zig index f19f9bc..501e021 100644 --- a/tests/cli_integration_test.zig +++ b/tests/cli_integration_test.zig @@ -1080,7 +1080,7 @@ test "Scenario: Given a winget-style Windows codex launcher when running login t try std.testing.expectEqualStrings("exe", std.mem.trim(u8, launcher_data, " \r\n")); } -test "Scenario: Given an earlier PowerShell launcher and a later exe launcher when running login then PATH order still picks ps1" { +test "Scenario: Given an earlier PowerShell launcher and a later exe launcher when running login then ps1 stays a global fallback" { if (builtin.os.tag != .windows) return error.SkipZigTest; const gpa = std.testing.allocator; @@ -1140,7 +1140,7 @@ test "Scenario: Given an earlier PowerShell launcher and a later exe launcher wh defer gpa.free(launcher_path); const launcher_data = try fixtures.readFileAlloc(gpa, launcher_path); defer gpa.free(launcher_data); - try std.testing.expectEqualStrings("ps1", std.mem.trim(u8, launcher_data, " \r\n")); + try std.testing.expectEqualStrings("exe", std.mem.trim(u8, launcher_data, " \r\n")); } test "Scenario: Given refreshed active auth before login when running login then old account snapshot is synced first" { diff --git a/tests/workflows_live_test.zig b/tests/workflows_live_test.zig index 68ec42f..cf72219 100644 --- a/tests/workflows_live_test.zig +++ b/tests/workflows_live_test.zig @@ -34,6 +34,10 @@ test "handled cli errors include missing curl" { try std.testing.expect(isHandledCliError(error.CurlRequired)); } +test "handled cli errors include missing PowerShell for codex ps1 launcher" { + try std.testing.expect(isHandledCliError(error.PowerShellNotFound)); +} + test "curl preflight skips api key only usage refreshes" { const gpa = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); From 8f1d10ff87787a941bebd1595553264f2b6f8fc0 Mon Sep 17 00:00:00 2001 From: Loongphy Date: Sat, 6 Jun 2026 18:33:07 +0800 Subject: [PATCH 15/20] fix(login): retry alternate Windows launchers --- src/cli/login.zig | 119 +++++++++++++++++++++++++++++++++------------- 1 file changed, 86 insertions(+), 33 deletions(-) diff --git a/src/cli/login.zig b/src/cli/login.zig index 5d318ff..f523369 100644 --- a/src/cli/login.zig +++ b/src/cli/login.zig @@ -40,6 +40,11 @@ const CodexLaunch = struct { const WindowsCodexPathList = std.ArrayList(WindowsCodexPath); +const PowerShellHost = enum { + powershell, + pwsh, +}; + pub const RetryableWindowsCodexBuildError = enum { powershell_not_found, }; @@ -224,6 +229,16 @@ fn resolveWindowsPowerShellExecutableAlloc(allocator: std.mem.Allocator) ![]u8 { return error.PowerShellNotFound; } +fn resolveWindowsPowerShellExecutableForHostAlloc( + allocator: std.mem.Allocator, + host: PowerShellHost, +) ![]u8 { + return switch (host) { + .powershell => (try resolveOptionalExecutableAlloc(allocator, "powershell.exe")) orelse error.PowerShellNotFound, + .pwsh => (try resolveOptionalExecutableAlloc(allocator, "pwsh.exe")) orelse error.PowerShellNotFound, + }; +} + fn buildCodexLaunchAlloc(allocator: std.mem.Allocator, opts: types.LoginOptions) !CodexLaunch { _ = allocator; var launch = CodexLaunch{}; @@ -251,26 +266,38 @@ fn buildWindowsCodexLaunchAlloc( return launch; }, .ps1 => { - const powershell = try resolveWindowsPowerShellExecutableAlloc(allocator); - errdefer allocator.free(powershell); - - var launch = CodexLaunch{ .owned_paths = .{powershell} }; - launch.argv_storage[0] = powershell; - launch.argv_storage[1] = "-NoLogo"; - launch.argv_storage[2] = "-NoProfile"; - launch.argv_storage[3] = "-File"; - launch.argv_storage[4] = resolved.path; - launch.argv_storage[5] = "login"; - launch.argv_len = 6; - if (opts.device_auth) { - launch.argv_storage[6] = "--device-auth"; - launch.argv_len = 7; - } - return launch; + return buildWindowsPowerShellCodexLaunchAlloc(allocator, resolved.path, opts, null); }, } } +fn buildWindowsPowerShellCodexLaunchAlloc( + allocator: std.mem.Allocator, + script_path: []const u8, + opts: types.LoginOptions, + preferred_host: ?PowerShellHost, +) !CodexLaunch { + const powershell = if (preferred_host) |host| + try resolveWindowsPowerShellExecutableForHostAlloc(allocator, host) + else + try resolveWindowsPowerShellExecutableAlloc(allocator); + errdefer allocator.free(powershell); + + var launch = CodexLaunch{ .owned_paths = .{powershell} }; + launch.argv_storage[0] = powershell; + launch.argv_storage[1] = "-NoLogo"; + launch.argv_storage[2] = "-NoProfile"; + launch.argv_storage[3] = "-File"; + launch.argv_storage[4] = script_path; + launch.argv_storage[5] = "login"; + launch.argv_len = 6; + if (opts.device_auth) { + launch.argv_storage[6] = "--device-auth"; + launch.argv_len = 7; + } + return launch; +} + fn ensureCodexLoginSucceeded(term: std.process.Child.Term) !void { switch (term) { .exited => |code| { @@ -345,13 +372,17 @@ fn shouldRetryWindowsCodexLaunch(err: std.process.SpawnError, kind: WindowsCodex return switch (err) { error.FileNotFound => true, error.AccessDenied => switch (kind) { - .cmd, .ps1 => true, - .exe => false, + .exe, .cmd, .ps1 => true, }, else => false, }; } +fn launchUsesWindowsPowerShellHost(launch: *const CodexLaunch) bool { + if (launch.argv_len == 0) return false; + return std.ascii.eqlIgnoreCase(std.fs.path.basename(launch.argv_storage[0]), "powershell.exe"); +} + pub fn runCodexLogin(opts: types.LoginOptions, codex_home: []const u8) !void { var env_map = try app_runtime.currentEnviron().createMap(std.heap.page_allocator); defer env_map.deinit(); @@ -368,30 +399,52 @@ pub fn runCodexLogin(opts: types.LoginOptions, codex_home: []const u8) !void { var last_retryable_spawn_error: ?std.process.SpawnError = null; var last_retryable_build_error: ?RetryableWindowsCodexBuildError = null; - for (candidates.items) |*candidate| { + candidate_loop: for (candidates.items) |*candidate| { var launch = buildWindowsCodexLaunchAlloc(std.heap.page_allocator, candidate, opts) catch |err| { if (shouldRetryWindowsCodexBuild(err, candidate.kind)) |retryable_err| { last_retryable_build_error = retryable_err; - continue; + continue :candidate_loop; } writeCodexLoginLaunchFailureHint(@errorName(err)) catch {}; return err; }; - var child = std.process.spawn(app_runtime.io(), .{ - .argv = launch.argv(), - .environ_map = &env_map, - .stdin = .inherit, - .stdout = .inherit, - .stderr = .inherit, - }) catch |err| { - launch.deinit(std.heap.page_allocator); - if (shouldRetryWindowsCodexLaunch(err, candidate.kind)) { - last_retryable_spawn_error = err; - continue; + var child = child: { + spawn_attempt: while (true) { + break :child std.process.spawn(app_runtime.io(), .{ + .argv = launch.argv(), + .environ_map = &env_map, + .stdin = .inherit, + .stdout = .inherit, + .stderr = .inherit, + }) catch |err| { + if (candidate.kind == .ps1 and launchUsesWindowsPowerShellHost(&launch) and err == error.AccessDenied) { + launch.deinit(std.heap.page_allocator); + launch = buildWindowsPowerShellCodexLaunchAlloc( + std.heap.page_allocator, + candidate.path, + opts, + .pwsh, + ) catch |build_err| { + if (shouldRetryWindowsCodexBuild(build_err, candidate.kind)) |retryable_err| { + last_retryable_build_error = retryable_err; + continue :candidate_loop; + } + writeCodexLoginLaunchFailureHint(@errorName(build_err)) catch {}; + return build_err; + }; + continue :spawn_attempt; + } + + launch.deinit(std.heap.page_allocator); + if (shouldRetryWindowsCodexLaunch(err, candidate.kind)) { + last_retryable_spawn_error = err; + continue :candidate_loop; + } + writeCodexLoginLaunchFailureHint(@errorName(err)) catch {}; + return err; + }; } - writeCodexLoginLaunchFailureHint(@errorName(err)) catch {}; - return err; }; launch.deinit(std.heap.page_allocator); From af3d3b10302c7cec828d5810e635e7b571a2d2f2 Mon Sep 17 00:00:00 2001 From: Loongphy Date: Sat, 6 Jun 2026 19:51:12 +0800 Subject: [PATCH 16/20] docs: remove fallback note --- fallback.md | 6 ------ 1 file changed, 6 deletions(-) delete mode 100644 fallback.md diff --git a/fallback.md b/fallback.md deleted file mode 100644 index 2b7ccfd..0000000 --- a/fallback.md +++ /dev/null @@ -1,6 +0,0 @@ -# Fallbacks - -- `src/cli/login.zig`: Windows `codex-auth login` checks `codex.ps1` only after exhausting `codex.exe` and `codex.cmd`. - Reason: real npm installs provide `codex.ps1`, and manual testing confirmed `powershell.exe -File codex.ps1 login --help` works. - Protected callers/data: Windows users whose Codex CLI install exposes only the PowerShell wrapper. - Remove when: supported Windows Codex installs are guaranteed to provide `codex.exe` or `codex.cmd`. From f40257c6036a69c3d6ca7d87f14881d0c8088c8f Mon Sep 17 00:00:00 2001 From: Loongphy Date: Sat, 6 Jun 2026 20:45:01 +0800 Subject: [PATCH 17/20] test(windows): fix launcher fallback fixtures --- tests/cli_integration_test.zig | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/tests/cli_integration_test.zig b/tests/cli_integration_test.zig index 501e021..dcb2228 100644 --- a/tests/cli_integration_test.zig +++ b/tests/cli_integration_test.zig @@ -236,7 +236,7 @@ fn writeBrokenBareWindowsCodex(dir: fs.Dir) !void { }); } -fn writeSuccessfulFakeCodexPowerShell(dir: fs.Dir) !void { +fn writeSuccessfulFakeCodexPowerShellAt(dir: fs.Dir, sub_path: []const u8) !void { if (builtin.os.tag != .windows) return; const script = @@ -249,13 +249,18 @@ fn writeSuccessfulFakeCodexPowerShell(dir: fs.Dir) !void { "if (-not (Test-Path -LiteralPath $codexHomeDir)) { New-Item -ItemType Directory -Path $codexHomeDir | Out-Null }\r\n" ++ "Copy-Item -Force (Join-Path $homePath 'fake-auth.json') (Join-Path $codexHomeDir 'auth.json')\r\n"; - try dir.writeFile(.{ .sub_path = fakeCodexPowerShellPath(), .data = script }); + try dir.writeFile(.{ .sub_path = sub_path, .data = script }); } -fn writeSuccessfulFakeCodexExe( +fn writeSuccessfulFakeCodexPowerShell(dir: fs.Dir) !void { + try writeSuccessfulFakeCodexPowerShellAt(dir, fakeCodexPowerShellPath()); +} + +fn writeSuccessfulFakeCodexExeAt( allocator: std.mem.Allocator, dir: fs.Dir, project_root: []const u8, + sub_path: []const u8, ) !void { if (builtin.os.tag != .windows) return; @@ -263,7 +268,15 @@ fn writeSuccessfulFakeCodexExe( defer allocator.free(built_fake_codex); const fake_codex_data = try fixtures.readFileAlloc(allocator, built_fake_codex); defer allocator.free(fake_codex_data); - try dir.writeFile(.{ .sub_path = fakeCodexExePath(), .data = fake_codex_data }); + try dir.writeFile(.{ .sub_path = sub_path, .data = fake_codex_data }); +} + +fn writeSuccessfulFakeCodexExe( + allocator: std.mem.Allocator, + dir: fs.Dir, + project_root: []const u8, +) !void { + try writeSuccessfulFakeCodexExeAt(allocator, dir, project_root, fakeCodexExePath()); } fn fakeCurlCommandPath() []const u8 { @@ -1103,13 +1116,8 @@ test "Scenario: Given an earlier PowerShell launcher and a later exe launcher wh try tmp.dir.writeFile(.{ .sub_path = "fake-auth.json", .data = fake_auth }); try tmp.dir.writeFile(.{ .sub_path = "ps1-bin/codex", .data = "#!/bin/sh\nexit 99\n" }); - const ps1_dir = try tmp.dir.openDir("ps1-bin", .{}); - defer ps1_dir.close(); - try writeSuccessfulFakeCodexPowerShell(ps1_dir); - - const exe_dir = try tmp.dir.openDir("exe-bin", .{}); - defer exe_dir.close(); - try writeSuccessfulFakeCodexExe(gpa, exe_dir, project_root); + try writeSuccessfulFakeCodexPowerShellAt(tmp.dir, "ps1-bin/codex.ps1"); + try writeSuccessfulFakeCodexExeAt(gpa, tmp.dir, project_root, "exe-bin/codex.exe"); const ps1_bin_path = try fs.path.join(gpa, &[_][]const u8{ home_root, "ps1-bin" }); defer gpa.free(ps1_bin_path); From 0617390e0f196232349f1b3e1bb089dc5881f0d3 Mon Sep 17 00:00:00 2001 From: Loongphy Date: Sun, 7 Jun 2026 12:30:37 +0800 Subject: [PATCH 18/20] fix(login): preserve PowerShell access denied fallback error --- src/cli/login.zig | 1 + 1 file changed, 1 insertion(+) diff --git a/src/cli/login.zig b/src/cli/login.zig index f523369..9e7e0d6 100644 --- a/src/cli/login.zig +++ b/src/cli/login.zig @@ -419,6 +419,7 @@ pub fn runCodexLogin(opts: types.LoginOptions, codex_home: []const u8) !void { .stderr = .inherit, }) catch |err| { if (candidate.kind == .ps1 and launchUsesWindowsPowerShellHost(&launch) and err == error.AccessDenied) { + last_retryable_spawn_error = err; launch.deinit(std.heap.page_allocator); launch = buildWindowsPowerShellCodexLaunchAlloc( std.heap.page_allocator, From e3ade547791723fed94ba3dfe5e1949d4d408531 Mon Sep 17 00:00:00 2001 From: Loongphy Date: Sun, 7 Jun 2026 12:39:00 +0800 Subject: [PATCH 19/20] fix(login): support Windows batch codex launchers --- src/cli/login.zig | 7 +++- src/cli/output.zig | 2 +- tests/cli_behavior_test.zig | 27 +++++++++++++- tests/cli_integration_test.zig | 68 ++++++++++++++++++++++++++++++++++ 4 files changed, 99 insertions(+), 5 deletions(-) diff --git a/src/cli/login.zig b/src/cli/login.zig index 9e7e0d6..a769706 100644 --- a/src/cli/login.zig +++ b/src/cli/login.zig @@ -10,6 +10,7 @@ const output = @import("output.zig"); pub const WindowsCodexPathKind = enum { exe, cmd, + bat, ps1, }; @@ -109,6 +110,7 @@ fn windowsCodexCandidateName(kind: WindowsCodexPathKind) []const u8 { return switch (kind) { .exe => "codex.exe", .cmd => "codex.cmd", + .bat => "codex.bat", .ps1 => "codex.ps1", }; } @@ -132,6 +134,7 @@ fn appendWindowsCodexPathEntryCandidatesAlloc( ) !void { try appendWindowsCodexPathCandidateIfAvailable(allocator, native_candidates, entry, .exe); try appendWindowsCodexPathCandidateIfAvailable(allocator, native_candidates, entry, .cmd); + try appendWindowsCodexPathCandidateIfAvailable(allocator, native_candidates, entry, .bat); try appendWindowsCodexPathCandidateIfAvailable(allocator, ps1_candidates, entry, .ps1); } @@ -254,7 +257,7 @@ fn buildWindowsCodexLaunchAlloc( opts: types.LoginOptions, ) !CodexLaunch { switch (resolved.kind) { - .exe, .cmd => { + .exe, .cmd, .bat => { var launch = CodexLaunch{}; launch.argv_storage[0] = resolved.path; launch.argv_storage[1] = "login"; @@ -372,7 +375,7 @@ fn shouldRetryWindowsCodexLaunch(err: std.process.SpawnError, kind: WindowsCodex return switch (err) { error.FileNotFound => true, error.AccessDenied => switch (kind) { - .exe, .cmd, .ps1 => true, + .exe, .cmd, .bat, .ps1 => true, }, else => false, }; diff --git a/src/cli/output.zig b/src/cli/output.zig index 0158d80..df5054c 100644 --- a/src/cli/output.zig +++ b/src/cli/output.zig @@ -460,7 +460,7 @@ pub fn writeCodexLoginLaunchFailureHintTo(out: *std.Io.Writer, err_name: []const } else if (std.mem.eql(u8, err_name, "PowerShellNotFound")) { try out.writeAll(" the `codex.ps1` launcher requires PowerShell, but neither `powershell.exe` nor `pwsh.exe` was found in your PATH.\n\n"); try writeHintPrefixTo(out, use_color); - try out.writeAll(" Install PowerShell, or use a Codex CLI installation that provides `codex.exe` or `codex.cmd`, then retry your command.\n"); + try out.writeAll(" Install PowerShell, or use a Codex CLI installation that provides `codex.exe`, `codex.cmd`, or `codex.bat`, then retry your command.\n"); } else { try out.writeAll(" failed to launch the `codex login` process.\n\n"); try writeHintPrefixTo(out, use_color); diff --git a/tests/cli_behavior_test.zig b/tests/cli_behavior_test.zig index aa507c2..b76b4e6 100644 --- a/tests/cli_behavior_test.zig +++ b/tests/cli_behavior_test.zig @@ -817,7 +817,7 @@ test "Scenario: Given PowerShell is missing for the codex ps1 launcher when rend const hint = aw.written(); try std.testing.expect(std.mem.indexOf(u8, hint, "the `codex.ps1` launcher requires PowerShell") != null); - try std.testing.expect(std.mem.indexOf(u8, hint, "Install PowerShell, or use a Codex CLI installation that provides `codex.exe` or `codex.cmd`, then retry your command.") != null); + try std.testing.expect(std.mem.indexOf(u8, hint, "Install PowerShell, or use a Codex CLI installation that provides `codex.exe`, `codex.cmd`, or `codex.bat`, then retry your command.") != null); try std.testing.expect(std.mem.indexOf(u8, hint, "the `codex` executable was not found in your PATH.") == null); } @@ -848,6 +848,7 @@ test "Scenario: Given winget and npm Windows launchers when resolving then PATH try tmp.dir.writeFile(.{ .sub_path = "winget-bin/codex.exe", .data = "" }); try tmp.dir.writeFile(.{ .sub_path = "npm-bin/codex", .data = "#!/bin/sh\nexit 1\n" }); try tmp.dir.writeFile(.{ .sub_path = "npm-bin/codex.cmd", .data = "@echo off\r\nexit /b 0\r\n" }); + try tmp.dir.writeFile(.{ .sub_path = "npm-bin/codex.bat", .data = "@echo off\r\nexit /b 0\r\n" }); try tmp.dir.writeFile(.{ .sub_path = "npm-bin/codex.ps1", .data = "exit 0\n" }); const root_dir = try tmp.dir.realpathAlloc(gpa, "."); defer gpa.free(root_dir); @@ -867,7 +868,7 @@ test "Scenario: Given winget and npm Windows launchers when resolving then PATH try std.testing.expect(std.mem.endsWith(u8, cmd_first.path, "codex.cmd")); } -test "Scenario: Given exe cmd and ps1 in one Windows directory when resolving then the fixed launcher priority wins" { +test "Scenario: Given exe cmd bat and ps1 in one Windows directory when resolving then the fixed launcher priority wins" { const gpa = std.testing.allocator; var tmp = fs.tmpDir(.{}); defer tmp.cleanup(); @@ -875,6 +876,7 @@ test "Scenario: Given exe cmd and ps1 in one Windows directory when resolving th try tmp.dir.makePath("mixed-bin"); try tmp.dir.writeFile(.{ .sub_path = "mixed-bin/codex.exe", .data = "" }); try tmp.dir.writeFile(.{ .sub_path = "mixed-bin/codex.cmd", .data = "@echo off\r\nexit /b 0\r\n" }); + try tmp.dir.writeFile(.{ .sub_path = "mixed-bin/codex.bat", .data = "@echo off\r\nexit /b 0\r\n" }); try tmp.dir.writeFile(.{ .sub_path = "mixed-bin/codex.ps1", .data = "exit 0\n" }); const root_dir = try tmp.dir.realpathAlloc(gpa, "."); defer gpa.free(root_dir); @@ -887,6 +889,27 @@ test "Scenario: Given exe cmd and ps1 in one Windows directory when resolving th try std.testing.expect(std.mem.endsWith(u8, resolved.path, "codex.exe")); } +test "Scenario: Given only a batch Windows launcher when resolving then bat is used before ps1" { + const gpa = std.testing.allocator; + var tmp = fs.tmpDir(.{}); + defer tmp.cleanup(); + + try tmp.dir.makePath("npm-bin"); + try tmp.dir.writeFile(.{ .sub_path = "npm-bin/codex", .data = "#!/bin/sh\nexit 1\n" }); + try tmp.dir.writeFile(.{ .sub_path = "npm-bin/codex.bat", .data = "@echo off\r\nexit /b 0\r\n" }); + try tmp.dir.writeFile(.{ .sub_path = "npm-bin/codex.ps1", .data = "exit 0\n" }); + + const root_dir = try tmp.dir.realpathAlloc(gpa, "."); + defer gpa.free(root_dir); + const npm_dir = try std.fs.path.join(gpa, &[_][]const u8{ root_dir, "npm-bin" }); + defer gpa.free(npm_dir); + + var resolved = (try cli.login.resolveWindowsCodexPathEntriesAlloc(gpa, &[_][]const u8{npm_dir})) orelse return error.TestUnexpectedResult; + defer resolved.deinit(gpa); + try std.testing.expectEqual(cli.login.WindowsCodexPathKind.bat, resolved.kind); + try std.testing.expect(std.mem.endsWith(u8, resolved.path, "codex.bat")); +} + test "Scenario: Given only the bare npm shell launcher on Windows when resolving then it is ignored" { const gpa = std.testing.allocator; var tmp = fs.tmpDir(.{}); diff --git a/tests/cli_integration_test.zig b/tests/cli_integration_test.zig index dcb2228..50af228 100644 --- a/tests/cli_integration_test.zig +++ b/tests/cli_integration_test.zig @@ -142,6 +142,10 @@ fn fakeCodexPowerShellPath() []const u8 { return "fake-bin/codex.ps1"; } +fn fakeCodexBatchPath() []const u8 { + return "fake-bin/codex.bat"; +} + fn fakeCodexExePath() []const u8 { return "fake-bin/codex.exe"; } @@ -228,6 +232,23 @@ fn writeStrictExistingCodexHomeFakeCodex(dir: fs.Dir) !void { } } +fn writeStrictExistingCodexHomeFakeCodexBatch(dir: fs.Dir) !void { + if (builtin.os.tag != .windows) return; + + const script = + "@echo off\r\n" ++ + ">\"%HOME%\\fake-codex-launcher.txt\" echo bat\r\n" ++ + ">\"%HOME%\\fake-codex-argv.txt\" echo %*\r\n" ++ + ">\"%HOME%\\fake-codex-home.txt\" echo %CODEX_HOME%\r\n" ++ + "set \"CODEX_HOME_DIR=%CODEX_HOME%\"\r\n" ++ + "if \"%CODEX_HOME_DIR%\"==\"\" set \"CODEX_HOME_DIR=%HOME%\\.codex\"\r\n" ++ + "if not exist \"%CODEX_HOME_DIR%\" exit /b 42\r\n" ++ + "copy /Y \"%HOME%\\fake-auth.json\" \"%CODEX_HOME_DIR%\\auth.json\" >NUL\r\n" ++ + "exit /b 0\r\n"; + + try dir.writeFile(.{ .sub_path = fakeCodexBatchPath(), .data = script }); +} + fn writeBrokenBareWindowsCodex(dir: fs.Dir) !void { if (builtin.os.tag != .windows) return; try dir.writeFile(.{ @@ -1000,6 +1021,53 @@ test "Scenario: Given npm-style Windows codex wrappers when running login then t try std.testing.expectEqualStrings("cmd", std.mem.trim(u8, launcher_data, " \r\n")); } +test "Scenario: Given only a Windows batch codex wrapper when running login then codex.bat is launched" { + if (builtin.os.tag != .windows) return error.SkipZigTest; + + const gpa = std.testing.allocator; + const project_root = try projectRootAlloc(gpa); + defer gpa.free(project_root); + try buildCliBinary(gpa, project_root); + + var tmp = fs.tmpDir(.{}); + defer tmp.cleanup(); + + const home_root = try tmp.dir.realpathAlloc(gpa, "."); + defer gpa.free(home_root); + try tmp.dir.makePath(".codex"); + try tmp.dir.makePath("fake-bin"); + + const expected_email = "windows-bat@example.com"; + const fake_auth = try fixtures.authJsonWithEmailPlan(gpa, expected_email, "plus"); + defer gpa.free(fake_auth); + try tmp.dir.writeFile(.{ .sub_path = "fake-auth.json", .data = fake_auth }); + try writeBrokenBareWindowsCodex(tmp.dir); + try writeStrictExistingCodexHomeFakeCodexBatch(tmp.dir); + + const fake_bin_path = try fs.path.join(gpa, &[_][]const u8{ home_root, "fake-bin" }); + defer gpa.free(fake_bin_path); + const path_override = try prependPathEntryAlloc(gpa, fake_bin_path); + defer gpa.free(path_override); + + const result = try runCliWithIsolatedHomeAndPath( + gpa, + project_root, + home_root, + path_override, + &[_][]const u8{ "login", "--device-auth" }, + ); + defer gpa.free(result.stdout); + defer gpa.free(result.stderr); + + try expectSuccess(result); + + const launcher_path = try fs.path.join(gpa, &[_][]const u8{ home_root, "fake-codex-launcher.txt" }); + defer gpa.free(launcher_path); + const launcher_data = try fixtures.readFileAlloc(gpa, launcher_path); + defer gpa.free(launcher_data); + try std.testing.expectEqualStrings("bat", std.mem.trim(u8, launcher_data, " \r\n")); +} + test "Scenario: Given only a PowerShell Windows codex wrapper when running login then codex.ps1 is launched" { if (builtin.os.tag != .windows) return error.SkipZigTest; From b7995d34099ecab54398ee31b88524ee9ac47564 Mon Sep 17 00:00:00 2001 From: Loongphy Date: Sun, 7 Jun 2026 12:39:53 +0800 Subject: [PATCH 20/20] fix(login): bypass PowerShell script policy for ps1 fallback --- src/cli/login.zig | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/cli/login.zig b/src/cli/login.zig index a769706..4179de0 100644 --- a/src/cli/login.zig +++ b/src/cli/login.zig @@ -290,13 +290,15 @@ fn buildWindowsPowerShellCodexLaunchAlloc( launch.argv_storage[0] = powershell; launch.argv_storage[1] = "-NoLogo"; launch.argv_storage[2] = "-NoProfile"; - launch.argv_storage[3] = "-File"; - launch.argv_storage[4] = script_path; - launch.argv_storage[5] = "login"; - launch.argv_len = 6; + launch.argv_storage[3] = "-ExecutionPolicy"; + launch.argv_storage[4] = "Bypass"; + launch.argv_storage[5] = "-File"; + launch.argv_storage[6] = script_path; + launch.argv_storage[7] = "login"; + launch.argv_len = 8; if (opts.device_auth) { - launch.argv_storage[6] = "--device-auth"; - launch.argv_len = 7; + launch.argv_storage[8] = "--device-auth"; + launch.argv_len = 9; } return launch; }