Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,21 @@ npx @loongphy/codex-auth list
> npm installs already satisfy that requirement.
> Legacy standalone binary installs need Node.js 18+ on `PATH` when `codex-auth config api enable` is used.

## Storage Root

`codex-auth` uses the same Codex state root as the current process. Resolution order:

1. `CODEX_HOME` when set to a non-empty existing directory
2. `HOME/.codex`
3. `USERPROFILE/.codex` on Windows

When `CODEX_HOME` is set, `codex-auth` follows Codex and requires that directory to already exist.
That means you can isolate auth, registry, config, and session files by running:

```shell
CODEX_HOME=/path/to/custom-codex codex-auth list
```

### Uninstall

#### npm
Expand Down
2 changes: 1 addition & 1 deletion docs/auto-switch.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ Platform bootstrap:
- macOS: `LaunchAgent` with `KeepAlive`
- Windows: user scheduled task with an `ONLOGON` trigger, restart-on-failure settings, and an unlimited execution time for `codex-auth-auto.exe`, plus an immediate `schtasks /Run` during enablement

Service install paths still resolve from the real user home directory.
Service definition files stay in the platform-standard per-user locations. The managed watcher process uses the current `codex_home` root, so when `CODEX_HOME` is set during enablement the watcher keeps reading and writing that override after it starts in the background.
Foreground commands other than `help`, `version`, `status`, and `daemon` still reconcile the managed service definition after they complete.
`config auto enable` also prints a short usage-mode note so the user can see whether switching is currently running with default API-backed usage data or local-only fallback semantics.
When migrating from older Linux/WSL timer-based installs, enable/reconcile also removes the legacy `codex-auth-autoswitch.timer` unit file instead of leaving the old minute timer behind.
Expand Down
21 changes: 11 additions & 10 deletions docs/implement.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,18 @@ This document describes how `codex-auth` stores accounts, synchronizes auth file

## File Layout

- `~/.codex/auth.json`
- `~/.codex/accounts/registry.json`
- `~/.codex/accounts/<account file key>.auth.json`
- `~/.codex/accounts/auth.json.bak.YYYYMMDD-hhmmss[.N]`
- `~/.codex/accounts/registry.json.bak.YYYYMMDD-hhmmss[.N]`
- `~/.codex/sessions/...`
- `<codex_home>/auth.json`
- `<codex_home>/accounts/registry.json`
- `<codex_home>/accounts/<account file key>.auth.json`
- `<codex_home>/accounts/auth.json.bak.YYYYMMDD-hhmmss[.N]`
- `<codex_home>/accounts/registry.json.bak.YYYYMMDD-hhmmss[.N]`
- `<codex_home>/sessions/...`

`codex-auth` resolves `codex_home` from the real user home directory:
`codex-auth` resolves `codex_home` in this order:

1. `HOME/.codex`
2. `USERPROFILE/.codex` (Windows fallback)
1. `CODEX_HOME` when it is set to a non-empty existing directory
2. `HOME/.codex`
3. `USERPROFILE/.codex` (Windows fallback)

## Testing Conventions (BDD Style on std.testing)

Expand Down Expand Up @@ -216,7 +217,7 @@ The detailed runtime, thresholds, service model, and data-source priority rules
This document keeps only the cross-reference points that matter to the rest of the implementation:

- background config still lives in `registry.json` under top-level `auto_switch` and `api` blocks
- managed services still resolve install paths from the real user home directory
- managed services resolve from the same `codex_home` root as the active CLI process
- successful foreground `codex-auth` commands except `help`, `version`, `status`, and `daemon` still reconcile the managed service definition
- Linux/WSL `config auto enable` still requires a working `systemd --user` session

Expand Down
2 changes: 1 addition & 1 deletion docs/test.md
Original file line number Diff line number Diff line change
Expand Up @@ -227,4 +227,4 @@ Get-ScheduledTask -TaskName 'CodexAuthAutoSwitch' -ErrorAction SilentlyContinue

Expected result after disable: no task is returned.

For isolated HOME tests, use `daemon --once` to validate actual switching behavior. The Windows managed service artifacts are installed under the real Windows user profile, so `enable/disable/status` and `daemon --once` together provide the cleanest acceptance signal even though the managed task itself now starts the persistent watcher mode.
For isolated HOME tests, use `daemon --once` to validate actual switching behavior. The Windows task definition still lives in the real Windows user profile, while the managed watcher itself uses the `codex_home` that was active during enablement. `enable/disable/status` and `daemon --once` together still provide the cleanest acceptance signal.
96 changes: 73 additions & 23 deletions src/auto.zig
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ pub const Status = struct {
};

const service_version_env_name = "CODEX_AUTH_VERSION";
const codex_home_env_name = "CODEX_HOME";

pub const AutoSwitchAttempt = struct {
refreshed_candidates: bool,
Expand Down Expand Up @@ -2036,12 +2037,11 @@ pub fn deleteAbsoluteFileIfExists(path: []const u8) void {
}

fn installWindowsService(allocator: std.mem.Allocator, codex_home: []const u8, self_exe: []const u8) !void {
_ = codex_home;
const helper_path = try windowsHelperPath(allocator, self_exe);
defer allocator.free(helper_path);
try std.fs.cwd().access(helper_path, .{});

const register_script = try windowsRegisterTaskScript(allocator, helper_path);
const register_script = try windowsRegisterTaskScript(allocator, helper_path, codex_home);
defer allocator.free(register_script);
const end_script = try windowsEndTaskScript(allocator);
defer allocator.free(end_script);
Expand Down Expand Up @@ -2125,52 +2125,60 @@ fn queryWindowsRuntimeState(allocator: std.mem.Allocator) RuntimeState {
}

pub fn linuxUnitText(allocator: std.mem.Allocator, self_exe: []const u8, codex_home: []const u8) ![]u8 {
_ = codex_home;
const exec = try std.fmt.allocPrint(allocator, "\"{s}\" daemon --watch", .{self_exe});
defer allocator.free(exec);
const escaped_version = try escapeSystemdValue(allocator, version.app_version);
defer allocator.free(escaped_version);
const escaped_codex_home = try escapeSystemdValue(allocator, codex_home);
defer allocator.free(escaped_codex_home);
return try std.fmt.allocPrint(
allocator,
"[Unit]\nDescription=codex-auth auto-switch watcher\n\n[Service]\nType=simple\nRestart=always\nRestartSec=1\nEnvironment=\"{s}={s}\"\nExecStart={s}\n\n[Install]\nWantedBy=default.target\n",
"[Unit]\nDescription=codex-auth auto-switch watcher\n\n[Service]\nType=simple\nRestart=always\nRestartSec=1\nEnvironment=\"{s}={s}\"\nEnvironment=\"{s}={s}\"\nExecStart={s}\n\n[Install]\nWantedBy=default.target\n",
.{
service_version_env_name,
escaped_version,
codex_home_env_name,
escaped_codex_home,
exec,
},
);
}

pub fn macPlistText(allocator: std.mem.Allocator, self_exe: []const u8, codex_home: []const u8) ![]u8 {
_ = codex_home;
const exe = try escapeXml(allocator, self_exe);
defer allocator.free(exe);
const current_version = try escapeXml(allocator, version.app_version);
defer allocator.free(current_version);
const escaped_codex_home = try escapeXml(allocator, codex_home);
defer allocator.free(escaped_codex_home);
return try std.fmt.allocPrint(
allocator,
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n <key>Label</key>\n <string>{s}</string>\n <key>ProgramArguments</key>\n <array>\n <string>{s}</string>\n <string>daemon</string>\n <string>--watch</string>\n </array>\n <key>EnvironmentVariables</key>\n <dict>\n <key>{s}</key>\n <string>{s}</string>\n </dict>\n <key>RunAtLoad</key>\n <true/>\n <key>KeepAlive</key>\n <true/>\n</dict>\n</plist>\n",
.{ mac_label, exe, service_version_env_name, current_version },
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n <key>Label</key>\n <string>{s}</string>\n <key>ProgramArguments</key>\n <array>\n <string>{s}</string>\n <string>daemon</string>\n <string>--watch</string>\n </array>\n <key>EnvironmentVariables</key>\n <dict>\n <key>{s}</key>\n <string>{s}</string>\n <key>{s}</key>\n <string>{s}</string>\n </dict>\n <key>RunAtLoad</key>\n <true/>\n <key>KeepAlive</key>\n <true/>\n</dict>\n</plist>\n",
.{ mac_label, exe, service_version_env_name, current_version, codex_home_env_name, escaped_codex_home },
);
}

pub fn windowsTaskAction(allocator: std.mem.Allocator, helper_path: []const u8) ![]u8 {
pub fn windowsTaskAction(allocator: std.mem.Allocator, helper_path: []const u8, codex_home: []const u8) ![]u8 {
const args = try windowsTaskArguments(allocator, codex_home);
defer allocator.free(args);
return try std.fmt.allocPrint(
allocator,
"\"{s}\" --service-version {s}",
.{ helper_path, version.app_version },
"\"{s}\" {s}",
.{ helper_path, args },
);
}

pub fn windowsRegisterTaskScript(allocator: std.mem.Allocator, helper_path: []const u8) ![]u8 {
pub fn windowsRegisterTaskScript(allocator: std.mem.Allocator, helper_path: []const u8, codex_home: []const u8) ![]u8 {
const escaped_helper_path = try escapePowerShellSingleQuoted(allocator, helper_path);
defer allocator.free(escaped_helper_path);
const escaped_version = try escapePowerShellSingleQuoted(allocator, version.app_version);
defer allocator.free(escaped_version);
const args = try windowsTaskArguments(allocator, codex_home);
defer allocator.free(args);
const escaped_args = try escapePowerShellSingleQuoted(allocator, args);
defer allocator.free(escaped_args);
return try std.fmt.allocPrint(
allocator,
"$action = New-ScheduledTaskAction -Execute '{s}' -Argument '--service-version {s}'; $trigger = New-ScheduledTaskTrigger -AtLogOn; $settings = New-ScheduledTaskSettingsSet -RestartCount {s} -RestartInterval (New-TimeSpan -Minutes 1) -ExecutionTimeLimit (New-TimeSpan -Seconds 0); Register-ScheduledTask -TaskName '{s}' -Action $action -Trigger $trigger -Settings $settings -Force | Out-Null",
.{ escaped_helper_path, escaped_version, windows_task_restart_count, windows_task_name },
"$action = New-ScheduledTaskAction -Execute '{s}' -Argument '{s}'; $trigger = New-ScheduledTaskTrigger -AtLogOn; $settings = New-ScheduledTaskSettingsSet -RestartCount {s} -RestartInterval (New-TimeSpan -Minutes 1) -ExecutionTimeLimit (New-TimeSpan -Seconds 0); Register-ScheduledTask -TaskName '{s}' -Action $action -Trigger $trigger -Settings $settings -Force | Out-Null",
.{ escaped_helper_path, escaped_args, windows_task_restart_count, windows_task_name },
);
}

Expand Down Expand Up @@ -2309,10 +2317,9 @@ fn macPlistMatches(allocator: std.mem.Allocator, codex_home: []const u8, self_ex
}

fn windowsTaskMatches(allocator: std.mem.Allocator, codex_home: []const u8, self_exe: []const u8) !bool {
_ = codex_home;
const helper_path = try windowsHelperPath(allocator, self_exe);
defer allocator.free(helper_path);
const expected_action = try windowsExpectedTaskFingerprint(allocator, helper_path);
const expected_action = try windowsExpectedTaskFingerprint(allocator, helper_path, codex_home);
defer allocator.free(expected_action);
const expected_fingerprint = try windowsExpectedTaskDefinitionFingerprint(allocator, expected_action);
defer allocator.free(expected_fingerprint);
Expand All @@ -2335,12 +2342,10 @@ fn windowsTaskMatches(allocator: std.mem.Allocator, codex_home: []const u8, self
};
}

fn windowsExpectedTaskFingerprint(allocator: std.mem.Allocator, helper_path: []const u8) ![]u8 {
return try std.fmt.allocPrint(
allocator,
"{s} --service-version {s}",
.{ helper_path, version.app_version },
);
fn windowsExpectedTaskFingerprint(allocator: std.mem.Allocator, helper_path: []const u8, codex_home: []const u8) ![]u8 {
const args = try windowsTaskArguments(allocator, codex_home);
defer allocator.free(args);
return try std.fmt.allocPrint(allocator, "{s} {s}", .{ helper_path, args });
}

fn windowsExpectedTaskDefinitionFingerprint(allocator: std.mem.Allocator, action: []const u8) ![]u8 {
Expand Down Expand Up @@ -2443,6 +2448,51 @@ fn escapePowerShellSingleQuoted(allocator: std.mem.Allocator, input: []const u8)
return std.mem.replaceOwned(u8, allocator, input, "'", "''");
}

fn windowsTaskArguments(allocator: std.mem.Allocator, codex_home: []const u8) ![]u8 {
const quoted_codex_home = try quoteWindowsCommandArg(allocator, codex_home);
defer allocator.free(quoted_codex_home);
return try std.fmt.allocPrint(
allocator,
"--service-version {s} --codex-home {s}",
.{ version.app_version, quoted_codex_home },
);
}

fn quoteWindowsCommandArg(allocator: std.mem.Allocator, arg: []const u8) ![]u8 {
const needs_quotes = blk: {
if (arg.len == 0) break :blk true;
for (arg) |ch| {
if (ch <= ' ' or ch == '"') break :blk true;
}
break :blk false;
};
if (!needs_quotes) return try allocator.dupe(u8, arg);

var out = std.ArrayList(u8).empty;
defer out.deinit(allocator);
try out.append(allocator, '"');

var backslash_count: usize = 0;
for (arg) |byte| {
switch (byte) {
'\\' => backslash_count += 1,
'"' => {
try out.appendNTimes(allocator, '\\', backslash_count * 2 + 1);
try out.append(allocator, '"');
backslash_count = 0;
},
else => {
try out.appendNTimes(allocator, '\\', backslash_count);
try out.append(allocator, byte);
backslash_count = 0;
},
}
}
try out.appendNTimes(allocator, '\\', backslash_count * 2);
try out.append(allocator, '"');
return try out.toOwnedSlice(allocator);
}

test "candidate index refreshes cached ranking after a reset window expires" {
const bdd = @import("tests/bdd_helpers.zig");
const gpa = std.testing.allocator;
Expand Down
66 changes: 63 additions & 3 deletions src/registry.zig
Original file line number Diff line number Diff line change
Expand Up @@ -278,10 +278,70 @@ fn getNonEmptyEnvVarOwned(allocator: std.mem.Allocator, name: []const u8) !?[]u8
return val;
}

fn resolveExistingCodexHomeOverride(allocator: std.mem.Allocator, path: []const u8) ![]u8 {
const stat = std.fs.cwd().statFile(path) catch |err| switch (err) {
error.IsDir => {
return std.fs.realpathAlloc(allocator, path) catch |realpath_err| {
logCodexHomeResolutionError("failed to canonicalize CODEX_HOME `{s}`: {s}", .{ path, @errorName(realpath_err) });
return realpath_err;
};
},
error.FileNotFound => {
logCodexHomeResolutionError("CODEX_HOME points to `{s}`, but that path does not exist", .{path});
return err;
},
else => {
logCodexHomeResolutionError("failed to read CODEX_HOME `{s}`: {s}", .{ path, @errorName(err) });
return err;
},
};
if (stat.kind != .directory) {
logCodexHomeResolutionError("CODEX_HOME points to `{s}`, but that path is not a directory", .{path});
return error.NotDir;
}
return std.fs.realpathAlloc(allocator, path) catch |err| {
logCodexHomeResolutionError("failed to canonicalize CODEX_HOME `{s}`: {s}", .{ path, @errorName(err) });
return err;
};
}

fn logCodexHomeResolutionError(
comptime fmt: []const u8,
args: anytype,
) void {
if (builtin.is_test) return;
std.log.err(fmt, args);
}

pub fn resolveCodexHomeFromEnv(
allocator: std.mem.Allocator,
codex_home_override: ?[]const u8,
home: ?[]const u8,
user_profile: ?[]const u8,
) ![]u8 {
if (codex_home_override) |path| {
if (path.len != 0) return try resolveExistingCodexHomeOverride(allocator, path);
}
if (home) |path| {
if (path.len != 0) return try std.fs.path.join(allocator, &[_][]const u8{ path, ".codex" });
}
if (user_profile) |path| {
if (path.len != 0) return try std.fs.path.join(allocator, &[_][]const u8{ path, ".codex" });
}
return error.EnvironmentVariableNotFound;
}

pub fn resolveCodexHome(allocator: std.mem.Allocator) ![]u8 {
const home = try resolveUserHome(allocator);
defer allocator.free(home);
return try std.fs.path.join(allocator, &[_][]const u8{ home, ".codex" });
const codex_home_override = try getNonEmptyEnvVarOwned(allocator, "CODEX_HOME");
defer if (codex_home_override) |path| allocator.free(path);

const home = try getNonEmptyEnvVarOwned(allocator, "HOME");
defer if (home) |path| allocator.free(path);

const user_profile = try getNonEmptyEnvVarOwned(allocator, "USERPROFILE");
defer if (user_profile) |path| allocator.free(path);

return try resolveCodexHomeFromEnv(allocator, codex_home_override, home, user_profile);
}

pub fn resolveUserHome(allocator: std.mem.Allocator) ![]u8 {
Expand Down
9 changes: 7 additions & 2 deletions src/tests/auto_test.zig
Original file line number Diff line number Diff line change
Expand Up @@ -1365,6 +1365,7 @@ test "Scenario: Given linux service unit when rendering then it keeps a persiste
try std.testing.expect(std.mem.indexOf(u8, unit, "Type=simple") != null);
try std.testing.expect(std.mem.indexOf(u8, unit, "Restart=always") != null);
try std.testing.expect(std.mem.indexOf(u8, unit, "Environment=\"CODEX_AUTH_VERSION=") != null);
try std.testing.expect(std.mem.indexOf(u8, unit, "Environment=\"CODEX_HOME=/tmp/custom-codex-home\"") != null);
try std.testing.expect(std.mem.indexOf(u8, unit, "ExecStart=\"/tmp/codex-auth\" daemon --watch") != null);
try std.testing.expect(std.mem.indexOf(u8, unit, "[Install]") != null);
try std.testing.expect(std.mem.indexOf(u8, unit, "WantedBy=default.target") != null);
Expand Down Expand Up @@ -1403,27 +1404,31 @@ test "Scenario: Given mac plist when rendering then it includes version metadata
defer gpa.free(plist);

try std.testing.expect(std.mem.indexOf(u8, plist, "<key>CODEX_AUTH_VERSION</key>") != null);
try std.testing.expect(std.mem.indexOf(u8, plist, "<key>CODEX_HOME</key>") != null);
try std.testing.expect(std.mem.indexOf(u8, plist, "<string>/tmp/custom-codex-home</string>") != null);
try std.testing.expect(std.mem.indexOf(u8, plist, "<string>daemon</string>") != null);
}

test "Scenario: Given windows task action when rendering then it launches the helper directly without cmd" {
const gpa = std.testing.allocator;
const action = try auto.windowsTaskAction(gpa, "C:\\Program Files\\codex-auth\\codex-auth-auto.exe");
const action = try auto.windowsTaskAction(gpa, "C:\\Program Files\\codex-auth\\codex-auth-auto.exe", "C:\\Users\\demo\\Codex Home\\");
defer gpa.free(action);

try std.testing.expect(std.mem.indexOf(u8, action, "cmd.exe /D /C") == null);
try std.testing.expect(std.mem.indexOf(u8, action, "\"C:\\Program Files\\codex-auth\\codex-auth-auto.exe\"") != null);
try std.testing.expect(std.mem.indexOf(u8, action, "--service-version ") != null);
try std.testing.expect(std.mem.indexOf(u8, action, "--codex-home \"C:\\Users\\demo\\Codex Home\\\\\"") != null);
try std.testing.expect(std.mem.indexOf(u8, action, "powershell.exe") == null);
try std.testing.expect(action.len < 262);
}

test "Scenario: Given windows task register script when rendering then it configures restart-on-failure" {
const gpa = std.testing.allocator;
const script = try auto.windowsRegisterTaskScript(gpa, "C:\\Program Files\\codex-auth\\codex-auth-auto.exe");
const script = try auto.windowsRegisterTaskScript(gpa, "C:\\Program Files\\codex-auth\\codex-auth-auto.exe", "C:\\Users\\demo\\Codex Home\\");
defer gpa.free(script);

try std.testing.expect(std.mem.indexOf(u8, script, "New-ScheduledTaskAction") != null);
try std.testing.expect(std.mem.indexOf(u8, script, "--codex-home \"C:\\Users\\demo\\Codex Home\\\\\"") != null);
try std.testing.expect(std.mem.indexOf(u8, script, "New-ScheduledTaskTrigger -AtLogOn") != null);
try std.testing.expect(std.mem.indexOf(u8, script, "New-ScheduledTaskSettingsSet -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1)") != null);
try std.testing.expect(std.mem.indexOf(u8, script, "-ExecutionTimeLimit (New-TimeSpan -Seconds 0)") != null);
Expand Down
Loading
Loading