From fb287640ef7fc229eacd25a473b9ce8986dee4fe Mon Sep 17 00:00:00 2001 From: Loongphy Date: Thu, 30 Apr 2026 09:09:59 +0800 Subject: [PATCH 01/16] Add Codex app launch command --- README.md | 7 + docs/commands/README.md | 1 + docs/commands/app.md | 65 ++++ src/cli/commands/app.zig | 85 +++++ src/cli/commands/root.zig | 3 + src/cli/help.zig | 28 +- src/cli/types.zig | 14 + src/workflows/app.zig | 720 ++++++++++++++++++++++++++++++++++++ src/workflows/preflight.zig | 9 +- src/workflows/root.zig | 2 + tests/cli_behavior_test.zig | 56 +++ 11 files changed, 986 insertions(+), 4 deletions(-) create mode 100644 docs/commands/app.md create mode 100644 src/cli/commands/app.zig create mode 100644 src/workflows/app.zig diff --git a/README.md b/README.md index 1622745f..7f6461d0 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,13 @@ Detailed command documentation lives in [docs/commands/README.md](./docs/command | [`codex-auth import --purge []`](./docs/commands/import.md) | Rebuild `registry.json` from auth files | | [`codex-auth clean`](./docs/commands/clean.md) | Delete managed backup and stale account files | +### Codex App Launching + +| Command | Description | +|---------|-------------| +| [`codex-auth app [--app-path ] [--cli-path ]`](./docs/commands/app.md) | Launch Codex App with detected defaults, CODEX_HOME, CODEX_CLI_PATH, and platform overrides | +| [`codex-auth app status`](./docs/commands/app.md) | Show the effective Codex App launch environment | + ### Configuration | Command | Description | diff --git a/docs/commands/README.md b/docs/commands/README.md index 3d432bf6..35300eaf 100644 --- a/docs/commands/README.md +++ b/docs/commands/README.md @@ -15,6 +15,7 @@ This directory documents command behavior by command. Use `codex-auth | `config` | [docs/commands/config.md](./config.md) | | `status` | [docs/commands/status.md](./status.md) | | `daemon` | [docs/commands/daemon.md](./daemon.md) | +| `app` | [docs/commands/app.md](./app.md) | ## Shared Behavior diff --git a/docs/commands/app.md b/docs/commands/app.md new file mode 100644 index 00000000..97b25130 --- /dev/null +++ b/docs/commands/app.md @@ -0,0 +1,65 @@ +# `codex-auth app` + +## Usage + +```shell +codex-auth app [--app-path ] [--cli-path ] [--home ] [--platform win|wsl|mac] +codex-auth app status [--app-path ] [--cli-path ] [--home ] [--platform win|wsl|mac] +``` + +## Behavior + +Launches the official Codex App with per-process environment overrides. + +- `codex-auth app` launches the app. There is no `launch` subcommand. +- `codex-auth app status` prints the effective defaults without downloading the CLI or launching the app. +- `--app-path ` points to the App executable or an installed package/app directory. +- `--cli-path ` is injected as `CODEX_CLI_PATH` for this launch. If it is omitted, `CODEX_CLI_PATH` is reused when set; otherwise launch downloads the latest Loongphy codext release into the accounts cache and uses that cached binary. +- `--home ` is injected as `CODEX_HOME` for this launch. +- `--platform win|wsl|mac` selects the app runtime platform: + - `win` writes the Windows global setting so the app runs the agent natively. + - `wsl` writes the Windows global setting so the app runs the agent inside WSL. + - `mac` launches the macOS app directly and does not use the Windows WSL setting. +- `--dry-run` prints the effective launch environment without starting the app. +- `--wait` waits for the launched process to exit. +- `-- ` passes remaining arguments to the app executable on non-Windows platforms. + +If `--app-path` is omitted, `CODEX_AUTH_APP_PATH` is used when set; otherwise +the official installed app is auto-detected. On Windows this uses AppX package +lookup for `OpenAI.Codex` and resolves the package executable. On macOS it +checks `/Applications/Codex.app` and `~/Applications/Codex.app`; the latter is +the standard per-user Applications folder. + +If `--platform` is omitted, Windows reads `$CODEX_HOME/.codex-global-state.json` +and uses `wsl` when `runCodexInWindowsSubsystemForLinux` is `true`; otherwise it +uses `win`. macOS defaults to `mac`. + +Default downloaded CLIs are cached under: + +```text +$CODEX_HOME/accounts/codext-cli///codex +``` + +On Windows, the default download prepares both the Windows-native and WSL Linux +Loongphy codext assets for the current CPU architecture, such as `win32-x64` +and `linux-x64`. On macOS, it downloads only the matching macOS asset, such as +`darwin-x64` or `darwin-arm64`. + +Windows App launching is handled by the Windows `codex-auth.exe` build. Use a +Windows app path such as `C:\Program Files\WindowsApps\...\app\Codex.exe` for +`--app-path`. The WSL build does not patch or launch Windows App packages. + +For Windows-native App launches, `--cli-path` must point to something the Windows +App process can spawn. A WSL command name such as `codex-custom` is not a +Windows executable path. + +For macOS App launches, `--app-path` may point to `/Applications/Codex.app` or +the app executable inside `Contents/MacOS`. The packaged macOS app normally uses +`Contents/Resources/codex` directly as its bundled CLI; setting `--cli-path` +injects `CODEX_CLI_PATH` and takes precedence over that bundled resource. + +The Electron app currently appends `--analytics-default-enabled` when it starts +`app-server`. A plain `CODEX_CLI_PATH` override changes which binary is executed +but does not remove that argument. To suppress it at launch time, point +`--cli-path` at a wrapper/shim that filters that argument before execing the real +codext binary. diff --git a/src/cli/commands/app.zig b/src/cli/commands/app.zig new file mode 100644 index 00000000..a858526d --- /dev/null +++ b/src/cli/commands/app.zig @@ -0,0 +1,85 @@ +const std = @import("std"); +const types = @import("../types.zig"); +const common = @import("common.zig"); + +pub fn parse(allocator: std.mem.Allocator, args: []const [:0]const u8) !types.ParseResult { + if (args.len == 0) return parseOptions(allocator, .launch, args); + const first = std.mem.sliceTo(args[0], 0); + if (common.isHelpFlag(first)) return .{ .command = .{ .help = .app } }; + + if (std.mem.eql(u8, first, "status")) return parseOptions(allocator, .status, args[1..]); + return parseOptions(allocator, .launch, args); +} + +fn parseOptions( + allocator: std.mem.Allocator, + action: types.AppAction, + args: []const [:0]const u8, +) !types.ParseResult { + var opts = types.AppOptions{ .action = action }; + var i: usize = 0; + while (i < args.len) : (i += 1) { + const arg = std.mem.sliceTo(args[i], 0); + if (std.mem.eql(u8, arg, "--")) { + opts.extra_args = @ptrCast(args[i + 1 ..]); + break; + } + if (common.isHelpFlag(arg)) return .{ .command = .{ .help = .app } }; + if (std.mem.eql(u8, arg, "--app-path")) { + if (i + 1 >= args.len) return common.usageErrorResult(allocator, .app, "missing value for `--app-path`.", .{}); + if (opts.app_path != null) return common.usageErrorResult(allocator, .app, "duplicate `--app-path` for `app`.", .{}); + i += 1; + opts.app_path = std.mem.sliceTo(args[i], 0); + continue; + } + if (std.mem.eql(u8, arg, "--cli-path")) { + if (i + 1 >= args.len) return common.usageErrorResult(allocator, .app, "missing value for `--cli-path`.", .{}); + if (opts.cli_path != null) return common.usageErrorResult(allocator, .app, "duplicate `--cli-path` for `app`.", .{}); + i += 1; + opts.cli_path = std.mem.sliceTo(args[i], 0); + continue; + } + if (std.mem.eql(u8, arg, "--home")) { + if (i + 1 >= args.len) return common.usageErrorResult(allocator, .app, "missing value for `--home`.", .{}); + if (opts.home != null) return common.usageErrorResult(allocator, .app, "duplicate `--home` for `app`.", .{}); + i += 1; + opts.home = std.mem.sliceTo(args[i], 0); + continue; + } + if (std.mem.eql(u8, arg, "--platform")) { + if (i + 1 >= args.len) return common.usageErrorResult(allocator, .app, "missing value for `--platform`.", .{}); + if (opts.platform != null) return common.usageErrorResult(allocator, .app, "duplicate `--platform` for `app`.", .{}); + i += 1; + const value = std.mem.sliceTo(args[i], 0); + if (std.mem.eql(u8, value, "win")) { + opts.platform = .win; + } else if (std.mem.eql(u8, value, "wsl")) { + opts.platform = .wsl; + } else if (std.mem.eql(u8, value, "mac")) { + opts.platform = .mac; + } else { + return common.usageErrorResult(allocator, .app, "`--platform` must be `win`, `wsl`, or `mac`.", .{}); + } + continue; + } + if (std.mem.eql(u8, arg, "--dry-run")) { + if (opts.dry_run) return common.usageErrorResult(allocator, .app, "duplicate `--dry-run` for `app`.", .{}); + opts.dry_run = true; + continue; + } + if (std.mem.eql(u8, arg, "--wait")) { + if (opts.wait) return common.usageErrorResult(allocator, .app, "duplicate `--wait` for `app`.", .{}); + opts.wait = true; + continue; + } + if (std.mem.startsWith(u8, arg, "-")) { + return common.usageErrorResult(allocator, .app, "unknown flag `{s}` for `app`.", .{arg}); + } + return common.usageErrorResult(allocator, .app, "unexpected argument `{s}` for `app`.", .{arg}); + } + + if (opts.extra_args.len != 0 and action != .launch) { + return common.usageErrorResult(allocator, .app, "`app status` does not accept passthrough arguments.", .{}); + } + return .{ .command = .{ .app = opts } }; +} diff --git a/src/cli/commands/root.zig b/src/cli/commands/root.zig index d24f5588..14b74b41 100644 --- a/src/cli/commands/root.zig +++ b/src/cli/commands/root.zig @@ -2,6 +2,7 @@ const std = @import("std"); const types = @import("../types.zig"); const common = @import("common.zig"); +const app = @import("app.zig"); const clean = @import("clean.zig"); const config = @import("config.zig"); const daemon = @import("daemon.zig"); @@ -47,6 +48,7 @@ pub fn parseArgs(allocator: std.mem.Allocator, args: []const [:0]const u8) !type if (std.mem.eql(u8, cmd, "status")) return status.parse(allocator, args[2..]); if (std.mem.eql(u8, cmd, "config")) return config.parse(allocator, args[2..]); if (std.mem.eql(u8, cmd, "daemon")) return daemon.parse(allocator, args[2..]); + if (std.mem.eql(u8, cmd, "app")) return app.parse(allocator, args[2..]); return common.usageErrorResult(allocator, .top_level, "unknown command `{s}`.", .{cmd}); } @@ -97,5 +99,6 @@ fn helpTopicForName(name: []const u8) ?types.HelpTopic { if (std.mem.eql(u8, name, "clean")) return .clean; if (std.mem.eql(u8, name, "config")) return .config; if (std.mem.eql(u8, name, "daemon")) return .daemon; + if (std.mem.eql(u8, name, "app")) return .app; return null; } diff --git a/src/cli/help.zig b/src/cli/help.zig index 6f7f7114..f3d01990 100644 --- a/src/cli/help.zig +++ b/src/cli/help.zig @@ -87,6 +87,8 @@ pub fn writeHelp( try writeCommandDetail(out, use_color, "config api disable"); try writeCommandDetail(out, use_color, "config live --interval "); try writeCommandSummary(out, use_color, "daemon --watch|--once", "Run the background auto-switch daemon"); + try writeCommandSummary(out, use_color, "app", "Launch Codex App with managed environment overrides"); + try writeCommandDetail(out, use_color, "app status"); try out.writeAll("\n"); if (use_color) try out.writeAll(style.ansi.cyan); @@ -161,6 +163,7 @@ fn commandNameForTopic(topic: HelpTopic) []const u8 { .clean => "clean", .config => "config", .daemon => "daemon", + .app => "app", }; } @@ -176,19 +179,20 @@ fn commandDescriptionForTopic(topic: HelpTopic) []const u8 { .clean => "Delete backup and stale files under accounts/.", .config => "Manage auto-switch, API, and live refresh configuration.", .daemon => "Run the background auto-switch daemon.", + .app => "Launch Codex App with CODEX_HOME and CODEX_CLI_PATH overrides.", }; } fn commandHelpHasExamples(topic: HelpTopic) bool { return switch (topic) { - .import_auth, .switch_account, .remove_account, .config, .daemon => true, + .import_auth, .switch_account, .remove_account, .config, .daemon, .app => true, else => false, }; } fn commandHelpHasOptions(topic: HelpTopic) bool { return switch (topic) { - .list, .login, .import_auth, .switch_account, .remove_account, .config, .daemon => true, + .list, .login, .import_auth, .switch_account, .remove_account, .config, .daemon, .app => true, else => false, }; } @@ -250,6 +254,10 @@ fn writeUsageLines(out: *std.Io.Writer, topic: HelpTopic) !void { try out.writeAll(" codex-auth daemon --watch\n"); try out.writeAll(" codex-auth daemon --once\n"); }, + .app => { + try out.writeAll(" codex-auth app [--app-path ] [--cli-path ] [--home ] [--platform win|wsl|mac]\n"); + try out.writeAll(" codex-auth app status [--app-path ] [--cli-path ] [--home ] [--platform win|wsl|mac]\n"); + }, } } @@ -265,6 +273,7 @@ pub fn helpCommandForTopic(topic: HelpTopic) []const u8 { .clean => "codex-auth clean --help", .config => "codex-auth config --help", .daemon => "codex-auth daemon --help", + .app => "codex-auth app --help", }; } @@ -320,6 +329,16 @@ fn writeOptionLines(out: *std.Io.Writer, topic: HelpTopic) !void { try out.writeAll(" --watch Run continuously and switch accounts when thresholds are reached.\n"); try out.writeAll(" --once Run one auto-switch check, then exit.\n"); }, + .app => { + try out.writeAll(" --app-path Official Codex App executable or install directory.\n"); + try out.writeAll(" --cli-path Value injected as CODEX_CLI_PATH. Defaults to cached/latest Loongphy codext.\n"); + try out.writeAll(" --home Value injected as CODEX_HOME for this launch.\n"); + try out.writeAll(" --platform win|wsl|mac\n"); + try out.writeAll(" Preselect the app platform. Defaults to the current app setting on Windows and mac on macOS.\n"); + try out.writeAll(" --dry-run Print the effective launch environment without starting the app.\n"); + try out.writeAll(" --wait Wait for the launched app process to exit.\n"); + try out.writeAll(" -- Pass additional arguments to the app executable.\n"); + }, else => {}, } } @@ -383,6 +402,11 @@ fn writeExampleLines(out: *std.Io.Writer, topic: HelpTopic) !void { try out.writeAll(" codex-auth daemon --watch\n"); try out.writeAll(" codex-auth daemon --once\n"); }, + .app => { + try out.writeAll(" codex-auth app\n"); + try out.writeAll(" codex-auth app --platform win\n"); + try out.writeAll(" codex-auth app status --app-path /Applications/Codex.app --cli-path /usr/local/bin/codext\n"); + }, } } diff --git a/src/cli/types.zig b/src/cli/types.zig index f2e281fc..62e2c0e1 100644 --- a/src/cli/types.zig +++ b/src/cli/types.zig @@ -50,6 +50,18 @@ pub const ConfigOptions = union(enum) { }; pub const DaemonMode = enum { watch, once }; pub const DaemonOptions = struct { mode: DaemonMode }; +pub const AppAction = enum { launch, status }; +pub const AppPlatform = enum { win, wsl, mac }; +pub const AppOptions = struct { + action: AppAction, + app_path: ?[]const u8 = null, + cli_path: ?[]const u8 = null, + home: ?[]const u8 = null, + platform: ?AppPlatform = null, + dry_run: bool = false, + wait: bool = false, + extra_args: []const []const u8 = &.{}, +}; pub const HelpTopic = enum { top_level, list, @@ -61,6 +73,7 @@ pub const HelpTopic = enum { clean, config, daemon, + app, }; pub const Command = union(enum) { @@ -73,6 +86,7 @@ pub const Command = union(enum) { config: ConfigOptions, status: void, daemon: DaemonOptions, + app: AppOptions, version: void, help: HelpTopic, }; diff --git a/src/workflows/app.zig b/src/workflows/app.zig new file mode 100644 index 00000000..17a91f74 --- /dev/null +++ b/src/workflows/app.zig @@ -0,0 +1,720 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const app_runtime = @import("../core/runtime.zig"); +const io_util = @import("../core/io_util.zig"); +const http_child = @import("../api/http_child.zig"); +const registry = @import("../registry/root.zig"); +const types = @import("../cli/types.zig"); + +const codex_cli_path_env = "CODEX_CLI_PATH"; +const codex_home_env = "CODEX_HOME"; +const app_path_env = "CODEX_AUTH_APP_PATH"; +const wsl_agent_mode_key = "runCodexInWindowsSubsystemForLinux"; +const codext_repo_latest_url = "https://api.github.com/repos/Loongphy/codext/releases/latest"; +const codext_cache_dir_name = "codext-cli"; + +const ValueSource = enum { explicit, env, detected, cached, downloaded, not_set }; + +const ResolvedValue = struct { + value: ?[]const u8, + source: ValueSource, + owned: bool = false, + + fn deinit(self: ResolvedValue, allocator: std.mem.Allocator) void { + if (self.owned) if (self.value) |value| allocator.free(@constCast(value)); + } +}; + +const ResolvedPlatform = struct { + value: ?types.AppPlatform, + source: ValueSource, +}; + +pub fn handleApp(allocator: std.mem.Allocator, resolved_codex_home: []const u8, opts: types.AppOptions) !void { + const effective_home = opts.home orelse resolved_codex_home; + const effective_platform = try resolvePlatform(allocator, effective_home, opts.platform); + if (opts.action == .launch and !opts.dry_run) try validateAppPlatform(effective_platform.value); + const effective_app_path = try resolveAppPath(allocator, opts); + defer effective_app_path.deinit(allocator); + const effective_cli_path = try resolveCliPath(allocator, effective_home, effective_platform.value, opts, opts.action == .launch and !opts.dry_run); + defer effective_cli_path.deinit(allocator); + + switch (opts.action) { + .status => try printStatus(effective_app_path, effective_cli_path, effective_home, effective_platform, opts), + .launch => try launchApp(allocator, effective_app_path, effective_cli_path, effective_home, effective_platform, opts), + } +} + +fn getOptionalEnv(allocator: std.mem.Allocator, name: []const u8) ?[]const u8 { + const value = registry.getEnvVarOwned(allocator, name) catch return null; + if (value.len == 0) { + allocator.free(value); + return null; + } + return value; +} + +fn resolveAppPath(allocator: std.mem.Allocator, opts: types.AppOptions) !ResolvedValue { + if (opts.app_path) |path| return .{ .value = path, .source = .explicit }; + if (getOptionalEnv(allocator, app_path_env)) |path| return .{ .value = path, .source = .env, .owned = true }; + if (try detectInstalledAppPath(allocator)) |path| return .{ .value = path, .source = .detected, .owned = true }; + return .{ .value = null, .source = .not_set }; +} + +fn resolveCliPath( + allocator: std.mem.Allocator, + home: []const u8, + platform: ?types.AppPlatform, + opts: types.AppOptions, + allow_download: bool, +) !ResolvedValue { + if (opts.cli_path) |path| return .{ .value = path, .source = .explicit }; + if (getOptionalEnv(allocator, codex_cli_path_env)) |path| return .{ .value = path, .source = .env, .owned = true }; + + const target_platform = platform orelse nativeDefaultPlatform(); + if (try cachedCodextCliPath(allocator, home, target_platform)) |path| return .{ .value = path, .source = .cached, .owned = true }; + if (!allow_download) return .{ .value = null, .source = .not_set }; + + const path = try downloadDefaultCodextCli(allocator, home, target_platform); + return .{ .value = path, .source = .downloaded, .owned = true }; +} + +fn resolvePlatform(allocator: std.mem.Allocator, home: []const u8, explicit: ?types.AppPlatform) !ResolvedPlatform { + if (explicit) |platform| return .{ .value = platform, .source = .explicit }; + if (builtin.os.tag == .windows) { + const use_wsl = try readWindowsWslBackendSetting(allocator, home); + return .{ .value = if (use_wsl) .wsl else .win, .source = .detected }; + } + if (builtin.os.tag == .macos) return .{ .value = .mac, .source = .detected }; + return .{ .value = null, .source = .not_set }; +} + +fn nativeDefaultPlatform() types.AppPlatform { + return switch (builtin.os.tag) { + .windows => .win, + .macos => .mac, + else => .wsl, + }; +} + +fn readWindowsWslBackendSetting(allocator: std.mem.Allocator, home: []const u8) !bool { + const state_path = try std.fs.path.join(allocator, &.{ home, ".codex-global-state.json" }); + defer allocator.free(state_path); + + var file = std.Io.Dir.cwd().openFile(app_runtime.io(), state_path, .{}) catch |err| switch (err) { + error.FileNotFound => return false, + else => return err, + }; + defer file.close(app_runtime.io()); + const data = try registry.readFileAlloc(file, allocator, 1024 * 1024); + defer allocator.free(data); + + const parsed = std.json.parseFromSlice(std.json.Value, allocator, data, .{}) catch return false; + defer parsed.deinit(); + const object = switch (parsed.value) { + .object => |object| object, + else => return false, + }; + const value = object.get(wsl_agent_mode_key) orelse return false; + return switch (value) { + .bool => |enabled| enabled, + else => false, + }; +} + +fn printStatus( + app_path: ResolvedValue, + cli_path: ResolvedValue, + home: []const u8, + platform: ResolvedPlatform, + opts: types.AppOptions, +) !void { + var stdout: io_util.Stdout = undefined; + stdout.init(); + const out = stdout.out(); + try out.writeAll("Codex App launch environment\n"); + try out.print(" app path: {s} ({s})\n", .{ app_path.value orelse "(not set)", valueSourceName(app_path.source) }); + try out.print(" CODEX_HOME: {s}\n", .{home}); + try out.print(" CODEX_CLI_PATH: {s} ({s})\n", .{ cli_path.value orelse "(not cached)", valueSourceName(cli_path.source) }); + try out.print(" platform: {s} ({s})\n", .{appPlatformName(platform.value), valueSourceName(platform.source)}); + try out.print(" dry run: {s}\n", .{if (opts.dry_run) "yes" else "no"}); + try out.print(" wait: {s}\n", .{if (opts.wait) "yes" else "no"}); + try out.flush(); +} + +fn launchApp( + allocator: std.mem.Allocator, + app_path: ResolvedValue, + cli_path: ResolvedValue, + home: []const u8, + platform: ResolvedPlatform, + opts: types.AppOptions, +) !void { + const target = app_path.value orelse { + try writeAppError("app launch could not find the installed Codex app. Pass `--app-path ` or set CODEX_AUTH_APP_PATH.\n"); + return error.AppPathRequired; + }; + if (opts.dry_run) { + try printStatus(app_path, cli_path, home, platform, opts); + return; + } + try validateAppPlatform(platform.value); + try applyAppPlatform(allocator, home, platform.value); + + if (builtin.os.tag == .windows) { + return launchWindowsViaPowerShell(allocator, target, cli_path.value, home, opts); + } + if (looksLikeWindowsPath(target) or looksLikeWslWindowsMountPath(target)) { + try writeAppError("windows app launch must run from the Windows codex-auth executable.\n"); + return error.WindowsAppLaunchRequiresWindows; + } + return launchNative(allocator, target, cli_path.value, home, opts); +} + +fn appPlatformName(value: ?types.AppPlatform) []const u8 { + return switch (value orelse return "(not set)") { + .win => "win", + .wsl => "wsl", + .mac => "mac", + }; +} + +fn valueSourceName(value: ValueSource) []const u8 { + return switch (value) { + .explicit => "explicit", + .env => "env", + .detected => "detected", + .cached => "cached", + .downloaded => "downloaded", + .not_set => "not set", + }; +} + +fn validateAppPlatform(value: ?types.AppPlatform) !void { + const platform = value orelse return; + switch (platform) { + .win, .wsl => if (builtin.os.tag != .windows) { + try writeAppError("app launch with `--platform win` or `--platform wsl` must run from the Windows codex-auth executable.\n"); + return error.WindowsAppPlatformRequiresWindows; + }, + .mac => if (builtin.os.tag != .macos) { + try writeAppError("app launch with `--platform mac` must run from the macOS codex-auth executable.\n"); + return error.MacAppPlatformRequiresMacOS; + }, + } +} + +fn applyAppPlatform(allocator: std.mem.Allocator, home: []const u8, value: ?types.AppPlatform) !void { + const platform = value orelse return; + const use_wsl = switch (platform) { + .win => false, + .wsl => true, + .mac => return, + }; + const state_path = try std.fs.path.join(allocator, &.{ home, ".codex-global-state.json" }); + defer allocator.free(state_path); + + if (std.fs.path.dirname(state_path)) |dir| { + try std.Io.Dir.cwd().createDirPath(app_runtime.io(), dir); + } + + var parsed: ?std.json.Parsed(std.json.Value) = null; + defer if (parsed) |*p| p.deinit(); + + var root: std.json.Value = blk: { + var file = std.Io.Dir.cwd().openFile(app_runtime.io(), state_path, .{}) catch |err| switch (err) { + error.FileNotFound => break :blk .{ .object = .{} }, + else => return err, + }; + defer file.close(app_runtime.io()); + const data = try registry.readFileAlloc(file, allocator, 1024 * 1024); + defer allocator.free(data); + parsed = std.json.parseFromSlice(std.json.Value, allocator, data, .{}) catch { + break :blk .{ .object = .{} }; + }; + break :blk switch (parsed.?.value) { + .object => try cloneJsonValue(allocator, parsed.?.value), + else => .{ .object = .{} }, + }; + }; + defer deinitClonedJsonValue(allocator, &root); + + switch (root) { + .object => |*obj| try obj.put(allocator, wsl_agent_mode_key, .{ .bool = use_wsl }), + else => unreachable, + } + + var aw: std.Io.Writer.Allocating = .init(allocator); + defer aw.deinit(); + try std.json.Stringify.value(root, .{ .whitespace = .indent_2 }, &aw.writer); + + var file = try std.Io.Dir.cwd().createFile(app_runtime.io(), state_path, .{ .truncate = true }); + defer file.close(app_runtime.io()); + try file.writeStreamingAll(app_runtime.io(), aw.written()); +} + +fn cloneJsonValue(allocator: std.mem.Allocator, value: std.json.Value) !std.json.Value { + return switch (value) { + .null, .bool, .integer, .float, .number_string, .string => value, + .array => |array| blk: { + var cloned = std.json.Array.init(allocator); + for (array.items) |item| { + try cloned.append(try cloneJsonValue(allocator, item)); + } + break :blk .{ .array = cloned }; + }, + .object => |object| blk: { + var cloned: std.json.ObjectMap = .{}; + for (object.keys(), object.values()) |key, item| { + try cloned.put(allocator, key, try cloneJsonValue(allocator, item)); + } + break :blk .{ .object = cloned }; + }, + }; +} + +fn deinitClonedJsonValue(allocator: std.mem.Allocator, value: *std.json.Value) void { + switch (value.*) { + .array => |*array| { + for (array.items) |*item| deinitClonedJsonValue(allocator, item); + array.deinit(); + }, + .object => |*object| { + for (object.values()) |*item| deinitClonedJsonValue(allocator, item); + object.deinit(allocator); + }, + else => {}, + } +} + +fn looksLikeWindowsPath(path: []const u8) bool { + return (path.len >= 3 and std.ascii.isAlphabetic(path[0]) and path[1] == ':' and (path[2] == '\\' or path[2] == '/')) or + std.mem.startsWith(u8, path, "\\\\"); +} + +fn looksLikeWslWindowsMountPath(path: []const u8) bool { + return std.mem.startsWith(u8, path, "/mnt/") and path.len >= "/mnt/c/".len and path[6] == '/'; +} + +fn launchNative( + allocator: std.mem.Allocator, + app_path: []const u8, + cli_path: ?[]const u8, + home: []const u8, + opts: types.AppOptions, +) !void { + const launch_path = try resolveLaunchPath(allocator, app_path); + defer allocator.free(launch_path); + + var env_map = try registry.getEnvMap(allocator); + defer env_map.deinit(); + try env_map.put(codex_home_env, home); + if (cli_path) |path| { + try env_map.put(codex_cli_path_env, path); + } + + var argv = std.ArrayList([]const u8).empty; + defer argv.deinit(allocator); + try argv.append(allocator, launch_path); + try argv.appendSlice(allocator, opts.extra_args); + + var child = try std.process.spawn(app_runtime.io(), .{ + .argv = argv.items, + .environ_map = &env_map, + .stdin = .ignore, + .stdout = .inherit, + .stderr = .inherit, + }); + if (opts.wait) { + _ = try child.wait(app_runtime.io()); + } +} + +fn resolveLaunchPath(allocator: std.mem.Allocator, app_path: []const u8) ![]u8 { + if (!isDirectory(app_path)) return try allocator.dupe(u8, app_path); + + const candidates = [_][]const u8{ + "Codex.exe", + "codex.exe", + "app/Codex.exe", + "app/codex.exe", + "Codex", + "codex", + "Contents/MacOS/Codex", + "Contents/MacOS/codex", + }; + for (candidates) |candidate| { + const joined = try std.fs.path.join(allocator, &.{ app_path, candidate }); + if (fileExists(joined)) return joined; + allocator.free(joined); + } + return error.AppExecutableNotFound; +} + +fn isDirectory(path: []const u8) bool { + const stat = std.Io.Dir.cwd().statFile(app_runtime.io(), path, .{}) catch return false; + return stat.kind == .directory; +} + +fn fileExists(path: []const u8) bool { + std.Io.Dir.cwd().access(app_runtime.io(), path, .{}) catch return false; + return true; +} + +fn detectInstalledAppPath(allocator: std.mem.Allocator) !?[]u8 { + return switch (builtin.os.tag) { + .windows => try detectWindowsInstalledAppPath(allocator), + .macos => try detectMacInstalledAppPath(allocator), + else => null, + }; +} + +fn detectWindowsInstalledAppPath(allocator: std.mem.Allocator) !?[]u8 { + const script = try std.fmt.allocPrint( + allocator, + "$ErrorActionPreference='SilentlyContinue'; $pkg=Get-AppxPackage -Name 'OpenAI.Codex' | Sort-Object Version -Descending | Select-Object -First 1; if ($pkg) {{ foreach ($rel in @('app\\Codex.exe','Codex.exe')) {{ $p=Join-Path $pkg.InstallLocation $rel; if (Test-Path -LiteralPath $p -PathType Leaf) {{ [Console]::Out.Write($p); exit 0 }} }} }}", + .{}, + ); + defer allocator.free(script); + var result = try http_child.runChildCapture(allocator, &[_][]const u8{ "pwsh.exe", "-NoProfile", "-Command", script }, 7000, null); + defer result.deinit(allocator); + const trimmed = std.mem.trim(u8, result.stdout, " \t\r\n"); + if (trimmed.len == 0) return null; + return try allocator.dupe(u8, trimmed); +} + +fn detectMacInstalledAppPath(allocator: std.mem.Allocator) !?[]u8 { + const candidates = [_][]const u8{ + "/Applications/Codex.app", + "~/Applications/Codex.app", + }; + for (candidates[0..]) |candidate| { + const expanded = try expandTildePath(allocator, candidate); + if (isDirectory(expanded) or fileExists(expanded)) return expanded; + allocator.free(expanded); + } + return null; +} + +fn expandTildePath(allocator: std.mem.Allocator, path: []const u8) ![]u8 { + if (!std.mem.startsWith(u8, path, "~/")) return try allocator.dupe(u8, path); + const home = getOptionalEnv(allocator, "HOME") orelse return try allocator.dupe(u8, path); + defer allocator.free(@constCast(home)); + return try std.fs.path.join(allocator, &.{ home, path[2..] }); +} + +fn cachedCodextCliPath(allocator: std.mem.Allocator, home: []const u8, platform: types.AppPlatform) !?[]u8 { + const platform_name = codextPlatformCacheName(platform); + const root_path = try std.fs.path.join(allocator, &.{ home, "accounts", codext_cache_dir_name }); + defer allocator.free(root_path); + + var root = std.Io.Dir.cwd().openDir(app_runtime.io(), root_path, .{ .iterate = true }) catch |err| switch (err) { + error.FileNotFound => return null, + else => return err, + }; + defer root.close(app_runtime.io()); + + var best: ?[]u8 = null; + var best_tag: ?[]u8 = null; + var it = root.iterate(); + while (try it.next(app_runtime.io())) |entry| { + if (entry.kind != .directory) continue; + const candidate = try findCachedCodextExecutable(allocator, root_path, entry.name, platform_name, platform) orelse continue; + if (fileExists(candidate)) { + if (best_tag == null or std.mem.order(u8, entry.name, best_tag.?) == .gt) { + if (best) |old| allocator.free(old); + if (best_tag) |old| allocator.free(old); + best = candidate; + best_tag = try allocator.dupe(u8, entry.name); + } else { + allocator.free(candidate); + } + } else { + allocator.free(candidate); + } + } + if (best_tag) |tag| allocator.free(tag); + return best; +} + +fn downloadDefaultCodextCli(allocator: std.mem.Allocator, home: []const u8, platform: types.AppPlatform) ![]u8 { + const release = try fetchLatestCodextRelease(allocator); + defer release.deinit(allocator); + + const cache_root = try std.fs.path.join(allocator, &.{ home, "accounts", codext_cache_dir_name, release.tag }); + defer allocator.free(cache_root); + try std.Io.Dir.cwd().createDirPath(app_runtime.io(), cache_root); + + if (builtin.os.tag == .windows) { + const win_asset = release.assetFor(.win) orelse return error.CodextReleaseAssetNotFound; + const wsl_asset = release.assetFor(.wsl) orelse return error.CodextReleaseAssetNotFound; + try writeAppInfo("downloading from {s}\ndownloading from {s}\n", .{ win_asset.url, wsl_asset.url }); + try downloadAndInstallCodextAsset(allocator, cache_root, .win, win_asset); + try downloadAndInstallCodextAsset(allocator, cache_root, .wsl, wsl_asset); + } else { + const asset = release.assetFor(platform) orelse return error.CodextReleaseAssetNotFound; + try writeAppInfo("downloading from {s}\n", .{asset.url}); + try downloadAndInstallCodextAsset(allocator, cache_root, platform, asset); + } + + const installed = try std.fs.path.join(allocator, &.{ cache_root, codextPlatformCacheName(platform), codextExecutableName(platform) }); + if (!fileExists(installed)) { + allocator.free(installed); + return error.CodextReleaseInstallFailed; + } + return installed; +} + +fn findCachedCodextExecutable( + allocator: std.mem.Allocator, + root_path: []const u8, + tag: []const u8, + platform_name: []const u8, + platform: types.AppPlatform, +) !?[]u8 { + const primary = try std.fs.path.join(allocator, &.{ root_path, tag, platform_name, codextExecutableName(platform) }); + if (fileExists(primary)) return primary; + allocator.free(primary); + const legacy = try std.fs.path.join(allocator, &.{ root_path, tag, platform_name, codextReleaseExecutableName(platform) }); + if (fileExists(legacy)) return legacy; + allocator.free(legacy); + return null; +} + +const CodextAsset = struct { + name: []u8, + url: []u8, + + fn deinit(self: CodextAsset, allocator: std.mem.Allocator) void { + allocator.free(self.name); + allocator.free(self.url); + } +}; + +const CodextRelease = struct { + tag: []u8, + win_asset: ?CodextAsset = null, + linux_asset: ?CodextAsset = null, + mac_asset: ?CodextAsset = null, + + fn deinit(self: CodextRelease, allocator: std.mem.Allocator) void { + allocator.free(self.tag); + if (self.win_asset) |value| value.deinit(allocator); + if (self.linux_asset) |value| value.deinit(allocator); + if (self.mac_asset) |value| value.deinit(allocator); + } + + fn assetFor(self: CodextRelease, platform: types.AppPlatform) ?CodextAsset { + return switch (platform) { + .win => self.win_asset, + .wsl => self.linux_asset, + .mac => self.mac_asset, + }; + } +}; + +fn fetchLatestCodextRelease(allocator: std.mem.Allocator) !CodextRelease { + var result = try http_child.runChildCapture(allocator, &[_][]const u8{ curlExecutable(), "-L", "--fail", "--silent", codext_repo_latest_url }, 15000, null); + defer result.deinit(allocator); + const parsed = try std.json.parseFromSlice(std.json.Value, allocator, result.stdout, .{}); + defer parsed.deinit(); + const object = switch (parsed.value) { + .object => |object| object, + else => return error.InvalidCodextReleaseResponse, + }; + const tag_value = object.get("tag_name") orelse return error.InvalidCodextReleaseResponse; + const tag = switch (tag_value) { + .string => |value| try allocator.dupe(u8, value), + else => return error.InvalidCodextReleaseResponse, + }; + var release = CodextRelease{ .tag = tag }; + errdefer release.deinit(allocator); + + const assets_value = object.get("assets") orelse return error.InvalidCodextReleaseResponse; + const assets = switch (assets_value) { + .array => |array| array.items, + else => return error.InvalidCodextReleaseResponse, + }; + const want_win = releaseAssetNeedle(.win); + const want_linux = releaseAssetNeedle(.wsl); + const want_mac = releaseAssetNeedle(.mac); + for (assets) |asset| { + const asset_object = switch (asset) { + .object => |asset_object| asset_object, + else => continue, + }; + const name = switch (asset_object.get("name") orelse continue) { + .string => |value| value, + else => continue, + }; + const url = switch (asset_object.get("browser_download_url") orelse continue) { + .string => |value| value, + else => continue, + }; + if (std.mem.indexOf(u8, name, want_win) != null) { + if (release.win_asset == null) release.win_asset = try dupeCodextAsset(allocator, name, url); + } else if (std.mem.indexOf(u8, name, want_linux) != null) { + if (release.linux_asset == null) release.linux_asset = try dupeCodextAsset(allocator, name, url); + } else if (std.mem.indexOf(u8, name, want_mac) != null) { + if (release.mac_asset == null) release.mac_asset = try dupeCodextAsset(allocator, name, url); + } + } + return release; +} + +fn dupeCodextAsset(allocator: std.mem.Allocator, name: []const u8, url: []const u8) !CodextAsset { + return .{ + .name = try allocator.dupe(u8, name), + .url = try allocator.dupe(u8, url), + }; +} + +fn downloadAndInstallCodextAsset( + allocator: std.mem.Allocator, + cache_root: []const u8, + platform: types.AppPlatform, + asset: CodextAsset, +) !void { + const platform_dir = try std.fs.path.join(allocator, &.{ cache_root, codextPlatformCacheName(platform) }); + defer allocator.free(platform_dir); + if (isDirectory(platform_dir)) try std.Io.Dir.cwd().deleteTree(app_runtime.io(), platform_dir); + try std.Io.Dir.cwd().createDirPath(app_runtime.io(), platform_dir); + + const archive_name = if (platform == .win) "codext.zip" else "codext.tar.gz"; + const archive_path = try std.fs.path.join(allocator, &.{ platform_dir, archive_name }); + defer allocator.free(archive_path); + try runChecked(allocator, &[_][]const u8{ curlExecutable(), "-L", "--fail", "--silent", "--show-error", "-o", archive_path, asset.url }, 120000); + if (platform == .win) { + const archive_quoted = try psSingleQuoteAlloc(allocator, archive_path); + defer allocator.free(archive_quoted); + const dest_quoted = try psSingleQuoteAlloc(allocator, platform_dir); + defer allocator.free(dest_quoted); + const script = try std.fmt.allocPrint(allocator, "Expand-Archive -LiteralPath {s} -DestinationPath {s} -Force", .{ archive_quoted, dest_quoted }); + defer allocator.free(script); + try runChecked(allocator, &[_][]const u8{ "pwsh.exe", "-NoProfile", "-Command", script }, 120000); + } else { + try runChecked(allocator, &[_][]const u8{ tarExecutable(), "-xzf", archive_path, "-C", platform_dir }, 120000); + } + try normalizeCodextExecutableName(allocator, platform_dir, platform); +} + +fn runChecked(allocator: std.mem.Allocator, argv: []const []const u8, timeout_ms: u64) !void { + var result = try http_child.runChildCapture(allocator, argv, timeout_ms, null); + defer result.deinit(allocator); + if (result.timed_out) return error.ChildProcessTimedOut; + switch (result.term) { + .exited => |code| if (code == 0) return, + else => {}, + } + return error.ChildProcessFailed; +} + +fn codextPlatformCacheName(platform: types.AppPlatform) []const u8 { + return switch (platform) { + .win => if (builtin.cpu.arch == .aarch64) "win32-arm64" else "win32-x64", + .wsl => if (builtin.cpu.arch == .aarch64) "linux-arm64" else "linux-x64", + .mac => if (builtin.cpu.arch == .aarch64) "darwin-arm64" else "darwin-x64", + }; +} + +fn releaseAssetNeedle(platform: types.AppPlatform) []const u8 { + return codextPlatformCacheName(platform); +} + +fn curlExecutable() []const u8 { + return if (builtin.os.tag == .windows) "C:\\Windows\\System32\\curl.exe" else "curl"; +} + +fn tarExecutable() []const u8 { + return if (builtin.os.tag == .windows) "C:\\Windows\\System32\\tar.exe" else "tar"; +} + +fn codextExecutableName(platform: types.AppPlatform) []const u8 { + return switch (platform) { + .win => "codex.exe", + .wsl, .mac => "codex", + }; +} + +fn codextReleaseExecutableName(platform: types.AppPlatform) []const u8 { + return switch (platform) { + .win => "codext.exe", + .wsl, .mac => "codext", + }; +} + +fn normalizeCodextExecutableName(allocator: std.mem.Allocator, platform_dir: []const u8, platform: types.AppPlatform) !void { + const target = try std.fs.path.join(allocator, &.{ platform_dir, codextExecutableName(platform) }); + defer allocator.free(target); + if (fileExists(target)) return; + const source = try std.fs.path.join(allocator, &.{ platform_dir, codextReleaseExecutableName(platform) }); + defer allocator.free(source); + if (!fileExists(source)) return; + try std.Io.Dir.renameAbsolute(source, target, app_runtime.io()); +} + +fn writeAppError(message: []const u8) !void { + var buffer: [512]u8 = undefined; + var writer = std.Io.File.stderr().writer(app_runtime.io(), &buffer); + const out = &writer.interface; + try out.writeAll(message); + try out.flush(); +} + +fn writeAppInfo(comptime format: []const u8, args: anytype) !void { + var buffer: [1024]u8 = undefined; + var writer = std.Io.File.stderr().writer(app_runtime.io(), &buffer); + const out = &writer.interface; + try out.print(format, args); + try out.flush(); +} + +fn launchWindowsViaPowerShell( + allocator: std.mem.Allocator, + app_path: []const u8, + cli_path: ?[]const u8, + home: []const u8, + opts: types.AppOptions, +) !void { + if (opts.extra_args.len != 0) return error.WindowsPassthroughArgsUnsupported; + + const app_quoted = try psSingleQuoteAlloc(allocator, app_path); + defer allocator.free(app_quoted); + const home_quoted = try psSingleQuoteAlloc(allocator, home); + defer allocator.free(home_quoted); + const cli_quoted = if (cli_path) |path| try psSingleQuoteAlloc(allocator, path) else null; + defer if (cli_quoted) |path| allocator.free(path); + + const cli_part = if (cli_quoted) |path| + try std.fmt.allocPrint(allocator, "; CODEX_CLI_PATH={s}", .{path}) + else + try allocator.dupe(u8, ""); + defer allocator.free(cli_part); + + const script = try std.fmt.allocPrint( + allocator, + "$ErrorActionPreference='Stop'; $p={s}; if (Test-Path -LiteralPath $p -PathType Container) {{ $c=@('Codex.exe','codex.exe','app\\Codex.exe','app\\codex.exe'); foreach ($n in $c) {{ $x=Join-Path $p $n; if (Test-Path -LiteralPath $x -PathType Leaf) {{ $p=$x; break }} }} }}; Start-Process -FilePath $p -Environment @{{ CODEX_HOME={s}{s} }}{s}", + .{ app_quoted, home_quoted, cli_part, if (opts.wait) " -Wait" else "" }, + ); + defer allocator.free(script); + + var child = try std.process.spawn(app_runtime.io(), .{ + .argv = &[_][]const u8{ "pwsh.exe", "-NoProfile", "-Command", script }, + .stdin = .ignore, + .stdout = .inherit, + .stderr = .inherit, + }); + _ = try child.wait(app_runtime.io()); +} + +fn psSingleQuoteAlloc(allocator: std.mem.Allocator, value: []const u8) ![]u8 { + var out = std.ArrayList(u8).empty; + errdefer out.deinit(allocator); + try out.append(allocator, '\''); + for (value) |ch| { + try out.append(allocator, ch); + if (ch == '\'') try out.append(allocator, '\''); + } + try out.append(allocator, '\''); + return try out.toOwnedSlice(allocator); +} diff --git a/src/workflows/preflight.zig b/src/workflows/preflight.zig index ba53245c..35fab584 100644 --- a/src/workflows/preflight.zig +++ b/src/workflows/preflight.zig @@ -25,13 +25,18 @@ pub fn isHandledCliError(err: anyerror) bool { err == error.SwitchSelectionRequiresTty or err == error.RemoveConfirmationUnavailable or err == error.RemoveSelectionRequiresTty or - err == error.InvalidRemoveSelectionInput; + err == error.InvalidRemoveSelectionInput or + err == error.AppPathRequired or + err == error.WindowsAppLaunchRequiresWindows or + err == error.WindowsAppPlatformRequiresWindows or + err == error.MacAppPlatformRequiresMacOS or + err == error.WindowsPassthroughArgsUnsupported; } pub fn shouldReconcileManagedService(cmd: cli.types.Command) bool { if (hasNonEmptyEnvVar(skip_service_reconcile_env)) return false; return switch (cmd) { - .help, .version, .status, .daemon => false, + .help, .version, .status, .daemon, .app => false, else => true, }; } diff --git a/src/workflows/root.zig b/src/workflows/root.zig index f65d4895..bc6c9135 100644 --- a/src/workflows/root.zig +++ b/src/workflows/root.zig @@ -18,6 +18,7 @@ const live_flow = @import("live.zig"); const help_workflow = @import("help.zig"); const clean_workflow = @import("clean.zig"); const config_workflow = @import("config.zig"); +const app_workflow = @import("app.zig"); const list_workflow = @import("list.zig"); const login_workflow = @import("login.zig"); const import_workflow = @import("import.zig"); @@ -150,6 +151,7 @@ fn runMain(init: std.process.Init.Minimal) !void { .once => try auto.runDaemonOnce(allocator, codex_home.?), }, .config => |opts| try config_workflow.handleConfig(allocator, codex_home.?, opts), + .app => |opts| try app_workflow.handleApp(allocator, codex_home.?, opts), .list => |opts| try list_workflow.handleList(allocator, codex_home.?, opts), .login => |opts| try login_workflow.handleLogin(allocator, codex_home.?, opts), .import_auth => |opts| try import_workflow.handleImport(allocator, codex_home.?, opts), diff --git a/tests/cli_behavior_test.zig b/tests/cli_behavior_test.zig index 664df16e..a390ed6f 100644 --- a/tests/cli_behavior_test.zig +++ b/tests/cli_behavior_test.zig @@ -76,6 +76,62 @@ fn expectArgv(actual: []const []const u8, expected: []const []const u8) !void { } } +test "Scenario: Given app launch overrides when parsing then paths and passthrough args are preserved" { + const gpa = std.testing.allocator; + const args = [_][:0]const u8{ + "codex-auth", + "app", + "--app-path", + "C:\\Program Files\\WindowsApps\\OpenAI.Codex", + "--cli-path", + "codex-custom", + "--home", + "/mnt/c/Users/Loong/.codext", + "--platform", + "win", + "--dry-run", + "--", + "--trace", + }; + var result = try cli.commands.parseArgs(gpa, &args); + defer cli.commands.freeParseResult(gpa, &result); + + switch (result) { + .command => |cmd| switch (cmd) { + .app => |opts| { + try std.testing.expectEqual(cli.types.AppAction.launch, opts.action); + try std.testing.expectEqualStrings("C:\\Program Files\\WindowsApps\\OpenAI.Codex", opts.app_path.?); + try std.testing.expectEqualStrings("codex-custom", opts.cli_path.?); + try std.testing.expectEqualStrings("/mnt/c/Users/Loong/.codext", opts.home.?); + try std.testing.expectEqual(cli.types.AppPlatform.win, opts.platform.?); + try std.testing.expect(opts.dry_run); + try std.testing.expect(!opts.wait); + try expectArgv(opts.extra_args, &[_][]const u8{"--trace"}); + }, + else => return error.TestExpectedEqual, + }, + else => return error.TestExpectedEqual, + } +} + +test "Scenario: Given app status with passthrough args when parsing then usage error is returned" { + const gpa = std.testing.allocator; + const args = [_][:0]const u8{ "codex-auth", "app", "status", "--", "--trace" }; + var result = try cli.commands.parseArgs(gpa, &args); + defer cli.commands.freeParseResult(gpa, &result); + + try expectUsageError(result, .app, "`app status` does not accept passthrough arguments."); +} + +test "Scenario: Given removed app launch subcommand when parsing then usage error is returned" { + const gpa = std.testing.allocator; + const args = [_][:0]const u8{ "codex-auth", "app", "launch" }; + var result = try cli.commands.parseArgs(gpa, &args); + defer cli.commands.freeParseResult(gpa, &result); + + try expectUsageError(result, .app, "unexpected argument `launch` for `app`."); +} + fn expectedImportMarker(outcome: registry.ImportOutcome) []const u8 { return switch (outcome) { .imported => if (builtin.os.tag == .windows) "[+]" else "✓", From 9fd5d59b7801996e20c8713eb33eaa30f2a03ba4 Mon Sep 17 00:00:00 2001 From: Loongphy Date: Sat, 2 May 2026 03:14:13 +0800 Subject: [PATCH 02/16] Add persistent Codex app CLI patch --- README.md | 2 + docs/commands/app.md | 29 ++++- src/cli/commands/app.zig | 4 +- src/cli/help.zig | 12 +- src/cli/types.zig | 2 +- src/workflows/app.zig | 235 ++++++++++++++++++++++++++++++++++-- tests/cli_behavior_test.zig | 34 ++++++ 7 files changed, 302 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 7f6461d0..f14f8e9c 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,8 @@ Detailed command documentation lives in [docs/commands/README.md](./docs/command |---------|-------------| | [`codex-auth app [--app-path ] [--cli-path ]`](./docs/commands/app.md) | Launch Codex App with detected defaults, CODEX_HOME, CODEX_CLI_PATH, and platform overrides | | [`codex-auth app status`](./docs/commands/app.md) | Show the effective Codex App launch environment | +| [`codex-auth app patch`](./docs/commands/app.md) | Persist CODEX_CLI_PATH so normal Codex App launches use the managed CLI | +| [`codex-auth app unpatch`](./docs/commands/app.md) | Remove the persistent CODEX_CLI_PATH patch | ### Configuration diff --git a/docs/commands/app.md b/docs/commands/app.md index 97b25130..1e9a87db 100644 --- a/docs/commands/app.md +++ b/docs/commands/app.md @@ -5,17 +5,23 @@ ```shell codex-auth app [--app-path ] [--cli-path ] [--home ] [--platform win|wsl|mac] codex-auth app status [--app-path ] [--cli-path ] [--home ] [--platform win|wsl|mac] +codex-auth app patch [--cli-path ] [--home ] [--platform win|wsl|mac] +codex-auth app unpatch ``` ## Behavior -Launches the official Codex App with per-process environment overrides. +Launches the official Codex App with per-process environment overrides, or +installs a persistent CLI override for normal app launches. - `codex-auth app` launches the app. There is no `launch` subcommand. - `codex-auth app status` prints the effective defaults without downloading the CLI or launching the app. +- `codex-auth app patch` writes a user-level persistent `CODEX_CLI_PATH` patch. After Codex App is fully restarted, normal launches from the Start menu, Finder, or Dock use the managed CLI without running `codex-auth app` each time. +- `codex-auth app unpatch` removes the persistent `CODEX_CLI_PATH` patch. - `--app-path ` points to the App executable or an installed package/app directory. - `--cli-path ` is injected as `CODEX_CLI_PATH` for this launch. If it is omitted, `CODEX_CLI_PATH` is reused when set; otherwise launch downloads the latest Loongphy codext release into the accounts cache and uses that cached binary. -- `--home ` is injected as `CODEX_HOME` for this launch. +- For `app patch`, an omitted `--cli-path` intentionally uses the managed cached/latest Loongphy codext CLI instead of reusing the current process environment. +- `--home ` is injected as `CODEX_HOME` for `app` launches. For `app patch`, it selects the accounts cache and the Windows platform-state file that are prepared before persisting `CODEX_CLI_PATH`; it does not persist `CODEX_HOME`. - `--platform win|wsl|mac` selects the app runtime platform: - `win` writes the Windows global setting so the app runs the agent natively. - `wsl` writes the Windows global setting so the app runs the agent inside WSL. @@ -34,6 +40,10 @@ If `--platform` is omitted, Windows reads `$CODEX_HOME/.codex-global-state.json` and uses `wsl` when `runCodexInWindowsSubsystemForLinux` is `true`; otherwise it uses `win`. macOS defaults to `mac`. +`app patch` uses the same platform resolution and writes the same Windows +setting before persisting `CODEX_CLI_PATH`, so the selected backend keeps using +the matching native Windows or Linux codext binary. + Default downloaded CLIs are cached under: ```text @@ -49,6 +59,21 @@ Windows App launching is handled by the Windows `codex-auth.exe` build. Use a Windows app path such as `C:\Program Files\WindowsApps\...\app\Codex.exe` for `--app-path`. The WSL build does not patch or launch Windows App packages. +On Windows, `app patch` writes the user environment variable with +`[Environment]::SetEnvironmentVariable(..., 'User')` and broadcasts an +environment change. Existing Codex App processes must still be closed; some +already-running parent processes may require a fresh Explorer session, sign-out, +or reboot before Start-menu launches inherit the updated variable. + +On macOS, `app patch` sets the current `launchctl` GUI-session environment and +installs `~/Library/LaunchAgents/com.codex-auth.app-env.plist` so the variable is +restored at login. `app unpatch` unloads and removes that LaunchAgent. + +This follows the same durable-hook idea as app-bundle patchers, but it uses the +official `CODEX_CLI_PATH` hook instead of editing the app package. That avoids +MSIX/AppX package-integrity and install-directory permission problems on +Windows while still making normal app launches use the replacement CLI. + For Windows-native App launches, `--cli-path` must point to something the Windows App process can spawn. A WSL command name such as `codex-custom` is not a Windows executable path. diff --git a/src/cli/commands/app.zig b/src/cli/commands/app.zig index a858526d..36e9e928 100644 --- a/src/cli/commands/app.zig +++ b/src/cli/commands/app.zig @@ -8,6 +8,8 @@ pub fn parse(allocator: std.mem.Allocator, args: []const [:0]const u8) !types.Pa if (common.isHelpFlag(first)) return .{ .command = .{ .help = .app } }; if (std.mem.eql(u8, first, "status")) return parseOptions(allocator, .status, args[1..]); + if (std.mem.eql(u8, first, "patch")) return parseOptions(allocator, .patch, args[1..]); + if (std.mem.eql(u8, first, "unpatch")) return parseOptions(allocator, .unpatch, args[1..]); return parseOptions(allocator, .launch, args); } @@ -79,7 +81,7 @@ fn parseOptions( } if (opts.extra_args.len != 0 and action != .launch) { - return common.usageErrorResult(allocator, .app, "`app status` does not accept passthrough arguments.", .{}); + return common.usageErrorResult(allocator, .app, "`app {s}` does not accept passthrough arguments.", .{@tagName(action)}); } return .{ .command = .{ .app = opts } }; } diff --git a/src/cli/help.zig b/src/cli/help.zig index f3d01990..70ea725e 100644 --- a/src/cli/help.zig +++ b/src/cli/help.zig @@ -87,8 +87,10 @@ pub fn writeHelp( try writeCommandDetail(out, use_color, "config api disable"); try writeCommandDetail(out, use_color, "config live --interval "); try writeCommandSummary(out, use_color, "daemon --watch|--once", "Run the background auto-switch daemon"); - try writeCommandSummary(out, use_color, "app", "Launch Codex App with managed environment overrides"); + try writeCommandSummary(out, use_color, "app", "Launch or patch Codex App with managed CLI overrides"); try writeCommandDetail(out, use_color, "app status"); + try writeCommandDetail(out, use_color, "app patch"); + try writeCommandDetail(out, use_color, "app unpatch"); try out.writeAll("\n"); if (use_color) try out.writeAll(style.ansi.cyan); @@ -179,7 +181,7 @@ fn commandDescriptionForTopic(topic: HelpTopic) []const u8 { .clean => "Delete backup and stale files under accounts/.", .config => "Manage auto-switch, API, and live refresh configuration.", .daemon => "Run the background auto-switch daemon.", - .app => "Launch Codex App with CODEX_HOME and CODEX_CLI_PATH overrides.", + .app => "Launch or persistently patch Codex App CLI overrides.", }; } @@ -257,6 +259,8 @@ fn writeUsageLines(out: *std.Io.Writer, topic: HelpTopic) !void { .app => { try out.writeAll(" codex-auth app [--app-path ] [--cli-path ] [--home ] [--platform win|wsl|mac]\n"); try out.writeAll(" codex-auth app status [--app-path ] [--cli-path ] [--home ] [--platform win|wsl|mac]\n"); + try out.writeAll(" codex-auth app patch [--cli-path ] [--home ] [--platform win|wsl|mac]\n"); + try out.writeAll(" codex-auth app unpatch\n"); }, } } @@ -331,7 +335,7 @@ fn writeOptionLines(out: *std.Io.Writer, topic: HelpTopic) !void { }, .app => { try out.writeAll(" --app-path Official Codex App executable or install directory.\n"); - try out.writeAll(" --cli-path Value injected as CODEX_CLI_PATH. Defaults to cached/latest Loongphy codext.\n"); + try out.writeAll(" --cli-path Value injected or persisted as CODEX_CLI_PATH. Defaults to cached/latest Loongphy codext.\n"); try out.writeAll(" --home Value injected as CODEX_HOME for this launch.\n"); try out.writeAll(" --platform win|wsl|mac\n"); try out.writeAll(" Preselect the app platform. Defaults to the current app setting on Windows and mac on macOS.\n"); @@ -405,6 +409,8 @@ fn writeExampleLines(out: *std.Io.Writer, topic: HelpTopic) !void { .app => { try out.writeAll(" codex-auth app\n"); try out.writeAll(" codex-auth app --platform win\n"); + try out.writeAll(" codex-auth app patch --platform wsl\n"); + try out.writeAll(" codex-auth app unpatch\n"); try out.writeAll(" codex-auth app status --app-path /Applications/Codex.app --cli-path /usr/local/bin/codext\n"); }, } diff --git a/src/cli/types.zig b/src/cli/types.zig index 62e2c0e1..dd54031d 100644 --- a/src/cli/types.zig +++ b/src/cli/types.zig @@ -50,7 +50,7 @@ pub const ConfigOptions = union(enum) { }; pub const DaemonMode = enum { watch, once }; pub const DaemonOptions = struct { mode: DaemonMode }; -pub const AppAction = enum { launch, status }; +pub const AppAction = enum { launch, status, patch, unpatch }; pub const AppPlatform = enum { win, wsl, mac }; pub const AppOptions = struct { action: AppAction, diff --git a/src/workflows/app.zig b/src/workflows/app.zig index 17a91f74..fc134d5d 100644 --- a/src/workflows/app.zig +++ b/src/workflows/app.zig @@ -12,6 +12,7 @@ const app_path_env = "CODEX_AUTH_APP_PATH"; const wsl_agent_mode_key = "runCodexInWindowsSubsystemForLinux"; const codext_repo_latest_url = "https://api.github.com/repos/Loongphy/codext/releases/latest"; const codext_cache_dir_name = "codext-cli"; +const mac_persistent_env_label = "com.codex-auth.app-env"; const ValueSource = enum { explicit, env, detected, cached, downloaded, not_set }; @@ -33,15 +34,20 @@ const ResolvedPlatform = struct { pub fn handleApp(allocator: std.mem.Allocator, resolved_codex_home: []const u8, opts: types.AppOptions) !void { const effective_home = opts.home orelse resolved_codex_home; const effective_platform = try resolvePlatform(allocator, effective_home, opts.platform); - if (opts.action == .launch and !opts.dry_run) try validateAppPlatform(effective_platform.value); + if ((opts.action == .launch or opts.action == .patch) and !opts.dry_run) try validateAppPlatform(effective_platform.value); const effective_app_path = try resolveAppPath(allocator, opts); defer effective_app_path.deinit(allocator); - const effective_cli_path = try resolveCliPath(allocator, effective_home, effective_platform.value, opts, opts.action == .launch and !opts.dry_run); + const allow_download = (opts.action == .launch or opts.action == .patch) and !opts.dry_run; + const effective_cli_path = try resolveCliPath(allocator, effective_home, effective_platform.value, opts, allow_download); defer effective_cli_path.deinit(allocator); + const persistent_cli_path = if (opts.action == .status or opts.dry_run) try readPersistentCliPath(allocator) else null; + defer if (persistent_cli_path) |path| allocator.free(path); switch (opts.action) { - .status => try printStatus(effective_app_path, effective_cli_path, effective_home, effective_platform, opts), - .launch => try launchApp(allocator, effective_app_path, effective_cli_path, effective_home, effective_platform, opts), + .status => try printStatus(effective_app_path, effective_cli_path, persistent_cli_path, effective_home, effective_platform, opts), + .launch => try launchApp(allocator, effective_app_path, effective_cli_path, persistent_cli_path, effective_home, effective_platform, opts), + .patch => try patchApp(allocator, effective_app_path, effective_cli_path, persistent_cli_path, effective_home, effective_platform, opts), + .unpatch => try unpatchApp(allocator, effective_app_path, effective_cli_path, persistent_cli_path, effective_home, effective_platform, opts), } } @@ -69,7 +75,9 @@ fn resolveCliPath( allow_download: bool, ) !ResolvedValue { if (opts.cli_path) |path| return .{ .value = path, .source = .explicit }; - if (getOptionalEnv(allocator, codex_cli_path_env)) |path| return .{ .value = path, .source = .env, .owned = true }; + if (opts.action != .patch) { + if (getOptionalEnv(allocator, codex_cli_path_env)) |path| return .{ .value = path, .source = .env, .owned = true }; + } const target_platform = platform orelse nativeDefaultPlatform(); if (try cachedCodextCliPath(allocator, home, target_platform)) |path| return .{ .value = path, .source = .cached, .owned = true }; @@ -125,6 +133,7 @@ fn readWindowsWslBackendSetting(allocator: std.mem.Allocator, home: []const u8) fn printStatus( app_path: ResolvedValue, cli_path: ResolvedValue, + persistent_cli_path: ?[]const u8, home: []const u8, platform: ResolvedPlatform, opts: types.AppOptions, @@ -136,7 +145,8 @@ fn printStatus( try out.print(" app path: {s} ({s})\n", .{ app_path.value orelse "(not set)", valueSourceName(app_path.source) }); try out.print(" CODEX_HOME: {s}\n", .{home}); try out.print(" CODEX_CLI_PATH: {s} ({s})\n", .{ cli_path.value orelse "(not cached)", valueSourceName(cli_path.source) }); - try out.print(" platform: {s} ({s})\n", .{appPlatformName(platform.value), valueSourceName(platform.source)}); + try out.print(" persistent CODEX_CLI_PATH: {s}\n", .{persistent_cli_path orelse "(not set)"}); + try out.print(" platform: {s} ({s})\n", .{ appPlatformName(platform.value), valueSourceName(platform.source) }); try out.print(" dry run: {s}\n", .{if (opts.dry_run) "yes" else "no"}); try out.print(" wait: {s}\n", .{if (opts.wait) "yes" else "no"}); try out.flush(); @@ -146,6 +156,7 @@ fn launchApp( allocator: std.mem.Allocator, app_path: ResolvedValue, cli_path: ResolvedValue, + persistent_cli_path: ?[]const u8, home: []const u8, platform: ResolvedPlatform, opts: types.AppOptions, @@ -155,7 +166,7 @@ fn launchApp( return error.AppPathRequired; }; if (opts.dry_run) { - try printStatus(app_path, cli_path, home, platform, opts); + try printStatus(app_path, cli_path, persistent_cli_path, home, platform, opts); return; } try validateAppPlatform(platform.value); @@ -171,6 +182,46 @@ fn launchApp( return launchNative(allocator, target, cli_path.value, home, opts); } +fn patchApp( + allocator: std.mem.Allocator, + app_path: ResolvedValue, + cli_path: ResolvedValue, + persistent_cli_path: ?[]const u8, + home: []const u8, + platform: ResolvedPlatform, + opts: types.AppOptions, +) !void { + if (opts.dry_run) { + try printStatus(app_path, cli_path, persistent_cli_path, home, platform, opts); + return; + } + try validateAppPlatform(platform.value); + try applyAppPlatform(allocator, home, platform.value); + const target_cli = cli_path.value orelse { + try writeAppError("app patch could not resolve CODEX_CLI_PATH. Pass `--cli-path ` or allow the default Loongphy codext download.\n"); + return error.CliPathRequired; + }; + try persistCliPath(allocator, target_cli); + try writeAppOutput("persistent CODEX_CLI_PATH={s}\n", .{target_cli}); +} + +fn unpatchApp( + allocator: std.mem.Allocator, + app_path: ResolvedValue, + cli_path: ResolvedValue, + persistent_cli_path: ?[]const u8, + home: []const u8, + platform: ResolvedPlatform, + opts: types.AppOptions, +) !void { + if (opts.dry_run) { + try printStatus(app_path, cli_path, persistent_cli_path, home, platform, opts); + return; + } + try clearPersistentCliPath(allocator); + try writeAppOutput("persistent CODEX_CLI_PATH cleared\n", .{}); +} + fn appPlatformName(value: ?types.AppPlatform) []const u8 { return switch (value orelse return "(not set)") { .win => "win", @@ -194,11 +245,11 @@ fn validateAppPlatform(value: ?types.AppPlatform) !void { const platform = value orelse return; switch (platform) { .win, .wsl => if (builtin.os.tag != .windows) { - try writeAppError("app launch with `--platform win` or `--platform wsl` must run from the Windows codex-auth executable.\n"); + try writeAppError("app with `--platform win` or `--platform wsl` must run from the Windows codex-auth executable.\n"); return error.WindowsAppPlatformRequiresWindows; }, .mac => if (builtin.os.tag != .macos) { - try writeAppError("app launch with `--platform mac` must run from the macOS codex-auth executable.\n"); + try writeAppError("app with `--platform mac` must run from the macOS codex-auth executable.\n"); return error.MacAppPlatformRequiresMacOS; }, } @@ -361,6 +412,164 @@ fn fileExists(path: []const u8) bool { return true; } +fn readPersistentCliPath(allocator: std.mem.Allocator) !?[]u8 { + return switch (builtin.os.tag) { + .windows => readWindowsPersistentCliPath(allocator), + .macos => readMacPersistentCliPath(allocator), + else => null, + }; +} + +fn readWindowsPersistentCliPath(allocator: std.mem.Allocator) !?[]u8 { + var result = http_child.runChildCapture( + allocator, + &[_][]const u8{ "pwsh.exe", "-NoProfile", "-Command", "[Console]::Out.Write([Environment]::GetEnvironmentVariable('CODEX_CLI_PATH','User'))" }, + 7000, + null, + ) catch return null; + defer result.deinit(allocator); + return switch (result.term) { + .exited => |code| if (code == 0) try dupTrimmedOrNull(allocator, result.stdout) else null, + else => null, + }; +} + +fn readMacPersistentCliPath(allocator: std.mem.Allocator) !?[]u8 { + var result = http_child.runChildCapture( + allocator, + &[_][]const u8{ "launchctl", "getenv", codex_cli_path_env }, + 7000, + null, + ) catch return null; + defer result.deinit(allocator); + return switch (result.term) { + .exited => |code| if (code == 0) try dupTrimmedOrNull(allocator, result.stdout) else null, + else => null, + }; +} + +fn dupTrimmedOrNull(allocator: std.mem.Allocator, value: []const u8) !?[]u8 { + const trimmed = std.mem.trim(u8, value, " \t\r\n"); + if (trimmed.len == 0) return null; + return try allocator.dupe(u8, trimmed); +} + +fn persistCliPath(allocator: std.mem.Allocator, cli_path: []const u8) !void { + switch (builtin.os.tag) { + .windows => try persistWindowsCliPath(allocator, cli_path), + .macos => try persistMacCliPath(allocator, cli_path), + else => { + try writeAppError("app patch is supported only from the Windows or macOS codex-auth executable.\n"); + return error.UnsupportedPlatform; + }, + } +} + +fn clearPersistentCliPath(allocator: std.mem.Allocator) !void { + switch (builtin.os.tag) { + .windows => try clearWindowsPersistentCliPath(allocator), + .macos => try clearMacPersistentCliPath(allocator), + else => { + try writeAppError("app unpatch is supported only from the Windows or macOS codex-auth executable.\n"); + return error.UnsupportedPlatform; + }, + } +} + +fn persistWindowsCliPath(allocator: std.mem.Allocator, cli_path: []const u8) !void { + const cli_quoted = try psSingleQuoteAlloc(allocator, cli_path); + defer allocator.free(cli_quoted); + const script = try std.fmt.allocPrint( + allocator, + "$ErrorActionPreference='Stop'; [Environment]::SetEnvironmentVariable('CODEX_CLI_PATH',{s},'User'); try {{ $sig='[DllImport(\"user32.dll\", SetLastError=true, CharSet=CharSet.Auto)] public static extern IntPtr SendMessageTimeout(IntPtr hWnd, int Msg, UIntPtr wParam, string lParam, int fuFlags, int uTimeout, out UIntPtr lpdwResult);'; Add-Type -MemberDefinition $sig -Name NativeMethods -Namespace CodexAuthEnv -ErrorAction SilentlyContinue; $r=[UIntPtr]::Zero; [CodexAuthEnv.NativeMethods]::SendMessageTimeout([IntPtr]0xffff,0x1A,[UIntPtr]::Zero,'Environment',0x2,5000,[ref]$r) | Out-Null }} catch {{ }}", + .{cli_quoted}, + ); + defer allocator.free(script); + try runChecked(allocator, &[_][]const u8{ "pwsh.exe", "-NoProfile", "-Command", script }, 7000); +} + +fn clearWindowsPersistentCliPath(allocator: std.mem.Allocator) !void { + const script = + "$ErrorActionPreference='Stop'; [Environment]::SetEnvironmentVariable('CODEX_CLI_PATH',$null,'User'); try { $sig='[DllImport(\"user32.dll\", SetLastError=true, CharSet=CharSet.Auto)] public static extern IntPtr SendMessageTimeout(IntPtr hWnd, int Msg, UIntPtr wParam, string lParam, int fuFlags, int uTimeout, out UIntPtr lpdwResult);'; Add-Type -MemberDefinition $sig -Name NativeMethods -Namespace CodexAuthEnv -ErrorAction SilentlyContinue; $r=[UIntPtr]::Zero; [CodexAuthEnv.NativeMethods]::SendMessageTimeout([IntPtr]0xffff,0x1A,[UIntPtr]::Zero,'Environment',0x2,5000,[ref]$r) | Out-Null } catch { }"; + try runChecked(allocator, &[_][]const u8{ "pwsh.exe", "-NoProfile", "-Command", script }, 7000); +} + +fn persistMacCliPath(allocator: std.mem.Allocator, cli_path: []const u8) !void { + const plist_path = try macPersistentEnvPlistPath(allocator); + defer allocator.free(plist_path); + const plist = try macPersistentEnvPlistText(allocator, cli_path); + defer allocator.free(plist); + + if (std.fs.path.dirname(plist_path)) |dir| { + try std.Io.Dir.cwd().createDirPath(app_runtime.io(), dir); + } + try std.Io.Dir.cwd().writeFile(app_runtime.io(), .{ .sub_path = plist_path, .data = plist }); + _ = runChecked(allocator, &[_][]const u8{ "launchctl", "unload", plist_path }, 7000) catch {}; + try runChecked(allocator, &[_][]const u8{ "launchctl", "load", plist_path }, 7000); + try runChecked(allocator, &[_][]const u8{ "launchctl", "setenv", codex_cli_path_env, cli_path }, 7000); +} + +fn clearMacPersistentCliPath(allocator: std.mem.Allocator) !void { + const plist_path = try macPersistentEnvPlistPath(allocator); + defer allocator.free(plist_path); + _ = runChecked(allocator, &[_][]const u8{ "launchctl", "unsetenv", codex_cli_path_env }, 7000) catch {}; + _ = runChecked(allocator, &[_][]const u8{ "launchctl", "unload", plist_path }, 7000) catch {}; + std.Io.Dir.deleteFileAbsolute(app_runtime.io(), plist_path) catch |err| switch (err) { + error.FileNotFound => {}, + else => return err, + }; +} + +fn macPersistentEnvPlistPath(allocator: std.mem.Allocator) ![]u8 { + const home = getOptionalEnv(allocator, "HOME") orelse return error.EnvironmentVariableNotFound; + defer allocator.free(@constCast(home)); + return try std.fs.path.join(allocator, &.{ home, "Library", "LaunchAgents", "com.codex-auth.app-env.plist" }); +} + +fn macPersistentEnvPlistText(allocator: std.mem.Allocator, cli_path: []const u8) ![]u8 { + const escaped_path = try xmlEscapeAlloc(allocator, cli_path); + defer allocator.free(escaped_path); + return try std.fmt.allocPrint( + allocator, + \\ + \\ + \\ + \\ + \\ Label + \\ {s} + \\ ProgramArguments + \\ + \\ /bin/launchctl + \\ setenv + \\ CODEX_CLI_PATH + \\ {s} + \\ + \\ RunAtLoad + \\ + \\ + \\ + \\ + , + .{ mac_persistent_env_label, escaped_path }, + ); +} + +fn xmlEscapeAlloc(allocator: std.mem.Allocator, value: []const u8) ![]u8 { + var out = std.ArrayList(u8).empty; + errdefer out.deinit(allocator); + for (value) |ch| { + switch (ch) { + '&' => try out.appendSlice(allocator, "&"), + '<' => try out.appendSlice(allocator, "<"), + '>' => try out.appendSlice(allocator, ">"), + '"' => try out.appendSlice(allocator, """), + '\'' => try out.appendSlice(allocator, "'"), + else => try out.append(allocator, ch), + } + } + return try out.toOwnedSlice(allocator); +} + fn detectInstalledAppPath(allocator: std.mem.Allocator) !?[]u8 { return switch (builtin.os.tag) { .windows => try detectWindowsInstalledAppPath(allocator), @@ -669,6 +878,14 @@ fn writeAppInfo(comptime format: []const u8, args: anytype) !void { try out.flush(); } +fn writeAppOutput(comptime format: []const u8, args: anytype) !void { + var buffer: [1024]u8 = undefined; + var writer = std.Io.File.stdout().writer(app_runtime.io(), &buffer); + const out = &writer.interface; + try out.print(format, args); + try out.flush(); +} + fn launchWindowsViaPowerShell( allocator: std.mem.Allocator, app_path: []const u8, diff --git a/tests/cli_behavior_test.zig b/tests/cli_behavior_test.zig index a390ed6f..b5f2058e 100644 --- a/tests/cli_behavior_test.zig +++ b/tests/cli_behavior_test.zig @@ -132,6 +132,40 @@ test "Scenario: Given removed app launch subcommand when parsing then usage erro try expectUsageError(result, .app, "unexpected argument `launch` for `app`."); } +test "Scenario: Given app patch when parsing then patch action is preserved" { + const gpa = std.testing.allocator; + const args = [_][:0]const u8{ "codex-auth", "app", "patch", "--platform", "wsl", "--dry-run" }; + var result = try cli.commands.parseArgs(gpa, &args); + defer cli.commands.freeParseResult(gpa, &result); + + switch (result) { + .command => |cmd| switch (cmd) { + .app => |opts| { + try std.testing.expectEqual(cli.types.AppAction.patch, opts.action); + try std.testing.expectEqual(cli.types.AppPlatform.wsl, opts.platform.?); + try std.testing.expect(opts.dry_run); + }, + else => return error.TestExpectedEqual, + }, + else => return error.TestExpectedEqual, + } +} + +test "Scenario: Given app unpatch when parsing then unpatch action is preserved" { + const gpa = std.testing.allocator; + const args = [_][:0]const u8{ "codex-auth", "app", "unpatch" }; + var result = try cli.commands.parseArgs(gpa, &args); + defer cli.commands.freeParseResult(gpa, &result); + + switch (result) { + .command => |cmd| switch (cmd) { + .app => |opts| try std.testing.expectEqual(cli.types.AppAction.unpatch, opts.action), + else => return error.TestExpectedEqual, + }, + else => return error.TestExpectedEqual, + } +} + fn expectedImportMarker(outcome: registry.ImportOutcome) []const u8 { return switch (outcome) { .imported => if (builtin.os.tag == .windows) "[+]" else "✓", From 1106ff5f7aebd25f3a9046f2f36867439f37738b Mon Sep 17 00:00:00 2001 From: Loongphy Date: Sat, 2 May 2026 09:18:21 +0800 Subject: [PATCH 03/16] Guard persistent Codex app CLI patch by version --- docs/commands/app.md | 33 +++- src/cli/help.zig | 4 +- src/main.zig | 8 + src/root.zig | 1 + src/workflows/app.zig | 360 +++++++++++++++++++++++++++++++++++++++++- 5 files changed, 394 insertions(+), 12 deletions(-) diff --git a/docs/commands/app.md b/docs/commands/app.md index 1e9a87db..20fdb8d8 100644 --- a/docs/commands/app.md +++ b/docs/commands/app.md @@ -16,7 +16,7 @@ installs a persistent CLI override for normal app launches. - `codex-auth app` launches the app. There is no `launch` subcommand. - `codex-auth app status` prints the effective defaults without downloading the CLI or launching the app. -- `codex-auth app patch` writes a user-level persistent `CODEX_CLI_PATH` patch. After Codex App is fully restarted, normal launches from the Start menu, Finder, or Dock use the managed CLI without running `codex-auth app` each time. +- `codex-auth app patch` writes a user-level persistent `CODEX_CLI_PATH` patch. After Codex App is fully restarted, normal launches from the Start menu, Finder, or Dock go through a generated guarded shim without running `codex-auth app` each time. - `codex-auth app unpatch` removes the persistent `CODEX_CLI_PATH` patch. - `--app-path ` points to the App executable or an installed package/app directory. - `--cli-path ` is injected as `CODEX_CLI_PATH` for this launch. If it is omitted, `CODEX_CLI_PATH` is reused when set; otherwise launch downloads the latest Loongphy codext release into the accounts cache and uses that cached binary. @@ -42,7 +42,8 @@ uses `win`. macOS defaults to `mac`. `app patch` uses the same platform resolution and writes the same Windows setting before persisting `CODEX_CLI_PATH`, so the selected backend keeps using -the matching native Windows or Linux codext binary. +the matching native Windows or Linux codext binary while the installed app +version still matches the patch. Default downloaded CLIs are cached under: @@ -61,13 +62,29 @@ Windows app path such as `C:\Program Files\WindowsApps\...\app\Codex.exe` for On Windows, `app patch` writes the user environment variable with `[Environment]::SetEnvironmentVariable(..., 'User')` and broadcasts an -environment change. Existing Codex App processes must still be closed; some +environment change. The value points to a generated guarded shim under +`$CODEX_HOME/accounts/codext-cli/app-patch//`, not directly to the +codext binary. Existing Codex App processes must still be closed; some already-running parent processes may require a fresh Explorer session, sign-out, or reboot before Start-menu launches inherit the updated variable. On macOS, `app patch` sets the current `launchctl` GUI-session environment and installs `~/Library/LaunchAgents/com.codex-auth.app-env.plist` so the variable is -restored at login. `app unpatch` unloads and removes that LaunchAgent. +restored at login. The LaunchAgent also points at a generated guarded shim. +`app unpatch` unloads and removes that LaunchAgent. + +The guarded shim is version-bound: + +- Windows MSIX/AppX patches are tied to the package install path, which includes + the AppX package version. +- WSL patches use the same package-root guard after Windows paths are converted + to WSL paths. +- macOS patches are tied to the app bundle's `CFBundleVersion`. + +If the app updates or a different Codex-family app inherits the same user-level +`CODEX_CLI_PATH`, the shim does not continue using the patched codext binary. It +falls back to the bundled/default CLI for that app where available, so a new app +version requires running `codex-auth app patch` again. This follows the same durable-hook idea as app-bundle patchers, but it uses the official `CODEX_CLI_PATH` hook instead of editing the app package. That avoids @@ -84,7 +101,7 @@ the app executable inside `Contents/MacOS`. The packaged macOS app normally uses injects `CODEX_CLI_PATH` and takes precedence over that bundled resource. The Electron app currently appends `--analytics-default-enabled` when it starts -`app-server`. A plain `CODEX_CLI_PATH` override changes which binary is executed -but does not remove that argument. To suppress it at launch time, point -`--cli-path` at a wrapper/shim that filters that argument before execing the real -codext binary. +`app-server`. The `CODEX_CLI_PATH` override changes which binary is executed but +does not remove that argument. To suppress it at launch time, point `--cli-path` +at a wrapper/shim that filters that argument before execing the real codext +binary; `app patch` will still wrap that path in its own version guard. diff --git a/src/cli/help.zig b/src/cli/help.zig index 70ea725e..1b2358b3 100644 --- a/src/cli/help.zig +++ b/src/cli/help.zig @@ -87,7 +87,7 @@ pub fn writeHelp( try writeCommandDetail(out, use_color, "config api disable"); try writeCommandDetail(out, use_color, "config live --interval "); try writeCommandSummary(out, use_color, "daemon --watch|--once", "Run the background auto-switch daemon"); - try writeCommandSummary(out, use_color, "app", "Launch or patch Codex App with managed CLI overrides"); + try writeCommandSummary(out, use_color, "app", "Launch or version-bound patch Codex App CLI overrides"); try writeCommandDetail(out, use_color, "app status"); try writeCommandDetail(out, use_color, "app patch"); try writeCommandDetail(out, use_color, "app unpatch"); @@ -181,7 +181,7 @@ fn commandDescriptionForTopic(topic: HelpTopic) []const u8 { .clean => "Delete backup and stale files under accounts/.", .config => "Manage auto-switch, API, and live refresh configuration.", .daemon => "Run the background auto-switch daemon.", - .app => "Launch or persistently patch Codex App CLI overrides.", + .app => "Launch or persistently patch version-bound Codex App CLI overrides.", }; } diff --git a/src/main.zig b/src/main.zig index 42008362..27a3cd47 100644 --- a/src/main.zig +++ b/src/main.zig @@ -2,5 +2,13 @@ const std = @import("std"); const codex_auth = @import("root.zig"); pub fn main(init: std.process.Init.Minimal) !void { + var gpa: std.heap.DebugAllocator(.{}) = .init; + defer std.debug.assert(gpa.deinit() == .ok); + const allocator = gpa.allocator(); + const self_exe = try std.process.executablePathAlloc(codex_auth.core.runtime.io(), allocator); + defer allocator.free(self_exe); + if (codex_auth.app_workflow.isGuardedShimExecutablePath(self_exe)) { + return codex_auth.app_workflow.runGuardedAppShim(allocator, init); + } return codex_auth.workflows.main(init); } diff --git a/src/root.zig b/src/root.zig index f2f1dbf0..a12fffd6 100644 --- a/src/root.zig +++ b/src/root.zig @@ -12,6 +12,7 @@ pub const auth = struct { pub const auto = @import("auto/root.zig"); pub const cli = @import("cli/root.zig"); pub const workflows = @import("workflows/root.zig"); +pub const app_workflow = @import("workflows/app.zig"); pub const core = struct { pub const compat_fs = @import("core/compat_fs.zig"); diff --git a/src/workflows/app.zig b/src/workflows/app.zig index fc134d5d..17c0069b 100644 --- a/src/workflows/app.zig +++ b/src/workflows/app.zig @@ -12,6 +12,9 @@ const app_path_env = "CODEX_AUTH_APP_PATH"; const wsl_agent_mode_key = "runCodexInWindowsSubsystemForLinux"; const codext_repo_latest_url = "https://api.github.com/repos/Loongphy/codext/releases/latest"; const codext_cache_dir_name = "codext-cli"; +const guarded_shim_dir_name = "app-patch"; +const guarded_script_name = "codex-auth-app-shim"; +const guarded_windows_shim_name = "codex-auth-app-shim.exe"; const mac_persistent_env_label = "com.codex-auth.app-env"; const ValueSource = enum { explicit, env, detected, cached, downloaded, not_set }; @@ -201,8 +204,18 @@ fn patchApp( try writeAppError("app patch could not resolve CODEX_CLI_PATH. Pass `--cli-path ` or allow the default Loongphy codext download.\n"); return error.CliPathRequired; }; - try persistCliPath(allocator, target_cli); - try writeAppOutput("persistent CODEX_CLI_PATH={s}\n", .{target_cli}); + const target_app = app_path.value orelse { + try writeAppError("app patch could not find the installed Codex app. Pass `--app-path ` or set CODEX_AUTH_APP_PATH.\n"); + return error.AppPathRequired; + }; + const launch_path = try resolveLaunchPath(allocator, target_app); + defer allocator.free(launch_path); + const target_platform = platform.value orelse return error.UnsupportedPlatform; + const shim_path = try installGuardedCliShim(allocator, home, launch_path, target_cli, target_platform); + defer allocator.free(shim_path); + try persistCliPath(allocator, shim_path); + try writeAppOutput("persistent CODEX_CLI_PATH={s}\n", .{shim_path}); + try writeAppOutput("guarded target CLI={s}\n", .{target_cli}); } fn unpatchApp( @@ -412,6 +425,318 @@ fn fileExists(path: []const u8) bool { return true; } +pub fn isGuardedShimExecutablePath(path: []const u8) bool { + const base = std.fs.path.basename(path); + return std.mem.eql(u8, base, guarded_windows_shim_name) or std.mem.eql(u8, base, guarded_script_name); +} + +const GuardedShimConfig = struct { + expected_root: []u8, + target_cli: []u8, + + fn deinit(self: GuardedShimConfig, allocator: std.mem.Allocator) void { + allocator.free(self.expected_root); + allocator.free(self.target_cli); + } +}; + +pub fn runGuardedAppShim(allocator: std.mem.Allocator, init: std.process.Init.Minimal) !void { + var arena_state = std.heap.ArenaAllocator.init(allocator); + defer arena_state.deinit(); + const args = try init.args.toSlice(arena_state.allocator()); + + const self_exe = try std.process.executablePathAlloc(app_runtime.io(), allocator); + defer allocator.free(self_exe); + const config = try readGuardedShimConfig(allocator, self_exe); + defer config.deinit(allocator); + + const cwd_z = try std.process.currentPathAlloc(app_runtime.io(), allocator); + defer allocator.free(cwd_z); + const cwd = std.mem.sliceTo(cwd_z, 0); + + const target = if (pathHasRoot(cwd, config.expected_root, builtin.os.tag == .windows)) + try allocator.dupe(u8, config.target_cli) + else + try fallbackCliForCurrentApp(allocator, cwd); + defer allocator.free(target); + + var argv = std.ArrayList([]const u8).empty; + defer argv.deinit(allocator); + try argv.append(allocator, target); + for (args[1..]) |arg| try argv.append(allocator, std.mem.sliceTo(arg, 0)); + + var env_map = try registry.getEnvMap(allocator); + defer env_map.deinit(); + var child = try std.process.spawn(app_runtime.io(), .{ + .argv = argv.items, + .environ_map = &env_map, + .stdin = .inherit, + .stdout = .inherit, + .stderr = .inherit, + }); + const term = try child.wait(app_runtime.io()); + switch (term) { + .exited => |code| std.process.exit(@intCast(@min(code, 255))), + else => std.process.exit(1), + } +} + +fn readGuardedShimConfig(allocator: std.mem.Allocator, self_exe: []const u8) !GuardedShimConfig { + const config_path = try std.fmt.allocPrint(allocator, "{s}.json", .{self_exe}); + defer allocator.free(config_path); + var file = try std.Io.Dir.cwd().openFile(app_runtime.io(), config_path, .{}); + defer file.close(app_runtime.io()); + const data = try registry.readFileAlloc(file, allocator, 1024 * 1024); + defer allocator.free(data); + const parsed = try std.json.parseFromSlice(std.json.Value, allocator, data, .{}); + defer parsed.deinit(); + const object = switch (parsed.value) { + .object => |object| object, + else => return error.InvalidGuardedShimConfig, + }; + const expected_root = switch (object.get("expected_root") orelse return error.InvalidGuardedShimConfig) { + .string => |value| try allocator.dupe(u8, value), + else => return error.InvalidGuardedShimConfig, + }; + errdefer allocator.free(expected_root); + const target_cli = switch (object.get("target_cli") orelse return error.InvalidGuardedShimConfig) { + .string => |value| try allocator.dupe(u8, value), + else => return error.InvalidGuardedShimConfig, + }; + return .{ .expected_root = expected_root, .target_cli = target_cli }; +} + +fn fallbackCliForCurrentApp(allocator: std.mem.Allocator, cwd: []const u8) ![]u8 { + const candidates = [_][]const u8{ "codex.exe", "codex" }; + for (candidates) |name| { + const candidate = try std.fs.path.join(allocator, &.{ cwd, name }); + if (fileExists(candidate)) return candidate; + allocator.free(candidate); + } + try writeAppError("codex-auth app shim skipped the guarded override because the app package changed, but no bundled fallback CLI was found in the current app resources.\n"); + return error.GuardedShimFallbackNotFound; +} + +fn pathHasRoot(path: []const u8, root: []const u8, case_insensitive: bool) bool { + if (path.len < root.len) return false; + const path_prefix = path[0..root.len]; + const prefix_matches = if (case_insensitive) + std.ascii.eqlIgnoreCase(path_prefix, root) + else + std.mem.eql(u8, path_prefix, root); + if (!prefix_matches) return false; + if (path.len == root.len) return true; + return path[root.len] == '/' or path[root.len] == '\\'; +} + +fn installGuardedCliShim( + allocator: std.mem.Allocator, + home: []const u8, + app_launch_path: []const u8, + target_cli: []const u8, + platform: types.AppPlatform, +) ![]u8 { + const expected_root = try appGuardRootAlloc(allocator, app_launch_path, platform); + defer allocator.free(expected_root); + const shim_dir = try std.fs.path.join(allocator, &.{ home, "accounts", codext_cache_dir_name, guarded_shim_dir_name, appPlatformName(platform) }); + defer allocator.free(shim_dir); + try std.Io.Dir.cwd().createDirPath(app_runtime.io(), shim_dir); + + return switch (platform) { + .win => try installWindowsGuardedCliShim(allocator, shim_dir, expected_root, target_cli), + .wsl => try installWslGuardedCliShim(allocator, shim_dir, home, expected_root, target_cli), + .mac => try installMacGuardedCliShim(allocator, shim_dir, expected_root, target_cli), + }; +} + +fn installWindowsGuardedCliShim( + allocator: std.mem.Allocator, + shim_dir: []const u8, + expected_root: []const u8, + target_cli: []const u8, +) ![]u8 { + const self_exe = try std.process.executablePathAlloc(app_runtime.io(), allocator); + defer allocator.free(self_exe); + const shim_path = try std.fs.path.join(allocator, &.{ shim_dir, guarded_windows_shim_name }); + errdefer allocator.free(shim_path); + try std.Io.Dir.copyFileAbsolute(self_exe, shim_path, app_runtime.io(), .{ .replace = true, .make_path = true }); + const config_path = try std.fmt.allocPrint(allocator, "{s}.json", .{shim_path}); + defer allocator.free(config_path); + const config = try guardedShimConfigText(allocator, expected_root, target_cli); + defer allocator.free(config); + try std.Io.Dir.cwd().writeFile(app_runtime.io(), .{ .sub_path = config_path, .data = config }); + return shim_path; +} + +fn installWslGuardedCliShim( + allocator: std.mem.Allocator, + shim_dir: []const u8, + home: []const u8, + expected_root: []const u8, + target_cli: []const u8, +) ![]u8 { + const expected_wsl = try windowsPathToWslPathAlloc(allocator, expected_root); + defer allocator.free(expected_wsl); + const target_wsl = try windowsPathToWslPathAlloc(allocator, target_cli); + defer allocator.free(target_wsl); + const home_wsl = try windowsPathToWslPathAlloc(allocator, home); + defer allocator.free(home_wsl); + const script = try wslGuardedShimScript(allocator, expected_wsl, target_wsl, home_wsl); + defer allocator.free(script); + const shim_path = try std.fs.path.join(allocator, &.{ shim_dir, guarded_script_name }); + errdefer allocator.free(shim_path); + try writeExecutableTextFile(shim_path, script); + return shim_path; +} + +fn installMacGuardedCliShim( + allocator: std.mem.Allocator, + shim_dir: []const u8, + expected_root: []const u8, + target_cli: []const u8, +) ![]u8 { + const expected_version = try readMacBundleVersion(allocator, expected_root); + defer allocator.free(expected_version); + const script = try macGuardedShimScript(allocator, expected_root, expected_version, target_cli); + defer allocator.free(script); + const shim_path = try std.fs.path.join(allocator, &.{ shim_dir, guarded_script_name }); + errdefer allocator.free(shim_path); + try writeExecutableTextFile(shim_path, script); + return shim_path; +} + +fn writeExecutableTextFile(path: []const u8, data: []const u8) !void { + try std.Io.Dir.cwd().writeFile(app_runtime.io(), .{ .sub_path = path, .data = data }); + if (builtin.os.tag != .windows) { + try std.Io.Dir.cwd().setFilePermissions(app_runtime.io(), path, std.Io.File.Permissions.fromMode(0o755), .{}); + } +} + +fn guardedShimConfigText(allocator: std.mem.Allocator, expected_root: []const u8, target_cli: []const u8) ![]u8 { + const escaped_root = try jsonEscapeAlloc(allocator, expected_root); + defer allocator.free(escaped_root); + const escaped_target = try jsonEscapeAlloc(allocator, target_cli); + defer allocator.free(escaped_target); + return try std.fmt.allocPrint( + allocator, + "{{\"expected_root\":\"{s}\",\"target_cli\":\"{s}\"}}\n", + .{ escaped_root, escaped_target }, + ); +} + +fn wslGuardedShimScript(allocator: std.mem.Allocator, expected_root: []const u8, target_cli: []const u8, fallback_home: []const u8) ![]u8 { + const expected_quoted = try shellSingleQuoteAlloc(allocator, expected_root); + defer allocator.free(expected_quoted); + const target_quoted = try shellSingleQuoteAlloc(allocator, target_cli); + defer allocator.free(target_quoted); + const fallback_home_quoted = try shellSingleQuoteAlloc(allocator, fallback_home); + defer allocator.free(fallback_home_quoted); + return try std.fmt.allocPrint( + allocator, + \\#!/usr/bin/env bash + \\set -e + \\expected={s} + \\target={s} + \\fallback_home={s} + \\case "$PWD" in + \\ "$expected"|"$expected"/*) exec "$target" "$@" ;; + \\esac + \\for fallback in "${{CODEX_HOME:-}}/bin/wsl/codex" "$fallback_home/bin/wsl/codex"; do + \\ if [ -x "$fallback" ]; then exec "$fallback" "$@"; fi + \\done + \\printf '%s\n' 'codex-auth app shim skipped the guarded override because the app package changed, but no bundled fallback CLI was found.' >&2 + \\exit 126 + \\ + , + .{ expected_quoted, target_quoted, fallback_home_quoted }, + ); +} + +fn macGuardedShimScript(allocator: std.mem.Allocator, expected_root: []const u8, expected_version: []const u8, target_cli: []const u8) ![]u8 { + const root_quoted = try shellSingleQuoteAlloc(allocator, expected_root); + defer allocator.free(root_quoted); + const version_quoted = try shellSingleQuoteAlloc(allocator, expected_version); + defer allocator.free(version_quoted); + const target_quoted = try shellSingleQuoteAlloc(allocator, target_cli); + defer allocator.free(target_quoted); + return try std.fmt.allocPrint( + allocator, + \\#!/usr/bin/env bash + \\set -e + \\expected_root={s} + \\expected_version={s} + \\target={s} + \\current_version=$(/usr/bin/defaults read "$expected_root/Contents/Info" CFBundleVersion 2>/dev/null || true) + \\if [ "$current_version" = "$expected_version" ]; then + \\ exec "$target" "$@" + \\fi + \\for fallback in "$PWD/codex" "$expected_root/Contents/Resources/codex"; do + \\ if [ -x "$fallback" ]; then exec "$fallback" "$@"; fi + \\done + \\printf '%s\n' 'codex-auth app shim skipped the guarded override because the app bundle version changed, but no bundled fallback CLI was found.' >&2 + \\exit 126 + \\ + , + .{ root_quoted, version_quoted, target_quoted }, + ); +} + +fn appGuardRootAlloc(allocator: std.mem.Allocator, app_launch_path: []const u8, platform: types.AppPlatform) ![]u8 { + if (platform == .mac) { + if (std.mem.indexOf(u8, app_launch_path, ".app")) |idx| { + return try allocator.dupe(u8, app_launch_path[0 .. idx + ".app".len]); + } + } + + if (indexOfIgnoreCase(app_launch_path, "\\app\\codex.exe")) |idx| return try allocator.dupe(u8, app_launch_path[0..idx]); + if (indexOfIgnoreCase(app_launch_path, "/app/codex.exe")) |idx| return try allocator.dupe(u8, app_launch_path[0..idx]); + if (std.fs.path.dirname(app_launch_path)) |dir| { + if (std.fs.path.dirname(dir)) |parent| return try allocator.dupe(u8, parent); + return try allocator.dupe(u8, dir); + } + return try allocator.dupe(u8, app_launch_path); +} + +fn readMacBundleVersion(allocator: std.mem.Allocator, app_root: []const u8) ![]u8 { + const info_path = try std.fs.path.join(allocator, &.{ app_root, "Contents", "Info.plist" }); + defer allocator.free(info_path); + var result = try http_child.runChildCapture( + allocator, + &[_][]const u8{ "/usr/bin/plutil", "-extract", "CFBundleVersion", "raw", "-o", "-", info_path }, + 7000, + null, + ); + defer result.deinit(allocator); + const trimmed = std.mem.trim(u8, result.stdout, " \t\r\n"); + if (trimmed.len == 0) return error.MacBundleVersionNotFound; + return try allocator.dupe(u8, trimmed); +} + +fn windowsPathToWslPathAlloc(allocator: std.mem.Allocator, path: []const u8) ![]u8 { + if (std.mem.startsWith(u8, path, "/")) return try allocator.dupe(u8, path); + if (path.len >= 3 and std.ascii.isAlphabetic(path[0]) and path[1] == ':' and (path[2] == '\\' or path[2] == '/')) { + var out = std.ArrayList(u8).empty; + errdefer out.deinit(allocator); + try out.appendSlice(allocator, "/mnt/"); + try out.append(allocator, std.ascii.toLower(path[0])); + for (path[2..]) |ch| { + try out.append(allocator, if (ch == '\\') '/' else ch); + } + return try out.toOwnedSlice(allocator); + } + return try allocator.dupe(u8, path); +} + +fn indexOfIgnoreCase(haystack: []const u8, needle: []const u8) ?usize { + if (needle.len == 0) return 0; + if (haystack.len < needle.len) return null; + var i: usize = 0; + while (i + needle.len <= haystack.len) : (i += 1) { + if (std.ascii.eqlIgnoreCase(haystack[i .. i + needle.len], needle)) return i; + } + return null; +} + fn readPersistentCliPath(allocator: std.mem.Allocator) !?[]u8 { return switch (builtin.os.tag) { .windows => readWindowsPersistentCliPath(allocator), @@ -570,6 +895,37 @@ fn xmlEscapeAlloc(allocator: std.mem.Allocator, value: []const u8) ![]u8 { return try out.toOwnedSlice(allocator); } +fn jsonEscapeAlloc(allocator: std.mem.Allocator, value: []const u8) ![]u8 { + var out = std.ArrayList(u8).empty; + errdefer out.deinit(allocator); + for (value) |ch| { + switch (ch) { + '\\' => try out.appendSlice(allocator, "\\\\"), + '"' => try out.appendSlice(allocator, "\\\""), + '\n' => try out.appendSlice(allocator, "\\n"), + '\r' => try out.appendSlice(allocator, "\\r"), + '\t' => try out.appendSlice(allocator, "\\t"), + else => try out.append(allocator, ch), + } + } + return try out.toOwnedSlice(allocator); +} + +fn shellSingleQuoteAlloc(allocator: std.mem.Allocator, value: []const u8) ![]u8 { + var out = std.ArrayList(u8).empty; + errdefer out.deinit(allocator); + try out.append(allocator, '\''); + for (value) |ch| { + if (ch == '\'') { + try out.appendSlice(allocator, "'\\''"); + } else { + try out.append(allocator, ch); + } + } + try out.append(allocator, '\''); + return try out.toOwnedSlice(allocator); +} + fn detectInstalledAppPath(allocator: std.mem.Allocator) !?[]u8 { return switch (builtin.os.tag) { .windows => try detectWindowsInstalledAppPath(allocator), From 957f492d0ef7a9f9b230554381ddc1b19546da3e Mon Sep 17 00:00:00 2001 From: Loongphy Date: Thu, 14 May 2026 11:10:13 +0800 Subject: [PATCH 04/16] fix: flatten managed codext app cache --- docs/commands/app.md | 6 ++- src/workflows/app.zig | 108 ++++++++++++++++++------------------------ 2 files changed, 52 insertions(+), 62 deletions(-) diff --git a/docs/commands/app.md b/docs/commands/app.md index 20fdb8d8..fc544186 100644 --- a/docs/commands/app.md +++ b/docs/commands/app.md @@ -48,7 +48,7 @@ version still matches the patch. Default downloaded CLIs are cached under: ```text -$CODEX_HOME/accounts/codext-cli///codex +$CODEX_HOME/accounts/codext-cli/codex- ``` On Windows, the default download prepares both the Windows-native and WSL Linux @@ -73,6 +73,10 @@ installs `~/Library/LaunchAgents/com.codex-auth.app-env.plist` so the variable i restored at login. The LaunchAgent also points at a generated guarded shim. `app unpatch` unloads and removes that LaunchAgent. +This is only needed for persistent GUI launches from Finder, Dock, Spotlight, or +login-restored sessions. One-shot `codex-auth app` launches do not need the +LaunchAgent; they pass `CODEX_CLI_PATH` directly to the launched process. + The guarded shim is version-bound: - Windows MSIX/AppX patches are tied to the package install path, which includes diff --git a/src/workflows/app.zig b/src/workflows/app.zig index 17c0069b..ddce6a19 100644 --- a/src/workflows/app.zig +++ b/src/workflows/app.zig @@ -969,44 +969,17 @@ fn expandTildePath(allocator: std.mem.Allocator, path: []const u8) ![]u8 { } fn cachedCodextCliPath(allocator: std.mem.Allocator, home: []const u8, platform: types.AppPlatform) !?[]u8 { - const platform_name = codextPlatformCacheName(platform); - const root_path = try std.fs.path.join(allocator, &.{ home, "accounts", codext_cache_dir_name }); - defer allocator.free(root_path); - - var root = std.Io.Dir.cwd().openDir(app_runtime.io(), root_path, .{ .iterate = true }) catch |err| switch (err) { - error.FileNotFound => return null, - else => return err, - }; - defer root.close(app_runtime.io()); - - var best: ?[]u8 = null; - var best_tag: ?[]u8 = null; - var it = root.iterate(); - while (try it.next(app_runtime.io())) |entry| { - if (entry.kind != .directory) continue; - const candidate = try findCachedCodextExecutable(allocator, root_path, entry.name, platform_name, platform) orelse continue; - if (fileExists(candidate)) { - if (best_tag == null or std.mem.order(u8, entry.name, best_tag.?) == .gt) { - if (best) |old| allocator.free(old); - if (best_tag) |old| allocator.free(old); - best = candidate; - best_tag = try allocator.dupe(u8, entry.name); - } else { - allocator.free(candidate); - } - } else { - allocator.free(candidate); - } - } - if (best_tag) |tag| allocator.free(tag); - return best; + const candidate = try managedCodextExecutablePath(allocator, home, platform); + if (fileExists(candidate)) return candidate; + allocator.free(candidate); + return null; } fn downloadDefaultCodextCli(allocator: std.mem.Allocator, home: []const u8, platform: types.AppPlatform) ![]u8 { const release = try fetchLatestCodextRelease(allocator); defer release.deinit(allocator); - const cache_root = try std.fs.path.join(allocator, &.{ home, "accounts", codext_cache_dir_name, release.tag }); + const cache_root = try std.fs.path.join(allocator, &.{ home, "accounts", codext_cache_dir_name }); defer allocator.free(cache_root); try std.Io.Dir.cwd().createDirPath(app_runtime.io(), cache_root); @@ -1022,7 +995,7 @@ fn downloadDefaultCodextCli(allocator: std.mem.Allocator, home: []const u8, plat try downloadAndInstallCodextAsset(allocator, cache_root, platform, asset); } - const installed = try std.fs.path.join(allocator, &.{ cache_root, codextPlatformCacheName(platform), codextExecutableName(platform) }); + const installed = try managedCodextExecutablePath(allocator, home, platform); if (!fileExists(installed)) { allocator.free(installed); return error.CodextReleaseInstallFailed; @@ -1030,20 +1003,10 @@ fn downloadDefaultCodextCli(allocator: std.mem.Allocator, home: []const u8, plat return installed; } -fn findCachedCodextExecutable( - allocator: std.mem.Allocator, - root_path: []const u8, - tag: []const u8, - platform_name: []const u8, - platform: types.AppPlatform, -) !?[]u8 { - const primary = try std.fs.path.join(allocator, &.{ root_path, tag, platform_name, codextExecutableName(platform) }); - if (fileExists(primary)) return primary; - allocator.free(primary); - const legacy = try std.fs.path.join(allocator, &.{ root_path, tag, platform_name, codextReleaseExecutableName(platform) }); - if (fileExists(legacy)) return legacy; - allocator.free(legacy); - return null; +fn managedCodextExecutablePath(allocator: std.mem.Allocator, home: []const u8, platform: types.AppPlatform) ![]u8 { + const name = try managedCodextExecutableName(allocator, platform); + defer allocator.free(name); + return try std.fs.path.join(allocator, &.{ home, "accounts", codext_cache_dir_name, name }); } const CodextAsset = struct { @@ -1140,27 +1103,30 @@ fn downloadAndInstallCodextAsset( platform: types.AppPlatform, asset: CodextAsset, ) !void { - const platform_dir = try std.fs.path.join(allocator, &.{ cache_root, codextPlatformCacheName(platform) }); - defer allocator.free(platform_dir); - if (isDirectory(platform_dir)) try std.Io.Dir.cwd().deleteTree(app_runtime.io(), platform_dir); - try std.Io.Dir.cwd().createDirPath(app_runtime.io(), platform_dir); + const extract_dir_name = try std.fmt.allocPrint(allocator, ".extract-{s}", .{codextPlatformCacheName(platform)}); + defer allocator.free(extract_dir_name); + const extract_dir = try std.fs.path.join(allocator, &.{ cache_root, extract_dir_name }); + defer allocator.free(extract_dir); + if (isDirectory(extract_dir)) try std.Io.Dir.cwd().deleteTree(app_runtime.io(), extract_dir); + defer std.Io.Dir.cwd().deleteTree(app_runtime.io(), extract_dir) catch {}; + try std.Io.Dir.cwd().createDirPath(app_runtime.io(), extract_dir); const archive_name = if (platform == .win) "codext.zip" else "codext.tar.gz"; - const archive_path = try std.fs.path.join(allocator, &.{ platform_dir, archive_name }); + const archive_path = try std.fs.path.join(allocator, &.{ extract_dir, archive_name }); defer allocator.free(archive_path); try runChecked(allocator, &[_][]const u8{ curlExecutable(), "-L", "--fail", "--silent", "--show-error", "-o", archive_path, asset.url }, 120000); if (platform == .win) { const archive_quoted = try psSingleQuoteAlloc(allocator, archive_path); defer allocator.free(archive_quoted); - const dest_quoted = try psSingleQuoteAlloc(allocator, platform_dir); + const dest_quoted = try psSingleQuoteAlloc(allocator, extract_dir); defer allocator.free(dest_quoted); const script = try std.fmt.allocPrint(allocator, "Expand-Archive -LiteralPath {s} -DestinationPath {s} -Force", .{ archive_quoted, dest_quoted }); defer allocator.free(script); try runChecked(allocator, &[_][]const u8{ "pwsh.exe", "-NoProfile", "-Command", script }, 120000); } else { - try runChecked(allocator, &[_][]const u8{ tarExecutable(), "-xzf", archive_path, "-C", platform_dir }, 120000); + try runChecked(allocator, &[_][]const u8{ tarExecutable(), "-xzf", archive_path, "-C", extract_dir }, 120000); } - try normalizeCodextExecutableName(allocator, platform_dir, platform); + try installManagedCodextExecutable(allocator, cache_root, extract_dir, platform); } fn runChecked(allocator: std.mem.Allocator, argv: []const []const u8, timeout_ms: u64) !void { @@ -1208,16 +1174,36 @@ fn codextReleaseExecutableName(platform: types.AppPlatform) []const u8 { }; } -fn normalizeCodextExecutableName(allocator: std.mem.Allocator, platform_dir: []const u8, platform: types.AppPlatform) !void { - const target = try std.fs.path.join(allocator, &.{ platform_dir, codextExecutableName(platform) }); - defer allocator.free(target); - if (fileExists(target)) return; - const source = try std.fs.path.join(allocator, &.{ platform_dir, codextReleaseExecutableName(platform) }); +fn managedCodextExecutableName(allocator: std.mem.Allocator, platform: types.AppPlatform) ![]u8 { + return if (platform == .win) + try std.fmt.allocPrint(allocator, "codex-{s}.exe", .{codextPlatformCacheName(platform)}) + else + try std.fmt.allocPrint(allocator, "codex-{s}", .{codextPlatformCacheName(platform)}); +} + +fn installManagedCodextExecutable(allocator: std.mem.Allocator, cache_root: []const u8, extract_dir: []const u8, platform: types.AppPlatform) !void { + const source = try extractedCodextExecutablePath(allocator, extract_dir, platform); defer allocator.free(source); - if (!fileExists(source)) return; + const target_name = try managedCodextExecutableName(allocator, platform); + defer allocator.free(target_name); + const target = try std.fs.path.join(allocator, &.{ cache_root, target_name }); + defer allocator.free(target); + if (fileExists(target)) try std.Io.Dir.deleteFileAbsolute(app_runtime.io(), target); try std.Io.Dir.renameAbsolute(source, target, app_runtime.io()); } +fn extractedCodextExecutablePath(allocator: std.mem.Allocator, extract_dir: []const u8, platform: types.AppPlatform) ![]u8 { + const primary = try std.fs.path.join(allocator, &.{ extract_dir, codextExecutableName(platform) }); + if (fileExists(primary)) return primary; + allocator.free(primary); + + const release = try std.fs.path.join(allocator, &.{ extract_dir, codextReleaseExecutableName(platform) }); + if (fileExists(release)) return release; + allocator.free(release); + + return error.CodextReleaseInstallFailed; +} + fn writeAppError(message: []const u8) !void { var buffer: [512]u8 = undefined; var writer = std.Io.File.stderr().writer(app_runtime.io(), &buffer); From c110fdf521046aeaeb7742ec66c2061abcddc522 Mon Sep 17 00:00:00 2001 From: Loongphy Date: Thu, 14 May 2026 12:10:47 +0800 Subject: [PATCH 05/16] fix: detach app launches from terminal --- docs/commands/app.md | 8 ++++---- src/workflows/app.zig | 11 ++++------- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/docs/commands/app.md b/docs/commands/app.md index fc544186..f5ef3fbf 100644 --- a/docs/commands/app.md +++ b/docs/commands/app.md @@ -19,15 +19,14 @@ installs a persistent CLI override for normal app launches. - `codex-auth app patch` writes a user-level persistent `CODEX_CLI_PATH` patch. After Codex App is fully restarted, normal launches from the Start menu, Finder, or Dock go through a generated guarded shim without running `codex-auth app` each time. - `codex-auth app unpatch` removes the persistent `CODEX_CLI_PATH` patch. - `--app-path ` points to the App executable or an installed package/app directory. -- `--cli-path ` is injected as `CODEX_CLI_PATH` for this launch. If it is omitted, `CODEX_CLI_PATH` is reused when set; otherwise launch downloads the latest Loongphy codext release into the accounts cache and uses that cached binary. -- For `app patch`, an omitted `--cli-path` intentionally uses the managed cached/latest Loongphy codext CLI instead of reusing the current process environment. +- `--cli-path ` is injected as `CODEX_CLI_PATH` for this launch or used as the guarded target for `app patch`. If it is omitted, the command uses the managed cached/latest Loongphy codext CLI; it does not reuse an existing `CODEX_CLI_PATH` from the current shell. - `--home ` is injected as `CODEX_HOME` for `app` launches. For `app patch`, it selects the accounts cache and the Windows platform-state file that are prepared before persisting `CODEX_CLI_PATH`; it does not persist `CODEX_HOME`. - `--platform win|wsl|mac` selects the app runtime platform: - `win` writes the Windows global setting so the app runs the agent natively. - `wsl` writes the Windows global setting so the app runs the agent inside WSL. - `mac` launches the macOS app directly and does not use the Windows WSL setting. - `--dry-run` prints the effective launch environment without starting the app. -- `--wait` waits for the launched process to exit. +- `--wait` waits for the launched process to exit and keeps its stdout/stderr attached. Without `--wait`, `app` starts the GUI app quietly and detaches from terminal output. - `-- ` passes remaining arguments to the app executable on non-Windows platforms. If `--app-path` is omitted, `CODEX_AUTH_APP_PATH` is used when set; otherwise @@ -75,7 +74,8 @@ restored at login. The LaunchAgent also points at a generated guarded shim. This is only needed for persistent GUI launches from Finder, Dock, Spotlight, or login-restored sessions. One-shot `codex-auth app` launches do not need the -LaunchAgent; they pass `CODEX_CLI_PATH` directly to the launched process. +LaunchAgent; they pass the resolved `CODEX_CLI_PATH` directly to the launched +process. The guarded shim is version-bound: diff --git a/src/workflows/app.zig b/src/workflows/app.zig index ddce6a19..d9bfa066 100644 --- a/src/workflows/app.zig +++ b/src/workflows/app.zig @@ -78,9 +78,6 @@ fn resolveCliPath( allow_download: bool, ) !ResolvedValue { if (opts.cli_path) |path| return .{ .value = path, .source = .explicit }; - if (opts.action != .patch) { - if (getOptionalEnv(allocator, codex_cli_path_env)) |path| return .{ .value = path, .source = .env, .owned = true }; - } const target_platform = platform orelse nativeDefaultPlatform(); if (try cachedCodextCliPath(allocator, home, target_platform)) |path| return .{ .value = path, .source = .cached, .owned = true }; @@ -386,8 +383,8 @@ fn launchNative( .argv = argv.items, .environ_map = &env_map, .stdin = .ignore, - .stdout = .inherit, - .stderr = .inherit, + .stdout = if (opts.wait) .inherit else .ignore, + .stderr = if (opts.wait) .inherit else .ignore, }); if (opts.wait) { _ = try child.wait(app_runtime.io()); @@ -1260,8 +1257,8 @@ fn launchWindowsViaPowerShell( var child = try std.process.spawn(app_runtime.io(), .{ .argv = &[_][]const u8{ "pwsh.exe", "-NoProfile", "-Command", script }, .stdin = .ignore, - .stdout = .inherit, - .stderr = .inherit, + .stdout = if (opts.wait) .inherit else .ignore, + .stderr = if (opts.wait) .inherit else .ignore, }); _ = try child.wait(app_runtime.io()); } From e3b72dcea8074d727f93457f147af6863630e2e3 Mon Sep 17 00:00:00 2001 From: Loongphy Date: Thu, 14 May 2026 12:20:36 +0800 Subject: [PATCH 06/16] fix: flatten app patch artifacts --- docs/commands/app.md | 13 +++++++------ src/cli/help.zig | 2 +- src/workflows/app.zig | 35 +++++++++++++++++++++++++---------- 3 files changed, 33 insertions(+), 17 deletions(-) diff --git a/docs/commands/app.md b/docs/commands/app.md index f5ef3fbf..c3a47afc 100644 --- a/docs/commands/app.md +++ b/docs/commands/app.md @@ -19,7 +19,7 @@ installs a persistent CLI override for normal app launches. - `codex-auth app patch` writes a user-level persistent `CODEX_CLI_PATH` patch. After Codex App is fully restarted, normal launches from the Start menu, Finder, or Dock go through a generated guarded shim without running `codex-auth app` each time. - `codex-auth app unpatch` removes the persistent `CODEX_CLI_PATH` patch. - `--app-path ` points to the App executable or an installed package/app directory. -- `--cli-path ` is injected as `CODEX_CLI_PATH` for this launch or used as the guarded target for `app patch`. If it is omitted, the command uses the managed cached/latest Loongphy codext CLI; it does not reuse an existing `CODEX_CLI_PATH` from the current shell. +- `--cli-path ` is injected as `CODEX_CLI_PATH` for this launch or used as the guarded target for `app patch`. If it is omitted, `app` and `app patch` fetch the latest Loongphy codext release, replace the managed cached CLI, and use that file; they do not reuse an existing `CODEX_CLI_PATH` from the current shell. `app status` only reports the current cache and does not download. - `--home ` is injected as `CODEX_HOME` for `app` launches. For `app patch`, it selects the accounts cache and the Windows platform-state file that are prepared before persisting `CODEX_CLI_PATH`; it does not persist `CODEX_HOME`. - `--platform win|wsl|mac` selects the app runtime platform: - `win` writes the Windows global setting so the app runs the agent natively. @@ -44,7 +44,7 @@ setting before persisting `CODEX_CLI_PATH`, so the selected backend keeps using the matching native Windows or Linux codext binary while the installed app version still matches the patch. -Default downloaded CLIs are cached under: +Default downloaded CLIs are cached directly under: ```text $CODEX_HOME/accounts/codext-cli/codex- @@ -62,10 +62,11 @@ Windows app path such as `C:\Program Files\WindowsApps\...\app\Codex.exe` for On Windows, `app patch` writes the user environment variable with `[Environment]::SetEnvironmentVariable(..., 'User')` and broadcasts an environment change. The value points to a generated guarded shim under -`$CODEX_HOME/accounts/codext-cli/app-patch//`, not directly to the -codext binary. Existing Codex App processes must still be closed; some -already-running parent processes may require a fresh Explorer session, sign-out, -or reboot before Start-menu launches inherit the updated variable. +`$CODEX_HOME/accounts/codext-cli/codex-patch-`, and that shim points to +the managed `codex-` file in the same directory. Existing Codex App +processes must still be closed; some already-running parent processes may +require a fresh Explorer session, sign-out, or reboot before Start-menu launches +inherit the updated variable. On macOS, `app patch` sets the current `launchctl` GUI-session environment and installs `~/Library/LaunchAgents/com.codex-auth.app-env.plist` so the variable is diff --git a/src/cli/help.zig b/src/cli/help.zig index c68ca089..7b5b41d7 100644 --- a/src/cli/help.zig +++ b/src/cli/help.zig @@ -289,7 +289,7 @@ fn writeOptionLines(out: *std.Io.Writer, topic: HelpTopic) !void { }, .app => { try out.writeAll(" --app-path Official Codex App executable or install directory.\n"); - try out.writeAll(" --cli-path Value injected or persisted as CODEX_CLI_PATH. Defaults to cached/latest Loongphy codext.\n"); + try out.writeAll(" --cli-path Value injected or persisted as CODEX_CLI_PATH. Defaults to latest managed Loongphy codext.\n"); try out.writeAll(" --home Value injected as CODEX_HOME for this launch.\n"); try out.writeAll(" --platform win|wsl|mac\n"); try out.writeAll(" Preselect the app platform. Defaults to the current app setting on Windows and mac on macOS.\n"); diff --git a/src/workflows/app.zig b/src/workflows/app.zig index d9bfa066..ccbb5639 100644 --- a/src/workflows/app.zig +++ b/src/workflows/app.zig @@ -12,7 +12,6 @@ const app_path_env = "CODEX_AUTH_APP_PATH"; const wsl_agent_mode_key = "runCodexInWindowsSubsystemForLinux"; const codext_repo_latest_url = "https://api.github.com/repos/Loongphy/codext/releases/latest"; const codext_cache_dir_name = "codext-cli"; -const guarded_shim_dir_name = "app-patch"; const guarded_script_name = "codex-auth-app-shim"; const guarded_windows_shim_name = "codex-auth-app-shim.exe"; const mac_persistent_env_label = "com.codex-auth.app-env"; @@ -80,11 +79,12 @@ fn resolveCliPath( if (opts.cli_path) |path| return .{ .value = path, .source = .explicit }; const target_platform = platform orelse nativeDefaultPlatform(); + if (allow_download) { + const path = try downloadDefaultCodextCli(allocator, home, target_platform); + return .{ .value = path, .source = .downloaded, .owned = true }; + } if (try cachedCodextCliPath(allocator, home, target_platform)) |path| return .{ .value = path, .source = .cached, .owned = true }; - if (!allow_download) return .{ .value = null, .source = .not_set }; - - const path = try downloadDefaultCodextCli(allocator, home, target_platform); - return .{ .value = path, .source = .downloaded, .owned = true }; + return .{ .value = null, .source = .not_set }; } fn resolvePlatform(allocator: std.mem.Allocator, home: []const u8, explicit: ?types.AppPlatform) !ResolvedPlatform { @@ -424,7 +424,9 @@ fn fileExists(path: []const u8) bool { pub fn isGuardedShimExecutablePath(path: []const u8) bool { const base = std.fs.path.basename(path); - return std.mem.eql(u8, base, guarded_windows_shim_name) or std.mem.eql(u8, base, guarded_script_name); + return std.mem.eql(u8, base, guarded_windows_shim_name) or + std.mem.eql(u8, base, guarded_script_name) or + (std.mem.startsWith(u8, base, "codex-patch-") and (std.mem.endsWith(u8, base, ".exe") or std.mem.indexOfScalar(u8, base, '.') == null)); } const GuardedShimConfig = struct { @@ -535,7 +537,7 @@ fn installGuardedCliShim( ) ![]u8 { const expected_root = try appGuardRootAlloc(allocator, app_launch_path, platform); defer allocator.free(expected_root); - const shim_dir = try std.fs.path.join(allocator, &.{ home, "accounts", codext_cache_dir_name, guarded_shim_dir_name, appPlatformName(platform) }); + const shim_dir = try std.fs.path.join(allocator, &.{ home, "accounts", codext_cache_dir_name }); defer allocator.free(shim_dir); try std.Io.Dir.cwd().createDirPath(app_runtime.io(), shim_dir); @@ -554,7 +556,9 @@ fn installWindowsGuardedCliShim( ) ![]u8 { const self_exe = try std.process.executablePathAlloc(app_runtime.io(), allocator); defer allocator.free(self_exe); - const shim_path = try std.fs.path.join(allocator, &.{ shim_dir, guarded_windows_shim_name }); + const shim_name = try guardedShimFileName(allocator, .win); + defer allocator.free(shim_name); + const shim_path = try std.fs.path.join(allocator, &.{ shim_dir, shim_name }); errdefer allocator.free(shim_path); try std.Io.Dir.copyFileAbsolute(self_exe, shim_path, app_runtime.io(), .{ .replace = true, .make_path = true }); const config_path = try std.fmt.allocPrint(allocator, "{s}.json", .{shim_path}); @@ -580,7 +584,9 @@ fn installWslGuardedCliShim( defer allocator.free(home_wsl); const script = try wslGuardedShimScript(allocator, expected_wsl, target_wsl, home_wsl); defer allocator.free(script); - const shim_path = try std.fs.path.join(allocator, &.{ shim_dir, guarded_script_name }); + const shim_name = try guardedShimFileName(allocator, .wsl); + defer allocator.free(shim_name); + const shim_path = try std.fs.path.join(allocator, &.{ shim_dir, shim_name }); errdefer allocator.free(shim_path); try writeExecutableTextFile(shim_path, script); return shim_path; @@ -596,7 +602,9 @@ fn installMacGuardedCliShim( defer allocator.free(expected_version); const script = try macGuardedShimScript(allocator, expected_root, expected_version, target_cli); defer allocator.free(script); - const shim_path = try std.fs.path.join(allocator, &.{ shim_dir, guarded_script_name }); + const shim_name = try guardedShimFileName(allocator, .mac); + defer allocator.free(shim_name); + const shim_path = try std.fs.path.join(allocator, &.{ shim_dir, shim_name }); errdefer allocator.free(shim_path); try writeExecutableTextFile(shim_path, script); return shim_path; @@ -609,6 +617,13 @@ fn writeExecutableTextFile(path: []const u8, data: []const u8) !void { } } +fn guardedShimFileName(allocator: std.mem.Allocator, platform: types.AppPlatform) ![]u8 { + return if (platform == .win) + try std.fmt.allocPrint(allocator, "codex-patch-{s}.exe", .{codextPlatformCacheName(platform)}) + else + try std.fmt.allocPrint(allocator, "codex-patch-{s}", .{codextPlatformCacheName(platform)}); +} + fn guardedShimConfigText(allocator: std.mem.Allocator, expected_root: []const u8, target_cli: []const u8) ![]u8 { const escaped_root = try jsonEscapeAlloc(allocator, expected_root); defer allocator.free(escaped_root); From be0daec43682a1d82e4c86552b382505c97244b8 Mon Sep 17 00:00:00 2001 From: Loongphy Date: Thu, 14 May 2026 12:29:29 +0800 Subject: [PATCH 07/16] fix: skip unchanged codext downloads --- docs/commands/app.md | 3 +- src/workflows/app.zig | 76 ++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 73 insertions(+), 6 deletions(-) diff --git a/docs/commands/app.md b/docs/commands/app.md index c3a47afc..3aea7f88 100644 --- a/docs/commands/app.md +++ b/docs/commands/app.md @@ -19,7 +19,7 @@ installs a persistent CLI override for normal app launches. - `codex-auth app patch` writes a user-level persistent `CODEX_CLI_PATH` patch. After Codex App is fully restarted, normal launches from the Start menu, Finder, or Dock go through a generated guarded shim without running `codex-auth app` each time. - `codex-auth app unpatch` removes the persistent `CODEX_CLI_PATH` patch. - `--app-path ` points to the App executable or an installed package/app directory. -- `--cli-path ` is injected as `CODEX_CLI_PATH` for this launch or used as the guarded target for `app patch`. If it is omitted, `app` and `app patch` fetch the latest Loongphy codext release, replace the managed cached CLI, and use that file; they do not reuse an existing `CODEX_CLI_PATH` from the current shell. `app status` only reports the current cache and does not download. +- `--cli-path ` is injected as `CODEX_CLI_PATH` for this launch or used as the guarded target for `app patch`. If it is omitted, `app` and `app patch` fetch the latest Loongphy codext release metadata, compare it with the managed cached CLI version, download only when the cached version differs or is missing, and use that file; they do not reuse an existing `CODEX_CLI_PATH` from the current shell. `app status` only reports the current cache and does not download. - `--home ` is injected as `CODEX_HOME` for `app` launches. For `app patch`, it selects the accounts cache and the Windows platform-state file that are prepared before persisting `CODEX_CLI_PATH`; it does not persist `CODEX_HOME`. - `--platform win|wsl|mac` selects the app runtime platform: - `win` writes the Windows global setting so the app runs the agent natively. @@ -48,6 +48,7 @@ Default downloaded CLIs are cached directly under: ```text $CODEX_HOME/accounts/codext-cli/codex- +$CODEX_HOME/accounts/codext-cli/codex-.version ``` On Windows, the default download prepares both the Windows-native and WSL Linux diff --git a/src/workflows/app.zig b/src/workflows/app.zig index ccbb5639..6ae73044 100644 --- a/src/workflows/app.zig +++ b/src/workflows/app.zig @@ -998,13 +998,11 @@ fn downloadDefaultCodextCli(allocator: std.mem.Allocator, home: []const u8, plat if (builtin.os.tag == .windows) { const win_asset = release.assetFor(.win) orelse return error.CodextReleaseAssetNotFound; const wsl_asset = release.assetFor(.wsl) orelse return error.CodextReleaseAssetNotFound; - try writeAppInfo("downloading from {s}\ndownloading from {s}\n", .{ win_asset.url, wsl_asset.url }); - try downloadAndInstallCodextAsset(allocator, cache_root, .win, win_asset); - try downloadAndInstallCodextAsset(allocator, cache_root, .wsl, wsl_asset); + try ensureCodextAssetInstalled(allocator, cache_root, release.tag, .win, win_asset); + try ensureCodextAssetInstalled(allocator, cache_root, release.tag, .wsl, wsl_asset); } else { const asset = release.assetFor(platform) orelse return error.CodextReleaseAssetNotFound; - try writeAppInfo("downloading from {s}\n", .{asset.url}); - try downloadAndInstallCodextAsset(allocator, cache_root, platform, asset); + try ensureCodextAssetInstalled(allocator, cache_root, release.tag, platform, asset); } const installed = try managedCodextExecutablePath(allocator, home, platform); @@ -1021,6 +1019,58 @@ fn managedCodextExecutablePath(allocator: std.mem.Allocator, home: []const u8, p return try std.fs.path.join(allocator, &.{ home, "accounts", codext_cache_dir_name, name }); } +fn ensureCodextAssetInstalled( + allocator: std.mem.Allocator, + cache_root: []const u8, + tag: []const u8, + platform: types.AppPlatform, + asset: CodextAsset, +) !void { + if (try managedCodextAssetIsCurrent(allocator, cache_root, tag, platform, asset)) return; + try writeAppInfo("downloading from {s}\n", .{asset.url}); + try downloadAndInstallCodextAsset(allocator, cache_root, tag, platform, asset); +} + +fn managedCodextAssetIsCurrent( + allocator: std.mem.Allocator, + cache_root: []const u8, + tag: []const u8, + platform: types.AppPlatform, + asset: CodextAsset, +) !bool { + const executable_name = try managedCodextExecutableName(allocator, platform); + defer allocator.free(executable_name); + const executable_path = try std.fs.path.join(allocator, &.{ cache_root, executable_name }); + defer allocator.free(executable_path); + if (!fileExists(executable_path)) return false; + + const version_path = try managedCodextVersionPath(allocator, cache_root, platform); + defer allocator.free(version_path); + var file = std.Io.Dir.cwd().openFile(app_runtime.io(), version_path, .{}) catch |err| switch (err) { + error.FileNotFound => return false, + else => return err, + }; + defer file.close(app_runtime.io()); + const data = try registry.readFileAlloc(file, allocator, 16 * 1024); + defer allocator.free(data); + + const expected = try managedCodextVersionText(allocator, tag, asset); + defer allocator.free(expected); + return std.mem.eql(u8, data, expected); +} + +fn managedCodextVersionPath(allocator: std.mem.Allocator, cache_root: []const u8, platform: types.AppPlatform) ![]u8 { + const executable_name = try managedCodextExecutableName(allocator, platform); + defer allocator.free(executable_name); + const version_name = try std.fmt.allocPrint(allocator, "{s}.version", .{executable_name}); + defer allocator.free(version_name); + return try std.fs.path.join(allocator, &.{ cache_root, version_name }); +} + +fn managedCodextVersionText(allocator: std.mem.Allocator, tag: []const u8, asset: CodextAsset) ![]u8 { + return try std.fmt.allocPrint(allocator, "tag={s}\nasset={s}\n", .{ tag, asset.name }); +} + const CodextAsset = struct { name: []u8, url: []u8, @@ -1112,6 +1162,7 @@ fn dupeCodextAsset(allocator: std.mem.Allocator, name: []const u8, url: []const fn downloadAndInstallCodextAsset( allocator: std.mem.Allocator, cache_root: []const u8, + tag: []const u8, platform: types.AppPlatform, asset: CodextAsset, ) !void { @@ -1139,6 +1190,21 @@ fn downloadAndInstallCodextAsset( try runChecked(allocator, &[_][]const u8{ tarExecutable(), "-xzf", archive_path, "-C", extract_dir }, 120000); } try installManagedCodextExecutable(allocator, cache_root, extract_dir, platform); + try writeManagedCodextVersion(allocator, cache_root, tag, platform, asset); +} + +fn writeManagedCodextVersion( + allocator: std.mem.Allocator, + cache_root: []const u8, + tag: []const u8, + platform: types.AppPlatform, + asset: CodextAsset, +) !void { + const version_path = try managedCodextVersionPath(allocator, cache_root, platform); + defer allocator.free(version_path); + const data = try managedCodextVersionText(allocator, tag, asset); + defer allocator.free(data); + try std.Io.Dir.cwd().writeFile(app_runtime.io(), .{ .sub_path = version_path, .data = data }); } fn runChecked(allocator: std.mem.Allocator, argv: []const []const u8, timeout_ms: u64) !void { From 69834b2db30fd47b079eefb62e3d7d05a9ea6a72 Mon Sep 17 00:00:00 2001 From: Loongphy Date: Mon, 18 May 2026 22:55:16 +0800 Subject: [PATCH 08/16] Normalize preview install command [skip ci] --- .github/workflows/preview-release.yml | 43 +++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/.github/workflows/preview-release.yml b/.github/workflows/preview-release.yml index f7694e5b..276d11d8 100644 --- a/.github/workflows/preview-release.yml +++ b/.github/workflows/preview-release.yml @@ -5,6 +5,7 @@ on: permissions: contents: read + issues: write jobs: release-assets: @@ -104,3 +105,45 @@ jobs: ./dist/npm/codex-auth-darwin-arm64 ./dist/npm/codex-auth-win32-x64 ./dist/npm/codex-auth-win32-arm64 + + - name: Normalize preview comment command + uses: actions/github-script@v8 + with: + script: | + const { owner, repo } = context.repo; + const issue_number = context.payload.pull_request.number; + const comments = await github.paginate(github.rest.issues.listComments, { + owner, + repo, + issue_number, + per_page: 100, + }); + const comment = comments + .reverse() + .find(({ body, user }) => + user?.type === "Bot" && + body?.includes("pkg.pr.new") && + body?.includes("npx https://pkg.pr.new/") + ); + + if (!comment) { + core.info("No pkg.pr.new comment needs normalization."); + return; + } + + const body = comment.body.replaceAll( + "npx https://pkg.pr.new/", + "npx -y https://pkg.pr.new/", + ); + + if (body === comment.body) { + core.info("pkg.pr.new comment is already normalized."); + return; + } + + await github.rest.issues.updateComment({ + owner, + repo, + comment_id: comment.id, + body, + }); From af2532b0201c0c71d81a843c3df85d9622348712 Mon Sep 17 00:00:00 2001 From: Loongphy Date: Tue, 19 May 2026 01:27:57 +0800 Subject: [PATCH 09/16] feat: refine app launch controls --- README.md | 2 +- docs/commands/app.md | 46 +++++---- src/cli/commands/app.zig | 35 +++---- src/cli/help.zig | 18 ++-- src/cli/types.zig | 8 +- src/workflows/app.zig | 195 +++++++++++++++++++++++------------- tests/cli_behavior_test.zig | 27 ++--- 7 files changed, 188 insertions(+), 143 deletions(-) diff --git a/README.md b/README.md index 98a5ec2f..50617b20 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ Detailed command documentation lives in [docs/commands/README.md](./docs/command | Command | Description | |---------|-------------| -| [`codex-auth app [--app-path ] [--cli-path ]`](./docs/commands/app.md) | Launch Codex App with detected defaults, CODEX_HOME, CODEX_CLI_PATH, and platform overrides | +| [`codex-auth app [--app-path ] [--codex-cli-path ]`](./docs/commands/app.md) | Launch Codex App with detected defaults, CODEX_HOME, CODEX_CLI_PATH, and platform overrides | | [`codex-auth app status`](./docs/commands/app.md) | Show the effective Codex App launch environment | | [`codex-auth app patch`](./docs/commands/app.md) | Persist CODEX_CLI_PATH so normal Codex App launches use the managed CLI | | [`codex-auth app unpatch`](./docs/commands/app.md) | Remove the persistent CODEX_CLI_PATH patch | diff --git a/docs/commands/app.md b/docs/commands/app.md index 3aea7f88..8aa38345 100644 --- a/docs/commands/app.md +++ b/docs/commands/app.md @@ -3,9 +3,9 @@ ## Usage ```shell -codex-auth app [--app-path ] [--cli-path ] [--home ] [--platform win|wsl|mac] -codex-auth app status [--app-path ] [--cli-path ] [--home ] [--platform win|wsl|mac] -codex-auth app patch [--cli-path ] [--home ] [--platform win|wsl|mac] +codex-auth app [--app-path ] [--codex-cli-path ] [--codex-home ] [--platform win|wsl|mac] +codex-auth app status [--app-path ] [--codex-cli-path ] [--codex-home ] [--platform win|wsl|mac] +codex-auth app patch [--codex-cli-path ] [--codex-home ] [--platform win|wsl|mac] codex-auth app unpatch ``` @@ -19,21 +19,19 @@ installs a persistent CLI override for normal app launches. - `codex-auth app patch` writes a user-level persistent `CODEX_CLI_PATH` patch. After Codex App is fully restarted, normal launches from the Start menu, Finder, or Dock go through a generated guarded shim without running `codex-auth app` each time. - `codex-auth app unpatch` removes the persistent `CODEX_CLI_PATH` patch. - `--app-path ` points to the App executable or an installed package/app directory. -- `--cli-path ` is injected as `CODEX_CLI_PATH` for this launch or used as the guarded target for `app patch`. If it is omitted, `app` and `app patch` fetch the latest Loongphy codext release metadata, compare it with the managed cached CLI version, download only when the cached version differs or is missing, and use that file; they do not reuse an existing `CODEX_CLI_PATH` from the current shell. `app status` only reports the current cache and does not download. -- `--home ` is injected as `CODEX_HOME` for `app` launches. For `app patch`, it selects the accounts cache and the Windows platform-state file that are prepared before persisting `CODEX_CLI_PATH`; it does not persist `CODEX_HOME`. +- `--codex-cli-path ` is injected as `CODEX_CLI_PATH` for this launch or used as the guarded target for `app patch`. If it is omitted, `app` and `app patch` fetch the latest Loongphy codext release metadata, compare it with the managed cached CLI version, download only when the cached version differs or is missing, and use that file; they do not reuse an existing `CODEX_CLI_PATH` from the current shell. `app status` only reports the current cache and does not download. +- `--codex-home ` is injected as `CODEX_HOME` for `app` launches. For `app patch`, it selects the accounts cache and the Windows platform-state file that are prepared before persisting `CODEX_CLI_PATH`; it does not persist `CODEX_HOME`. - `--platform win|wsl|mac` selects the app runtime platform: - `win` writes the Windows global setting so the app runs the agent natively. - `wsl` writes the Windows global setting so the app runs the agent inside WSL. - `mac` launches the macOS app directly and does not use the Windows WSL setting. -- `--dry-run` prints the effective launch environment without starting the app. -- `--wait` waits for the launched process to exit and keeps its stdout/stderr attached. Without `--wait`, `app` starts the GUI app quietly and detaches from terminal output. -- `-- ` passes remaining arguments to the app executable on non-Windows platforms. +- `--std` starts the app executable directly with stdout/stderr attached to the current terminal. Use it for debugging app logs; normal launches stay quiet and use the platform GUI launcher. If `--app-path` is omitted, `CODEX_AUTH_APP_PATH` is used when set; otherwise the official installed app is auto-detected. On Windows this uses AppX package -lookup for `OpenAI.Codex` and resolves the package executable. On macOS it -checks `/Applications/Codex.app` and `~/Applications/Codex.app`; the latter is -the standard per-user Applications folder. +lookup for `OpenAI.Codex`. On macOS it checks `/Applications/Codex.app` and +`~/Applications/Codex.app`; the latter is the standard per-user Applications +folder. If `--platform` is omitted, Windows reads `$CODEX_HOME/.codex-global-state.json` and uses `wsl` when `runCodexInWindowsSubsystemForLinux` is `true`; otherwise it @@ -41,7 +39,7 @@ uses `win`. macOS defaults to `mac`. `app patch` uses the same platform resolution and writes the same Windows setting before persisting `CODEX_CLI_PATH`, so the selected backend keeps using -the matching native Windows or Linux codext binary while the installed app +the matching native Windows or WSL Linux codext binary while the installed app version still matches the patch. Default downloaded CLIs are cached directly under: @@ -56,9 +54,12 @@ Loongphy codext assets for the current CPU architecture, such as `win32-x64` and `linux-x64`. On macOS, it downloads only the matching macOS asset, such as `darwin-x64` or `darwin-arm64`. -Windows App launching is handled by the Windows `codex-auth.exe` build. Use a -Windows app path such as `C:\Program Files\WindowsApps\...\app\Codex.exe` for -`--app-path`. The WSL build does not patch or launch Windows App packages. +Windows App launching is handled by the Windows `codex-auth.exe` build. For the +auto-detected app, launch resolves the package AUMID and opens +`shell:AppsFolder\`. Use a Windows app path such as +`C:\Program Files\WindowsApps\...\app\Codex.exe` for `--app-path` only when an +explicit override is needed. The WSL build does not patch or launch Windows App +packages. On Windows, `app patch` writes the user environment variable with `[Environment]::SetEnvironmentVariable(..., 'User')` and broadcasts an @@ -97,17 +98,20 @@ official `CODEX_CLI_PATH` hook instead of editing the app package. That avoids MSIX/AppX package-integrity and install-directory permission problems on Windows while still making normal app launches use the replacement CLI. -For Windows-native App launches, `--cli-path` must point to something the Windows +For Windows-native App launches, `--codex-cli-path` must point to something the Windows App process can spawn. A WSL command name such as `codex-custom` is not a Windows executable path. -For macOS App launches, `--app-path` may point to `/Applications/Codex.app` or -the app executable inside `Contents/MacOS`. The packaged macOS app normally uses -`Contents/Resources/codex` directly as its bundled CLI; setting `--cli-path` -injects `CODEX_CLI_PATH` and takes precedence over that bundled resource. +For macOS App launches, the auto-detected app is opened with bundle identifier +`com.openai.codex`. `--app-path` may point to `/Applications/Codex.app` or the +app bundle path. Bundle paths are opened with `open`; direct executable paths +are not supported for app launch. The packaged macOS app normally uses +`Contents/Resources/codex` directly as its bundled CLI; setting +`--codex-cli-path` injects `CODEX_CLI_PATH` and takes precedence over that +bundled resource. The Electron app currently appends `--analytics-default-enabled` when it starts `app-server`. The `CODEX_CLI_PATH` override changes which binary is executed but -does not remove that argument. To suppress it at launch time, point `--cli-path` +does not remove that argument. To suppress it at launch time, point `--codex-cli-path` at a wrapper/shim that filters that argument before execing the real codext binary; `app patch` will still wrap that path in its own version guard. diff --git a/src/cli/commands/app.zig b/src/cli/commands/app.zig index 36e9e928..db2132e3 100644 --- a/src/cli/commands/app.zig +++ b/src/cli/commands/app.zig @@ -22,10 +22,7 @@ fn parseOptions( var i: usize = 0; while (i < args.len) : (i += 1) { const arg = std.mem.sliceTo(args[i], 0); - if (std.mem.eql(u8, arg, "--")) { - opts.extra_args = @ptrCast(args[i + 1 ..]); - break; - } + if (std.mem.eql(u8, arg, "--")) return common.usageErrorResult(allocator, .app, "`app` does not accept passthrough arguments.", .{}); if (common.isHelpFlag(arg)) return .{ .command = .{ .help = .app } }; if (std.mem.eql(u8, arg, "--app-path")) { if (i + 1 >= args.len) return common.usageErrorResult(allocator, .app, "missing value for `--app-path`.", .{}); @@ -34,18 +31,18 @@ fn parseOptions( opts.app_path = std.mem.sliceTo(args[i], 0); continue; } - if (std.mem.eql(u8, arg, "--cli-path")) { - if (i + 1 >= args.len) return common.usageErrorResult(allocator, .app, "missing value for `--cli-path`.", .{}); - if (opts.cli_path != null) return common.usageErrorResult(allocator, .app, "duplicate `--cli-path` for `app`.", .{}); + if (std.mem.eql(u8, arg, "--codex-cli-path")) { + if (i + 1 >= args.len) return common.usageErrorResult(allocator, .app, "missing value for `--codex-cli-path`.", .{}); + if (opts.codex_cli_path != null) return common.usageErrorResult(allocator, .app, "duplicate `--codex-cli-path` for `app`.", .{}); i += 1; - opts.cli_path = std.mem.sliceTo(args[i], 0); + opts.codex_cli_path = std.mem.sliceTo(args[i], 0); continue; } - if (std.mem.eql(u8, arg, "--home")) { - if (i + 1 >= args.len) return common.usageErrorResult(allocator, .app, "missing value for `--home`.", .{}); - if (opts.home != null) return common.usageErrorResult(allocator, .app, "duplicate `--home` for `app`.", .{}); + if (std.mem.eql(u8, arg, "--codex-home")) { + if (i + 1 >= args.len) return common.usageErrorResult(allocator, .app, "missing value for `--codex-home`.", .{}); + if (opts.codex_home != null) return common.usageErrorResult(allocator, .app, "duplicate `--codex-home` for `app`.", .{}); i += 1; - opts.home = std.mem.sliceTo(args[i], 0); + opts.codex_home = std.mem.sliceTo(args[i], 0); continue; } if (std.mem.eql(u8, arg, "--platform")) { @@ -64,14 +61,9 @@ fn parseOptions( } continue; } - if (std.mem.eql(u8, arg, "--dry-run")) { - if (opts.dry_run) return common.usageErrorResult(allocator, .app, "duplicate `--dry-run` for `app`.", .{}); - opts.dry_run = true; - continue; - } - if (std.mem.eql(u8, arg, "--wait")) { - if (opts.wait) return common.usageErrorResult(allocator, .app, "duplicate `--wait` for `app`.", .{}); - opts.wait = true; + if (std.mem.eql(u8, arg, "--std")) { + if (opts.inherit_stdio) return common.usageErrorResult(allocator, .app, "duplicate `--std` for `app`.", .{}); + opts.inherit_stdio = true; continue; } if (std.mem.startsWith(u8, arg, "-")) { @@ -80,8 +72,5 @@ fn parseOptions( return common.usageErrorResult(allocator, .app, "unexpected argument `{s}` for `app`.", .{arg}); } - if (opts.extra_args.len != 0 and action != .launch) { - return common.usageErrorResult(allocator, .app, "`app {s}` does not accept passthrough arguments.", .{@tagName(action)}); - } return .{ .command = .{ .app = opts } }; } diff --git a/src/cli/help.zig b/src/cli/help.zig index 7b5b41d7..181edf7a 100644 --- a/src/cli/help.zig +++ b/src/cli/help.zig @@ -218,9 +218,9 @@ fn writeUsageLines(out: *std.Io.Writer, topic: HelpTopic) !void { try out.writeAll(" codex-auth config live --interval \n"); }, .app => { - try out.writeAll(" codex-auth app [--app-path ] [--cli-path ] [--home ] [--platform win|wsl|mac]\n"); - try out.writeAll(" codex-auth app status [--app-path ] [--cli-path ] [--home ] [--platform win|wsl|mac]\n"); - try out.writeAll(" codex-auth app patch [--cli-path ] [--home ] [--platform win|wsl|mac]\n"); + try out.writeAll(" codex-auth app [--app-path ] [--codex-cli-path ] [--codex-home ] [--platform win|wsl|mac]\n"); + try out.writeAll(" codex-auth app status [--app-path ] [--codex-cli-path ] [--codex-home ] [--platform win|wsl|mac]\n"); + try out.writeAll(" codex-auth app patch [--codex-cli-path ] [--codex-home ] [--platform win|wsl|mac]\n"); try out.writeAll(" codex-auth app unpatch\n"); }, } @@ -289,13 +289,13 @@ fn writeOptionLines(out: *std.Io.Writer, topic: HelpTopic) !void { }, .app => { try out.writeAll(" --app-path Official Codex App executable or install directory.\n"); - try out.writeAll(" --cli-path Value injected or persisted as CODEX_CLI_PATH. Defaults to latest managed Loongphy codext.\n"); - try out.writeAll(" --home Value injected as CODEX_HOME for this launch.\n"); + try out.writeAll(" --codex-cli-path \n"); + try out.writeAll(" Value injected or persisted as CODEX_CLI_PATH. Defaults to latest managed Loongphy codext.\n"); + try out.writeAll(" --codex-home \n"); + try out.writeAll(" Value injected as CODEX_HOME for this launch.\n"); try out.writeAll(" --platform win|wsl|mac\n"); try out.writeAll(" Preselect the app platform. Defaults to the current app setting on Windows and mac on macOS.\n"); - try out.writeAll(" --dry-run Print the effective launch environment without starting the app.\n"); - try out.writeAll(" --wait Wait for the launched app process to exit.\n"); - try out.writeAll(" -- Pass additional arguments to the app executable.\n"); + try out.writeAll(" --std Run the app executable with stdout/stderr attached to this terminal.\n"); }, else => {}, } @@ -367,7 +367,7 @@ fn writeExampleLines(out: *std.Io.Writer, topic: HelpTopic) !void { try out.writeAll(" codex-auth app --platform win\n"); try out.writeAll(" codex-auth app patch --platform wsl\n"); try out.writeAll(" codex-auth app unpatch\n"); - try out.writeAll(" codex-auth app status --app-path /Applications/Codex.app --cli-path /usr/local/bin/codext\n"); + try out.writeAll(" codex-auth app status --app-path /Applications/Codex.app --codex-cli-path /usr/local/bin/codext\n"); }, } } diff --git a/src/cli/types.zig b/src/cli/types.zig index 2e8de93a..47ddf0a8 100644 --- a/src/cli/types.zig +++ b/src/cli/types.zig @@ -48,12 +48,10 @@ pub const AppPlatform = enum { win, wsl, mac }; pub const AppOptions = struct { action: AppAction, app_path: ?[]const u8 = null, - cli_path: ?[]const u8 = null, - home: ?[]const u8 = null, + codex_cli_path: ?[]const u8 = null, + codex_home: ?[]const u8 = null, platform: ?AppPlatform = null, - dry_run: bool = false, - wait: bool = false, - extra_args: []const []const u8 = &.{}, + inherit_stdio: bool = false, }; pub const HelpTopic = enum { top_level, diff --git a/src/workflows/app.zig b/src/workflows/app.zig index 6ae73044..5eddc67d 100644 --- a/src/workflows/app.zig +++ b/src/workflows/app.zig @@ -9,6 +9,8 @@ const types = @import("../cli/types.zig"); const codex_cli_path_env = "CODEX_CLI_PATH"; const codex_home_env = "CODEX_HOME"; const app_path_env = "CODEX_AUTH_APP_PATH"; +const codex_app_package_name = "OpenAI.Codex"; +const codex_app_bundle_id = "com.openai.codex"; const wsl_agent_mode_key = "runCodexInWindowsSubsystemForLinux"; const codext_repo_latest_url = "https://api.github.com/repos/Loongphy/codext/releases/latest"; const codext_cache_dir_name = "codext-cli"; @@ -34,22 +36,23 @@ const ResolvedPlatform = struct { }; pub fn handleApp(allocator: std.mem.Allocator, resolved_codex_home: []const u8, opts: types.AppOptions) !void { - const effective_home = opts.home orelse resolved_codex_home; + const effective_home = opts.codex_home orelse resolved_codex_home; const effective_platform = try resolvePlatform(allocator, effective_home, opts.platform); - if ((opts.action == .launch or opts.action == .patch) and !opts.dry_run) try validateAppPlatform(effective_platform.value); + if (opts.action == .launch or opts.action == .patch) try validateAppPlatform(effective_platform.value); const effective_app_path = try resolveAppPath(allocator, opts); defer effective_app_path.deinit(allocator); - const allow_download = (opts.action == .launch or opts.action == .patch) and !opts.dry_run; - const effective_cli_path = try resolveCliPath(allocator, effective_home, effective_platform.value, opts, allow_download); + const allow_download = opts.action == .launch or opts.action == .patch; + const quiet_download = opts.action == .launch; + const effective_cli_path = try resolveCliPath(allocator, effective_home, effective_platform.value, opts, allow_download, quiet_download); defer effective_cli_path.deinit(allocator); - const persistent_cli_path = if (opts.action == .status or opts.dry_run) try readPersistentCliPath(allocator) else null; + const persistent_cli_path = if (opts.action == .status) try readPersistentCliPath(allocator) else null; defer if (persistent_cli_path) |path| allocator.free(path); switch (opts.action) { - .status => try printStatus(effective_app_path, effective_cli_path, persistent_cli_path, effective_home, effective_platform, opts), - .launch => try launchApp(allocator, effective_app_path, effective_cli_path, persistent_cli_path, effective_home, effective_platform, opts), - .patch => try patchApp(allocator, effective_app_path, effective_cli_path, persistent_cli_path, effective_home, effective_platform, opts), - .unpatch => try unpatchApp(allocator, effective_app_path, effective_cli_path, persistent_cli_path, effective_home, effective_platform, opts), + .status => try printStatus(effective_app_path, effective_cli_path, persistent_cli_path, effective_home, effective_platform), + .launch => try launchApp(allocator, effective_app_path, effective_cli_path, effective_home, effective_platform, opts.inherit_stdio), + .patch => try patchApp(allocator, effective_app_path, effective_cli_path, effective_home, effective_platform), + .unpatch => try unpatchApp(allocator), } } @@ -75,12 +78,13 @@ fn resolveCliPath( platform: ?types.AppPlatform, opts: types.AppOptions, allow_download: bool, + quiet_download: bool, ) !ResolvedValue { - if (opts.cli_path) |path| return .{ .value = path, .source = .explicit }; + if (opts.codex_cli_path) |path| return .{ .value = path, .source = .explicit }; const target_platform = platform orelse nativeDefaultPlatform(); if (allow_download) { - const path = try downloadDefaultCodextCli(allocator, home, target_platform); + const path = try downloadDefaultCodextCli(allocator, home, target_platform, quiet_download); return .{ .value = path, .source = .downloaded, .owned = true }; } if (try cachedCodextCliPath(allocator, home, target_platform)) |path| return .{ .value = path, .source = .cached, .owned = true }; @@ -136,7 +140,6 @@ fn printStatus( persistent_cli_path: ?[]const u8, home: []const u8, platform: ResolvedPlatform, - opts: types.AppOptions, ) !void { var stdout: io_util.Stdout = undefined; stdout.init(); @@ -147,8 +150,6 @@ fn printStatus( try out.print(" CODEX_CLI_PATH: {s} ({s})\n", .{ cli_path.value orelse "(not cached)", valueSourceName(cli_path.source) }); try out.print(" persistent CODEX_CLI_PATH: {s}\n", .{persistent_cli_path orelse "(not set)"}); try out.print(" platform: {s} ({s})\n", .{ appPlatformName(platform.value), valueSourceName(platform.source) }); - try out.print(" dry run: {s}\n", .{if (opts.dry_run) "yes" else "no"}); - try out.print(" wait: {s}\n", .{if (opts.wait) "yes" else "no"}); try out.flush(); } @@ -156,49 +157,46 @@ fn launchApp( allocator: std.mem.Allocator, app_path: ResolvedValue, cli_path: ResolvedValue, - persistent_cli_path: ?[]const u8, home: []const u8, platform: ResolvedPlatform, - opts: types.AppOptions, + inherit_stdio: bool, ) !void { const target = app_path.value orelse { try writeAppError("app launch could not find the installed Codex app. Pass `--app-path ` or set CODEX_AUTH_APP_PATH.\n"); return error.AppPathRequired; }; - if (opts.dry_run) { - try printStatus(app_path, cli_path, persistent_cli_path, home, platform, opts); - return; - } try validateAppPlatform(platform.value); try applyAppPlatform(allocator, home, platform.value); + if (inherit_stdio) { + return launchExecutableWithStdio(allocator, target, cli_path.value, home); + } + if (builtin.os.tag == .windows) { - return launchWindowsViaPowerShell(allocator, target, cli_path.value, home, opts); + return launchWindowsViaPowerShell(allocator, target, app_path.source, cli_path.value, home); } if (looksLikeWindowsPath(target) or looksLikeWslWindowsMountPath(target)) { try writeAppError("windows app launch must run from the Windows codex-auth executable.\n"); return error.WindowsAppLaunchRequiresWindows; } - return launchNative(allocator, target, cli_path.value, home, opts); + if (builtin.os.tag == .macos) { + return launchMac(allocator, target, app_path.source, cli_path.value, home); + } + try writeAppError("app launch is supported only from the Windows or macOS codex-auth executable.\n"); + return error.UnsupportedPlatform; } fn patchApp( allocator: std.mem.Allocator, app_path: ResolvedValue, cli_path: ResolvedValue, - persistent_cli_path: ?[]const u8, home: []const u8, platform: ResolvedPlatform, - opts: types.AppOptions, ) !void { - if (opts.dry_run) { - try printStatus(app_path, cli_path, persistent_cli_path, home, platform, opts); - return; - } try validateAppPlatform(platform.value); try applyAppPlatform(allocator, home, platform.value); const target_cli = cli_path.value orelse { - try writeAppError("app patch could not resolve CODEX_CLI_PATH. Pass `--cli-path ` or allow the default Loongphy codext download.\n"); + try writeAppError("app patch could not resolve CODEX_CLI_PATH. Pass `--codex-cli-path ` or allow the default Loongphy codext download.\n"); return error.CliPathRequired; }; const target_app = app_path.value orelse { @@ -215,19 +213,7 @@ fn patchApp( try writeAppOutput("guarded target CLI={s}\n", .{target_cli}); } -fn unpatchApp( - allocator: std.mem.Allocator, - app_path: ResolvedValue, - cli_path: ResolvedValue, - persistent_cli_path: ?[]const u8, - home: []const u8, - platform: ResolvedPlatform, - opts: types.AppOptions, -) !void { - if (opts.dry_run) { - try printStatus(app_path, cli_path, persistent_cli_path, home, platform, opts); - return; - } +fn unpatchApp(allocator: std.mem.Allocator) !void { try clearPersistentCliPath(allocator); try writeAppOutput("persistent CODEX_CLI_PATH cleared\n", .{}); } @@ -357,12 +343,11 @@ fn looksLikeWslWindowsMountPath(path: []const u8) bool { return std.mem.startsWith(u8, path, "/mnt/") and path.len >= "/mnt/c/".len and path[6] == '/'; } -fn launchNative( +fn launchExecutableWithStdio( allocator: std.mem.Allocator, app_path: []const u8, cli_path: ?[]const u8, home: []const u8, - opts: types.AppOptions, ) !void { const launch_path = try resolveLaunchPath(allocator, app_path); defer allocator.free(launch_path); @@ -370,25 +355,58 @@ fn launchNative( var env_map = try registry.getEnvMap(allocator); defer env_map.deinit(); try env_map.put(codex_home_env, home); - if (cli_path) |path| { - try env_map.put(codex_cli_path_env, path); + if (cli_path) |path| try env_map.put(codex_cli_path_env, path); + + var child = try std.process.spawn(app_runtime.io(), .{ + .argv = &[_][]const u8{launch_path}, + .environ_map = &env_map, + .stdin = .ignore, + .stdout = .inherit, + .stderr = .inherit, + }); + _ = try child.wait(app_runtime.io()); +} + +fn launchMac( + allocator: std.mem.Allocator, + app_path: []const u8, + app_source: ValueSource, + cli_path: ?[]const u8, + home: []const u8, +) !void { + if (!isDirectory(app_path) and std.mem.indexOf(u8, app_path, ".app") == null) { + try writeAppError("macOS app launch requires an app bundle path such as `/Applications/Codex.app`.\n"); + return error.AppPathRequired; } + const home_env = try std.fmt.allocPrint(allocator, "{s}={s}", .{ codex_home_env, home }); + defer allocator.free(home_env); + const cli_env = if (cli_path) |path| try std.fmt.allocPrint(allocator, "{s}={s}", .{ codex_cli_path_env, path }) else null; + defer if (cli_env) |value| allocator.free(value); + var argv = std.ArrayList([]const u8).empty; defer argv.deinit(allocator); - try argv.append(allocator, launch_path); - try argv.appendSlice(allocator, opts.extra_args); - + try argv.append(allocator, "/usr/bin/open"); + try argv.appendSlice(allocator, &[_][]const u8{ "--env", home_env }); + if (cli_env) |value| try argv.appendSlice(allocator, &[_][]const u8{ "--env", value }); + try argv.appendSlice(allocator, &[_][]const u8{ + "--stdout", + "/dev/null", + "--stderr", + "/dev/null", + }); + if (app_source == .detected) { + try argv.appendSlice(allocator, &[_][]const u8{ "-b", codex_app_bundle_id }); + } else { + try argv.append(allocator, app_path); + } var child = try std.process.spawn(app_runtime.io(), .{ .argv = argv.items, - .environ_map = &env_map, .stdin = .ignore, - .stdout = if (opts.wait) .inherit else .ignore, - .stderr = if (opts.wait) .inherit else .ignore, + .stdout = .ignore, + .stderr = .ignore, }); - if (opts.wait) { - _ = try child.wait(app_runtime.io()); - } + _ = try child.wait(app_runtime.io()); } fn resolveLaunchPath(allocator: std.mem.Allocator, app_path: []const u8) ![]u8 { @@ -947,10 +965,12 @@ fn detectInstalledAppPath(allocator: std.mem.Allocator) !?[]u8 { } fn detectWindowsInstalledAppPath(allocator: std.mem.Allocator) !?[]u8 { + const package_quoted = try psSingleQuoteAlloc(allocator, codex_app_package_name); + defer allocator.free(package_quoted); const script = try std.fmt.allocPrint( allocator, - "$ErrorActionPreference='SilentlyContinue'; $pkg=Get-AppxPackage -Name 'OpenAI.Codex' | Sort-Object Version -Descending | Select-Object -First 1; if ($pkg) {{ foreach ($rel in @('app\\Codex.exe','Codex.exe')) {{ $p=Join-Path $pkg.InstallLocation $rel; if (Test-Path -LiteralPath $p -PathType Leaf) {{ [Console]::Out.Write($p); exit 0 }} }} }}", - .{}, + "$ErrorActionPreference='SilentlyContinue'; $pkg=Get-AppxPackage -Name {s} | Sort-Object Version -Descending | Select-Object -First 1; if ($pkg) {{ [Console]::Out.Write($pkg.InstallLocation) }}", + .{package_quoted}, ); defer allocator.free(script); var result = try http_child.runChildCapture(allocator, &[_][]const u8{ "pwsh.exe", "-NoProfile", "-Command", script }, 7000, null); @@ -987,7 +1007,7 @@ fn cachedCodextCliPath(allocator: std.mem.Allocator, home: []const u8, platform: return null; } -fn downloadDefaultCodextCli(allocator: std.mem.Allocator, home: []const u8, platform: types.AppPlatform) ![]u8 { +fn downloadDefaultCodextCli(allocator: std.mem.Allocator, home: []const u8, platform: types.AppPlatform, quiet: bool) ![]u8 { const release = try fetchLatestCodextRelease(allocator); defer release.deinit(allocator); @@ -998,11 +1018,11 @@ fn downloadDefaultCodextCli(allocator: std.mem.Allocator, home: []const u8, plat if (builtin.os.tag == .windows) { const win_asset = release.assetFor(.win) orelse return error.CodextReleaseAssetNotFound; const wsl_asset = release.assetFor(.wsl) orelse return error.CodextReleaseAssetNotFound; - try ensureCodextAssetInstalled(allocator, cache_root, release.tag, .win, win_asset); - try ensureCodextAssetInstalled(allocator, cache_root, release.tag, .wsl, wsl_asset); + try ensureCodextAssetInstalled(allocator, cache_root, release.tag, .win, win_asset, quiet); + try ensureCodextAssetInstalled(allocator, cache_root, release.tag, .wsl, wsl_asset, quiet); } else { const asset = release.assetFor(platform) orelse return error.CodextReleaseAssetNotFound; - try ensureCodextAssetInstalled(allocator, cache_root, release.tag, platform, asset); + try ensureCodextAssetInstalled(allocator, cache_root, release.tag, platform, asset, quiet); } const installed = try managedCodextExecutablePath(allocator, home, platform); @@ -1025,9 +1045,10 @@ fn ensureCodextAssetInstalled( tag: []const u8, platform: types.AppPlatform, asset: CodextAsset, + quiet: bool, ) !void { if (try managedCodextAssetIsCurrent(allocator, cache_root, tag, platform, asset)) return; - try writeAppInfo("downloading from {s}\n", .{asset.url}); + if (!quiet) try writeAppInfo("downloading from {s}\n", .{asset.url}); try downloadAndInstallCodextAsset(allocator, cache_root, tag, platform, asset); } @@ -1309,11 +1330,13 @@ fn writeAppOutput(comptime format: []const u8, args: anytype) !void { fn launchWindowsViaPowerShell( allocator: std.mem.Allocator, app_path: []const u8, + app_source: ValueSource, cli_path: ?[]const u8, home: []const u8, - opts: types.AppOptions, ) !void { - if (opts.extra_args.len != 0) return error.WindowsPassthroughArgsUnsupported; + if (app_source == .detected) { + return launchWindowsDetectedPackageViaPowerShell(allocator, cli_path, home); + } const app_quoted = try psSingleQuoteAlloc(allocator, app_path); defer allocator.free(app_quoted); @@ -1330,16 +1353,52 @@ fn launchWindowsViaPowerShell( const script = try std.fmt.allocPrint( allocator, - "$ErrorActionPreference='Stop'; $p={s}; if (Test-Path -LiteralPath $p -PathType Container) {{ $c=@('Codex.exe','codex.exe','app\\Codex.exe','app\\codex.exe'); foreach ($n in $c) {{ $x=Join-Path $p $n; if (Test-Path -LiteralPath $x -PathType Leaf) {{ $p=$x; break }} }} }}; Start-Process -FilePath $p -Environment @{{ CODEX_HOME={s}{s} }}{s}", - .{ app_quoted, home_quoted, cli_part, if (opts.wait) " -Wait" else "" }, + "$ErrorActionPreference='Stop'; $p={s}; if (Test-Path -LiteralPath $p -PathType Container) {{ $c=@('Codex.exe','codex.exe','app\\Codex.exe','app\\codex.exe'); foreach ($n in $c) {{ $x=Join-Path $p $n; if (Test-Path -LiteralPath $x -PathType Leaf) {{ $p=$x; break }} }} }}; Start-Process -FilePath $p -Environment @{{ CODEX_HOME={s}{s} }}", + .{ app_quoted, home_quoted, cli_part }, + ); + defer allocator.free(script); + + var child = try std.process.spawn(app_runtime.io(), .{ + .argv = &[_][]const u8{ "pwsh.exe", "-NoProfile", "-Command", script }, + .stdin = .ignore, + .stdout = .ignore, + .stderr = .ignore, + .create_no_window = true, + }); + _ = try child.wait(app_runtime.io()); +} + +fn launchWindowsDetectedPackageViaPowerShell( + allocator: std.mem.Allocator, + cli_path: ?[]const u8, + home: []const u8, +) !void { + const package_quoted = try psSingleQuoteAlloc(allocator, codex_app_package_name); + defer allocator.free(package_quoted); + const home_quoted = try psSingleQuoteAlloc(allocator, home); + defer allocator.free(home_quoted); + const cli_quoted = if (cli_path) |path| try psSingleQuoteAlloc(allocator, path) else null; + defer if (cli_quoted) |path| allocator.free(path); + + const cli_part = if (cli_quoted) |path| + try std.fmt.allocPrint(allocator, "; $env:CODEX_CLI_PATH={s}", .{path}) + else + try allocator.dupe(u8, ""); + defer allocator.free(cli_part); + + const script = try std.fmt.allocPrint( + allocator, + "$ErrorActionPreference='Stop'; $pkg=Get-AppxPackage -Name {s} | Sort-Object Version -Descending | Select-Object -First 1; if (-not $pkg) {{ throw 'OpenAI.Codex package not found' }}; $appId=(Get-AppxPackageManifest $pkg).Package.Applications.Application | Select-Object -First 1 -ExpandProperty Id; $aumid=\"$($pkg.PackageFamilyName)!$appId\"; $env:CODEX_HOME={s}{s}; Start-Process -FilePath \"shell:AppsFolder\\$aumid\"", + .{ package_quoted, home_quoted, cli_part }, ); defer allocator.free(script); var child = try std.process.spawn(app_runtime.io(), .{ .argv = &[_][]const u8{ "pwsh.exe", "-NoProfile", "-Command", script }, .stdin = .ignore, - .stdout = if (opts.wait) .inherit else .ignore, - .stderr = if (opts.wait) .inherit else .ignore, + .stdout = .ignore, + .stderr = .ignore, + .create_no_window = true, }); _ = try child.wait(app_runtime.io()); } diff --git a/tests/cli_behavior_test.zig b/tests/cli_behavior_test.zig index f1b59f5a..4a72875b 100644 --- a/tests/cli_behavior_test.zig +++ b/tests/cli_behavior_test.zig @@ -75,22 +75,20 @@ fn expectArgv(actual: []const []const u8, expected: []const []const u8) !void { } } -test "Scenario: Given app launch overrides when parsing then paths and passthrough args are preserved" { +test "Scenario: Given app launch overrides when parsing then paths are preserved" { const gpa = std.testing.allocator; const args = [_][:0]const u8{ "codex-auth", "app", "--app-path", "C:\\Program Files\\WindowsApps\\OpenAI.Codex", - "--cli-path", + "--codex-cli-path", "codex-custom", - "--home", + "--codex-home", "/mnt/c/Users/Loong/.codext", "--platform", "win", - "--dry-run", - "--", - "--trace", + "--std", }; var result = try cli.commands.parseArgs(gpa, &args); defer cli.commands.freeParseResult(gpa, &result); @@ -100,12 +98,10 @@ test "Scenario: Given app launch overrides when parsing then paths and passthrou .app => |opts| { try std.testing.expectEqual(cli.types.AppAction.launch, opts.action); try std.testing.expectEqualStrings("C:\\Program Files\\WindowsApps\\OpenAI.Codex", opts.app_path.?); - try std.testing.expectEqualStrings("codex-custom", opts.cli_path.?); - try std.testing.expectEqualStrings("/mnt/c/Users/Loong/.codext", opts.home.?); + try std.testing.expectEqualStrings("codex-custom", opts.codex_cli_path.?); + try std.testing.expectEqualStrings("/mnt/c/Users/Loong/.codext", opts.codex_home.?); try std.testing.expectEqual(cli.types.AppPlatform.win, opts.platform.?); - try std.testing.expect(opts.dry_run); - try std.testing.expect(!opts.wait); - try expectArgv(opts.extra_args, &[_][]const u8{"--trace"}); + try std.testing.expect(opts.inherit_stdio); }, else => return error.TestExpectedEqual, }, @@ -113,13 +109,13 @@ test "Scenario: Given app launch overrides when parsing then paths and passthrou } } -test "Scenario: Given app status with passthrough args when parsing then usage error is returned" { +test "Scenario: Given app passthrough args when parsing then usage error is returned" { const gpa = std.testing.allocator; - const args = [_][:0]const u8{ "codex-auth", "app", "status", "--", "--trace" }; + const args = [_][:0]const u8{ "codex-auth", "app", "--", "--trace" }; var result = try cli.commands.parseArgs(gpa, &args); defer cli.commands.freeParseResult(gpa, &result); - try expectUsageError(result, .app, "`app status` does not accept passthrough arguments."); + try expectUsageError(result, .app, "`app` does not accept passthrough arguments."); } test "Scenario: Given removed app launch subcommand when parsing then usage error is returned" { @@ -133,7 +129,7 @@ test "Scenario: Given removed app launch subcommand when parsing then usage erro test "Scenario: Given app patch when parsing then patch action is preserved" { const gpa = std.testing.allocator; - const args = [_][:0]const u8{ "codex-auth", "app", "patch", "--platform", "wsl", "--dry-run" }; + const args = [_][:0]const u8{ "codex-auth", "app", "patch", "--platform", "wsl" }; var result = try cli.commands.parseArgs(gpa, &args); defer cli.commands.freeParseResult(gpa, &result); @@ -142,7 +138,6 @@ test "Scenario: Given app patch when parsing then patch action is preserved" { .app => |opts| { try std.testing.expectEqual(cli.types.AppAction.patch, opts.action); try std.testing.expectEqual(cli.types.AppPlatform.wsl, opts.platform.?); - try std.testing.expect(opts.dry_run); }, else => return error.TestExpectedEqual, }, From 8342a9cf5a452eab05ef9a404f85a53d8964f983 Mon Sep 17 00:00:00 2001 From: Loongphy Date: Tue, 19 May 2026 17:52:06 +0800 Subject: [PATCH 10/16] ci: tolerate pkg pr comment permission errors --- .github/workflows/preview-release.yml | 20 ++++++++++++++------ docs/commands/app.md | 6 ------ 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/.github/workflows/preview-release.yml b/.github/workflows/preview-release.yml index 276d11d8..a524dd0a 100644 --- a/.github/workflows/preview-release.yml +++ b/.github/workflows/preview-release.yml @@ -141,9 +141,17 @@ jobs: return; } - await github.rest.issues.updateComment({ - owner, - repo, - comment_id: comment.id, - body, - }); + try { + await github.rest.issues.updateComment({ + owner, + repo, + comment_id: comment.id, + body, + }); + } catch (error) { + if (error.status === 403) { + core.warning("pkg.pr.new comment normalization skipped: GitHub token cannot update this bot comment."); + return; + } + throw error; + } diff --git a/docs/commands/app.md b/docs/commands/app.md index 8aa38345..af80487a 100644 --- a/docs/commands/app.md +++ b/docs/commands/app.md @@ -109,9 +109,3 @@ are not supported for app launch. The packaged macOS app normally uses `Contents/Resources/codex` directly as its bundled CLI; setting `--codex-cli-path` injects `CODEX_CLI_PATH` and takes precedence over that bundled resource. - -The Electron app currently appends `--analytics-default-enabled` when it starts -`app-server`. The `CODEX_CLI_PATH` override changes which binary is executed but -does not remove that argument. To suppress it at launch time, point `--codex-cli-path` -at a wrapper/shim that filters that argument before execing the real codext -binary; `app patch` will still wrap that path in its own version guard. From 56e15da6b015a62ebbe0029e04e1067262c934d8 Mon Sep 17 00:00:00 2001 From: Loongphy Date: Tue, 19 May 2026 18:14:25 +0800 Subject: [PATCH 11/16] ci: remove pkg pr comment normalization --- .github/workflows/preview-release.yml | 50 --------------------------- 1 file changed, 50 deletions(-) diff --git a/.github/workflows/preview-release.yml b/.github/workflows/preview-release.yml index a524dd0a..d2177103 100644 --- a/.github/workflows/preview-release.yml +++ b/.github/workflows/preview-release.yml @@ -105,53 +105,3 @@ jobs: ./dist/npm/codex-auth-darwin-arm64 ./dist/npm/codex-auth-win32-x64 ./dist/npm/codex-auth-win32-arm64 - - - name: Normalize preview comment command - uses: actions/github-script@v8 - with: - script: | - const { owner, repo } = context.repo; - const issue_number = context.payload.pull_request.number; - const comments = await github.paginate(github.rest.issues.listComments, { - owner, - repo, - issue_number, - per_page: 100, - }); - const comment = comments - .reverse() - .find(({ body, user }) => - user?.type === "Bot" && - body?.includes("pkg.pr.new") && - body?.includes("npx https://pkg.pr.new/") - ); - - if (!comment) { - core.info("No pkg.pr.new comment needs normalization."); - return; - } - - const body = comment.body.replaceAll( - "npx https://pkg.pr.new/", - "npx -y https://pkg.pr.new/", - ); - - if (body === comment.body) { - core.info("pkg.pr.new comment is already normalized."); - return; - } - - try { - await github.rest.issues.updateComment({ - owner, - repo, - comment_id: comment.id, - body, - }); - } catch (error) { - if (error.status === 403) { - core.warning("pkg.pr.new comment normalization skipped: GitHub token cannot update this bot comment."); - return; - } - throw error; - } From a63f15ea50b5e059114c0cd31f4b75ac0262b743 Mon Sep 17 00:00:00 2001 From: Loongphy Date: Tue, 19 May 2026 18:25:01 +0800 Subject: [PATCH 12/16] ci: drop preview issue write permission [skip ci] --- .github/workflows/preview-release.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/preview-release.yml b/.github/workflows/preview-release.yml index d2177103..f7694e5b 100644 --- a/.github/workflows/preview-release.yml +++ b/.github/workflows/preview-release.yml @@ -5,7 +5,6 @@ on: permissions: contents: read - issues: write jobs: release-assets: From 98421f4bf15afadbf9d0965f4bfdc68c004df6e5 Mon Sep 17 00:00:00 2001 From: Loongphy Date: Wed, 20 May 2026 00:40:15 +0800 Subject: [PATCH 13/16] feat: remove app status command --- README.md | 1 - docs/commands/app.md | 4 +- src/cli/commands/app.zig | 1 - src/cli/help.zig | 3 -- src/cli/types.zig | 2 +- src/workflows/app.zig | 84 ------------------------------------- tests/cli_behavior_test.zig | 9 ++++ 7 files changed, 11 insertions(+), 93 deletions(-) diff --git a/README.md b/README.md index 50617b20..6d734afa 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,6 @@ Detailed command documentation lives in [docs/commands/README.md](./docs/command | Command | Description | |---------|-------------| | [`codex-auth app [--app-path ] [--codex-cli-path ]`](./docs/commands/app.md) | Launch Codex App with detected defaults, CODEX_HOME, CODEX_CLI_PATH, and platform overrides | -| [`codex-auth app status`](./docs/commands/app.md) | Show the effective Codex App launch environment | | [`codex-auth app patch`](./docs/commands/app.md) | Persist CODEX_CLI_PATH so normal Codex App launches use the managed CLI | | [`codex-auth app unpatch`](./docs/commands/app.md) | Remove the persistent CODEX_CLI_PATH patch | diff --git a/docs/commands/app.md b/docs/commands/app.md index af80487a..ead92085 100644 --- a/docs/commands/app.md +++ b/docs/commands/app.md @@ -4,7 +4,6 @@ ```shell codex-auth app [--app-path ] [--codex-cli-path ] [--codex-home ] [--platform win|wsl|mac] -codex-auth app status [--app-path ] [--codex-cli-path ] [--codex-home ] [--platform win|wsl|mac] codex-auth app patch [--codex-cli-path ] [--codex-home ] [--platform win|wsl|mac] codex-auth app unpatch ``` @@ -15,11 +14,10 @@ Launches the official Codex App with per-process environment overrides, or installs a persistent CLI override for normal app launches. - `codex-auth app` launches the app. There is no `launch` subcommand. -- `codex-auth app status` prints the effective defaults without downloading the CLI or launching the app. - `codex-auth app patch` writes a user-level persistent `CODEX_CLI_PATH` patch. After Codex App is fully restarted, normal launches from the Start menu, Finder, or Dock go through a generated guarded shim without running `codex-auth app` each time. - `codex-auth app unpatch` removes the persistent `CODEX_CLI_PATH` patch. - `--app-path ` points to the App executable or an installed package/app directory. -- `--codex-cli-path ` is injected as `CODEX_CLI_PATH` for this launch or used as the guarded target for `app patch`. If it is omitted, `app` and `app patch` fetch the latest Loongphy codext release metadata, compare it with the managed cached CLI version, download only when the cached version differs or is missing, and use that file; they do not reuse an existing `CODEX_CLI_PATH` from the current shell. `app status` only reports the current cache and does not download. +- `--codex-cli-path ` is injected as `CODEX_CLI_PATH` for this launch or used as the guarded target for `app patch`. If it is omitted, `app` and `app patch` fetch the latest Loongphy codext release metadata, compare it with the managed cached CLI version, download only when the cached version differs or is missing, and use that file; they do not reuse an existing `CODEX_CLI_PATH` from the current shell. - `--codex-home ` is injected as `CODEX_HOME` for `app` launches. For `app patch`, it selects the accounts cache and the Windows platform-state file that are prepared before persisting `CODEX_CLI_PATH`; it does not persist `CODEX_HOME`. - `--platform win|wsl|mac` selects the app runtime platform: - `win` writes the Windows global setting so the app runs the agent natively. diff --git a/src/cli/commands/app.zig b/src/cli/commands/app.zig index db2132e3..3844b7f4 100644 --- a/src/cli/commands/app.zig +++ b/src/cli/commands/app.zig @@ -7,7 +7,6 @@ pub fn parse(allocator: std.mem.Allocator, args: []const [:0]const u8) !types.Pa const first = std.mem.sliceTo(args[0], 0); if (common.isHelpFlag(first)) return .{ .command = .{ .help = .app } }; - if (std.mem.eql(u8, first, "status")) return parseOptions(allocator, .status, args[1..]); if (std.mem.eql(u8, first, "patch")) return parseOptions(allocator, .patch, args[1..]); if (std.mem.eql(u8, first, "unpatch")) return parseOptions(allocator, .unpatch, args[1..]); return parseOptions(allocator, .launch, args); diff --git a/src/cli/help.zig b/src/cli/help.zig index 4ca5dd66..236c15c7 100644 --- a/src/cli/help.zig +++ b/src/cli/help.zig @@ -58,7 +58,6 @@ pub fn writeHelp( try writeCommandSummary(out, use_color, "config", "Manage configuration"); try writeCommandDetail(out, use_color, "config live --interval "); try writeCommandSummary(out, use_color, "app", "Launch or version-bound patch Codex App CLI overrides"); - try writeCommandDetail(out, use_color, "app status"); try writeCommandDetail(out, use_color, "app patch"); try writeCommandDetail(out, use_color, "app unpatch"); @@ -229,7 +228,6 @@ fn writeUsageLines(out: *std.Io.Writer, topic: HelpTopic) !void { }, .app => { try out.writeAll(" codex-auth app [--app-path ] [--codex-cli-path ] [--codex-home ] [--platform win|wsl|mac]\n"); - try out.writeAll(" codex-auth app status [--app-path ] [--codex-cli-path ] [--codex-home ] [--platform win|wsl|mac]\n"); try out.writeAll(" codex-auth app patch [--codex-cli-path ] [--codex-home ] [--platform win|wsl|mac]\n"); try out.writeAll(" codex-auth app unpatch\n"); }, @@ -390,7 +388,6 @@ fn writeExampleLines(out: *std.Io.Writer, topic: HelpTopic) !void { try out.writeAll(" codex-auth app --platform win\n"); try out.writeAll(" codex-auth app patch --platform wsl\n"); try out.writeAll(" codex-auth app unpatch\n"); - try out.writeAll(" codex-auth app status --app-path /Applications/Codex.app --codex-cli-path /usr/local/bin/codext\n"); }, } } diff --git a/src/cli/types.zig b/src/cli/types.zig index 87f5fb38..623574f5 100644 --- a/src/cli/types.zig +++ b/src/cli/types.zig @@ -54,7 +54,7 @@ pub const LiveOptions = struct { interval_seconds: u16, }; pub const ConfigOptions = union(enum) { live: LiveOptions }; -pub const AppAction = enum { launch, status, patch, unpatch }; +pub const AppAction = enum { launch, patch, unpatch }; pub const AppPlatform = enum { win, wsl, mac }; pub const AppOptions = struct { action: AppAction, diff --git a/src/workflows/app.zig b/src/workflows/app.zig index 5eddc67d..2501acdb 100644 --- a/src/workflows/app.zig +++ b/src/workflows/app.zig @@ -1,7 +1,6 @@ const std = @import("std"); const builtin = @import("builtin"); const app_runtime = @import("../core/runtime.zig"); -const io_util = @import("../core/io_util.zig"); const http_child = @import("../api/http_child.zig"); const registry = @import("../registry/root.zig"); const types = @import("../cli/types.zig"); @@ -45,11 +44,8 @@ pub fn handleApp(allocator: std.mem.Allocator, resolved_codex_home: []const u8, const quiet_download = opts.action == .launch; const effective_cli_path = try resolveCliPath(allocator, effective_home, effective_platform.value, opts, allow_download, quiet_download); defer effective_cli_path.deinit(allocator); - const persistent_cli_path = if (opts.action == .status) try readPersistentCliPath(allocator) else null; - defer if (persistent_cli_path) |path| allocator.free(path); switch (opts.action) { - .status => try printStatus(effective_app_path, effective_cli_path, persistent_cli_path, effective_home, effective_platform), .launch => try launchApp(allocator, effective_app_path, effective_cli_path, effective_home, effective_platform, opts.inherit_stdio), .patch => try patchApp(allocator, effective_app_path, effective_cli_path, effective_home, effective_platform), .unpatch => try unpatchApp(allocator), @@ -134,25 +130,6 @@ fn readWindowsWslBackendSetting(allocator: std.mem.Allocator, home: []const u8) }; } -fn printStatus( - app_path: ResolvedValue, - cli_path: ResolvedValue, - persistent_cli_path: ?[]const u8, - home: []const u8, - platform: ResolvedPlatform, -) !void { - var stdout: io_util.Stdout = undefined; - stdout.init(); - const out = stdout.out(); - try out.writeAll("Codex App launch environment\n"); - try out.print(" app path: {s} ({s})\n", .{ app_path.value orelse "(not set)", valueSourceName(app_path.source) }); - try out.print(" CODEX_HOME: {s}\n", .{home}); - try out.print(" CODEX_CLI_PATH: {s} ({s})\n", .{ cli_path.value orelse "(not cached)", valueSourceName(cli_path.source) }); - try out.print(" persistent CODEX_CLI_PATH: {s}\n", .{persistent_cli_path orelse "(not set)"}); - try out.print(" platform: {s} ({s})\n", .{ appPlatformName(platform.value), valueSourceName(platform.source) }); - try out.flush(); -} - fn launchApp( allocator: std.mem.Allocator, app_path: ResolvedValue, @@ -218,25 +195,6 @@ fn unpatchApp(allocator: std.mem.Allocator) !void { try writeAppOutput("persistent CODEX_CLI_PATH cleared\n", .{}); } -fn appPlatformName(value: ?types.AppPlatform) []const u8 { - return switch (value orelse return "(not set)") { - .win => "win", - .wsl => "wsl", - .mac => "mac", - }; -} - -fn valueSourceName(value: ValueSource) []const u8 { - return switch (value) { - .explicit => "explicit", - .env => "env", - .detected => "detected", - .cached => "cached", - .downloaded => "downloaded", - .not_set => "not set", - }; -} - fn validateAppPlatform(value: ?types.AppPlatform) !void { const platform = value orelse return; switch (platform) { @@ -767,48 +725,6 @@ fn indexOfIgnoreCase(haystack: []const u8, needle: []const u8) ?usize { return null; } -fn readPersistentCliPath(allocator: std.mem.Allocator) !?[]u8 { - return switch (builtin.os.tag) { - .windows => readWindowsPersistentCliPath(allocator), - .macos => readMacPersistentCliPath(allocator), - else => null, - }; -} - -fn readWindowsPersistentCliPath(allocator: std.mem.Allocator) !?[]u8 { - var result = http_child.runChildCapture( - allocator, - &[_][]const u8{ "pwsh.exe", "-NoProfile", "-Command", "[Console]::Out.Write([Environment]::GetEnvironmentVariable('CODEX_CLI_PATH','User'))" }, - 7000, - null, - ) catch return null; - defer result.deinit(allocator); - return switch (result.term) { - .exited => |code| if (code == 0) try dupTrimmedOrNull(allocator, result.stdout) else null, - else => null, - }; -} - -fn readMacPersistentCliPath(allocator: std.mem.Allocator) !?[]u8 { - var result = http_child.runChildCapture( - allocator, - &[_][]const u8{ "launchctl", "getenv", codex_cli_path_env }, - 7000, - null, - ) catch return null; - defer result.deinit(allocator); - return switch (result.term) { - .exited => |code| if (code == 0) try dupTrimmedOrNull(allocator, result.stdout) else null, - else => null, - }; -} - -fn dupTrimmedOrNull(allocator: std.mem.Allocator, value: []const u8) !?[]u8 { - const trimmed = std.mem.trim(u8, value, " \t\r\n"); - if (trimmed.len == 0) return null; - return try allocator.dupe(u8, trimmed); -} - fn persistCliPath(allocator: std.mem.Allocator, cli_path: []const u8) !void { switch (builtin.os.tag) { .windows => try persistWindowsCliPath(allocator, cli_path), diff --git a/tests/cli_behavior_test.zig b/tests/cli_behavior_test.zig index 2273f455..b3e513c9 100644 --- a/tests/cli_behavior_test.zig +++ b/tests/cli_behavior_test.zig @@ -127,6 +127,15 @@ test "Scenario: Given removed app launch subcommand when parsing then usage erro try expectUsageError(result, .app, "unexpected argument `launch` for `app`."); } +test "Scenario: Given removed app status subcommand when parsing then usage error is returned" { + const gpa = std.testing.allocator; + const args = [_][:0]const u8{ "codex-auth", "app", "status" }; + var result = try cli.commands.parseArgs(gpa, &args); + defer cli.commands.freeParseResult(gpa, &result); + + try expectUsageError(result, .app, "unexpected argument `status` for `app`."); +} + test "Scenario: Given app patch when parsing then patch action is preserved" { const gpa = std.testing.allocator; const args = [_][:0]const u8{ "codex-auth", "app", "patch", "--platform", "wsl" }; From 0ac965bcc2ff2ea923d95c82a6fd9a9b3161e6eb Mon Sep 17 00:00:00 2001 From: Loongphy Date: Wed, 20 May 2026 01:34:47 +0800 Subject: [PATCH 14/16] feat: remove app patch commands --- README.md | 2 - docs/commands/app.md | 56 +--- src/cli/commands/app.zig | 2 - src/cli/help.zig | 10 +- src/cli/types.zig | 2 +- src/main.zig | 6 - src/workflows/app.zig | 502 +----------------------------------- tests/cli_behavior_test.zig | 23 +- 8 files changed, 13 insertions(+), 590 deletions(-) diff --git a/README.md b/README.md index 6d734afa..819ad48e 100644 --- a/README.md +++ b/README.md @@ -83,8 +83,6 @@ Detailed command documentation lives in [docs/commands/README.md](./docs/command | Command | Description | |---------|-------------| | [`codex-auth app [--app-path ] [--codex-cli-path ]`](./docs/commands/app.md) | Launch Codex App with detected defaults, CODEX_HOME, CODEX_CLI_PATH, and platform overrides | -| [`codex-auth app patch`](./docs/commands/app.md) | Persist CODEX_CLI_PATH so normal Codex App launches use the managed CLI | -| [`codex-auth app unpatch`](./docs/commands/app.md) | Remove the persistent CODEX_CLI_PATH patch | ### Configuration diff --git a/docs/commands/app.md b/docs/commands/app.md index ead92085..c4ad3a98 100644 --- a/docs/commands/app.md +++ b/docs/commands/app.md @@ -4,21 +4,16 @@ ```shell codex-auth app [--app-path ] [--codex-cli-path ] [--codex-home ] [--platform win|wsl|mac] -codex-auth app patch [--codex-cli-path ] [--codex-home ] [--platform win|wsl|mac] -codex-auth app unpatch ``` ## Behavior -Launches the official Codex App with per-process environment overrides, or -installs a persistent CLI override for normal app launches. +Launches the official Codex App with per-process environment overrides. - `codex-auth app` launches the app. There is no `launch` subcommand. -- `codex-auth app patch` writes a user-level persistent `CODEX_CLI_PATH` patch. After Codex App is fully restarted, normal launches from the Start menu, Finder, or Dock go through a generated guarded shim without running `codex-auth app` each time. -- `codex-auth app unpatch` removes the persistent `CODEX_CLI_PATH` patch. - `--app-path ` points to the App executable or an installed package/app directory. -- `--codex-cli-path ` is injected as `CODEX_CLI_PATH` for this launch or used as the guarded target for `app patch`. If it is omitted, `app` and `app patch` fetch the latest Loongphy codext release metadata, compare it with the managed cached CLI version, download only when the cached version differs or is missing, and use that file; they do not reuse an existing `CODEX_CLI_PATH` from the current shell. -- `--codex-home ` is injected as `CODEX_HOME` for `app` launches. For `app patch`, it selects the accounts cache and the Windows platform-state file that are prepared before persisting `CODEX_CLI_PATH`; it does not persist `CODEX_HOME`. +- `--codex-cli-path ` is injected as `CODEX_CLI_PATH` for this launch. If it is omitted, `app` fetches the latest Loongphy codext release metadata, compares it with the managed cached CLI version, downloads only when the cached version differs or is missing, and uses that file; it does not reuse an existing `CODEX_CLI_PATH` from the current shell. +- `--codex-home ` is injected as `CODEX_HOME` for `app` launches and selects the accounts cache used for managed CLI resolution. - `--platform win|wsl|mac` selects the app runtime platform: - `win` writes the Windows global setting so the app runs the agent natively. - `wsl` writes the Windows global setting so the app runs the agent inside WSL. @@ -35,11 +30,6 @@ If `--platform` is omitted, Windows reads `$CODEX_HOME/.codex-global-state.json` and uses `wsl` when `runCodexInWindowsSubsystemForLinux` is `true`; otherwise it uses `win`. macOS defaults to `mac`. -`app patch` uses the same platform resolution and writes the same Windows -setting before persisting `CODEX_CLI_PATH`, so the selected backend keeps using -the matching native Windows or WSL Linux codext binary while the installed app -version still matches the patch. - Default downloaded CLIs are cached directly under: ```text @@ -56,45 +46,7 @@ Windows App launching is handled by the Windows `codex-auth.exe` build. For the auto-detected app, launch resolves the package AUMID and opens `shell:AppsFolder\`. Use a Windows app path such as `C:\Program Files\WindowsApps\...\app\Codex.exe` for `--app-path` only when an -explicit override is needed. The WSL build does not patch or launch Windows App -packages. - -On Windows, `app patch` writes the user environment variable with -`[Environment]::SetEnvironmentVariable(..., 'User')` and broadcasts an -environment change. The value points to a generated guarded shim under -`$CODEX_HOME/accounts/codext-cli/codex-patch-`, and that shim points to -the managed `codex-` file in the same directory. Existing Codex App -processes must still be closed; some already-running parent processes may -require a fresh Explorer session, sign-out, or reboot before Start-menu launches -inherit the updated variable. - -On macOS, `app patch` sets the current `launchctl` GUI-session environment and -installs `~/Library/LaunchAgents/com.codex-auth.app-env.plist` so the variable is -restored at login. The LaunchAgent also points at a generated guarded shim. -`app unpatch` unloads and removes that LaunchAgent. - -This is only needed for persistent GUI launches from Finder, Dock, Spotlight, or -login-restored sessions. One-shot `codex-auth app` launches do not need the -LaunchAgent; they pass the resolved `CODEX_CLI_PATH` directly to the launched -process. - -The guarded shim is version-bound: - -- Windows MSIX/AppX patches are tied to the package install path, which includes - the AppX package version. -- WSL patches use the same package-root guard after Windows paths are converted - to WSL paths. -- macOS patches are tied to the app bundle's `CFBundleVersion`. - -If the app updates or a different Codex-family app inherits the same user-level -`CODEX_CLI_PATH`, the shim does not continue using the patched codext binary. It -falls back to the bundled/default CLI for that app where available, so a new app -version requires running `codex-auth app patch` again. - -This follows the same durable-hook idea as app-bundle patchers, but it uses the -official `CODEX_CLI_PATH` hook instead of editing the app package. That avoids -MSIX/AppX package-integrity and install-directory permission problems on -Windows while still making normal app launches use the replacement CLI. +explicit override is needed. The WSL build does not launch Windows App packages. For Windows-native App launches, `--codex-cli-path` must point to something the Windows App process can spawn. A WSL command name such as `codex-custom` is not a diff --git a/src/cli/commands/app.zig b/src/cli/commands/app.zig index 3844b7f4..98ed1cb5 100644 --- a/src/cli/commands/app.zig +++ b/src/cli/commands/app.zig @@ -7,8 +7,6 @@ pub fn parse(allocator: std.mem.Allocator, args: []const [:0]const u8) !types.Pa const first = std.mem.sliceTo(args[0], 0); if (common.isHelpFlag(first)) return .{ .command = .{ .help = .app } }; - if (std.mem.eql(u8, first, "patch")) return parseOptions(allocator, .patch, args[1..]); - if (std.mem.eql(u8, first, "unpatch")) return parseOptions(allocator, .unpatch, args[1..]); return parseOptions(allocator, .launch, args); } diff --git a/src/cli/help.zig b/src/cli/help.zig index 236c15c7..9369638e 100644 --- a/src/cli/help.zig +++ b/src/cli/help.zig @@ -57,9 +57,7 @@ pub fn writeHelp( try writeCommandDetail(out, use_color, "clean background"); try writeCommandSummary(out, use_color, "config", "Manage configuration"); try writeCommandDetail(out, use_color, "config live --interval "); - try writeCommandSummary(out, use_color, "app", "Launch or version-bound patch Codex App CLI overrides"); - try writeCommandDetail(out, use_color, "app patch"); - try writeCommandDetail(out, use_color, "app unpatch"); + try writeCommandSummary(out, use_color, "app", "Launch Codex App with CLI overrides"); try out.writeAll("\n"); if (use_color) try out.writeAll(style.ansi.cyan); @@ -150,7 +148,7 @@ fn commandDescriptionForTopic(topic: HelpTopic) []const u8 { .alias => "Set or clear an account alias by alias, email, display number, or partial query.", .clean => "Delete backup and stale files under accounts/.", .config => "Manage live refresh configuration.", - .app => "Launch or persistently patch version-bound Codex App CLI overrides.", + .app => "Launch Codex App with CLI overrides.", }; } @@ -228,8 +226,6 @@ fn writeUsageLines(out: *std.Io.Writer, topic: HelpTopic) !void { }, .app => { try out.writeAll(" codex-auth app [--app-path ] [--codex-cli-path ] [--codex-home ] [--platform win|wsl|mac]\n"); - try out.writeAll(" codex-auth app patch [--codex-cli-path ] [--codex-home ] [--platform win|wsl|mac]\n"); - try out.writeAll(" codex-auth app unpatch\n"); }, } } @@ -386,8 +382,6 @@ fn writeExampleLines(out: *std.Io.Writer, topic: HelpTopic) !void { .app => { try out.writeAll(" codex-auth app\n"); try out.writeAll(" codex-auth app --platform win\n"); - try out.writeAll(" codex-auth app patch --platform wsl\n"); - try out.writeAll(" codex-auth app unpatch\n"); }, } } diff --git a/src/cli/types.zig b/src/cli/types.zig index 623574f5..33fd2f4d 100644 --- a/src/cli/types.zig +++ b/src/cli/types.zig @@ -54,7 +54,7 @@ pub const LiveOptions = struct { interval_seconds: u16, }; pub const ConfigOptions = union(enum) { live: LiveOptions }; -pub const AppAction = enum { launch, patch, unpatch }; +pub const AppAction = enum { launch }; pub const AppPlatform = enum { win, wsl, mac }; pub const AppOptions = struct { action: AppAction, diff --git a/src/main.zig b/src/main.zig index 27a3cd47..fb6d5ded 100644 --- a/src/main.zig +++ b/src/main.zig @@ -4,11 +4,5 @@ const codex_auth = @import("root.zig"); pub fn main(init: std.process.Init.Minimal) !void { var gpa: std.heap.DebugAllocator(.{}) = .init; defer std.debug.assert(gpa.deinit() == .ok); - const allocator = gpa.allocator(); - const self_exe = try std.process.executablePathAlloc(codex_auth.core.runtime.io(), allocator); - defer allocator.free(self_exe); - if (codex_auth.app_workflow.isGuardedShimExecutablePath(self_exe)) { - return codex_auth.app_workflow.runGuardedAppShim(allocator, init); - } return codex_auth.workflows.main(init); } diff --git a/src/workflows/app.zig b/src/workflows/app.zig index 2501acdb..8ee1d0c3 100644 --- a/src/workflows/app.zig +++ b/src/workflows/app.zig @@ -13,9 +13,6 @@ const codex_app_bundle_id = "com.openai.codex"; const wsl_agent_mode_key = "runCodexInWindowsSubsystemForLinux"; const codext_repo_latest_url = "https://api.github.com/repos/Loongphy/codext/releases/latest"; const codext_cache_dir_name = "codext-cli"; -const guarded_script_name = "codex-auth-app-shim"; -const guarded_windows_shim_name = "codex-auth-app-shim.exe"; -const mac_persistent_env_label = "com.codex-auth.app-env"; const ValueSource = enum { explicit, env, detected, cached, downloaded, not_set }; @@ -37,18 +34,14 @@ const ResolvedPlatform = struct { pub fn handleApp(allocator: std.mem.Allocator, resolved_codex_home: []const u8, opts: types.AppOptions) !void { const effective_home = opts.codex_home orelse resolved_codex_home; const effective_platform = try resolvePlatform(allocator, effective_home, opts.platform); - if (opts.action == .launch or opts.action == .patch) try validateAppPlatform(effective_platform.value); + try validateAppPlatform(effective_platform.value); const effective_app_path = try resolveAppPath(allocator, opts); defer effective_app_path.deinit(allocator); - const allow_download = opts.action == .launch or opts.action == .patch; - const quiet_download = opts.action == .launch; - const effective_cli_path = try resolveCliPath(allocator, effective_home, effective_platform.value, opts, allow_download, quiet_download); + const effective_cli_path = try resolveCliPath(allocator, effective_home, effective_platform.value, opts, true, true); defer effective_cli_path.deinit(allocator); switch (opts.action) { .launch => try launchApp(allocator, effective_app_path, effective_cli_path, effective_home, effective_platform, opts.inherit_stdio), - .patch => try patchApp(allocator, effective_app_path, effective_cli_path, effective_home, effective_platform), - .unpatch => try unpatchApp(allocator), } } @@ -163,38 +156,6 @@ fn launchApp( return error.UnsupportedPlatform; } -fn patchApp( - allocator: std.mem.Allocator, - app_path: ResolvedValue, - cli_path: ResolvedValue, - home: []const u8, - platform: ResolvedPlatform, -) !void { - try validateAppPlatform(platform.value); - try applyAppPlatform(allocator, home, platform.value); - const target_cli = cli_path.value orelse { - try writeAppError("app patch could not resolve CODEX_CLI_PATH. Pass `--codex-cli-path ` or allow the default Loongphy codext download.\n"); - return error.CliPathRequired; - }; - const target_app = app_path.value orelse { - try writeAppError("app patch could not find the installed Codex app. Pass `--app-path ` or set CODEX_AUTH_APP_PATH.\n"); - return error.AppPathRequired; - }; - const launch_path = try resolveLaunchPath(allocator, target_app); - defer allocator.free(launch_path); - const target_platform = platform.value orelse return error.UnsupportedPlatform; - const shim_path = try installGuardedCliShim(allocator, home, launch_path, target_cli, target_platform); - defer allocator.free(shim_path); - try persistCliPath(allocator, shim_path); - try writeAppOutput("persistent CODEX_CLI_PATH={s}\n", .{shim_path}); - try writeAppOutput("guarded target CLI={s}\n", .{target_cli}); -} - -fn unpatchApp(allocator: std.mem.Allocator) !void { - try clearPersistentCliPath(allocator); - try writeAppOutput("persistent CODEX_CLI_PATH cleared\n", .{}); -} - fn validateAppPlatform(value: ?types.AppPlatform) !void { const platform = value orelse return; switch (platform) { @@ -398,465 +359,6 @@ fn fileExists(path: []const u8) bool { return true; } -pub fn isGuardedShimExecutablePath(path: []const u8) bool { - const base = std.fs.path.basename(path); - return std.mem.eql(u8, base, guarded_windows_shim_name) or - std.mem.eql(u8, base, guarded_script_name) or - (std.mem.startsWith(u8, base, "codex-patch-") and (std.mem.endsWith(u8, base, ".exe") or std.mem.indexOfScalar(u8, base, '.') == null)); -} - -const GuardedShimConfig = struct { - expected_root: []u8, - target_cli: []u8, - - fn deinit(self: GuardedShimConfig, allocator: std.mem.Allocator) void { - allocator.free(self.expected_root); - allocator.free(self.target_cli); - } -}; - -pub fn runGuardedAppShim(allocator: std.mem.Allocator, init: std.process.Init.Minimal) !void { - var arena_state = std.heap.ArenaAllocator.init(allocator); - defer arena_state.deinit(); - const args = try init.args.toSlice(arena_state.allocator()); - - const self_exe = try std.process.executablePathAlloc(app_runtime.io(), allocator); - defer allocator.free(self_exe); - const config = try readGuardedShimConfig(allocator, self_exe); - defer config.deinit(allocator); - - const cwd_z = try std.process.currentPathAlloc(app_runtime.io(), allocator); - defer allocator.free(cwd_z); - const cwd = std.mem.sliceTo(cwd_z, 0); - - const target = if (pathHasRoot(cwd, config.expected_root, builtin.os.tag == .windows)) - try allocator.dupe(u8, config.target_cli) - else - try fallbackCliForCurrentApp(allocator, cwd); - defer allocator.free(target); - - var argv = std.ArrayList([]const u8).empty; - defer argv.deinit(allocator); - try argv.append(allocator, target); - for (args[1..]) |arg| try argv.append(allocator, std.mem.sliceTo(arg, 0)); - - var env_map = try registry.getEnvMap(allocator); - defer env_map.deinit(); - var child = try std.process.spawn(app_runtime.io(), .{ - .argv = argv.items, - .environ_map = &env_map, - .stdin = .inherit, - .stdout = .inherit, - .stderr = .inherit, - }); - const term = try child.wait(app_runtime.io()); - switch (term) { - .exited => |code| std.process.exit(@intCast(@min(code, 255))), - else => std.process.exit(1), - } -} - -fn readGuardedShimConfig(allocator: std.mem.Allocator, self_exe: []const u8) !GuardedShimConfig { - const config_path = try std.fmt.allocPrint(allocator, "{s}.json", .{self_exe}); - defer allocator.free(config_path); - var file = try std.Io.Dir.cwd().openFile(app_runtime.io(), config_path, .{}); - defer file.close(app_runtime.io()); - const data = try registry.readFileAlloc(file, allocator, 1024 * 1024); - defer allocator.free(data); - const parsed = try std.json.parseFromSlice(std.json.Value, allocator, data, .{}); - defer parsed.deinit(); - const object = switch (parsed.value) { - .object => |object| object, - else => return error.InvalidGuardedShimConfig, - }; - const expected_root = switch (object.get("expected_root") orelse return error.InvalidGuardedShimConfig) { - .string => |value| try allocator.dupe(u8, value), - else => return error.InvalidGuardedShimConfig, - }; - errdefer allocator.free(expected_root); - const target_cli = switch (object.get("target_cli") orelse return error.InvalidGuardedShimConfig) { - .string => |value| try allocator.dupe(u8, value), - else => return error.InvalidGuardedShimConfig, - }; - return .{ .expected_root = expected_root, .target_cli = target_cli }; -} - -fn fallbackCliForCurrentApp(allocator: std.mem.Allocator, cwd: []const u8) ![]u8 { - const candidates = [_][]const u8{ "codex.exe", "codex" }; - for (candidates) |name| { - const candidate = try std.fs.path.join(allocator, &.{ cwd, name }); - if (fileExists(candidate)) return candidate; - allocator.free(candidate); - } - try writeAppError("codex-auth app shim skipped the guarded override because the app package changed, but no bundled fallback CLI was found in the current app resources.\n"); - return error.GuardedShimFallbackNotFound; -} - -fn pathHasRoot(path: []const u8, root: []const u8, case_insensitive: bool) bool { - if (path.len < root.len) return false; - const path_prefix = path[0..root.len]; - const prefix_matches = if (case_insensitive) - std.ascii.eqlIgnoreCase(path_prefix, root) - else - std.mem.eql(u8, path_prefix, root); - if (!prefix_matches) return false; - if (path.len == root.len) return true; - return path[root.len] == '/' or path[root.len] == '\\'; -} - -fn installGuardedCliShim( - allocator: std.mem.Allocator, - home: []const u8, - app_launch_path: []const u8, - target_cli: []const u8, - platform: types.AppPlatform, -) ![]u8 { - const expected_root = try appGuardRootAlloc(allocator, app_launch_path, platform); - defer allocator.free(expected_root); - const shim_dir = try std.fs.path.join(allocator, &.{ home, "accounts", codext_cache_dir_name }); - defer allocator.free(shim_dir); - try std.Io.Dir.cwd().createDirPath(app_runtime.io(), shim_dir); - - return switch (platform) { - .win => try installWindowsGuardedCliShim(allocator, shim_dir, expected_root, target_cli), - .wsl => try installWslGuardedCliShim(allocator, shim_dir, home, expected_root, target_cli), - .mac => try installMacGuardedCliShim(allocator, shim_dir, expected_root, target_cli), - }; -} - -fn installWindowsGuardedCliShim( - allocator: std.mem.Allocator, - shim_dir: []const u8, - expected_root: []const u8, - target_cli: []const u8, -) ![]u8 { - const self_exe = try std.process.executablePathAlloc(app_runtime.io(), allocator); - defer allocator.free(self_exe); - const shim_name = try guardedShimFileName(allocator, .win); - defer allocator.free(shim_name); - const shim_path = try std.fs.path.join(allocator, &.{ shim_dir, shim_name }); - errdefer allocator.free(shim_path); - try std.Io.Dir.copyFileAbsolute(self_exe, shim_path, app_runtime.io(), .{ .replace = true, .make_path = true }); - const config_path = try std.fmt.allocPrint(allocator, "{s}.json", .{shim_path}); - defer allocator.free(config_path); - const config = try guardedShimConfigText(allocator, expected_root, target_cli); - defer allocator.free(config); - try std.Io.Dir.cwd().writeFile(app_runtime.io(), .{ .sub_path = config_path, .data = config }); - return shim_path; -} - -fn installWslGuardedCliShim( - allocator: std.mem.Allocator, - shim_dir: []const u8, - home: []const u8, - expected_root: []const u8, - target_cli: []const u8, -) ![]u8 { - const expected_wsl = try windowsPathToWslPathAlloc(allocator, expected_root); - defer allocator.free(expected_wsl); - const target_wsl = try windowsPathToWslPathAlloc(allocator, target_cli); - defer allocator.free(target_wsl); - const home_wsl = try windowsPathToWslPathAlloc(allocator, home); - defer allocator.free(home_wsl); - const script = try wslGuardedShimScript(allocator, expected_wsl, target_wsl, home_wsl); - defer allocator.free(script); - const shim_name = try guardedShimFileName(allocator, .wsl); - defer allocator.free(shim_name); - const shim_path = try std.fs.path.join(allocator, &.{ shim_dir, shim_name }); - errdefer allocator.free(shim_path); - try writeExecutableTextFile(shim_path, script); - return shim_path; -} - -fn installMacGuardedCliShim( - allocator: std.mem.Allocator, - shim_dir: []const u8, - expected_root: []const u8, - target_cli: []const u8, -) ![]u8 { - const expected_version = try readMacBundleVersion(allocator, expected_root); - defer allocator.free(expected_version); - const script = try macGuardedShimScript(allocator, expected_root, expected_version, target_cli); - defer allocator.free(script); - const shim_name = try guardedShimFileName(allocator, .mac); - defer allocator.free(shim_name); - const shim_path = try std.fs.path.join(allocator, &.{ shim_dir, shim_name }); - errdefer allocator.free(shim_path); - try writeExecutableTextFile(shim_path, script); - return shim_path; -} - -fn writeExecutableTextFile(path: []const u8, data: []const u8) !void { - try std.Io.Dir.cwd().writeFile(app_runtime.io(), .{ .sub_path = path, .data = data }); - if (builtin.os.tag != .windows) { - try std.Io.Dir.cwd().setFilePermissions(app_runtime.io(), path, std.Io.File.Permissions.fromMode(0o755), .{}); - } -} - -fn guardedShimFileName(allocator: std.mem.Allocator, platform: types.AppPlatform) ![]u8 { - return if (platform == .win) - try std.fmt.allocPrint(allocator, "codex-patch-{s}.exe", .{codextPlatformCacheName(platform)}) - else - try std.fmt.allocPrint(allocator, "codex-patch-{s}", .{codextPlatformCacheName(platform)}); -} - -fn guardedShimConfigText(allocator: std.mem.Allocator, expected_root: []const u8, target_cli: []const u8) ![]u8 { - const escaped_root = try jsonEscapeAlloc(allocator, expected_root); - defer allocator.free(escaped_root); - const escaped_target = try jsonEscapeAlloc(allocator, target_cli); - defer allocator.free(escaped_target); - return try std.fmt.allocPrint( - allocator, - "{{\"expected_root\":\"{s}\",\"target_cli\":\"{s}\"}}\n", - .{ escaped_root, escaped_target }, - ); -} - -fn wslGuardedShimScript(allocator: std.mem.Allocator, expected_root: []const u8, target_cli: []const u8, fallback_home: []const u8) ![]u8 { - const expected_quoted = try shellSingleQuoteAlloc(allocator, expected_root); - defer allocator.free(expected_quoted); - const target_quoted = try shellSingleQuoteAlloc(allocator, target_cli); - defer allocator.free(target_quoted); - const fallback_home_quoted = try shellSingleQuoteAlloc(allocator, fallback_home); - defer allocator.free(fallback_home_quoted); - return try std.fmt.allocPrint( - allocator, - \\#!/usr/bin/env bash - \\set -e - \\expected={s} - \\target={s} - \\fallback_home={s} - \\case "$PWD" in - \\ "$expected"|"$expected"/*) exec "$target" "$@" ;; - \\esac - \\for fallback in "${{CODEX_HOME:-}}/bin/wsl/codex" "$fallback_home/bin/wsl/codex"; do - \\ if [ -x "$fallback" ]; then exec "$fallback" "$@"; fi - \\done - \\printf '%s\n' 'codex-auth app shim skipped the guarded override because the app package changed, but no bundled fallback CLI was found.' >&2 - \\exit 126 - \\ - , - .{ expected_quoted, target_quoted, fallback_home_quoted }, - ); -} - -fn macGuardedShimScript(allocator: std.mem.Allocator, expected_root: []const u8, expected_version: []const u8, target_cli: []const u8) ![]u8 { - const root_quoted = try shellSingleQuoteAlloc(allocator, expected_root); - defer allocator.free(root_quoted); - const version_quoted = try shellSingleQuoteAlloc(allocator, expected_version); - defer allocator.free(version_quoted); - const target_quoted = try shellSingleQuoteAlloc(allocator, target_cli); - defer allocator.free(target_quoted); - return try std.fmt.allocPrint( - allocator, - \\#!/usr/bin/env bash - \\set -e - \\expected_root={s} - \\expected_version={s} - \\target={s} - \\current_version=$(/usr/bin/defaults read "$expected_root/Contents/Info" CFBundleVersion 2>/dev/null || true) - \\if [ "$current_version" = "$expected_version" ]; then - \\ exec "$target" "$@" - \\fi - \\for fallback in "$PWD/codex" "$expected_root/Contents/Resources/codex"; do - \\ if [ -x "$fallback" ]; then exec "$fallback" "$@"; fi - \\done - \\printf '%s\n' 'codex-auth app shim skipped the guarded override because the app bundle version changed, but no bundled fallback CLI was found.' >&2 - \\exit 126 - \\ - , - .{ root_quoted, version_quoted, target_quoted }, - ); -} - -fn appGuardRootAlloc(allocator: std.mem.Allocator, app_launch_path: []const u8, platform: types.AppPlatform) ![]u8 { - if (platform == .mac) { - if (std.mem.indexOf(u8, app_launch_path, ".app")) |idx| { - return try allocator.dupe(u8, app_launch_path[0 .. idx + ".app".len]); - } - } - - if (indexOfIgnoreCase(app_launch_path, "\\app\\codex.exe")) |idx| return try allocator.dupe(u8, app_launch_path[0..idx]); - if (indexOfIgnoreCase(app_launch_path, "/app/codex.exe")) |idx| return try allocator.dupe(u8, app_launch_path[0..idx]); - if (std.fs.path.dirname(app_launch_path)) |dir| { - if (std.fs.path.dirname(dir)) |parent| return try allocator.dupe(u8, parent); - return try allocator.dupe(u8, dir); - } - return try allocator.dupe(u8, app_launch_path); -} - -fn readMacBundleVersion(allocator: std.mem.Allocator, app_root: []const u8) ![]u8 { - const info_path = try std.fs.path.join(allocator, &.{ app_root, "Contents", "Info.plist" }); - defer allocator.free(info_path); - var result = try http_child.runChildCapture( - allocator, - &[_][]const u8{ "/usr/bin/plutil", "-extract", "CFBundleVersion", "raw", "-o", "-", info_path }, - 7000, - null, - ); - defer result.deinit(allocator); - const trimmed = std.mem.trim(u8, result.stdout, " \t\r\n"); - if (trimmed.len == 0) return error.MacBundleVersionNotFound; - return try allocator.dupe(u8, trimmed); -} - -fn windowsPathToWslPathAlloc(allocator: std.mem.Allocator, path: []const u8) ![]u8 { - if (std.mem.startsWith(u8, path, "/")) return try allocator.dupe(u8, path); - if (path.len >= 3 and std.ascii.isAlphabetic(path[0]) and path[1] == ':' and (path[2] == '\\' or path[2] == '/')) { - var out = std.ArrayList(u8).empty; - errdefer out.deinit(allocator); - try out.appendSlice(allocator, "/mnt/"); - try out.append(allocator, std.ascii.toLower(path[0])); - for (path[2..]) |ch| { - try out.append(allocator, if (ch == '\\') '/' else ch); - } - return try out.toOwnedSlice(allocator); - } - return try allocator.dupe(u8, path); -} - -fn indexOfIgnoreCase(haystack: []const u8, needle: []const u8) ?usize { - if (needle.len == 0) return 0; - if (haystack.len < needle.len) return null; - var i: usize = 0; - while (i + needle.len <= haystack.len) : (i += 1) { - if (std.ascii.eqlIgnoreCase(haystack[i .. i + needle.len], needle)) return i; - } - return null; -} - -fn persistCliPath(allocator: std.mem.Allocator, cli_path: []const u8) !void { - switch (builtin.os.tag) { - .windows => try persistWindowsCliPath(allocator, cli_path), - .macos => try persistMacCliPath(allocator, cli_path), - else => { - try writeAppError("app patch is supported only from the Windows or macOS codex-auth executable.\n"); - return error.UnsupportedPlatform; - }, - } -} - -fn clearPersistentCliPath(allocator: std.mem.Allocator) !void { - switch (builtin.os.tag) { - .windows => try clearWindowsPersistentCliPath(allocator), - .macos => try clearMacPersistentCliPath(allocator), - else => { - try writeAppError("app unpatch is supported only from the Windows or macOS codex-auth executable.\n"); - return error.UnsupportedPlatform; - }, - } -} - -fn persistWindowsCliPath(allocator: std.mem.Allocator, cli_path: []const u8) !void { - const cli_quoted = try psSingleQuoteAlloc(allocator, cli_path); - defer allocator.free(cli_quoted); - const script = try std.fmt.allocPrint( - allocator, - "$ErrorActionPreference='Stop'; [Environment]::SetEnvironmentVariable('CODEX_CLI_PATH',{s},'User'); try {{ $sig='[DllImport(\"user32.dll\", SetLastError=true, CharSet=CharSet.Auto)] public static extern IntPtr SendMessageTimeout(IntPtr hWnd, int Msg, UIntPtr wParam, string lParam, int fuFlags, int uTimeout, out UIntPtr lpdwResult);'; Add-Type -MemberDefinition $sig -Name NativeMethods -Namespace CodexAuthEnv -ErrorAction SilentlyContinue; $r=[UIntPtr]::Zero; [CodexAuthEnv.NativeMethods]::SendMessageTimeout([IntPtr]0xffff,0x1A,[UIntPtr]::Zero,'Environment',0x2,5000,[ref]$r) | Out-Null }} catch {{ }}", - .{cli_quoted}, - ); - defer allocator.free(script); - try runChecked(allocator, &[_][]const u8{ "pwsh.exe", "-NoProfile", "-Command", script }, 7000); -} - -fn clearWindowsPersistentCliPath(allocator: std.mem.Allocator) !void { - const script = - "$ErrorActionPreference='Stop'; [Environment]::SetEnvironmentVariable('CODEX_CLI_PATH',$null,'User'); try { $sig='[DllImport(\"user32.dll\", SetLastError=true, CharSet=CharSet.Auto)] public static extern IntPtr SendMessageTimeout(IntPtr hWnd, int Msg, UIntPtr wParam, string lParam, int fuFlags, int uTimeout, out UIntPtr lpdwResult);'; Add-Type -MemberDefinition $sig -Name NativeMethods -Namespace CodexAuthEnv -ErrorAction SilentlyContinue; $r=[UIntPtr]::Zero; [CodexAuthEnv.NativeMethods]::SendMessageTimeout([IntPtr]0xffff,0x1A,[UIntPtr]::Zero,'Environment',0x2,5000,[ref]$r) | Out-Null } catch { }"; - try runChecked(allocator, &[_][]const u8{ "pwsh.exe", "-NoProfile", "-Command", script }, 7000); -} - -fn persistMacCliPath(allocator: std.mem.Allocator, cli_path: []const u8) !void { - const plist_path = try macPersistentEnvPlistPath(allocator); - defer allocator.free(plist_path); - const plist = try macPersistentEnvPlistText(allocator, cli_path); - defer allocator.free(plist); - - if (std.fs.path.dirname(plist_path)) |dir| { - try std.Io.Dir.cwd().createDirPath(app_runtime.io(), dir); - } - try std.Io.Dir.cwd().writeFile(app_runtime.io(), .{ .sub_path = plist_path, .data = plist }); - _ = runChecked(allocator, &[_][]const u8{ "launchctl", "unload", plist_path }, 7000) catch {}; - try runChecked(allocator, &[_][]const u8{ "launchctl", "load", plist_path }, 7000); - try runChecked(allocator, &[_][]const u8{ "launchctl", "setenv", codex_cli_path_env, cli_path }, 7000); -} - -fn clearMacPersistentCliPath(allocator: std.mem.Allocator) !void { - const plist_path = try macPersistentEnvPlistPath(allocator); - defer allocator.free(plist_path); - _ = runChecked(allocator, &[_][]const u8{ "launchctl", "unsetenv", codex_cli_path_env }, 7000) catch {}; - _ = runChecked(allocator, &[_][]const u8{ "launchctl", "unload", plist_path }, 7000) catch {}; - std.Io.Dir.deleteFileAbsolute(app_runtime.io(), plist_path) catch |err| switch (err) { - error.FileNotFound => {}, - else => return err, - }; -} - -fn macPersistentEnvPlistPath(allocator: std.mem.Allocator) ![]u8 { - const home = getOptionalEnv(allocator, "HOME") orelse return error.EnvironmentVariableNotFound; - defer allocator.free(@constCast(home)); - return try std.fs.path.join(allocator, &.{ home, "Library", "LaunchAgents", "com.codex-auth.app-env.plist" }); -} - -fn macPersistentEnvPlistText(allocator: std.mem.Allocator, cli_path: []const u8) ![]u8 { - const escaped_path = try xmlEscapeAlloc(allocator, cli_path); - defer allocator.free(escaped_path); - return try std.fmt.allocPrint( - allocator, - \\ - \\ - \\ - \\ - \\ Label - \\ {s} - \\ ProgramArguments - \\ - \\ /bin/launchctl - \\ setenv - \\ CODEX_CLI_PATH - \\ {s} - \\ - \\ RunAtLoad - \\ - \\ - \\ - \\ - , - .{ mac_persistent_env_label, escaped_path }, - ); -} - -fn xmlEscapeAlloc(allocator: std.mem.Allocator, value: []const u8) ![]u8 { - var out = std.ArrayList(u8).empty; - errdefer out.deinit(allocator); - for (value) |ch| { - switch (ch) { - '&' => try out.appendSlice(allocator, "&"), - '<' => try out.appendSlice(allocator, "<"), - '>' => try out.appendSlice(allocator, ">"), - '"' => try out.appendSlice(allocator, """), - '\'' => try out.appendSlice(allocator, "'"), - else => try out.append(allocator, ch), - } - } - return try out.toOwnedSlice(allocator); -} - -fn jsonEscapeAlloc(allocator: std.mem.Allocator, value: []const u8) ![]u8 { - var out = std.ArrayList(u8).empty; - errdefer out.deinit(allocator); - for (value) |ch| { - switch (ch) { - '\\' => try out.appendSlice(allocator, "\\\\"), - '"' => try out.appendSlice(allocator, "\\\""), - '\n' => try out.appendSlice(allocator, "\\n"), - '\r' => try out.appendSlice(allocator, "\\r"), - '\t' => try out.appendSlice(allocator, "\\t"), - else => try out.append(allocator, ch), - } - } - return try out.toOwnedSlice(allocator); -} - fn shellSingleQuoteAlloc(allocator: std.mem.Allocator, value: []const u8) ![]u8 { var out = std.ArrayList(u8).empty; errdefer out.deinit(allocator); diff --git a/tests/cli_behavior_test.zig b/tests/cli_behavior_test.zig index b3e513c9..68c868fc 100644 --- a/tests/cli_behavior_test.zig +++ b/tests/cli_behavior_test.zig @@ -136,37 +136,22 @@ test "Scenario: Given removed app status subcommand when parsing then usage erro try expectUsageError(result, .app, "unexpected argument `status` for `app`."); } -test "Scenario: Given app patch when parsing then patch action is preserved" { +test "Scenario: Given removed app patch subcommand when parsing then usage error is returned" { const gpa = std.testing.allocator; const args = [_][:0]const u8{ "codex-auth", "app", "patch", "--platform", "wsl" }; var result = try cli.commands.parseArgs(gpa, &args); defer cli.commands.freeParseResult(gpa, &result); - switch (result) { - .command => |cmd| switch (cmd) { - .app => |opts| { - try std.testing.expectEqual(cli.types.AppAction.patch, opts.action); - try std.testing.expectEqual(cli.types.AppPlatform.wsl, opts.platform.?); - }, - else => return error.TestExpectedEqual, - }, - else => return error.TestExpectedEqual, - } + try expectUsageError(result, .app, "unexpected argument `patch` for `app`."); } -test "Scenario: Given app unpatch when parsing then unpatch action is preserved" { +test "Scenario: Given removed app unpatch subcommand when parsing then usage error is returned" { const gpa = std.testing.allocator; const args = [_][:0]const u8{ "codex-auth", "app", "unpatch" }; var result = try cli.commands.parseArgs(gpa, &args); defer cli.commands.freeParseResult(gpa, &result); - switch (result) { - .command => |cmd| switch (cmd) { - .app => |opts| try std.testing.expectEqual(cli.types.AppAction.unpatch, opts.action), - else => return error.TestExpectedEqual, - }, - else => return error.TestExpectedEqual, - } + try expectUsageError(result, .app, "unexpected argument `unpatch` for `app`."); } fn expectedImportMarker(outcome: registry.ImportOutcome) []const u8 { From 1322e71390b7d743ed6f9ffde0c611182febbd81 Mon Sep 17 00:00:00 2001 From: Loongphy Date: Thu, 21 May 2026 07:40:05 +0800 Subject: [PATCH 15/16] Add app command flow improvements --- docs/commands/app.md | 40 ++- docs/windows.md | 59 ++++ src/cli/help.zig | 2 +- src/cli/style.zig | 10 + src/cli/table_layout.zig | 4 +- src/terminal/color.zig | 44 ++- src/workflows/app.zig | 501 +++++++++++++++++++++++++++++++-- src/workflows/preflight.zig | 8 + style.md | 8 + tests/cli_integration_test.zig | 177 ++++++++++++ 10 files changed, 823 insertions(+), 30 deletions(-) create mode 100644 docs/windows.md diff --git a/docs/commands/app.md b/docs/commands/app.md index c4ad3a98..22d25541 100644 --- a/docs/commands/app.md +++ b/docs/commands/app.md @@ -11,8 +11,10 @@ codex-auth app [--app-path ] [--codex-cli-path ] [--codex-home ` points to the App executable or an installed package/app directory. -- `--codex-cli-path ` is injected as `CODEX_CLI_PATH` for this launch. If it is omitted, `app` fetches the latest Loongphy codext release metadata, compares it with the managed cached CLI version, downloads only when the cached version differs or is missing, and uses that file; it does not reuse an existing `CODEX_CLI_PATH` from the current shell. +- If the Codex App is already running, `app` prints that status and exits before + resolving or downloading the managed CLI. +- `--app-path ` points to the App executable or an installed package/app directory. Explicit and environment-provided app paths must exist before launch planning starts. +- `--codex-cli-path ` is injected as `CODEX_CLI_PATH` for this launch. Explicit CLI paths must exist. If it is omitted, `app` fetches the latest [`Loongphy/codext`](https://github.com/Loongphy/codext) release metadata, compares it with the managed cached CLI version for the selected platform, downloads only when the cached version differs or is missing, and uses that file; it does not reuse an existing `CODEX_CLI_PATH` from the current shell. - `--codex-home ` is injected as `CODEX_HOME` for `app` launches and selects the accounts cache used for managed CLI resolution. - `--platform win|wsl|mac` selects the app runtime platform: - `win` writes the Windows global setting so the app runs the agent natively. @@ -20,6 +22,32 @@ Launches the official Codex App with per-process environment overrides. - `mac` launches the macOS app directly and does not use the Windows WSL setting. - `--std` starts the app executable directly with stdout/stderr attached to the current terminal. Use it for debugging app logs; normal launches stay quiet and use the platform GUI launcher. +`app` prints its launch plan and managed CLI resolution to stderr before +starting the GUI launcher. Example output: + +```text +Codex App is already running, launch skipped. +``` + +When the app is not already running, the output continues with launch planning: + +```text +- Checking latest https://github.com/Loongphy/codext release... + Downloading Codext CLI for WSL (v0.3.0) + https://github.com/Loongphy/codext/releases/download/.../codext-linux-x64.tar.gz +OK Downloaded Codext CLI for WSL (v0.3.0) + +- Environment Configuration ------------------------------------------------ + Platform: WSL (auto-detected) + Codex Home: C:\Users\Alice\.codext (explicit) + App Path: C:\Program Files\WindowsApps\OpenAI.Codext_...\app (explicit) + CLI Path: C:\Users\Alice\.codext\accounts\codext-cli\codex-linux-x64 (downloaded) +---------------------------------------------------------------------------- +Launching Codex App... +``` + +See [Windows](../windows.md) for Windows console color and character rules. + If `--app-path` is omitted, `CODEX_AUTH_APP_PATH` is used when set; otherwise the official installed app is auto-detected. On Windows this uses AppX package lookup for `OpenAI.Codex`. On macOS it checks `/Applications/Codex.app` and @@ -37,10 +65,10 @@ $CODEX_HOME/accounts/codext-cli/codex- $CODEX_HOME/accounts/codext-cli/codex-.version ``` -On Windows, the default download prepares both the Windows-native and WSL Linux -Loongphy codext assets for the current CPU architecture, such as `win32-x64` -and `linux-x64`. On macOS, it downloads only the matching macOS asset, such as -`darwin-x64` or `darwin-arm64`. +The default download prepares only the selected platform's +[`Loongphy/codext`](https://github.com/Loongphy/codext) asset for the current +CPU architecture, such as `win32-x64`, `linux-x64`, `darwin-x64`, or +`darwin-arm64`. Windows App launching is handled by the Windows `codex-auth.exe` build. For the auto-detected app, launch resolves the package AUMID and opens diff --git a/docs/windows.md b/docs/windows.md new file mode 100644 index 00000000..d1229945 --- /dev/null +++ b/docs/windows.md @@ -0,0 +1,59 @@ +# Windows + +## CLI output + +Windows console hosts vary in how they handle UTF-8 text and ANSI escape +sequences. `codex-auth` keeps Windows CLI output conservative so PowerShell, +Windows Terminal, `cmd.exe`, and CI logs stay readable. + +### Color + +- Color is enabled only for TTY output. +- `NO_COLOR` disables color. +- On Windows, ANSI color is emitted only after + `ENABLE_VIRTUAL_TERMINAL_PROCESSING` is already enabled or can be enabled for + the target console handle. +- If virtual-terminal processing cannot be verified, output falls back to plain + text with no ANSI escape sequences. + +### Characters + +- Windows-facing status markers must be ASCII by default. +- Do not use Unicode status glyphs such as check marks, warning signs, bullets, + arrows, or box-drawing characters in Windows default output. +- Unicode may be used for non-Windows output when it is already part of an + established command style. + +Recommended Windows status markers: + +```text +Codex App is already running, launch skipped. +- Checking latest https://github.com/Loongphy/codext release... + Downloading Codext CLI for WSL (v0.3.0) +OK Downloaded Codext CLI for WSL (v0.3.0) +``` + +### App command examples + +Already running: + +```text +Codex App is already running, launch skipped. +``` + +Launch with a managed CLI download: + +```text +- Checking latest https://github.com/Loongphy/codext release... + Downloading Codext CLI for WSL (v0.3.0) + https://github.com/Loongphy/codext/releases/download/.../codext-linux-x64.tar.gz +OK Downloaded Codext CLI for WSL (v0.3.0) + +- Environment Configuration ------------------------------------------------ + Platform: WSL (auto-detected) + Codex Home: C:\Users\Loong\.codext (explicit) + App Path: C:\Program Files\WindowsApps\OpenAI.Codext_...\app (explicit) + CLI Path: C:\Users\Loong\.codext\accounts\codext-cli\codex-linux-x64 (downloaded) +---------------------------------------------------------------------------- +Launching Codex App... +``` diff --git a/src/cli/help.zig b/src/cli/help.zig index 9369638e..8eefe508 100644 --- a/src/cli/help.zig +++ b/src/cli/help.zig @@ -301,7 +301,7 @@ fn writeOptionLines(out: *std.Io.Writer, topic: HelpTopic) !void { .app => { try out.writeAll(" --app-path Official Codex App executable or install directory.\n"); try out.writeAll(" --codex-cli-path \n"); - try out.writeAll(" Value injected or persisted as CODEX_CLI_PATH. Defaults to latest managed Loongphy codext.\n"); + try out.writeAll(" Value injected or persisted as CODEX_CLI_PATH. Defaults to latest managed Loongphy/codext.\n"); try out.writeAll(" --codex-home \n"); try out.writeAll(" Value injected as CODEX_HOME for this launch.\n"); try out.writeAll(" --platform win|wsl|mac\n"); diff --git a/src/cli/style.zig b/src/cli/style.zig index cac3b5f1..49396455 100644 --- a/src/cli/style.zig +++ b/src/cli/style.zig @@ -2,12 +2,22 @@ const std = @import("std"); pub const ansi = struct { pub const reset = "\x1b[0m"; + pub const bold = "\x1b[1m"; pub const dim = "\x1b[2m"; pub const red = "\x1b[31m"; pub const green = "\x1b[32m"; pub const cyan = "\x1b[36m"; }; +pub const role = struct { + pub const key = ansi.bold; + pub const secondary = ansi.dim; + pub const status = ansi.cyan; + pub const success = ansi.green; + pub const warning = ansi.cyan; + pub const error_text = ansi.red; +}; + pub const StyledWriter = struct { out: *std.Io.Writer, color_enabled: bool, diff --git a/src/cli/table_layout.zig b/src/cli/table_layout.zig index 37702a45..24db81f5 100644 --- a/src/cli/table_layout.zig +++ b/src/cli/table_layout.zig @@ -31,7 +31,7 @@ pub const LiveTable = struct { prefix_width: usize, pub fn writeHeader(self: *const LiveTable, writer: *style.StyledWriter) !void { - try writer.writeStyle(style.ansi.cyan); + try writer.writeStyle(style.role.status); try writeRepeat(writer.out, ' ', self.prefix_width); try self.writeCells(writer.out, &.{ .{ .text = self.columns[0].header }, @@ -45,7 +45,7 @@ pub const LiveTable = struct { } pub fn writeGroupRow(self: *const LiveTable, writer: *style.StyledWriter, account: []const u8) !void { - try writer.writeStyle(style.ansi.dim); + try writer.writeStyle(style.role.secondary); try writeRepeat(writer.out, ' ', self.prefix_width); try writeAccountTruncatedPadded(writer.out, account, self.columns[0].width); try writer.reset(); diff --git a/src/terminal/color.zig b/src/terminal/color.zig index a300f115..8a316c1c 100644 --- a/src/terminal/color.zig +++ b/src/terminal/color.zig @@ -1,6 +1,48 @@ const std = @import("std"); +const builtin = @import("builtin"); const app_runtime = @import("../core/runtime.zig"); +const windows = std.os.windows; + +const win = if (builtin.os.tag == .windows) struct { + const ENABLE_PROCESSED_OUTPUT: windows.DWORD = 0x0001; + + extern "kernel32" fn GetConsoleMode( + console_handle: windows.HANDLE, + mode: *windows.DWORD, + ) callconv(.winapi) windows.BOOL; + + extern "kernel32" fn SetConsoleMode( + console_handle: windows.HANDLE, + mode: windows.DWORD, + ) callconv(.winapi) windows.BOOL; +} else struct {}; + pub fn fileColorEnabled(file: std.Io.File) bool { - return file.isTty(app_runtime.io()) catch false; + if (envExists("NO_COLOR")) return false; + if (!(file.isTty(app_runtime.io()) catch false)) return false; + if (builtin.os.tag == .windows) return windowsAnsiColorEnabled(file); + return true; +} + +fn envExists(comptime name: [:0]const u8) bool { + const value = std.c.getenv(name) orelse return false; + return std.mem.span(value).len != 0; +} + +fn windowsAnsiColorEnabled(file: std.Io.File) bool { + if (builtin.os.tag != .windows) return false; + + var mode: windows.DWORD = 0; + if (win.GetConsoleMode(file.handle, &mode) == .FALSE) return false; + if ((mode & windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING) != 0) return true; + + const requested = mode | + win.ENABLE_PROCESSED_OUTPUT | + windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING; + if (win.SetConsoleMode(file.handle, requested) == .FALSE) return false; + + var verified: windows.DWORD = 0; + if (win.GetConsoleMode(file.handle, &verified) == .FALSE) return false; + return (verified & windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING) != 0; } diff --git a/src/workflows/app.zig b/src/workflows/app.zig index 8ee1d0c3..9d8ba05b 100644 --- a/src/workflows/app.zig +++ b/src/workflows/app.zig @@ -4,6 +4,8 @@ const app_runtime = @import("../core/runtime.zig"); const http_child = @import("../api/http_child.zig"); const registry = @import("../registry/root.zig"); const types = @import("../cli/types.zig"); +const io_util = @import("../core/io_util.zig"); +const cli_style = @import("../cli/style.zig"); const codex_cli_path_env = "CODEX_CLI_PATH"; const codex_home_env = "CODEX_HOME"; @@ -12,6 +14,7 @@ const codex_app_package_name = "OpenAI.Codex"; const codex_app_bundle_id = "com.openai.codex"; const wsl_agent_mode_key = "runCodexInWindowsSubsystemForLinux"; const codext_repo_latest_url = "https://api.github.com/repos/Loongphy/codext/releases/latest"; +const codext_repo_url = "https://github.com/Loongphy/codext"; const codext_cache_dir_name = "codext-cli"; const ValueSource = enum { explicit, env, detected, cached, downloaded, not_set }; @@ -31,14 +34,32 @@ const ResolvedPlatform = struct { source: ValueSource, }; +const CodextInstallResult = struct { + path: []u8, + source: ValueSource, +}; + +const AppPathValidationIssue = struct { + option: []const u8, + message: []const u8, + path: []const u8, +}; + pub fn handleApp(allocator: std.mem.Allocator, resolved_codex_home: []const u8, opts: types.AppOptions) !void { const effective_home = opts.codex_home orelse resolved_codex_home; const effective_platform = try resolvePlatform(allocator, effective_home, opts.platform); try validateAppPlatform(effective_platform.value); const effective_app_path = try resolveAppPath(allocator, opts); defer effective_app_path.deinit(allocator); - const effective_cli_path = try resolveCliPath(allocator, effective_home, effective_platform.value, opts, true, true); + try validateConfiguredAppPaths(allocator, effective_app_path, opts); + if (try isCodexAppRunning(allocator, effective_platform.value, effective_app_path)) { + try writeAppAlreadyRunning(); + return; + } + try requireAppPath(effective_app_path); + const effective_cli_path = try resolveCliPath(allocator, effective_home, effective_platform.value, opts, true, false); defer effective_cli_path.deinit(allocator); + try writeAppLaunchPlan(allocator, opts.codex_home != null, effective_home, effective_platform, effective_app_path, effective_cli_path); switch (opts.action) { .launch => try launchApp(allocator, effective_app_path, effective_cli_path, effective_home, effective_platform, opts.inherit_stdio), @@ -73,8 +94,8 @@ fn resolveCliPath( const target_platform = platform orelse nativeDefaultPlatform(); if (allow_download) { - const path = try downloadDefaultCodextCli(allocator, home, target_platform, quiet_download); - return .{ .value = path, .source = .downloaded, .owned = true }; + const result = try downloadDefaultCodextCli(allocator, home, target_platform, quiet_download); + return .{ .value = result.path, .source = result.source, .owned = true }; } if (try cachedCodextCliPath(allocator, home, target_platform)) |path| return .{ .value = path, .source = .cached, .owned = true }; return .{ .value = null, .source = .not_set }; @@ -90,6 +111,336 @@ fn resolvePlatform(allocator: std.mem.Allocator, home: []const u8, explicit: ?ty return .{ .value = null, .source = .not_set }; } +fn validateConfiguredAppPaths(allocator: std.mem.Allocator, app_path: ResolvedValue, opts: types.AppOptions) !void { + var issues = std.ArrayList(AppPathValidationIssue).empty; + defer issues.deinit(allocator); + + try appendConfiguredAppPathIssue(allocator, &issues, app_path); + if (opts.codex_cli_path) |path| try appendConfiguredCliPathIssue(allocator, &issues, path); + + if (issues.items.len == 0) return; + try writeAppPathValidationIssues(issues.items); + return error.AppPathValidationFailed; +} + +fn appendConfiguredAppPathIssue( + allocator: std.mem.Allocator, + issues: *std.ArrayList(AppPathValidationIssue), + app_path: ResolvedValue, +) !void { + const path = app_path.value orelse return; + switch (app_path.source) { + .explicit, .env => {}, + else => return, + } + + const kind = pathKind(path) catch |err| switch (err) { + error.AccessDenied, error.PermissionDenied => { + try issues.append(allocator, .{ .option = appPathOptionLabel(app_path.source), .message = "Path is not accessible", .path = path }); + return; + }, + else => return err, + } orelse { + try issues.append(allocator, .{ .option = appPathOptionLabel(app_path.source), .message = "Path does not exist", .path = path }); + return; + }; + if (kind == .directory) { + const launch_path = resolveLaunchPath(allocator, path) catch |err| switch (err) { + error.AppExecutableNotFound => { + try issues.append(allocator, .{ .option = appPathOptionLabel(app_path.source), .message = "Path does not contain a Codex executable", .path = path }); + return; + }, + else => return err, + }; + allocator.free(launch_path); + return; + } + if (kind == .file or kind == .sym_link) return; + + try issues.append(allocator, .{ .option = appPathOptionLabel(app_path.source), .message = "Path is not an executable file or app directory", .path = path }); +} + +fn requireAppPath(app_path: ResolvedValue) !void { + if (app_path.value != null) return; + try writeAppError("app launch could not find the installed Codex app. Pass `--app-path ` or set CODEX_AUTH_APP_PATH.\n"); + return error.AppPathRequired; +} + +fn appendConfiguredCliPathIssue( + allocator: std.mem.Allocator, + issues: *std.ArrayList(AppPathValidationIssue), + path: []const u8, +) !void { + const kind = pathKind(path) catch |err| switch (err) { + error.AccessDenied, error.PermissionDenied => { + try issues.append(allocator, .{ .option = "--codex-cli-path", .message = "Path is not accessible", .path = path }); + return; + }, + else => return err, + } orelse { + try issues.append(allocator, .{ .option = "--codex-cli-path", .message = "Path does not exist", .path = path }); + return; + }; + if (kind == .file or kind == .sym_link) return; + + try issues.append(allocator, .{ .option = "--codex-cli-path", .message = "Path is not a file", .path = path }); +} + +fn appPathOptionLabel(source: ValueSource) []const u8 { + return switch (source) { + .env => app_path_env, + else => "--app-path", + }; +} + +fn writeAppPathValidationIssues(issues: []const AppPathValidationIssue) !void { + var stderr: io_util.Stderr = undefined; + stderr.init(); + var writer = cli_style.StyledWriter.init(stderr.out(), stderr.color_enabled); + + for (issues) |issue| { + try writer.writeStyle(cli_style.role.error_text); + try writer.print("ERROR: {s}: {s}\n", .{ issue.option, issue.message }); + try writer.reset(); + try writer.writeStyle(cli_style.role.secondary); + try writer.print(" \"{s}\"\n", .{issue.path}); + try writer.reset(); + } + try writer.flush(); +} + +fn writeAppLaunchPlan( + allocator: std.mem.Allocator, + show_home: bool, + home: []const u8, + platform: ResolvedPlatform, + app_path: ResolvedValue, + cli_path: ResolvedValue, +) !void { + var stderr: io_util.Stderr = undefined; + stderr.init(); + var writer = cli_style.StyledWriter.init(stderr.out(), stderr.color_enabled); + const out = &writer; + const columns = terminalColumns(); + + try out.writeStyle(cli_style.role.secondary); + try out.writeAll("\n- Environment Configuration ------------------------------------------------\n"); + + if (platform.value) |value| { + try writePanelField(allocator, out, columns, "Platform:", platformLabel(value), platformSourceLabel(platform.source)); + } else { + try writePanelField(allocator, out, columns, "Platform:", "", null); + } + if (show_home) try writePanelField(allocator, out, columns, "Codex Home:", home, valueSourceLabel(.explicit)); + if (app_path.value) |value| { + try writePanelField(allocator, out, columns, "App Path:", value, valueSourceLabel(app_path.source)); + } else { + try writePanelField(allocator, out, columns, "App Path:", "", null); + } + if (cli_path.value) |value| { + try writePanelField(allocator, out, columns, "CLI Path:", value, valueSourceLabel(cli_path.source)); + } else { + try writePanelField(allocator, out, columns, "CLI Path:", "", null); + } + + try out.writeAll("----------------------------------------------------------------------------\n"); + try out.reset(); + try out.flush(); +} + +fn valueSourceLabel(source: ValueSource) []const u8 { + return switch (source) { + .explicit => "explicit", + .env => "environment", + .detected => "auto-detected", + .cached => "", + .downloaded => "downloaded", + .not_set => "not set", + }; +} + +fn platformSourceLabel(source: ValueSource) []const u8 { + return switch (source) { + .detected => "auto-detected", + else => valueSourceLabel(source), + }; +} + +fn writePanelField( + allocator: std.mem.Allocator, + out: *cli_style.StyledWriter, + columns: usize, + label: []const u8, + value: []const u8, + source: ?[]const u8, +) !void { + try writePanelFieldStyled(allocator, out, columns, label, value, source, ""); +} + +fn writePanelFieldStyled( + allocator: std.mem.Allocator, + out: *cli_style.StyledWriter, + columns: usize, + label: []const u8, + value: []const u8, + source: ?[]const u8, + value_style: []const u8, +) !void { + const display_value = try panelDisplayValue(allocator, value, source); + defer allocator.free(display_value); + + try out.writeAll(" "); + try out.print("{s}", .{label}); + try out.writeAll(" "); + try out.writeStyle(value_style); + try writeWrappedPanelValue(out, columns, 2 + label.len + 1, display_value); + try out.writeAll("\n"); +} + +fn panelDisplayValue(allocator: std.mem.Allocator, value: []const u8, source: ?[]const u8) ![]u8 { + if (source) |source_label| { + if (source_label.len != 0) return try std.fmt.allocPrint(allocator, "{s} ({s})", .{ value, source_label }); + } + return try allocator.dupe(u8, value); +} + +fn writeWrappedPanelValue(out: *cli_style.StyledWriter, columns: usize, first_line_prefix: usize, value: []const u8) !void { + var remaining = value; + var used = first_line_prefix; + while (remaining.len > 0) { + const available = if (columns > used) columns - used else 1; + if (remaining.len <= available) { + try out.writeAll(remaining); + return; + } + try out.writeAll(remaining[0..available]); + remaining = remaining[available..]; + try out.writeAll("\n "); + used = 2; + } +} + +fn terminalColumns() usize { + const file = std.Io.File.stderr(); + if (!(file.isTty(app_runtime.io()) catch false)) return 80; + if (comptime builtin.os.tag == .windows) { + var get_console_info = std.os.windows.CONSOLE.USER_IO.GET_SCREEN_BUFFER_INFO; + switch (get_console_info.operate(app_runtime.io(), file) catch return 80) { + .SUCCESS => {}, + else => return 80, + } + const cols = @as(i32, get_console_info.Data.dwWindowSize.X); + if (cols <= 0) return 80; + return @intCast(cols); + } else { + var wsz: std.posix.winsize = .{ + .row = 0, + .col = 0, + .xpixel = 0, + .ypixel = 0, + }; + const rc = std.posix.system.ioctl(file.handle, std.posix.T.IOCGWINSZ, @intFromPtr(&wsz)); + if (std.posix.errno(rc) != .SUCCESS or wsz.col == 0) return 80; + return @intCast(wsz.col); + } +} + +fn platformLabel(platform: types.AppPlatform) []const u8 { + return switch (platform) { + .win => "Windows", + .wsl => "WSL", + .mac => "macOS", + }; +} + +fn isCodexAppRunning(allocator: std.mem.Allocator, platform: ?types.AppPlatform, app_path: ResolvedValue) !bool { + return switch (builtin.os.tag) { + .windows => try isCodexAppRunningOnWindows(allocator, app_path), + .macos => try isCodexAppRunningOnMac(allocator, app_path), + else => switch (platform orelse return false) { + .win, .wsl, .mac => false, + }, + }; +} + +fn isCodexAppRunningOnWindows(allocator: std.mem.Allocator, app_path: ResolvedValue) !bool { + const fallback_script = "$ErrorActionPreference='SilentlyContinue'; " ++ + "$p=Get-Process -Name 'Codex', 'codex', 'Codext', 'codext' | Select-Object -First 1; " ++ + "if ($p) { [Console]::Out.Write('running') }"; + const script = if (app_path.value) |path| switch (app_path.source) { + .explicit, .env => try windowsAppPathRunningScriptAlloc(allocator, path), + else => try allocator.dupe(u8, fallback_script), + } else try allocator.dupe(u8, fallback_script); + defer allocator.free(script); + + var result = http_child.runChildCapture( + allocator, + &[_][]const u8{ "pwsh.exe", "-NoProfile", "-Command", script }, + 3000, + null, + ) catch return false; + defer result.deinit(allocator); + if (result.timed_out) return false; + return std.mem.trim(u8, result.stdout, " \t\r\n").len != 0; +} + +fn isCodexAppRunningOnMac(allocator: std.mem.Allocator, app_path: ResolvedValue) !bool { + if (app_path.value) |path| switch (app_path.source) { + .explicit, .env => { + const launch_path = resolveLaunchPath(allocator, path) catch try allocator.dupe(u8, path); + defer allocator.free(launch_path); + return try isExecutablePathRunningOnMac(allocator, launch_path); + }, + else => {}, + }; + + var result = http_child.runChildCapture( + allocator, + &[_][]const u8{ "/usr/bin/pgrep", "-x", "Codex|Codext" }, + 3000, + null, + ) catch return false; + defer result.deinit(allocator); + if (result.timed_out) return false; + return std.mem.trim(u8, result.stdout, " \t\r\n").len != 0; +} + +fn windowsAppPathRunningScriptAlloc(allocator: std.mem.Allocator, app_path: []const u8) ![]u8 { + const app_quoted = try psSingleQuoteAlloc(allocator, app_path); + defer allocator.free(app_quoted); + return try std.fmt.allocPrint( + allocator, + "$ErrorActionPreference='SilentlyContinue'; " ++ + "$target={s}; " ++ + "if (Test-Path -LiteralPath $target -PathType Container) {{ " ++ + "$c=@('Codex.exe', 'codex.exe', 'app\\Codex.exe', 'app\\codex.exe'); " ++ + "foreach ($n in $c) {{ $x=Join-Path $target $n; if (Test-Path -LiteralPath $x -PathType Leaf) {{ $target=$x; break }} }} " ++ + "}}; " ++ + "try {{ $target=[System.IO.Path]::GetFullPath($target) }} catch {{ }}; " ++ + "$p=Get-CimInstance Win32_Process | Where-Object {{ $_.ExecutablePath -and ([System.IO.Path]::GetFullPath($_.ExecutablePath) -ieq $target) }} | Select-Object -First 1; " ++ + "if ($p) {{ [Console]::Out.Write('running') }}", + .{app_quoted}, + ); +} + +fn isExecutablePathRunningOnMac(allocator: std.mem.Allocator, executable_path: []const u8) !bool { + var result = http_child.runChildCapture( + allocator, + &[_][]const u8{ "/bin/ps", "-axo", "comm=" }, + 3000, + null, + ) catch return false; + defer result.deinit(allocator); + if (result.timed_out) return false; + + var lines = std.mem.splitScalar(u8, result.stdout, '\n'); + while (lines.next()) |line| { + const trimmed = std.mem.trim(u8, line, " \t\r\n"); + if (std.mem.eql(u8, trimmed, executable_path)) return true; + } + return false; +} + fn nativeDefaultPlatform() types.AppPlatform { return switch (builtin.os.tag) { .windows => .win, @@ -139,10 +490,12 @@ fn launchApp( try applyAppPlatform(allocator, home, platform.value); if (inherit_stdio) { + try writeAppLaunching(); return launchExecutableWithStdio(allocator, target, cli_path.value, home); } if (builtin.os.tag == .windows) { + try writeAppLaunching(); return launchWindowsViaPowerShell(allocator, target, app_path.source, cli_path.value, home); } if (looksLikeWindowsPath(target) or looksLikeWslWindowsMountPath(target)) { @@ -297,6 +650,7 @@ fn launchMac( try writeAppError("macOS app launch requires an app bundle path such as `/Applications/Codex.app`.\n"); return error.AppPathRequired; } + try writeAppLaunching(); const home_env = try std.fmt.allocPrint(allocator, "{s}={s}", .{ codex_home_env, home }); defer allocator.free(home_env); @@ -325,7 +679,12 @@ fn launchMac( .stdout = .ignore, .stderr = .ignore, }); - _ = try child.wait(app_runtime.io()); + switch (try child.wait(app_runtime.io())) { + .exited => |code| if (code == 0) return, + else => {}, + } + try writeAppError("app launcher failed.\n"); + return error.AppLaunchFailed; } fn resolveLaunchPath(allocator: std.mem.Allocator, app_path: []const u8) ![]u8 { @@ -354,6 +713,14 @@ fn isDirectory(path: []const u8) bool { return stat.kind == .directory; } +fn pathKind(path: []const u8) !?std.Io.File.Kind { + const stat = std.Io.Dir.cwd().statFile(app_runtime.io(), path, .{}) catch |err| switch (err) { + error.FileNotFound, error.NotDir => return null, + else => return err, + }; + return stat.kind; +} + fn fileExists(path: []const u8) bool { std.Io.Dir.cwd().access(app_runtime.io(), path, .{}) catch return false; return true; @@ -425,7 +792,8 @@ fn cachedCodextCliPath(allocator: std.mem.Allocator, home: []const u8, platform: return null; } -fn downloadDefaultCodextCli(allocator: std.mem.Allocator, home: []const u8, platform: types.AppPlatform, quiet: bool) ![]u8 { +fn downloadDefaultCodextCli(allocator: std.mem.Allocator, home: []const u8, platform: types.AppPlatform, quiet: bool) !CodextInstallResult { + if (!quiet) try writeAppStep("Checking latest " ++ codext_repo_url ++ " release..."); const release = try fetchLatestCodextRelease(allocator); defer release.deinit(allocator); @@ -433,22 +801,18 @@ fn downloadDefaultCodextCli(allocator: std.mem.Allocator, home: []const u8, plat defer allocator.free(cache_root); try std.Io.Dir.cwd().createDirPath(app_runtime.io(), cache_root); - if (builtin.os.tag == .windows) { - const win_asset = release.assetFor(.win) orelse return error.CodextReleaseAssetNotFound; - const wsl_asset = release.assetFor(.wsl) orelse return error.CodextReleaseAssetNotFound; - try ensureCodextAssetInstalled(allocator, cache_root, release.tag, .win, win_asset, quiet); - try ensureCodextAssetInstalled(allocator, cache_root, release.tag, .wsl, wsl_asset, quiet); - } else { - const asset = release.assetFor(platform) orelse return error.CodextReleaseAssetNotFound; - try ensureCodextAssetInstalled(allocator, cache_root, release.tag, platform, asset, quiet); - } + const asset = release.assetFor(platform) orelse return error.CodextReleaseAssetNotFound; + const target_downloaded = try ensureCodextAssetInstalled(allocator, cache_root, release.tag, platform, asset, quiet); const installed = try managedCodextExecutablePath(allocator, home, platform); if (!fileExists(installed)) { allocator.free(installed); return error.CodextReleaseInstallFailed; } - return installed; + return .{ + .path = installed, + .source = if (target_downloaded) .downloaded else .cached, + }; } fn managedCodextExecutablePath(allocator: std.mem.Allocator, home: []const u8, platform: types.AppPlatform) ![]u8 { @@ -464,10 +828,15 @@ fn ensureCodextAssetInstalled( platform: types.AppPlatform, asset: CodextAsset, quiet: bool, -) !void { - if (try managedCodextAssetIsCurrent(allocator, cache_root, tag, platform, asset)) return; - if (!quiet) try writeAppInfo("downloading from {s}\n", .{asset.url}); +) !bool { + if (try managedCodextAssetIsCurrent(allocator, cache_root, tag, platform, asset)) { + if (!quiet) try writeAppUpToDate(platform, tag); + return false; + } + if (!quiet) try writeAppDownload(platform, tag, asset.url); try downloadAndInstallCodextAsset(allocator, cache_root, tag, platform, asset); + if (!quiet) try writeAppInstalled(platform, tag); + return true; } fn managedCodextAssetIsCurrent( @@ -729,6 +1098,88 @@ fn writeAppError(message: []const u8) !void { try out.flush(); } +fn writeAppAlreadyRunning() !void { + var stderr: io_util.Stderr = undefined; + stderr.init(); + var writer = cli_style.StyledWriter.init(stderr.out(), stderr.color_enabled); + try writer.writeAll("Codex App is already running, launch skipped.\n"); + try writer.flush(); +} + +fn writeAppStep(message: []const u8) !void { + var stderr: io_util.Stderr = undefined; + stderr.init(); + var writer = cli_style.StyledWriter.init(stderr.out(), stderr.color_enabled); + try writer.writeStyle(cli_style.role.status); + try writer.writeAll(stepMarker()); + try writer.writeAll(message); + try writer.reset(); + try writer.writeAll("\n"); + try writer.flush(); +} + +fn stepMarker() []const u8 { + return "- "; +} + +fn writeAppDownload(platform: types.AppPlatform, tag: []const u8, url: []const u8) !void { + var stderr: io_util.Stderr = undefined; + stderr.init(); + var writer = cli_style.StyledWriter.init(stderr.out(), stderr.color_enabled); + try writer.writeStyle(cli_style.role.secondary); + try writer.print(" {s}Downloading Codext CLI for {s} ({s})\n", .{ downloadMarker(), platformLabel(platform), tag }); + try writer.print(" {s}\n", .{url}); + try writer.reset(); + try writer.flush(); +} + +fn downloadMarker() []const u8 { + return ""; +} + +fn writeAppInstalled(platform: types.AppPlatform, tag: []const u8) !void { + var stderr: io_util.Stderr = undefined; + stderr.init(); + var writer = cli_style.StyledWriter.init(stderr.out(), stderr.color_enabled); + try writer.writeStyle(cli_style.role.success); + try writer.writeAll(successMarker()); + try writer.reset(); + try writer.print(" Downloaded Codext CLI for {s} ({s})\n", .{ platformLabel(platform), tag }); + try writer.flush(); +} + +fn writeAppUpToDate(platform: types.AppPlatform, tag: []const u8) !void { + var stderr: io_util.Stderr = undefined; + stderr.init(); + var writer = cli_style.StyledWriter.init(stderr.out(), stderr.color_enabled); + try writer.writeStyle(cli_style.role.success); + try writer.writeAll(successMarker()); + try writer.reset(); + _ = platform; + try writer.print(" Codext CLI is up-to-date ({s})\n", .{tag}); + try writer.flush(); +} + +fn successMarker() []const u8 { + return "OK"; +} + +fn writeAppLaunching() !void { + var stderr: io_util.Stderr = undefined; + stderr.init(); + var writer = cli_style.StyledWriter.init(stderr.out(), stderr.color_enabled); + try writer.writeStyle(cli_style.role.status); + try writer.writeAll(launchMarker()); + try writer.writeAll("Launching"); + try writer.reset(); + try writer.writeAll(" Codex App...\n"); + try writer.flush(); +} + +fn launchMarker() []const u8 { + return ""; +} + fn writeAppInfo(comptime format: []const u8, args: anytype) !void { var buffer: [1024]u8 = undefined; var writer = std.Io.File.stderr().writer(app_runtime.io(), &buffer); @@ -783,7 +1234,12 @@ fn launchWindowsViaPowerShell( .stderr = .ignore, .create_no_window = true, }); - _ = try child.wait(app_runtime.io()); + switch (try child.wait(app_runtime.io())) { + .exited => |code| if (code == 0) return, + else => {}, + } + try writeAppError("app launcher failed.\n"); + return error.AppLaunchFailed; } fn launchWindowsDetectedPackageViaPowerShell( @@ -818,7 +1274,12 @@ fn launchWindowsDetectedPackageViaPowerShell( .stderr = .ignore, .create_no_window = true, }); - _ = try child.wait(app_runtime.io()); + switch (try child.wait(app_runtime.io())) { + .exited => |code| if (code == 0) return, + else => {}, + } + try writeAppError("app launcher failed.\n"); + return error.AppLaunchFailed; } fn psSingleQuoteAlloc(allocator: std.mem.Allocator, value: []const u8) ![]u8 { diff --git a/src/workflows/preflight.zig b/src/workflows/preflight.zig index 8a1cbe6d..f32f1c66 100644 --- a/src/workflows/preflight.zig +++ b/src/workflows/preflight.zig @@ -26,7 +26,15 @@ pub fn isHandledCliError(err: anyerror) bool { err == error.RemoveConfirmationUnavailable or err == error.RemoveSelectionRequiresTty or err == error.InvalidRemoveSelectionInput or + err == error.AppPathValidationFailed or err == error.AppPathRequired or + err == error.AppPathNotFound or + err == error.AppPathNotAccessible or + err == error.AppExecutableNotFound or + err == error.CodexCliPathNotFound or + err == error.CodexCliPathNotAccessible or + err == error.CodexCliPathNotFile or + err == error.AppLaunchFailed or err == error.WindowsAppLaunchRequiresWindows or err == error.WindowsAppPlatformRequiresWindows or err == error.MacAppPlatformRequiresMacOS or diff --git a/style.md b/style.md index 960f0471..771d6cc9 100644 --- a/style.md +++ b/style.md @@ -4,11 +4,19 @@ This guide covers user-facing terminal output, including shared output and comma # Text Roles +User-facing output code should use the semantic roles in `src/cli/style.zig` +instead of referencing raw ANSI colors directly. Keep raw ANSI constants as the +low-level palette only; changing a role mapping should update all matching +output without searching business workflows for individual color names. + - **Header / table header:** Use ANSI `cyan`. - **Primary text:** Use the terminal's default foreground color. - **Secondary text:** Use ANSI `dim`. - **Footer / key hints:** Use ANSI `cyan`. - **Live refresh status line:** Use ANSI `cyan`. +- **Status / in-progress action:** Use ANSI `cyan`. +- **Configuration key:** Use ANSI `bold`. +- **Warning / non-fatal status:** Use ANSI `cyan`. # Foreground Colors diff --git a/tests/cli_integration_test.zig b/tests/cli_integration_test.zig index 54ed25f9..8e2ea783 100644 --- a/tests/cli_integration_test.zig +++ b/tests/cli_integration_test.zig @@ -3473,3 +3473,180 @@ test "Scenario: Given default api usage when listing accounts then no warning is try std.testing.expect(std.mem.indexOf(u8, result.stdout, "ACCOUNT") != null); try std.testing.expectEqualStrings("", result.stderr); } + +test "Scenario: Given explicit codex home when planning app launch then codex home source is explicit" { + if (builtin.os.tag == .windows or builtin.os.tag == .macos) 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"); + const codex_home = try codexHomeAlloc(gpa, home_root); + defer gpa.free(codex_home); + + const result = try runCliWithIsolatedHome(gpa, project_root, home_root, &[_][]const u8{ + "app", + "--app-path", + "/bin/true", + "--codex-cli-path", + "/bin/true", + "--codex-home", + codex_home, + }); + defer gpa.free(result.stdout); + defer gpa.free(result.stderr); + + try expectFailure(result); + try std.testing.expectEqualStrings("", result.stdout); + + const home_label_index = std.mem.indexOf(u8, result.stderr, " Codex Home:") orelse return error.TestUnexpectedResult; + const home_tail = result.stderr[home_label_index..]; + try std.testing.expect(std.mem.indexOf(u8, home_tail, "(explicit)") != null); + try std.testing.expect(std.mem.indexOf(u8, result.stderr, "via --codex-home") == null); +} + +test "Scenario: Given inherited codex home when planning app launch then codex home is omitted" { + if (builtin.os.tag == .windows or builtin.os.tag == .macos) 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"); + const codex_home = try codexHomeAlloc(gpa, home_root); + defer gpa.free(codex_home); + + const result = try runCliWithIsolatedHomeAndCodexHome(gpa, project_root, home_root, codex_home, &[_][]const u8{ + "app", + "--app-path", + "/bin/true", + "--codex-cli-path", + "/bin/true", + }); + defer gpa.free(result.stdout); + defer gpa.free(result.stderr); + + try expectFailure(result); + try std.testing.expectEqualStrings("", result.stdout); + try std.testing.expect(std.mem.indexOf(u8, result.stderr, "Environment Configuration") != null); + try std.testing.expect(std.mem.indexOf(u8, result.stderr, " Codex Home:") == null); +} + +test "Scenario: Given missing explicit app path when launching app then command fails before launch plan" { + if (builtin.os.tag == .windows or builtin.os.tag == .macos) 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); + const missing_app_path = try fs.path.join(gpa, &[_][]const u8{ home_root, "missing-app" }); + defer gpa.free(missing_app_path); + + const result = try runCliWithIsolatedHome(gpa, project_root, home_root, &[_][]const u8{ + "app", + "--app-path", + missing_app_path, + }); + defer gpa.free(result.stdout); + defer gpa.free(result.stderr); + + try expectFailure(result); + try std.testing.expectEqualStrings("", result.stdout); + try std.testing.expect(std.mem.indexOf(u8, result.stderr, "ERROR: --app-path: Path does not exist\n") != null); + try std.testing.expect(std.mem.indexOf(u8, result.stderr, " \"") != null); + try std.testing.expect(std.mem.indexOf(u8, result.stderr, missing_app_path) != null); + try std.testing.expect(std.mem.indexOf(u8, result.stderr, "Environment Configuration") == null); +} + +test "Scenario: Given missing explicit codex CLI path when launching app then command fails before launch plan" { + if (builtin.os.tag == .windows or builtin.os.tag == .macos) 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.writeFile(.{ .sub_path = "fake-app", .data = "" }); + const app_path = try fs.path.join(gpa, &[_][]const u8{ home_root, "fake-app" }); + defer gpa.free(app_path); + const missing_cli_path = try fs.path.join(gpa, &[_][]const u8{ home_root, "missing-codex" }); + defer gpa.free(missing_cli_path); + + const result = try runCliWithIsolatedHome(gpa, project_root, home_root, &[_][]const u8{ + "app", + "--app-path", + app_path, + "--codex-cli-path", + missing_cli_path, + }); + defer gpa.free(result.stdout); + defer gpa.free(result.stderr); + + try expectFailure(result); + try std.testing.expectEqualStrings("", result.stdout); + try std.testing.expect(std.mem.indexOf(u8, result.stderr, "ERROR: --codex-cli-path: Path does not exist\n") != null); + try std.testing.expect(std.mem.indexOf(u8, result.stderr, " \"") != null); + try std.testing.expect(std.mem.indexOf(u8, result.stderr, missing_cli_path) != null); + try std.testing.expect(std.mem.indexOf(u8, result.stderr, "Environment Configuration") == null); +} + +test "Scenario: Given multiple missing explicit app paths when launching app then every path error is printed before launch plan" { + if (builtin.os.tag == .windows or builtin.os.tag == .macos) 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); + const missing_app_path = try fs.path.join(gpa, &[_][]const u8{ home_root, "missing-app" }); + defer gpa.free(missing_app_path); + const missing_cli_path = try fs.path.join(gpa, &[_][]const u8{ home_root, "missing-codex" }); + defer gpa.free(missing_cli_path); + + const result = try runCliWithIsolatedHome(gpa, project_root, home_root, &[_][]const u8{ + "app", + "--app-path", + missing_app_path, + "--codex-cli-path", + missing_cli_path, + }); + defer gpa.free(result.stdout); + defer gpa.free(result.stderr); + + try expectFailure(result); + try std.testing.expectEqualStrings("", result.stdout); + try std.testing.expect(std.mem.indexOf(u8, result.stderr, "ERROR: --app-path: Path does not exist\n") != null); + try std.testing.expect(std.mem.indexOf(u8, result.stderr, missing_app_path) != null); + try std.testing.expect(std.mem.indexOf(u8, result.stderr, "ERROR: --codex-cli-path: Path does not exist\n") != null); + try std.testing.expect(std.mem.indexOf(u8, result.stderr, missing_cli_path) != null); + try std.testing.expect(std.mem.indexOf(u8, result.stderr, "Environment Configuration") == null); +} From 0dbc4737902c2a83c68256047819da79fcb7174c Mon Sep 17 00:00:00 2001 From: Loongphy Date: Thu, 21 May 2026 12:05:05 +0800 Subject: [PATCH 16/16] feat: launch app by app id --- README.md | 2 +- docs/commands/app.md | 37 ++- docs/windows.md | 2 +- src/cli/commands/app.zig | 8 +- src/cli/help.zig | 6 +- src/cli/types.zig | 2 +- src/workflows/app.zig | 463 +++++++++++++-------------------- src/workflows/preflight.zig | 7 +- tests/cli_behavior_test.zig | 8 +- tests/cli_integration_test.zig | 84 +----- 10 files changed, 222 insertions(+), 397 deletions(-) diff --git a/README.md b/README.md index 819ad48e..f7d43e74 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ Detailed command documentation lives in [docs/commands/README.md](./docs/command | Command | Description | |---------|-------------| -| [`codex-auth app [--app-path ] [--codex-cli-path ]`](./docs/commands/app.md) | Launch Codex App with detected defaults, CODEX_HOME, CODEX_CLI_PATH, and platform overrides | +| [`codex-auth app [--app-id ] [--codex-cli-path ]`](./docs/commands/app.md) | Launch Codex App with detected defaults, CODEX_HOME, CODEX_CLI_PATH, and platform overrides | ### Configuration diff --git a/docs/commands/app.md b/docs/commands/app.md index 22d25541..a3a938f0 100644 --- a/docs/commands/app.md +++ b/docs/commands/app.md @@ -3,7 +3,7 @@ ## Usage ```shell -codex-auth app [--app-path ] [--codex-cli-path ] [--codex-home ] [--platform win|wsl|mac] +codex-auth app [--app-id ] [--codex-cli-path ] [--codex-home ] [--platform win|wsl|mac] ``` ## Behavior @@ -13,14 +13,18 @@ Launches the official Codex App with per-process environment overrides. - `codex-auth app` launches the app. There is no `launch` subcommand. - If the Codex App is already running, `app` prints that status and exits before resolving or downloading the managed CLI. -- `--app-path ` points to the App executable or an installed package/app directory. Explicit and environment-provided app paths must exist before launch planning starts. +- `--app-id ` selects the packaged app to launch. On Windows it accepts an + AppX/MSIX package name such as `OpenAI.Codex` or `Loongphy.Codext`, or a full + AUMID. On macOS it accepts a bundle identifier such as `com.openai.codex`. +- If `--app-id` is omitted, `CODEX_AUTH_APP_ID` is used when set; otherwise the + default is `OpenAI.Codex` on Windows and `com.openai.codex` on macOS. - `--codex-cli-path ` is injected as `CODEX_CLI_PATH` for this launch. Explicit CLI paths must exist. If it is omitted, `app` fetches the latest [`Loongphy/codext`](https://github.com/Loongphy/codext) release metadata, compares it with the managed cached CLI version for the selected platform, downloads only when the cached version differs or is missing, and uses that file; it does not reuse an existing `CODEX_CLI_PATH` from the current shell. - `--codex-home ` is injected as `CODEX_HOME` for `app` launches and selects the accounts cache used for managed CLI resolution. - `--platform win|wsl|mac` selects the app runtime platform: - `win` writes the Windows global setting so the app runs the agent natively. - `wsl` writes the Windows global setting so the app runs the agent inside WSL. - `mac` launches the macOS app directly and does not use the Windows WSL setting. -- `--std` starts the app executable directly with stdout/stderr attached to the current terminal. Use it for debugging app logs; normal launches stay quiet and use the platform GUI launcher. +- `--std` resolves the packaged app executable, then starts it with stdout/stderr attached to the current terminal. Use it for debugging app logs; normal launches stay quiet and use the platform GUI launcher. `app` prints its launch plan and managed CLI resolution to stderr before starting the GUI launcher. Example output: @@ -40,7 +44,7 @@ OK Downloaded Codext CLI for WSL (v0.3.0) - Environment Configuration ------------------------------------------------ Platform: WSL (auto-detected) Codex Home: C:\Users\Alice\.codext (explicit) - App Path: C:\Program Files\WindowsApps\OpenAI.Codext_...\app (explicit) + App ID: Loongphy.Codext (explicit) CLI Path: C:\Users\Alice\.codext\accounts\codext-cli\codex-linux-x64 (downloaded) ---------------------------------------------------------------------------- Launching Codex App... @@ -48,12 +52,6 @@ Launching Codex App... See [Windows](../windows.md) for Windows console color and character rules. -If `--app-path` is omitted, `CODEX_AUTH_APP_PATH` is used when set; otherwise -the official installed app is auto-detected. On Windows this uses AppX package -lookup for `OpenAI.Codex`. On macOS it checks `/Applications/Codex.app` and -`~/Applications/Codex.app`; the latter is the standard per-user Applications -folder. - If `--platform` is omitted, Windows reads `$CODEX_HOME/.codex-global-state.json` and uses `wsl` when `runCodexInWindowsSubsystemForLinux` is `true`; otherwise it uses `win`. macOS defaults to `mac`. @@ -70,20 +68,15 @@ The default download prepares only the selected platform's CPU architecture, such as `win32-x64`, `linux-x64`, `darwin-x64`, or `darwin-arm64`. -Windows App launching is handled by the Windows `codex-auth.exe` build. For the -auto-detected app, launch resolves the package AUMID and opens -`shell:AppsFolder\`. Use a Windows app path such as -`C:\Program Files\WindowsApps\...\app\Codex.exe` for `--app-path` only when an -explicit override is needed. The WSL build does not launch Windows App packages. +Windows App launching is handled by the Windows `codex-auth.exe` build. Normal +launch resolves the package name or AUMID and opens `shell:AppsFolder\`. +The WSL build does not launch Windows App packages. For Windows-native App launches, `--codex-cli-path` must point to something the Windows App process can spawn. A WSL command name such as `codex-custom` is not a Windows executable path. -For macOS App launches, the auto-detected app is opened with bundle identifier -`com.openai.codex`. `--app-path` may point to `/Applications/Codex.app` or the -app bundle path. Bundle paths are opened with `open`; direct executable paths -are not supported for app launch. The packaged macOS app normally uses -`Contents/Resources/codex` directly as its bundled CLI; setting -`--codex-cli-path` injects `CODEX_CLI_PATH` and takes precedence over that -bundled resource. +For macOS App launches, the app is opened with its bundle identifier. The +packaged macOS app normally uses `Contents/Resources/codex` directly as its +bundled CLI; setting `--codex-cli-path` injects `CODEX_CLI_PATH` and takes +precedence over that bundled resource. diff --git a/docs/windows.md b/docs/windows.md index d1229945..a7ab100b 100644 --- a/docs/windows.md +++ b/docs/windows.md @@ -52,7 +52,7 @@ OK Downloaded Codext CLI for WSL (v0.3.0) - Environment Configuration ------------------------------------------------ Platform: WSL (auto-detected) Codex Home: C:\Users\Loong\.codext (explicit) - App Path: C:\Program Files\WindowsApps\OpenAI.Codext_...\app (explicit) + App ID: Loongphy.Codext (explicit) CLI Path: C:\Users\Loong\.codext\accounts\codext-cli\codex-linux-x64 (downloaded) ---------------------------------------------------------------------------- Launching Codex App... diff --git a/src/cli/commands/app.zig b/src/cli/commands/app.zig index 98ed1cb5..4b772609 100644 --- a/src/cli/commands/app.zig +++ b/src/cli/commands/app.zig @@ -21,11 +21,11 @@ fn parseOptions( const arg = std.mem.sliceTo(args[i], 0); if (std.mem.eql(u8, arg, "--")) return common.usageErrorResult(allocator, .app, "`app` does not accept passthrough arguments.", .{}); if (common.isHelpFlag(arg)) return .{ .command = .{ .help = .app } }; - if (std.mem.eql(u8, arg, "--app-path")) { - if (i + 1 >= args.len) return common.usageErrorResult(allocator, .app, "missing value for `--app-path`.", .{}); - if (opts.app_path != null) return common.usageErrorResult(allocator, .app, "duplicate `--app-path` for `app`.", .{}); + if (std.mem.eql(u8, arg, "--app-id")) { + if (i + 1 >= args.len) return common.usageErrorResult(allocator, .app, "missing value for `--app-id`.", .{}); + if (opts.app_id != null) return common.usageErrorResult(allocator, .app, "duplicate `--app-id` for `app`.", .{}); i += 1; - opts.app_path = std.mem.sliceTo(args[i], 0); + opts.app_id = std.mem.sliceTo(args[i], 0); continue; } if (std.mem.eql(u8, arg, "--codex-cli-path")) { diff --git a/src/cli/help.zig b/src/cli/help.zig index 8eefe508..459b03c8 100644 --- a/src/cli/help.zig +++ b/src/cli/help.zig @@ -225,7 +225,7 @@ fn writeUsageLines(out: *std.Io.Writer, topic: HelpTopic) !void { try out.writeAll(" codex-auth config live --interval \n"); }, .app => { - try out.writeAll(" codex-auth app [--app-path ] [--codex-cli-path ] [--codex-home ] [--platform win|wsl|mac]\n"); + try out.writeAll(" codex-auth app [--app-id ] [--codex-cli-path ] [--codex-home ] [--platform win|wsl|mac]\n"); }, } } @@ -299,14 +299,14 @@ fn writeOptionLines(out: *std.Io.Writer, topic: HelpTopic) !void { try out.writeAll(" Set the live TUI refresh interval from 5 to 3600 seconds.\n"); }, .app => { - try out.writeAll(" --app-path Official Codex App executable or install directory.\n"); + try out.writeAll(" --app-id Windows package/AUMID or macOS bundle identifier.\n"); try out.writeAll(" --codex-cli-path \n"); try out.writeAll(" Value injected or persisted as CODEX_CLI_PATH. Defaults to latest managed Loongphy/codext.\n"); try out.writeAll(" --codex-home \n"); try out.writeAll(" Value injected as CODEX_HOME for this launch.\n"); try out.writeAll(" --platform win|wsl|mac\n"); try out.writeAll(" Preselect the app platform. Defaults to the current app setting on Windows and mac on macOS.\n"); - try out.writeAll(" --std Run the app executable with stdout/stderr attached to this terminal.\n"); + try out.writeAll(" --std Resolve the app package executable, then attach stdout/stderr to this terminal.\n"); }, else => {}, } diff --git a/src/cli/types.zig b/src/cli/types.zig index 33fd2f4d..24dc21c6 100644 --- a/src/cli/types.zig +++ b/src/cli/types.zig @@ -58,7 +58,7 @@ pub const AppAction = enum { launch }; pub const AppPlatform = enum { win, wsl, mac }; pub const AppOptions = struct { action: AppAction, - app_path: ?[]const u8 = null, + app_id: ?[]const u8 = null, codex_cli_path: ?[]const u8 = null, codex_home: ?[]const u8 = null, platform: ?AppPlatform = null, diff --git a/src/workflows/app.zig b/src/workflows/app.zig index 9d8ba05b..ac405075 100644 --- a/src/workflows/app.zig +++ b/src/workflows/app.zig @@ -9,15 +9,40 @@ const cli_style = @import("../cli/style.zig"); const codex_cli_path_env = "CODEX_CLI_PATH"; const codex_home_env = "CODEX_HOME"; -const app_path_env = "CODEX_AUTH_APP_PATH"; +const app_id_env = "CODEX_AUTH_APP_ID"; const codex_app_package_name = "OpenAI.Codex"; const codex_app_bundle_id = "com.openai.codex"; const wsl_agent_mode_key = "runCodexInWindowsSubsystemForLinux"; const codext_repo_latest_url = "https://api.github.com/repos/Loongphy/codext/releases/latest"; const codext_repo_url = "https://github.com/Loongphy/codext"; const codext_cache_dir_name = "codext-cli"; - -const ValueSource = enum { explicit, env, detected, cached, downloaded, not_set }; +const windows_app_id_resolver_script = + "function Resolve-CodexAppPackage { param([string]$AppId) " ++ + "if ([string]::IsNullOrWhiteSpace($AppId)) { throw 'Codex App ID is empty' }; " ++ + "$id=$AppId; " ++ + "if ($id.Contains('!')) { " ++ + "$family=$id.Split('!')[0]; " ++ + "$pkg=Get-AppxPackage | Where-Object { $_.PackageFamilyName -ieq $family -or $_.PackageFullName -ieq $family -or $_.Name -ieq $family } | Sort-Object Version -Descending | Select-Object -First 1; " ++ + "if (-not $pkg) { throw \"App package not found: $id\" }; return $pkg } " ++ + "$pkg=Get-AppxPackage -Name $id | Sort-Object Version -Descending | Select-Object -First 1; " ++ + "if (-not $pkg) { $pkg=Get-AppxPackage | Where-Object { $_.PackageFamilyName -ieq $id -or $_.PackageFullName -ieq $id } | Sort-Object Version -Descending | Select-Object -First 1 }; " ++ + "if (-not $pkg) { throw \"App package not found: $id\" }; return $pkg } " ++ + "function Resolve-CodexAppAumid { param([string]$AppId) " ++ + "if ($AppId.Contains('!')) { return $AppId }; " ++ + "$pkg=Resolve-CodexAppPackage $AppId; " ++ + "$appId=(Get-AppxPackageManifest $pkg).Package.Applications.Application | Select-Object -First 1 -ExpandProperty Id; " ++ + "if (-not $appId) { throw \"App manifest has no application id: $AppId\" }; " ++ + "return \"$($pkg.PackageFamilyName)!$appId\" } " ++ + "function Resolve-CodexAppExecutable { param([string]$AppId) " ++ + "$pkg=Resolve-CodexAppPackage $AppId; " ++ + "$app=(Get-AppxPackageManifest $pkg).Package.Applications.Application | Select-Object -First 1; " ++ + "if (-not $app -or -not $app.Executable) { throw \"App executable not found: $AppId\" }; " ++ + "$exe=Join-Path $pkg.InstallLocation ([string]$app.Executable); " ++ + "if (-not (Test-Path -LiteralPath $exe -PathType Leaf)) { throw \"App executable not found: $exe\" }; " ++ + "return [System.IO.Path]::GetFullPath($exe) }"; + +const ValueSource = enum { explicit, env, detected, built_in, cached, downloaded, not_set }; +const WindowsLaunchMode = enum { gui, stdio }; const ResolvedValue = struct { value: ?[]const u8, @@ -39,7 +64,7 @@ const CodextInstallResult = struct { source: ValueSource, }; -const AppPathValidationIssue = struct { +const PathValidationIssue = struct { option: []const u8, message: []const u8, path: []const u8, @@ -49,20 +74,21 @@ pub fn handleApp(allocator: std.mem.Allocator, resolved_codex_home: []const u8, const effective_home = opts.codex_home orelse resolved_codex_home; const effective_platform = try resolvePlatform(allocator, effective_home, opts.platform); try validateAppPlatform(effective_platform.value); - const effective_app_path = try resolveAppPath(allocator, opts); - defer effective_app_path.deinit(allocator); - try validateConfiguredAppPaths(allocator, effective_app_path, opts); - if (try isCodexAppRunning(allocator, effective_platform.value, effective_app_path)) { + const effective_app_id = try resolveAppId(allocator, effective_platform.value, opts); + defer effective_app_id.deinit(allocator); + try requireAppId(effective_app_id); + try validateConfiguredPaths(allocator, opts); + if (try isCodexAppRunning(allocator, effective_platform.value, effective_app_id)) { try writeAppAlreadyRunning(); return; } - try requireAppPath(effective_app_path); + try requireResolvableAppId(allocator, effective_platform.value, effective_app_id); const effective_cli_path = try resolveCliPath(allocator, effective_home, effective_platform.value, opts, true, false); defer effective_cli_path.deinit(allocator); - try writeAppLaunchPlan(allocator, opts.codex_home != null, effective_home, effective_platform, effective_app_path, effective_cli_path); + try writeAppLaunchPlan(allocator, opts.codex_home != null, effective_home, effective_platform, effective_app_id, effective_cli_path); switch (opts.action) { - .launch => try launchApp(allocator, effective_app_path, effective_cli_path, effective_home, effective_platform, opts.inherit_stdio), + .launch => try launchApp(allocator, effective_app_id, effective_cli_path, effective_home, effective_platform, opts.inherit_stdio), } } @@ -75,13 +101,21 @@ fn getOptionalEnv(allocator: std.mem.Allocator, name: []const u8) ?[]const u8 { return value; } -fn resolveAppPath(allocator: std.mem.Allocator, opts: types.AppOptions) !ResolvedValue { - if (opts.app_path) |path| return .{ .value = path, .source = .explicit }; - if (getOptionalEnv(allocator, app_path_env)) |path| return .{ .value = path, .source = .env, .owned = true }; - if (try detectInstalledAppPath(allocator)) |path| return .{ .value = path, .source = .detected, .owned = true }; +fn resolveAppId(allocator: std.mem.Allocator, platform: ?types.AppPlatform, opts: types.AppOptions) !ResolvedValue { + if (opts.app_id) |app_id| return .{ .value = app_id, .source = .explicit }; + if (getOptionalEnv(allocator, app_id_env)) |app_id| return .{ .value = app_id, .source = .env, .owned = true }; + if (defaultAppId(platform)) |app_id| return .{ .value = app_id, .source = .built_in }; return .{ .value = null, .source = .not_set }; } +fn defaultAppId(platform: ?types.AppPlatform) ?[]const u8 { + const value = platform orelse return null; + return switch (value) { + .win, .wsl => codex_app_package_name, + .mac => codex_app_bundle_id, + }; +} + fn resolveCliPath( allocator: std.mem.Allocator, home: []const u8, @@ -111,64 +145,41 @@ fn resolvePlatform(allocator: std.mem.Allocator, home: []const u8, explicit: ?ty return .{ .value = null, .source = .not_set }; } -fn validateConfiguredAppPaths(allocator: std.mem.Allocator, app_path: ResolvedValue, opts: types.AppOptions) !void { - var issues = std.ArrayList(AppPathValidationIssue).empty; +fn validateConfiguredPaths(allocator: std.mem.Allocator, opts: types.AppOptions) !void { + var issues = std.ArrayList(PathValidationIssue).empty; defer issues.deinit(allocator); - try appendConfiguredAppPathIssue(allocator, &issues, app_path); if (opts.codex_cli_path) |path| try appendConfiguredCliPathIssue(allocator, &issues, path); if (issues.items.len == 0) return; - try writeAppPathValidationIssues(issues.items); - return error.AppPathValidationFailed; + try writePathValidationIssues(issues.items); + return error.AppLaunchConfigValidationFailed; } -fn appendConfiguredAppPathIssue( - allocator: std.mem.Allocator, - issues: *std.ArrayList(AppPathValidationIssue), - app_path: ResolvedValue, -) !void { - const path = app_path.value orelse return; - switch (app_path.source) { - .explicit, .env => {}, - else => return, - } - - const kind = pathKind(path) catch |err| switch (err) { - error.AccessDenied, error.PermissionDenied => { - try issues.append(allocator, .{ .option = appPathOptionLabel(app_path.source), .message = "Path is not accessible", .path = path }); - return; - }, - else => return err, - } orelse { - try issues.append(allocator, .{ .option = appPathOptionLabel(app_path.source), .message = "Path does not exist", .path = path }); - return; - }; - if (kind == .directory) { - const launch_path = resolveLaunchPath(allocator, path) catch |err| switch (err) { - error.AppExecutableNotFound => { - try issues.append(allocator, .{ .option = appPathOptionLabel(app_path.source), .message = "Path does not contain a Codex executable", .path = path }); - return; - }, - else => return err, - }; - allocator.free(launch_path); - return; - } - if (kind == .file or kind == .sym_link) return; +fn requireAppId(app_id: ResolvedValue) !void { + if (app_id.value != null) return; + try writeAppError("app launch needs an app ID. Pass `--app-id ` or set CODEX_AUTH_APP_ID.\n"); + return error.AppIdRequired; +} - try issues.append(allocator, .{ .option = appPathOptionLabel(app_path.source), .message = "Path is not an executable file or app directory", .path = path }); +fn requireResolvableAppId(allocator: std.mem.Allocator, platform: ?types.AppPlatform, app_id: ResolvedValue) !void { + const value = app_id.value orelse return error.AppIdRequired; + if (try appIdCanResolve(allocator, platform, value)) return; + try writeAppError("app launch could not find the installed app for the configured app ID.\n"); + return error.AppIdNotFound; } -fn requireAppPath(app_path: ResolvedValue) !void { - if (app_path.value != null) return; - try writeAppError("app launch could not find the installed Codex app. Pass `--app-path ` or set CODEX_AUTH_APP_PATH.\n"); - return error.AppPathRequired; +fn appIdCanResolve(allocator: std.mem.Allocator, platform: ?types.AppPlatform, app_id: []const u8) !bool { + const value = platform orelse return true; + return switch (value) { + .win, .wsl => try windowsCanResolveAppId(allocator, app_id), + .mac => try macCanResolveAppId(allocator, app_id), + }; } fn appendConfiguredCliPathIssue( allocator: std.mem.Allocator, - issues: *std.ArrayList(AppPathValidationIssue), + issues: *std.ArrayList(PathValidationIssue), path: []const u8, ) !void { const kind = pathKind(path) catch |err| switch (err) { @@ -186,14 +197,7 @@ fn appendConfiguredCliPathIssue( try issues.append(allocator, .{ .option = "--codex-cli-path", .message = "Path is not a file", .path = path }); } -fn appPathOptionLabel(source: ValueSource) []const u8 { - return switch (source) { - .env => app_path_env, - else => "--app-path", - }; -} - -fn writeAppPathValidationIssues(issues: []const AppPathValidationIssue) !void { +fn writePathValidationIssues(issues: []const PathValidationIssue) !void { var stderr: io_util.Stderr = undefined; stderr.init(); var writer = cli_style.StyledWriter.init(stderr.out(), stderr.color_enabled); @@ -214,7 +218,7 @@ fn writeAppLaunchPlan( show_home: bool, home: []const u8, platform: ResolvedPlatform, - app_path: ResolvedValue, + app_id: ResolvedValue, cli_path: ResolvedValue, ) !void { var stderr: io_util.Stderr = undefined; @@ -232,10 +236,10 @@ fn writeAppLaunchPlan( try writePanelField(allocator, out, columns, "Platform:", "", null); } if (show_home) try writePanelField(allocator, out, columns, "Codex Home:", home, valueSourceLabel(.explicit)); - if (app_path.value) |value| { - try writePanelField(allocator, out, columns, "App Path:", value, valueSourceLabel(app_path.source)); + if (app_id.value) |value| { + try writePanelField(allocator, out, columns, "App ID:", value, valueSourceLabel(app_id.source)); } else { - try writePanelField(allocator, out, columns, "App Path:", "", null); + try writePanelField(allocator, out, columns, "App ID:", "", null); } if (cli_path.value) |value| { try writePanelField(allocator, out, columns, "CLI Path:", value, valueSourceLabel(cli_path.source)); @@ -253,6 +257,7 @@ fn valueSourceLabel(source: ValueSource) []const u8 { .explicit => "explicit", .env => "environment", .detected => "auto-detected", + .built_in => "default", .cached => "", .downloaded => "downloaded", .not_set => "not set", @@ -353,24 +358,19 @@ fn platformLabel(platform: types.AppPlatform) []const u8 { }; } -fn isCodexAppRunning(allocator: std.mem.Allocator, platform: ?types.AppPlatform, app_path: ResolvedValue) !bool { +fn isCodexAppRunning(allocator: std.mem.Allocator, platform: ?types.AppPlatform, app_id: ResolvedValue) !bool { + const value = app_id.value orelse return false; return switch (builtin.os.tag) { - .windows => try isCodexAppRunningOnWindows(allocator, app_path), - .macos => try isCodexAppRunningOnMac(allocator, app_path), + .windows => try isCodexAppRunningOnWindows(allocator, value), + .macos => try isCodexAppRunningOnMac(allocator, value), else => switch (platform orelse return false) { .win, .wsl, .mac => false, }, }; } -fn isCodexAppRunningOnWindows(allocator: std.mem.Allocator, app_path: ResolvedValue) !bool { - const fallback_script = "$ErrorActionPreference='SilentlyContinue'; " ++ - "$p=Get-Process -Name 'Codex', 'codex', 'Codext', 'codext' | Select-Object -First 1; " ++ - "if ($p) { [Console]::Out.Write('running') }"; - const script = if (app_path.value) |path| switch (app_path.source) { - .explicit, .env => try windowsAppPathRunningScriptAlloc(allocator, path), - else => try allocator.dupe(u8, fallback_script), - } else try allocator.dupe(u8, fallback_script); +fn isCodexAppRunningOnWindows(allocator: std.mem.Allocator, app_id: []const u8) !bool { + const script = try windowsAppIdRunningScriptAlloc(allocator, app_id); defer allocator.free(script); var result = http_child.runChildCapture( @@ -384,61 +384,45 @@ fn isCodexAppRunningOnWindows(allocator: std.mem.Allocator, app_path: ResolvedVa return std.mem.trim(u8, result.stdout, " \t\r\n").len != 0; } -fn isCodexAppRunningOnMac(allocator: std.mem.Allocator, app_path: ResolvedValue) !bool { - if (app_path.value) |path| switch (app_path.source) { - .explicit, .env => { - const launch_path = resolveLaunchPath(allocator, path) catch try allocator.dupe(u8, path); - defer allocator.free(launch_path); - return try isExecutablePathRunningOnMac(allocator, launch_path); - }, - else => {}, - }; - +fn isCodexAppRunningOnMac(allocator: std.mem.Allocator, app_id: []const u8) !bool { var result = http_child.runChildCapture( allocator, - &[_][]const u8{ "/usr/bin/pgrep", "-x", "Codex|Codext" }, + &[_][]const u8{ + "/usr/bin/osascript", + "-e", + "on run argv", + "-e", + "application id (item 1 of argv) is running", + "-e", + "end run", + app_id, + }, 3000, null, ) catch return false; defer result.deinit(allocator); - if (result.timed_out) return false; - return std.mem.trim(u8, result.stdout, " \t\r\n").len != 0; + if (result.timed_out or !childExitedSuccessfully(result.term)) return false; + return std.mem.eql(u8, std.mem.trim(u8, result.stdout, " \t\r\n"), "true"); } -fn windowsAppPathRunningScriptAlloc(allocator: std.mem.Allocator, app_path: []const u8) ![]u8 { - const app_quoted = try psSingleQuoteAlloc(allocator, app_path); +fn windowsAppIdRunningScriptAlloc(allocator: std.mem.Allocator, app_id: []const u8) ![]u8 { + const app_quoted = try psSingleQuoteAlloc(allocator, app_id); defer allocator.free(app_quoted); return try std.fmt.allocPrint( allocator, - "$ErrorActionPreference='SilentlyContinue'; " ++ - "$target={s}; " ++ - "if (Test-Path -LiteralPath $target -PathType Container) {{ " ++ - "$c=@('Codex.exe', 'codex.exe', 'app\\Codex.exe', 'app\\codex.exe'); " ++ - "foreach ($n in $c) {{ $x=Join-Path $target $n; if (Test-Path -LiteralPath $x -PathType Leaf) {{ $target=$x; break }} }} " ++ - "}}; " ++ - "try {{ $target=[System.IO.Path]::GetFullPath($target) }} catch {{ }}; " ++ + "$ErrorActionPreference='SilentlyContinue'; {s}; " ++ + "$target=Resolve-CodexAppExecutable {s}; " ++ "$p=Get-CimInstance Win32_Process | Where-Object {{ $_.ExecutablePath -and ([System.IO.Path]::GetFullPath($_.ExecutablePath) -ieq $target) }} | Select-Object -First 1; " ++ "if ($p) {{ [Console]::Out.Write('running') }}", - .{app_quoted}, + .{ windows_app_id_resolver_script, app_quoted }, ); } -fn isExecutablePathRunningOnMac(allocator: std.mem.Allocator, executable_path: []const u8) !bool { - var result = http_child.runChildCapture( - allocator, - &[_][]const u8{ "/bin/ps", "-axo", "comm=" }, - 3000, - null, - ) catch return false; - defer result.deinit(allocator); - if (result.timed_out) return false; - - var lines = std.mem.splitScalar(u8, result.stdout, '\n'); - while (lines.next()) |line| { - const trimmed = std.mem.trim(u8, line, " \t\r\n"); - if (std.mem.eql(u8, trimmed, executable_path)) return true; - } - return false; +fn childExitedSuccessfully(term: std.process.Child.Term) bool { + return switch (term) { + .exited => |code| code == 0, + else => false, + }; } fn nativeDefaultPlatform() types.AppPlatform { @@ -476,34 +460,30 @@ fn readWindowsWslBackendSetting(allocator: std.mem.Allocator, home: []const u8) fn launchApp( allocator: std.mem.Allocator, - app_path: ResolvedValue, + app_id: ResolvedValue, cli_path: ResolvedValue, home: []const u8, platform: ResolvedPlatform, inherit_stdio: bool, ) !void { - const target = app_path.value orelse { - try writeAppError("app launch could not find the installed Codex app. Pass `--app-path ` or set CODEX_AUTH_APP_PATH.\n"); - return error.AppPathRequired; + const target = app_id.value orelse { + try writeAppError("app launch needs an app ID. Pass `--app-id ` or set CODEX_AUTH_APP_ID.\n"); + return error.AppIdRequired; }; try validateAppPlatform(platform.value); try applyAppPlatform(allocator, home, platform.value); - if (inherit_stdio) { - try writeAppLaunching(); - return launchExecutableWithStdio(allocator, target, cli_path.value, home); - } - if (builtin.os.tag == .windows) { try writeAppLaunching(); - return launchWindowsViaPowerShell(allocator, target, app_path.source, cli_path.value, home); - } - if (looksLikeWindowsPath(target) or looksLikeWslWindowsMountPath(target)) { - try writeAppError("windows app launch must run from the Windows codex-auth executable.\n"); - return error.WindowsAppLaunchRequiresWindows; + return launchWindowsViaPowerShell(allocator, target, cli_path.value, home, if (inherit_stdio) .stdio else .gui); } + if (builtin.os.tag == .macos) { - return launchMac(allocator, target, app_path.source, cli_path.value, home); + if (inherit_stdio) { + try writeAppLaunching(); + return launchMacExecutableWithStdio(allocator, target, cli_path.value, home); + } + return launchMac(allocator, target, cli_path.value, home); } try writeAppError("app launch is supported only from the Windows or macOS codex-auth executable.\n"); return error.UnsupportedPlatform; @@ -606,22 +586,15 @@ fn deinitClonedJsonValue(allocator: std.mem.Allocator, value: *std.json.Value) v } } -fn looksLikeWindowsPath(path: []const u8) bool { - return (path.len >= 3 and std.ascii.isAlphabetic(path[0]) and path[1] == ':' and (path[2] == '\\' or path[2] == '/')) or - std.mem.startsWith(u8, path, "\\\\"); -} - -fn looksLikeWslWindowsMountPath(path: []const u8) bool { - return std.mem.startsWith(u8, path, "/mnt/") and path.len >= "/mnt/c/".len and path[6] == '/'; -} - -fn launchExecutableWithStdio( +fn launchMacExecutableWithStdio( allocator: std.mem.Allocator, - app_path: []const u8, + app_id: []const u8, cli_path: ?[]const u8, home: []const u8, ) !void { - const launch_path = try resolveLaunchPath(allocator, app_path); + const bundle_path = try resolveMacBundlePath(allocator, app_id); + defer allocator.free(bundle_path); + const launch_path = try resolveMacBundleExecutablePath(allocator, bundle_path); defer allocator.free(launch_path); var env_map = try registry.getEnvMap(allocator); @@ -641,15 +614,10 @@ fn launchExecutableWithStdio( fn launchMac( allocator: std.mem.Allocator, - app_path: []const u8, - app_source: ValueSource, + app_id: []const u8, cli_path: ?[]const u8, home: []const u8, ) !void { - if (!isDirectory(app_path) and std.mem.indexOf(u8, app_path, ".app") == null) { - try writeAppError("macOS app launch requires an app bundle path such as `/Applications/Codex.app`.\n"); - return error.AppPathRequired; - } try writeAppLaunching(); const home_env = try std.fmt.allocPrint(allocator, "{s}={s}", .{ codex_home_env, home }); @@ -667,12 +635,9 @@ fn launchMac( "/dev/null", "--stderr", "/dev/null", + "-b", + app_id, }); - if (app_source == .detected) { - try argv.appendSlice(allocator, &[_][]const u8{ "-b", codex_app_bundle_id }); - } else { - try argv.append(allocator, app_path); - } var child = try std.process.spawn(app_runtime.io(), .{ .argv = argv.items, .stdin = .ignore, @@ -687,27 +652,65 @@ fn launchMac( return error.AppLaunchFailed; } -fn resolveLaunchPath(allocator: std.mem.Allocator, app_path: []const u8) ![]u8 { - if (!isDirectory(app_path)) return try allocator.dupe(u8, app_path); +fn resolveMacBundlePath(allocator: std.mem.Allocator, app_id: []const u8) ![]u8 { + var result = http_child.runChildCapture( + allocator, + &[_][]const u8{ + "/usr/bin/osascript", + "-e", + "on run argv", + "-e", + "POSIX path of (path to application id (item 1 of argv))", + "-e", + "end run", + app_id, + }, + 7000, + null, + ) catch return error.AppIdNotFound; + defer result.deinit(allocator); + if (result.timed_out or !childExitedSuccessfully(result.term)) return error.AppIdNotFound; + const trimmed = std.mem.trim(u8, result.stdout, " \t\r\n"); + if (trimmed.len == 0) return error.AppIdNotFound; + return try allocator.dupe(u8, trimmed); +} + +fn macCanResolveAppId(allocator: std.mem.Allocator, app_id: []const u8) !bool { + const bundle_path = resolveMacBundlePath(allocator, app_id) catch return false; + allocator.free(bundle_path); + return true; +} +fn resolveMacBundleExecutablePath(allocator: std.mem.Allocator, bundle_path: []const u8) ![]u8 { const candidates = [_][]const u8{ - "Codex.exe", - "codex.exe", - "app/Codex.exe", - "app/codex.exe", - "Codex", - "codex", "Contents/MacOS/Codex", "Contents/MacOS/codex", + "Contents/MacOS/Codext", + "Contents/MacOS/codext", }; for (candidates) |candidate| { - const joined = try std.fs.path.join(allocator, &.{ app_path, candidate }); + const joined = try std.fs.path.join(allocator, &.{ bundle_path, candidate }); if (fileExists(joined)) return joined; allocator.free(joined); } return error.AppExecutableNotFound; } +fn windowsCanResolveAppId(allocator: std.mem.Allocator, app_id: []const u8) !bool { + const app_quoted = try psSingleQuoteAlloc(allocator, app_id); + defer allocator.free(app_quoted); + const script = try std.fmt.allocPrint( + allocator, + "$ErrorActionPreference='Stop'; {s}; try {{ $null=Resolve-CodexAppAumid {s}; [Console]::Out.Write('ok') }} catch {{ }}", + .{ windows_app_id_resolver_script, app_quoted }, + ); + defer allocator.free(script); + var result = http_child.runChildCapture(allocator, &[_][]const u8{ "pwsh.exe", "-NoProfile", "-Command", script }, 5000, null) catch return false; + defer result.deinit(allocator); + if (result.timed_out) return false; + return std.mem.eql(u8, std.mem.trim(u8, result.stdout, " \t\r\n"), "ok"); +} + fn isDirectory(path: []const u8) bool { const stat = std.Io.Dir.cwd().statFile(app_runtime.io(), path, .{}) catch return false; return stat.kind == .directory; @@ -726,65 +729,6 @@ fn fileExists(path: []const u8) bool { return true; } -fn shellSingleQuoteAlloc(allocator: std.mem.Allocator, value: []const u8) ![]u8 { - var out = std.ArrayList(u8).empty; - errdefer out.deinit(allocator); - try out.append(allocator, '\''); - for (value) |ch| { - if (ch == '\'') { - try out.appendSlice(allocator, "'\\''"); - } else { - try out.append(allocator, ch); - } - } - try out.append(allocator, '\''); - return try out.toOwnedSlice(allocator); -} - -fn detectInstalledAppPath(allocator: std.mem.Allocator) !?[]u8 { - return switch (builtin.os.tag) { - .windows => try detectWindowsInstalledAppPath(allocator), - .macos => try detectMacInstalledAppPath(allocator), - else => null, - }; -} - -fn detectWindowsInstalledAppPath(allocator: std.mem.Allocator) !?[]u8 { - const package_quoted = try psSingleQuoteAlloc(allocator, codex_app_package_name); - defer allocator.free(package_quoted); - const script = try std.fmt.allocPrint( - allocator, - "$ErrorActionPreference='SilentlyContinue'; $pkg=Get-AppxPackage -Name {s} | Sort-Object Version -Descending | Select-Object -First 1; if ($pkg) {{ [Console]::Out.Write($pkg.InstallLocation) }}", - .{package_quoted}, - ); - defer allocator.free(script); - var result = try http_child.runChildCapture(allocator, &[_][]const u8{ "pwsh.exe", "-NoProfile", "-Command", script }, 7000, null); - defer result.deinit(allocator); - const trimmed = std.mem.trim(u8, result.stdout, " \t\r\n"); - if (trimmed.len == 0) return null; - return try allocator.dupe(u8, trimmed); -} - -fn detectMacInstalledAppPath(allocator: std.mem.Allocator) !?[]u8 { - const candidates = [_][]const u8{ - "/Applications/Codex.app", - "~/Applications/Codex.app", - }; - for (candidates[0..]) |candidate| { - const expanded = try expandTildePath(allocator, candidate); - if (isDirectory(expanded) or fileExists(expanded)) return expanded; - allocator.free(expanded); - } - return null; -} - -fn expandTildePath(allocator: std.mem.Allocator, path: []const u8) ![]u8 { - if (!std.mem.startsWith(u8, path, "~/")) return try allocator.dupe(u8, path); - const home = getOptionalEnv(allocator, "HOME") orelse return try allocator.dupe(u8, path); - defer allocator.free(@constCast(home)); - return try std.fs.path.join(allocator, &.{ home, path[2..] }); -} - fn cachedCodextCliPath(allocator: std.mem.Allocator, home: []const u8, platform: types.AppPlatform) !?[]u8 { const candidate = try managedCodextExecutablePath(allocator, home, platform); if (fileExists(candidate)) return candidate; @@ -1198,81 +1142,42 @@ fn writeAppOutput(comptime format: []const u8, args: anytype) !void { fn launchWindowsViaPowerShell( allocator: std.mem.Allocator, - app_path: []const u8, - app_source: ValueSource, + app_id: []const u8, cli_path: ?[]const u8, home: []const u8, + mode: WindowsLaunchMode, ) !void { - if (app_source == .detected) { - return launchWindowsDetectedPackageViaPowerShell(allocator, cli_path, home); - } - - const app_quoted = try psSingleQuoteAlloc(allocator, app_path); + const app_quoted = try psSingleQuoteAlloc(allocator, app_id); defer allocator.free(app_quoted); const home_quoted = try psSingleQuoteAlloc(allocator, home); defer allocator.free(home_quoted); const cli_quoted = if (cli_path) |path| try psSingleQuoteAlloc(allocator, path) else null; defer if (cli_quoted) |path| allocator.free(path); - const cli_part = if (cli_quoted) |path| - try std.fmt.allocPrint(allocator, "; CODEX_CLI_PATH={s}", .{path}) - else - try allocator.dupe(u8, ""); - defer allocator.free(cli_part); - - const script = try std.fmt.allocPrint( - allocator, - "$ErrorActionPreference='Stop'; $p={s}; if (Test-Path -LiteralPath $p -PathType Container) {{ $c=@('Codex.exe','codex.exe','app\\Codex.exe','app\\codex.exe'); foreach ($n in $c) {{ $x=Join-Path $p $n; if (Test-Path -LiteralPath $x -PathType Leaf) {{ $p=$x; break }} }} }}; Start-Process -FilePath $p -Environment @{{ CODEX_HOME={s}{s} }}", - .{ app_quoted, home_quoted, cli_part }, - ); - defer allocator.free(script); - - var child = try std.process.spawn(app_runtime.io(), .{ - .argv = &[_][]const u8{ "pwsh.exe", "-NoProfile", "-Command", script }, - .stdin = .ignore, - .stdout = .ignore, - .stderr = .ignore, - .create_no_window = true, - }); - switch (try child.wait(app_runtime.io())) { - .exited => |code| if (code == 0) return, - else => {}, - } - try writeAppError("app launcher failed.\n"); - return error.AppLaunchFailed; -} - -fn launchWindowsDetectedPackageViaPowerShell( - allocator: std.mem.Allocator, - cli_path: ?[]const u8, - home: []const u8, -) !void { - const package_quoted = try psSingleQuoteAlloc(allocator, codex_app_package_name); - defer allocator.free(package_quoted); - const home_quoted = try psSingleQuoteAlloc(allocator, home); - defer allocator.free(home_quoted); - const cli_quoted = if (cli_path) |path| try psSingleQuoteAlloc(allocator, path) else null; - defer if (cli_quoted) |path| allocator.free(path); - const cli_part = if (cli_quoted) |path| try std.fmt.allocPrint(allocator, "; $env:CODEX_CLI_PATH={s}", .{path}) else try allocator.dupe(u8, ""); defer allocator.free(cli_part); + const launch_part = switch (mode) { + .gui => "$aumid=Resolve-CodexAppAumid $id; Start-Process -FilePath \"shell:AppsFolder\\$aumid\"", + .stdio => "$app=Resolve-CodexAppExecutable $id; $wd=Split-Path -Parent $app; Push-Location $wd; try { & $app; $code=$LASTEXITCODE } finally { Pop-Location }; if ($null -ne $code) { exit $code }", + }; + const script = try std.fmt.allocPrint( allocator, - "$ErrorActionPreference='Stop'; $pkg=Get-AppxPackage -Name {s} | Sort-Object Version -Descending | Select-Object -First 1; if (-not $pkg) {{ throw 'OpenAI.Codex package not found' }}; $appId=(Get-AppxPackageManifest $pkg).Package.Applications.Application | Select-Object -First 1 -ExpandProperty Id; $aumid=\"$($pkg.PackageFamilyName)!$appId\"; $env:CODEX_HOME={s}{s}; Start-Process -FilePath \"shell:AppsFolder\\$aumid\"", - .{ package_quoted, home_quoted, cli_part }, + "$ErrorActionPreference='Stop'; {s}; $id={s}; $env:CODEX_HOME={s}{s}; {s}", + .{ windows_app_id_resolver_script, app_quoted, home_quoted, cli_part, launch_part }, ); defer allocator.free(script); var child = try std.process.spawn(app_runtime.io(), .{ .argv = &[_][]const u8{ "pwsh.exe", "-NoProfile", "-Command", script }, .stdin = .ignore, - .stdout = .ignore, - .stderr = .ignore, - .create_no_window = true, + .stdout = if (mode == .stdio) .inherit else .ignore, + .stderr = if (mode == .stdio) .inherit else .ignore, + .create_no_window = mode == .gui, }); switch (try child.wait(app_runtime.io())) { .exited => |code| if (code == 0) return, diff --git a/src/workflows/preflight.zig b/src/workflows/preflight.zig index f32f1c66..b725cea4 100644 --- a/src/workflows/preflight.zig +++ b/src/workflows/preflight.zig @@ -26,10 +26,9 @@ pub fn isHandledCliError(err: anyerror) bool { err == error.RemoveConfirmationUnavailable or err == error.RemoveSelectionRequiresTty or err == error.InvalidRemoveSelectionInput or - err == error.AppPathValidationFailed or - err == error.AppPathRequired or - err == error.AppPathNotFound or - err == error.AppPathNotAccessible or + err == error.AppLaunchConfigValidationFailed or + err == error.AppIdRequired or + err == error.AppIdNotFound or err == error.AppExecutableNotFound or err == error.CodexCliPathNotFound or err == error.CodexCliPathNotAccessible or diff --git a/tests/cli_behavior_test.zig b/tests/cli_behavior_test.zig index 68c868fc..e8739750 100644 --- a/tests/cli_behavior_test.zig +++ b/tests/cli_behavior_test.zig @@ -75,13 +75,13 @@ fn expectArgv(actual: []const []const u8, expected: []const []const u8) !void { } } -test "Scenario: Given app launch overrides when parsing then paths are preserved" { +test "Scenario: Given app launch overrides when parsing then IDs and paths are preserved" { const gpa = std.testing.allocator; const args = [_][:0]const u8{ "codex-auth", "app", - "--app-path", - "C:\\Program Files\\WindowsApps\\OpenAI.Codex", + "--app-id", + "OpenAI.Codex", "--codex-cli-path", "codex-custom", "--codex-home", @@ -97,7 +97,7 @@ test "Scenario: Given app launch overrides when parsing then paths are preserved .command => |cmd| switch (cmd) { .app => |opts| { try std.testing.expectEqual(cli.types.AppAction.launch, opts.action); - try std.testing.expectEqualStrings("C:\\Program Files\\WindowsApps\\OpenAI.Codex", opts.app_path.?); + try std.testing.expectEqualStrings("OpenAI.Codex", opts.app_id.?); try std.testing.expectEqualStrings("codex-custom", opts.codex_cli_path.?); try std.testing.expectEqualStrings("/mnt/c/Users/Loong/.codext", opts.codex_home.?); try std.testing.expectEqual(cli.types.AppPlatform.win, opts.platform.?); diff --git a/tests/cli_integration_test.zig b/tests/cli_integration_test.zig index 8e2ea783..4b316bd1 100644 --- a/tests/cli_integration_test.zig +++ b/tests/cli_integration_test.zig @@ -3493,8 +3493,8 @@ test "Scenario: Given explicit codex home when planning app launch then codex ho const result = try runCliWithIsolatedHome(gpa, project_root, home_root, &[_][]const u8{ "app", - "--app-path", - "/bin/true", + "--app-id", + "OpenAI.Codex", "--codex-cli-path", "/bin/true", "--codex-home", @@ -3531,8 +3531,8 @@ test "Scenario: Given inherited codex home when planning app launch then codex h const result = try runCliWithIsolatedHomeAndCodexHome(gpa, project_root, home_root, codex_home, &[_][]const u8{ "app", - "--app-path", - "/bin/true", + "--app-id", + "OpenAI.Codex", "--codex-cli-path", "/bin/true", }); @@ -3545,38 +3545,6 @@ test "Scenario: Given inherited codex home when planning app launch then codex h try std.testing.expect(std.mem.indexOf(u8, result.stderr, " Codex Home:") == null); } -test "Scenario: Given missing explicit app path when launching app then command fails before launch plan" { - if (builtin.os.tag == .windows or builtin.os.tag == .macos) 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); - const missing_app_path = try fs.path.join(gpa, &[_][]const u8{ home_root, "missing-app" }); - defer gpa.free(missing_app_path); - - const result = try runCliWithIsolatedHome(gpa, project_root, home_root, &[_][]const u8{ - "app", - "--app-path", - missing_app_path, - }); - defer gpa.free(result.stdout); - defer gpa.free(result.stderr); - - try expectFailure(result); - try std.testing.expectEqualStrings("", result.stdout); - try std.testing.expect(std.mem.indexOf(u8, result.stderr, "ERROR: --app-path: Path does not exist\n") != null); - try std.testing.expect(std.mem.indexOf(u8, result.stderr, " \"") != null); - try std.testing.expect(std.mem.indexOf(u8, result.stderr, missing_app_path) != null); - try std.testing.expect(std.mem.indexOf(u8, result.stderr, "Environment Configuration") == null); -} - test "Scenario: Given missing explicit codex CLI path when launching app then command fails before launch plan" { if (builtin.os.tag == .windows or builtin.os.tag == .macos) return error.SkipZigTest; @@ -3590,16 +3558,13 @@ test "Scenario: Given missing explicit codex CLI path when launching app then co const home_root = try tmp.dir.realpathAlloc(gpa, "."); defer gpa.free(home_root); - try tmp.dir.writeFile(.{ .sub_path = "fake-app", .data = "" }); - const app_path = try fs.path.join(gpa, &[_][]const u8{ home_root, "fake-app" }); - defer gpa.free(app_path); const missing_cli_path = try fs.path.join(gpa, &[_][]const u8{ home_root, "missing-codex" }); defer gpa.free(missing_cli_path); const result = try runCliWithIsolatedHome(gpa, project_root, home_root, &[_][]const u8{ "app", - "--app-path", - app_path, + "--app-id", + "OpenAI.Codex", "--codex-cli-path", missing_cli_path, }); @@ -3613,40 +3578,3 @@ test "Scenario: Given missing explicit codex CLI path when launching app then co try std.testing.expect(std.mem.indexOf(u8, result.stderr, missing_cli_path) != null); try std.testing.expect(std.mem.indexOf(u8, result.stderr, "Environment Configuration") == null); } - -test "Scenario: Given multiple missing explicit app paths when launching app then every path error is printed before launch plan" { - if (builtin.os.tag == .windows or builtin.os.tag == .macos) 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); - const missing_app_path = try fs.path.join(gpa, &[_][]const u8{ home_root, "missing-app" }); - defer gpa.free(missing_app_path); - const missing_cli_path = try fs.path.join(gpa, &[_][]const u8{ home_root, "missing-codex" }); - defer gpa.free(missing_cli_path); - - const result = try runCliWithIsolatedHome(gpa, project_root, home_root, &[_][]const u8{ - "app", - "--app-path", - missing_app_path, - "--codex-cli-path", - missing_cli_path, - }); - defer gpa.free(result.stdout); - defer gpa.free(result.stderr); - - try expectFailure(result); - try std.testing.expectEqualStrings("", result.stdout); - try std.testing.expect(std.mem.indexOf(u8, result.stderr, "ERROR: --app-path: Path does not exist\n") != null); - try std.testing.expect(std.mem.indexOf(u8, result.stderr, missing_app_path) != null); - try std.testing.expect(std.mem.indexOf(u8, result.stderr, "ERROR: --codex-cli-path: Path does not exist\n") != null); - try std.testing.expect(std.mem.indexOf(u8, result.stderr, missing_cli_path) != null); - try std.testing.expect(std.mem.indexOf(u8, result.stderr, "Environment Configuration") == null); -}