From fb287640ef7fc229eacd25a473b9ce8986dee4fe Mon Sep 17 00:00:00 2001 From: Loongphy Date: Thu, 30 Apr 2026 09:09:59 +0800 Subject: [PATCH 01/14] 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 1622745..7f6461d 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 3d432bf..35300ea 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 0000000..97b2513 --- /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 0000000..a858526 --- /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 d24f558..14b74b4 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 6f7f711..f3d0199 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 f2e281f..62e2c0e 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 0000000..17a91f7 --- /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 ba53245..35fab58 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 f65d489..bc6c913 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 664df16..a390ed6 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/14] 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 7f6461d..f14f8e9 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 97b2513..1e9a87d 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 a858526..36e9e92 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 f3d0199..70ea725 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 62e2c0e..dd54031 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 17a91f7..fc134d5 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 a390ed6..b5f2058 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/14] 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 1e9a87d..20fdb8d 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 70ea725..1b2358b 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 4200836..27a3cd4 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 f2f1dbf..a12fffd 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 fc134d5..17c0069 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/14] 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 20fdb8d..fc54418 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 17c0069..ddce6a1 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/14] 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 fc54418..f5ef3fb 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 ddce6a1..d9bfa06 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/14] 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 f5ef3fb..c3a47af 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 c68ca08..7b5b41d 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 d9bfa06..ccbb563 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/14] 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 c3a47af..3aea7f8 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 ccbb563..6ae7304 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/14] 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 f7694e5..276d11d 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/14] 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 98a5ec2..50617b2 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 3aea7f8..8aa3834 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 36e9e92..db2132e 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 7b5b41d..181edf7 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 2e8de93..47ddf0a 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 6ae7304..5eddc67 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 f1b59f5..4a72875 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/14] 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 276d11d..a524dd0 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 8aa3834..af80487 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/14] 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 a524dd0..d217710 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/14] 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 d217710..f7694e5 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/14] 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 50617b2..6d734af 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 af80487..ead9208 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 db2132e..3844b7f 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 4ca5dd6..236c15c 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 87f5fb3..623574f 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 5eddc67..2501acd 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 2273f45..b3e513c 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/14] 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 6d734af..819ad48 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 ead9208..c4ad3a9 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 3844b7f..98ed1cb 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 236c15c..9369638 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 623574f..33fd2f4 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 27a3cd4..fb6d5de 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 2501acd..8ee1d0c 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 b3e513c..68c868f 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 {