From 499eac6d596e0e1812597a44d6f8347f4b77ab9e Mon Sep 17 00:00:00 2001 From: Loongphy Date: Thu, 26 Mar 2026 16:03:50 +0800 Subject: [PATCH 01/22] feat(account): sync account names from accounts/check --- plans/2026-03-26-account-name.md | 121 ++++++++++++++ src/account_name_api.zig | 113 +++++++++++++ src/chatgpt_http.zig | 194 +++++++++++++++++++++++ src/display_rows.zig | 36 ++++- src/main.zig | 175 +++++++++++++++++++++ src/registry.zig | 90 ++++++++++- src/tests/account_name_api_test.zig | 134 ++++++++++++++++ src/tests/bdd_helpers.zig | 1 + src/tests/display_rows_test.zig | 43 +++++ src/tests/e2e_cli_test.zig | 1 + src/tests/main_test.zig | 235 ++++++++++++++++++++++++++++ src/tests/registry_test.zig | 97 ++++++++++++ src/usage_api.zig | 159 +------------------ 13 files changed, 1231 insertions(+), 168 deletions(-) create mode 100644 plans/2026-03-26-account-name.md create mode 100644 src/account_name_api.zig create mode 100644 src/chatgpt_http.zig create mode 100644 src/tests/account_name_api_test.zig diff --git a/plans/2026-03-26-account-name.md b/plans/2026-03-26-account-name.md new file mode 100644 index 0000000..2458a27 --- /dev/null +++ b/plans/2026-03-26-account-name.md @@ -0,0 +1,121 @@ +--- +name: account-name +description: Persist ChatGPT account names from accounts/check, show them in list/switch, and keep request volume low by fetching only when metadata is missing +--- + +# Plan + +Add stored `account_name` metadata to registry records, fetch it from `accounts/check` with the minimal three-header request shape, and surface it in `list` and `switch` with alias-first display precedence. + +## Progress +- [x] Create the dedicated worktree and lock execution to this plan file. +- [x] Extend the registry model and persistence for `account_name`. +- [x] Add the `accounts/check` metadata fetcher and align request headers with `wham/usage`. +- [x] Wire refresh behavior into `login`, `switch`, single-file `import`, and `list`. +- [x] Update shared display labels for `list` and `switch`. +- [x] Add parser, registry compatibility, flow, and display tests. +- [x] Run relevant Zig tests and `zig build run -- list`. + +## Summary +- Keep `registry.json` at schema `3`; this is an additive field only. +- Add `account_name: ?[]u8` to each account record. +- Treat missing or null names as `null`; do not use `""` as a stored default. +- Use the same minimal header rule for both APIs: + - `Authorization: Bearer ` + - `ChatGPT-Account-Id: ` only when available + - `User-Agent: codex-auth` +- Remove `Accept-Encoding: identity` from the current usage API implementation. +- Fetch account names only when metadata is missing and only once per command. + +## Requirements +- Parse `accounts/check` from: + - `accounts..account.account_id` + - `accounts..account.name` +- Ignore: + - `accounts.default` + - `account_ordering` + - all other payload fields +- Normalize `name: null` or `name: ""` to `account_name = null`. +- Refresh timing: + - after `login` + - after `switch` + - after single-file `import` + - during `list`, only if the active user still has any `account_name == null` +- Do not refresh during directory import or `import --purge`. +- Do not trigger `accounts/check` from the `wham/usage` refresh path. + +## Data model / API changes +- Extend `registry.AccountRecord` with `account_name: ?[]u8`. +- Old registries without that field must load successfully with `account_name = null`. +- New saves must always emit `account_name` as either a string or `null`. +- Add a dedicated account-name fetcher module or helper, separate from `usage_api` parsing. +- `accounts/check` request contract: + - method: `GET` + - URL: `https://chatgpt.com/backend-api/accounts/check/v4-2023-04-27` + - headers: + - `Authorization: Bearer ` + - `ChatGPT-Account-Id: ` when present + - `User-Agent: codex-auth` +- `wham/usage` request contract should be aligned to the same header policy. + +## Display behavior +- Use one shared label builder for both `list` and `switch`. +- Label precedence: + - alias + account name => `alias (account_name)` + - alias only => `alias` + - account name only => `account_name` + - neither => current fallback behavior +- Apply the same precedence to singleton rows, grouped child rows, and switch-picker rows. + +## Refresh and metadata behavior +- General rule: at most one `accounts/check` request per command, and only if the relevant active/imported user still has at least one null `account_name`. +- `login`: + - after login succeeds and the active auth is ready, fetch once if that user has missing names +- `switch`: + - after the target snapshot becomes active, fetch once if that user has missing names +- Single-file `import`: + - use the imported auth context directly + - fetch once only if that imported user has missing names +- Directory import and `import --purge`: + - never fetch names during the batch + - leave names null until a later `list`, `switch`, or `login` +- After a successful fetch: + - update only records whose `chatgpt_user_id` matches the auth used for the request + - set `account_name` for returned account IDs + - clear `account_name` to `null` for same-user records that were not returned + - leave other users unchanged +- On request or parse failure: + - keep command success behavior unchanged + - keep stored values unchanged + +## Testing and validation +- Add parser tests for: + - one real account plus `default` + - multiple non-default accounts + - `name: null` + - `name: ""` + - malformed / HTML response treated as non-fatal failure +- Add registry compatibility tests for: + - loading old registry data without `account_name` + - round-tripping `account_name: null` + - round-tripping `account_name: "abcd"` +- Add flow tests for: + - `login` issues at most one metadata request on missing-name records + - `switch` issues at most one metadata request on missing-name records + - single-file import issues at most one metadata request on missing-name records + - directory import and purge issue zero metadata requests + - `list` issues one metadata request only when the active user still has missing names +- Add display tests for: + - alias + account name + - alias only + - account name only + - neither +- Run: + - relevant Zig tests + - `zig build run -- list` + +## Assumptions +- `ChatGPT-Account-Id` is the required addition for `accounts/check`. +- Minimal three-header requests are sufficient for both `accounts/check` and `wham/usage`. +- Missing-name-only refresh is the preferred low-risk policy because account names rarely change. +- Skipping batch-import refresh is the right tradeoff for latency and request-volume control. diff --git a/src/account_name_api.zig b/src/account_name_api.zig new file mode 100644 index 0000000..de6c802 --- /dev/null +++ b/src/account_name_api.zig @@ -0,0 +1,113 @@ +const std = @import("std"); +const chatgpt_http = @import("chatgpt_http.zig"); + +pub const default_account_name_endpoint = "https://chatgpt.com/backend-api/accounts/check/v4-2023-04-27"; + +pub const AccountNameEntry = struct { + account_id: []u8, + account_name: ?[]u8, + + pub fn deinit(self: *const AccountNameEntry, allocator: std.mem.Allocator) void { + allocator.free(self.account_id); + if (self.account_name) |name| allocator.free(name); + } +}; + +pub const FetchResult = struct { + entries: ?[]AccountNameEntry, + status_code: ?u16, + + pub fn deinit(self: *const FetchResult, allocator: std.mem.Allocator) void { + if (self.entries) |entries| { + for (entries) |*entry| entry.deinit(allocator); + allocator.free(entries); + } + } +}; + +pub fn fetchAccountNamesForTokenDetailed( + allocator: std.mem.Allocator, + endpoint: []const u8, + access_token: []const u8, + account_id: ?[]const u8, +) !FetchResult { + const http_result = try chatgpt_http.runGetJsonCommand(allocator, endpoint, access_token, account_id); + defer allocator.free(http_result.body); + if (http_result.body.len == 0) { + return .{ + .entries = null, + .status_code = http_result.status_code, + }; + } + + return .{ + .entries = try parseAccountNamesResponse(allocator, http_result.body), + .status_code = http_result.status_code, + }; +} + +pub fn parseAccountNamesResponse(allocator: std.mem.Allocator, body: []const u8) !?[]AccountNameEntry { + var parsed = std.json.parseFromSlice(std.json.Value, allocator, body, .{}) catch |err| switch (err) { + error.OutOfMemory => return err, + else => return null, + }; + defer parsed.deinit(); + + const root_obj = switch (parsed.value) { + .object => |obj| obj, + else => return null, + }; + const accounts_value = root_obj.get("accounts") orelse return null; + const accounts_obj = switch (accounts_value) { + .object => |obj| obj, + else => return null, + }; + + var entries = std.ArrayList(AccountNameEntry).empty; + errdefer { + for (entries.items) |*entry| entry.deinit(allocator); + entries.deinit(allocator); + } + + var it = accounts_obj.iterator(); + while (it.next()) |kv| { + if (std.mem.eql(u8, kv.key_ptr.*, "default")) continue; + const entry_obj = switch (kv.value_ptr.*) { + .object => |obj| obj, + else => continue, + }; + const account_value = entry_obj.get("account") orelse continue; + const account_obj = switch (account_value) { + .object => |obj| obj, + else => continue, + }; + const account_id_value = account_obj.get("account_id") orelse continue; + const account_id = switch (account_id_value) { + .string => |value| value, + else => continue, + }; + if (account_id.len == 0) continue; + + const owned_account_id = try allocator.dupe(u8, account_id); + errdefer allocator.free(owned_account_id); + const owned_account_name = try parseAccountNameAlloc(allocator, account_obj.get("name")); + errdefer if (owned_account_name) |name| allocator.free(name); + + try entries.append(allocator, .{ + .account_id = owned_account_id, + .account_name = owned_account_name, + }); + } + + return try entries.toOwnedSlice(allocator); +} + +fn parseAccountNameAlloc(allocator: std.mem.Allocator, value: ?std.json.Value) !?[]u8 { + const raw = switch (value orelse return null) { + .string => |name| name, + .null => return null, + else => return null, + }; + if (raw.len == 0) return null; + return try allocator.dupe(u8, raw); +} diff --git a/src/chatgpt_http.zig b/src/chatgpt_http.zig new file mode 100644 index 0000000..1803850 --- /dev/null +++ b/src/chatgpt_http.zig @@ -0,0 +1,194 @@ +const std = @import("std"); +const builtin = @import("builtin"); + +pub const request_timeout_secs: []const u8 = "5"; + +pub const HttpResult = struct { + body: []u8, + status_code: ?u16, +}; + +const ParsedCurlHttpOutput = struct { + body: []const u8, + status_code: ?u16, +}; + +pub fn runGetJsonCommand( + allocator: std.mem.Allocator, + endpoint: []const u8, + access_token: []const u8, + account_id: ?[]const u8, +) !HttpResult { + return if (builtin.os.tag == .windows) + runPowerShellGetJsonCommand(allocator, endpoint, access_token, account_id) + else + runCurlGetJsonCommand(allocator, endpoint, access_token, account_id); +} + +fn runCurlGetJsonCommand( + allocator: std.mem.Allocator, + endpoint: []const u8, + access_token: []const u8, + account_id: ?[]const u8, +) !HttpResult { + const authorization = try std.fmt.allocPrint(allocator, "Authorization: Bearer {s}", .{access_token}); + defer allocator.free(authorization); + const account_header = if (account_id) |value| + try std.fmt.allocPrint(allocator, "ChatGPT-Account-Id: {s}", .{value}) + else + null; + defer if (account_header) |header| allocator.free(header); + + var argv = std.ArrayList([]const u8).empty; + defer argv.deinit(allocator); + + try argv.appendSlice(allocator, &.{ + "curl", + "--silent", + "--show-error", + "--location", + "--connect-timeout", + request_timeout_secs, + "--max-time", + request_timeout_secs, + "--write-out", + "\n%{http_code}", + "-H", + authorization, + }); + if (account_header) |header| { + try argv.appendSlice(allocator, &.{ "-H", header }); + } + try argv.appendSlice(allocator, &.{ + "-H", + "User-Agent: codex-auth", + endpoint, + }); + + const result = try std.process.Child.run(.{ + .allocator = allocator, + .argv = argv.items, + .max_output_bytes = 1024 * 1024, + }); + defer allocator.free(result.stderr); + defer allocator.free(result.stdout); + + const code = switch (result.term) { + .Exited => |exit_code| exit_code, + else => return error.RequestFailed, + }; + if (code != 0) return curlTransportError(code); + + const parsed = parseCurlHttpOutput(result.stdout) orelse return error.CommandFailed; + return .{ + .body = try allocator.dupe(u8, parsed.body), + .status_code = parsed.status_code, + }; +} + +fn runPowerShellGetJsonCommand( + allocator: std.mem.Allocator, + endpoint: []const u8, + access_token: []const u8, + account_id: ?[]const u8, +) !HttpResult { + const escaped_token = try escapePowerShellSingleQuoted(allocator, access_token); + defer allocator.free(escaped_token); + const escaped_account_id = if (account_id) |value| + try escapePowerShellSingleQuoted(allocator, value) + else + null; + defer if (escaped_account_id) |value| allocator.free(value); + const escaped_endpoint = try escapePowerShellSingleQuoted(allocator, endpoint); + defer allocator.free(escaped_endpoint); + const account_header_fragment = if (escaped_account_id) |value| + try std.fmt.allocPrint(allocator, "'ChatGPT-Account-Id' = '{s}'; ", .{value}) + else + try allocator.dupe(u8, ""); + defer allocator.free(account_header_fragment); + + const script = try std.fmt.allocPrint( + allocator, + "$headers = @{{ Authorization = 'Bearer {s}'; {s}'User-Agent' = 'codex-auth' }}; $status = 0; $body = ''; try {{ $response = Invoke-WebRequest -UseBasicParsing -TimeoutSec {s} -Headers $headers -Uri '{s}'; $status = [int]$response.StatusCode; $body = [string]$response.Content }} catch {{ if ($_.Exception.Response) {{ $status = [int]$_.Exception.Response.StatusCode.value__; $stream = $_.Exception.Response.GetResponseStream(); if ($stream) {{ $reader = New-Object System.IO.StreamReader($stream); try {{ $body = $reader.ReadToEnd() }} finally {{ $reader.Dispose() }} }} }} }}; [Console]::Out.Write([Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($body))); [Console]::Out.Write(\"`n\"); [Console]::Out.Write($status)", + .{ escaped_token, account_header_fragment, request_timeout_secs, escaped_endpoint }, + ); + defer allocator.free(script); + + const result = try std.process.Child.run(.{ + .allocator = allocator, + .argv = &.{ + "powershell.exe", + "-NoLogo", + "-NoProfile", + "-Command", + script, + }, + .max_output_bytes = 1024 * 1024, + }); + defer allocator.free(result.stderr); + + switch (result.term) { + .Exited => {}, + else => { + allocator.free(result.stdout); + return error.RequestFailed; + }, + } + + const parsed = parsePowerShellHttpOutput(allocator, result.stdout) orelse { + allocator.free(result.stdout); + return error.CommandFailed; + }; + allocator.free(result.stdout); + if (parsed.status_code == null and parsed.body.len == 0) { + allocator.free(parsed.body); + return error.RequestFailed; + } + return parsed; +} + +fn curlTransportError(exit_code: u8) anyerror { + return switch (exit_code) { + 28 => error.TimedOut, + else => error.RequestFailed, + }; +} + +fn escapePowerShellSingleQuoted(allocator: std.mem.Allocator, input: []const u8) ![]u8 { + return std.mem.replaceOwned(u8, allocator, input, "'", "''"); +} + +fn parseCurlHttpOutput(output: []const u8) ?ParsedCurlHttpOutput { + const trimmed = std.mem.trimRight(u8, output, "\r\n"); + const newline_idx = std.mem.lastIndexOfScalar(u8, trimmed, '\n') orelse return null; + const code_slice = std.mem.trim(u8, trimmed[newline_idx + 1 ..], " \r\t"); + if (code_slice.len == 0) return null; + const status = std.fmt.parseInt(u16, code_slice, 10) catch return null; + const body = std.mem.trimRight(u8, trimmed[0..newline_idx], "\r"); + return .{ + .body = body, + .status_code = if (status == 0) null else status, + }; +} + +fn parsePowerShellHttpOutput(allocator: std.mem.Allocator, output: []const u8) ?HttpResult { + const trimmed = std.mem.trimRight(u8, output, "\r\n"); + const newline_idx = std.mem.lastIndexOfScalar(u8, trimmed, '\n') orelse return null; + const encoded_body = std.mem.trim(u8, trimmed[0..newline_idx], " \r\t"); + const code_slice = std.mem.trim(u8, trimmed[newline_idx + 1 ..], " \r\t"); + const status = std.fmt.parseInt(u16, code_slice, 10) catch return null; + const decoded_body = decodeBase64Alloc(allocator, encoded_body) catch return null; + return .{ + .body = decoded_body, + .status_code = if (status == 0) null else status, + }; +} + +fn decodeBase64Alloc(allocator: std.mem.Allocator, input: []const u8) ![]u8 { + const decoder = std.base64.standard.Decoder; + const out_len = try decoder.calcSizeForSlice(input); + const buf = try allocator.alloc(u8, out_len); + errdefer allocator.free(buf); + try decoder.decode(buf, input); + return buf; +} diff --git a/src/display_rows.zig b/src/display_rows.zig index 1cac84b..24d28f9 100644 --- a/src/display_rows.zig +++ b/src/display_rows.zig @@ -154,8 +154,7 @@ fn isActive(reg: *const registry.Registry, account_idx: usize) bool { } fn singletonAccountCellAlloc(allocator: std.mem.Allocator, rec: *const registry.AccountRecord) ![]u8 { - if (rec.alias.len == 0) return allocator.dupe(u8, rec.email); - return std.fmt.allocPrint(allocator, "({s}){s}", .{ rec.alias, rec.email }); + return buildPreferredAccountLabelAlloc(allocator, rec, rec.email); } fn groupedAccountCellAlloc( @@ -165,8 +164,6 @@ fn groupedAccountCellAlloc( account_idx: usize, ) ![]u8 { const rec = ®.accounts.items[account_idx]; - if (rec.alias.len != 0) return allocator.dupe(u8, rec.alias); - const base = displayPlan(rec); var total_same: usize = 0; var ordinal: usize = 1; @@ -181,6 +178,33 @@ fn groupedAccountCellAlloc( } } - if (total_same <= 1) return allocator.dupe(u8, base); - return std.fmt.allocPrint(allocator, "{s} #{d}", .{ base, ordinal }); + const fallback = if (total_same <= 1) + try allocator.dupe(u8, base) + else + try std.fmt.allocPrint(allocator, "{s} #{d}", .{ base, ordinal }); + defer allocator.free(fallback); + + return buildPreferredAccountLabelAlloc(allocator, rec, fallback); +} + +pub fn buildPreferredAccountLabelAlloc( + allocator: std.mem.Allocator, + rec: *const registry.AccountRecord, + fallback: []const u8, +) ![]u8 { + const alias = if (rec.alias.len != 0) rec.alias else null; + const account_name = normalizedAccountName(rec); + + if (alias != null and account_name != null) { + return std.fmt.allocPrint(allocator, "{s} ({s})", .{ alias.?, account_name.? }); + } + if (alias != null) return allocator.dupe(u8, alias.?); + if (account_name != null) return allocator.dupe(u8, account_name.?); + return allocator.dupe(u8, fallback); +} + +fn normalizedAccountName(rec: *const registry.AccountRecord) ?[]const u8 { + const account_name = rec.account_name orelse return null; + if (account_name.len == 0) return null; + return account_name; } diff --git a/src/main.zig b/src/main.zig index 9324955..f0ea6e0 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const account_name_api = @import("account_name_api.zig"); const cli = @import("cli.zig"); const registry = @import("registry.zig"); const auth = @import("auth.zig"); @@ -7,6 +8,12 @@ const format = @import("format.zig"); const skip_service_reconcile_env = "CODEX_AUTH_SKIP_SERVICE_RECONCILE"; +const AccountNameFetchFn = *const fn ( + allocator: std.mem.Allocator, + access_token: []const u8, + account_id: ?[]const u8, +) anyerror!account_name_api.FetchResult; + pub fn main() !void { var exit_code: u8 = 0; runMain() catch |err| { @@ -155,6 +162,156 @@ fn maybeRefreshForegroundUsage( } } +fn defaultAccountNameFetcher( + allocator: std.mem.Allocator, + access_token: []const u8, + account_id: ?[]const u8, +) !account_name_api.FetchResult { + return try account_name_api.fetchAccountNamesForTokenDetailed( + allocator, + account_name_api.default_account_name_endpoint, + access_token, + account_id, + ); +} + +fn maybeRefreshAccountNamesForAuthInfo( + allocator: std.mem.Allocator, + reg: *registry.Registry, + info: *const auth.AuthInfo, + fetcher: AccountNameFetchFn, +) !bool { + const chatgpt_user_id = info.chatgpt_user_id orelse return false; + if (!registry.hasMissingAccountNameForUser(reg, chatgpt_user_id)) return false; + const access_token = info.access_token orelse return false; + + const result = fetcher(allocator, access_token, info.chatgpt_account_id) catch |err| { + std.log.warn("account metadata refresh skipped: {s}", .{@errorName(err)}); + return false; + }; + defer result.deinit(allocator); + + const entries = result.entries orelse return false; + return try registry.applyAccountNamesForUser(allocator, reg, chatgpt_user_id, entries); +} + +fn loadActiveAuthInfoForAccountNames(allocator: std.mem.Allocator, codex_home: []const u8) !?auth.AuthInfo { + const auth_path = try registry.activeAuthPath(allocator, codex_home); + defer allocator.free(auth_path); + + return auth.parseAuthInfo(allocator, auth_path) catch |err| switch (err) { + error.OutOfMemory => return err, + error.FileNotFound => null, + else => { + std.log.warn("account metadata refresh skipped: {s}", .{@errorName(err)}); + return null; + }, + }; +} + +fn refreshAccountNamesForActiveAuth( + allocator: std.mem.Allocator, + codex_home: []const u8, + reg: *registry.Registry, + fetcher: AccountNameFetchFn, +) !bool { + const active_user_id = registry.activeChatgptUserId(reg) orelse return false; + if (!registry.hasMissingAccountNameForUser(reg, active_user_id)) return false; + + var info = (try loadActiveAuthInfoForAccountNames(allocator, codex_home)) orelse return false; + defer info.deinit(allocator); + return try maybeRefreshAccountNamesForAuthInfo(allocator, reg, &info, fetcher); +} + +pub fn refreshAccountNamesAfterLogin( + allocator: std.mem.Allocator, + reg: *registry.Registry, + info: *const auth.AuthInfo, + fetcher: AccountNameFetchFn, +) !bool { + return try maybeRefreshAccountNamesForAuthInfo(allocator, reg, info, fetcher); +} + +pub fn refreshAccountNamesAfterSwitch( + allocator: std.mem.Allocator, + codex_home: []const u8, + reg: *registry.Registry, + fetcher: AccountNameFetchFn, +) !bool { + return try refreshAccountNamesForActiveAuth(allocator, codex_home, reg, fetcher); +} + +pub fn refreshAccountNamesForList( + allocator: std.mem.Allocator, + codex_home: []const u8, + reg: *registry.Registry, + fetcher: AccountNameFetchFn, +) !bool { + return try refreshAccountNamesForActiveAuth(allocator, codex_home, reg, fetcher); +} + +pub fn refreshAccountNamesAfterImport( + allocator: std.mem.Allocator, + reg: *registry.Registry, + purge: bool, + render_kind: registry.ImportRenderKind, + info: ?*const auth.AuthInfo, + fetcher: AccountNameFetchFn, +) !bool { + if (purge or render_kind != .single_file or info == null) return false; + return try maybeRefreshAccountNamesForAuthInfo(allocator, reg, info.?, fetcher); +} + +fn loadSingleFileImportAuthInfo( + allocator: std.mem.Allocator, + opts: cli.ImportOptions, +) !?auth.AuthInfo { + if (opts.purge or opts.auth_path == null) return null; + + return switch (opts.source) { + .standard => auth.parseAuthInfo(allocator, opts.auth_path.?) catch |err| switch (err) { + error.OutOfMemory => return err, + else => { + std.log.warn("account metadata refresh skipped: {s}", .{@errorName(err)}); + return null; + }, + }, + .cpa => blk: { + var file = std.fs.cwd().openFile(opts.auth_path.?, .{}) catch |err| { + std.log.warn("account metadata refresh skipped: {s}", .{@errorName(err)}); + return null; + }; + defer file.close(); + + const data = file.readToEndAlloc(allocator, 10 * 1024 * 1024) catch |err| switch (err) { + error.OutOfMemory => return err, + else => { + std.log.warn("account metadata refresh skipped: {s}", .{@errorName(err)}); + return null; + }, + }; + defer allocator.free(data); + + const converted = auth.convertCpaAuthJson(allocator, data) catch |err| switch (err) { + error.OutOfMemory => return err, + else => { + std.log.warn("account metadata refresh skipped: {s}", .{@errorName(err)}); + return null; + }, + }; + defer allocator.free(converted); + + break :blk auth.parseAuthInfoData(allocator, converted) catch |err| switch (err) { + error.OutOfMemory => return err, + else => { + std.log.warn("account metadata refresh skipped: {s}", .{@errorName(err)}); + return null; + }, + }; + }, + }; +} + fn handleList(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli.ListOptions) !void { _ = opts; var reg = try registry.loadRegistry(allocator, codex_home); @@ -174,6 +331,9 @@ fn handleList(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli.Li try registry.saveRegistry(allocator, codex_home, ®); } } + if (try refreshAccountNamesForList(allocator, codex_home, ®, defaultAccountNameFetcher)) { + try registry.saveRegistry(allocator, codex_home, ®); + } try maybeRefreshForegroundUsage(allocator, codex_home, ®, .list); try format.printAccounts(allocator, ®, .table); } @@ -202,6 +362,7 @@ fn handleLogin(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli.L const record = try registry.accountFromAuth(allocator, "", &info); try registry.upsertAccount(allocator, ®, record); try registry.setActiveAccountKey(allocator, ®, record_key); + _ = try refreshAccountNamesAfterLogin(allocator, ®, &info, defaultAccountNameFetcher); try registry.saveRegistry(allocator, codex_home, ®); } @@ -222,6 +383,18 @@ fn handleImport(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli. }; defer report.deinit(allocator); if (report.appliedCount() > 0) { + if (report.render_kind == .single_file) { + var imported_info = try loadSingleFileImportAuthInfo(allocator, opts); + defer if (imported_info) |*info| info.deinit(allocator); + _ = try refreshAccountNamesAfterImport( + allocator, + ®, + opts.purge, + report.render_kind, + if (imported_info) |*info| info else null, + defaultAccountNameFetcher, + ); + } try registry.saveRegistry(allocator, codex_home, ®); } try cli.printImportReport(&report); @@ -260,6 +433,7 @@ fn handleSwitch(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli. const account_key = selected_account_key.?; try registry.activateAccountByKey(allocator, codex_home, ®, account_key); + _ = try refreshAccountNamesAfterSwitch(allocator, codex_home, ®, defaultAccountNameFetcher); try registry.saveRegistry(allocator, codex_home, ®); } @@ -500,6 +674,7 @@ fn handleClean(allocator: std.mem.Allocator, codex_home: []const u8) !void { test { _ = @import("tests/auth_test.zig"); _ = @import("tests/sessions_test.zig"); + _ = @import("tests/account_name_api_test.zig"); _ = @import("tests/usage_api_test.zig"); _ = @import("tests/auto_test.zig"); _ = @import("tests/registry_test.zig"); diff --git a/src/registry.zig b/src/registry.zig index 1cfbce9..fea2059 100644 --- a/src/registry.zig +++ b/src/registry.zig @@ -1,5 +1,6 @@ const std = @import("std"); const builtin = @import("builtin"); +const account_name_api = @import("account_name_api.zig"); const c_time = @cImport({ @cInclude("time.h"); }); @@ -59,6 +60,7 @@ pub const AccountRecord = struct { chatgpt_user_id: []u8, email: []u8, alias: []u8, + account_name: ?[]u8, plan: ?PlanType, auth_mode: ?AuthMode, created_at: i64, @@ -105,6 +107,7 @@ fn freeAccountRecord(allocator: std.mem.Allocator, rec: *const AccountRecord) vo allocator.free(rec.chatgpt_user_id); allocator.free(rec.email); allocator.free(rec.alias); + if (rec.account_name) |account_name| allocator.free(account_name); if (rec.last_local_rollout) |*sig| freeRolloutSignature(allocator, sig); if (rec.last_usage) |*u| { freeRateLimitSnapshot(allocator, u); @@ -225,6 +228,21 @@ fn optionalStringEqual(a: ?[]const u8, b: ?[]const u8) bool { return std.mem.eql(u8, a.?, b.?); } +fn cloneOptionalStringAlloc(allocator: std.mem.Allocator, value: ?[]const u8) !?[]u8 { + return if (value) |text| try allocator.dupe(u8, text) else null; +} + +fn replaceOptionalStringAlloc( + allocator: std.mem.Allocator, + target: *?[]u8, + value: ?[]const u8, +) !bool { + if (optionalStringEqual(target.*, value)) return false; + if (target.*) |existing| allocator.free(existing); + target.* = try cloneOptionalStringAlloc(allocator, value); + return true; +} + fn getNonEmptyEnvVarOwned(allocator: std.mem.Allocator, name: []const u8) !?[]u8 { const val = std.process.getEnvVarOwned(allocator, name) catch |err| switch (err) { error.EnvironmentVariableNotFound => return null, @@ -1742,6 +1760,44 @@ pub fn resolveRateWindow(usage: ?RateLimitSnapshot, minutes: i64, fallback_prima return if (fallback_primary) usage.?.primary else usage.?.secondary; } +pub fn hasMissingAccountNameForUser(reg: *const Registry, chatgpt_user_id: []const u8) bool { + for (reg.accounts.items) |rec| { + if (!std.mem.eql(u8, rec.chatgpt_user_id, chatgpt_user_id)) continue; + if (rec.account_name == null) return true; + } + return false; +} + +pub fn activeChatgptUserId(reg: *Registry) ?[]const u8 { + const active_account_key = reg.active_account_key orelse return null; + const idx = findAccountIndexByAccountKey(reg, active_account_key) orelse return null; + return reg.accounts.items[idx].chatgpt_user_id; +} + +pub fn applyAccountNamesForUser( + allocator: std.mem.Allocator, + reg: *Registry, + chatgpt_user_id: []const u8, + entries: []const account_name_api.AccountNameEntry, +) !bool { + var changed = false; + for (reg.accounts.items) |*rec| { + if (!std.mem.eql(u8, rec.chatgpt_user_id, chatgpt_user_id)) continue; + + var account_name: ?[]const u8 = null; + for (entries) |entry| { + if (!std.mem.eql(u8, rec.chatgpt_account_id, entry.account_id)) continue; + account_name = entry.account_name; + break; + } + + if (try replaceOptionalStringAlloc(allocator, &rec.account_name, account_name)) { + changed = true; + } + } + return changed; +} + pub fn activateAccountByKey( allocator: std.mem.Allocator, codex_home: []const u8, @@ -1802,6 +1858,7 @@ pub fn accountFromAuth( .chatgpt_user_id = owned_chatgpt_user_id, .email = owned_email, .alias = owned_alias, + .account_name = null, .plan = info.plan, .auth_mode = info.auth_mode, .created_at = std.time.timestamp(), @@ -1824,19 +1881,26 @@ fn recordFreshness(rec: *const AccountRecord) i64 { } fn mergeAccountRecord(allocator: std.mem.Allocator, dest: *AccountRecord, incoming: AccountRecord) void { - if (recordFreshness(&incoming) > recordFreshness(dest)) { + var merged_incoming = incoming; + if (recordFreshness(&merged_incoming) > recordFreshness(dest)) { + if (merged_incoming.account_name == null and dest.account_name != null) { + merged_incoming.account_name = cloneOptionalStringAlloc(allocator, dest.account_name) catch unreachable; + } freeAccountRecord(allocator, dest); - dest.* = incoming; + dest.* = merged_incoming; return; } - if (incoming.alias.len != 0 and dest.alias.len == 0) { - const replacement = allocator.dupe(u8, incoming.alias) catch allocator.dupe(u8, "") catch unreachable; + if (merged_incoming.alias.len != 0 and dest.alias.len == 0) { + const replacement = allocator.dupe(u8, merged_incoming.alias) catch allocator.dupe(u8, "") catch unreachable; allocator.free(dest.alias); dest.alias = replacement; } - if (dest.plan == null) dest.plan = incoming.plan; - if (dest.auth_mode == null) dest.auth_mode = incoming.auth_mode; - freeAccountRecord(allocator, &incoming); + if (dest.account_name == null and merged_incoming.account_name != null) { + dest.account_name = cloneOptionalStringAlloc(allocator, merged_incoming.account_name) catch unreachable; + } + if (dest.plan == null) dest.plan = merged_incoming.plan; + if (dest.auth_mode == null) dest.auth_mode = merged_incoming.auth_mode; + freeAccountRecord(allocator, &merged_incoming); } pub fn upsertAccount(allocator: std.mem.Allocator, reg: *Registry, record: AccountRecord) !void { @@ -1946,6 +2010,7 @@ fn parseAccountRecord(allocator: std.mem.Allocator, obj: std.json.ObjectMap) !Ac }, .email = try normalizeEmailAlloc(allocator, email), .alias = try allocator.dupe(u8, alias), + .account_name = try parseOptionalStoredStringAlloc(allocator, obj.get("account_name")), .plan = null, .auth_mode = null, .created_at = readInt(obj.get("created_at")) orelse std.time.timestamp(), @@ -1977,6 +2042,16 @@ fn parseAccountRecord(allocator: std.mem.Allocator, obj: std.json.ObjectMap) !Ac return rec; } +fn parseOptionalStoredStringAlloc(allocator: std.mem.Allocator, value: ?std.json.Value) !?[]u8 { + const text = switch (value orelse return null) { + .string => |s| s, + .null => return null, + else => return null, + }; + if (text.len == 0) return null; + return try allocator.dupe(u8, text); +} + fn maybeCopyFile(src: []const u8, dest: []const u8) !void { if (std.mem.eql(u8, src, dest)) return; try copyFile(src, dest); @@ -2059,6 +2134,7 @@ fn migrateLegacyRecord( .chatgpt_user_id = try allocator.dupe(u8, info.chatgpt_user_id orelse return error.MissingChatgptUserId), .email = try allocator.dupe(u8, legacy.email), .alias = try allocator.dupe(u8, legacy.alias), + .account_name = null, .plan = info.plan orelse legacy.plan, .auth_mode = info.auth_mode, .created_at = legacy.created_at, diff --git a/src/tests/account_name_api_test.zig b/src/tests/account_name_api_test.zig new file mode 100644 index 0000000..f8a2c1e --- /dev/null +++ b/src/tests/account_name_api_test.zig @@ -0,0 +1,134 @@ +const std = @import("std"); +const account_name_api = @import("../account_name_api.zig"); + +fn findEntryByAccountId(entries: []const account_name_api.AccountNameEntry, account_id: []const u8) ?*const account_name_api.AccountNameEntry { + for (entries) |*entry| { + if (std.mem.eql(u8, entry.account_id, account_id)) return entry; + } + return null; +} + +fn freeEntries(allocator: std.mem.Allocator, entries: ?[]account_name_api.AccountNameEntry) void { + if (entries) |owned_entries| { + for (owned_entries) |*entry| entry.deinit(allocator); + allocator.free(owned_entries); + } +} + +test "parse account names response ignores default and keeps one real account" { + const gpa = std.testing.allocator; + const body = + \\{ + \\ "accounts": { + \\ "default": { + \\ "account": { + \\ "account_id": "default-account", + \\ "name": "Default" + \\ } + \\ }, + \\ "team-1": { + \\ "account": { + \\ "account_id": "67fe2bbb-0de6-49a4-b2b3-d1df366d1faf", + \\ "name": "Primary Workspace" + \\ } + \\ } + \\ }, + \\ "account_ordering": ["default", "team-1"] + \\} + ; + + const entries = try account_name_api.parseAccountNamesResponse(gpa, body); + defer freeEntries(gpa, entries); + + try std.testing.expect(entries != null); + try std.testing.expectEqual(@as(usize, 1), entries.?.len); + try std.testing.expect(std.mem.eql(u8, entries.?[0].account_id, "67fe2bbb-0de6-49a4-b2b3-d1df366d1faf")); + try std.testing.expect(entries.?[0].account_name != null); + try std.testing.expect(std.mem.eql(u8, entries.?[0].account_name.?, "Primary Workspace")); +} + +test "parse account names response keeps multiple non-default accounts" { + const gpa = std.testing.allocator; + const body = + \\{ + \\ "accounts": { + \\ "team-1": { + \\ "account": { + \\ "account_id": "67fe2bbb-0de6-49a4-b2b3-d1df366d1faf", + \\ "name": "Primary Workspace" + \\ } + \\ }, + \\ "team-2": { + \\ "account": { + \\ "account_id": "518a44d9-ba75-4bad-87e5-ae9377042960", + \\ "name": "Backup Workspace" + \\ } + \\ } + \\ } + \\} + ; + + const entries = try account_name_api.parseAccountNamesResponse(gpa, body); + defer freeEntries(gpa, entries); + + try std.testing.expect(entries != null); + try std.testing.expectEqual(@as(usize, 2), entries.?.len); + const primary = findEntryByAccountId(entries.?, "67fe2bbb-0de6-49a4-b2b3-d1df366d1faf") orelse return error.TestExpectedEqual; + const backup = findEntryByAccountId(entries.?, "518a44d9-ba75-4bad-87e5-ae9377042960") orelse return error.TestExpectedEqual; + try std.testing.expect(primary.account_name != null); + try std.testing.expect(std.mem.eql(u8, primary.account_name.?, "Primary Workspace")); + try std.testing.expect(backup.account_name != null); + try std.testing.expect(std.mem.eql(u8, backup.account_name.?, "Backup Workspace")); +} + +test "parse account names response normalizes null names to null" { + const gpa = std.testing.allocator; + const body = + \\{ + \\ "accounts": { + \\ "team-1": { + \\ "account": { + \\ "account_id": "67fe2bbb-0de6-49a4-b2b3-d1df366d1faf", + \\ "name": null + \\ } + \\ } + \\ } + \\} + ; + + const entries = try account_name_api.parseAccountNamesResponse(gpa, body); + defer freeEntries(gpa, entries); + + try std.testing.expect(entries != null); + try std.testing.expectEqual(@as(usize, 1), entries.?.len); + try std.testing.expect(entries.?[0].account_name == null); +} + +test "parse account names response normalizes empty names to null" { + const gpa = std.testing.allocator; + const body = + \\{ + \\ "accounts": { + \\ "team-1": { + \\ "account": { + \\ "account_id": "67fe2bbb-0de6-49a4-b2b3-d1df366d1faf", + \\ "name": "" + \\ } + \\ } + \\ } + \\} + ; + + const entries = try account_name_api.parseAccountNamesResponse(gpa, body); + defer freeEntries(gpa, entries); + + try std.testing.expect(entries != null); + try std.testing.expectEqual(@as(usize, 1), entries.?.len); + try std.testing.expect(entries.?[0].account_name == null); +} + +test "parse account names response treats malformed html as non-fatal failure" { + const gpa = std.testing.allocator; + const result = try account_name_api.parseAccountNamesResponse(gpa, "not json"); + try std.testing.expect(result == null); +} diff --git a/src/tests/bdd_helpers.zig b/src/tests/bdd_helpers.zig index f74f1dd..9edc117 100644 --- a/src/tests/bdd_helpers.zig +++ b/src/tests/bdd_helpers.zig @@ -213,6 +213,7 @@ pub fn appendAccount( .chatgpt_user_id = owned_chatgpt_user_id, .email = owned_email, .alias = owned_alias, + .account_name = null, .plan = plan, .auth_mode = .chatgpt, .created_at = std.time.timestamp(), diff --git a/src/tests/display_rows_test.zig b/src/tests/display_rows_test.zig index b0e290c..ffd4c4f 100644 --- a/src/tests/display_rows_test.zig +++ b/src/tests/display_rows_test.zig @@ -30,6 +30,7 @@ fn appendAccount( .chatgpt_user_id = try allocator.dupe(u8, chatgpt_user_id), .email = try allocator.dupe(u8, email), .alias = try allocator.dupe(u8, alias), + .account_name = null, .plan = plan, .auth_mode = .chatgpt, .created_at = 1, @@ -78,3 +79,45 @@ test "Scenario: Given grouped accounts with aliases when building display rows t try std.testing.expect(std.mem.eql(u8, rows.rows[1].account_cell, "backup") or std.mem.eql(u8, rows.rows[1].account_cell, "work")); try std.testing.expect(std.mem.eql(u8, rows.rows[2].account_cell, "backup") or std.mem.eql(u8, rows.rows[2].account_cell, "work")); } + +test "Scenario: Given singleton accounts with alias and account name combinations when building display rows then preferred labels are used" { + const gpa = std.testing.allocator; + var reg = makeRegistry(); + defer reg.deinit(gpa); + + try appendAccount(gpa, ®, "user-A::acct-1", "alias-name@example.com", "work", .team); + reg.accounts.items[0].account_name = try gpa.dupe(u8, "Primary Workspace"); + try appendAccount(gpa, ®, "user-B::acct-2", "alias-only@example.com", "backup", .team); + try appendAccount(gpa, ®, "user-C::acct-3", "name-only@example.com", "", .team); + reg.accounts.items[2].account_name = try gpa.dupe(u8, "Sandbox"); + try appendAccount(gpa, ®, "user-D::acct-4", "fallback@example.com", "", .team); + + var rows = try display_rows.buildDisplayRows(gpa, ®, null); + defer rows.deinit(gpa); + + try std.testing.expectEqual(@as(usize, 4), rows.rows.len); + try std.testing.expect(std.mem.eql(u8, rows.rows[0].account_cell, "work (Primary Workspace)")); + try std.testing.expect(std.mem.eql(u8, rows.rows[1].account_cell, "backup")); + try std.testing.expect(std.mem.eql(u8, rows.rows[2].account_cell, "Sandbox")); + try std.testing.expect(std.mem.eql(u8, rows.rows[3].account_cell, "fallback@example.com")); +} + +test "Scenario: Given grouped accounts with account names when building display rows then child labels use the same precedence" { + const gpa = std.testing.allocator; + var reg = makeRegistry(); + defer reg.deinit(gpa); + + try appendAccount(gpa, ®, "user-ESYgcy2QkOGZc0NoxSlFCeVT::67fe2bbb-0de6-49a4-b2b3-d1df366d1faf", "user@example.com", "work", .team); + reg.accounts.items[0].account_name = try gpa.dupe(u8, "Primary Workspace"); + try appendAccount(gpa, ®, "user-ESYgcy2QkOGZc0NoxSlFCeVT::518a44d9-ba75-4bad-87e5-ae9377042960", "user@example.com", "", .team); + reg.accounts.items[1].account_name = try gpa.dupe(u8, "Backup Workspace"); + try appendAccount(gpa, ®, "user-ESYgcy2QkOGZc0NoxSlFCeVT::a4021fa5-998b-4774-989f-784fa69c367b", "user@example.com", "", .plus); + + var rows = try display_rows.buildDisplayRows(gpa, ®, null); + defer rows.deinit(gpa); + + try std.testing.expectEqual(@as(usize, 4), rows.rows.len); + try std.testing.expect(std.mem.eql(u8, rows.rows[1].account_cell, "work (Primary Workspace)")); + try std.testing.expect(std.mem.eql(u8, rows.rows[2].account_cell, "Backup Workspace")); + try std.testing.expect(std.mem.eql(u8, rows.rows[3].account_cell, "plus")); +} diff --git a/src/tests/e2e_cli_test.zig b/src/tests/e2e_cli_test.zig index 57ce1cf..d07901a 100644 --- a/src/tests/e2e_cli_test.zig +++ b/src/tests/e2e_cli_test.zig @@ -219,6 +219,7 @@ fn appendCustomAccount( .chatgpt_user_id = try allocator.dupe(u8, chatgpt_user_id), .email = try allocator.dupe(u8, email), .alias = try allocator.dupe(u8, alias), + .account_name = null, .plan = plan, .auth_mode = .chatgpt, .created_at = std.time.timestamp(), diff --git a/src/tests/main_test.zig b/src/tests/main_test.zig index ff1601b..6131fbf 100644 --- a/src/tests/main_test.zig +++ b/src/tests/main_test.zig @@ -1,8 +1,22 @@ const std = @import("std"); +const account_name_api = @import("../account_name_api.zig"); +const auth_mod = @import("../auth.zig"); const main_mod = @import("../main.zig"); const registry = @import("../registry.zig"); const bdd = @import("bdd_helpers.zig"); +const shared_user_id = "user-ESYgcy2QkOGZc0NoxSlFCeVT"; +const primary_account_id = "67fe2bbb-0de6-49a4-b2b3-d1df366d1faf"; +const secondary_account_id = "518a44d9-ba75-4bad-87e5-ae9377042960"; +const primary_record_key = shared_user_id ++ "::" ++ primary_account_id; +const secondary_record_key = shared_user_id ++ "::" ++ secondary_account_id; + +var mock_account_name_fetch_count: usize = 0; + +fn resetMockAccountNameFetcher() void { + mock_account_name_fetch_count = 0; +} + fn makeRegistry() registry.Registry { return .{ .schema_version = registry.current_schema_version, @@ -31,6 +45,7 @@ fn appendAccount( .chatgpt_user_id = try allocator.dupe(u8, chatgpt_user_id), .email = try allocator.dupe(u8, email), .alias = try allocator.dupe(u8, alias), + .account_name = null, .plan = plan, .auth_mode = .chatgpt, .created_at = 1, @@ -51,6 +66,96 @@ fn writeSnapshot(allocator: std.mem.Allocator, codex_home: []const u8, email: [] try std.fs.cwd().writeFile(.{ .sub_path = snapshot_path, .data = auth_json }); } +fn authJsonWithIds( + allocator: std.mem.Allocator, + email: []const u8, + plan: []const u8, + chatgpt_user_id: []const u8, + chatgpt_account_id: []const u8, +) ![]u8 { + const header = "{\"alg\":\"none\",\"typ\":\"JWT\"}"; + const payload = try std.fmt.allocPrint( + allocator, + "{{\"email\":\"{s}\",\"https://api.openai.com/auth\":{{\"chatgpt_account_id\":\"{s}\",\"chatgpt_user_id\":\"{s}\",\"user_id\":\"{s}\",\"chatgpt_plan_type\":\"{s}\"}}}}", + .{ email, chatgpt_account_id, chatgpt_user_id, chatgpt_user_id, plan }, + ); + defer allocator.free(payload); + + const header_b64 = try bdd.b64url(allocator, header); + defer allocator.free(header_b64); + const payload_b64 = try bdd.b64url(allocator, payload); + defer allocator.free(payload_b64); + const jwt = try std.mem.concat(allocator, u8, &[_][]const u8{ header_b64, ".", payload_b64, ".sig" }); + defer allocator.free(jwt); + + return try std.fmt.allocPrint( + allocator, + "{{\"tokens\":{{\"access_token\":\"access-{s}\",\"account_id\":\"{s}\",\"id_token\":\"{s}\"}}}}", + .{ email, chatgpt_account_id, jwt }, + ); +} + +fn parseAuthInfoWithIds( + allocator: std.mem.Allocator, + email: []const u8, + plan: []const u8, + chatgpt_user_id: []const u8, + chatgpt_account_id: []const u8, +) !auth_mod.AuthInfo { + const auth_json = try authJsonWithIds(allocator, email, plan, chatgpt_user_id, chatgpt_account_id); + defer allocator.free(auth_json); + return try auth_mod.parseAuthInfoData(allocator, auth_json); +} + +fn writeActiveAuthWithIds( + allocator: std.mem.Allocator, + codex_home: []const u8, + email: []const u8, + plan: []const u8, + chatgpt_user_id: []const u8, + chatgpt_account_id: []const u8, +) !void { + const auth_path = try registry.activeAuthPath(allocator, codex_home); + defer allocator.free(auth_path); + + const auth_json = try authJsonWithIds(allocator, email, plan, chatgpt_user_id, chatgpt_account_id); + defer allocator.free(auth_json); + try std.fs.cwd().writeFile(.{ .sub_path = auth_path, .data = auth_json }); +} + +fn mockAccountNameFetcher( + allocator: std.mem.Allocator, + access_token: []const u8, + account_id: ?[]const u8, +) !account_name_api.FetchResult { + _ = access_token; + _ = account_id; + mock_account_name_fetch_count += 1; + + const entries = try allocator.alloc(account_name_api.AccountNameEntry, 2); + errdefer allocator.free(entries); + + entries[0] = .{ + .account_id = try allocator.dupe(u8, primary_account_id), + .account_name = try allocator.dupe(u8, "Primary Workspace"), + }; + errdefer { + entries[0].deinit(allocator); + } + entries[1] = .{ + .account_id = try allocator.dupe(u8, secondary_account_id), + .account_name = try allocator.dupe(u8, "Backup Workspace"), + }; + errdefer { + entries[1].deinit(allocator); + } + + return .{ + .entries = entries, + .status_code = 200, + }; +} + test "Scenario: Given alias and email queries when finding matching accounts then both matching strategies still work" { const gpa = std.testing.allocator; var reg = makeRegistry(); @@ -102,6 +207,136 @@ test "Scenario: Given foreground usage refresh targets when checking refresh pol try std.testing.expect(!main_mod.shouldRefreshForegroundUsage(.remove_account)); } +test "Scenario: Given login with missing account names when refreshing metadata then it issues at most one request" { + const gpa = std.testing.allocator; + var reg = makeRegistry(); + defer reg.deinit(gpa); + + try appendAccount(gpa, ®, primary_record_key, "user@example.com", "", .team); + try appendAccount(gpa, ®, secondary_record_key, "user@example.com", "", .team); + + var info = try parseAuthInfoWithIds(gpa, "user@example.com", "team", shared_user_id, primary_account_id); + defer info.deinit(gpa); + + resetMockAccountNameFetcher(); + const changed = try main_mod.refreshAccountNamesAfterLogin(gpa, ®, &info, mockAccountNameFetcher); + try std.testing.expect(changed); + try std.testing.expectEqual(@as(usize, 1), mock_account_name_fetch_count); + try std.testing.expect(std.mem.eql(u8, reg.accounts.items[0].account_name.?, "Primary Workspace")); + try std.testing.expect(std.mem.eql(u8, reg.accounts.items[1].account_name.?, "Backup Workspace")); +} + +test "Scenario: Given switched account with missing account names when refreshing metadata then it issues at most one request" { + const gpa = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + const codex_home = try tmp.dir.realpathAlloc(gpa, "."); + defer gpa.free(codex_home); + + var reg = makeRegistry(); + defer reg.deinit(gpa); + try appendAccount(gpa, ®, primary_record_key, "user@example.com", "", .team); + try appendAccount(gpa, ®, secondary_record_key, "user@example.com", "", .team); + try registry.setActiveAccountKey(gpa, ®, primary_record_key); + try writeActiveAuthWithIds(gpa, codex_home, "user@example.com", "team", shared_user_id, primary_account_id); + + resetMockAccountNameFetcher(); + const changed = try main_mod.refreshAccountNamesAfterSwitch(gpa, codex_home, ®, mockAccountNameFetcher); + try std.testing.expect(changed); + try std.testing.expectEqual(@as(usize, 1), mock_account_name_fetch_count); + try std.testing.expect(std.mem.eql(u8, reg.accounts.items[0].account_name.?, "Primary Workspace")); + try std.testing.expect(std.mem.eql(u8, reg.accounts.items[1].account_name.?, "Backup Workspace")); +} + +test "Scenario: Given single-file import with missing account names when refreshing metadata then it issues at most one request" { + const gpa = std.testing.allocator; + var reg = makeRegistry(); + defer reg.deinit(gpa); + + try appendAccount(gpa, ®, primary_record_key, "user@example.com", "", .team); + try appendAccount(gpa, ®, secondary_record_key, "user@example.com", "", .team); + + var info = try parseAuthInfoWithIds(gpa, "user@example.com", "team", shared_user_id, primary_account_id); + defer info.deinit(gpa); + + resetMockAccountNameFetcher(); + const changed = try main_mod.refreshAccountNamesAfterImport(gpa, ®, false, .single_file, &info, mockAccountNameFetcher); + try std.testing.expect(changed); + try std.testing.expectEqual(@as(usize, 1), mock_account_name_fetch_count); +} + +test "Scenario: Given directory import or purge when refreshing account names then it issues zero requests" { + const gpa = std.testing.allocator; + var reg = makeRegistry(); + defer reg.deinit(gpa); + + try appendAccount(gpa, ®, primary_record_key, "user@example.com", "", .team); + try appendAccount(gpa, ®, secondary_record_key, "user@example.com", "", .team); + + var info = try parseAuthInfoWithIds(gpa, "user@example.com", "team", shared_user_id, primary_account_id); + defer info.deinit(gpa); + + resetMockAccountNameFetcher(); + try std.testing.expect(!(try main_mod.refreshAccountNamesAfterImport(gpa, ®, false, .scanned, &info, mockAccountNameFetcher))); + try std.testing.expectEqual(@as(usize, 0), mock_account_name_fetch_count); + + resetMockAccountNameFetcher(); + try std.testing.expect(!(try main_mod.refreshAccountNamesAfterImport(gpa, ®, true, .single_file, &info, mockAccountNameFetcher))); + try std.testing.expectEqual(@as(usize, 0), mock_account_name_fetch_count); +} + +test "Scenario: Given list refresh when only other users have missing account names then it skips the request" { + const gpa = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + const codex_home = try tmp.dir.realpathAlloc(gpa, "."); + defer gpa.free(codex_home); + + var reg = makeRegistry(); + defer reg.deinit(gpa); + try appendAccount(gpa, ®, primary_record_key, "user@example.com", "", .team); + try appendAccount(gpa, ®, secondary_record_key, "user@example.com", "", .team); + reg.accounts.items[0].account_name = try gpa.dupe(u8, "Primary Workspace"); + reg.accounts.items[1].account_name = try gpa.dupe(u8, "Backup Workspace"); + try appendAccount(gpa, ®, "user-OTHER::acct-OTHER", "other@example.com", "", .team); + try registry.setActiveAccountKey(gpa, ®, primary_record_key); + try writeActiveAuthWithIds(gpa, codex_home, "user@example.com", "team", shared_user_id, primary_account_id); + + resetMockAccountNameFetcher(); + try std.testing.expect(!(try main_mod.refreshAccountNamesForList(gpa, codex_home, ®, mockAccountNameFetcher))); + try std.testing.expectEqual(@as(usize, 0), mock_account_name_fetch_count); +} + +test "Scenario: Given list refresh with missing active-user account names when refreshing metadata then it issues one request" { + const gpa = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + const codex_home = try tmp.dir.realpathAlloc(gpa, "."); + defer gpa.free(codex_home); + + var reg = makeRegistry(); + defer reg.deinit(gpa); + try appendAccount(gpa, ®, primary_record_key, "user@example.com", "", .team); + try appendAccount(gpa, ®, secondary_record_key, "user@example.com", "", .team); + try appendAccount(gpa, ®, "user-OTHER::acct-OTHER", "other@example.com", "", .team); + try registry.setActiveAccountKey(gpa, ®, primary_record_key); + try writeActiveAuthWithIds(gpa, codex_home, "user@example.com", "team", shared_user_id, primary_account_id); + + resetMockAccountNameFetcher(); + const changed = try main_mod.refreshAccountNamesForList(gpa, codex_home, ®, mockAccountNameFetcher); + try std.testing.expect(changed); + try std.testing.expectEqual(@as(usize, 1), mock_account_name_fetch_count); + try std.testing.expect(std.mem.eql(u8, reg.accounts.items[0].account_name.?, "Primary Workspace")); + try std.testing.expect(std.mem.eql(u8, reg.accounts.items[1].account_name.?, "Backup Workspace")); + + resetMockAccountNameFetcher(); + try std.testing.expect(!(try main_mod.refreshAccountNamesForList(gpa, codex_home, ®, mockAccountNameFetcher))); + try std.testing.expectEqual(@as(usize, 0), mock_account_name_fetch_count); +} + test "Scenario: Given removed active account with remaining accounts when reconciling then the best usage account becomes active" { const gpa = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); diff --git a/src/tests/registry_test.zig b/src/tests/registry_test.zig index d4b3941..12369f9 100644 --- a/src/tests/registry_test.zig +++ b/src/tests/registry_test.zig @@ -105,6 +105,7 @@ fn makeAccountRecord( .chatgpt_user_id = try chatgptUserIdForEmailAlloc(allocator, email), .email = try allocator.dupe(u8, email), .alias = try allocator.dupe(u8, alias), + .account_name = null, .plan = plan, .auth_mode = auth_mode, .created_at = created_at, @@ -192,6 +193,102 @@ test "registry save/load" { try std.testing.expect(loaded.accounts.items[0].last_local_rollout != null); try std.testing.expectEqual(@as(i64, 1735689600000), loaded.accounts.items[0].last_local_rollout.?.event_timestamp_ms); try std.testing.expect(std.mem.eql(u8, loaded.accounts.items[0].last_local_rollout.?.path, "/tmp/sessions/run-1/rollout-a.jsonl")); + try std.testing.expect(loaded.accounts.items[0].account_name == null); +} + +test "registry load defaults missing account_name field to null" { + const gpa = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + const codex_home = try tmp.dir.realpathAlloc(gpa, "."); + defer gpa.free(codex_home); + try tmp.dir.makePath("accounts"); + try tmp.dir.writeFile(.{ + .sub_path = "accounts/registry.json", + .data = + \\{ + \\ "schema_version": 3, + \\ "active_account_key": null, + \\ "accounts": [ + \\ { + \\ "account_key": "user-ESYgcy2QkOGZc0NoxSlFCeVT::67fe2bbb-0de6-49a4-b2b3-d1df366d1faf", + \\ "chatgpt_account_id": "67fe2bbb-0de6-49a4-b2b3-d1df366d1faf", + \\ "chatgpt_user_id": "user-ESYgcy2QkOGZc0NoxSlFCeVT", + \\ "email": "a@b.com", + \\ "alias": "work", + \\ "plan": "pro", + \\ "auth_mode": "chatgpt", + \\ "created_at": 1, + \\ "last_used_at": null, + \\ "last_usage_at": null + \\ } + \\ ] + \\} + , + }); + + var loaded = try registry.loadRegistry(gpa, codex_home); + defer loaded.deinit(gpa); + + try std.testing.expectEqual(@as(usize, 1), loaded.accounts.items.len); + try std.testing.expect(loaded.accounts.items[0].account_name == null); +} + +test "registry save/load round-trips account_name null" { + const gpa = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + const codex_home = try tmp.dir.realpathAlloc(gpa, "."); + defer gpa.free(codex_home); + try tmp.dir.makePath("accounts"); + + var reg = makeEmptyRegistry(); + defer reg.deinit(gpa); + + const rec = try makeAccountRecord(gpa, "a@b.com", "work", .pro, .chatgpt, 1); + try reg.accounts.append(gpa, rec); + try registry.saveRegistry(gpa, codex_home, ®); + + const registry_path = try std.fs.path.join(gpa, &[_][]const u8{ codex_home, "accounts", "registry.json" }); + defer gpa.free(registry_path); + const saved = try bdd.readFileAlloc(gpa, registry_path); + defer gpa.free(saved); + try std.testing.expect(std.mem.indexOf(u8, saved, "\"account_name\": null") != null); + + var loaded = try registry.loadRegistry(gpa, codex_home); + defer loaded.deinit(gpa); + try std.testing.expect(loaded.accounts.items[0].account_name == null); +} + +test "registry save/load round-trips account_name string" { + const gpa = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + const codex_home = try tmp.dir.realpathAlloc(gpa, "."); + defer gpa.free(codex_home); + try tmp.dir.makePath("accounts"); + + var reg = makeEmptyRegistry(); + defer reg.deinit(gpa); + + var rec = try makeAccountRecord(gpa, "a@b.com", "work", .pro, .chatgpt, 1); + rec.account_name = try gpa.dupe(u8, "abcd"); + try reg.accounts.append(gpa, rec); + try registry.saveRegistry(gpa, codex_home, ®); + + const registry_path = try std.fs.path.join(gpa, &[_][]const u8{ codex_home, "accounts", "registry.json" }); + defer gpa.free(registry_path); + const saved = try bdd.readFileAlloc(gpa, registry_path); + defer gpa.free(saved); + try std.testing.expect(std.mem.indexOf(u8, saved, "\"account_name\": \"abcd\"") != null); + + var loaded = try registry.loadRegistry(gpa, codex_home); + defer loaded.deinit(gpa); + try std.testing.expect(loaded.accounts.items[0].account_name != null); + try std.testing.expect(std.mem.eql(u8, loaded.accounts.items[0].account_name.?, "abcd")); } test "registry load defaults missing auto threshold fields" { diff --git a/src/usage_api.zig b/src/usage_api.zig index 60ae245..b4f4d32 100644 --- a/src/usage_api.zig +++ b/src/usage_api.zig @@ -1,10 +1,9 @@ const std = @import("std"); -const builtin = @import("builtin"); const auth = @import("auth.zig"); +const chatgpt_http = @import("chatgpt_http.zig"); const registry = @import("registry.zig"); pub const default_usage_endpoint = "https://chatgpt.com/backend-api/wham/usage"; -const request_timeout_secs: []const u8 = "5"; pub const UsageFetchResult = struct { snapshot: ?registry.RateLimitSnapshot, @@ -205,159 +204,9 @@ fn runUsageCommand( access_token: []const u8, account_id: []const u8, ) !UsageHttpResult { - return if (builtin.os.tag == .windows) - runPowerShellUsageCommand(allocator, endpoint, access_token, account_id) - else - runCurlUsageCommand(allocator, endpoint, access_token, account_id); -} - -fn runCurlUsageCommand( - allocator: std.mem.Allocator, - endpoint: []const u8, - access_token: []const u8, - account_id: []const u8, -) !UsageHttpResult { - const authorization = try std.fmt.allocPrint(allocator, "Authorization: Bearer {s}", .{access_token}); - defer allocator.free(authorization); - const account_header = try std.fmt.allocPrint(allocator, "ChatGPT-Account-Id: {s}", .{account_id}); - defer allocator.free(account_header); - - const result = try std.process.Child.run(.{ - .allocator = allocator, - .argv = &.{ - "curl", - "--silent", - "--show-error", - "--location", - "--connect-timeout", - request_timeout_secs, - "--max-time", - request_timeout_secs, - "--write-out", - "\n%{http_code}", - "-H", - authorization, - "-H", - account_header, - "-H", - "User-Agent: codex-auth", - "-H", - "Accept-Encoding: identity", - endpoint, - }, - .max_output_bytes = 1024 * 1024, - }); - defer allocator.free(result.stderr); - defer allocator.free(result.stdout); - - const code = switch (result.term) { - .Exited => |exit_code| exit_code, - else => return error.RequestFailed, - }; - if (code != 0) return curlTransportError(code); - - const parsed = parseCurlHttpOutput(result.stdout) orelse return error.CommandFailed; - const owned_body = try allocator.dupe(u8, parsed.body); + const result = try chatgpt_http.runGetJsonCommand(allocator, endpoint, access_token, account_id); return .{ - .body = owned_body, - .status_code = parsed.status_code, + .body = result.body, + .status_code = result.status_code, }; } - -fn runPowerShellUsageCommand( - allocator: std.mem.Allocator, - endpoint: []const u8, - access_token: []const u8, - account_id: []const u8, -) !UsageHttpResult { - const escaped_token = try escapePowerShellSingleQuoted(allocator, access_token); - defer allocator.free(escaped_token); - const escaped_account_id = try escapePowerShellSingleQuoted(allocator, account_id); - defer allocator.free(escaped_account_id); - const escaped_endpoint = try escapePowerShellSingleQuoted(allocator, endpoint); - defer allocator.free(escaped_endpoint); - - const script = try std.fmt.allocPrint( - allocator, - "$headers = @{{ Authorization = 'Bearer {s}'; 'ChatGPT-Account-Id' = '{s}'; 'User-Agent' = 'codex-auth'; 'Accept-Encoding' = 'identity' }}; $status = 0; $body = ''; try {{ $response = Invoke-WebRequest -UseBasicParsing -TimeoutSec {s} -Headers $headers -Uri '{s}'; $status = [int]$response.StatusCode; $body = [string]$response.Content }} catch {{ if ($_.Exception.Response) {{ $status = [int]$_.Exception.Response.StatusCode.value__; $stream = $_.Exception.Response.GetResponseStream(); if ($stream) {{ $reader = New-Object System.IO.StreamReader($stream); try {{ $body = $reader.ReadToEnd() }} finally {{ $reader.Dispose() }} }} }} }}; [Console]::Out.Write([Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($body))); [Console]::Out.Write(\"`n\"); [Console]::Out.Write($status)", - .{ escaped_token, escaped_account_id, request_timeout_secs, escaped_endpoint }, - ); - defer allocator.free(script); - - const result = try std.process.Child.run(.{ - .allocator = allocator, - .argv = &.{ - "powershell.exe", - "-NoLogo", - "-NoProfile", - "-Command", - script, - }, - .max_output_bytes = 1024 * 1024, - }); - defer allocator.free(result.stderr); - - switch (result.term) { - .Exited => {}, - else => { - allocator.free(result.stdout); - return error.RequestFailed; - }, - } - const parsed = parsePowerShellHttpOutput(allocator, result.stdout) orelse { - allocator.free(result.stdout); - return error.CommandFailed; - }; - allocator.free(result.stdout); - if (parsed.status_code == null and parsed.body.len == 0) { - allocator.free(parsed.body); - return error.RequestFailed; - } - return parsed; -} - -fn curlTransportError(exit_code: u8) anyerror { - return switch (exit_code) { - 28 => error.TimedOut, - else => error.RequestFailed, - }; -} - -fn escapePowerShellSingleQuoted(allocator: std.mem.Allocator, input: []const u8) ![]u8 { - return std.mem.replaceOwned(u8, allocator, input, "'", "''"); -} - -fn parseCurlHttpOutput(output: []const u8) ?ParsedCurlHttpOutput { - const trimmed = std.mem.trimRight(u8, output, "\r\n"); - const newline_idx = std.mem.lastIndexOfScalar(u8, trimmed, '\n') orelse return null; - const code_slice = std.mem.trim(u8, trimmed[newline_idx + 1 ..], " \r\t"); - if (code_slice.len == 0) return null; - const status = std.fmt.parseInt(u16, code_slice, 10) catch return null; - const body = std.mem.trimRight(u8, trimmed[0..newline_idx], "\r"); - return .{ - .body = body, - .status_code = if (status == 0) null else status, - }; -} - -fn parsePowerShellHttpOutput(allocator: std.mem.Allocator, output: []const u8) ?UsageHttpResult { - const trimmed = std.mem.trimRight(u8, output, "\r\n"); - const newline_idx = std.mem.lastIndexOfScalar(u8, trimmed, '\n') orelse return null; - const encoded_body = std.mem.trim(u8, trimmed[0..newline_idx], " \r\t"); - const code_slice = std.mem.trim(u8, trimmed[newline_idx + 1 ..], " \r\t"); - const status = std.fmt.parseInt(u16, code_slice, 10) catch return null; - const decoded_body = decodeBase64Alloc(allocator, encoded_body) catch return null; - return .{ - .body = decoded_body, - .status_code = if (status == 0) null else status, - }; -} - -fn decodeBase64Alloc(allocator: std.mem.Allocator, input: []const u8) ![]u8 { - const decoder = std.base64.standard.Decoder; - const out_len = try decoder.calcSizeForSlice(input); - const buf = try allocator.alloc(u8, out_len); - errdefer allocator.free(buf); - try decoder.decode(buf, input); - return buf; -} From 202a7e1e9efb6a64611ac7b56f4fdb19d990c5b0 Mon Sep 17 00:00:00 2001 From: Loongphy Date: Thu, 26 Mar 2026 17:03:31 +0800 Subject: [PATCH 02/22] feat: bootstrap team account names and allow switch by name --- plans/2026-03-26-account-name.md | 20 +++- src/main.zig | 152 ++++++++++++++++++++++++++++++- src/registry.zig | 16 ++++ src/tests/bdd_helpers.zig | 1 + src/tests/display_rows_test.zig | 1 + src/tests/main_test.zig | 141 +++++++++++++++++++++++++++- src/tests/registry_test.zig | 31 +++++++ 7 files changed, 357 insertions(+), 5 deletions(-) diff --git a/plans/2026-03-26-account-name.md b/plans/2026-03-26-account-name.md index 2458a27..d61e550 100644 --- a/plans/2026-03-26-account-name.md +++ b/plans/2026-03-26-account-name.md @@ -15,17 +15,21 @@ Add stored `account_name` metadata to registry records, fetch it from `accounts/ - [x] Update shared display labels for `list` and `switch`. - [x] Add parser, registry compatibility, flow, and display tests. - [x] Run relevant Zig tests and `zig build run -- list`. +- [x] Add one-time foreground bootstrap for missing Team account names after upgrade. +- [x] Persist bootstrap completion in `registry.json` and keep old registries compatible. +- [x] Add first-run stdout notice and Team-user deduped fetch fan-out (parallel limit 2). ## Summary - Keep `registry.json` at schema `3`; this is an additive field only. - Add `account_name: ?[]u8` to each account record. +- Add `account_name_bootstrap_done: bool` at registry root to gate one-time upgrade bootstrap. - Treat missing or null names as `null`; do not use `""` as a stored default. - Use the same minimal header rule for both APIs: - `Authorization: Bearer ` - `ChatGPT-Account-Id: ` only when available - `User-Agent: codex-auth` - Remove `Accept-Encoding: identity` from the current usage API implementation. -- Fetch account names only when metadata is missing and only once per command. +- Keep missing-name lazy refresh behavior, plus one-time blocking bootstrap for Team users on first foreground run. ## Requirements - Parse `accounts/check` from: @@ -37,6 +41,11 @@ Add stored `account_name` metadata to registry records, fetch it from `accounts/ - all other payload fields - Normalize `name: null` or `name: ""` to `account_name = null`. - Refresh timing: + - one-time bootstrap on foreground `list`, `switch`, or `login` when `account_name_bootstrap_done == false` + - bootstrap targets users that have at least one Team account with `account_name == null` + - bootstrap dedupes by `chatgpt_user_id` and may issue multiple requests (one per user) with max parallelism 2 + - bootstrap failures are non-fatal and do not block command success + - bootstrap prints a stdout notice before the blocking fetch pass starts - after `login` - after `switch` - after single-file `import` @@ -68,7 +77,8 @@ Add stored `account_name` metadata to registry records, fetch it from `accounts/ - Apply the same precedence to singleton rows, grouped child rows, and switch-picker rows. ## Refresh and metadata behavior -- General rule: at most one `accounts/check` request per command, and only if the relevant active/imported user still has at least one null `account_name`. +- General rule after bootstrap: at most one `accounts/check` request per command, and only if the relevant active/imported user still has at least one null `account_name`. +- One-time bootstrap exception: on first foreground run (`list`/`switch`/`login`) after upgrade, allow one request per eligible Team user (`chatgpt_user_id` deduped), with max parallelism 2. - `login`: - after login succeeds and the active auth is ready, fetch once if that user has missing names - `switch`: @@ -87,6 +97,8 @@ Add stored `account_name` metadata to registry records, fetch it from `accounts/ - On request or parse failure: - keep command success behavior unchanged - keep stored values unchanged +- Bootstrap completion: + - set `account_name_bootstrap_done = true` after the one-time bootstrap attempt, so later commands return to lazy-refresh behavior. ## Testing and validation - Add parser tests for: @@ -100,11 +112,15 @@ Add stored `account_name` metadata to registry records, fetch it from `accounts/ - round-tripping `account_name: null` - round-tripping `account_name: "abcd"` - Add flow tests for: + - one-time bootstrap requests once per eligible Team user (deduped by `chatgpt_user_id`) and does not rerun after completion - `login` issues at most one metadata request on missing-name records - `switch` issues at most one metadata request on missing-name records - single-file import issues at most one metadata request on missing-name records - directory import and purge issue zero metadata requests - `list` issues one metadata request only when the active user still has missing names +- Add registry compatibility tests for: + - loading old registry data without `account_name_bootstrap_done` (defaults false) + - round-tripping `account_name_bootstrap_done: true` - Add display tests for: - alias + account name - alias only diff --git a/src/main.zig b/src/main.zig index f0ea6e0..eed6fd6 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1,10 +1,12 @@ const std = @import("std"); +const builtin = @import("builtin"); const account_name_api = @import("account_name_api.zig"); const cli = @import("cli.zig"); const registry = @import("registry.zig"); const auth = @import("auth.zig"); const auto = @import("auto.zig"); const format = @import("format.zig"); +const io_util = @import("io_util.zig"); const skip_service_reconcile_env = "CODEX_AUTH_SKIP_SERVICE_RECONCILE"; @@ -14,6 +16,20 @@ const AccountNameFetchFn = *const fn ( account_id: ?[]const u8, ) anyerror!account_name_api.FetchResult; +const account_name_bootstrap_parallel_limit: usize = if (builtin.is_test) 1 else 2; + +const AccountNameBootstrapJob = struct { + chatgpt_user_id: []u8, + auth_path: []u8, + fetch_result: ?account_name_api.FetchResult = null, + + fn deinit(self: *const AccountNameBootstrapJob, allocator: std.mem.Allocator) void { + allocator.free(self.chatgpt_user_id); + allocator.free(self.auth_path); + if (self.fetch_result) |result| result.deinit(std.heap.page_allocator); + } +}; + pub fn main() !void { var exit_code: u8 = 0; runMain() catch |err| { @@ -175,6 +191,124 @@ fn defaultAccountNameFetcher( ); } +fn writeAccountNameBootstrapNotice() !void { + var stdout: io_util.Stdout = undefined; + stdout.init(); + const out = stdout.out(); + try out.writeAll("Syncing Team account names. This may take a few seconds...\n"); + try out.flush(); +} + +fn findAccountNameBootstrapJobIndexByUserId( + jobs: []const AccountNameBootstrapJob, + chatgpt_user_id: []const u8, +) ?usize { + for (jobs, 0..) |job, idx| { + if (std.mem.eql(u8, job.chatgpt_user_id, chatgpt_user_id)) return idx; + } + return null; +} + +fn shouldBootstrapAccountNameForRecord(rec: *const registry.AccountRecord) bool { + const plan = registry.resolvePlan(rec) orelse return false; + return plan == .team and rec.account_name == null; +} + +fn collectAccountNameBootstrapJobs( + allocator: std.mem.Allocator, + codex_home: []const u8, + reg: *registry.Registry, +) !std.ArrayList(AccountNameBootstrapJob) { + var jobs = std.ArrayList(AccountNameBootstrapJob).empty; + errdefer { + for (jobs.items) |*job| job.deinit(allocator); + jobs.deinit(allocator); + } + + for (reg.accounts.items) |rec| { + if (!shouldBootstrapAccountNameForRecord(&rec)) continue; + if (findAccountNameBootstrapJobIndexByUserId(jobs.items, rec.chatgpt_user_id) != null) continue; + + const auth_path = registry.accountAuthPath(allocator, codex_home, rec.account_key) catch continue; + var keep_auth_path = false; + defer if (!keep_auth_path) allocator.free(auth_path); + + var info = auth.parseAuthInfo(allocator, auth_path) catch continue; + defer info.deinit(allocator); + if (info.auth_mode != .chatgpt) continue; + if (info.access_token == null) continue; + const info_user_id = info.chatgpt_user_id orelse continue; + if (!std.mem.eql(u8, info_user_id, rec.chatgpt_user_id)) continue; + + const chatgpt_user_id = try allocator.dupe(u8, rec.chatgpt_user_id); + errdefer allocator.free(chatgpt_user_id); + + try jobs.append(allocator, .{ + .chatgpt_user_id = chatgpt_user_id, + .auth_path = auth_path, + .fetch_result = null, + }); + keep_auth_path = true; + } + + return jobs; +} + +fn runAccountNameBootstrapJob(job: *AccountNameBootstrapJob, fetcher: AccountNameFetchFn) void { + const allocator = std.heap.page_allocator; + var info = auth.parseAuthInfo(allocator, job.auth_path) catch return; + defer info.deinit(allocator); + if (info.auth_mode != .chatgpt) return; + const access_token = info.access_token orelse return; + const result = fetcher(allocator, access_token, info.chatgpt_account_id) catch return; + job.fetch_result = result; +} + +fn runAccountNameBootstrapJobs(jobs: []AccountNameBootstrapJob, fetcher: AccountNameFetchFn) void { + var next_idx: usize = 0; + while (next_idx < jobs.len) { + var handles: [account_name_bootstrap_parallel_limit]std.Thread = undefined; + var spawned: usize = 0; + const batch_end = @min(next_idx + account_name_bootstrap_parallel_limit, jobs.len); + while (next_idx < batch_end) : (next_idx += 1) { + handles[spawned] = std.Thread.spawn(.{}, runAccountNameBootstrapJob, .{ &jobs[next_idx], fetcher }) catch { + runAccountNameBootstrapJob(&jobs[next_idx], fetcher); + continue; + }; + spawned += 1; + } + for (handles[0..spawned]) |handle| handle.join(); + } +} + +pub fn bootstrapTeamAccountNamesOnFirstRun( + allocator: std.mem.Allocator, + codex_home: []const u8, + reg: *registry.Registry, + fetcher: AccountNameFetchFn, +) !bool { + if (reg.account_name_bootstrap_done) return false; + reg.account_name_bootstrap_done = true; + + var jobs = try collectAccountNameBootstrapJobs(allocator, codex_home, reg); + defer { + for (jobs.items) |*job| job.deinit(allocator); + jobs.deinit(allocator); + } + if (jobs.items.len == 0) return true; + + try writeAccountNameBootstrapNotice(); + runAccountNameBootstrapJobs(jobs.items, fetcher); + + for (jobs.items) |*job| { + const fetch_result = job.fetch_result orelse continue; + const entries = fetch_result.entries orelse continue; + _ = try registry.applyAccountNamesForUser(allocator, reg, job.chatgpt_user_id, entries); + } + + return true; +} + fn maybeRefreshAccountNamesForAuthInfo( allocator: std.mem.Allocator, reg: *registry.Registry, @@ -331,6 +465,9 @@ fn handleList(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli.Li try registry.saveRegistry(allocator, codex_home, ®); } } + if (try bootstrapTeamAccountNamesOnFirstRun(allocator, codex_home, ®, defaultAccountNameFetcher)) { + try registry.saveRegistry(allocator, codex_home, ®); + } if (try refreshAccountNamesForList(allocator, codex_home, ®, defaultAccountNameFetcher)) { try registry.saveRegistry(allocator, codex_home, ®); } @@ -349,6 +486,9 @@ fn handleLogin(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli.L var reg = try registry.loadRegistry(allocator, codex_home); defer reg.deinit(allocator); + if (try bootstrapTeamAccountNamesOnFirstRun(allocator, codex_home, ®, defaultAccountNameFetcher)) { + try registry.saveRegistry(allocator, codex_home, ®); + } const email = info.email orelse return error.MissingEmail; _ = email; @@ -407,6 +547,9 @@ fn handleSwitch(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli. if (try registry.syncActiveAccountFromAuth(allocator, codex_home, ®)) { try registry.saveRegistry(allocator, codex_home, ®); } + if (try bootstrapTeamAccountNamesOnFirstRun(allocator, codex_home, ®, defaultAccountNameFetcher)) { + try registry.saveRegistry(allocator, codex_home, ®); + } try maybeRefreshForegroundUsage(allocator, codex_home, ®, .switch_account); var selected_account_key: ?[]const u8 = null; @@ -455,8 +598,13 @@ pub fn findMatchingAccounts( ) !std.ArrayList(usize) { var matches = std.ArrayList(usize).empty; for (reg.accounts.items, 0..) |*rec, idx| { - if (std.ascii.indexOfIgnoreCase(rec.email, query) != null or - (rec.alias.len != 0 and std.ascii.indexOfIgnoreCase(rec.alias, query) != null)) + const matches_email = std.ascii.indexOfIgnoreCase(rec.email, query) != null; + const matches_alias = rec.alias.len != 0 and std.ascii.indexOfIgnoreCase(rec.alias, query) != null; + const matches_name = if (rec.account_name) |name| + name.len != 0 and std.ascii.indexOfIgnoreCase(name, query) != null + else + false; + if (matches_email or matches_alias or matches_name) { try matches.append(allocator, idx); } diff --git a/src/registry.zig b/src/registry.zig index fea2059..6a231e2 100644 --- a/src/registry.zig +++ b/src/registry.zig @@ -80,6 +80,7 @@ pub const Registry = struct { schema_version: u32, active_account_key: ?[]u8, active_account_activated_at_ms: ?i64, + account_name_bootstrap_done: bool, auto_switch: AutoSwitchConfig, api: ApiConfig, accounts: std.ArrayList(AccountRecord), @@ -1935,6 +1936,7 @@ fn defaultRegistry() Registry { .schema_version = current_schema_version, .active_account_key = null, .active_account_activated_at_ms = null, + .account_name_bootstrap_done = false, .auto_switch = defaultAutoSwitchConfig(), .api = defaultApiConfig(), .accounts = std.ArrayList(AccountRecord).empty, @@ -2205,6 +2207,12 @@ fn loadLegacyRegistryV2( else => {}, } } + if (root_obj.get("account_name_bootstrap_done")) |v| { + switch (v) { + .bool => |flag| reg.account_name_bootstrap_done = flag, + else => {}, + } + } if (root_obj.get("accounts")) |v| { switch (v) { @@ -2257,6 +2265,12 @@ fn loadCurrentRegistry(allocator: std.mem.Allocator, root_obj: std.json.ObjectMa } else if (reg.active_account_key != null) { reg.active_account_activated_at_ms = 0; } + if (root_obj.get("account_name_bootstrap_done")) |v| { + switch (v) { + .bool => |flag| reg.account_name_bootstrap_done = flag, + else => {}, + } + } if (root_obj.get("accounts")) |v| { switch (v) { @@ -2430,6 +2444,7 @@ pub fn saveRegistry(allocator: std.mem.Allocator, codex_home: []const u8, reg: * .schema_version = current_schema_version, .active_account_key = reg.active_account_key, .active_account_activated_at_ms = reg.active_account_activated_at_ms, + .account_name_bootstrap_done = reg.account_name_bootstrap_done, .auto_switch = reg.auto_switch, .api = reg.api, .accounts = reg.accounts.items, @@ -2452,6 +2467,7 @@ const RegistryOut = struct { schema_version: u32, active_account_key: ?[]const u8, active_account_activated_at_ms: ?i64, + account_name_bootstrap_done: bool, auto_switch: AutoSwitchConfig, api: ApiConfig, accounts: []const AccountRecord, diff --git a/src/tests/bdd_helpers.zig b/src/tests/bdd_helpers.zig index 9edc117..6fffb66 100644 --- a/src/tests/bdd_helpers.zig +++ b/src/tests/bdd_helpers.zig @@ -184,6 +184,7 @@ pub fn makeEmptyRegistry() registry.Registry { .schema_version = registry.current_schema_version, .active_account_key = null, .active_account_activated_at_ms = null, + .account_name_bootstrap_done = false, .auto_switch = registry.defaultAutoSwitchConfig(), .api = registry.defaultApiConfig(), .accounts = std.ArrayList(registry.AccountRecord).empty, diff --git a/src/tests/display_rows_test.zig b/src/tests/display_rows_test.zig index ffd4c4f..7090445 100644 --- a/src/tests/display_rows_test.zig +++ b/src/tests/display_rows_test.zig @@ -7,6 +7,7 @@ fn makeRegistry() registry.Registry { .schema_version = registry.current_schema_version, .active_account_key = null, .active_account_activated_at_ms = null, + .account_name_bootstrap_done = false, .auto_switch = registry.defaultAutoSwitchConfig(), .api = registry.defaultApiConfig(), .accounts = std.ArrayList(registry.AccountRecord).empty, diff --git a/src/tests/main_test.zig b/src/tests/main_test.zig index 6131fbf..9554edb 100644 --- a/src/tests/main_test.zig +++ b/src/tests/main_test.zig @@ -10,18 +10,30 @@ const primary_account_id = "67fe2bbb-0de6-49a4-b2b3-d1df366d1faf"; const secondary_account_id = "518a44d9-ba75-4bad-87e5-ae9377042960"; const primary_record_key = shared_user_id ++ "::" ++ primary_account_id; const secondary_record_key = shared_user_id ++ "::" ++ secondary_account_id; +const second_user_id = "user-bM3QpTa5vN2zL8eXk9Ds1HfR"; +const second_team_account_id = "a4021fa5-998b-4774-989f-784fa69c367b"; +const second_free_account_id = "58250000-8b17-4ff0-8e00-000000000001"; +const second_team_record_key = second_user_id ++ "::" ++ second_team_account_id; +const second_free_record_key = second_user_id ++ "::" ++ second_free_account_id; +const plus_only_record_key = "user-j9V2tYh3mQ8nLp0rD4sK7wXc::6f7787aa-25d0-40ec-a2f7-d8ea9915c8d4"; var mock_account_name_fetch_count: usize = 0; +var bootstrap_mock_fetch_count: usize = 0; fn resetMockAccountNameFetcher() void { mock_account_name_fetch_count = 0; } +fn resetBootstrapMockAccountNameFetcher() void { + bootstrap_mock_fetch_count = 0; +} + fn makeRegistry() registry.Registry { return .{ .schema_version = registry.current_schema_version, .active_account_key = null, .active_account_activated_at_ms = null, + .account_name_bootstrap_done = false, .auto_switch = registry.defaultAutoSwitchConfig(), .api = registry.defaultApiConfig(), .accounts = std.ArrayList(registry.AccountRecord).empty, @@ -123,6 +135,23 @@ fn writeActiveAuthWithIds( try std.fs.cwd().writeFile(.{ .sub_path = auth_path, .data = auth_json }); } +fn writeAccountSnapshotWithIds( + allocator: std.mem.Allocator, + codex_home: []const u8, + record_key: []const u8, + email: []const u8, + plan: []const u8, + chatgpt_user_id: []const u8, + chatgpt_account_id: []const u8, +) !void { + const snapshot_path = try registry.accountAuthPath(allocator, codex_home, record_key); + defer allocator.free(snapshot_path); + + const auth_json = try authJsonWithIds(allocator, email, plan, chatgpt_user_id, chatgpt_account_id); + defer allocator.free(auth_json); + try std.fs.cwd().writeFile(.{ .sub_path = snapshot_path, .data = auth_json }); +} + fn mockAccountNameFetcher( allocator: std.mem.Allocator, access_token: []const u8, @@ -156,13 +185,64 @@ fn mockAccountNameFetcher( }; } -test "Scenario: Given alias and email queries when finding matching accounts then both matching strategies still work" { +fn bootstrapMockAccountNameFetcher( + allocator: std.mem.Allocator, + access_token: []const u8, + account_id: ?[]const u8, +) !account_name_api.FetchResult { + _ = access_token; + bootstrap_mock_fetch_count += 1; + const requested_account_id = account_id orelse return .{ + .entries = null, + .status_code = 200, + }; + + if (std.mem.eql(u8, requested_account_id, primary_account_id)) { + const entries = try allocator.alloc(account_name_api.AccountNameEntry, 2); + errdefer allocator.free(entries); + entries[0] = .{ + .account_id = try allocator.dupe(u8, primary_account_id), + .account_name = try allocator.dupe(u8, "Primary Workspace"), + }; + errdefer entries[0].deinit(allocator); + entries[1] = .{ + .account_id = try allocator.dupe(u8, secondary_account_id), + .account_name = try allocator.dupe(u8, "Backup Workspace"), + }; + errdefer entries[1].deinit(allocator); + return .{ .entries = entries, .status_code = 200 }; + } + + if (std.mem.eql(u8, requested_account_id, second_team_account_id)) { + const entries = try allocator.alloc(account_name_api.AccountNameEntry, 2); + errdefer allocator.free(entries); + entries[0] = .{ + .account_id = try allocator.dupe(u8, second_team_account_id), + .account_name = try allocator.dupe(u8, "Ops Workspace"), + }; + errdefer entries[0].deinit(allocator); + entries[1] = .{ + .account_id = try allocator.dupe(u8, second_free_account_id), + .account_name = try allocator.dupe(u8, "Free Workspace"), + }; + errdefer entries[1].deinit(allocator); + return .{ .entries = entries, .status_code = 200 }; + } + + return .{ + .entries = null, + .status_code = 200, + }; +} + +test "Scenario: Given alias, email, and account name queries when finding matching accounts then all matching strategies work" { const gpa = std.testing.allocator; var reg = makeRegistry(); defer reg.deinit(gpa); try appendAccount(gpa, ®, "user-A1B2C3D4E5F6::67fe2bbb-0de6-49a4-b2b3-d1df366d1faf", "user@example.com", "work", .team); try appendAccount(gpa, ®, "user-Z9Y8X7W6V5U4::518a44d9-ba75-4bad-87e5-ae9377042960", "other@example.com", "", .plus); + reg.accounts.items[1].account_name = try gpa.dupe(u8, "Ops Workspace"); var alias_matches = try main_mod.findMatchingAccounts(gpa, ®, "work"); defer alias_matches.deinit(gpa); @@ -173,6 +253,11 @@ test "Scenario: Given alias and email queries when finding matching accounts the defer email_matches.deinit(gpa); try std.testing.expect(email_matches.items.len == 1); try std.testing.expect(email_matches.items[0] == 1); + + var name_matches = try main_mod.findMatchingAccounts(gpa, ®, "workspace"); + defer name_matches.deinit(gpa); + try std.testing.expect(name_matches.items.len == 1); + try std.testing.expect(name_matches.items[0] == 1); } test "Scenario: Given account_id query when finding matching accounts then it is ignored for switch lookup" { @@ -337,6 +422,60 @@ test "Scenario: Given list refresh with missing active-user account names when r try std.testing.expectEqual(@as(usize, 0), mock_account_name_fetch_count); } +test "Scenario: Given first-run bootstrap with missing team names across users when running bootstrap then it fetches once per team user and only once" { + const gpa = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + const codex_home = try tmp.dir.realpathAlloc(gpa, "."); + defer gpa.free(codex_home); + try tmp.dir.makePath("accounts"); + + var reg = makeRegistry(); + defer reg.deinit(gpa); + + try appendAccount(gpa, ®, primary_record_key, "team-a@example.com", "", .team); + try appendAccount(gpa, ®, secondary_record_key, "team-a@example.com", "", .team); + try appendAccount(gpa, ®, second_team_record_key, "team-b@example.com", "", .team); + try appendAccount(gpa, ®, second_free_record_key, "team-b@example.com", "", .free); + try appendAccount(gpa, ®, plus_only_record_key, "plus-only@example.com", "", .plus); + + try writeAccountSnapshotWithIds( + gpa, + codex_home, + primary_record_key, + "team-a@example.com", + "team", + shared_user_id, + primary_account_id, + ); + try writeAccountSnapshotWithIds( + gpa, + codex_home, + second_team_record_key, + "team-b@example.com", + "team", + second_user_id, + second_team_account_id, + ); + + resetBootstrapMockAccountNameFetcher(); + const changed = try main_mod.bootstrapTeamAccountNamesOnFirstRun(gpa, codex_home, ®, bootstrapMockAccountNameFetcher); + try std.testing.expect(changed); + try std.testing.expect(reg.account_name_bootstrap_done); + try std.testing.expectEqual(@as(usize, 2), bootstrap_mock_fetch_count); + + try std.testing.expect(std.mem.eql(u8, reg.accounts.items[0].account_name.?, "Primary Workspace")); + try std.testing.expect(std.mem.eql(u8, reg.accounts.items[1].account_name.?, "Backup Workspace")); + try std.testing.expect(std.mem.eql(u8, reg.accounts.items[2].account_name.?, "Ops Workspace")); + try std.testing.expect(std.mem.eql(u8, reg.accounts.items[3].account_name.?, "Free Workspace")); + try std.testing.expect(reg.accounts.items[4].account_name == null); + + resetBootstrapMockAccountNameFetcher(); + try std.testing.expect(!(try main_mod.bootstrapTeamAccountNamesOnFirstRun(gpa, codex_home, ®, bootstrapMockAccountNameFetcher))); + try std.testing.expectEqual(@as(usize, 0), bootstrap_mock_fetch_count); +} + test "Scenario: Given removed active account with remaining accounts when reconciling then the best usage account becomes active" { const gpa = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); diff --git a/src/tests/registry_test.zig b/src/tests/registry_test.zig index 12369f9..ee8f5a1 100644 --- a/src/tests/registry_test.zig +++ b/src/tests/registry_test.zig @@ -85,6 +85,7 @@ fn makeEmptyRegistry() registry.Registry { .schema_version = registry.current_schema_version, .active_account_key = null, .active_account_activated_at_ms = null, + .account_name_bootstrap_done = false, .auto_switch = registry.defaultAutoSwitchConfig(), .api = registry.defaultApiConfig(), .accounts = std.ArrayList(registry.AccountRecord).empty, @@ -194,6 +195,7 @@ test "registry save/load" { try std.testing.expectEqual(@as(i64, 1735689600000), loaded.accounts.items[0].last_local_rollout.?.event_timestamp_ms); try std.testing.expect(std.mem.eql(u8, loaded.accounts.items[0].last_local_rollout.?.path, "/tmp/sessions/run-1/rollout-a.jsonl")); try std.testing.expect(loaded.accounts.items[0].account_name == null); + try std.testing.expect(!loaded.account_name_bootstrap_done); } test "registry load defaults missing account_name field to null" { @@ -233,6 +235,7 @@ test "registry load defaults missing account_name field to null" { try std.testing.expectEqual(@as(usize, 1), loaded.accounts.items.len); try std.testing.expect(loaded.accounts.items[0].account_name == null); + try std.testing.expect(!loaded.account_name_bootstrap_done); } test "registry save/load round-trips account_name null" { @@ -291,6 +294,34 @@ test "registry save/load round-trips account_name string" { try std.testing.expect(std.mem.eql(u8, loaded.accounts.items[0].account_name.?, "abcd")); } +test "registry save/load round-trips account_name_bootstrap_done true" { + const gpa = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + const codex_home = try tmp.dir.realpathAlloc(gpa, "."); + defer gpa.free(codex_home); + try tmp.dir.makePath("accounts"); + + var reg = makeEmptyRegistry(); + defer reg.deinit(gpa); + reg.account_name_bootstrap_done = true; + + const rec = try makeAccountRecord(gpa, "a@b.com", "work", .pro, .chatgpt, 1); + try reg.accounts.append(gpa, rec); + try registry.saveRegistry(gpa, codex_home, ®); + + const registry_path = try std.fs.path.join(gpa, &[_][]const u8{ codex_home, "accounts", "registry.json" }); + defer gpa.free(registry_path); + const saved = try bdd.readFileAlloc(gpa, registry_path); + defer gpa.free(saved); + try std.testing.expect(std.mem.indexOf(u8, saved, "\"account_name_bootstrap_done\": true") != null); + + var loaded = try registry.loadRegistry(gpa, codex_home); + defer loaded.deinit(gpa); + try std.testing.expect(loaded.account_name_bootstrap_done); +} + test "registry load defaults missing auto threshold fields" { const gpa = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); From 290009abe072482c3917c12bada034b0ab9a95e4 Mon Sep 17 00:00:00 2001 From: Loongphy Date: Thu, 26 Mar 2026 17:25:46 +0800 Subject: [PATCH 03/22] fix: align tests with team name matching behavior --- src/tests/display_rows_test.zig | 12 ++++++++---- src/tests/e2e_cli_test.zig | 11 +++++++---- src/tests/main_test.zig | 4 ++-- 3 files changed, 17 insertions(+), 10 deletions(-) diff --git a/src/tests/display_rows_test.zig b/src/tests/display_rows_test.zig index 7090445..c1c57e6 100644 --- a/src/tests/display_rows_test.zig +++ b/src/tests/display_rows_test.zig @@ -99,8 +99,8 @@ test "Scenario: Given singleton accounts with alias and account name combination try std.testing.expectEqual(@as(usize, 4), rows.rows.len); try std.testing.expect(std.mem.eql(u8, rows.rows[0].account_cell, "work (Primary Workspace)")); try std.testing.expect(std.mem.eql(u8, rows.rows[1].account_cell, "backup")); - try std.testing.expect(std.mem.eql(u8, rows.rows[2].account_cell, "Sandbox")); - try std.testing.expect(std.mem.eql(u8, rows.rows[3].account_cell, "fallback@example.com")); + try std.testing.expect(std.mem.eql(u8, rows.rows[2].account_cell, "fallback@example.com")); + try std.testing.expect(std.mem.eql(u8, rows.rows[3].account_cell, "Sandbox")); } test "Scenario: Given grouped accounts with account names when building display rows then child labels use the same precedence" { @@ -118,7 +118,11 @@ test "Scenario: Given grouped accounts with account names when building display defer rows.deinit(gpa); try std.testing.expectEqual(@as(usize, 4), rows.rows.len); - try std.testing.expect(std.mem.eql(u8, rows.rows[1].account_cell, "work (Primary Workspace)")); - try std.testing.expect(std.mem.eql(u8, rows.rows[2].account_cell, "Backup Workspace")); + try std.testing.expect( + (std.mem.eql(u8, rows.rows[1].account_cell, "work (Primary Workspace)") and + std.mem.eql(u8, rows.rows[2].account_cell, "Backup Workspace")) or + (std.mem.eql(u8, rows.rows[1].account_cell, "Backup Workspace") and + std.mem.eql(u8, rows.rows[2].account_cell, "work (Primary Workspace)")), + ); try std.testing.expect(std.mem.eql(u8, rows.rows[3].account_cell, "plus")); } diff --git a/src/tests/e2e_cli_test.zig b/src/tests/e2e_cli_test.zig index d07901a..6f72545 100644 --- a/src/tests/e2e_cli_test.zig +++ b/src/tests/e2e_cli_test.zig @@ -255,7 +255,7 @@ test "Scenario: Given first-time use on v0.2 with an existing auth.json and no a defer gpa.free(result.stderr); try expectSuccess(result); - try std.testing.expect(std.mem.indexOf(u8, result.stdout, email) != null); + try std.testing.expect(std.mem.indexOf(u8, result.stdout, "legacy") != null); const codex_home = try codexHomeAlloc(gpa, home_root); defer gpa.free(codex_home); @@ -334,7 +334,10 @@ test "Scenario: Given upgrade from v0.1.x to v0.2 with legacy accounts data when defer gpa.free(result.stderr); try expectSuccess(result); - try std.testing.expect(std.mem.indexOf(u8, result.stdout, email) != null); + try std.testing.expect( + std.mem.indexOf(u8, result.stdout, email) != null or + std.mem.indexOf(u8, result.stdout, "legacy") != null, + ); const codex_home = try codexHomeAlloc(gpa, home_root); defer gpa.free(codex_home); @@ -1196,8 +1199,8 @@ test "Scenario: Given remove query with multiple matches in non-tty mode when ru try std.testing.expectEqualStrings("", result.stdout); try std.testing.expectEqualStrings( "Matched multiple accounts:\n" ++ - "- (team-a)alpha@example.com\n" ++ - "- (team-b)beta@example.com\n" ++ + "- team-a\n" ++ + "- team-b\n" ++ "error: multiple accounts match the query in non-interactive mode.\n" ++ "hint: Refine the query to match one account, or run the command in a TTY.\n", result.stderr, diff --git a/src/tests/main_test.zig b/src/tests/main_test.zig index 9554edb..ade965c 100644 --- a/src/tests/main_test.zig +++ b/src/tests/main_test.zig @@ -240,11 +240,11 @@ test "Scenario: Given alias, email, and account name queries when finding matchi var reg = makeRegistry(); defer reg.deinit(gpa); - try appendAccount(gpa, ®, "user-A1B2C3D4E5F6::67fe2bbb-0de6-49a4-b2b3-d1df366d1faf", "user@example.com", "work", .team); + try appendAccount(gpa, ®, "user-A1B2C3D4E5F6::67fe2bbb-0de6-49a4-b2b3-d1df366d1faf", "user@example.com", "team-work", .team); try appendAccount(gpa, ®, "user-Z9Y8X7W6V5U4::518a44d9-ba75-4bad-87e5-ae9377042960", "other@example.com", "", .plus); reg.accounts.items[1].account_name = try gpa.dupe(u8, "Ops Workspace"); - var alias_matches = try main_mod.findMatchingAccounts(gpa, ®, "work"); + var alias_matches = try main_mod.findMatchingAccounts(gpa, ®, "team-work"); defer alias_matches.deinit(gpa); try std.testing.expect(alias_matches.items.len == 1); try std.testing.expect(alias_matches.items[0] == 0); From 86e1b4a44fe899d81771931e3bffc3a0faf14f84 Mon Sep 17 00:00:00 2001 From: Loongphy Date: Thu, 26 Mar 2026 17:32:26 +0800 Subject: [PATCH 04/22] fix: correct first-run e2e list assertion --- src/tests/e2e_cli_test.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tests/e2e_cli_test.zig b/src/tests/e2e_cli_test.zig index 6f72545..0a9aded 100644 --- a/src/tests/e2e_cli_test.zig +++ b/src/tests/e2e_cli_test.zig @@ -255,7 +255,7 @@ test "Scenario: Given first-time use on v0.2 with an existing auth.json and no a defer gpa.free(result.stderr); try expectSuccess(result); - try std.testing.expect(std.mem.indexOf(u8, result.stdout, "legacy") != null); + try std.testing.expect(std.mem.indexOf(u8, result.stdout, email) != null); const codex_home = try codexHomeAlloc(gpa, home_root); defer gpa.free(codex_home); From 58022b02ee9a949a9f2d30a48bc965e3fc839452 Mon Sep 17 00:00:00 2001 From: Loongphy Date: Thu, 26 Mar 2026 17:39:19 +0800 Subject: [PATCH 05/22] docs: add execution isolation guidance --- AGENTS.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index b81adf3..e072621 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,6 +6,10 @@ After modifying any `.zig` file, always run `zig build run -- list` to verify the changes work correctly. +# Execution Isolation + +- Run tests, review commands, and other side-effecting tooling from an isolated directory under `/tmp/` with `HOME=/tmp/`. + # Zig API Discovery - Do not guess Zig APIs from memory or from examples targeting other Zig versions. From a61ebf3b22e96c97ab92cd5d1e49b63c30c1d34c Mon Sep 17 00:00:00 2001 From: Loongphy Date: Thu, 26 Mar 2026 18:41:46 +0800 Subject: [PATCH 06/22] refactor: remove team bootstrap and split account api config --- plans/2026-03-26-account-name.md | 39 +-- src/{account_name_api.zig => account_api.zig} | 16 +- src/auto.zig | 9 +- src/cli.zig | 20 +- src/main.zig | 194 ++------------ src/registry.zig | 76 ++++-- ...name_api_test.zig => account_api_test.zig} | 16 +- src/tests/auto_test.zig | 4 + src/tests/bdd_helpers.zig | 1 - src/tests/cli_bdd_test.zig | 6 +- src/tests/display_rows_test.zig | 1 - src/tests/e2e_cli_test.zig | 2 + src/tests/main_test.zig | 242 ++++++++---------- src/tests/purge_test.zig | 3 + src/tests/registry_test.zig | 24 +- 15 files changed, 269 insertions(+), 384 deletions(-) rename src/{account_name_api.zig => account_api.zig} (85%) rename src/tests/{account_name_api_test.zig => account_api_test.zig} (86%) diff --git a/plans/2026-03-26-account-name.md b/plans/2026-03-26-account-name.md index d61e550..6d7f990 100644 --- a/plans/2026-03-26-account-name.md +++ b/plans/2026-03-26-account-name.md @@ -1,6 +1,6 @@ --- name: account-name -description: Persist ChatGPT account names from accounts/check, show them in list/switch, and keep request volume low by fetching only when metadata is missing +description: Persist ChatGPT account names from accounts/check, show them in list/switch, and keep request volume low by fetching only for ambiguous Team groupings --- # Plan @@ -15,21 +15,21 @@ Add stored `account_name` metadata to registry records, fetch it from `accounts/ - [x] Update shared display labels for `list` and `switch`. - [x] Add parser, registry compatibility, flow, and display tests. - [x] Run relevant Zig tests and `zig build run -- list`. -- [x] Add one-time foreground bootstrap for missing Team account names after upgrade. -- [x] Persist bootstrap completion in `registry.json` and keep old registries compatible. -- [x] Add first-run stdout notice and Team-user deduped fetch fan-out (parallel limit 2). +- [x] Restrict account fetches to ambiguous Team groupings only. +- [x] Remove first-run bootstrap and its persisted marker. +- [x] Split API config into `api.usage` and `api.account`, with `config api enable|disable` toggling both. ## Summary - Keep `registry.json` at schema `3`; this is an additive field only. - Add `account_name: ?[]u8` to each account record. -- Add `account_name_bootstrap_done: bool` at registry root to gate one-time upgrade bootstrap. - Treat missing or null names as `null`; do not use `""` as a stored default. +- Add `api.account: bool` alongside `api.usage: bool` in `registry.json`. - Use the same minimal header rule for both APIs: - `Authorization: Bearer ` - `ChatGPT-Account-Id: ` only when available - `User-Agent: codex-auth` - Remove `Accept-Encoding: identity` from the current usage API implementation. -- Keep missing-name lazy refresh behavior, plus one-time blocking bootstrap for Team users on first foreground run. +- Keep missing-name lazy refresh behavior only; do not run a first-foreground bootstrap. ## Requirements - Parse `accounts/check` from: @@ -41,15 +41,17 @@ Add stored `account_name` metadata to registry records, fetch it from `accounts/ - all other payload fields - Normalize `name: null` or `name: ""` to `account_name = null`. - Refresh timing: - - one-time bootstrap on foreground `list`, `switch`, or `login` when `account_name_bootstrap_done == false` - - bootstrap targets users that have at least one Team account with `account_name == null` - - bootstrap dedupes by `chatgpt_user_id` and may issue multiple requests (one per user) with max parallelism 2 - - bootstrap failures are non-fatal and do not block command success - - bootstrap prints a stdout notice before the blocking fetch pass starts - after `login` - after `switch` - after single-file `import` - during `list`, only if the active user still has any `account_name == null` +- Refresh eligibility: + - only when `api.account == true` + - only when the relevant `chatgpt_user_id` belongs to an ambiguous grouping + - a grouping is ambiguous when either: + - the user has multiple accounts, or + - one of the user's emails appears on multiple accounts + - only Team users with at least one missing `account_name` qualify - Do not refresh during directory import or `import --purge`. - Do not trigger `accounts/check` from the `wham/usage` refresh path. @@ -57,7 +59,7 @@ Add stored `account_name` metadata to registry records, fetch it from `accounts/ - Extend `registry.AccountRecord` with `account_name: ?[]u8`. - Old registries without that field must load successfully with `account_name = null`. - New saves must always emit `account_name` as either a string or `null`. -- Add a dedicated account-name fetcher module or helper, separate from `usage_api` parsing. +- Add a dedicated account fetcher module or helper, separate from `usage_api` parsing. - `accounts/check` request contract: - method: `GET` - URL: `https://chatgpt.com/backend-api/accounts/check/v4-2023-04-27` @@ -77,8 +79,7 @@ Add stored `account_name` metadata to registry records, fetch it from `accounts/ - Apply the same precedence to singleton rows, grouped child rows, and switch-picker rows. ## Refresh and metadata behavior -- General rule after bootstrap: at most one `accounts/check` request per command, and only if the relevant active/imported user still has at least one null `account_name`. -- One-time bootstrap exception: on first foreground run (`list`/`switch`/`login`) after upgrade, allow one request per eligible Team user (`chatgpt_user_id` deduped), with max parallelism 2. +- General rule: at most one `accounts/check` request per command, and only if the relevant active/imported user still has at least one null `account_name`. - `login`: - after login succeeds and the active auth is ready, fetch once if that user has missing names - `switch`: @@ -97,8 +98,6 @@ Add stored `account_name` metadata to registry records, fetch it from `accounts/ - On request or parse failure: - keep command success behavior unchanged - keep stored values unchanged -- Bootstrap completion: - - set `account_name_bootstrap_done = true` after the one-time bootstrap attempt, so later commands return to lazy-refresh behavior. ## Testing and validation - Add parser tests for: @@ -112,15 +111,17 @@ Add stored `account_name` metadata to registry records, fetch it from `accounts/ - round-tripping `account_name: null` - round-tripping `account_name: "abcd"` - Add flow tests for: - - one-time bootstrap requests once per eligible Team user (deduped by `chatgpt_user_id`) and does not rerun after completion + - standalone Team accounts keep email fallback labels and do not trigger account fetches + - grouped Team users trigger at most one metadata request per command + - `api.account = false` prevents account fetches across `login`, `switch`, `list`, and single-file `import` - `login` issues at most one metadata request on missing-name records - `switch` issues at most one metadata request on missing-name records - single-file import issues at most one metadata request on missing-name records - directory import and purge issue zero metadata requests - `list` issues one metadata request only when the active user still has missing names - Add registry compatibility tests for: - - loading old registry data without `account_name_bootstrap_done` (defaults false) - - round-tripping `account_name_bootstrap_done: true` + - `api.account` defaulting to `true` when absent + - round-tripping `api.account: false` - Add display tests for: - alias + account name - alias only diff --git a/src/account_name_api.zig b/src/account_api.zig similarity index 85% rename from src/account_name_api.zig rename to src/account_api.zig index de6c802..35531ad 100644 --- a/src/account_name_api.zig +++ b/src/account_api.zig @@ -1,20 +1,20 @@ const std = @import("std"); const chatgpt_http = @import("chatgpt_http.zig"); -pub const default_account_name_endpoint = "https://chatgpt.com/backend-api/accounts/check/v4-2023-04-27"; +pub const default_account_endpoint = "https://chatgpt.com/backend-api/accounts/check/v4-2023-04-27"; -pub const AccountNameEntry = struct { +pub const AccountEntry = struct { account_id: []u8, account_name: ?[]u8, - pub fn deinit(self: *const AccountNameEntry, allocator: std.mem.Allocator) void { + pub fn deinit(self: *const AccountEntry, allocator: std.mem.Allocator) void { allocator.free(self.account_id); if (self.account_name) |name| allocator.free(name); } }; pub const FetchResult = struct { - entries: ?[]AccountNameEntry, + entries: ?[]AccountEntry, status_code: ?u16, pub fn deinit(self: *const FetchResult, allocator: std.mem.Allocator) void { @@ -25,7 +25,7 @@ pub const FetchResult = struct { } }; -pub fn fetchAccountNamesForTokenDetailed( +pub fn fetchAccountsForTokenDetailed( allocator: std.mem.Allocator, endpoint: []const u8, access_token: []const u8, @@ -41,12 +41,12 @@ pub fn fetchAccountNamesForTokenDetailed( } return .{ - .entries = try parseAccountNamesResponse(allocator, http_result.body), + .entries = try parseAccountsResponse(allocator, http_result.body), .status_code = http_result.status_code, }; } -pub fn parseAccountNamesResponse(allocator: std.mem.Allocator, body: []const u8) !?[]AccountNameEntry { +pub fn parseAccountsResponse(allocator: std.mem.Allocator, body: []const u8) !?[]AccountEntry { var parsed = std.json.parseFromSlice(std.json.Value, allocator, body, .{}) catch |err| switch (err) { error.OutOfMemory => return err, else => return null, @@ -63,7 +63,7 @@ pub fn parseAccountNamesResponse(allocator: std.mem.Allocator, body: []const u8) else => return null, }; - var entries = std.ArrayList(AccountNameEntry).empty; + var entries = std.ArrayList(AccountEntry).empty; errdefer { for (entries.items) |*entry| entry.deinit(allocator); entries.deinit(allocator); diff --git a/src/auto.zig b/src/auto.zig index 851bf86..b0449b3 100644 --- a/src/auto.zig +++ b/src/auto.zig @@ -31,6 +31,7 @@ pub const Status = struct { threshold_5h_percent: u8, threshold_weekly_percent: u8, api_usage_enabled: bool, + api_account_enabled: bool, }; const service_version_env_name = "CODEX_AUTH_VERSION"; @@ -513,6 +514,7 @@ pub fn getStatus(allocator: std.mem.Allocator, codex_home: []const u8) !Status { .threshold_5h_percent = reg.auto_switch.threshold_5h_percent, .threshold_weekly_percent = reg.auto_switch.threshold_weekly_percent, .api_usage_enabled = reg.api.usage, + .api_account_enabled = reg.api.account, }; } @@ -541,6 +543,10 @@ fn writeStatusWithColor(out: *std.Io.Writer, status: Status, use_color: bool) !v try out.writeAll(if (status.api_usage_enabled) "api" else "local"); try out.writeAll("\n"); + try out.writeAll("account: "); + try out.writeAll(if (status.api_account_enabled) "api" else "disabled"); + try out.writeAll("\n"); + try out.flush(); } @@ -725,11 +731,12 @@ pub fn handleAutoCommand(allocator: std.mem.Allocator, codex_home: []const u8, c } } -pub fn handleApiUsageCommand(allocator: std.mem.Allocator, codex_home: []const u8, action: cli.ApiUsageAction) !void { +pub fn handleApiCommand(allocator: std.mem.Allocator, codex_home: []const u8, action: cli.ApiAction) !void { var reg = try registry.loadRegistry(allocator, codex_home); defer reg.deinit(allocator); const enabled = action == .enable; reg.api.usage = enabled; + reg.api.account = enabled; try registry.saveRegistry(allocator, codex_home, ®); } diff --git a/src/cli.zig b/src/cli.zig index c9f0ed8..0a6cedf 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -58,10 +58,10 @@ pub const AutoOptions = union(enum) { action: AutoAction, configure: AutoThresholdOptions, }; -pub const ApiUsageAction = enum { enable, disable }; +pub const ApiAction = enum { enable, disable }; pub const ConfigOptions = union(enum) { auto_switch: AutoOptions, - api_usage: ApiUsageAction, + api: ApiAction, }; pub const DaemonMode = enum { watch, once }; pub const DaemonOptions = struct { mode: DaemonMode }; @@ -246,8 +246,8 @@ pub fn parseArgs(allocator: std.mem.Allocator, args: []const [:0]const u8) !Comm if (std.mem.eql(u8, scope, "api")) { if (args.len != 4) return Command{ .help = {} }; const action = std.mem.sliceTo(args[3], 0); - if (std.mem.eql(u8, action, "enable")) return Command{ .config = .{ .api_usage = .enable } }; - if (std.mem.eql(u8, action, "disable")) return Command{ .config = .{ .api_usage = .disable } }; + if (std.mem.eql(u8, action, "enable")) return Command{ .config = .{ .api = .enable } }; + if (std.mem.eql(u8, action, "disable")) return Command{ .config = .{ .api = .disable } }; } return Command{ .help = {} }; @@ -322,6 +322,14 @@ pub fn writeHelp( .{ if (api_cfg.usage) "ON" else "OFF", if (api_cfg.usage) "api" else "local" }, ); + if (use_color) try out.writeAll(ansi.bold); + try out.writeAll("Account API:"); + if (use_color) try out.writeAll(ansi.reset); + try out.print( + " {s}\n\n", + .{if (api_cfg.account) "ON" else "OFF"}, + ); + if (use_color) try out.writeAll(ansi.bold); try out.writeAll("Commands:"); if (use_color) try out.writeAll(ansi.reset); @@ -348,8 +356,8 @@ pub fn writeHelp( .{ .name = "auto enable", .description = "Enable background auto-switching" }, .{ .name = "auto disable", .description = "Disable background auto-switching" }, .{ .name = "auto --5h [--weekly ]", .description = "Configure auto-switch thresholds" }, - .{ .name = "api enable", .description = "Enable usage API mode" }, - .{ .name = "api disable", .description = "Enable local-only mode" }, + .{ .name = "api enable", .description = "Enable usage and account APIs" }, + .{ .name = "api disable", .description = "Disable usage and account APIs" }, }; const parent_indent: usize = 2; const child_indent: usize = parent_indent + 4; diff --git a/src/main.zig b/src/main.zig index eed6fd6..1a1f57a 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1,34 +1,18 @@ const std = @import("std"); -const builtin = @import("builtin"); -const account_name_api = @import("account_name_api.zig"); +const account_api = @import("account_api.zig"); const cli = @import("cli.zig"); const registry = @import("registry.zig"); const auth = @import("auth.zig"); const auto = @import("auto.zig"); const format = @import("format.zig"); -const io_util = @import("io_util.zig"); const skip_service_reconcile_env = "CODEX_AUTH_SKIP_SERVICE_RECONCILE"; -const AccountNameFetchFn = *const fn ( +const AccountFetchFn = *const fn ( allocator: std.mem.Allocator, access_token: []const u8, account_id: ?[]const u8, -) anyerror!account_name_api.FetchResult; - -const account_name_bootstrap_parallel_limit: usize = if (builtin.is_test) 1 else 2; - -const AccountNameBootstrapJob = struct { - chatgpt_user_id: []u8, - auth_path: []u8, - fetch_result: ?account_name_api.FetchResult = null, - - fn deinit(self: *const AccountNameBootstrapJob, allocator: std.mem.Allocator) void { - allocator.free(self.chatgpt_user_id); - allocator.free(self.auth_path); - if (self.fetch_result) |result| result.deinit(std.heap.page_allocator); - } -}; +) anyerror!account_api.FetchResult; pub fn main() !void { var exit_code: u8 = 0; @@ -178,145 +162,28 @@ fn maybeRefreshForegroundUsage( } } -fn defaultAccountNameFetcher( +fn defaultAccountFetcher( allocator: std.mem.Allocator, access_token: []const u8, account_id: ?[]const u8, -) !account_name_api.FetchResult { - return try account_name_api.fetchAccountNamesForTokenDetailed( +) !account_api.FetchResult { + return try account_api.fetchAccountsForTokenDetailed( allocator, - account_name_api.default_account_name_endpoint, + account_api.default_account_endpoint, access_token, account_id, ); } -fn writeAccountNameBootstrapNotice() !void { - var stdout: io_util.Stdout = undefined; - stdout.init(); - const out = stdout.out(); - try out.writeAll("Syncing Team account names. This may take a few seconds...\n"); - try out.flush(); -} - -fn findAccountNameBootstrapJobIndexByUserId( - jobs: []const AccountNameBootstrapJob, - chatgpt_user_id: []const u8, -) ?usize { - for (jobs, 0..) |job, idx| { - if (std.mem.eql(u8, job.chatgpt_user_id, chatgpt_user_id)) return idx; - } - return null; -} - -fn shouldBootstrapAccountNameForRecord(rec: *const registry.AccountRecord) bool { - const plan = registry.resolvePlan(rec) orelse return false; - return plan == .team and rec.account_name == null; -} - -fn collectAccountNameBootstrapJobs( - allocator: std.mem.Allocator, - codex_home: []const u8, - reg: *registry.Registry, -) !std.ArrayList(AccountNameBootstrapJob) { - var jobs = std.ArrayList(AccountNameBootstrapJob).empty; - errdefer { - for (jobs.items) |*job| job.deinit(allocator); - jobs.deinit(allocator); - } - - for (reg.accounts.items) |rec| { - if (!shouldBootstrapAccountNameForRecord(&rec)) continue; - if (findAccountNameBootstrapJobIndexByUserId(jobs.items, rec.chatgpt_user_id) != null) continue; - - const auth_path = registry.accountAuthPath(allocator, codex_home, rec.account_key) catch continue; - var keep_auth_path = false; - defer if (!keep_auth_path) allocator.free(auth_path); - - var info = auth.parseAuthInfo(allocator, auth_path) catch continue; - defer info.deinit(allocator); - if (info.auth_mode != .chatgpt) continue; - if (info.access_token == null) continue; - const info_user_id = info.chatgpt_user_id orelse continue; - if (!std.mem.eql(u8, info_user_id, rec.chatgpt_user_id)) continue; - - const chatgpt_user_id = try allocator.dupe(u8, rec.chatgpt_user_id); - errdefer allocator.free(chatgpt_user_id); - - try jobs.append(allocator, .{ - .chatgpt_user_id = chatgpt_user_id, - .auth_path = auth_path, - .fetch_result = null, - }); - keep_auth_path = true; - } - - return jobs; -} - -fn runAccountNameBootstrapJob(job: *AccountNameBootstrapJob, fetcher: AccountNameFetchFn) void { - const allocator = std.heap.page_allocator; - var info = auth.parseAuthInfo(allocator, job.auth_path) catch return; - defer info.deinit(allocator); - if (info.auth_mode != .chatgpt) return; - const access_token = info.access_token orelse return; - const result = fetcher(allocator, access_token, info.chatgpt_account_id) catch return; - job.fetch_result = result; -} - -fn runAccountNameBootstrapJobs(jobs: []AccountNameBootstrapJob, fetcher: AccountNameFetchFn) void { - var next_idx: usize = 0; - while (next_idx < jobs.len) { - var handles: [account_name_bootstrap_parallel_limit]std.Thread = undefined; - var spawned: usize = 0; - const batch_end = @min(next_idx + account_name_bootstrap_parallel_limit, jobs.len); - while (next_idx < batch_end) : (next_idx += 1) { - handles[spawned] = std.Thread.spawn(.{}, runAccountNameBootstrapJob, .{ &jobs[next_idx], fetcher }) catch { - runAccountNameBootstrapJob(&jobs[next_idx], fetcher); - continue; - }; - spawned += 1; - } - for (handles[0..spawned]) |handle| handle.join(); - } -} - -pub fn bootstrapTeamAccountNamesOnFirstRun( - allocator: std.mem.Allocator, - codex_home: []const u8, - reg: *registry.Registry, - fetcher: AccountNameFetchFn, -) !bool { - if (reg.account_name_bootstrap_done) return false; - reg.account_name_bootstrap_done = true; - - var jobs = try collectAccountNameBootstrapJobs(allocator, codex_home, reg); - defer { - for (jobs.items) |*job| job.deinit(allocator); - jobs.deinit(allocator); - } - if (jobs.items.len == 0) return true; - - try writeAccountNameBootstrapNotice(); - runAccountNameBootstrapJobs(jobs.items, fetcher); - - for (jobs.items) |*job| { - const fetch_result = job.fetch_result orelse continue; - const entries = fetch_result.entries orelse continue; - _ = try registry.applyAccountNamesForUser(allocator, reg, job.chatgpt_user_id, entries); - } - - return true; -} - fn maybeRefreshAccountNamesForAuthInfo( allocator: std.mem.Allocator, reg: *registry.Registry, info: *const auth.AuthInfo, - fetcher: AccountNameFetchFn, + fetcher: AccountFetchFn, ) !bool { + if (!reg.api.account) return false; const chatgpt_user_id = info.chatgpt_user_id orelse return false; - if (!registry.hasMissingAccountNameForUser(reg, chatgpt_user_id)) return false; + if (!registry.shouldFetchTeamAccountNamesForUser(reg, chatgpt_user_id)) return false; const access_token = info.access_token orelse return false; const result = fetcher(allocator, access_token, info.chatgpt_account_id) catch |err| { @@ -329,7 +196,7 @@ fn maybeRefreshAccountNamesForAuthInfo( return try registry.applyAccountNamesForUser(allocator, reg, chatgpt_user_id, entries); } -fn loadActiveAuthInfoForAccountNames(allocator: std.mem.Allocator, codex_home: []const u8) !?auth.AuthInfo { +fn loadActiveAuthInfoForAccountRefresh(allocator: std.mem.Allocator, codex_home: []const u8) !?auth.AuthInfo { const auth_path = try registry.activeAuthPath(allocator, codex_home); defer allocator.free(auth_path); @@ -347,12 +214,13 @@ fn refreshAccountNamesForActiveAuth( allocator: std.mem.Allocator, codex_home: []const u8, reg: *registry.Registry, - fetcher: AccountNameFetchFn, + fetcher: AccountFetchFn, ) !bool { + if (!reg.api.account) return false; const active_user_id = registry.activeChatgptUserId(reg) orelse return false; - if (!registry.hasMissingAccountNameForUser(reg, active_user_id)) return false; + if (!registry.shouldFetchTeamAccountNamesForUser(reg, active_user_id)) return false; - var info = (try loadActiveAuthInfoForAccountNames(allocator, codex_home)) orelse return false; + var info = (try loadActiveAuthInfoForAccountRefresh(allocator, codex_home)) orelse return false; defer info.deinit(allocator); return try maybeRefreshAccountNamesForAuthInfo(allocator, reg, &info, fetcher); } @@ -361,7 +229,7 @@ pub fn refreshAccountNamesAfterLogin( allocator: std.mem.Allocator, reg: *registry.Registry, info: *const auth.AuthInfo, - fetcher: AccountNameFetchFn, + fetcher: AccountFetchFn, ) !bool { return try maybeRefreshAccountNamesForAuthInfo(allocator, reg, info, fetcher); } @@ -370,7 +238,7 @@ pub fn refreshAccountNamesAfterSwitch( allocator: std.mem.Allocator, codex_home: []const u8, reg: *registry.Registry, - fetcher: AccountNameFetchFn, + fetcher: AccountFetchFn, ) !bool { return try refreshAccountNamesForActiveAuth(allocator, codex_home, reg, fetcher); } @@ -379,7 +247,7 @@ pub fn refreshAccountNamesForList( allocator: std.mem.Allocator, codex_home: []const u8, reg: *registry.Registry, - fetcher: AccountNameFetchFn, + fetcher: AccountFetchFn, ) !bool { return try refreshAccountNamesForActiveAuth(allocator, codex_home, reg, fetcher); } @@ -390,7 +258,7 @@ pub fn refreshAccountNamesAfterImport( purge: bool, render_kind: registry.ImportRenderKind, info: ?*const auth.AuthInfo, - fetcher: AccountNameFetchFn, + fetcher: AccountFetchFn, ) !bool { if (purge or render_kind != .single_file or info == null) return false; return try maybeRefreshAccountNamesForAuthInfo(allocator, reg, info.?, fetcher); @@ -465,10 +333,7 @@ fn handleList(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli.Li try registry.saveRegistry(allocator, codex_home, ®); } } - if (try bootstrapTeamAccountNamesOnFirstRun(allocator, codex_home, ®, defaultAccountNameFetcher)) { - try registry.saveRegistry(allocator, codex_home, ®); - } - if (try refreshAccountNamesForList(allocator, codex_home, ®, defaultAccountNameFetcher)) { + if (try refreshAccountNamesForList(allocator, codex_home, ®, defaultAccountFetcher)) { try registry.saveRegistry(allocator, codex_home, ®); } try maybeRefreshForegroundUsage(allocator, codex_home, ®, .list); @@ -486,9 +351,6 @@ fn handleLogin(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli.L var reg = try registry.loadRegistry(allocator, codex_home); defer reg.deinit(allocator); - if (try bootstrapTeamAccountNamesOnFirstRun(allocator, codex_home, ®, defaultAccountNameFetcher)) { - try registry.saveRegistry(allocator, codex_home, ®); - } const email = info.email orelse return error.MissingEmail; _ = email; @@ -502,7 +364,7 @@ fn handleLogin(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli.L const record = try registry.accountFromAuth(allocator, "", &info); try registry.upsertAccount(allocator, ®, record); try registry.setActiveAccountKey(allocator, ®, record_key); - _ = try refreshAccountNamesAfterLogin(allocator, ®, &info, defaultAccountNameFetcher); + _ = try refreshAccountNamesAfterLogin(allocator, ®, &info, defaultAccountFetcher); try registry.saveRegistry(allocator, codex_home, ®); } @@ -532,7 +394,7 @@ fn handleImport(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli. opts.purge, report.render_kind, if (imported_info) |*info| info else null, - defaultAccountNameFetcher, + defaultAccountFetcher, ); } try registry.saveRegistry(allocator, codex_home, ®); @@ -547,9 +409,6 @@ fn handleSwitch(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli. if (try registry.syncActiveAccountFromAuth(allocator, codex_home, ®)) { try registry.saveRegistry(allocator, codex_home, ®); } - if (try bootstrapTeamAccountNamesOnFirstRun(allocator, codex_home, ®, defaultAccountNameFetcher)) { - try registry.saveRegistry(allocator, codex_home, ®); - } try maybeRefreshForegroundUsage(allocator, codex_home, ®, .switch_account); var selected_account_key: ?[]const u8 = null; @@ -576,14 +435,14 @@ fn handleSwitch(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli. const account_key = selected_account_key.?; try registry.activateAccountByKey(allocator, codex_home, ®, account_key); - _ = try refreshAccountNamesAfterSwitch(allocator, codex_home, ®, defaultAccountNameFetcher); + _ = try refreshAccountNamesAfterSwitch(allocator, codex_home, ®, defaultAccountFetcher); try registry.saveRegistry(allocator, codex_home, ®); } fn handleConfig(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli.ConfigOptions) !void { switch (opts) { .auto_switch => |auto_opts| try auto.handleAutoCommand(allocator, codex_home, auto_opts), - .api_usage => |action| try auto.handleApiUsageCommand(allocator, codex_home, action), + .api => |action| try auto.handleApiCommand(allocator, codex_home, action), } } @@ -604,8 +463,7 @@ pub fn findMatchingAccounts( name.len != 0 and std.ascii.indexOfIgnoreCase(name, query) != null else false; - if (matches_email or matches_alias or matches_name) - { + if (matches_email or matches_alias or matches_name) { try matches.append(allocator, idx); } } @@ -822,7 +680,7 @@ fn handleClean(allocator: std.mem.Allocator, codex_home: []const u8) !void { test { _ = @import("tests/auth_test.zig"); _ = @import("tests/sessions_test.zig"); - _ = @import("tests/account_name_api_test.zig"); + _ = @import("tests/account_api_test.zig"); _ = @import("tests/usage_api_test.zig"); _ = @import("tests/auto_test.zig"); _ = @import("tests/registry_test.zig"); diff --git a/src/registry.zig b/src/registry.zig index 6a231e2..9987ac2 100644 --- a/src/registry.zig +++ b/src/registry.zig @@ -1,6 +1,6 @@ const std = @import("std"); const builtin = @import("builtin"); -const account_name_api = @import("account_name_api.zig"); +const account_api = @import("account_api.zig"); const c_time = @cImport({ @cInclude("time.h"); }); @@ -52,6 +52,7 @@ pub const AutoSwitchConfig = struct { pub const ApiConfig = struct { usage: bool = true, + account: bool = true, }; pub const AccountRecord = struct { @@ -80,7 +81,6 @@ pub const Registry = struct { schema_version: u32, active_account_key: ?[]u8, active_account_activated_at_ms: ?i64, - account_name_bootstrap_done: bool, auto_switch: AutoSwitchConfig, api: ApiConfig, accounts: std.ArrayList(AccountRecord), @@ -1761,14 +1761,59 @@ pub fn resolveRateWindow(usage: ?RateLimitSnapshot, minutes: i64, fallback_prima return if (fallback_primary) usage.?.primary else usage.?.secondary; } +fn hasStoredAccountName(rec: *const AccountRecord) bool { + const account_name = rec.account_name orelse return false; + return account_name.len != 0; +} + +fn isTeamAccount(rec: *const AccountRecord) bool { + const plan = resolvePlan(rec) orelse return false; + return plan == .team; +} + +fn emailHasMultipleAccounts(reg: *const Registry, email: []const u8) bool { + var count: usize = 0; + for (reg.accounts.items) |rec| { + if (!std.mem.eql(u8, rec.email, email)) continue; + count += 1; + if (count > 1) return true; + } + return false; +} + pub fn hasMissingAccountNameForUser(reg: *const Registry, chatgpt_user_id: []const u8) bool { for (reg.accounts.items) |rec| { if (!std.mem.eql(u8, rec.chatgpt_user_id, chatgpt_user_id)) continue; - if (rec.account_name == null) return true; + if (!hasStoredAccountName(&rec)) return true; } return false; } +pub fn shouldFetchTeamAccountNamesForUser(reg: *const Registry, chatgpt_user_id: []const u8) bool { + var account_count: usize = 0; + var has_team_account = false; + var has_missing_team_account_name = false; + var has_grouped_email = false; + + for (reg.accounts.items) |rec| { + if (!std.mem.eql(u8, rec.chatgpt_user_id, chatgpt_user_id)) continue; + + account_count += 1; + if (!has_grouped_email and emailHasMultipleAccounts(reg, rec.email)) { + has_grouped_email = true; + } + if (!isTeamAccount(&rec)) continue; + + has_team_account = true; + if (!hasStoredAccountName(&rec)) { + has_missing_team_account_name = true; + } + } + + if (!has_team_account or !has_missing_team_account_name) return false; + return account_count > 1 or has_grouped_email; +} + pub fn activeChatgptUserId(reg: *Registry) ?[]const u8 { const active_account_key = reg.active_account_key orelse return null; const idx = findAccountIndexByAccountKey(reg, active_account_key) orelse return null; @@ -1779,7 +1824,7 @@ pub fn applyAccountNamesForUser( allocator: std.mem.Allocator, reg: *Registry, chatgpt_user_id: []const u8, - entries: []const account_name_api.AccountNameEntry, + entries: []const account_api.AccountEntry, ) !bool { var changed = false; for (reg.accounts.items) |*rec| { @@ -1936,7 +1981,6 @@ fn defaultRegistry() Registry { .schema_version = current_schema_version, .active_account_key = null, .active_account_activated_at_ms = null, - .account_name_bootstrap_done = false, .auto_switch = defaultAutoSwitchConfig(), .api = defaultApiConfig(), .accounts = std.ArrayList(AccountRecord).empty, @@ -2207,13 +2251,6 @@ fn loadLegacyRegistryV2( else => {}, } } - if (root_obj.get("account_name_bootstrap_done")) |v| { - switch (v) { - .bool => |flag| reg.account_name_bootstrap_done = flag, - else => {}, - } - } - if (root_obj.get("accounts")) |v| { switch (v) { .array => |arr| { @@ -2265,13 +2302,6 @@ fn loadCurrentRegistry(allocator: std.mem.Allocator, root_obj: std.json.ObjectMa } else if (reg.active_account_key != null) { reg.active_account_activated_at_ms = 0; } - if (root_obj.get("account_name_bootstrap_done")) |v| { - switch (v) { - .bool => |flag| reg.account_name_bootstrap_done = flag, - else => {}, - } - } - if (root_obj.get("accounts")) |v| { switch (v) { .array => |arr| { @@ -2444,7 +2474,6 @@ pub fn saveRegistry(allocator: std.mem.Allocator, codex_home: []const u8, reg: * .schema_version = current_schema_version, .active_account_key = reg.active_account_key, .active_account_activated_at_ms = reg.active_account_activated_at_ms, - .account_name_bootstrap_done = reg.account_name_bootstrap_done, .auto_switch = reg.auto_switch, .api = reg.api, .accounts = reg.accounts.items, @@ -2467,7 +2496,6 @@ const RegistryOut = struct { schema_version: u32, active_account_key: ?[]const u8, active_account_activated_at_ms: ?i64, - account_name_bootstrap_done: bool, auto_switch: AutoSwitchConfig, api: ApiConfig, accounts: []const AccountRecord, @@ -2544,6 +2572,12 @@ fn parseApiConfig(cfg: *ApiConfig, v: std.json.Value) void { else => {}, } } + if (obj.get("account")) |account| { + switch (account) { + .bool => |flag| cfg.account = flag, + else => {}, + } + } } fn parseRolloutSignature(allocator: std.mem.Allocator, v: std.json.Value) ?RolloutSignature { diff --git a/src/tests/account_name_api_test.zig b/src/tests/account_api_test.zig similarity index 86% rename from src/tests/account_name_api_test.zig rename to src/tests/account_api_test.zig index f8a2c1e..16bb24b 100644 --- a/src/tests/account_name_api_test.zig +++ b/src/tests/account_api_test.zig @@ -1,14 +1,14 @@ const std = @import("std"); -const account_name_api = @import("../account_name_api.zig"); +const account_api = @import("../account_api.zig"); -fn findEntryByAccountId(entries: []const account_name_api.AccountNameEntry, account_id: []const u8) ?*const account_name_api.AccountNameEntry { +fn findEntryByAccountId(entries: []const account_api.AccountEntry, account_id: []const u8) ?*const account_api.AccountEntry { for (entries) |*entry| { if (std.mem.eql(u8, entry.account_id, account_id)) return entry; } return null; } -fn freeEntries(allocator: std.mem.Allocator, entries: ?[]account_name_api.AccountNameEntry) void { +fn freeEntries(allocator: std.mem.Allocator, entries: ?[]account_api.AccountEntry) void { if (entries) |owned_entries| { for (owned_entries) |*entry| entry.deinit(allocator); allocator.free(owned_entries); @@ -37,7 +37,7 @@ test "parse account names response ignores default and keeps one real account" { \\} ; - const entries = try account_name_api.parseAccountNamesResponse(gpa, body); + const entries = try account_api.parseAccountsResponse(gpa, body); defer freeEntries(gpa, entries); try std.testing.expect(entries != null); @@ -68,7 +68,7 @@ test "parse account names response keeps multiple non-default accounts" { \\} ; - const entries = try account_name_api.parseAccountNamesResponse(gpa, body); + const entries = try account_api.parseAccountsResponse(gpa, body); defer freeEntries(gpa, entries); try std.testing.expect(entries != null); @@ -96,7 +96,7 @@ test "parse account names response normalizes null names to null" { \\} ; - const entries = try account_name_api.parseAccountNamesResponse(gpa, body); + const entries = try account_api.parseAccountsResponse(gpa, body); defer freeEntries(gpa, entries); try std.testing.expect(entries != null); @@ -119,7 +119,7 @@ test "parse account names response normalizes empty names to null" { \\} ; - const entries = try account_name_api.parseAccountNamesResponse(gpa, body); + const entries = try account_api.parseAccountsResponse(gpa, body); defer freeEntries(gpa, entries); try std.testing.expect(entries != null); @@ -129,6 +129,6 @@ test "parse account names response normalizes empty names to null" { test "parse account names response treats malformed html as non-fatal failure" { const gpa = std.testing.allocator; - const result = try account_name_api.parseAccountNamesResponse(gpa, "not json"); + const result = try account_api.parseAccountsResponse(gpa, "not json"); try std.testing.expect(result == null); } diff --git a/src/tests/auto_test.zig b/src/tests/auto_test.zig index 3f9aedb..30bb103 100644 --- a/src/tests/auto_test.zig +++ b/src/tests/auto_test.zig @@ -1234,6 +1234,7 @@ test "Scenario: Given status when rendering then auto and usage api settings are .threshold_5h_percent = 12, .threshold_weekly_percent = 8, .api_usage_enabled = false, + .api_account_enabled = false, }); const output = aw.written(); @@ -1241,6 +1242,7 @@ test "Scenario: Given status when rendering then auto and usage api settings are try std.testing.expect(std.mem.indexOf(u8, output, "service: running") != null); try std.testing.expect(std.mem.indexOf(u8, output, "thresholds: 5h<12%, weekly<8%") != null); try std.testing.expect(std.mem.indexOf(u8, output, "usage: local") != null); + try std.testing.expect(std.mem.indexOf(u8, output, "account: disabled") != null); try std.testing.expect(std.mem.indexOf(u8, output, "Warning: Usage refresh is currently using the ChatGPT usage API") == null); } @@ -1255,10 +1257,12 @@ test "Scenario: Given api usage mode when rendering status body then risk warnin .threshold_5h_percent = 12, .threshold_weekly_percent = 8, .api_usage_enabled = true, + .api_account_enabled = true, }); const output = aw.written(); try std.testing.expect(std.mem.indexOf(u8, output, "usage: api") != null); + try std.testing.expect(std.mem.indexOf(u8, output, "account: api") != null); try std.testing.expect(std.mem.indexOf(u8, output, "Warning: Usage refresh is currently using the ChatGPT usage API") == null); try std.testing.expect(std.mem.indexOf(u8, output, "`codex-auth config api disable`") == null); } diff --git a/src/tests/bdd_helpers.zig b/src/tests/bdd_helpers.zig index 6fffb66..9edc117 100644 --- a/src/tests/bdd_helpers.zig +++ b/src/tests/bdd_helpers.zig @@ -184,7 +184,6 @@ pub fn makeEmptyRegistry() registry.Registry { .schema_version = registry.current_schema_version, .active_account_key = null, .active_account_activated_at_ms = null, - .account_name_bootstrap_done = false, .auto_switch = registry.defaultAutoSwitchConfig(), .api = registry.defaultApiConfig(), .accounts = std.ArrayList(registry.AccountRecord).empty, diff --git a/src/tests/cli_bdd_test.zig b/src/tests/cli_bdd_test.zig index 9095c6e..2458112 100644 --- a/src/tests/cli_bdd_test.zig +++ b/src/tests/cli_bdd_test.zig @@ -147,12 +147,14 @@ test "Scenario: Given help when rendering then login and compatibility notes are auto_cfg.threshold_5h_percent = 12; auto_cfg.threshold_weekly_percent = 8; api_cfg.usage = true; + api_cfg.account = true; try cli.writeHelp(&aw.writer, false, &auto_cfg, &api_cfg); const help = aw.written(); try std.testing.expect(std.mem.indexOf(u8, help, "Auto Switch: ON (5h<12%, weekly<8%)") != null); try std.testing.expect(std.mem.indexOf(u8, help, "Usage API: ON (api)") != null); + try std.testing.expect(std.mem.indexOf(u8, help, "Account API: ON") != null); try std.testing.expect(std.mem.indexOf(u8, help, "--cpa []") != null); try std.testing.expect(std.mem.indexOf(u8, help, "Warning: Usage refresh is currently using the ChatGPT usage API") == null); try std.testing.expect(std.mem.indexOf(u8, help, "`codex-auth config api disable`") == null); @@ -309,7 +311,7 @@ test "Scenario: Given config api enable when parsing then api action is preserve switch (cmd) { .config => |opts| switch (opts) { - .api_usage => |action| try std.testing.expect(action == .enable), + .api => |action| try std.testing.expect(action == .enable), else => return error.TestExpectedEqual, }, else => return error.TestExpectedEqual, @@ -324,7 +326,7 @@ test "Scenario: Given config api disable when parsing then api disable action is switch (cmd) { .config => |opts| switch (opts) { - .api_usage => |action| try std.testing.expect(action == .disable), + .api => |action| try std.testing.expect(action == .disable), else => return error.TestExpectedEqual, }, else => return error.TestExpectedEqual, diff --git a/src/tests/display_rows_test.zig b/src/tests/display_rows_test.zig index c1c57e6..a5fb550 100644 --- a/src/tests/display_rows_test.zig +++ b/src/tests/display_rows_test.zig @@ -7,7 +7,6 @@ fn makeRegistry() registry.Registry { .schema_version = registry.current_schema_version, .active_account_key = null, .active_account_activated_at_ms = null, - .account_name_bootstrap_done = false, .auto_switch = registry.defaultAutoSwitchConfig(), .api = registry.defaultApiConfig(), .accounts = std.ArrayList(registry.AccountRecord).empty, diff --git a/src/tests/e2e_cli_test.zig b/src/tests/e2e_cli_test.zig index 0a9aded..5596c4e 100644 --- a/src/tests/e2e_cli_test.zig +++ b/src/tests/e2e_cli_test.zig @@ -798,6 +798,7 @@ test "Scenario: Given default api usage when rendering help then the api enable try expectSuccess(result); try std.testing.expect(std.mem.indexOf(u8, result.stdout, "codex-auth") != null); try std.testing.expect(std.mem.indexOf(u8, result.stdout, "Usage API: ON (api)") != null); + try std.testing.expect(std.mem.indexOf(u8, result.stdout, "Account API: ON") != null); try std.testing.expect(std.mem.indexOf(u8, result.stdout, "`config api enable` may trigger OpenAI account restrictions or suspension in some environments.") != null); try std.testing.expectEqualStrings("", result.stderr); } @@ -1644,6 +1645,7 @@ test "Scenario: Given default api usage when rendering status then no warning is try expectSuccess(result); try std.testing.expect(std.mem.indexOf(u8, result.stdout, "auto-switch: OFF") != null); try std.testing.expect(std.mem.indexOf(u8, result.stdout, "usage: api") != null); + try std.testing.expect(std.mem.indexOf(u8, result.stdout, "account: api") != null); try std.testing.expectEqualStrings("", result.stderr); } diff --git a/src/tests/main_test.zig b/src/tests/main_test.zig index ade965c..54d86cf 100644 --- a/src/tests/main_test.zig +++ b/src/tests/main_test.zig @@ -1,6 +1,7 @@ const std = @import("std"); -const account_name_api = @import("../account_name_api.zig"); +const account_api = @import("../account_api.zig"); const auth_mod = @import("../auth.zig"); +const display_rows = @import("../display_rows.zig"); const main_mod = @import("../main.zig"); const registry = @import("../registry.zig"); const bdd = @import("bdd_helpers.zig"); @@ -10,30 +11,21 @@ const primary_account_id = "67fe2bbb-0de6-49a4-b2b3-d1df366d1faf"; const secondary_account_id = "518a44d9-ba75-4bad-87e5-ae9377042960"; const primary_record_key = shared_user_id ++ "::" ++ primary_account_id; const secondary_record_key = shared_user_id ++ "::" ++ secondary_account_id; -const second_user_id = "user-bM3QpTa5vN2zL8eXk9Ds1HfR"; -const second_team_account_id = "a4021fa5-998b-4774-989f-784fa69c367b"; -const second_free_account_id = "58250000-8b17-4ff0-8e00-000000000001"; -const second_team_record_key = second_user_id ++ "::" ++ second_team_account_id; -const second_free_record_key = second_user_id ++ "::" ++ second_free_account_id; -const plus_only_record_key = "user-j9V2tYh3mQ8nLp0rD4sK7wXc::6f7787aa-25d0-40ec-a2f7-d8ea9915c8d4"; +const standalone_team_user_id = "user-q2Lm6Nx8Vc4Rb7Ty1Hp9JkDs"; +const standalone_team_account_id = "29a9c0cb-e840-45ec-97bf-d6c5f7e0f55b"; +const standalone_team_record_key = standalone_team_user_id ++ "::" ++ standalone_team_account_id; var mock_account_name_fetch_count: usize = 0; -var bootstrap_mock_fetch_count: usize = 0; fn resetMockAccountNameFetcher() void { mock_account_name_fetch_count = 0; } -fn resetBootstrapMockAccountNameFetcher() void { - bootstrap_mock_fetch_count = 0; -} - fn makeRegistry() registry.Registry { return .{ .schema_version = registry.current_schema_version, .active_account_key = null, .active_account_activated_at_ms = null, - .account_name_bootstrap_done = false, .auto_switch = registry.defaultAutoSwitchConfig(), .api = registry.defaultApiConfig(), .accounts = std.ArrayList(registry.AccountRecord).empty, @@ -135,33 +127,16 @@ fn writeActiveAuthWithIds( try std.fs.cwd().writeFile(.{ .sub_path = auth_path, .data = auth_json }); } -fn writeAccountSnapshotWithIds( - allocator: std.mem.Allocator, - codex_home: []const u8, - record_key: []const u8, - email: []const u8, - plan: []const u8, - chatgpt_user_id: []const u8, - chatgpt_account_id: []const u8, -) !void { - const snapshot_path = try registry.accountAuthPath(allocator, codex_home, record_key); - defer allocator.free(snapshot_path); - - const auth_json = try authJsonWithIds(allocator, email, plan, chatgpt_user_id, chatgpt_account_id); - defer allocator.free(auth_json); - try std.fs.cwd().writeFile(.{ .sub_path = snapshot_path, .data = auth_json }); -} - fn mockAccountNameFetcher( allocator: std.mem.Allocator, access_token: []const u8, account_id: ?[]const u8, -) !account_name_api.FetchResult { +) !account_api.FetchResult { _ = access_token; _ = account_id; mock_account_name_fetch_count += 1; - const entries = try allocator.alloc(account_name_api.AccountNameEntry, 2); + const entries = try allocator.alloc(account_api.AccountEntry, 2); errdefer allocator.free(entries); entries[0] = .{ @@ -185,56 +160,6 @@ fn mockAccountNameFetcher( }; } -fn bootstrapMockAccountNameFetcher( - allocator: std.mem.Allocator, - access_token: []const u8, - account_id: ?[]const u8, -) !account_name_api.FetchResult { - _ = access_token; - bootstrap_mock_fetch_count += 1; - const requested_account_id = account_id orelse return .{ - .entries = null, - .status_code = 200, - }; - - if (std.mem.eql(u8, requested_account_id, primary_account_id)) { - const entries = try allocator.alloc(account_name_api.AccountNameEntry, 2); - errdefer allocator.free(entries); - entries[0] = .{ - .account_id = try allocator.dupe(u8, primary_account_id), - .account_name = try allocator.dupe(u8, "Primary Workspace"), - }; - errdefer entries[0].deinit(allocator); - entries[1] = .{ - .account_id = try allocator.dupe(u8, secondary_account_id), - .account_name = try allocator.dupe(u8, "Backup Workspace"), - }; - errdefer entries[1].deinit(allocator); - return .{ .entries = entries, .status_code = 200 }; - } - - if (std.mem.eql(u8, requested_account_id, second_team_account_id)) { - const entries = try allocator.alloc(account_name_api.AccountNameEntry, 2); - errdefer allocator.free(entries); - entries[0] = .{ - .account_id = try allocator.dupe(u8, second_team_account_id), - .account_name = try allocator.dupe(u8, "Ops Workspace"), - }; - errdefer entries[0].deinit(allocator); - entries[1] = .{ - .account_id = try allocator.dupe(u8, second_free_account_id), - .account_name = try allocator.dupe(u8, "Free Workspace"), - }; - errdefer entries[1].deinit(allocator); - return .{ .entries = entries, .status_code = 200 }; - } - - return .{ - .entries = null, - .status_code = 200, - }; -} - test "Scenario: Given alias, email, and account name queries when finding matching accounts then all matching strategies work" { const gpa = std.testing.allocator; var reg = makeRegistry(); @@ -279,7 +204,7 @@ test "Scenario: Given foreground commands when checking reconcile policy then co .threshold_5h_percent = 12, .threshold_weekly_percent = null, } } } })); - try std.testing.expect(main_mod.shouldReconcileManagedService(.{ .config = .{ .api_usage = .enable } })); + try std.testing.expect(main_mod.shouldReconcileManagedService(.{ .config = .{ .api = .enable } })); try std.testing.expect(!main_mod.shouldReconcileManagedService(.{ .help = {} })); try std.testing.expect(!main_mod.shouldReconcileManagedService(.{ .status = {} })); try std.testing.expect(!main_mod.shouldReconcileManagedService(.{ .version = {} })); @@ -292,6 +217,103 @@ test "Scenario: Given foreground usage refresh targets when checking refresh pol try std.testing.expect(!main_mod.shouldRefreshForegroundUsage(.remove_account)); } +test "Scenario: Given team name fetch candidates when checking grouped-account policy then only ambiguous team users qualify" { + const gpa = std.testing.allocator; + var reg = makeRegistry(); + defer reg.deinit(gpa); + + try appendAccount(gpa, ®, primary_record_key, "same-user@example.com", "", .team); + try appendAccount(gpa, ®, secondary_record_key, "same-user@example.com", "", .free); + try appendAccount(gpa, ®, "user-email-team::acct-email-team", "same-email@example.com", "", .team); + try appendAccount(gpa, ®, "user-email-plus::acct-email-plus", "same-email@example.com", "", .plus); + try appendAccount(gpa, ®, standalone_team_record_key, "solo-team@example.com", "", .team); + try appendAccount(gpa, ®, "user-plus-only::acct-plus-a", "plus-only@example.com", "", .plus); + try appendAccount(gpa, ®, "user-plus-only::acct-plus-b", "plus-only-alt@example.com", "", .plus); + + try std.testing.expect(registry.shouldFetchTeamAccountNamesForUser(®, shared_user_id)); + try std.testing.expect(registry.shouldFetchTeamAccountNamesForUser(®, "user-email-team")); + try std.testing.expect(!registry.shouldFetchTeamAccountNamesForUser(®, "user-email-plus")); + try std.testing.expect(!registry.shouldFetchTeamAccountNamesForUser(®, standalone_team_user_id)); + try std.testing.expect(!registry.shouldFetchTeamAccountNamesForUser(®, "user-plus-only")); +} + +test "Scenario: Given a standalone team account when building display rows and refreshing names then it keeps the email label and skips requests" { + const gpa = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + const codex_home = try tmp.dir.realpathAlloc(gpa, "."); + defer gpa.free(codex_home); + + var reg = makeRegistry(); + defer reg.deinit(gpa); + try appendAccount(gpa, ®, standalone_team_record_key, "solo-team@example.com", "", .team); + try registry.setActiveAccountKey(gpa, ®, standalone_team_record_key); + try writeActiveAuthWithIds(gpa, codex_home, "solo-team@example.com", "team", standalone_team_user_id, standalone_team_account_id); + + var rows = try display_rows.buildDisplayRows(gpa, ®, null); + defer rows.deinit(gpa); + try std.testing.expectEqual(@as(usize, 1), rows.rows.len); + try std.testing.expect(std.mem.eql(u8, rows.rows[0].account_cell, "solo-team@example.com")); + try std.testing.expect(!registry.shouldFetchTeamAccountNamesForUser(®, standalone_team_user_id)); + + var info = try parseAuthInfoWithIds(gpa, "solo-team@example.com", "team", standalone_team_user_id, standalone_team_account_id); + defer info.deinit(gpa); + + resetMockAccountNameFetcher(); + try std.testing.expect(!(try main_mod.refreshAccountNamesAfterLogin(gpa, ®, &info, mockAccountNameFetcher))); + try std.testing.expectEqual(@as(usize, 0), mock_account_name_fetch_count); + + resetMockAccountNameFetcher(); + try std.testing.expect(!(try main_mod.refreshAccountNamesAfterImport(gpa, ®, false, .single_file, &info, mockAccountNameFetcher))); + try std.testing.expectEqual(@as(usize, 0), mock_account_name_fetch_count); + + resetMockAccountNameFetcher(); + try std.testing.expect(!(try main_mod.refreshAccountNamesAfterSwitch(gpa, codex_home, ®, mockAccountNameFetcher))); + try std.testing.expectEqual(@as(usize, 0), mock_account_name_fetch_count); + + resetMockAccountNameFetcher(); + try std.testing.expect(!(try main_mod.refreshAccountNamesForList(gpa, codex_home, ®, mockAccountNameFetcher))); + try std.testing.expectEqual(@as(usize, 0), mock_account_name_fetch_count); + try std.testing.expect(reg.accounts.items[0].account_name == null); +} + +test "Scenario: Given grouped team accounts with account api disabled when refreshing names then every entry point skips requests" { + const gpa = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + const codex_home = try tmp.dir.realpathAlloc(gpa, "."); + defer gpa.free(codex_home); + + var reg = makeRegistry(); + defer reg.deinit(gpa); + reg.api.account = false; + try appendAccount(gpa, ®, primary_record_key, "user@example.com", "", .team); + try appendAccount(gpa, ®, secondary_record_key, "user@example.com", "", .team); + try registry.setActiveAccountKey(gpa, ®, primary_record_key); + try writeActiveAuthWithIds(gpa, codex_home, "user@example.com", "team", shared_user_id, primary_account_id); + + var info = try parseAuthInfoWithIds(gpa, "user@example.com", "team", shared_user_id, primary_account_id); + defer info.deinit(gpa); + + resetMockAccountNameFetcher(); + try std.testing.expect(!(try main_mod.refreshAccountNamesAfterLogin(gpa, ®, &info, mockAccountNameFetcher))); + try std.testing.expectEqual(@as(usize, 0), mock_account_name_fetch_count); + + resetMockAccountNameFetcher(); + try std.testing.expect(!(try main_mod.refreshAccountNamesAfterImport(gpa, ®, false, .single_file, &info, mockAccountNameFetcher))); + try std.testing.expectEqual(@as(usize, 0), mock_account_name_fetch_count); + + resetMockAccountNameFetcher(); + try std.testing.expect(!(try main_mod.refreshAccountNamesAfterSwitch(gpa, codex_home, ®, mockAccountNameFetcher))); + try std.testing.expectEqual(@as(usize, 0), mock_account_name_fetch_count); + + resetMockAccountNameFetcher(); + try std.testing.expect(!(try main_mod.refreshAccountNamesForList(gpa, codex_home, ®, mockAccountNameFetcher))); + try std.testing.expectEqual(@as(usize, 0), mock_account_name_fetch_count); +} + test "Scenario: Given login with missing account names when refreshing metadata then it issues at most one request" { const gpa = std.testing.allocator; var reg = makeRegistry(); @@ -422,60 +444,6 @@ test "Scenario: Given list refresh with missing active-user account names when r try std.testing.expectEqual(@as(usize, 0), mock_account_name_fetch_count); } -test "Scenario: Given first-run bootstrap with missing team names across users when running bootstrap then it fetches once per team user and only once" { - const gpa = std.testing.allocator; - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - - const codex_home = try tmp.dir.realpathAlloc(gpa, "."); - defer gpa.free(codex_home); - try tmp.dir.makePath("accounts"); - - var reg = makeRegistry(); - defer reg.deinit(gpa); - - try appendAccount(gpa, ®, primary_record_key, "team-a@example.com", "", .team); - try appendAccount(gpa, ®, secondary_record_key, "team-a@example.com", "", .team); - try appendAccount(gpa, ®, second_team_record_key, "team-b@example.com", "", .team); - try appendAccount(gpa, ®, second_free_record_key, "team-b@example.com", "", .free); - try appendAccount(gpa, ®, plus_only_record_key, "plus-only@example.com", "", .plus); - - try writeAccountSnapshotWithIds( - gpa, - codex_home, - primary_record_key, - "team-a@example.com", - "team", - shared_user_id, - primary_account_id, - ); - try writeAccountSnapshotWithIds( - gpa, - codex_home, - second_team_record_key, - "team-b@example.com", - "team", - second_user_id, - second_team_account_id, - ); - - resetBootstrapMockAccountNameFetcher(); - const changed = try main_mod.bootstrapTeamAccountNamesOnFirstRun(gpa, codex_home, ®, bootstrapMockAccountNameFetcher); - try std.testing.expect(changed); - try std.testing.expect(reg.account_name_bootstrap_done); - try std.testing.expectEqual(@as(usize, 2), bootstrap_mock_fetch_count); - - try std.testing.expect(std.mem.eql(u8, reg.accounts.items[0].account_name.?, "Primary Workspace")); - try std.testing.expect(std.mem.eql(u8, reg.accounts.items[1].account_name.?, "Backup Workspace")); - try std.testing.expect(std.mem.eql(u8, reg.accounts.items[2].account_name.?, "Ops Workspace")); - try std.testing.expect(std.mem.eql(u8, reg.accounts.items[3].account_name.?, "Free Workspace")); - try std.testing.expect(reg.accounts.items[4].account_name == null); - - resetBootstrapMockAccountNameFetcher(); - try std.testing.expect(!(try main_mod.bootstrapTeamAccountNamesOnFirstRun(gpa, codex_home, ®, bootstrapMockAccountNameFetcher))); - try std.testing.expectEqual(@as(usize, 0), bootstrap_mock_fetch_count); -} - test "Scenario: Given removed active account with remaining accounts when reconciling then the best usage account becomes active" { const gpa = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); diff --git a/src/tests/purge_test.zig b/src/tests/purge_test.zig index 29ce8e5..d8ada6d 100644 --- a/src/tests/purge_test.zig +++ b/src/tests/purge_test.zig @@ -318,6 +318,7 @@ test "Scenario: Given purge import with file when rebuilding then current auth i try std.testing.expectEqual(@as(u8, 12), loaded.auto_switch.threshold_5h_percent); try std.testing.expectEqual(@as(u8, 7), loaded.auto_switch.threshold_weekly_percent); try std.testing.expect(loaded.api.usage); + try std.testing.expect(loaded.api.account); try std.testing.expect(loaded.active_account_activated_at_ms != null); const active_account_key = try accountKeyForEmailAlloc(gpa, "active@example.com"); @@ -386,6 +387,7 @@ test "Scenario: Given purge with newer schema registry when rebuilding then auto try std.testing.expectEqual(@as(u8, 18), loaded.auto_switch.threshold_5h_percent); try std.testing.expectEqual(@as(u8, 6), loaded.auto_switch.threshold_weekly_percent); try std.testing.expect(loaded.api.usage); + try std.testing.expect(loaded.api.account); } test "Scenario: Given purge with malformed registry when rebuilding then auto and api config are recovered best effort" { @@ -431,6 +433,7 @@ test "Scenario: Given purge with malformed registry when rebuilding then auto an try std.testing.expectEqual(@as(u8, 13), loaded.auto_switch.threshold_5h_percent); try std.testing.expectEqual(@as(u8, 4), loaded.auto_switch.threshold_weekly_percent); try std.testing.expect(loaded.api.usage); + try std.testing.expect(loaded.api.account); } test "Scenario: Given purge without path when rebuilding then it scans account snapshots and ignores registry metadata files" { diff --git a/src/tests/registry_test.zig b/src/tests/registry_test.zig index ee8f5a1..c528b8d 100644 --- a/src/tests/registry_test.zig +++ b/src/tests/registry_test.zig @@ -85,7 +85,6 @@ fn makeEmptyRegistry() registry.Registry { .schema_version = registry.current_schema_version, .active_account_key = null, .active_account_activated_at_ms = null, - .account_name_bootstrap_done = false, .auto_switch = registry.defaultAutoSwitchConfig(), .api = registry.defaultApiConfig(), .accounts = std.ArrayList(registry.AccountRecord).empty, @@ -184,18 +183,24 @@ test "registry save/load" { try registry.saveRegistry(gpa, codex_home, ®); + const registry_path = try std.fs.path.join(gpa, &[_][]const u8{ codex_home, "accounts", "registry.json" }); + defer gpa.free(registry_path); + const saved = try bdd.readFileAlloc(gpa, registry_path); + defer gpa.free(saved); + try std.testing.expect(std.mem.indexOf(u8, saved, "\"account\": true") != null); + var loaded = try registry.loadRegistry(gpa, codex_home); defer loaded.deinit(gpa); try std.testing.expect(loaded.accounts.items.len == 1); try std.testing.expect(loaded.auto_switch.threshold_5h_percent == 12); try std.testing.expect(loaded.auto_switch.threshold_weekly_percent == 8); try std.testing.expect(loaded.api.usage); + try std.testing.expect(loaded.api.account); try std.testing.expect(loaded.active_account_activated_at_ms != null); try std.testing.expect(loaded.accounts.items[0].last_local_rollout != null); try std.testing.expectEqual(@as(i64, 1735689600000), loaded.accounts.items[0].last_local_rollout.?.event_timestamp_ms); try std.testing.expect(std.mem.eql(u8, loaded.accounts.items[0].last_local_rollout.?.path, "/tmp/sessions/run-1/rollout-a.jsonl")); try std.testing.expect(loaded.accounts.items[0].account_name == null); - try std.testing.expect(!loaded.account_name_bootstrap_done); } test "registry load defaults missing account_name field to null" { @@ -235,7 +240,6 @@ test "registry load defaults missing account_name field to null" { try std.testing.expectEqual(@as(usize, 1), loaded.accounts.items.len); try std.testing.expect(loaded.accounts.items[0].account_name == null); - try std.testing.expect(!loaded.account_name_bootstrap_done); } test "registry save/load round-trips account_name null" { @@ -294,7 +298,7 @@ test "registry save/load round-trips account_name string" { try std.testing.expect(std.mem.eql(u8, loaded.accounts.items[0].account_name.?, "abcd")); } -test "registry save/load round-trips account_name_bootstrap_done true" { +test "registry save/load round-trips api.account false" { const gpa = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); @@ -305,21 +309,16 @@ test "registry save/load round-trips account_name_bootstrap_done true" { var reg = makeEmptyRegistry(); defer reg.deinit(gpa); - reg.account_name_bootstrap_done = true; + reg.api.account = false; const rec = try makeAccountRecord(gpa, "a@b.com", "work", .pro, .chatgpt, 1); try reg.accounts.append(gpa, rec); try registry.saveRegistry(gpa, codex_home, ®); - const registry_path = try std.fs.path.join(gpa, &[_][]const u8{ codex_home, "accounts", "registry.json" }); - defer gpa.free(registry_path); - const saved = try bdd.readFileAlloc(gpa, registry_path); - defer gpa.free(saved); - try std.testing.expect(std.mem.indexOf(u8, saved, "\"account_name_bootstrap_done\": true") != null); - var loaded = try registry.loadRegistry(gpa, codex_home); defer loaded.deinit(gpa); - try std.testing.expect(loaded.account_name_bootstrap_done); + try std.testing.expect(loaded.api.usage); + try std.testing.expect(!loaded.api.account); } test "registry load defaults missing auto threshold fields" { @@ -350,6 +349,7 @@ test "registry load defaults missing auto threshold fields" { try std.testing.expect(loaded.auto_switch.threshold_5h_percent == registry.default_auto_switch_threshold_5h_percent); try std.testing.expect(loaded.auto_switch.threshold_weekly_percent == registry.default_auto_switch_threshold_weekly_percent); try std.testing.expect(loaded.api.usage); + try std.testing.expect(loaded.api.account); try std.testing.expect(loaded.active_account_activated_at_ms == null); } From 310cc6b0d74635a909928185e00e432908e8d208 Mon Sep 17 00:00:00 2001 From: Loongphy Date: Thu, 26 Mar 2026 19:25:19 +0800 Subject: [PATCH 07/22] fix(remove): preserve unique labels and safe account name updates --- src/cli.zig | 11 ++++--- src/registry.zig | 3 +- src/tests/cli_bdd_test.zig | 59 +++++++++++++++++++++++++++++++++++++ src/tests/registry_test.zig | 32 ++++++++++++++++++++ 4 files changed, 100 insertions(+), 5 deletions(-) diff --git a/src/cli.zig b/src/cli.zig index f4857e5..98636d8 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -898,10 +898,13 @@ pub fn buildRemoveLabels( continue; } - const label = if (row.depth == 0 or current_header == null) - try allocator.dupe(u8, row.account_cell) - else - try std.fmt.allocPrint(allocator, "{s} / {s}", .{ current_header.?, row.account_cell }); + const label = if (row.depth == 0 or current_header == null) blk: { + const rec = ®.accounts.items[row.account_index.?]; + if (std.mem.eql(u8, row.account_cell, rec.email)) { + break :blk try allocator.dupe(u8, row.account_cell); + } + break :blk try std.fmt.allocPrint(allocator, "{s} / {s}", .{ rec.email, row.account_cell }); + } else try std.fmt.allocPrint(allocator, "{s} / {s}", .{ current_header.?, row.account_cell }); try labels.append(allocator, label); } return labels; diff --git a/src/registry.zig b/src/registry.zig index 9987ac2..2b4cb4c 100644 --- a/src/registry.zig +++ b/src/registry.zig @@ -239,8 +239,9 @@ fn replaceOptionalStringAlloc( value: ?[]const u8, ) !bool { if (optionalStringEqual(target.*, value)) return false; + const replacement = try cloneOptionalStringAlloc(allocator, value); if (target.*) |existing| allocator.free(existing); - target.* = try cloneOptionalStringAlloc(allocator, value); + target.* = replacement; return true; } diff --git a/src/tests/cli_bdd_test.zig b/src/tests/cli_bdd_test.zig index 126e57d..5b88a18 100644 --- a/src/tests/cli_bdd_test.zig +++ b/src/tests/cli_bdd_test.zig @@ -2,6 +2,45 @@ const std = @import("std"); const cli = @import("../cli.zig"); const registry = @import("../registry.zig"); +fn makeRegistry() registry.Registry { + return .{ + .schema_version = registry.current_schema_version, + .active_account_key = null, + .active_account_activated_at_ms = null, + .auto_switch = registry.defaultAutoSwitchConfig(), + .api = registry.defaultApiConfig(), + .accounts = std.ArrayList(registry.AccountRecord).empty, + }; +} + +fn appendAccount( + allocator: std.mem.Allocator, + reg: *registry.Registry, + record_key: []const u8, + email: []const u8, + alias: []const u8, + plan: registry.PlanType, +) !void { + const sep = std.mem.lastIndexOf(u8, record_key, "::") orelse return error.InvalidRecordKey; + const chatgpt_user_id = record_key[0..sep]; + const chatgpt_account_id = record_key[sep + 2 ..]; + try reg.accounts.append(allocator, .{ + .account_key = try allocator.dupe(u8, record_key), + .chatgpt_account_id = try allocator.dupe(u8, chatgpt_account_id), + .chatgpt_user_id = try allocator.dupe(u8, chatgpt_user_id), + .email = try allocator.dupe(u8, email), + .alias = try allocator.dupe(u8, alias), + .account_name = null, + .plan = plan, + .auth_mode = .chatgpt, + .created_at = 1, + .last_used_at = null, + .last_usage = null, + .last_usage_at = null, + .last_local_rollout = null, + }); +} + fn expectHelp(result: cli.ParseResult, topic: cli.HelpTopic) !void { switch (result) { .command => |cmd| switch (cmd) { @@ -692,6 +731,26 @@ test "Scenario: Given multiple matched accounts when rendering confirmation then ); } +test "Scenario: Given singleton aliases from different emails when building remove labels then each label keeps email context" { + const gpa = std.testing.allocator; + var reg = makeRegistry(); + defer reg.deinit(gpa); + + try appendAccount(gpa, ®, "user-A::acct-1", "alpha@example.com", "work", .team); + try appendAccount(gpa, ®, "user-B::acct-2", "beta@example.com", "work", .team); + + const indices = [_]usize{ 0, 1 }; + var labels = try cli.buildRemoveLabels(gpa, ®, &indices); + defer { + for (labels.items) |label| gpa.free(@constCast(label)); + labels.deinit(gpa); + } + + try std.testing.expectEqual(@as(usize, 2), labels.items.len); + try std.testing.expectEqualStrings("alpha@example.com / work", labels.items[0]); + try std.testing.expectEqualStrings("beta@example.com / work", labels.items[1]); +} + test "Scenario: Given selector environment when deciding remove UI then non-tty or windows use the numbered selector" { try std.testing.expect(cli.shouldUseNumberedRemoveSelector(false, false)); try std.testing.expect(!cli.shouldUseNumberedRemoveSelector(false, true)); diff --git a/src/tests/registry_test.zig b/src/tests/registry_test.zig index c528b8d..d5a9d28 100644 --- a/src/tests/registry_test.zig +++ b/src/tests/registry_test.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const account_api = @import("../account_api.zig"); const registry = @import("../registry.zig"); const bdd = @import("bdd_helpers.zig"); @@ -298,6 +299,37 @@ test "registry save/load round-trips account_name string" { try std.testing.expect(std.mem.eql(u8, loaded.accounts.items[0].account_name.?, "abcd")); } +test "applyAccountNamesForUser preserves existing account_name when replacement allocation fails" { + const gpa = std.testing.allocator; + var reg = makeEmptyRegistry(); + defer reg.deinit(gpa); + + var rec = try makeAccountRecord(gpa, "a@b.com", "work", .pro, .chatgpt, 1); + rec.account_name = try gpa.dupe(u8, "Primary Workspace"); + try reg.accounts.append(gpa, rec); + + var entry = account_api.AccountEntry{ + .account_id = try gpa.dupe(u8, reg.accounts.items[0].chatgpt_account_id), + .account_name = try gpa.dupe(u8, "Ops Workspace"), + }; + defer entry.deinit(gpa); + + var failing_allocator = std.testing.FailingAllocator.init(gpa, .{ .fail_index = 0 }); + const entries = [_]account_api.AccountEntry{entry}; + + try std.testing.expectError( + error.OutOfMemory, + registry.applyAccountNamesForUser( + failing_allocator.allocator(), + ®, + reg.accounts.items[0].chatgpt_user_id, + &entries, + ), + ); + try std.testing.expect(reg.accounts.items[0].account_name != null); + try std.testing.expectEqualStrings("Primary Workspace", reg.accounts.items[0].account_name.?); +} + test "registry save/load round-trips api.account false" { const gpa = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); From 8117f9fb4d9488ed90a4c0d2c1a07fbf03d492de Mon Sep 17 00:00:00 2001 From: Loongphy Date: Thu, 26 Mar 2026 20:06:28 +0800 Subject: [PATCH 08/22] fix: backfill api config and align remove output tests --- README.md | 8 ++-- review.md | 22 +++++++++++ src/registry.zig | 40 +++++++++++++++++-- src/tests/e2e_cli_test.zig | 4 +- src/tests/registry_test.zig | 77 +++++++++++++++++++++++++++++++++++++ 5 files changed, 142 insertions(+), 9 deletions(-) create mode 100644 review.md diff --git a/README.md b/README.md index f624312..8d4965f 100644 --- a/README.md +++ b/README.md @@ -112,7 +112,7 @@ Remove-Item "$env:LOCALAPPDATA\codex-auth\bin\codex-auth-auto.exe" -Force -Error |---------|-------------| | `codex-auth config auto enable\|disable` | Enable or disable background auto-switching | | `codex-auth config auto [--5h <%>] [--weekly <%>]` | Set auto-switch thresholds | -| `codex-auth config api enable\|disable` | Use API-backed fallback or local-only usage refresh | +| `codex-auth config api enable\|disable` | Enable or disable both usage refresh and account-name refresh API calls | --- @@ -337,8 +337,8 @@ This project is provided as-is and use is at your own risk. **Usage Data Refresh Source:** `codex-auth` supports two sources for refreshing account usage/usage limit information: -1. **API (default):** When `config api enable` is on, the tool makes direct HTTPS requests to OpenAI's endpoints using your account's access token. This is the current default mode. -2. **Local-only:** When `config api disable` is on, the tool scans local `~/.codex/sessions/*/rollout-*.jsonl` files without making API calls. This mode is safer, but it can be less accurate because recent Codex rollout files often contain `rate_limits: null`, so the latest local usage limit data may lag by several hours. +1. **API (default):** When `config api enable` is on, the tool makes direct HTTPS requests to OpenAI's endpoints using your account's access token. This enables both usage refresh and grouped-account name refresh. +2. **Local-only:** When `config api disable` is on, the tool scans local `~/.codex/sessions/*/rollout-*.jsonl` files for usage data and skips account-name refresh API calls. This mode is safer, but it can be less accurate because recent Codex rollout files often contain `rate_limits: null`, so the latest local usage limit data may lag by several hours. **API Call Declaration:** -By enabling API-based usage refresh, this tool will send your ChatGPT access token to OpenAI's servers (specifically `https://chatgpt.com/backend-api/wham/usage`) to fetch current quota information. This behavior may be detected by OpenAI and could violate their terms of service, potentially leading to account suspension or other risks. The decision to use this feature and any resulting consequences are entirely yours. +By enabling API-backed refresh, this tool will send your ChatGPT access token to OpenAI's servers, including `https://chatgpt.com/backend-api/wham/usage` for quota information and `https://chatgpt.com/backend-api/accounts/check/v4-2023-04-27` for grouped-account metadata. This behavior may be detected by OpenAI and could violate their terms of service, potentially leading to account suspension or other risks. The decision to use this feature and any resulting consequences are entirely yours. diff --git a/review.md b/review.md new file mode 100644 index 0000000..9ce679b --- /dev/null +++ b/review.md @@ -0,0 +1,22 @@ +## Review Notes + +### P3 + +Accepted as-is. + +Same-email grouped accounts are allowed to resolve to the same `account_name`. In that case, duplicate child labels are acceptable, and we do not need to preserve the old grouped fallback labels such as `team #1` and `team #2` once a synced `account_name` is available. + +Example: + +- `user@example.com` / plan=`team` / `account_name="Acme"` +- `user@example.com` / plan=`team` / `account_name="Acme"` + +Rendered output: + +```text +user@example.com + Acme + Acme +``` + +This is acceptable for the new behavior. diff --git a/src/registry.zig b/src/registry.zig index 2b4cb4c..b13f984 100644 --- a/src/registry.zig +++ b/src/registry.zig @@ -55,6 +55,12 @@ pub const ApiConfig = struct { account: bool = true, }; +const ApiConfigParseResult = struct { + has_object: bool = false, + has_usage: bool = false, + has_account: bool = false, +}; + pub const AccountRecord = struct { account_key: []u8, chatgpt_account_id: []u8, @@ -2347,6 +2353,11 @@ fn usesLegacyVersionField(root_obj: std.json.ObjectMap) bool { fn currentLayoutNeedsRewrite(root_obj: std.json.ObjectMap) bool { if (root_obj.get("last_attributed_rollout") != null) return true; + if (root_obj.get("api")) |v| { + if (apiConfigNeedsRewrite(v)) return true; + } else { + return true; + } return root_obj.get("active_account_key") != null and root_obj.get("active_account_activated_at_ms") == null; } @@ -2563,22 +2574,45 @@ fn parseAutoSwitch(allocator: std.mem.Allocator, cfg: *AutoSwitchConfig, v: std. } fn parseApiConfig(cfg: *ApiConfig, v: std.json.Value) void { + _ = parseApiConfigDetailed(cfg, v); +} + +fn apiConfigNeedsRewrite(v: std.json.Value) bool { + var cfg = defaultApiConfig(); + const result = parseApiConfigDetailed(&cfg, v); + return !result.has_object or !result.has_usage or !result.has_account; +} + +fn parseApiConfigDetailed(cfg: *ApiConfig, v: std.json.Value) ApiConfigParseResult { const obj = switch (v) { .object => |o| o, - else => return, + else => return .{}, }; + var result = ApiConfigParseResult{ .has_object = true }; if (obj.get("usage")) |usage| { switch (usage) { - .bool => |flag| cfg.usage = flag, + .bool => |flag| { + cfg.usage = flag; + result.has_usage = true; + }, else => {}, } } if (obj.get("account")) |account| { switch (account) { - .bool => |flag| cfg.account = flag, + .bool => |flag| { + cfg.account = flag; + result.has_account = true; + }, else => {}, } } + if (result.has_usage and !result.has_account) { + cfg.account = cfg.usage; + } else if (result.has_account and !result.has_usage) { + cfg.usage = cfg.account; + } + return result; } fn parseRolloutSignature(allocator: std.mem.Allocator, v: std.json.Value) ?RolloutSignature { diff --git a/src/tests/e2e_cli_test.zig b/src/tests/e2e_cli_test.zig index 5596c4e..f8471b0 100644 --- a/src/tests/e2e_cli_test.zig +++ b/src/tests/e2e_cli_test.zig @@ -1200,8 +1200,8 @@ test "Scenario: Given remove query with multiple matches in non-tty mode when ru try std.testing.expectEqualStrings("", result.stdout); try std.testing.expectEqualStrings( "Matched multiple accounts:\n" ++ - "- team-a\n" ++ - "- team-b\n" ++ + "- alpha@example.com / team-a\n" ++ + "- beta@example.com / team-b\n" ++ "error: multiple accounts match the query in non-interactive mode.\n" ++ "hint: Refine the query to match one account, or run the command in a TTY.\n", result.stderr, diff --git a/src/tests/registry_test.zig b/src/tests/registry_test.zig index d5a9d28..bb01917 100644 --- a/src/tests/registry_test.zig +++ b/src/tests/registry_test.zig @@ -383,6 +383,83 @@ test "registry load defaults missing auto threshold fields" { try std.testing.expect(loaded.api.usage); try std.testing.expect(loaded.api.account); try std.testing.expect(loaded.active_account_activated_at_ms == null); + + const registry_path = try std.fs.path.join(gpa, &[_][]const u8{ codex_home, "accounts", "registry.json" }); + defer gpa.free(registry_path); + const saved = try bdd.readFileAlloc(gpa, registry_path); + defer gpa.free(saved); + try std.testing.expect(std.mem.indexOf(u8, saved, "\"usage\": true") != null); + try std.testing.expect(std.mem.indexOf(u8, saved, "\"account\": true") != null); +} + +test "registry load backfills missing api.account from api.usage and rewrites file" { + const gpa = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + const codex_home = try tmp.dir.realpathAlloc(gpa, "."); + defer gpa.free(codex_home); + try tmp.dir.makePath("accounts"); + try tmp.dir.writeFile(.{ + .sub_path = "accounts/registry.json", + .data = + \\{ + \\ "schema_version": 3, + \\ "active_account_key": null, + \\ "api": { + \\ "usage": false + \\ }, + \\ "accounts": [] + \\} + , + }); + + var loaded = try registry.loadRegistry(gpa, codex_home); + defer loaded.deinit(gpa); + try std.testing.expect(!loaded.api.usage); + try std.testing.expect(!loaded.api.account); + + const registry_path = try std.fs.path.join(gpa, &[_][]const u8{ codex_home, "accounts", "registry.json" }); + defer gpa.free(registry_path); + const saved = try bdd.readFileAlloc(gpa, registry_path); + defer gpa.free(saved); + try std.testing.expect(std.mem.indexOf(u8, saved, "\"usage\": false") != null); + try std.testing.expect(std.mem.indexOf(u8, saved, "\"account\": false") != null); +} + +test "registry load backfills missing api.usage from api.account and rewrites file" { + const gpa = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + const codex_home = try tmp.dir.realpathAlloc(gpa, "."); + defer gpa.free(codex_home); + try tmp.dir.makePath("accounts"); + try tmp.dir.writeFile(.{ + .sub_path = "accounts/registry.json", + .data = + \\{ + \\ "schema_version": 3, + \\ "active_account_key": null, + \\ "api": { + \\ "account": false + \\ }, + \\ "accounts": [] + \\} + , + }); + + var loaded = try registry.loadRegistry(gpa, codex_home); + defer loaded.deinit(gpa); + try std.testing.expect(!loaded.api.usage); + try std.testing.expect(!loaded.api.account); + + const registry_path = try std.fs.path.join(gpa, &[_][]const u8{ codex_home, "accounts", "registry.json" }); + defer gpa.free(registry_path); + const saved = try bdd.readFileAlloc(gpa, registry_path); + defer gpa.free(saved); + try std.testing.expect(std.mem.indexOf(u8, saved, "\"usage\": false") != null); + try std.testing.expect(std.mem.indexOf(u8, saved, "\"account\": false") != null); } test "schema 3 registry with legacy rollout attribution rewrites to normalized schema 3" { From 8584268552092b895db8cac585922d8b62cc76a9 Mon Sep 17 00:00:00 2001 From: Loongphy Date: Thu, 26 Mar 2026 20:48:52 +0800 Subject: [PATCH 09/22] fix(display): preserve email labels for singleton accounts --- src/cli.zig | 7 ++++- src/display_rows.zig | 2 +- src/tests/cli_bdd_test.zig | 22 ++++++++++++++ src/tests/display_rows_test.zig | 53 ++++++++++++++++++++++++++++++--- 4 files changed, 78 insertions(+), 6 deletions(-) diff --git a/src/cli.zig b/src/cli.zig index 98636d8..a8050c3 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -901,7 +901,12 @@ pub fn buildRemoveLabels( const label = if (row.depth == 0 or current_header == null) blk: { const rec = ®.accounts.items[row.account_index.?]; if (std.mem.eql(u8, row.account_cell, rec.email)) { - break :blk try allocator.dupe(u8, row.account_cell); + const preferred = try display_rows.buildPreferredAccountLabelAlloc(allocator, rec, rec.email); + defer allocator.free(preferred); + if (std.mem.eql(u8, preferred, rec.email)) { + break :blk try allocator.dupe(u8, row.account_cell); + } + break :blk try std.fmt.allocPrint(allocator, "{s} / {s}", .{ rec.email, preferred }); } break :blk try std.fmt.allocPrint(allocator, "{s} / {s}", .{ rec.email, row.account_cell }); } else try std.fmt.allocPrint(allocator, "{s} / {s}", .{ current_header.?, row.account_cell }); diff --git a/src/display_rows.zig b/src/display_rows.zig index 24d28f9..dc75477 100644 --- a/src/display_rows.zig +++ b/src/display_rows.zig @@ -154,7 +154,7 @@ fn isActive(reg: *const registry.Registry, account_idx: usize) bool { } fn singletonAccountCellAlloc(allocator: std.mem.Allocator, rec: *const registry.AccountRecord) ![]u8 { - return buildPreferredAccountLabelAlloc(allocator, rec, rec.email); + return allocator.dupe(u8, rec.email); } fn groupedAccountCellAlloc( diff --git a/src/tests/cli_bdd_test.zig b/src/tests/cli_bdd_test.zig index 5b88a18..356150f 100644 --- a/src/tests/cli_bdd_test.zig +++ b/src/tests/cli_bdd_test.zig @@ -751,6 +751,28 @@ test "Scenario: Given singleton aliases from different emails when building remo try std.testing.expectEqualStrings("beta@example.com / work", labels.items[1]); } +test "Scenario: Given singleton account names from different emails when building remove labels then each label keeps email context" { + const gpa = std.testing.allocator; + var reg = makeRegistry(); + defer reg.deinit(gpa); + + try appendAccount(gpa, ®, "user-A::acct-1", "alpha@example.com", "", .team); + reg.accounts.items[0].account_name = try gpa.dupe(u8, "Workspace"); + try appendAccount(gpa, ®, "user-B::acct-2", "beta@example.com", "", .team); + reg.accounts.items[1].account_name = try gpa.dupe(u8, "Workspace"); + + const indices = [_]usize{ 0, 1 }; + var labels = try cli.buildRemoveLabels(gpa, ®, &indices); + defer { + for (labels.items) |label| gpa.free(@constCast(label)); + labels.deinit(gpa); + } + + try std.testing.expectEqual(@as(usize, 2), labels.items.len); + try std.testing.expectEqualStrings("alpha@example.com / Workspace", labels.items[0]); + try std.testing.expectEqualStrings("beta@example.com / Workspace", labels.items[1]); +} + test "Scenario: Given selector environment when deciding remove UI then non-tty or windows use the numbered selector" { try std.testing.expect(cli.shouldUseNumberedRemoveSelector(false, false)); try std.testing.expect(!cli.shouldUseNumberedRemoveSelector(false, true)); diff --git a/src/tests/display_rows_test.zig b/src/tests/display_rows_test.zig index a5fb550..ad88805 100644 --- a/src/tests/display_rows_test.zig +++ b/src/tests/display_rows_test.zig @@ -80,7 +80,30 @@ test "Scenario: Given grouped accounts with aliases when building display rows t try std.testing.expect(std.mem.eql(u8, rows.rows[2].account_cell, "backup") or std.mem.eql(u8, rows.rows[2].account_cell, "work")); } -test "Scenario: Given singleton accounts with alias and account name combinations when building display rows then preferred labels are used" { +test "Scenario: Given same-email accounts filtered down to one row when building display rows then singleton is decided from the rendered subset" { + const gpa = std.testing.allocator; + var reg = makeRegistry(); + defer reg.deinit(gpa); + + try appendAccount(gpa, ®, "user-A::acct-1", "user@example.com", "work", .team); + reg.accounts.items[0].account_name = try gpa.dupe(u8, "Primary Workspace"); + try appendAccount(gpa, ®, "user-A::acct-2", "user@example.com", "", .plus); + + var grouped_rows = try display_rows.buildDisplayRows(gpa, ®, null); + defer grouped_rows.deinit(gpa); + try std.testing.expectEqual(@as(usize, 3), grouped_rows.rows.len); + try std.testing.expect(grouped_rows.rows[0].account_index == null); + try std.testing.expect(std.mem.eql(u8, grouped_rows.rows[0].account_cell, "user@example.com")); + + const indices = [_]usize{0}; + var singleton_rows = try display_rows.buildDisplayRows(gpa, ®, &indices); + defer singleton_rows.deinit(gpa); + try std.testing.expectEqual(@as(usize, 1), singleton_rows.rows.len); + try std.testing.expect(singleton_rows.rows[0].account_index != null); + try std.testing.expect(std.mem.eql(u8, singleton_rows.rows[0].account_cell, "user@example.com")); +} + +test "Scenario: Given singleton accounts with alias and account name combinations when building display rows then email labels are preserved" { const gpa = std.testing.allocator; var reg = makeRegistry(); defer reg.deinit(gpa); @@ -96,10 +119,32 @@ test "Scenario: Given singleton accounts with alias and account name combination defer rows.deinit(gpa); try std.testing.expectEqual(@as(usize, 4), rows.rows.len); - try std.testing.expect(std.mem.eql(u8, rows.rows[0].account_cell, "work (Primary Workspace)")); - try std.testing.expect(std.mem.eql(u8, rows.rows[1].account_cell, "backup")); + try std.testing.expect(std.mem.eql(u8, rows.rows[0].account_cell, "alias-name@example.com")); + try std.testing.expect(std.mem.eql(u8, rows.rows[1].account_cell, "alias-only@example.com")); try std.testing.expect(std.mem.eql(u8, rows.rows[2].account_cell, "fallback@example.com")); - try std.testing.expect(std.mem.eql(u8, rows.rows[3].account_cell, "Sandbox")); + try std.testing.expect(std.mem.eql(u8, rows.rows[3].account_cell, "name-only@example.com")); +} + +test "Scenario: Given mixed singleton and grouped accounts when building display rows then singleton rows keep email while grouped rows keep preferred labels" { + const gpa = std.testing.allocator; + var reg = makeRegistry(); + defer reg.deinit(gpa); + + try appendAccount(gpa, ®, "user-SOLO::acct-1", "solo@example.com", "solo", .team); + reg.accounts.items[0].account_name = try gpa.dupe(u8, "Solo Workspace"); + try appendAccount(gpa, ®, "user-GROUP::acct-2", "user@example.com", "work", .team); + reg.accounts.items[1].account_name = try gpa.dupe(u8, "Primary Workspace"); + try appendAccount(gpa, ®, "user-GROUP::acct-3", "user@example.com", "", .plus); + + var rows = try display_rows.buildDisplayRows(gpa, ®, null); + defer rows.deinit(gpa); + + try std.testing.expectEqual(@as(usize, 4), rows.rows.len); + try std.testing.expect(std.mem.eql(u8, rows.rows[0].account_cell, "solo@example.com")); + try std.testing.expect(rows.rows[1].account_index == null); + try std.testing.expect(std.mem.eql(u8, rows.rows[1].account_cell, "user@example.com")); + try std.testing.expect(std.mem.eql(u8, rows.rows[2].account_cell, "work (Primary Workspace)")); + try std.testing.expect(std.mem.eql(u8, rows.rows[3].account_cell, "plus")); } test "Scenario: Given grouped accounts with account names when building display rows then child labels use the same precedence" { From 4385e2db7d6cb0007745acc41dc989f0b6a2c6fb Mon Sep 17 00:00:00 2001 From: Loongphy Date: Thu, 26 Mar 2026 20:55:45 +0800 Subject: [PATCH 10/22] feat: refresh grouped account names in background --- README.md | 4 +- docs/api-refresh.md | 102 ++++++++++++++++++++++++++++++++++++ docs/implement.md | 22 +++----- src/main.zig | 49 ++++++++++++++++- src/registry.zig | 29 +++++----- src/tests/e2e_cli_test.zig | 2 + src/tests/main_test.zig | 33 +++++++++++- src/tests/registry_test.zig | 47 +++++++++++++++++ 8 files changed, 256 insertions(+), 32 deletions(-) create mode 100644 docs/api-refresh.md diff --git a/README.md b/README.md index 8d4965f..94803f1 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,8 @@ codex-auth switch Before the picker opens, the current active account's usage is refreshed once so the selected row is not stale. The newly selected account is not refreshed after the switch completes. +If grouped Team `account_name` metadata still needs syncing, that refresh is scheduled in the background after the switch is already saved, so `codex-auth switch` can return immediately. + ![command switch](https://github.com/user-attachments/assets/48a86acf-2a6e-4206-a8c4-591989fdc0df) Non-interactive: fuzzy match by email or alias. @@ -263,7 +265,7 @@ codex-auth config api disable Changing `config api` updates `registry.json` immediately. `api enable` is shown as API mode and `api disable` is shown as local mode. -Implementation details are documented in [`docs/auto-switch.md`](docs/auto-switch.md). +Implementation details are documented in [`docs/auto-switch.md`](docs/auto-switch.md) and [`docs/api-refresh.md`](docs/api-refresh.md). ## Q&A diff --git a/docs/api-refresh.md b/docs/api-refresh.md new file mode 100644 index 0000000..c68b3c1 --- /dev/null +++ b/docs/api-refresh.md @@ -0,0 +1,102 @@ +# API Refresh + +This document is the single source of truth for outbound ChatGPT API refresh behavior in `codex-auth`. + +## Endpoints + +### Usage Refresh + +- method: `GET` +- URL: `https://chatgpt.com/backend-api/wham/usage` +- headers: + - `Authorization: Bearer ` + - `ChatGPT-Account-Id: ` + - `User-Agent: codex-auth` + +### Account Metadata Refresh + +- method: `GET` +- URL: `https://chatgpt.com/backend-api/accounts/check/v4-2023-04-27` +- headers: + - `Authorization: Bearer ` + - `ChatGPT-Account-Id: ` when present + - `User-Agent: codex-auth` + +The `accounts/check` response is parsed by `chatgpt_account_id`. `name: null` and `name: ""` are both normalized to `account_name = null`. + +## Usage Refresh Rules + +- `api.usage = true`: foreground refresh uses the usage API. +- `api.usage = false`: foreground refresh reads only the newest local `~/.codex/sessions/**/rollout-*.jsonl`. +- `list` refreshes the current active account before rendering. +- `switch` refreshes the current active account before showing the picker so the currently selected row is not stale. +- `switch` does not refresh usage for the newly selected account after the switch completes. +- The background auto-switch watcher has its own runtime strategy; see [docs/auto-switch.md](./auto-switch.md). + +## Account Name Refresh Rules + +- `api.account = true` is required. +- `login` refreshes immediately after the new active auth is ready. +- Single-file `import` refreshes immediately for the imported auth context. +- `list` refreshes in the foreground for the current active scope when that scope still has missing Team `account_name` values. +- `switch` saves the selected account first, then schedules a best-effort background refresh so the command can exit immediately without waiting for `accounts/check`. + +At most one `accounts/check` request is attempted per refresh path. + +## Refresh Scope + +The grouped account-name refresh scope is anchored on the current active or imported `chatgpt_user_id`. + +That scope includes: + +- all records with the same `chatgpt_user_id` +- all records whose email matches any email owned by that user + +This means a `free`, `plus`, or `pro` record can still trigger a grouped Team-name refresh when it shares an email grouping with Team records. + +`accounts/check` is attempted only when: + +- the scope contains more than one record +- the scope contains at least one Team record +- at least one Team record in that scope still has `account_name = null` + +## Apply Rules + +After a successful `accounts/check` response: + +- returned entries are matched by `chatgpt_account_id` +- matched records overwrite the stored `account_name`, even when a Team record already had an older value +- in-scope Team records, or in-scope records that already had an `account_name`, are cleared back to `null` when they are not returned by the response +- records outside the scope are left unchanged + +## Examples + +Example 1: + +- active record: `user@example.com / team #1 / account_name = null` +- same grouped scope: `user@example.com / team #2 / account_name = null` + +Running `codex-auth list` should issue `accounts/check`. If the API returns: + +- `team-1 -> "Workspace Alpha"` +- `team-2 -> "Workspace Beta"` + +Then both grouped Team records are updated. + +Example 2: + +- active record: `user@example.com / pro / account_name = null` +- same grouped scope: `user@example.com / team #1 / account_name = null` +- same grouped scope: `user@example.com / team #2 / account_name = "Old Workspace"` + +Running `codex-auth list` should still issue `accounts/check`, because the grouped scope still has missing Team names. If the API returns: + +- `team-1 -> "Prod Workspace"` +- `team-2 -> "Sandbox Workspace"` + +Then: + +- `team #1` is filled with `Prod Workspace` +- `team #2` is overwritten from `Old Workspace` to `Sandbox Workspace` + +The same grouped-scope rule also applies after `switch`, but the refresh runs in the background after the switch is already saved. diff --git a/docs/implement.md b/docs/implement.md index 2d1614c..70c19eb 100644 --- a/docs/implement.md +++ b/docs/implement.md @@ -1,6 +1,6 @@ # Implementation Details -This document describes how `codex-auth` stores accounts, synchronizes auth files, and refreshes metadata. The tool reads and writes local files under `~/.codex`, and for ChatGPT-auth usage refresh it can call the ChatGPT usage endpoint for the current active account. +This document describes how `codex-auth` stores accounts, synchronizes auth files, and manages local state under `~/.codex`. Outbound API refresh rules, endpoint contracts, and grouped account-name sync examples now live in [docs/api-refresh.md](./api-refresh.md). ## Packaging and Release @@ -167,6 +167,8 @@ When switching: The switch command refreshes the current active account's usage once before rendering account choices, so the picker does not show stale data for the currently selected account. It does not refresh the newly selected account after the switch completes. +Grouped account-name metadata refresh, when needed, is scheduled after the switch has already been saved so the command can return immediately; see [docs/api-refresh.md](./api-refresh.md). + ## Removing Accounts `remove` now supports three foreground modes: @@ -230,21 +232,14 @@ This document keeps only the cross-reference points that matter to the rest of t ## Usage and Rate Limits -Foreground usage refresh is active-account-only and depends on `api.usage`: +Detailed API-backed refresh behavior now lives in [docs/api-refresh.md](./api-refresh.md). This section keeps only the local-state and rollout rules that interact with the rest of the implementation. + +Foreground usage refresh still depends on `api.usage`: -1. If `api.usage = true`, foreground refresh tries only the ChatGPT usage API with the current active `~/.codex/auth.json`. +1. If `api.usage = true`, the API contract and timing rules are defined in [docs/api-refresh.md](./api-refresh.md). 2. If `api.usage = false`, read only the newest `~/.codex/sessions/**/rollout-*.jsonl` file by `mtime`. -- ChatGPT API refresh sends `Authorization: Bearer ` and `ChatGPT-Account-Id: ` to `https://chatgpt.com/backend-api/wham/usage`. -- Foreground API refresh updates only the current active account. The background watcher keeps a daemon-local in-memory candidate index for non-active accounts and can refresh candidate ChatGPT accounts from their stored `accounts/.auth.json` snapshots without reloading the whole candidate set every cycle. -- In watcher mode, candidate freshness bookkeeping is runtime-only: the daemon keeps per-candidate last-checked timestamps in memory, does bounded top-candidate upkeep while the active account is healthy, and revalidates only the current heap top / next top candidates before a switch instead of re-fetching every candidate on every 1-second loop. -- In watcher mode, active-account API fallback cooldown is scoped to the current active account; switching to a different active account resets that cooldown for the new account. -- Watcher logs use compact `[local]`, `[api]`, and `[switch]` tags. - Local rollout watcher logs print the actual window lengths from the snapshot first, then the local event timestamp, then the full rollout basename (including the UUID suffix); when the newest event has no usable usage windows the same `[local]` log line also adds `fallback-to-api`. -- `config auto enable` prints a short usage-mode note after installing the watcher so the user can see whether auto-switch is currently running with API-backed usage or local-only fallback semantics. -- API refresh writes a new snapshot only when the fetched snapshot differs from the stored one; unchanged API responses do not rewrite `registry.json`. -- Watcher API logs are reduced to `refresh usage | status=...`, where `status` is either the HTTP status code, `NoUsageLimitsWindow`, `MissingAuth`, or the direct request error name. -- In API-only mode, API failures do not overwrite the stored usage snapshot and do not fall back to local rollout files. - The rollout scanner looks for `type:"event_msg"` and `payload.type:"token_count"`. - The rollout scanner reads only the newest rollout file. Within that file, it uses the last `token_count` event whose `rate_limits` payload is a parseable object. - If the newest rollout file has no usable `rate_limits` payload (for example `rate_limits: null` on every `token_count` event), refresh does not overwrite the account's existing stored usage snapshot. @@ -253,14 +248,11 @@ Foreground usage refresh is active-account-only and depends on `api.usage`: - Rate limits are mapped by `window_minutes`: `300` → 5h, `10080` → weekly (fallback to primary/secondary). - If `resets_at` is in the past, the UI shows `100%`. - `last_usage_at` stores the last time a newly observed snapshot was written; identical API refreshes leave it unchanged. -- `list` and `switch` use the foreground active-account refresh path. - The background auto-switch watcher has its own near-real-time refresh strategy; see `docs/auto-switch.md`. - In watcher mode, rollout scanning caches the newest rollout file between bounded full rescans so large `~/.codex/sessions` trees are not fully re-walked on every 1-second loop. - The free-plan `35%` real-time guard applies only when the 5h trigger comes from an actual 300-minute window or an unlabeled primary window; weekly-only free accounts still switch based on the configured weekly threshold. - For auto-switch candidate scoring, free accounts that expose only a single weekly (`10080`-minute) window still remain eligible and use that weekly remaining percentage as their candidate score. - On Linux/WSL, watcher installation/removal now explicitly deletes the old `codex-auth-autoswitch.timer` unit file so legacy minute-timer installs do not continue to fire after migration to the watcher service. -- `switch` refreshes only the current active account before the selection/switch step; it does not refresh the newly selected account after the switch completes. -- API refresh does not mutate any local rollout attribution state. - The rollout files still do not expose a stable account identity, so local-session ownership remains activation-window based rather than identity based. Current registry/account field roles: diff --git a/src/main.zig b/src/main.zig index 6268719..98e4d67 100644 --- a/src/main.zig +++ b/src/main.zig @@ -7,6 +7,8 @@ const auto = @import("auto.zig"); const format = @import("format.zig"); const skip_service_reconcile_env = "CODEX_AUTH_SKIP_SERVICE_RECONCILE"; +const account_name_refresh_only_env = "CODEX_AUTH_REFRESH_ACCOUNT_NAMES_ONLY"; +const disable_background_account_name_refresh_env = "CODEX_AUTH_DISABLE_BACKGROUND_ACCOUNT_NAME_REFRESH"; const AccountFetchFn = *const fn ( allocator: std.mem.Allocator, @@ -105,6 +107,14 @@ pub fn shouldRefreshForegroundUsage(target: ForegroundUsageRefreshTarget) bool { return target == .list or target == .switch_account; } +fn isAccountNameRefreshOnlyMode() bool { + return std.process.hasNonEmptyEnvVarConstant(account_name_refresh_only_env); +} + +fn isBackgroundAccountNameRefreshDisabled() bool { + return std.process.hasNonEmptyEnvVarConstant(disable_background_account_name_refresh_env); +} + fn trackedActiveAccountKey(reg: *registry.Registry) ?[]const u8 { const account_key = reg.active_account_key orelse return null; if (registry.findAccountIndexByAccountKey(reg, account_key) == null) return null; @@ -270,6 +280,42 @@ pub fn refreshAccountNamesForList( return try refreshAccountNamesForActiveAuth(allocator, codex_home, reg, fetcher); } +fn spawnBackgroundAccountNameRefresh(allocator: std.mem.Allocator) !void { + var env_map = std.process.getEnvMap(allocator) catch |err| { + std.log.warn("background account metadata refresh skipped: {s}", .{@errorName(err)}); + return; + }; + defer env_map.deinit(); + + try env_map.put(account_name_refresh_only_env, "1"); + try env_map.put(disable_background_account_name_refresh_env, "1"); + try env_map.put(skip_service_reconcile_env, "1"); + + const self_exe = try std.fs.selfExePathAlloc(allocator); + defer allocator.free(self_exe); + + var child = std.process.Child.init(&[_][]const u8{ self_exe, "list" }, allocator); + child.env_map = &env_map; + child.stdin_behavior = .Ignore; + child.stdout_behavior = .Ignore; + child.stderr_behavior = .Ignore; + child.create_no_window = true; + try child.spawn(); +} + +fn maybeSpawnBackgroundAccountNameRefresh( + allocator: std.mem.Allocator, + reg: *registry.Registry, +) void { + if (isBackgroundAccountNameRefreshDisabled()) return; + const active_user_id = registry.activeChatgptUserId(reg) orelse return; + if (!registry.shouldFetchTeamAccountNamesForUser(reg, active_user_id)) return; + + spawnBackgroundAccountNameRefresh(allocator) catch |err| { + std.log.warn("background account metadata refresh skipped: {s}", .{@errorName(err)}); + }; +} + pub fn refreshAccountNamesAfterImport( allocator: std.mem.Allocator, reg: *registry.Registry, @@ -354,6 +400,7 @@ fn handleList(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli.Li if (try refreshAccountNamesForList(allocator, codex_home, ®, defaultAccountFetcher)) { try registry.saveRegistry(allocator, codex_home, ®); } + if (isAccountNameRefreshOnlyMode()) return; try maybeRefreshForegroundUsage(allocator, codex_home, ®, .list); try format.printAccounts(®); } @@ -453,8 +500,8 @@ fn handleSwitch(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli. const account_key = selected_account_key.?; try registry.activateAccountByKey(allocator, codex_home, ®, account_key); - _ = try refreshAccountNamesAfterSwitch(allocator, codex_home, ®, defaultAccountFetcher); try registry.saveRegistry(allocator, codex_home, ®); + maybeSpawnBackgroundAccountNameRefresh(allocator, ®); } fn handleConfig(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli.ConfigOptions) !void { diff --git a/src/registry.zig b/src/registry.zig index b13f984..77f15bf 100644 --- a/src/registry.zig +++ b/src/registry.zig @@ -1778,20 +1778,22 @@ fn isTeamAccount(rec: *const AccountRecord) bool { return plan == .team; } -fn emailHasMultipleAccounts(reg: *const Registry, email: []const u8) bool { - var count: usize = 0; +fn userOwnsEmail(reg: *const Registry, chatgpt_user_id: []const u8, email: []const u8) bool { for (reg.accounts.items) |rec| { - if (!std.mem.eql(u8, rec.email, email)) continue; - count += 1; - if (count > 1) return true; + if (!std.mem.eql(u8, rec.chatgpt_user_id, chatgpt_user_id)) continue; + if (std.mem.eql(u8, rec.email, email)) return true; } return false; } +fn inAccountNameRefreshScope(reg: *const Registry, chatgpt_user_id: []const u8, rec: *const AccountRecord) bool { + return std.mem.eql(u8, rec.chatgpt_user_id, chatgpt_user_id) or userOwnsEmail(reg, chatgpt_user_id, rec.email); +} + pub fn hasMissingAccountNameForUser(reg: *const Registry, chatgpt_user_id: []const u8) bool { for (reg.accounts.items) |rec| { - if (!std.mem.eql(u8, rec.chatgpt_user_id, chatgpt_user_id)) continue; - if (!hasStoredAccountName(&rec)) return true; + if (!inAccountNameRefreshScope(reg, chatgpt_user_id, &rec)) continue; + if (isTeamAccount(&rec) and !hasStoredAccountName(&rec)) return true; } return false; } @@ -1800,15 +1802,11 @@ pub fn shouldFetchTeamAccountNamesForUser(reg: *const Registry, chatgpt_user_id: var account_count: usize = 0; var has_team_account = false; var has_missing_team_account_name = false; - var has_grouped_email = false; for (reg.accounts.items) |rec| { - if (!std.mem.eql(u8, rec.chatgpt_user_id, chatgpt_user_id)) continue; + if (!inAccountNameRefreshScope(reg, chatgpt_user_id, &rec)) continue; account_count += 1; - if (!has_grouped_email and emailHasMultipleAccounts(reg, rec.email)) { - has_grouped_email = true; - } if (!isTeamAccount(&rec)) continue; has_team_account = true; @@ -1818,7 +1816,7 @@ pub fn shouldFetchTeamAccountNamesForUser(reg: *const Registry, chatgpt_user_id: } if (!has_team_account or !has_missing_team_account_name) return false; - return account_count > 1 or has_grouped_email; + return account_count > 1; } pub fn activeChatgptUserId(reg: *Registry) ?[]const u8 { @@ -1835,15 +1833,18 @@ pub fn applyAccountNamesForUser( ) !bool { var changed = false; for (reg.accounts.items) |*rec| { - if (!std.mem.eql(u8, rec.chatgpt_user_id, chatgpt_user_id)) continue; + if (!inAccountNameRefreshScope(reg, chatgpt_user_id, rec)) continue; var account_name: ?[]const u8 = null; + var matched = false; for (entries) |entry| { if (!std.mem.eql(u8, rec.chatgpt_account_id, entry.account_id)) continue; account_name = entry.account_name; + matched = true; break; } + if (!matched and !isTeamAccount(rec) and !hasStoredAccountName(rec)) continue; if (try replaceOptionalStringAlloc(allocator, &rec.account_name, account_name)) { changed = true; } diff --git a/src/tests/e2e_cli_test.zig b/src/tests/e2e_cli_test.zig index f8471b0..d42cb65 100644 --- a/src/tests/e2e_cli_test.zig +++ b/src/tests/e2e_cli_test.zig @@ -76,6 +76,7 @@ fn runCliWithIsolatedHome( try env_map.put("HOME", home_root); try env_map.put("USERPROFILE", home_root); try env_map.put("CODEX_AUTH_SKIP_SERVICE_RECONCILE", "1"); + try env_map.put("CODEX_AUTH_DISABLE_BACKGROUND_ACCOUNT_NAME_REFRESH", "1"); return try std.process.Child.run(.{ .allocator = allocator, @@ -106,6 +107,7 @@ fn runCliWithIsolatedHomeAndStdin( try env_map.put("HOME", home_root); try env_map.put("USERPROFILE", home_root); try env_map.put("CODEX_AUTH_SKIP_SERVICE_RECONCILE", "1"); + try env_map.put("CODEX_AUTH_DISABLE_BACKGROUND_ACCOUNT_NAME_REFRESH", "1"); var child = std.process.Child.init(argv.items, allocator); child.cwd = project_root; diff --git a/src/tests/main_test.zig b/src/tests/main_test.zig index ebc1c52..f85f753 100644 --- a/src/tests/main_test.zig +++ b/src/tests/main_test.zig @@ -9,6 +9,7 @@ const bdd = @import("bdd_helpers.zig"); const shared_user_id = "user-ESYgcy2QkOGZc0NoxSlFCeVT"; const primary_account_id = "67fe2bbb-0de6-49a4-b2b3-d1df366d1faf"; const secondary_account_id = "518a44d9-ba75-4bad-87e5-ae9377042960"; +const tertiary_account_id = "a4021fa5-998b-4774-989f-784fa69c367b"; const primary_record_key = shared_user_id ++ "::" ++ primary_account_id; const secondary_record_key = shared_user_id ++ "::" ++ secondary_account_id; const standalone_team_user_id = "user-q2Lm6Nx8Vc4Rb7Ty1Hp9JkDs"; @@ -232,7 +233,7 @@ test "Scenario: Given team name fetch candidates when checking grouped-account p try std.testing.expect(registry.shouldFetchTeamAccountNamesForUser(®, shared_user_id)); try std.testing.expect(registry.shouldFetchTeamAccountNamesForUser(®, "user-email-team")); - try std.testing.expect(!registry.shouldFetchTeamAccountNamesForUser(®, "user-email-plus")); + try std.testing.expect(registry.shouldFetchTeamAccountNamesForUser(®, "user-email-plus")); try std.testing.expect(!registry.shouldFetchTeamAccountNamesForUser(®, standalone_team_user_id)); try std.testing.expect(!registry.shouldFetchTeamAccountNamesForUser(®, "user-plus-only")); } @@ -444,6 +445,36 @@ test "Scenario: Given list refresh with missing active-user account names when r try std.testing.expectEqual(@as(usize, 0), mock_account_name_fetch_count); } +test "Scenario: Given list refresh with same-email team names missing under a different active user when refreshing metadata then it updates the team records" { + const gpa = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + const codex_home = try tmp.dir.realpathAlloc(gpa, "."); + defer gpa.free(codex_home); + + var reg = makeRegistry(); + defer reg.deinit(gpa); + try appendAccount(gpa, ®, "user-email-team::" ++ primary_account_id, "same-email@example.com", "", .team); + try appendAccount(gpa, ®, "user-email-team::" ++ secondary_account_id, "same-email@example.com", "", .team); + reg.accounts.items[1].account_name = try gpa.dupe(u8, "Old Backup Workspace"); + try appendAccount(gpa, ®, "user-email-plus::" ++ tertiary_account_id, "same-email@example.com", "", .plus); + try registry.setActiveAccountKey(gpa, ®, "user-email-plus::" ++ tertiary_account_id); + try writeActiveAuthWithIds(gpa, codex_home, "same-email@example.com", "plus", "user-email-plus", tertiary_account_id); + + resetMockAccountNameFetcher(); + const changed = try main_mod.refreshAccountNamesForList(gpa, codex_home, ®, mockAccountNameFetcher); + try std.testing.expect(changed); + try std.testing.expectEqual(@as(usize, 1), mock_account_name_fetch_count); + try std.testing.expect(std.mem.eql(u8, reg.accounts.items[0].account_name.?, "Primary Workspace")); + try std.testing.expect(std.mem.eql(u8, reg.accounts.items[1].account_name.?, "Backup Workspace")); + try std.testing.expect(reg.accounts.items[2].account_name == null); + + resetMockAccountNameFetcher(); + try std.testing.expect(!(try main_mod.refreshAccountNamesForList(gpa, codex_home, ®, mockAccountNameFetcher))); + try std.testing.expectEqual(@as(usize, 0), mock_account_name_fetch_count); +} + test "Scenario: Given removed active account with remaining accounts when reconciling then the best usage account becomes active" { const gpa = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); diff --git a/src/tests/registry_test.zig b/src/tests/registry_test.zig index bb01917..ef0c60e 100644 --- a/src/tests/registry_test.zig +++ b/src/tests/registry_test.zig @@ -117,6 +117,20 @@ fn makeAccountRecord( }; } +fn setRecordIds( + allocator: std.mem.Allocator, + rec: *registry.AccountRecord, + chatgpt_user_id: []const u8, + chatgpt_account_id: []const u8, +) !void { + allocator.free(rec.chatgpt_user_id); + rec.chatgpt_user_id = try allocator.dupe(u8, chatgpt_user_id); + allocator.free(rec.chatgpt_account_id); + rec.chatgpt_account_id = try allocator.dupe(u8, chatgpt_account_id); + allocator.free(rec.account_key); + rec.account_key = try std.fmt.allocPrint(allocator, "{s}::{s}", .{ chatgpt_user_id, chatgpt_account_id }); +} + fn countBackups(dir: std.fs.Dir, prefix: []const u8) !usize { var count: usize = 0; var it = dir.iterate(); @@ -330,6 +344,39 @@ test "applyAccountNamesForUser preserves existing account_name when replacement try std.testing.expectEqualStrings("Primary Workspace", reg.accounts.items[0].account_name.?); } +test "applyAccountNamesForUser updates same-email team records for a different active user" { + const gpa = std.testing.allocator; + var reg = makeEmptyRegistry(); + defer reg.deinit(gpa); + + var team = try makeAccountRecord(gpa, "same@example.com", "", .team, .chatgpt, 1); + try setRecordIds(gpa, &team, "user-team", "acct-team"); + team.account_name = try gpa.dupe(u8, "Legacy Workspace"); + try reg.accounts.append(gpa, team); + + var plus = try makeAccountRecord(gpa, "same@example.com", "", .plus, .chatgpt, 2); + try setRecordIds(gpa, &plus, "user-plus", "acct-plus"); + try reg.accounts.append(gpa, plus); + + var other = try makeAccountRecord(gpa, "other@example.com", "", .team, .chatgpt, 3); + try setRecordIds(gpa, &other, "user-other", "acct-other"); + other.account_name = try gpa.dupe(u8, "Unrelated Workspace"); + try reg.accounts.append(gpa, other); + + var entry = account_api.AccountEntry{ + .account_id = try gpa.dupe(u8, "acct-team"), + .account_name = try gpa.dupe(u8, "Primary Workspace"), + }; + defer entry.deinit(gpa); + + const entries = [_]account_api.AccountEntry{entry}; + const changed = try registry.applyAccountNamesForUser(gpa, ®, "user-plus", &entries); + try std.testing.expect(changed); + try std.testing.expectEqualStrings("Primary Workspace", reg.accounts.items[0].account_name.?); + try std.testing.expect(reg.accounts.items[1].account_name == null); + try std.testing.expectEqualStrings("Unrelated Workspace", reg.accounts.items[2].account_name.?); +} + test "registry save/load round-trips api.account false" { const gpa = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); From b10c8a098fd1317a3024b07310148e9484a754b1 Mon Sep 17 00:00:00 2001 From: Loongphy Date: Thu, 26 Mar 2026 21:13:03 +0800 Subject: [PATCH 11/22] docs(account-name): finalize behavior and align test fixtures --- plans/2026-03-26-account-name.md | 227 +++++++++++++++---------------- src/tests/account_api_test.zig | 28 ++-- src/tests/cli_bdd_test.zig | 8 +- src/tests/display_rows_test.zig | 18 +-- 4 files changed, 136 insertions(+), 145 deletions(-) diff --git a/plans/2026-03-26-account-name.md b/plans/2026-03-26-account-name.md index 6d7f990..8dd015e 100644 --- a/plans/2026-03-26-account-name.md +++ b/plans/2026-03-26-account-name.md @@ -1,138 +1,123 @@ --- name: account-name -description: Persist ChatGPT account names from accounts/check, show them in list/switch, and keep request volume low by fetching only for ambiguous Team groupings +description: Finalized account-name sync behavior for accounts/check parsing, registry persistence, refresh policy, and list/switch/remove display rules --- -# Plan - -Add stored `account_name` metadata to registry records, fetch it from `accounts/check` with the minimal three-header request shape, and surface it in `list` and `switch` with alias-first display precedence. - -## Progress -- [x] Create the dedicated worktree and lock execution to this plan file. -- [x] Extend the registry model and persistence for `account_name`. -- [x] Add the `accounts/check` metadata fetcher and align request headers with `wham/usage`. -- [x] Wire refresh behavior into `login`, `switch`, single-file `import`, and `list`. -- [x] Update shared display labels for `list` and `switch`. -- [x] Add parser, registry compatibility, flow, and display tests. -- [x] Run relevant Zig tests and `zig build run -- list`. -- [x] Restrict account fetches to ambiguous Team groupings only. -- [x] Remove first-run bootstrap and its persisted marker. -- [x] Split API config into `api.usage` and `api.account`, with `config api enable|disable` toggling both. - -## Summary -- Keep `registry.json` at schema `3`; this is an additive field only. -- Add `account_name: ?[]u8` to each account record. -- Treat missing or null names as `null`; do not use `""` as a stored default. -- Add `api.account: bool` alongside `api.usage: bool` in `registry.json`. -- Use the same minimal header rule for both APIs: +# Account Name Sync + +This document records the shipped behavior for ChatGPT `account_name` sync and display. + +## Final Result + +- `registry.AccountRecord` stores `account_name: ?[]u8`. +- `registry.json` stays on schema `3`. +- `api` config is split into: + - `api.usage` + - `api.account` +- Missing `api.account` or `api.usage` fields are backfilled from the sibling flag on load. +- `account_name` is persisted as either a string or `null`. + +## Real Account Identity Format + +- The runtime account identity is `account_key = "::"`. +- Real keys look like `user-opaque-id::67fe2bbb-0de6-49a4-b2b3-d1df366d1faf`. +- `registry.json` stores the plain `account_key`. +- Snapshot files under `~/.codex/accounts` use a URL-safe base64 encoding of `account_key`, then append `.auth.json`. +- Encoding is required because `account_key` contains `:` and is not always filename-safe. + +## Accounts Check Payload + +- Endpoint: + - `https://chatgpt.com/backend-api/accounts/check/v4-2023-04-27` +- Request headers: - `Authorization: Bearer ` - - `ChatGPT-Account-Id: ` only when available + - `ChatGPT-Account-Id: ` when present - `User-Agent: codex-auth` -- Remove `Accept-Encoding: identity` from the current usage API implementation. -- Keep missing-name lazy refresh behavior only; do not run a first-foreground bootstrap. - -## Requirements -- Parse `accounts/check` from: +- Parsed fields: - `accounts..account.account_id` - `accounts..account.name` -- Ignore: +- Ignored fields: - `accounts.default` - `account_ordering` - all other payload fields -- Normalize `name: null` or `name: ""` to `account_name = null`. +- Real payload shape uses the `chatgpt_account_id` as the `accounts` map key. +- `default` is only the web default-selection entry and is not treated as a real account row. +- `account_ordering` may contain only real account IDs and is currently ignored by parsing. +- `name: null` and `name: ""` are both normalized to `account_name = null`. + +## Refresh Policy + +- Refresh is disabled when `api.account == false`. +- Refresh requires a usable auth context with: + - `access_token` + - `chatgpt_user_id` +- Refresh scope is broader than a single user ID: + - records owned by the same `chatgpt_user_id` + - plus records on emails also owned by that user +- This means a same-email plus/free account can trigger refresh for same-email team records. +- A refresh is eligible only when the scoped records satisfy all of these: + - there is more than one scoped account + - at least one scoped Team account exists + - at least one scoped Team account still has a missing `account_name` - Refresh timing: - - after `login` - - after `switch` - - after single-file `import` - - during `list`, only if the active user still has any `account_name == null` -- Refresh eligibility: - - only when `api.account == true` - - only when the relevant `chatgpt_user_id` belongs to an ambiguous grouping - - a grouping is ambiguous when either: - - the user has multiple accounts, or - - one of the user's emails appears on multiple accounts - - only Team users with at least one missing `account_name` qualify -- Do not refresh during directory import or `import --purge`. -- Do not trigger `accounts/check` from the `wham/usage` refresh path. - -## Data model / API changes -- Extend `registry.AccountRecord` with `account_name: ?[]u8`. -- Old registries without that field must load successfully with `account_name = null`. -- New saves must always emit `account_name` as either a string or `null`. -- Add a dedicated account fetcher module or helper, separate from `usage_api` parsing. -- `accounts/check` request contract: - - method: `GET` - - URL: `https://chatgpt.com/backend-api/accounts/check/v4-2023-04-27` - - headers: - - `Authorization: Bearer ` - - `ChatGPT-Account-Id: ` when present - - `User-Agent: codex-auth` -- `wham/usage` request contract should be aligned to the same header policy. - -## Display behavior -- Use one shared label builder for both `list` and `switch`. -- Label precedence: + - `login`: inline refresh after auth is available + - single-file `import`: inline refresh from the imported auth + - `list`: inline refresh for the active auth + - `switch`: activate and save first, then spawn a background `list` process in account-name-refresh-only mode +- No account-name refresh runs during: + - directory import + - `import --purge` + +## Apply Rules + +- Returned entries are matched by `chatgpt_account_id`. +- Matching scoped records receive the returned `account_name`. +- Scoped Team records missing from the response are cleared to `null`. +- Scoped non-Team records with no stored `account_name` stay unchanged when no entry matches. +- Scoped non-Team records with a stale stored `account_name` are cleared if the response does not include them. +- Records outside the refresh scope are left unchanged. +- Request failures and parse failures are non-fatal: + - the command still succeeds + - stored metadata is left as-is + +## Display Rules + +- `list` and `switch` share the same display-row builder. +- Rows are grouped by `email` within the rendered subset, not the full registry. +- Singleton rule: + - if the rendered subset contains exactly one account for an email, the row is singleton + - singleton rows display the email directly +- Grouped rule: + - the email becomes a header row + - child rows use the preferred label builder +- Preferred label precedence for grouped child rows: - alias + account name => `alias (account_name)` - alias only => `alias` - account name only => `account_name` - - neither => current fallback behavior -- Apply the same precedence to singleton rows, grouped child rows, and switch-picker rows. - -## Refresh and metadata behavior -- General rule: at most one `accounts/check` request per command, and only if the relevant active/imported user still has at least one null `account_name`. -- `login`: - - after login succeeds and the active auth is ready, fetch once if that user has missing names -- `switch`: - - after the target snapshot becomes active, fetch once if that user has missing names -- Single-file `import`: - - use the imported auth context directly - - fetch once only if that imported user has missing names -- Directory import and `import --purge`: - - never fetch names during the batch - - leave names null until a later `list`, `switch`, or `login` -- After a successful fetch: - - update only records whose `chatgpt_user_id` matches the auth used for the request - - set `account_name` for returned account IDs - - clear `account_name` to `null` for same-user records that were not returned - - leave other users unchanged -- On request or parse failure: - - keep command success behavior unchanged - - keep stored values unchanged - -## Testing and validation -- Add parser tests for: - - one real account plus `default` - - multiple non-default accounts - - `name: null` - - `name: ""` - - malformed / HTML response treated as non-fatal failure -- Add registry compatibility tests for: - - loading old registry data without `account_name` - - round-tripping `account_name: null` - - round-tripping `account_name: "abcd"` -- Add flow tests for: - - standalone Team accounts keep email fallback labels and do not trigger account fetches - - grouped Team users trigger at most one metadata request per command - - `api.account = false` prevents account fetches across `login`, `switch`, `list`, and single-file `import` - - `login` issues at most one metadata request on missing-name records - - `switch` issues at most one metadata request on missing-name records - - single-file import issues at most one metadata request on missing-name records - - directory import and purge issue zero metadata requests - - `list` issues one metadata request only when the active user still has missing names -- Add registry compatibility tests for: - - `api.account` defaulting to `true` when absent - - round-tripping `api.account: false` -- Add display tests for: - - alias + account name - - alias only - - account name only - - neither -- Run: - - relevant Zig tests + - neither => plan fallback such as `team`, `plus`, or `team #2` +- `remove` keeps email context even for singleton rows: + - plain singleton email stays `email` + - singleton alias/name rows are rendered as `email / preferred-label` + +## Validation Coverage + +- Parser coverage: + - ignores `default` + - accepts UUID-style account keys in the `accounts` object + - keeps multiple non-default accounts + - normalizes personal-account `name: null` + - normalizes personal-account `name: ""` + - treats malformed HTML as a non-fatal failure +- Registry coverage: + - old registries load with `account_name = null` + - `account_name` round-trips for `null` and string values + - `api.account` round-trips and backfills correctly + - same-email scoped updates apply to related Team records +- Display coverage: + - singleton rows keep email labels + - singleton/grouped behavior is decided from the rendered subset + - grouped child rows keep alias/account-name precedence + - remove labels preserve email context for singleton alias/name rows +- Command validation: - `zig build run -- list` -## Assumptions -- `ChatGPT-Account-Id` is the required addition for `accounts/check`. -- Minimal three-header requests are sufficient for both `accounts/check` and `wham/usage`. -- Missing-name-only refresh is the preferred low-risk policy because account names rarely change. -- Skipping batch-import refresh is the right tradeoff for latency and request-volume control. diff --git a/src/tests/account_api_test.zig b/src/tests/account_api_test.zig index 16bb24b..a04b0a8 100644 --- a/src/tests/account_api_test.zig +++ b/src/tests/account_api_test.zig @@ -26,14 +26,14 @@ test "parse account names response ignores default and keeps one real account" { \\ "name": "Default" \\ } \\ }, - \\ "team-1": { + \\ "67fe2bbb-0de6-49a4-b2b3-d1df366d1faf": { \\ "account": { \\ "account_id": "67fe2bbb-0de6-49a4-b2b3-d1df366d1faf", \\ "name": "Primary Workspace" \\ } \\ } \\ }, - \\ "account_ordering": ["default", "team-1"] + \\ "account_ordering": ["67fe2bbb-0de6-49a4-b2b3-d1df366d1faf"] \\} ; @@ -52,19 +52,23 @@ test "parse account names response keeps multiple non-default accounts" { const body = \\{ \\ "accounts": { - \\ "team-1": { + \\ "67fe2bbb-0de6-49a4-b2b3-d1df366d1faf": { \\ "account": { \\ "account_id": "67fe2bbb-0de6-49a4-b2b3-d1df366d1faf", \\ "name": "Primary Workspace" \\ } \\ }, - \\ "team-2": { + \\ "518a44d9-ba75-4bad-87e5-ae9377042960": { \\ "account": { \\ "account_id": "518a44d9-ba75-4bad-87e5-ae9377042960", \\ "name": "Backup Workspace" \\ } \\ } - \\ } + \\ }, + \\ "account_ordering": [ + \\ "67fe2bbb-0de6-49a4-b2b3-d1df366d1faf", + \\ "518a44d9-ba75-4bad-87e5-ae9377042960" + \\ ] \\} ; @@ -81,18 +85,19 @@ test "parse account names response keeps multiple non-default accounts" { try std.testing.expect(std.mem.eql(u8, backup.account_name.?, "Backup Workspace")); } -test "parse account names response normalizes null names to null" { +test "parse personal account response keeps null name as null" { const gpa = std.testing.allocator; const body = \\{ \\ "accounts": { - \\ "team-1": { + \\ "67fe2bbb-0de6-49a4-b2b3-d1df366d1faf": { \\ "account": { \\ "account_id": "67fe2bbb-0de6-49a4-b2b3-d1df366d1faf", \\ "name": null \\ } \\ } - \\ } + \\ }, + \\ "account_ordering": ["67fe2bbb-0de6-49a4-b2b3-d1df366d1faf"] \\} ; @@ -104,18 +109,19 @@ test "parse account names response normalizes null names to null" { try std.testing.expect(entries.?[0].account_name == null); } -test "parse account names response normalizes empty names to null" { +test "parse personal account response normalizes empty name to null" { const gpa = std.testing.allocator; const body = \\{ \\ "accounts": { - \\ "team-1": { + \\ "67fe2bbb-0de6-49a4-b2b3-d1df366d1faf": { \\ "account": { \\ "account_id": "67fe2bbb-0de6-49a4-b2b3-d1df366d1faf", \\ "name": "" \\ } \\ } - \\ } + \\ }, + \\ "account_ordering": ["67fe2bbb-0de6-49a4-b2b3-d1df366d1faf"] \\} ; diff --git a/src/tests/cli_bdd_test.zig b/src/tests/cli_bdd_test.zig index 356150f..628b0fc 100644 --- a/src/tests/cli_bdd_test.zig +++ b/src/tests/cli_bdd_test.zig @@ -736,8 +736,8 @@ test "Scenario: Given singleton aliases from different emails when building remo var reg = makeRegistry(); defer reg.deinit(gpa); - try appendAccount(gpa, ®, "user-A::acct-1", "alpha@example.com", "work", .team); - try appendAccount(gpa, ®, "user-B::acct-2", "beta@example.com", "work", .team); + try appendAccount(gpa, ®, "user-4QmYj7PkN2sLx8AcVbR3TwHd::67fe2bbb-0de6-49a4-b2b3-d1df366d1faf", "alpha@example.com", "work", .team); + try appendAccount(gpa, ®, "user-8LnCq5VzR1mHx9SfKpT4JdWe::518a44d9-ba75-4bad-87e5-ae9377042960", "beta@example.com", "work", .team); const indices = [_]usize{ 0, 1 }; var labels = try cli.buildRemoveLabels(gpa, ®, &indices); @@ -756,9 +756,9 @@ test "Scenario: Given singleton account names from different emails when buildin var reg = makeRegistry(); defer reg.deinit(gpa); - try appendAccount(gpa, ®, "user-A::acct-1", "alpha@example.com", "", .team); + try appendAccount(gpa, ®, "user-4QmYj7PkN2sLx8AcVbR3TwHd::67fe2bbb-0de6-49a4-b2b3-d1df366d1faf", "alpha@example.com", "", .team); reg.accounts.items[0].account_name = try gpa.dupe(u8, "Workspace"); - try appendAccount(gpa, ®, "user-B::acct-2", "beta@example.com", "", .team); + try appendAccount(gpa, ®, "user-8LnCq5VzR1mHx9SfKpT4JdWe::518a44d9-ba75-4bad-87e5-ae9377042960", "beta@example.com", "", .team); reg.accounts.items[1].account_name = try gpa.dupe(u8, "Workspace"); const indices = [_]usize{ 0, 1 }; diff --git a/src/tests/display_rows_test.zig b/src/tests/display_rows_test.zig index ad88805..55a2df2 100644 --- a/src/tests/display_rows_test.zig +++ b/src/tests/display_rows_test.zig @@ -85,9 +85,9 @@ test "Scenario: Given same-email accounts filtered down to one row when building var reg = makeRegistry(); defer reg.deinit(gpa); - try appendAccount(gpa, ®, "user-A::acct-1", "user@example.com", "work", .team); + try appendAccount(gpa, ®, "user-ESYgcy2QkOGZc0NoxSlFCeVT::67fe2bbb-0de6-49a4-b2b3-d1df366d1faf", "user@example.com", "work", .team); reg.accounts.items[0].account_name = try gpa.dupe(u8, "Primary Workspace"); - try appendAccount(gpa, ®, "user-A::acct-2", "user@example.com", "", .plus); + try appendAccount(gpa, ®, "user-ESYgcy2QkOGZc0NoxSlFCeVT::a4021fa5-998b-4774-989f-784fa69c367b", "user@example.com", "", .plus); var grouped_rows = try display_rows.buildDisplayRows(gpa, ®, null); defer grouped_rows.deinit(gpa); @@ -108,12 +108,12 @@ test "Scenario: Given singleton accounts with alias and account name combination var reg = makeRegistry(); defer reg.deinit(gpa); - try appendAccount(gpa, ®, "user-A::acct-1", "alias-name@example.com", "work", .team); + try appendAccount(gpa, ®, "user-4QmYj7PkN2sLx8AcVbR3TwHd::67fe2bbb-0de6-49a4-b2b3-d1df366d1faf", "alias-name@example.com", "work", .team); reg.accounts.items[0].account_name = try gpa.dupe(u8, "Primary Workspace"); - try appendAccount(gpa, ®, "user-B::acct-2", "alias-only@example.com", "backup", .team); - try appendAccount(gpa, ®, "user-C::acct-3", "name-only@example.com", "", .team); + try appendAccount(gpa, ®, "user-8LnCq5VzR1mHx9SfKpT4JdWe::518a44d9-ba75-4bad-87e5-ae9377042960", "alias-only@example.com", "backup", .team); + try appendAccount(gpa, ®, "user-2RbFk6NsQ8vLp3XtJmW7CyHa::a4021fa5-998b-4774-989f-784fa69c367b", "name-only@example.com", "", .team); reg.accounts.items[2].account_name = try gpa.dupe(u8, "Sandbox"); - try appendAccount(gpa, ®, "user-D::acct-4", "fallback@example.com", "", .team); + try appendAccount(gpa, ®, "user-9TwHs4KmP7xNc2LdVrQ6BjYe::d8f0f19d-7b6f-4db8-b7a8-07b9fbf5774a", "fallback@example.com", "", .team); var rows = try display_rows.buildDisplayRows(gpa, ®, null); defer rows.deinit(gpa); @@ -130,11 +130,11 @@ test "Scenario: Given mixed singleton and grouped accounts when building display var reg = makeRegistry(); defer reg.deinit(gpa); - try appendAccount(gpa, ®, "user-SOLO::acct-1", "solo@example.com", "solo", .team); + try appendAccount(gpa, ®, "user-6JpMv8XrT3nLc9QsHbW4DyKa::67fe2bbb-0de6-49a4-b2b3-d1df366d1faf", "solo@example.com", "solo", .team); reg.accounts.items[0].account_name = try gpa.dupe(u8, "Solo Workspace"); - try appendAccount(gpa, ®, "user-GROUP::acct-2", "user@example.com", "work", .team); + try appendAccount(gpa, ®, "user-1ZdKr5NtV8mQx3LsHpW7CyFb::518a44d9-ba75-4bad-87e5-ae9377042960", "user@example.com", "work", .team); reg.accounts.items[1].account_name = try gpa.dupe(u8, "Primary Workspace"); - try appendAccount(gpa, ®, "user-GROUP::acct-3", "user@example.com", "", .plus); + try appendAccount(gpa, ®, "user-1ZdKr5NtV8mQx3LsHpW7CyFb::a4021fa5-998b-4774-989f-784fa69c367b", "user@example.com", "", .plus); var rows = try display_rows.buildDisplayRows(gpa, ®, null); defer rows.deinit(gpa); From 6831c2785da36748b063a555701182d8bd9f481c Mon Sep 17 00:00:00 2001 From: Loongphy Date: Fri, 27 Mar 2026 07:39:44 +0800 Subject: [PATCH 12/22] fix: isolate post-switch account-name refresh --- docs/api-refresh.md | 5 ++- plans/2026-03-26-account-name.md | 5 ++- src/main.zig | 63 +++++++++++++++++++++++++++---- src/tests/main_test.zig | 64 ++++++++++++++++++++++++++++++++ 4 files changed, 127 insertions(+), 10 deletions(-) diff --git a/docs/api-refresh.md b/docs/api-refresh.md index c68b3c1..586ec34 100644 --- a/docs/api-refresh.md +++ b/docs/api-refresh.md @@ -39,7 +39,8 @@ The `accounts/check` response is parsed by `chatgpt_account_id`. `name: null` an - `login` refreshes immediately after the new active auth is ready. - Single-file `import` refreshes immediately for the imported auth context. - `list` refreshes in the foreground for the current active scope when that scope still has missing Team `account_name` values. -- `switch` saves the selected account first, then schedules a best-effort background refresh so the command can exit immediately without waiting for `accounts/check`. +- `switch` saves the selected account first, then schedules a best-effort background refresh for the newly active scope so the command can exit immediately without waiting for `accounts/check`. +- `switch` does not start that background refresh when `api.account = false`. At most one `accounts/check` request is attempted per refresh path. @@ -100,3 +101,5 @@ Then: - `team #2` is overwritten from `Old Workspace` to `Sandbox Workspace` The same grouped-scope rule also applies after `switch`, but the refresh runs in the background after the switch is already saved. + +The background `switch` refresh re-loads the latest `registry.json` after `accounts/check` returns, then applies only the grouped `account_name` result onto that latest registry state before saving. diff --git a/plans/2026-03-26-account-name.md b/plans/2026-03-26-account-name.md index 8dd015e..9180b6a 100644 --- a/plans/2026-03-26-account-name.md +++ b/plans/2026-03-26-account-name.md @@ -63,7 +63,9 @@ This document records the shipped behavior for ChatGPT `account_name` sync and d - `login`: inline refresh after auth is available - single-file `import`: inline refresh from the imported auth - `list`: inline refresh for the active auth - - `switch`: activate and save first, then spawn a background `list` process in account-name-refresh-only mode + - `switch`: activate and save first, then spawn a background account-name-only refresh for the newly active scope +- Background switch refresh is skipped when `api.account == false`. +- Background switch refresh re-loads the latest registry after `accounts/check` returns, then applies only the refreshed `account_name` result before saving. - No account-name refresh runs during: - directory import - `import --purge` @@ -120,4 +122,3 @@ This document records the shipped behavior for ChatGPT `account_name` sync and d - remove labels preserve email context for singleton alias/name rows - Command validation: - `zig build run -- list` - diff --git a/src/main.zig b/src/main.zig index 98e4d67..3c8e03d 100644 --- a/src/main.zig +++ b/src/main.zig @@ -209,9 +209,8 @@ fn maybeRefreshAccountNamesForAuthInfo( info: *const auth.AuthInfo, fetcher: AccountFetchFn, ) !bool { - if (!reg.api.account) return false; const chatgpt_user_id = info.chatgpt_user_id orelse return false; - if (!registry.shouldFetchTeamAccountNamesForUser(reg, chatgpt_user_id)) return false; + if (!shouldRefreshTeamAccountNamesForUserScope(reg, chatgpt_user_id)) return false; const access_token = info.access_token orelse return false; const result = fetcher(allocator, access_token, info.chatgpt_account_id) catch |err| { @@ -244,9 +243,8 @@ fn refreshAccountNamesForActiveAuth( reg: *registry.Registry, fetcher: AccountFetchFn, ) !bool { - if (!reg.api.account) return false; const active_user_id = registry.activeChatgptUserId(reg) orelse return false; - if (!registry.shouldFetchTeamAccountNamesForUser(reg, active_user_id)) return false; + if (!shouldRefreshTeamAccountNamesForUserScope(reg, active_user_id)) return false; var info = (try loadActiveAuthInfoForAccountRefresh(allocator, codex_home)) orelse return false; defer info.deinit(allocator); @@ -280,6 +278,57 @@ pub fn refreshAccountNamesForList( return try refreshAccountNamesForActiveAuth(allocator, codex_home, reg, fetcher); } +fn shouldRefreshTeamAccountNamesForUserScope(reg: *registry.Registry, chatgpt_user_id: []const u8) bool { + if (!reg.api.account) return false; + return registry.shouldFetchTeamAccountNamesForUser(reg, chatgpt_user_id); +} + +pub fn shouldScheduleBackgroundAccountNameRefresh(reg: *registry.Registry) bool { + const active_user_id = registry.activeChatgptUserId(reg) orelse return false; + return shouldRefreshTeamAccountNamesForUserScope(reg, active_user_id); +} + +fn applyAccountNameRefreshEntriesToLatestRegistry( + allocator: std.mem.Allocator, + codex_home: []const u8, + chatgpt_user_id: []const u8, + entries: []const account_api.AccountEntry, +) !bool { + var latest = try registry.loadRegistry(allocator, codex_home); + defer latest.deinit(allocator); + + if (!shouldRefreshTeamAccountNamesForUserScope(&latest, chatgpt_user_id)) return false; + if (!try registry.applyAccountNamesForUser(allocator, &latest, chatgpt_user_id, entries)) return false; + + try registry.saveRegistry(allocator, codex_home, &latest); + return true; +} + +pub fn runBackgroundAccountNameRefresh( + allocator: std.mem.Allocator, + codex_home: []const u8, + fetcher: AccountFetchFn, +) !void { + var info = (try loadActiveAuthInfoForAccountRefresh(allocator, codex_home)) orelse return; + defer info.deinit(allocator); + + const chatgpt_user_id = info.chatgpt_user_id orelse return; + const access_token = info.access_token orelse return; + + var reg = try registry.loadRegistry(allocator, codex_home); + defer reg.deinit(allocator); + if (!shouldRefreshTeamAccountNamesForUserScope(®, chatgpt_user_id)) return; + + const result = fetcher(allocator, access_token, info.chatgpt_account_id) catch |err| { + std.log.warn("account metadata refresh skipped: {s}", .{@errorName(err)}); + return; + }; + defer result.deinit(allocator); + + const entries = result.entries orelse return; + _ = try applyAccountNameRefreshEntriesToLatestRegistry(allocator, codex_home, chatgpt_user_id, entries); +} + fn spawnBackgroundAccountNameRefresh(allocator: std.mem.Allocator) !void { var env_map = std.process.getEnvMap(allocator) catch |err| { std.log.warn("background account metadata refresh skipped: {s}", .{@errorName(err)}); @@ -308,8 +357,7 @@ fn maybeSpawnBackgroundAccountNameRefresh( reg: *registry.Registry, ) void { if (isBackgroundAccountNameRefreshDisabled()) return; - const active_user_id = registry.activeChatgptUserId(reg) orelse return; - if (!registry.shouldFetchTeamAccountNamesForUser(reg, active_user_id)) return; + if (!shouldScheduleBackgroundAccountNameRefresh(reg)) return; spawnBackgroundAccountNameRefresh(allocator) catch |err| { std.log.warn("background account metadata refresh skipped: {s}", .{@errorName(err)}); @@ -380,6 +428,8 @@ fn loadSingleFileImportAuthInfo( fn handleList(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli.ListOptions) !void { _ = opts; + if (isAccountNameRefreshOnlyMode()) return try runBackgroundAccountNameRefresh(allocator, codex_home, defaultAccountFetcher); + var reg = try registry.loadRegistry(allocator, codex_home); defer reg.deinit(allocator); if (try registry.syncActiveAccountFromAuth(allocator, codex_home, ®)) { @@ -400,7 +450,6 @@ fn handleList(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli.Li if (try refreshAccountNamesForList(allocator, codex_home, ®, defaultAccountFetcher)) { try registry.saveRegistry(allocator, codex_home, ®); } - if (isAccountNameRefreshOnlyMode()) return; try maybeRefreshForegroundUsage(allocator, codex_home, ®, .list); try format.printAccounts(®); } diff --git a/src/tests/main_test.zig b/src/tests/main_test.zig index f85f753..c452fc3 100644 --- a/src/tests/main_test.zig +++ b/src/tests/main_test.zig @@ -17,9 +17,13 @@ const standalone_team_account_id = "29a9c0cb-e840-45ec-97bf-d6c5f7e0f55b"; const standalone_team_record_key = standalone_team_user_id ++ "::" ++ standalone_team_account_id; var mock_account_name_fetch_count: usize = 0; +var mutate_registry_during_account_fetch = false; +var mutate_registry_codex_home: ?[]const u8 = null; fn resetMockAccountNameFetcher() void { mock_account_name_fetch_count = 0; + mutate_registry_during_account_fetch = false; + mutate_registry_codex_home = null; } fn makeRegistry() registry.Registry { @@ -161,6 +165,23 @@ fn mockAccountNameFetcher( }; } +fn mockAccountNameFetcherWithRegistryMutation( + allocator: std.mem.Allocator, + access_token: []const u8, + account_id: ?[]const u8, +) !account_api.FetchResult { + if (mutate_registry_during_account_fetch) { + const codex_home = mutate_registry_codex_home orelse return error.TestExpectedEqual; + var reg = try registry.loadRegistry(allocator, codex_home); + defer reg.deinit(allocator); + reg.api.usage = false; + reg.api.account = false; + try registry.saveRegistry(allocator, codex_home, ®); + } + + return try mockAccountNameFetcher(allocator, access_token, account_id); +} + test "Scenario: Given alias, email, and account name queries when finding matching accounts then all matching strategies work" { const gpa = std.testing.allocator; var reg = makeRegistry(); @@ -315,6 +336,19 @@ test "Scenario: Given grouped team accounts with account api disabled when refre try std.testing.expectEqual(@as(usize, 0), mock_account_name_fetch_count); } +test "Scenario: Given grouped team accounts with account api disabled when checking switch background refresh then it is skipped" { + const gpa = std.testing.allocator; + var reg = makeRegistry(); + defer reg.deinit(gpa); + reg.api.account = false; + + try appendAccount(gpa, ®, primary_record_key, "user@example.com", "", .team); + try appendAccount(gpa, ®, secondary_record_key, "user@example.com", "", .team); + try registry.setActiveAccountKey(gpa, ®, primary_record_key); + + try std.testing.expect(!main_mod.shouldScheduleBackgroundAccountNameRefresh(®)); +} + test "Scenario: Given login with missing account names when refreshing metadata then it issues at most one request" { const gpa = std.testing.allocator; var reg = makeRegistry(); @@ -357,6 +391,36 @@ test "Scenario: Given switched account with missing account names when refreshin try std.testing.expect(std.mem.eql(u8, reg.accounts.items[1].account_name.?, "Backup Workspace")); } +test "Scenario: Given api disabled while background account-name refresh is in flight when it finishes then the latest api config is preserved" { + const gpa = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + const codex_home = try tmp.dir.realpathAlloc(gpa, "."); + defer gpa.free(codex_home); + + var reg = makeRegistry(); + defer reg.deinit(gpa); + try appendAccount(gpa, ®, primary_record_key, "user@example.com", "", .team); + try appendAccount(gpa, ®, secondary_record_key, "user@example.com", "", .team); + try registry.setActiveAccountKey(gpa, ®, primary_record_key); + try registry.saveRegistry(gpa, codex_home, ®); + try writeActiveAuthWithIds(gpa, codex_home, "user@example.com", "team", shared_user_id, primary_account_id); + + resetMockAccountNameFetcher(); + mutate_registry_during_account_fetch = true; + mutate_registry_codex_home = codex_home; + try main_mod.runBackgroundAccountNameRefresh(gpa, codex_home, mockAccountNameFetcherWithRegistryMutation); + + var loaded = try registry.loadRegistry(gpa, codex_home); + defer loaded.deinit(gpa); + try std.testing.expectEqual(@as(usize, 1), mock_account_name_fetch_count); + try std.testing.expect(!loaded.api.account); + try std.testing.expect(!loaded.api.usage); + try std.testing.expect(loaded.accounts.items[0].account_name == null); + try std.testing.expect(loaded.accounts.items[1].account_name == null); +} + test "Scenario: Given single-file import with missing account names when refreshing metadata then it issues at most one request" { const gpa = std.testing.allocator; var reg = makeRegistry(); From 8f9e99a819c0862ba668eceed3908a8ace86f6ab Mon Sep 17 00:00:00 2001 From: Loongphy Date: Fri, 27 Mar 2026 08:32:46 +0800 Subject: [PATCH 13/22] feat(auto): refresh team names during daemon cycles --- README.md | 12 +- docs/api-refresh.md | 1 + plans/2026-03-26-account-name.md | 1 + src/auto.zig | 129 +++++++++++++++++++++- src/tests/auto_test.zig | 184 +++++++++++++++++++++++++++++++ 5 files changed, 318 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 94803f1..2c7b949 100644 --- a/README.md +++ b/README.md @@ -112,7 +112,7 @@ Remove-Item "$env:LOCALAPPDATA\codex-auth\bin\codex-auth-auto.exe" -Force -Error |---------|-------------| | `codex-auth config auto enable\|disable` | Enable or disable background auto-switching | | `codex-auth config auto [--5h <%>] [--weekly <%>]` | Set auto-switch thresholds | -| `codex-auth config api enable\|disable` | Enable or disable both usage refresh and account-name refresh API calls | +| `codex-auth config api enable\|disable` | Enable or disable both usage refresh and team name refresh API calls | --- @@ -134,8 +134,6 @@ codex-auth switch Before the picker opens, the current active account's usage is refreshed once so the selected row is not stale. The newly selected account is not refreshed after the switch completes. -If grouped Team `account_name` metadata still needs syncing, that refresh is scheduled in the background after the switch is already saved, so `codex-auth switch` can return immediately. - ![command switch](https://github.com/user-attachments/assets/48a86acf-2a6e-4206-a8c4-591989fdc0df) Non-interactive: fuzzy match by email or alias. @@ -265,8 +263,6 @@ codex-auth config api disable Changing `config api` updates `registry.json` immediately. `api enable` is shown as API mode and `api disable` is shown as local mode. -Implementation details are documented in [`docs/auto-switch.md`](docs/auto-switch.md) and [`docs/api-refresh.md`](docs/api-refresh.md). - ## Q&A ### Why is my usage limit not refreshing? @@ -339,8 +335,8 @@ This project is provided as-is and use is at your own risk. **Usage Data Refresh Source:** `codex-auth` supports two sources for refreshing account usage/usage limit information: -1. **API (default):** When `config api enable` is on, the tool makes direct HTTPS requests to OpenAI's endpoints using your account's access token. This enables both usage refresh and grouped-account name refresh. -2. **Local-only:** When `config api disable` is on, the tool scans local `~/.codex/sessions/*/rollout-*.jsonl` files for usage data and skips account-name refresh API calls. This mode is safer, but it can be less accurate because recent Codex rollout files often contain `rate_limits: null`, so the latest local usage limit data may lag by several hours. +1. **API (default):** When `config api enable` is on, the tool makes direct HTTPS requests to OpenAI's endpoints using your account's access token. This enables both usage refresh and team name refresh. +2. **Local-only:** When `config api disable` is on, the tool scans local `~/.codex/sessions/*/rollout-*.jsonl` files for usage data and skips team name refresh API calls. This mode is safer, but it can be less accurate because recent Codex rollout files often contain `rate_limits: null`, so the latest local usage limit data may lag by several hours. **API Call Declaration:** -By enabling API-backed refresh, this tool will send your ChatGPT access token to OpenAI's servers, including `https://chatgpt.com/backend-api/wham/usage` for quota information and `https://chatgpt.com/backend-api/accounts/check/v4-2023-04-27` for grouped-account metadata. This behavior may be detected by OpenAI and could violate their terms of service, potentially leading to account suspension or other risks. The decision to use this feature and any resulting consequences are entirely yours. +By enabling API(`codex-auth config api enable`), this tool will send your ChatGPT access token to OpenAI's servers, including `https://chatgpt.com/backend-api/wham/usage` for usage limit and `https://chatgpt.com/backend-api/accounts/check/v4-2023-04-27` for team name. This behavior may be detected by OpenAI and could violate their terms of service, potentially leading to account suspension or other risks. The decision to use this feature and any resulting consequences are entirely yours. diff --git a/docs/api-refresh.md b/docs/api-refresh.md index 586ec34..a675731 100644 --- a/docs/api-refresh.md +++ b/docs/api-refresh.md @@ -41,6 +41,7 @@ The `accounts/check` response is parsed by `chatgpt_account_id`. `name: null` an - `list` refreshes in the foreground for the current active scope when that scope still has missing Team `account_name` values. - `switch` saves the selected account first, then schedules a best-effort background refresh for the newly active scope so the command can exit immediately without waiting for `accounts/check`. - `switch` does not start that background refresh when `api.account = false`. +- the auto-switch daemon also refreshes the current active scope when `auto_switch.enabled = true` and that scope still has missing Team `account_name` values At most one `accounts/check` request is attempted per refresh path. diff --git a/plans/2026-03-26-account-name.md b/plans/2026-03-26-account-name.md index 9180b6a..5c3223c 100644 --- a/plans/2026-03-26-account-name.md +++ b/plans/2026-03-26-account-name.md @@ -64,6 +64,7 @@ This document records the shipped behavior for ChatGPT `account_name` sync and d - single-file `import`: inline refresh from the imported auth - `list`: inline refresh for the active auth - `switch`: activate and save first, then spawn a background account-name-only refresh for the newly active scope + - `daemon`: when auto-switch is enabled, each daemon cycle also checks the active scope and refreshes missing Team names in the background watcher - Background switch refresh is skipped when `api.account == false`. - Background switch refresh re-loads the latest registry after `accounts/check` returns, then applies only the refreshed `account_name` result before saving. - No account-name refresh runs during: diff --git a/src/auto.zig b/src/auto.zig index b0449b3..685c15d 100644 --- a/src/auto.zig +++ b/src/auto.zig @@ -1,4 +1,6 @@ const std = @import("std"); +const account_api = @import("account_api.zig"); +const auth = @import("auth.zig"); const builtin = @import("builtin"); const c_time = @cImport({ @cInclude("time.h"); @@ -242,6 +244,8 @@ const CandidateIndex = struct { pub const DaemonRefreshState = struct { last_api_refresh_at_ns: i128 = 0, last_api_refresh_account_key: ?[]u8 = null, + last_account_name_refresh_at_ns: i128 = 0, + last_account_name_refresh_account_key: ?[]u8 = null, pending_bad_account_key: ?[]u8 = null, pending_bad_rollout: ?registry.RolloutSignature = null, current_reg: ?registry.Registry = null, @@ -254,6 +258,7 @@ pub const DaemonRefreshState = struct { pub fn deinit(self: *DaemonRefreshState, allocator: std.mem.Allocator) void { self.clearApiRefresh(allocator); + self.clearAccountNameRefresh(allocator); self.clearPending(allocator); if (self.current_reg) |*reg| { self.candidate_index.deinit(allocator); @@ -277,6 +282,14 @@ pub const DaemonRefreshState = struct { self.last_api_refresh_at_ns = 0; } + fn clearAccountNameRefresh(self: *DaemonRefreshState, allocator: std.mem.Allocator) void { + if (self.last_account_name_refresh_account_key) |account_key| { + allocator.free(account_key); + } + self.last_account_name_refresh_account_key = null; + self.last_account_name_refresh_at_ns = 0; + } + fn clearPending(self: *DaemonRefreshState, allocator: std.mem.Allocator) void { if (self.pending_bad_account_key) |account_key| { allocator.free(account_key); @@ -334,6 +347,18 @@ pub const DaemonRefreshState = struct { self.last_api_refresh_account_key = try allocator.dupe(u8, active_account_key); } + fn resetAccountNameCooldownIfAccountChanged( + self: *DaemonRefreshState, + allocator: std.mem.Allocator, + active_account_key: []const u8, + ) !void { + if (self.last_account_name_refresh_account_key) |account_key| { + if (std.mem.eql(u8, account_key, active_account_key)) return; + } + self.clearAccountNameRefresh(allocator); + self.last_account_name_refresh_account_key = try allocator.dupe(u8, active_account_key); + } + fn currentRegistry(self: *DaemonRefreshState) *registry.Registry { return &self.current_reg.?; } @@ -807,6 +832,87 @@ pub fn refreshActiveUsage(allocator: std.mem.Allocator, codex_home: []const u8, return refreshActiveUsageWithApiFetcher(allocator, codex_home, reg, usage_api.fetchActiveUsage); } +fn fetchActiveAccountNames( + allocator: std.mem.Allocator, + access_token: []const u8, + account_id: ?[]const u8, +) !account_api.FetchResult { + return try account_api.fetchAccountsForTokenDetailed( + allocator, + account_api.default_account_endpoint, + access_token, + account_id, + ); +} + +fn loadActiveAuthInfoForAccountNameRefresh(allocator: std.mem.Allocator, codex_home: []const u8) !?auth.AuthInfo { + const auth_path = try registry.activeAuthPath(allocator, codex_home); + defer allocator.free(auth_path); + + return auth.parseAuthInfo(allocator, auth_path) catch |err| switch (err) { + error.OutOfMemory => return err, + error.FileNotFound => null, + else => { + std.log.warn("account metadata refresh skipped: {s}", .{@errorName(err)}); + return null; + }, + }; +} + +fn refreshActiveAccountNamesForDaemon( + allocator: std.mem.Allocator, + codex_home: []const u8, + reg: *registry.Registry, + refresh_state: *DaemonRefreshState, +) !bool { + return refreshActiveAccountNamesForDaemonWithFetcher( + allocator, + codex_home, + reg, + refresh_state, + fetchActiveAccountNames, + ); +} + +pub fn refreshActiveAccountNamesForDaemonWithFetcher( + allocator: std.mem.Allocator, + codex_home: []const u8, + reg: *registry.Registry, + refresh_state: *DaemonRefreshState, + fetcher: anytype, +) !bool { + if (!reg.auto_switch.enabled) return false; + if (!reg.api.account) return false; + const account_key = reg.active_account_key orelse return false; + try refresh_state.resetAccountNameCooldownIfAccountChanged(allocator, account_key); + + const chatgpt_user_id = registry.activeChatgptUserId(reg) orelse return false; + if (!registry.shouldFetchTeamAccountNamesForUser(reg, chatgpt_user_id)) return false; + + const now_ns = std.time.nanoTimestamp(); + if (refresh_state.last_account_name_refresh_at_ns != 0 and + (now_ns - refresh_state.last_account_name_refresh_at_ns) < api_refresh_interval_ns) + { + return false; + } + + var info = (try loadActiveAuthInfoForAccountNameRefresh(allocator, codex_home)) orelse return false; + defer info.deinit(allocator); + const access_token = info.access_token orelse return false; + const auth_user_id = info.chatgpt_user_id orelse return false; + if (!std.mem.eql(u8, auth_user_id, chatgpt_user_id)) return false; + + refresh_state.last_account_name_refresh_at_ns = now_ns; + const result = fetcher(allocator, access_token, info.chatgpt_account_id) catch |err| { + std.log.warn("account metadata refresh skipped: {s}", .{@errorName(err)}); + return false; + }; + defer result.deinit(allocator); + + const entries = result.entries orelse return false; + return try registry.applyAccountNamesForUser(allocator, reg, chatgpt_user_id, entries); +} + pub fn refreshActiveUsageWithApiFetcher( allocator: std.mem.Allocator, codex_home: []const u8, @@ -1595,7 +1701,12 @@ fn resolve5hTriggerWindow(usage: ?registry.RateLimitSnapshot) Resolved5hWindow { return .{ .window = null, .allow_free_guard = false }; } -fn daemonCycle(allocator: std.mem.Allocator, codex_home: []const u8, refresh_state: *DaemonRefreshState) !bool { +fn daemonCycleWithAccountNameFetcher( + allocator: std.mem.Allocator, + codex_home: []const u8, + refresh_state: *DaemonRefreshState, + account_name_fetcher: anytype, +) !bool { const reg = try refresh_state.ensureRegistryLoaded(allocator, codex_home); if (!reg.auto_switch.enabled) return false; @@ -1622,6 +1733,9 @@ fn daemonCycle(allocator: std.mem.Allocator, codex_home: []const u8, refresh_sta changed = true; } + if (try refreshActiveAccountNamesForDaemonWithFetcher(allocator, codex_home, reg, refresh_state, account_name_fetcher)) { + changed = true; + } if (try refreshActiveUsageForDaemon(allocator, codex_home, reg, refresh_state)) { changed = true; } @@ -1650,6 +1764,19 @@ fn daemonCycle(allocator: std.mem.Allocator, codex_home: []const u8, refresh_sta return true; } +fn daemonCycle(allocator: std.mem.Allocator, codex_home: []const u8, refresh_state: *DaemonRefreshState) !bool { + return daemonCycleWithAccountNameFetcher(allocator, codex_home, refresh_state, fetchActiveAccountNames); +} + +pub fn daemonCycleWithAccountNameFetcherForTest( + allocator: std.mem.Allocator, + codex_home: []const u8, + refresh_state: *DaemonRefreshState, + account_name_fetcher: anytype, +) !bool { + return daemonCycleWithAccountNameFetcher(allocator, codex_home, refresh_state, account_name_fetcher); +} + fn enable(allocator: std.mem.Allocator, codex_home: []const u8) !void { const self_exe = try std.fs.selfExePathAlloc(allocator); defer allocator.free(self_exe); diff --git a/src/tests/auto_test.zig b/src/tests/auto_test.zig index 30bb103..60485cb 100644 --- a/src/tests/auto_test.zig +++ b/src/tests/auto_test.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const account_api = @import("../account_api.zig"); const auto = @import("../auto.zig"); const registry = @import("../registry.zig"); const usage_api = @import("../usage_api.zig"); @@ -18,9 +19,192 @@ const empty_rate_limits_rollout_line = "{" ++ "\"payload\":{\"type\":\"token_count\",\"rate_limits\":{}}}"; var daemon_api_fetch_count: usize = 0; var candidate_api_fetch_count: usize = 0; +var daemon_account_name_fetch_count: usize = 0; var candidate_high_auth_path: ?[]const u8 = null; var candidate_low_auth_path: ?[]const u8 = null; var candidate_reject_auth_path: ?[]const u8 = null; +const daemon_grouped_user_id = "user-auto-grouped"; +const daemon_primary_account_id = "67fe2bbb-0de6-49a4-b2b3-d1df366d1faf"; +const daemon_secondary_account_id = "518a44d9-ba75-4bad-87e5-ae9377042960"; + +fn appendGroupedAccount( + allocator: std.mem.Allocator, + reg: *registry.Registry, + chatgpt_user_id: []const u8, + chatgpt_account_id: []const u8, + email: []const u8, + plan: registry.PlanType, +) !void { + const record_key = try std.fmt.allocPrint(allocator, "{s}::{s}", .{ chatgpt_user_id, chatgpt_account_id }); + errdefer allocator.free(record_key); + + try reg.accounts.append(allocator, .{ + .account_key = record_key, + .chatgpt_account_id = try allocator.dupe(u8, chatgpt_account_id), + .chatgpt_user_id = try allocator.dupe(u8, chatgpt_user_id), + .email = try allocator.dupe(u8, email), + .alias = try allocator.dupe(u8, ""), + .account_name = null, + .plan = plan, + .auth_mode = .chatgpt, + .created_at = std.time.timestamp(), + .last_used_at = null, + .last_usage = null, + .last_usage_at = null, + .last_local_rollout = null, + }); +} + +fn authJsonWithIds( + allocator: std.mem.Allocator, + email: []const u8, + plan: []const u8, + chatgpt_user_id: []const u8, + chatgpt_account_id: []const u8, +) ![]u8 { + const header = "{\"alg\":\"none\",\"typ\":\"JWT\"}"; + const payload = try std.fmt.allocPrint( + allocator, + "{{\"email\":\"{s}\",\"https://api.openai.com/auth\":{{\"chatgpt_account_id\":\"{s}\",\"chatgpt_user_id\":\"{s}\",\"user_id\":\"{s}\",\"chatgpt_plan_type\":\"{s}\"}}}}", + .{ email, chatgpt_account_id, chatgpt_user_id, chatgpt_user_id, plan }, + ); + defer allocator.free(payload); + + const header_b64 = try bdd.b64url(allocator, header); + defer allocator.free(header_b64); + const payload_b64 = try bdd.b64url(allocator, payload); + defer allocator.free(payload_b64); + const jwt = try std.mem.concat(allocator, u8, &[_][]const u8{ header_b64, ".", payload_b64, ".sig" }); + defer allocator.free(jwt); + + return try std.fmt.allocPrint( + allocator, + "{{\"tokens\":{{\"access_token\":\"access-{s}\",\"account_id\":\"{s}\",\"id_token\":\"{s}\"}}}}", + .{ email, chatgpt_account_id, jwt }, + ); +} + +fn writeActiveAuthWithIds( + allocator: std.mem.Allocator, + codex_home: []const u8, + email: []const u8, + plan: []const u8, + chatgpt_user_id: []const u8, + chatgpt_account_id: []const u8, +) !void { + const auth_path = try registry.activeAuthPath(allocator, codex_home); + defer allocator.free(auth_path); + + const auth_json = try authJsonWithIds(allocator, email, plan, chatgpt_user_id, chatgpt_account_id); + defer allocator.free(auth_json); + try std.fs.cwd().writeFile(.{ .sub_path = auth_path, .data = auth_json }); +} + +fn resetDaemonAccountNameFetcher() void { + daemon_account_name_fetch_count = 0; +} + +fn fetchGroupedAccountNames( + allocator: std.mem.Allocator, + access_token: []const u8, + account_id: ?[]const u8, +) !account_api.FetchResult { + _ = access_token; + _ = account_id; + daemon_account_name_fetch_count += 1; + + const entries = try allocator.alloc(account_api.AccountEntry, 2); + errdefer allocator.free(entries); + + entries[0] = .{ + .account_id = try allocator.dupe(u8, daemon_primary_account_id), + .account_name = try allocator.dupe(u8, "Primary Workspace"), + }; + errdefer entries[0].deinit(allocator); + entries[1] = .{ + .account_id = try allocator.dupe(u8, daemon_secondary_account_id), + .account_name = try allocator.dupe(u8, "Backup Workspace"), + }; + errdefer entries[1].deinit(allocator); + + return .{ + .entries = entries, + .status_code = 200, + }; +} + +test "Scenario: Given auto-switch daemon with missing grouped account names when it detects the active scope then it refreshes and saves them" { + const gpa = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + const codex_home = try tmp.dir.realpathAlloc(gpa, "."); + defer gpa.free(codex_home); + try registry.ensureAccountsDir(gpa, codex_home); + + var reg = bdd.makeEmptyRegistry(); + defer reg.deinit(gpa); + reg.auto_switch.enabled = true; + reg.api.usage = false; + try appendGroupedAccount(gpa, ®, daemon_grouped_user_id, daemon_primary_account_id, "group@example.com", .team); + try appendGroupedAccount(gpa, ®, daemon_grouped_user_id, daemon_secondary_account_id, "group@example.com", .team); + try registry.setActiveAccountKey(gpa, ®, reg.accounts.items[0].account_key); + try registry.saveRegistry(gpa, codex_home, ®); + try writeActiveAuthWithIds(gpa, codex_home, "group@example.com", "team", daemon_grouped_user_id, daemon_primary_account_id); + + resetDaemonAccountNameFetcher(); + var refresh_state = auto.DaemonRefreshState{}; + defer refresh_state.deinit(gpa); + try std.testing.expect(try auto.daemonCycleWithAccountNameFetcherForTest( + gpa, + codex_home, + &refresh_state, + fetchGroupedAccountNames, + )); + + var loaded = try registry.loadRegistry(gpa, codex_home); + defer loaded.deinit(gpa); + try std.testing.expectEqual(@as(usize, 1), daemon_account_name_fetch_count); + try std.testing.expectEqualStrings("Primary Workspace", loaded.accounts.items[0].account_name.?); + try std.testing.expectEqualStrings("Backup Workspace", loaded.accounts.items[1].account_name.?); + + try std.testing.expect(try auto.daemonCycleWithAccountNameFetcherForTest( + gpa, + codex_home, + &refresh_state, + fetchGroupedAccountNames, + )); + try std.testing.expectEqual(@as(usize, 1), daemon_account_name_fetch_count); +} + +test "Scenario: Given auto-switch disabled when account names are missing then the daemon skips grouped name refresh" { + const gpa = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + const codex_home = try tmp.dir.realpathAlloc(gpa, "."); + defer gpa.free(codex_home); + try registry.ensureAccountsDir(gpa, codex_home); + + var reg = bdd.makeEmptyRegistry(); + defer reg.deinit(gpa); + reg.api.usage = false; + try appendGroupedAccount(gpa, ®, daemon_grouped_user_id, daemon_primary_account_id, "group@example.com", .team); + try appendGroupedAccount(gpa, ®, daemon_grouped_user_id, daemon_secondary_account_id, "group@example.com", .team); + try registry.setActiveAccountKey(gpa, ®, reg.accounts.items[0].account_key); + + resetDaemonAccountNameFetcher(); + var refresh_state = auto.DaemonRefreshState{}; + defer refresh_state.deinit(gpa); + try std.testing.expect(!(try auto.refreshActiveAccountNamesForDaemonWithFetcher( + gpa, + codex_home, + ®, + &refresh_state, + fetchGroupedAccountNames, + ))); + try std.testing.expectEqual(@as(usize, 0), daemon_account_name_fetch_count); +} fn appendAccountWithUsage( allocator: std.mem.Allocator, From dc277dfd41a963a49b28b36efee6e95149fd4179 Mon Sep 17 00:00:00 2001 From: Loongphy Date: Fri, 27 Mar 2026 09:33:35 +0800 Subject: [PATCH 14/22] fix: merge daemon account-name refresh onto latest registry --- src/auto.zig | 31 ++++++++++++++- src/tests/auto_test.zig | 85 ++++++++++++++++++++++++++++++++++++----- 2 files changed, 105 insertions(+), 11 deletions(-) diff --git a/src/auto.zig b/src/auto.zig index 685c15d..35fad97 100644 --- a/src/auto.zig +++ b/src/auto.zig @@ -859,6 +859,23 @@ fn loadActiveAuthInfoForAccountNameRefresh(allocator: std.mem.Allocator, codex_h }; } +fn applyDaemonAccountNameEntriesToLatestRegistry( + allocator: std.mem.Allocator, + codex_home: []const u8, + chatgpt_user_id: []const u8, + entries: []const account_api.AccountEntry, +) !bool { + var latest = try registry.loadRegistry(allocator, codex_home); + defer latest.deinit(allocator); + + if (!latest.auto_switch.enabled or !latest.api.account) return false; + if (!registry.shouldFetchTeamAccountNamesForUser(&latest, chatgpt_user_id)) return false; + if (!try registry.applyAccountNamesForUser(allocator, &latest, chatgpt_user_id, entries)) return false; + + try registry.saveRegistry(allocator, codex_home, &latest); + return true; +} + fn refreshActiveAccountNamesForDaemon( allocator: std.mem.Allocator, codex_home: []const u8, @@ -910,7 +927,7 @@ pub fn refreshActiveAccountNamesForDaemonWithFetcher( defer result.deinit(allocator); const entries = result.entries orelse return false; - return try registry.applyAccountNamesForUser(allocator, reg, chatgpt_user_id, entries); + return try applyDaemonAccountNameEntriesToLatestRegistry(allocator, codex_home, chatgpt_user_id, entries); } pub fn refreshActiveUsageWithApiFetcher( @@ -1707,7 +1724,7 @@ fn daemonCycleWithAccountNameFetcher( refresh_state: *DaemonRefreshState, account_name_fetcher: anytype, ) !bool { - const reg = try refresh_state.ensureRegistryLoaded(allocator, codex_home); + var reg = try refresh_state.ensureRegistryLoaded(allocator, codex_home); if (!reg.auto_switch.enabled) return false; var changed = false; @@ -1733,9 +1750,19 @@ fn daemonCycleWithAccountNameFetcher( changed = true; } + if (changed) { + try registry.saveRegistry(allocator, codex_home, reg); + try refresh_state.refreshTrackedFileMtims(allocator, codex_home); + changed = false; + } + if (try refreshActiveAccountNamesForDaemonWithFetcher(allocator, codex_home, reg, refresh_state, account_name_fetcher)) { changed = true; } + try refresh_state.reloadRegistryStateIfChanged(allocator, codex_home); + reg = refresh_state.currentRegistry(); + if (!reg.auto_switch.enabled) return true; + if (try refreshActiveUsageForDaemon(allocator, codex_home, reg, refresh_state)) { changed = true; } diff --git a/src/tests/auto_test.zig b/src/tests/auto_test.zig index 60485cb..bbb42fc 100644 --- a/src/tests/auto_test.zig +++ b/src/tests/auto_test.zig @@ -20,6 +20,7 @@ const empty_rate_limits_rollout_line = "{" ++ var daemon_api_fetch_count: usize = 0; var candidate_api_fetch_count: usize = 0; var daemon_account_name_fetch_count: usize = 0; +var daemon_account_name_fetch_registry_rewrite_codex_home: ?[]const u8 = null; var candidate_high_auth_path: ?[]const u8 = null; var candidate_low_auth_path: ?[]const u8 = null; var candidate_reject_auth_path: ?[]const u8 = null; @@ -102,17 +103,10 @@ fn writeActiveAuthWithIds( fn resetDaemonAccountNameFetcher() void { daemon_account_name_fetch_count = 0; + daemon_account_name_fetch_registry_rewrite_codex_home = null; } -fn fetchGroupedAccountNames( - allocator: std.mem.Allocator, - access_token: []const u8, - account_id: ?[]const u8, -) !account_api.FetchResult { - _ = access_token; - _ = account_id; - daemon_account_name_fetch_count += 1; - +fn buildGroupedAccountNamesFetchResult(allocator: std.mem.Allocator) !account_api.FetchResult { const entries = try allocator.alloc(account_api.AccountEntry, 2); errdefer allocator.free(entries); @@ -133,6 +127,36 @@ fn fetchGroupedAccountNames( }; } +fn fetchGroupedAccountNames( + allocator: std.mem.Allocator, + access_token: []const u8, + account_id: ?[]const u8, +) !account_api.FetchResult { + _ = access_token; + _ = account_id; + daemon_account_name_fetch_count += 1; + + return buildGroupedAccountNamesFetchResult(allocator); +} + +fn fetchGroupedAccountNamesAfterConcurrentUsageDisable( + allocator: std.mem.Allocator, + access_token: []const u8, + account_id: ?[]const u8, +) !account_api.FetchResult { + _ = access_token; + _ = account_id; + daemon_account_name_fetch_count += 1; + + const codex_home = daemon_account_name_fetch_registry_rewrite_codex_home orelse return error.TestMissingCodexHome; + var latest = try registry.loadRegistry(allocator, codex_home); + defer latest.deinit(allocator); + latest.api.usage = false; + try registry.saveRegistry(allocator, codex_home, &latest); + + return buildGroupedAccountNamesFetchResult(allocator); +} + test "Scenario: Given auto-switch daemon with missing grouped account names when it detects the active scope then it refreshes and saves them" { const gpa = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); @@ -206,6 +230,49 @@ test "Scenario: Given auto-switch disabled when account names are missing then t try std.testing.expectEqual(@as(usize, 0), daemon_account_name_fetch_count); } +test "Scenario: Given daemon account-name refresh when registry changes during fetch then it merges onto the latest registry" { + const gpa = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + const codex_home = try tmp.dir.realpathAlloc(gpa, "."); + defer gpa.free(codex_home); + try registry.ensureAccountsDir(gpa, codex_home); + + var reg = bdd.makeEmptyRegistry(); + defer reg.deinit(gpa); + reg.auto_switch.enabled = true; + reg.api.usage = true; + reg.api.account = true; + try appendGroupedAccount(gpa, ®, daemon_grouped_user_id, daemon_primary_account_id, "group@example.com", .team); + try appendGroupedAccount(gpa, ®, daemon_grouped_user_id, daemon_secondary_account_id, "group@example.com", .team); + try registry.setActiveAccountKey(gpa, ®, reg.accounts.items[0].account_key); + try registry.saveRegistry(gpa, codex_home, ®); + try writeActiveAuthWithIds(gpa, codex_home, "group@example.com", "team", daemon_grouped_user_id, daemon_primary_account_id); + + const rewrite_codex_home = try gpa.dupe(u8, codex_home); + defer gpa.free(rewrite_codex_home); + resetDaemonAccountNameFetcher(); + daemon_account_name_fetch_registry_rewrite_codex_home = rewrite_codex_home; + defer daemon_account_name_fetch_registry_rewrite_codex_home = null; + + var refresh_state = auto.DaemonRefreshState{}; + defer refresh_state.deinit(gpa); + try std.testing.expect(try auto.daemonCycleWithAccountNameFetcherForTest( + gpa, + codex_home, + &refresh_state, + fetchGroupedAccountNamesAfterConcurrentUsageDisable, + )); + + var loaded = try registry.loadRegistry(gpa, codex_home); + defer loaded.deinit(gpa); + try std.testing.expectEqual(@as(usize, 1), daemon_account_name_fetch_count); + try std.testing.expect(!loaded.api.usage); + try std.testing.expectEqualStrings("Primary Workspace", loaded.accounts.items[0].account_name.?); + try std.testing.expectEqualStrings("Backup Workspace", loaded.accounts.items[1].account_name.?); +} + fn appendAccountWithUsage( allocator: std.mem.Allocator, reg: *registry.Registry, From 205e90efae763e19f4db26344a33bae6f9b59421 Mon Sep 17 00:00:00 2001 From: Loongphy Date: Fri, 27 Mar 2026 10:27:15 +0800 Subject: [PATCH 15/22] fix: refresh account names from stored snapshots --- review.md | 33 +++++++++- src/main.zig | 131 ++++++++++++++++++++++++++++++++++------ src/tests/main_test.zig | 89 +++++++++++++++++++++++++++ 3 files changed, 233 insertions(+), 20 deletions(-) diff --git a/review.md b/review.md index 9ce679b..e7cb499 100644 --- a/review.md +++ b/review.md @@ -1,8 +1,39 @@ ## Review Notes +### P2 + +Rejected for the reported downgrade scenario. + +`account_id` is a workspace identifier. Personal plans (`free`, `plus`, `pro`) can upgrade or downgrade while keeping the same personal workspace, so their `account_id` stays stable. Team workspaces are different: each team has its own `account_id`, and one user may belong to multiple teams. + +The registry identity is `chatgpt_user_id::chatgpt_account_id`, so a team workspace and a personal workspace are different records by construction. Because of that, the reported "Team account downgraded into plus/pro/free and reused the old Team record" path does not match the account model here. + +In practice: + +- Personal account transitions such as `free -> plus -> pro` keep the same personal `account_id`. +- Team membership is represented by separate workspace `account_id` values. +- A Team workspace record does not become a personal workspace record in place just because the user's personal plan changed. +- Personal accounts do not receive synced workspace `account_name` values in this flow, so the claimed stale Team workspace name does not transfer through the personal upgrade/downgrade path described in the review. + ### P3 -Accepted as-is. +Accepted. + +The race is not that names are written onto the wrong record by `account_id` matching. The problem is earlier: the detached background refresh is scheduled by one `switch`, but when the child process starts it re-reads the latest `auth.json`, so it may refresh the later active workspace instead of the workspace that triggered the job. + +Current effect: + +- `switch` to workspace A schedules a refresh for A +- before the child starts, another `switch` updates `auth.json` to workspace B +- the first child reads B and refreshes B +- workspace A is left without the expected name backfill + +Simpler direction: + +- let both `list` and `switch` trigger the same detached background refresh +- make that background refresh scan registry snapshots instead of re-reading the current `auth.json` +- for each user scope that still has grouped Team accounts missing `account_name`, load a stored ChatGPT snapshot token and call the account API once +- apply returned names by `account_id` against the latest registry state Same-email grouped accounts are allowed to resolve to the same `account_name`. In that case, duplicate child labels are acceptable, and we do not need to preserve the old grouped fallback labels such as `team #1` and `team #2` once a synced `account_name` is available. diff --git a/src/main.zig b/src/main.zig index 3c8e03d..3321772 100644 --- a/src/main.zig +++ b/src/main.zig @@ -283,9 +283,58 @@ fn shouldRefreshTeamAccountNamesForUserScope(reg: *registry.Registry, chatgpt_us return registry.shouldFetchTeamAccountNamesForUser(reg, chatgpt_user_id); } +const BackgroundAccountNameRefreshCandidate = struct { + chatgpt_user_id: []u8, + + fn deinit(self: *const BackgroundAccountNameRefreshCandidate, allocator: std.mem.Allocator) void { + allocator.free(self.chatgpt_user_id); + } +}; + +fn hasBackgroundAccountNameRefreshCandidate( + candidates: []const BackgroundAccountNameRefreshCandidate, + chatgpt_user_id: []const u8, +) bool { + for (candidates) |candidate| { + if (std.mem.eql(u8, candidate.chatgpt_user_id, chatgpt_user_id)) return true; + } + return false; +} + +fn collectBackgroundAccountNameRefreshCandidates( + allocator: std.mem.Allocator, + reg: *registry.Registry, +) !std.ArrayList(BackgroundAccountNameRefreshCandidate) { + var candidates = std.ArrayList(BackgroundAccountNameRefreshCandidate).empty; + errdefer { + for (candidates.items) |*candidate| candidate.deinit(allocator); + candidates.deinit(allocator); + } + + if (!reg.api.account) return candidates; + + for (reg.accounts.items) |rec| { + if (rec.auth_mode != null and rec.auth_mode.? != .chatgpt) continue; + if (hasBackgroundAccountNameRefreshCandidate(candidates.items, rec.chatgpt_user_id)) continue; + if (!registry.shouldFetchTeamAccountNamesForUser(reg, rec.chatgpt_user_id)) continue; + + try candidates.append(allocator, .{ + .chatgpt_user_id = try allocator.dupe(u8, rec.chatgpt_user_id), + }); + } + + return candidates; +} + pub fn shouldScheduleBackgroundAccountNameRefresh(reg: *registry.Registry) bool { - const active_user_id = registry.activeChatgptUserId(reg) orelse return false; - return shouldRefreshTeamAccountNamesForUserScope(reg, active_user_id); + if (!reg.api.account) return false; + + for (reg.accounts.items) |rec| { + if (rec.auth_mode != null and rec.auth_mode.? != .chatgpt) continue; + if (registry.shouldFetchTeamAccountNamesForUser(reg, rec.chatgpt_user_id)) return true; + } + + return false; } fn applyAccountNameRefreshEntriesToLatestRegistry( @@ -304,29 +353,75 @@ fn applyAccountNameRefreshEntriesToLatestRegistry( return true; } +fn loadStoredAuthInfoForBackgroundAccountNameRefresh( + allocator: std.mem.Allocator, + codex_home: []const u8, + reg: *registry.Registry, + chatgpt_user_id: []const u8, +) !?auth.AuthInfo { + for (reg.accounts.items) |rec| { + if (!std.mem.eql(u8, rec.chatgpt_user_id, chatgpt_user_id)) continue; + if (rec.auth_mode != null and rec.auth_mode.? != .chatgpt) continue; + + const auth_path = try registry.accountAuthPath(allocator, codex_home, rec.account_key); + defer allocator.free(auth_path); + + const info = auth.parseAuthInfo(allocator, auth_path) catch |err| switch (err) { + error.OutOfMemory => return err, + error.FileNotFound => continue, + else => { + std.log.warn("account metadata refresh skipped: {s}", .{@errorName(err)}); + continue; + }, + }; + if (info.access_token == null) { + var owned_info = info; + owned_info.deinit(allocator); + continue; + } + return info; + } + + return null; +} + pub fn runBackgroundAccountNameRefresh( allocator: std.mem.Allocator, codex_home: []const u8, fetcher: AccountFetchFn, ) !void { - var info = (try loadActiveAuthInfoForAccountRefresh(allocator, codex_home)) orelse return; - defer info.deinit(allocator); - - const chatgpt_user_id = info.chatgpt_user_id orelse return; - const access_token = info.access_token orelse return; - var reg = try registry.loadRegistry(allocator, codex_home); defer reg.deinit(allocator); - if (!shouldRefreshTeamAccountNamesForUserScope(®, chatgpt_user_id)) return; + var candidates = try collectBackgroundAccountNameRefreshCandidates(allocator, ®); + defer { + for (candidates.items) |*candidate| candidate.deinit(allocator); + candidates.deinit(allocator); + } - const result = fetcher(allocator, access_token, info.chatgpt_account_id) catch |err| { - std.log.warn("account metadata refresh skipped: {s}", .{@errorName(err)}); - return; - }; - defer result.deinit(allocator); + for (candidates.items) |candidate| { + var latest = try registry.loadRegistry(allocator, codex_home); + defer latest.deinit(allocator); + + if (!shouldRefreshTeamAccountNamesForUserScope(&latest, candidate.chatgpt_user_id)) continue; - const entries = result.entries orelse return; - _ = try applyAccountNameRefreshEntriesToLatestRegistry(allocator, codex_home, chatgpt_user_id, entries); + var info = (try loadStoredAuthInfoForBackgroundAccountNameRefresh( + allocator, + codex_home, + &latest, + candidate.chatgpt_user_id, + )) orelse continue; + defer info.deinit(allocator); + + const access_token = info.access_token orelse continue; + const result = fetcher(allocator, access_token, info.chatgpt_account_id) catch |err| { + std.log.warn("account metadata refresh skipped: {s}", .{@errorName(err)}); + continue; + }; + defer result.deinit(allocator); + + const entries = result.entries orelse continue; + _ = try applyAccountNameRefreshEntriesToLatestRegistry(allocator, codex_home, candidate.chatgpt_user_id, entries); + } } fn spawnBackgroundAccountNameRefresh(allocator: std.mem.Allocator) !void { @@ -447,11 +542,9 @@ fn handleList(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli.Li try registry.saveRegistry(allocator, codex_home, ®); } } - if (try refreshAccountNamesForList(allocator, codex_home, ®, defaultAccountFetcher)) { - try registry.saveRegistry(allocator, codex_home, ®); - } try maybeRefreshForegroundUsage(allocator, codex_home, ®, .list); try format.printAccounts(®); + maybeSpawnBackgroundAccountNameRefresh(allocator, ®); } fn handleLogin(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli.LoginOptions) !void { diff --git a/src/tests/main_test.zig b/src/tests/main_test.zig index c452fc3..8e7b895 100644 --- a/src/tests/main_test.zig +++ b/src/tests/main_test.zig @@ -132,6 +132,25 @@ fn writeActiveAuthWithIds( try std.fs.cwd().writeFile(.{ .sub_path = auth_path, .data = auth_json }); } +fn writeAccountSnapshotWithIds( + allocator: std.mem.Allocator, + codex_home: []const u8, + email: []const u8, + plan: []const u8, + chatgpt_user_id: []const u8, + chatgpt_account_id: []const u8, +) !void { + const account_key = try std.fmt.allocPrint(allocator, "{s}::{s}", .{ chatgpt_user_id, chatgpt_account_id }); + defer allocator.free(account_key); + + const auth_path = try registry.accountAuthPath(allocator, codex_home, account_key); + defer allocator.free(auth_path); + + const auth_json = try authJsonWithIds(allocator, email, plan, chatgpt_user_id, chatgpt_account_id); + defer allocator.free(auth_json); + try std.fs.cwd().writeFile(.{ .sub_path = auth_path, .data = auth_json }); +} + fn mockAccountNameFetcher( allocator: std.mem.Allocator, access_token: []const u8, @@ -349,6 +368,22 @@ test "Scenario: Given grouped team accounts with account api disabled when check try std.testing.expect(!main_mod.shouldScheduleBackgroundAccountNameRefresh(®)); } +test "Scenario: Given only another user has missing grouped team names when checking background refresh then it is still scheduled" { + const gpa = std.testing.allocator; + var reg = makeRegistry(); + defer reg.deinit(gpa); + + try appendAccount(gpa, ®, primary_record_key, "user@example.com", "", .team); + reg.accounts.items[0].account_name = try gpa.dupe(u8, "Primary Workspace"); + try appendAccount(gpa, ®, secondary_record_key, "user@example.com", "", .team); + reg.accounts.items[1].account_name = try gpa.dupe(u8, "Backup Workspace"); + try appendAccount(gpa, ®, "user-OTHER::acct-OTHER-A", "other@example.com", "", .team); + try appendAccount(gpa, ®, "user-OTHER::acct-OTHER-B", "other@example.com", "", .team); + try registry.setActiveAccountKey(gpa, ®, primary_record_key); + + try std.testing.expect(main_mod.shouldScheduleBackgroundAccountNameRefresh(®)); +} + test "Scenario: Given login with missing account names when refreshing metadata then it issues at most one request" { const gpa = std.testing.allocator; var reg = makeRegistry(); @@ -405,6 +440,7 @@ test "Scenario: Given api disabled while background account-name refresh is in f try appendAccount(gpa, ®, secondary_record_key, "user@example.com", "", .team); try registry.setActiveAccountKey(gpa, ®, primary_record_key); try registry.saveRegistry(gpa, codex_home, ®); + try writeAccountSnapshotWithIds(gpa, codex_home, "user@example.com", "team", shared_user_id, primary_account_id); try writeActiveAuthWithIds(gpa, codex_home, "user@example.com", "team", shared_user_id, primary_account_id); resetMockAccountNameFetcher(); @@ -421,6 +457,59 @@ test "Scenario: Given api disabled while background account-name refresh is in f try std.testing.expect(loaded.accounts.items[1].account_name == null); } +test "Scenario: Given grouped stored snapshots without active auth when running background account-name refresh then it updates the missing names" { + const gpa = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + const codex_home = try tmp.dir.realpathAlloc(gpa, "."); + defer gpa.free(codex_home); + + var reg = makeRegistry(); + defer reg.deinit(gpa); + try appendAccount(gpa, ®, primary_record_key, "user@example.com", "", .team); + try appendAccount(gpa, ®, secondary_record_key, "user@example.com", "", .team); + try registry.saveRegistry(gpa, codex_home, ®); + try writeAccountSnapshotWithIds(gpa, codex_home, "user@example.com", "team", shared_user_id, primary_account_id); + + resetMockAccountNameFetcher(); + try main_mod.runBackgroundAccountNameRefresh(gpa, codex_home, mockAccountNameFetcher); + + var loaded = try registry.loadRegistry(gpa, codex_home); + defer loaded.deinit(gpa); + try std.testing.expectEqual(@as(usize, 1), mock_account_name_fetch_count); + try std.testing.expect(std.mem.eql(u8, loaded.accounts.items[0].account_name.?, "Primary Workspace")); + try std.testing.expect(std.mem.eql(u8, loaded.accounts.items[1].account_name.?, "Backup Workspace")); +} + +test "Scenario: Given same-email grouped team names with only a stored plus snapshot when running background account-name refresh then it updates the team records" { + const gpa = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + const codex_home = try tmp.dir.realpathAlloc(gpa, "."); + defer gpa.free(codex_home); + + var reg = makeRegistry(); + defer reg.deinit(gpa); + try appendAccount(gpa, ®, "user-email-team::" ++ primary_account_id, "same-email@example.com", "", .team); + try appendAccount(gpa, ®, "user-email-team::" ++ secondary_account_id, "same-email@example.com", "", .team); + reg.accounts.items[1].account_name = try gpa.dupe(u8, "Old Backup Workspace"); + try appendAccount(gpa, ®, "user-email-plus::" ++ tertiary_account_id, "same-email@example.com", "", .plus); + try registry.saveRegistry(gpa, codex_home, ®); + try writeAccountSnapshotWithIds(gpa, codex_home, "same-email@example.com", "plus", "user-email-plus", tertiary_account_id); + + resetMockAccountNameFetcher(); + try main_mod.runBackgroundAccountNameRefresh(gpa, codex_home, mockAccountNameFetcher); + + var loaded = try registry.loadRegistry(gpa, codex_home); + defer loaded.deinit(gpa); + try std.testing.expectEqual(@as(usize, 1), mock_account_name_fetch_count); + try std.testing.expect(std.mem.eql(u8, loaded.accounts.items[0].account_name.?, "Primary Workspace")); + try std.testing.expect(std.mem.eql(u8, loaded.accounts.items[1].account_name.?, "Backup Workspace")); + try std.testing.expect(loaded.accounts.items[2].account_name == null); +} + test "Scenario: Given single-file import with missing account names when refreshing metadata then it issues at most one request" { const gpa = std.testing.allocator; var reg = makeRegistry(); From 3c2e43f40d3cf7a6fd11e6bc15ddd66164822c97 Mon Sep 17 00:00:00 2001 From: Loongphy Date: Fri, 27 Mar 2026 10:44:38 +0800 Subject: [PATCH 16/22] refactor: unify daemon account-name refresh flow --- docs/api-refresh.md | 28 +++++---- src/account_name_refresh.zig | 75 +++++++++++++++++++++++++ src/auto.zig | 73 ++++++++++++++---------- src/main.zig | 80 +------------------------- src/tests/auto_test.zig | 106 +++++++++++++++++++++++++++++++++++ 5 files changed, 243 insertions(+), 119 deletions(-) create mode 100644 src/account_name_refresh.zig diff --git a/docs/api-refresh.md b/docs/api-refresh.md index a675731..e223aaf 100644 --- a/docs/api-refresh.md +++ b/docs/api-refresh.md @@ -28,26 +28,32 @@ The `accounts/check` response is parsed by `chatgpt_account_id`. `name: null` an - `api.usage = true`: foreground refresh uses the usage API. - `api.usage = false`: foreground refresh reads only the newest local `~/.codex/sessions/**/rollout-*.jsonl`. -- `list` refreshes the current active account before rendering. -- `switch` refreshes the current active account before showing the picker so the currently selected row is not stale. +- `list` refreshes only the current active account before rendering. +- `switch` refreshes only the current active account before showing the picker so the currently selected row is not stale. - `switch` does not refresh usage for the newly selected account after the switch completes. -- The background auto-switch watcher has its own runtime strategy; see [docs/auto-switch.md](./auto-switch.md). +- the auto-switch daemon refreshes the current active account usage during each cycle when `auto_switch.enabled = true` +- the auto-switch daemon may also refresh a small number of non-active candidate accounts from stored snapshots so it can score switch candidates +- the daemon usage paths are cooldown-limited; see [docs/auto-switch.md](./auto-switch.md) for the broader runtime loop ## Account Name Refresh Rules - `api.account = true` is required. - `login` refreshes immediately after the new active auth is ready. - Single-file `import` refreshes immediately for the imported auth context. -- `list` refreshes in the foreground for the current active scope when that scope still has missing Team `account_name` values. -- `switch` saves the selected account first, then schedules a best-effort background refresh for the newly active scope so the command can exit immediately without waiting for `accounts/check`. -- `switch` does not start that background refresh when `api.account = false`. -- the auto-switch daemon also refreshes the current active scope when `auto_switch.enabled = true` and that scope still has missing Team `account_name` values +- `list` schedules a detached background refresh after rendering. +- `switch` saves the selected account first, then schedules the same detached background refresh so the command can exit immediately without waiting for `accounts/check`. +- those `list` and `switch` background refreshes scan all registry-backed grouped scopes, not just the current `auth.json` scope. +- the auto-switch daemon uses the same grouped-scope scan during each cycle when `auto_switch.enabled = true`. +- `list`, `switch`, and daemon refreshes load access tokens from stored account snapshots under `accounts/` and do not depend on the current `auth.json` belonging to the scope being refreshed. -At most one `accounts/check` request is attempted per refresh path. +At most one `accounts/check` request is attempted per grouped user scope in a given refresh pass. ## Refresh Scope -The grouped account-name refresh scope is anchored on the current active or imported `chatgpt_user_id`. +Grouped account-name refresh always operates on one `chatgpt_user_id` scope at a time. + +- `login` and single-file `import` start from the just-parsed auth info +- `list`, `switch`, and daemon refreshes scan registry-backed grouped scopes and refresh each qualifying scope independently That scope includes: @@ -101,6 +107,4 @@ Then: - `team #1` is filled with `Prod Workspace` - `team #2` is overwritten from `Old Workspace` to `Sandbox Workspace` -The same grouped-scope rule also applies after `switch`, but the refresh runs in the background after the switch is already saved. - -The background `switch` refresh re-loads the latest `registry.json` after `accounts/check` returns, then applies only the grouped `account_name` result onto that latest registry state before saving. +The same grouped-scope rule also applies to detached `list` / `switch` refreshes and to the auto-switch daemon. Those paths re-load the latest `registry.json` before applying each grouped `account_name` update so concurrent registry changes are preserved. diff --git a/src/account_name_refresh.zig b/src/account_name_refresh.zig new file mode 100644 index 0000000..c05cd7f --- /dev/null +++ b/src/account_name_refresh.zig @@ -0,0 +1,75 @@ +const std = @import("std"); +const auth = @import("auth.zig"); +const registry = @import("registry.zig"); + +pub const Candidate = struct { + chatgpt_user_id: []u8, + + pub fn deinit(self: *const Candidate, allocator: std.mem.Allocator) void { + allocator.free(self.chatgpt_user_id); + } +}; + +fn hasCandidate(candidates: []const Candidate, chatgpt_user_id: []const u8) bool { + for (candidates) |candidate| { + if (std.mem.eql(u8, candidate.chatgpt_user_id, chatgpt_user_id)) return true; + } + return false; +} + +pub fn collectCandidates( + allocator: std.mem.Allocator, + reg: *registry.Registry, +) !std.ArrayList(Candidate) { + var candidates = std.ArrayList(Candidate).empty; + errdefer { + for (candidates.items) |*candidate| candidate.deinit(allocator); + candidates.deinit(allocator); + } + + if (!reg.api.account) return candidates; + + for (reg.accounts.items) |rec| { + if (rec.auth_mode != null and rec.auth_mode.? != .chatgpt) continue; + if (hasCandidate(candidates.items, rec.chatgpt_user_id)) continue; + if (!registry.shouldFetchTeamAccountNamesForUser(reg, rec.chatgpt_user_id)) continue; + + try candidates.append(allocator, .{ + .chatgpt_user_id = try allocator.dupe(u8, rec.chatgpt_user_id), + }); + } + + return candidates; +} + +pub fn loadStoredAuthInfoForUser( + allocator: std.mem.Allocator, + codex_home: []const u8, + reg: *registry.Registry, + chatgpt_user_id: []const u8, +) !?auth.AuthInfo { + for (reg.accounts.items) |rec| { + if (!std.mem.eql(u8, rec.chatgpt_user_id, chatgpt_user_id)) continue; + if (rec.auth_mode != null and rec.auth_mode.? != .chatgpt) continue; + + const auth_path = try registry.accountAuthPath(allocator, codex_home, rec.account_key); + defer allocator.free(auth_path); + + const info = auth.parseAuthInfo(allocator, auth_path) catch |err| switch (err) { + error.OutOfMemory => return err, + error.FileNotFound => continue, + else => { + std.log.warn("account metadata refresh skipped: {s}", .{@errorName(err)}); + continue; + }, + }; + if (info.access_token == null) { + var owned_info = info; + owned_info.deinit(allocator); + continue; + } + return info; + } + + return null; +} diff --git a/src/auto.zig b/src/auto.zig index 35fad97..21daebe 100644 --- a/src/auto.zig +++ b/src/auto.zig @@ -1,5 +1,6 @@ const std = @import("std"); const account_api = @import("account_api.zig"); +const account_name_refresh = @import("account_name_refresh.zig"); const auth = @import("auth.zig"); const builtin = @import("builtin"); const c_time = @cImport({ @@ -845,20 +846,6 @@ fn fetchActiveAccountNames( ); } -fn loadActiveAuthInfoForAccountNameRefresh(allocator: std.mem.Allocator, codex_home: []const u8) !?auth.AuthInfo { - const auth_path = try registry.activeAuthPath(allocator, codex_home); - defer allocator.free(auth_path); - - return auth.parseAuthInfo(allocator, auth_path) catch |err| switch (err) { - error.OutOfMemory => return err, - error.FileNotFound => null, - else => { - std.log.warn("account metadata refresh skipped: {s}", .{@errorName(err)}); - return null; - }, - }; -} - fn applyDaemonAccountNameEntriesToLatestRegistry( allocator: std.mem.Allocator, codex_home: []const u8, @@ -903,9 +890,6 @@ pub fn refreshActiveAccountNamesForDaemonWithFetcher( const account_key = reg.active_account_key orelse return false; try refresh_state.resetAccountNameCooldownIfAccountChanged(allocator, account_key); - const chatgpt_user_id = registry.activeChatgptUserId(reg) orelse return false; - if (!registry.shouldFetchTeamAccountNamesForUser(reg, chatgpt_user_id)) return false; - const now_ns = std.time.nanoTimestamp(); if (refresh_state.last_account_name_refresh_at_ns != 0 and (now_ns - refresh_state.last_account_name_refresh_at_ns) < api_refresh_interval_ns) @@ -913,21 +897,50 @@ pub fn refreshActiveAccountNamesForDaemonWithFetcher( return false; } - var info = (try loadActiveAuthInfoForAccountNameRefresh(allocator, codex_home)) orelse return false; - defer info.deinit(allocator); - const access_token = info.access_token orelse return false; - const auth_user_id = info.chatgpt_user_id orelse return false; - if (!std.mem.eql(u8, auth_user_id, chatgpt_user_id)) return false; + var candidates = try account_name_refresh.collectCandidates(allocator, reg); + defer { + for (candidates.items) |*candidate| candidate.deinit(allocator); + candidates.deinit(allocator); + } + if (candidates.items.len == 0) return false; - refresh_state.last_account_name_refresh_at_ns = now_ns; - const result = fetcher(allocator, access_token, info.chatgpt_account_id) catch |err| { - std.log.warn("account metadata refresh skipped: {s}", .{@errorName(err)}); - return false; - }; - defer result.deinit(allocator); + var attempted = false; + var changed = false; - const entries = result.entries orelse return false; - return try applyDaemonAccountNameEntriesToLatestRegistry(allocator, codex_home, chatgpt_user_id, entries); + for (candidates.items) |candidate| { + var latest = try registry.loadRegistry(allocator, codex_home); + defer latest.deinit(allocator); + + if (!latest.auto_switch.enabled or !latest.api.account) continue; + if (!registry.shouldFetchTeamAccountNamesForUser(&latest, candidate.chatgpt_user_id)) continue; + + var info = (try account_name_refresh.loadStoredAuthInfoForUser( + allocator, + codex_home, + &latest, + candidate.chatgpt_user_id, + )) orelse continue; + defer info.deinit(allocator); + + const access_token = info.access_token orelse continue; + if (!attempted) { + refresh_state.last_account_name_refresh_at_ns = now_ns; + attempted = true; + } + + const result = fetcher(allocator, access_token, info.chatgpt_account_id) catch |err| { + std.log.warn("account metadata refresh skipped: {s}", .{@errorName(err)}); + continue; + }; + defer result.deinit(allocator); + + const entries = result.entries orelse continue; + if (try applyDaemonAccountNameEntriesToLatestRegistry(allocator, codex_home, candidate.chatgpt_user_id, entries)) { + changed = true; + } + } + + return changed; } pub fn refreshActiveUsageWithApiFetcher( diff --git a/src/main.zig b/src/main.zig index 3321772..2a18ddd 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1,5 +1,6 @@ const std = @import("std"); const account_api = @import("account_api.zig"); +const account_name_refresh = @import("account_name_refresh.zig"); const cli = @import("cli.zig"); const registry = @import("registry.zig"); const auth = @import("auth.zig"); @@ -283,49 +284,6 @@ fn shouldRefreshTeamAccountNamesForUserScope(reg: *registry.Registry, chatgpt_us return registry.shouldFetchTeamAccountNamesForUser(reg, chatgpt_user_id); } -const BackgroundAccountNameRefreshCandidate = struct { - chatgpt_user_id: []u8, - - fn deinit(self: *const BackgroundAccountNameRefreshCandidate, allocator: std.mem.Allocator) void { - allocator.free(self.chatgpt_user_id); - } -}; - -fn hasBackgroundAccountNameRefreshCandidate( - candidates: []const BackgroundAccountNameRefreshCandidate, - chatgpt_user_id: []const u8, -) bool { - for (candidates) |candidate| { - if (std.mem.eql(u8, candidate.chatgpt_user_id, chatgpt_user_id)) return true; - } - return false; -} - -fn collectBackgroundAccountNameRefreshCandidates( - allocator: std.mem.Allocator, - reg: *registry.Registry, -) !std.ArrayList(BackgroundAccountNameRefreshCandidate) { - var candidates = std.ArrayList(BackgroundAccountNameRefreshCandidate).empty; - errdefer { - for (candidates.items) |*candidate| candidate.deinit(allocator); - candidates.deinit(allocator); - } - - if (!reg.api.account) return candidates; - - for (reg.accounts.items) |rec| { - if (rec.auth_mode != null and rec.auth_mode.? != .chatgpt) continue; - if (hasBackgroundAccountNameRefreshCandidate(candidates.items, rec.chatgpt_user_id)) continue; - if (!registry.shouldFetchTeamAccountNamesForUser(reg, rec.chatgpt_user_id)) continue; - - try candidates.append(allocator, .{ - .chatgpt_user_id = try allocator.dupe(u8, rec.chatgpt_user_id), - }); - } - - return candidates; -} - pub fn shouldScheduleBackgroundAccountNameRefresh(reg: *registry.Registry) bool { if (!reg.api.account) return false; @@ -353,38 +311,6 @@ fn applyAccountNameRefreshEntriesToLatestRegistry( return true; } -fn loadStoredAuthInfoForBackgroundAccountNameRefresh( - allocator: std.mem.Allocator, - codex_home: []const u8, - reg: *registry.Registry, - chatgpt_user_id: []const u8, -) !?auth.AuthInfo { - for (reg.accounts.items) |rec| { - if (!std.mem.eql(u8, rec.chatgpt_user_id, chatgpt_user_id)) continue; - if (rec.auth_mode != null and rec.auth_mode.? != .chatgpt) continue; - - const auth_path = try registry.accountAuthPath(allocator, codex_home, rec.account_key); - defer allocator.free(auth_path); - - const info = auth.parseAuthInfo(allocator, auth_path) catch |err| switch (err) { - error.OutOfMemory => return err, - error.FileNotFound => continue, - else => { - std.log.warn("account metadata refresh skipped: {s}", .{@errorName(err)}); - continue; - }, - }; - if (info.access_token == null) { - var owned_info = info; - owned_info.deinit(allocator); - continue; - } - return info; - } - - return null; -} - pub fn runBackgroundAccountNameRefresh( allocator: std.mem.Allocator, codex_home: []const u8, @@ -392,7 +318,7 @@ pub fn runBackgroundAccountNameRefresh( ) !void { var reg = try registry.loadRegistry(allocator, codex_home); defer reg.deinit(allocator); - var candidates = try collectBackgroundAccountNameRefreshCandidates(allocator, ®); + var candidates = try account_name_refresh.collectCandidates(allocator, ®); defer { for (candidates.items) |*candidate| candidate.deinit(allocator); candidates.deinit(allocator); @@ -404,7 +330,7 @@ pub fn runBackgroundAccountNameRefresh( if (!shouldRefreshTeamAccountNamesForUserScope(&latest, candidate.chatgpt_user_id)) continue; - var info = (try loadStoredAuthInfoForBackgroundAccountNameRefresh( + var info = (try account_name_refresh.loadStoredAuthInfoForUser( allocator, codex_home, &latest, diff --git a/src/tests/auto_test.zig b/src/tests/auto_test.zig index bbb42fc..a98afa4 100644 --- a/src/tests/auto_test.zig +++ b/src/tests/auto_test.zig @@ -101,6 +101,25 @@ fn writeActiveAuthWithIds( try std.fs.cwd().writeFile(.{ .sub_path = auth_path, .data = auth_json }); } +fn writeAccountSnapshotWithIds( + allocator: std.mem.Allocator, + codex_home: []const u8, + email: []const u8, + plan: []const u8, + chatgpt_user_id: []const u8, + chatgpt_account_id: []const u8, +) !void { + const account_key = try std.fmt.allocPrint(allocator, "{s}::{s}", .{ chatgpt_user_id, chatgpt_account_id }); + defer allocator.free(account_key); + + const auth_path = try registry.accountAuthPath(allocator, codex_home, account_key); + defer allocator.free(auth_path); + + const auth_json = try authJsonWithIds(allocator, email, plan, chatgpt_user_id, chatgpt_account_id); + defer allocator.free(auth_json); + try std.fs.cwd().writeFile(.{ .sub_path = auth_path, .data = auth_json }); +} + fn resetDaemonAccountNameFetcher() void { daemon_account_name_fetch_count = 0; daemon_account_name_fetch_registry_rewrite_codex_home = null; @@ -273,6 +292,93 @@ test "Scenario: Given daemon account-name refresh when registry changes during f try std.testing.expectEqualStrings("Backup Workspace", loaded.accounts.items[1].account_name.?); } +test "Scenario: Given auto-switch daemon with only another user missing grouped account names when it runs then it refreshes that stored scope too" { + const gpa = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + const codex_home = try tmp.dir.realpathAlloc(gpa, "."); + defer gpa.free(codex_home); + try registry.ensureAccountsDir(gpa, codex_home); + + var reg = bdd.makeEmptyRegistry(); + defer reg.deinit(gpa); + reg.auto_switch.enabled = true; + reg.api.usage = false; + reg.api.account = true; + try appendGroupedAccount(gpa, ®, "user-active", "acct-active-a", "active@example.com", .team); + reg.accounts.items[0].account_name = try gpa.dupe(u8, "Active Workspace"); + try appendGroupedAccount(gpa, ®, "user-active", "acct-active-b", "active@example.com", .team); + reg.accounts.items[1].account_name = try gpa.dupe(u8, "Active Backup"); + try appendGroupedAccount(gpa, ®, daemon_grouped_user_id, daemon_primary_account_id, "group@example.com", .team); + try appendGroupedAccount(gpa, ®, daemon_grouped_user_id, daemon_secondary_account_id, "group@example.com", .team); + try registry.setActiveAccountKey(gpa, ®, reg.accounts.items[0].account_key); + try registry.saveRegistry(gpa, codex_home, ®); + try writeActiveAuthWithIds(gpa, codex_home, "active@example.com", "team", "user-active", "acct-active-a"); + try writeAccountSnapshotWithIds(gpa, codex_home, "group@example.com", "team", daemon_grouped_user_id, daemon_primary_account_id); + + resetDaemonAccountNameFetcher(); + var refresh_state = auto.DaemonRefreshState{}; + defer refresh_state.deinit(gpa); + try std.testing.expect(try auto.daemonCycleWithAccountNameFetcherForTest( + gpa, + codex_home, + &refresh_state, + fetchGroupedAccountNames, + )); + + var loaded = try registry.loadRegistry(gpa, codex_home); + defer loaded.deinit(gpa); + try std.testing.expectEqual(@as(usize, 1), daemon_account_name_fetch_count); + try std.testing.expectEqualStrings("Primary Workspace", loaded.accounts.items[2].account_name.?); + try std.testing.expectEqualStrings("Backup Workspace", loaded.accounts.items[3].account_name.?); +} + +test "Scenario: Given auto-switch daemon with same-email team names and only a stored plus snapshot when it runs then it updates the team records" { + const gpa = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + const codex_home = try tmp.dir.realpathAlloc(gpa, "."); + defer gpa.free(codex_home); + try registry.ensureAccountsDir(gpa, codex_home); + + var reg = bdd.makeEmptyRegistry(); + defer reg.deinit(gpa); + reg.auto_switch.enabled = true; + reg.api.usage = false; + reg.api.account = true; + try appendGroupedAccount(gpa, ®, "user-active", "acct-active-a", "active@example.com", .team); + reg.accounts.items[0].account_name = try gpa.dupe(u8, "Active Workspace"); + try appendGroupedAccount(gpa, ®, "user-active", "acct-active-b", "active@example.com", .team); + reg.accounts.items[1].account_name = try gpa.dupe(u8, "Active Backup"); + try appendGroupedAccount(gpa, ®, "user-email-team", daemon_primary_account_id, "same-email@example.com", .team); + try appendGroupedAccount(gpa, ®, "user-email-team", daemon_secondary_account_id, "same-email@example.com", .team); + reg.accounts.items[3].account_name = try gpa.dupe(u8, "Old Backup Workspace"); + try appendGroupedAccount(gpa, ®, "user-email-plus", "acct-plus", "same-email@example.com", .plus); + try registry.setActiveAccountKey(gpa, ®, reg.accounts.items[0].account_key); + try registry.saveRegistry(gpa, codex_home, ®); + try writeActiveAuthWithIds(gpa, codex_home, "active@example.com", "team", "user-active", "acct-active-a"); + try writeAccountSnapshotWithIds(gpa, codex_home, "same-email@example.com", "plus", "user-email-plus", "acct-plus"); + + resetDaemonAccountNameFetcher(); + var refresh_state = auto.DaemonRefreshState{}; + defer refresh_state.deinit(gpa); + try std.testing.expect(try auto.daemonCycleWithAccountNameFetcherForTest( + gpa, + codex_home, + &refresh_state, + fetchGroupedAccountNames, + )); + + var loaded = try registry.loadRegistry(gpa, codex_home); + defer loaded.deinit(gpa); + try std.testing.expectEqual(@as(usize, 1), daemon_account_name_fetch_count); + try std.testing.expectEqualStrings("Primary Workspace", loaded.accounts.items[2].account_name.?); + try std.testing.expectEqualStrings("Backup Workspace", loaded.accounts.items[3].account_name.?); + try std.testing.expect(loaded.accounts.items[4].account_name == null); +} + fn appendAccountWithUsage( allocator: std.mem.Allocator, reg: *registry.Registry, From 31611461335b43d77b162691095a4f7996a207f0 Mon Sep 17 00:00:00 2001 From: Loongphy Date: Fri, 27 Mar 2026 11:59:27 +0800 Subject: [PATCH 17/22] fix: prefer freshest account-name snapshot --- src/account_name_refresh.zig | 26 +++- src/auth.zig | 252 ++++++++++++++++++----------------- src/tests/main_test.zig | 113 ++++++++++++++++ 3 files changed, 269 insertions(+), 122 deletions(-) diff --git a/src/account_name_refresh.zig b/src/account_name_refresh.zig index c05cd7f..dda2cbe 100644 --- a/src/account_name_refresh.zig +++ b/src/account_name_refresh.zig @@ -17,6 +17,12 @@ fn hasCandidate(candidates: []const Candidate, chatgpt_user_id: []const u8) bool return false; } +fn candidateIsNewer(candidate: *const auth.AuthInfo, best: *const auth.AuthInfo) bool { + const candidate_refresh = candidate.last_refresh orelse return false; + const best_refresh = best.last_refresh orelse return true; + return std.mem.order(u8, candidate_refresh, best_refresh) == .gt; +} + pub fn collectCandidates( allocator: std.mem.Allocator, reg: *registry.Registry, @@ -48,6 +54,9 @@ pub fn loadStoredAuthInfoForUser( reg: *registry.Registry, chatgpt_user_id: []const u8, ) !?auth.AuthInfo { + var best_info: ?auth.AuthInfo = null; + errdefer if (best_info) |*info| info.deinit(allocator); + for (reg.accounts.items) |rec| { if (!std.mem.eql(u8, rec.chatgpt_user_id, chatgpt_user_id)) continue; if (rec.auth_mode != null and rec.auth_mode.? != .chatgpt) continue; @@ -68,8 +77,21 @@ pub fn loadStoredAuthInfoForUser( owned_info.deinit(allocator); continue; } - return info; + + if (best_info == null) { + best_info = info; + continue; + } + + if (candidateIsNewer(&info, &best_info.?)) { + var previous = best_info.?; + previous.deinit(allocator); + best_info = info; + } else { + var rejected = info; + rejected.deinit(allocator); + } } - return null; + return best_info; } diff --git a/src/auth.zig b/src/auth.zig index 63a4883..cd53fed 100644 --- a/src/auth.zig +++ b/src/auth.zig @@ -7,6 +7,7 @@ pub const AuthInfo = struct { chatgpt_user_id: ?[]u8, record_key: ?[]u8, access_token: ?[]u8, + last_refresh: ?[]u8, plan: ?registry.PlanType, auth_mode: registry.AuthMode, @@ -16,6 +17,7 @@ pub const AuthInfo = struct { if (self.chatgpt_user_id) |id| allocator.free(id); if (self.record_key) |key| allocator.free(key); if (self.access_token) |token| allocator.free(token); + if (self.last_refresh) |value| allocator.free(value); } }; @@ -63,139 +65,148 @@ pub fn parseAuthInfoData(allocator: std.mem.Allocator, data: []const u8) !AuthIn const root = parsed.value; switch (root) { .object => |obj| { - if (obj.get("OPENAI_API_KEY")) |key_val| { - switch (key_val) { - .string => |s| { - if (s.len > 0) return AuthInfo{ - .email = null, - .chatgpt_account_id = null, - .chatgpt_user_id = null, - .record_key = null, - .access_token = null, - .plan = null, - .auth_mode = .apikey, - }; - }, - else => {}, + if (obj.get("OPENAI_API_KEY")) |key_val| { + switch (key_val) { + .string => |s| { + if (s.len > 0) return AuthInfo{ + .email = null, + .chatgpt_account_id = null, + .chatgpt_user_id = null, + .record_key = null, + .access_token = null, + .last_refresh = null, + .plan = null, + .auth_mode = .apikey, + }; + }, + else => {}, + } } - } - if (obj.get("tokens")) |tokens_val| { - switch (tokens_val) { - .object => |tobj| { - var access_token: ?[]u8 = null; - defer if (access_token) |token| allocator.free(token); - access_token = if (tobj.get("access_token")) |access_token_val| switch (access_token_val) { - .string => |s| if (s.len > 0) try allocator.dupe(u8, s) else null, - else => null, - } else null; - var token_chatgpt_account_id: ?[]u8 = null; - defer if (token_chatgpt_account_id) |id| allocator.free(id); - token_chatgpt_account_id = if (tobj.get("account_id")) |account_id_val| switch (account_id_val) { - .string => |s| if (s.len > 0) try allocator.dupe(u8, s) else null, - else => null, - } else null; - if (tobj.get("id_token")) |id_tok| { - switch (id_tok) { - .string => |jwt| { - const payload = try decodeJwtPayload(allocator, jwt); - defer allocator.free(payload); - var payload_json = try std.json.parseFromSlice(std.json.Value, allocator, payload, .{}); - defer payload_json.deinit(); - const claims = payload_json.value; + var last_refresh = if (obj.get("last_refresh")) |last_refresh_val| switch (last_refresh_val) { + .string => |s| if (s.len > 0) try allocator.dupe(u8, s) else null, + else => null, + } else null; + defer if (last_refresh) |value| allocator.free(value); - var jwt_chatgpt_account_id: ?[]u8 = null; - defer if (jwt_chatgpt_account_id) |id| allocator.free(id); - var chatgpt_user_id: ?[]u8 = null; - defer if (chatgpt_user_id) |id| allocator.free(id); - switch (claims) { - .object => |cobj| { - var email: ?[]u8 = null; - defer if (email) |e| allocator.free(e); - if (cobj.get("email")) |e| { - switch (e) { - .string => |s| email = try normalizeEmailAlloc(allocator, s), - else => {}, + if (obj.get("tokens")) |tokens_val| { + switch (tokens_val) { + .object => |tobj| { + var access_token: ?[]u8 = null; + defer if (access_token) |token| allocator.free(token); + access_token = if (tobj.get("access_token")) |access_token_val| switch (access_token_val) { + .string => |s| if (s.len > 0) try allocator.dupe(u8, s) else null, + else => null, + } else null; + var token_chatgpt_account_id: ?[]u8 = null; + defer if (token_chatgpt_account_id) |id| allocator.free(id); + token_chatgpt_account_id = if (tobj.get("account_id")) |account_id_val| switch (account_id_val) { + .string => |s| if (s.len > 0) try allocator.dupe(u8, s) else null, + else => null, + } else null; + if (tobj.get("id_token")) |id_tok| { + switch (id_tok) { + .string => |jwt| { + const payload = try decodeJwtPayload(allocator, jwt); + defer allocator.free(payload); + var payload_json = try std.json.parseFromSlice(std.json.Value, allocator, payload, .{}); + defer payload_json.deinit(); + const claims = payload_json.value; + + var jwt_chatgpt_account_id: ?[]u8 = null; + defer if (jwt_chatgpt_account_id) |id| allocator.free(id); + var chatgpt_user_id: ?[]u8 = null; + defer if (chatgpt_user_id) |id| allocator.free(id); + switch (claims) { + .object => |cobj| { + var email: ?[]u8 = null; + defer if (email) |e| allocator.free(e); + if (cobj.get("email")) |e| { + switch (e) { + .string => |s| email = try normalizeEmailAlloc(allocator, s), + else => {}, + } } - } - var plan: ?registry.PlanType = null; - if (cobj.get("https://api.openai.com/auth")) |auth_obj| { - switch (auth_obj) { - .object => |aobj| { - if (aobj.get("chatgpt_account_id")) |ai| { - switch (ai) { - .string => |s| { - if (s.len > 0) { - jwt_chatgpt_account_id = try allocator.dupe(u8, s); - } - }, - else => {}, - } - } - if (aobj.get("chatgpt_plan_type")) |pt| { - switch (pt) { - .string => |s| plan = parsePlanType(s), - else => {}, + var plan: ?registry.PlanType = null; + if (cobj.get("https://api.openai.com/auth")) |auth_obj| { + switch (auth_obj) { + .object => |aobj| { + if (aobj.get("chatgpt_account_id")) |ai| { + switch (ai) { + .string => |s| { + if (s.len > 0) { + jwt_chatgpt_account_id = try allocator.dupe(u8, s); + } + }, + else => {}, + } } - } - if (aobj.get("chatgpt_user_id")) |uid| { - switch (uid) { - .string => |s| { - if (s.len > 0) { - chatgpt_user_id = try allocator.dupe(u8, s); - } - }, - else => {}, + if (aobj.get("chatgpt_plan_type")) |pt| { + switch (pt) { + .string => |s| plan = parsePlanType(s), + else => {}, + } } - } else if (aobj.get("user_id")) |uid| { - switch (uid) { - .string => |s| { - if (s.len > 0) { - chatgpt_user_id = try allocator.dupe(u8, s); - } - }, - else => {}, + if (aobj.get("chatgpt_user_id")) |uid| { + switch (uid) { + .string => |s| { + if (s.len > 0) { + chatgpt_user_id = try allocator.dupe(u8, s); + } + }, + else => {}, + } + } else if (aobj.get("user_id")) |uid| { + switch (uid) { + .string => |s| { + if (s.len > 0) { + chatgpt_user_id = try allocator.dupe(u8, s); + } + }, + else => {}, + } } - } - }, - else => {}, + }, + else => {}, + } } - } - const chatgpt_account_id = token_chatgpt_account_id orelse return error.MissingAccountId; - if (jwt_chatgpt_account_id == null) return error.MissingAccountId; - if (!std.mem.eql(u8, chatgpt_account_id, jwt_chatgpt_account_id.?)) return error.AccountIdMismatch; - allocator.free(jwt_chatgpt_account_id.?); - jwt_chatgpt_account_id = null; - const chatgpt_user_id_value = chatgpt_user_id orelse return error.MissingChatgptUserId; - const record_key = try recordKeyAlloc(allocator, chatgpt_user_id_value, chatgpt_account_id); + const chatgpt_account_id = token_chatgpt_account_id orelse return error.MissingAccountId; + if (jwt_chatgpt_account_id == null) return error.MissingAccountId; + if (!std.mem.eql(u8, chatgpt_account_id, jwt_chatgpt_account_id.?)) return error.AccountIdMismatch; + allocator.free(jwt_chatgpt_account_id.?); + jwt_chatgpt_account_id = null; + const chatgpt_user_id_value = chatgpt_user_id orelse return error.MissingChatgptUserId; + const record_key = try recordKeyAlloc(allocator, chatgpt_user_id_value, chatgpt_account_id); - const info = AuthInfo{ - .email = email, - .chatgpt_account_id = chatgpt_account_id, - .chatgpt_user_id = chatgpt_user_id_value, - .record_key = record_key, - .access_token = access_token, - .plan = plan, - .auth_mode = .chatgpt, - }; - email = null; - token_chatgpt_account_id = null; - chatgpt_user_id = null; - access_token = null; - return info; - }, - else => {}, - } - }, - else => {}, + const info = AuthInfo{ + .email = email, + .chatgpt_account_id = chatgpt_account_id, + .chatgpt_user_id = chatgpt_user_id_value, + .record_key = record_key, + .access_token = access_token, + .last_refresh = last_refresh, + .plan = plan, + .auth_mode = .chatgpt, + }; + email = null; + token_chatgpt_account_id = null; + chatgpt_user_id = null; + access_token = null; + last_refresh = null; + return info; + }, + else => {}, + } + }, + else => {}, + } } - } - }, - else => {}, + }, + else => {}, + } } - } }, else => {}, } @@ -206,6 +217,7 @@ pub fn parseAuthInfoData(allocator: std.mem.Allocator, data: []const u8) !AuthIn .chatgpt_user_id = null, .record_key = null, .access_token = null, + .last_refresh = null, .plan = null, .auth_mode = .chatgpt, }; diff --git a/src/tests/main_test.zig b/src/tests/main_test.zig index 8e7b895..a581f6c 100644 --- a/src/tests/main_test.zig +++ b/src/tests/main_test.zig @@ -104,6 +104,37 @@ fn authJsonWithIds( ); } +fn authJsonWithIdsAndLastRefresh( + allocator: std.mem.Allocator, + email: []const u8, + plan: []const u8, + chatgpt_user_id: []const u8, + chatgpt_account_id: []const u8, + access_token: []const u8, + last_refresh: []const u8, +) ![]u8 { + const header = "{\"alg\":\"none\",\"typ\":\"JWT\"}"; + const payload = try std.fmt.allocPrint( + allocator, + "{{\"email\":\"{s}\",\"https://api.openai.com/auth\":{{\"chatgpt_account_id\":\"{s}\",\"chatgpt_user_id\":\"{s}\",\"user_id\":\"{s}\",\"chatgpt_plan_type\":\"{s}\"}}}}", + .{ email, chatgpt_account_id, chatgpt_user_id, chatgpt_user_id, plan }, + ); + defer allocator.free(payload); + + const header_b64 = try bdd.b64url(allocator, header); + defer allocator.free(header_b64); + const payload_b64 = try bdd.b64url(allocator, payload); + defer allocator.free(payload_b64); + const jwt = try std.mem.concat(allocator, u8, &[_][]const u8{ header_b64, ".", payload_b64, ".sig" }); + defer allocator.free(jwt); + + return try std.fmt.allocPrint( + allocator, + "{{\"tokens\":{{\"access_token\":\"{s}\",\"account_id\":\"{s}\",\"id_token\":\"{s}\"}},\"last_refresh\":\"{s}\"}}", + .{ access_token, chatgpt_account_id, jwt, last_refresh }, + ); +} + fn parseAuthInfoWithIds( allocator: std.mem.Allocator, email: []const u8, @@ -151,6 +182,35 @@ fn writeAccountSnapshotWithIds( try std.fs.cwd().writeFile(.{ .sub_path = auth_path, .data = auth_json }); } +fn writeAccountSnapshotWithIdsAndLastRefresh( + allocator: std.mem.Allocator, + codex_home: []const u8, + email: []const u8, + plan: []const u8, + chatgpt_user_id: []const u8, + chatgpt_account_id: []const u8, + access_token: []const u8, + last_refresh: []const u8, +) !void { + const account_key = try std.fmt.allocPrint(allocator, "{s}::{s}", .{ chatgpt_user_id, chatgpt_account_id }); + defer allocator.free(account_key); + + const auth_path = try registry.accountAuthPath(allocator, codex_home, account_key); + defer allocator.free(auth_path); + + const auth_json = try authJsonWithIdsAndLastRefresh( + allocator, + email, + plan, + chatgpt_user_id, + chatgpt_account_id, + access_token, + last_refresh, + ); + defer allocator.free(auth_json); + try std.fs.cwd().writeFile(.{ .sub_path = auth_path, .data = auth_json }); +} + fn mockAccountNameFetcher( allocator: std.mem.Allocator, access_token: []const u8, @@ -201,6 +261,15 @@ fn mockAccountNameFetcherWithRegistryMutation( return try mockAccountNameFetcher(allocator, access_token, account_id); } +fn mockAccountNameFetcherRequiringFreshToken( + allocator: std.mem.Allocator, + access_token: []const u8, + account_id: ?[]const u8, +) !account_api.FetchResult { + if (!std.mem.eql(u8, access_token, "fresh-token")) return error.Unauthorized; + return try mockAccountNameFetcher(allocator, access_token, account_id); +} + test "Scenario: Given alias, email, and account name queries when finding matching accounts then all matching strategies work" { const gpa = std.testing.allocator; var reg = makeRegistry(); @@ -482,6 +551,50 @@ test "Scenario: Given grouped stored snapshots without active auth when running try std.testing.expect(std.mem.eql(u8, loaded.accounts.items[1].account_name.?, "Backup Workspace")); } +test "Scenario: Given grouped stored snapshots with multiple tokens when running background account-name refresh then it prefers the newest last_refresh" { + const gpa = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + const codex_home = try tmp.dir.realpathAlloc(gpa, "."); + defer gpa.free(codex_home); + + var reg = makeRegistry(); + defer reg.deinit(gpa); + try appendAccount(gpa, ®, primary_record_key, "user@example.com", "", .team); + try appendAccount(gpa, ®, secondary_record_key, "user@example.com", "", .team); + try registry.saveRegistry(gpa, codex_home, ®); + try writeAccountSnapshotWithIdsAndLastRefresh( + gpa, + codex_home, + "user@example.com", + "team", + shared_user_id, + primary_account_id, + "stale-token", + "2026-03-20T00:00:00Z", + ); + try writeAccountSnapshotWithIdsAndLastRefresh( + gpa, + codex_home, + "user@example.com", + "team", + shared_user_id, + secondary_account_id, + "fresh-token", + "2026-03-21T00:00:00Z", + ); + + resetMockAccountNameFetcher(); + try main_mod.runBackgroundAccountNameRefresh(gpa, codex_home, mockAccountNameFetcherRequiringFreshToken); + + var loaded = try registry.loadRegistry(gpa, codex_home); + defer loaded.deinit(gpa); + try std.testing.expectEqual(@as(usize, 1), mock_account_name_fetch_count); + try std.testing.expect(std.mem.eql(u8, loaded.accounts.items[0].account_name.?, "Primary Workspace")); + try std.testing.expect(std.mem.eql(u8, loaded.accounts.items[1].account_name.?, "Backup Workspace")); +} + test "Scenario: Given same-email grouped team names with only a stored plus snapshot when running background account-name refresh then it updates the team records" { const gpa = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); From d53218bc6632f4650799b913eea52c0e2b953ba3 Mon Sep 17 00:00:00 2001 From: Loongphy Date: Fri, 27 Mar 2026 12:48:00 +0800 Subject: [PATCH 18/22] fix: stop backfilling account metadata from auth snapshots --- docs/api-refresh.md | 1 + docs/auto-switch.md | 11 +++++------ src/auto.zig | 18 ------------------ src/main.zig | 12 ------------ src/registry.zig | 42 ----------------------------------------- src/tests/auto_test.zig | 35 ---------------------------------- 6 files changed, 6 insertions(+), 113 deletions(-) diff --git a/docs/api-refresh.md b/docs/api-refresh.md index e223aaf..9a4b838 100644 --- a/docs/api-refresh.md +++ b/docs/api-refresh.md @@ -45,6 +45,7 @@ The `accounts/check` response is parsed by `chatgpt_account_id`. `name: null` an - those `list` and `switch` background refreshes scan all registry-backed grouped scopes, not just the current `auth.json` scope. - the auto-switch daemon uses the same grouped-scope scan during each cycle when `auto_switch.enabled = true`. - `list`, `switch`, and daemon refreshes load access tokens from stored account snapshots under `accounts/` and do not depend on the current `auth.json` belonging to the scope being refreshed. +- `list`, `switch`, and daemon refreshes do not backfill missing `plan` or `auth_mode` from stored snapshots before deciding whether a grouped Team scope qualifies. At most one `accounts/check` request is attempted per grouped user scope in a given refresh pass. diff --git a/docs/auto-switch.md b/docs/auto-switch.md index daf9e87..13f28c7 100644 --- a/docs/auto-switch.md +++ b/docs/auto-switch.md @@ -33,12 +33,11 @@ Each cycle: 1. keeps an in-memory candidate index for all non-active accounts, keyed by the same candidate score used for switching 2. reloads `registry.json` only when the on-disk file changed, then rebuilds that in-memory index 3. syncs the currently active `auth.json` into the in-memory registry when the active auth snapshot changed -4. refreshes missing account metadata from stored auth snapshots when needed -5. tries to refresh usage from the newest local rollout event first -6. if no new local rollout event is available, or the newest event has no usable rate-limit windows, and `api.usage = true`, falls back to the ChatGPT usage API at most once per minute for the current active account -7. keeps the candidate index warm with a bounded candidate upkeep pass instead of batch-refreshing every candidate -8. if the active account should switch, revalidates only the top few stale candidates before making the final switch decision -9. writes `registry.json` only when state changed +4. tries to refresh usage from the newest local rollout event first +5. if no new local rollout event is available, or the newest event has no usable rate-limit windows, and `api.usage = true`, falls back to the ChatGPT usage API at most once per minute for the current active account +6. keeps the candidate index warm with a bounded candidate upkeep pass instead of batch-refreshing every candidate +7. if the active account should switch, revalidates only the top few stale candidates before making the final switch decision +8. writes `registry.json` only when state changed The watcher also emits English-only service logs for debugging: diff --git a/src/auto.zig b/src/auto.zig index 21daebe..ab74475 100644 --- a/src/auto.zig +++ b/src/auto.zig @@ -1745,24 +1745,6 @@ fn daemonCycleWithAccountNameFetcher( changed = true; } - var needs_refresh = false; - for (reg.accounts.items) |rec| { - if (rec.plan == null or rec.auth_mode == null) { - needs_refresh = true; - break; - } - } - if (needs_refresh) { - const metadata_changed = try registry.refreshAccountsFromAuth(allocator, codex_home, reg); - if (!metadata_changed) { - needs_refresh = false; - } - } - if (needs_refresh) { - try refresh_state.rebuildCandidateState(allocator); - changed = true; - } - if (changed) { try registry.saveRegistry(allocator, codex_home, reg); try refresh_state.refreshTrackedFileMtims(allocator, codex_home); diff --git a/src/main.zig b/src/main.zig index 2a18ddd..9c7e6e4 100644 --- a/src/main.zig +++ b/src/main.zig @@ -456,18 +456,6 @@ fn handleList(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli.Li if (try registry.syncActiveAccountFromAuth(allocator, codex_home, ®)) { try registry.saveRegistry(allocator, codex_home, ®); } - var needs_refresh = false; - for (reg.accounts.items) |rec| { - if (rec.plan == null or rec.auth_mode == null) { - needs_refresh = true; - break; - } - } - if (needs_refresh) { - if (try registry.refreshAccountsFromAuth(allocator, codex_home, ®)) { - try registry.saveRegistry(allocator, codex_home, ®); - } - } try maybeRefreshForegroundUsage(allocator, codex_home, ®, .list); try format.printAccounts(®); maybeSpawnBackgroundAccountNameRefresh(allocator, ®); diff --git a/src/registry.zig b/src/registry.zig index 77f15bf..e9299ac 100644 --- a/src/registry.zig +++ b/src/registry.zig @@ -2694,48 +2694,6 @@ fn parseThresholdPercent(v: std.json.Value) ?u8 { return @as(u8, @intCast(raw)); } -pub fn refreshAccountsFromAuth(allocator: std.mem.Allocator, codex_home: []const u8, reg: *Registry) !bool { - var changed = false; - for (reg.accounts.items) |*rec| { - const path = resolveStrictAccountAuthPath(allocator, codex_home, rec.account_key) catch |err| switch (err) { - error.FileNotFound => continue, - else => return err, - }; - defer allocator.free(path); - const info = try @import("auth.zig").parseAuthInfo(allocator, path); - defer info.deinit(allocator); - const email = info.email orelse { - std.log.warn("auth file missing email for {s}; skipping refresh", .{rec.email}); - continue; - }; - const chatgpt_account_id = info.chatgpt_account_id orelse { - std.log.warn("auth file missing account_id for {s}; skipping refresh", .{rec.email}); - continue; - }; - const record_key = info.record_key orelse { - std.log.warn("auth file missing record key for {s}; skipping refresh", .{rec.email}); - continue; - }; - if (!std.mem.eql(u8, email, rec.email)) { - std.log.warn("auth file email mismatch for {s}; skipping refresh", .{rec.email}); - continue; - } - if (!std.mem.eql(u8, chatgpt_account_id, rec.chatgpt_account_id)) { - std.log.warn("auth file account_id mismatch for {s}; skipping refresh", .{rec.email}); - continue; - } - if (!std.mem.eql(u8, record_key, rec.account_key)) { - std.log.warn("auth file record_key mismatch for {s}; skipping refresh", .{rec.email}); - continue; - } - if (rec.plan != info.plan) changed = true; - if (rec.auth_mode != info.auth_mode) changed = true; - rec.plan = info.plan; - rec.auth_mode = info.auth_mode; - } - return changed; -} - pub fn autoImportActiveAuth(allocator: std.mem.Allocator, codex_home: []const u8, reg: *Registry) !bool { if (reg.accounts.items.len != 0) return false; diff --git a/src/tests/auto_test.zig b/src/tests/auto_test.zig index a98afa4..b3cffdf 100644 --- a/src/tests/auto_test.zig +++ b/src/tests/auto_test.zig @@ -2116,38 +2116,3 @@ test "Scenario: Given latest rollout file without usable rate limits when refres try std.testing.expectEqual(@as(f64, 41.0), reg.accounts.items[idx].last_usage.?.primary.?.used_percent); try std.testing.expectEqual(@as(i64, 777), reg.accounts.items[idx].last_usage_at.?); } - -test "Scenario: Given permanently null metadata when refreshing accounts from auth then it does not report a change" { - const gpa = std.testing.allocator; - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - - const codex_home = try tmp.dir.realpathAlloc(gpa, "."); - defer gpa.free(codex_home); - try tmp.dir.makePath("accounts"); - - var reg = bdd.makeEmptyRegistry(); - defer reg.deinit(gpa); - try bdd.appendAccount(gpa, ®, "api@example.com", "", null); - - const account_key = try bdd.accountKeyForEmailAlloc(gpa, "api@example.com"); - defer gpa.free(account_key); - const auth_path = try registry.accountAuthPath(gpa, codex_home, account_key); - defer gpa.free(auth_path); - try std.fs.cwd().writeFile(.{ - .sub_path = auth_path, - .data = - \\{ - \\ "OPENAI_API_KEY": "sk-test" - \\} - , - }); - - const idx = bdd.findAccountIndexByEmail(®, "api@example.com") orelse return error.TestExpectedEqual; - reg.accounts.items[idx].plan = null; - reg.accounts.items[idx].auth_mode = null; - - try std.testing.expect(!(try registry.refreshAccountsFromAuth(gpa, codex_home, ®))); - try std.testing.expect(reg.accounts.items[idx].plan == null); - try std.testing.expect(reg.accounts.items[idx].auth_mode == null); -} From 641f52ee5c4db2c7a13af166fc5f8db576c39efa Mon Sep 17 00:00:00 2001 From: Loongphy Date: Fri, 27 Mar 2026 16:12:36 +0800 Subject: [PATCH 19/22] fix: require ChatGPT account id for account refresh --- docs/api-refresh.md | 8 ++++++-- src/account_api.zig | 2 +- src/auto.zig | 5 +++-- src/chatgpt_http.zig | 29 +++++++++-------------------- src/main.zig | 10 ++++++---- src/tests/auto_test.zig | 4 ++-- src/tests/main_test.zig | 17 +++++++++++++---- 7 files changed, 40 insertions(+), 35 deletions(-) diff --git a/docs/api-refresh.md b/docs/api-refresh.md index 9a4b838..1c44a29 100644 --- a/docs/api-refresh.md +++ b/docs/api-refresh.md @@ -19,7 +19,7 @@ This document is the single source of truth for outbound ChatGPT API refresh beh - URL: `https://chatgpt.com/backend-api/accounts/check/v4-2023-04-27` - headers: - `Authorization: Bearer ` - - `ChatGPT-Account-Id: ` when present + - `ChatGPT-Account-Id: ` - `User-Agent: codex-auth` The `accounts/check` response is parsed by `chatgpt_account_id`. `name: null` and `name: ""` are both normalized to `account_name = null`. @@ -38,16 +38,20 @@ The `accounts/check` response is parsed by `chatgpt_account_id`. `name: null` an ## Account Name Refresh Rules - `api.account = true` is required. +- A usable ChatGPT auth context with both `access_token` and `chatgpt_account_id` is required. If either value is missing, refresh is skipped before any request is sent. - `login` refreshes immediately after the new active auth is ready. - Single-file `import` refreshes immediately for the imported auth context. - `list` schedules a detached background refresh after rendering. - `switch` saves the selected account first, then schedules the same detached background refresh so the command can exit immediately without waiting for `accounts/check`. - those `list` and `switch` background refreshes scan all registry-backed grouped scopes, not just the current `auth.json` scope. - the auto-switch daemon uses the same grouped-scope scan during each cycle when `auto_switch.enabled = true`. -- `list`, `switch`, and daemon refreshes load access tokens from stored account snapshots under `accounts/` and do not depend on the current `auth.json` belonging to the scope being refreshed. +- `list`, `switch`, and daemon refreshes load the request auth context from stored account snapshots under `accounts/` and do not depend on the current `auth.json` belonging to the scope being refreshed. +- when multiple stored ChatGPT snapshots exist for one grouped scope, background and daemon refreshes pick the snapshot with the newest `last_refresh`. +- stored snapshots without a usable `access_token` or `chatgpt_account_id` are skipped. - `list`, `switch`, and daemon refreshes do not backfill missing `plan` or `auth_mode` from stored snapshots before deciding whether a grouped Team scope qualifies. At most one `accounts/check` request is attempted per grouped user scope in a given refresh pass. +Request failures and unparseable responses are non-fatal and leave stored `account_name` values unchanged. ## Refresh Scope diff --git a/src/account_api.zig b/src/account_api.zig index 35531ad..cf4b25d 100644 --- a/src/account_api.zig +++ b/src/account_api.zig @@ -29,7 +29,7 @@ pub fn fetchAccountsForTokenDetailed( allocator: std.mem.Allocator, endpoint: []const u8, access_token: []const u8, - account_id: ?[]const u8, + account_id: []const u8, ) !FetchResult { const http_result = try chatgpt_http.runGetJsonCommand(allocator, endpoint, access_token, account_id); defer allocator.free(http_result.body); diff --git a/src/auto.zig b/src/auto.zig index ab74475..4861960 100644 --- a/src/auto.zig +++ b/src/auto.zig @@ -836,7 +836,7 @@ pub fn refreshActiveUsage(allocator: std.mem.Allocator, codex_home: []const u8, fn fetchActiveAccountNames( allocator: std.mem.Allocator, access_token: []const u8, - account_id: ?[]const u8, + account_id: []const u8, ) !account_api.FetchResult { return try account_api.fetchAccountsForTokenDetailed( allocator, @@ -923,12 +923,13 @@ pub fn refreshActiveAccountNamesForDaemonWithFetcher( defer info.deinit(allocator); const access_token = info.access_token orelse continue; + const chatgpt_account_id = info.chatgpt_account_id orelse continue; if (!attempted) { refresh_state.last_account_name_refresh_at_ns = now_ns; attempted = true; } - const result = fetcher(allocator, access_token, info.chatgpt_account_id) catch |err| { + const result = fetcher(allocator, access_token, chatgpt_account_id) catch |err| { std.log.warn("account metadata refresh skipped: {s}", .{@errorName(err)}); continue; }; diff --git a/src/chatgpt_http.zig b/src/chatgpt_http.zig index 1803850..c27f608 100644 --- a/src/chatgpt_http.zig +++ b/src/chatgpt_http.zig @@ -17,7 +17,7 @@ pub fn runGetJsonCommand( allocator: std.mem.Allocator, endpoint: []const u8, access_token: []const u8, - account_id: ?[]const u8, + account_id: []const u8, ) !HttpResult { return if (builtin.os.tag == .windows) runPowerShellGetJsonCommand(allocator, endpoint, access_token, account_id) @@ -29,15 +29,12 @@ fn runCurlGetJsonCommand( allocator: std.mem.Allocator, endpoint: []const u8, access_token: []const u8, - account_id: ?[]const u8, + account_id: []const u8, ) !HttpResult { const authorization = try std.fmt.allocPrint(allocator, "Authorization: Bearer {s}", .{access_token}); defer allocator.free(authorization); - const account_header = if (account_id) |value| - try std.fmt.allocPrint(allocator, "ChatGPT-Account-Id: {s}", .{value}) - else - null; - defer if (account_header) |header| allocator.free(header); + const account_header = try std.fmt.allocPrint(allocator, "ChatGPT-Account-Id: {s}", .{account_id}); + defer allocator.free(account_header); var argv = std.ArrayList([]const u8).empty; defer argv.deinit(allocator); @@ -56,9 +53,7 @@ fn runCurlGetJsonCommand( "-H", authorization, }); - if (account_header) |header| { - try argv.appendSlice(allocator, &.{ "-H", header }); - } + try argv.appendSlice(allocator, &.{ "-H", account_header }); try argv.appendSlice(allocator, &.{ "-H", "User-Agent: codex-auth", @@ -90,21 +85,15 @@ fn runPowerShellGetJsonCommand( allocator: std.mem.Allocator, endpoint: []const u8, access_token: []const u8, - account_id: ?[]const u8, + account_id: []const u8, ) !HttpResult { const escaped_token = try escapePowerShellSingleQuoted(allocator, access_token); defer allocator.free(escaped_token); - const escaped_account_id = if (account_id) |value| - try escapePowerShellSingleQuoted(allocator, value) - else - null; - defer if (escaped_account_id) |value| allocator.free(value); + const escaped_account_id = try escapePowerShellSingleQuoted(allocator, account_id); + defer allocator.free(escaped_account_id); const escaped_endpoint = try escapePowerShellSingleQuoted(allocator, endpoint); defer allocator.free(escaped_endpoint); - const account_header_fragment = if (escaped_account_id) |value| - try std.fmt.allocPrint(allocator, "'ChatGPT-Account-Id' = '{s}'; ", .{value}) - else - try allocator.dupe(u8, ""); + const account_header_fragment = try std.fmt.allocPrint(allocator, "'ChatGPT-Account-Id' = '{s}'; ", .{escaped_account_id}); defer allocator.free(account_header_fragment); const script = try std.fmt.allocPrint( diff --git a/src/main.zig b/src/main.zig index 9c7e6e4..920be0f 100644 --- a/src/main.zig +++ b/src/main.zig @@ -14,7 +14,7 @@ const disable_background_account_name_refresh_env = "CODEX_AUTH_DISABLE_BACKGROU const AccountFetchFn = *const fn ( allocator: std.mem.Allocator, access_token: []const u8, - account_id: ?[]const u8, + account_id: []const u8, ) anyerror!account_api.FetchResult; pub fn main() !void { @@ -194,7 +194,7 @@ fn maybeRefreshForegroundUsage( fn defaultAccountFetcher( allocator: std.mem.Allocator, access_token: []const u8, - account_id: ?[]const u8, + account_id: []const u8, ) !account_api.FetchResult { return try account_api.fetchAccountsForTokenDetailed( allocator, @@ -213,8 +213,9 @@ fn maybeRefreshAccountNamesForAuthInfo( const chatgpt_user_id = info.chatgpt_user_id orelse return false; if (!shouldRefreshTeamAccountNamesForUserScope(reg, chatgpt_user_id)) return false; const access_token = info.access_token orelse return false; + const chatgpt_account_id = info.chatgpt_account_id orelse return false; - const result = fetcher(allocator, access_token, info.chatgpt_account_id) catch |err| { + const result = fetcher(allocator, access_token, chatgpt_account_id) catch |err| { std.log.warn("account metadata refresh skipped: {s}", .{@errorName(err)}); return false; }; @@ -339,7 +340,8 @@ pub fn runBackgroundAccountNameRefresh( defer info.deinit(allocator); const access_token = info.access_token orelse continue; - const result = fetcher(allocator, access_token, info.chatgpt_account_id) catch |err| { + const chatgpt_account_id = info.chatgpt_account_id orelse continue; + const result = fetcher(allocator, access_token, chatgpt_account_id) catch |err| { std.log.warn("account metadata refresh skipped: {s}", .{@errorName(err)}); continue; }; diff --git a/src/tests/auto_test.zig b/src/tests/auto_test.zig index b3cffdf..9ddddc9 100644 --- a/src/tests/auto_test.zig +++ b/src/tests/auto_test.zig @@ -149,7 +149,7 @@ fn buildGroupedAccountNamesFetchResult(allocator: std.mem.Allocator) !account_ap fn fetchGroupedAccountNames( allocator: std.mem.Allocator, access_token: []const u8, - account_id: ?[]const u8, + account_id: []const u8, ) !account_api.FetchResult { _ = access_token; _ = account_id; @@ -161,7 +161,7 @@ fn fetchGroupedAccountNames( fn fetchGroupedAccountNamesAfterConcurrentUsageDisable( allocator: std.mem.Allocator, access_token: []const u8, - account_id: ?[]const u8, + account_id: []const u8, ) !account_api.FetchResult { _ = access_token; _ = account_id; diff --git a/src/tests/main_test.zig b/src/tests/main_test.zig index a581f6c..38b4982 100644 --- a/src/tests/main_test.zig +++ b/src/tests/main_test.zig @@ -19,11 +19,13 @@ const standalone_team_record_key = standalone_team_user_id ++ "::" ++ standalone var mock_account_name_fetch_count: usize = 0; var mutate_registry_during_account_fetch = false; var mutate_registry_codex_home: ?[]const u8 = null; +var expected_mock_account_name_fetch_account_id: ?[]const u8 = null; fn resetMockAccountNameFetcher() void { mock_account_name_fetch_count = 0; mutate_registry_during_account_fetch = false; mutate_registry_codex_home = null; + expected_mock_account_name_fetch_account_id = null; } fn makeRegistry() registry.Registry { @@ -214,10 +216,12 @@ fn writeAccountSnapshotWithIdsAndLastRefresh( fn mockAccountNameFetcher( allocator: std.mem.Allocator, access_token: []const u8, - account_id: ?[]const u8, + account_id: []const u8, ) !account_api.FetchResult { _ = access_token; - _ = account_id; + if (expected_mock_account_name_fetch_account_id) |expected_account_id| { + if (!std.mem.eql(u8, account_id, expected_account_id)) return error.TestUnexpectedAccountId; + } mock_account_name_fetch_count += 1; const entries = try allocator.alloc(account_api.AccountEntry, 2); @@ -247,7 +251,7 @@ fn mockAccountNameFetcher( fn mockAccountNameFetcherWithRegistryMutation( allocator: std.mem.Allocator, access_token: []const u8, - account_id: ?[]const u8, + account_id: []const u8, ) !account_api.FetchResult { if (mutate_registry_during_account_fetch) { const codex_home = mutate_registry_codex_home orelse return error.TestExpectedEqual; @@ -264,7 +268,7 @@ fn mockAccountNameFetcherWithRegistryMutation( fn mockAccountNameFetcherRequiringFreshToken( allocator: std.mem.Allocator, access_token: []const u8, - account_id: ?[]const u8, + account_id: []const u8, ) !account_api.FetchResult { if (!std.mem.eql(u8, access_token, "fresh-token")) return error.Unauthorized; return try mockAccountNameFetcher(allocator, access_token, account_id); @@ -465,6 +469,7 @@ test "Scenario: Given login with missing account names when refreshing metadata defer info.deinit(gpa); resetMockAccountNameFetcher(); + expected_mock_account_name_fetch_account_id = primary_account_id; const changed = try main_mod.refreshAccountNamesAfterLogin(gpa, ®, &info, mockAccountNameFetcher); try std.testing.expect(changed); try std.testing.expectEqual(@as(usize, 1), mock_account_name_fetch_count); @@ -488,6 +493,7 @@ test "Scenario: Given switched account with missing account names when refreshin try writeActiveAuthWithIds(gpa, codex_home, "user@example.com", "team", shared_user_id, primary_account_id); resetMockAccountNameFetcher(); + expected_mock_account_name_fetch_account_id = primary_account_id; const changed = try main_mod.refreshAccountNamesAfterSwitch(gpa, codex_home, ®, mockAccountNameFetcher); try std.testing.expect(changed); try std.testing.expectEqual(@as(usize, 1), mock_account_name_fetch_count); @@ -586,6 +592,7 @@ test "Scenario: Given grouped stored snapshots with multiple tokens when running ); resetMockAccountNameFetcher(); + expected_mock_account_name_fetch_account_id = secondary_account_id; try main_mod.runBackgroundAccountNameRefresh(gpa, codex_home, mockAccountNameFetcherRequiringFreshToken); var loaded = try registry.loadRegistry(gpa, codex_home); @@ -700,6 +707,7 @@ test "Scenario: Given list refresh with missing active-user account names when r try writeActiveAuthWithIds(gpa, codex_home, "user@example.com", "team", shared_user_id, primary_account_id); resetMockAccountNameFetcher(); + expected_mock_account_name_fetch_account_id = primary_account_id; const changed = try main_mod.refreshAccountNamesForList(gpa, codex_home, ®, mockAccountNameFetcher); try std.testing.expect(changed); try std.testing.expectEqual(@as(usize, 1), mock_account_name_fetch_count); @@ -729,6 +737,7 @@ test "Scenario: Given list refresh with same-email team names missing under a di try writeActiveAuthWithIds(gpa, codex_home, "same-email@example.com", "plus", "user-email-plus", tertiary_account_id); resetMockAccountNameFetcher(); + expected_mock_account_name_fetch_account_id = tertiary_account_id; const changed = try main_mod.refreshAccountNamesForList(gpa, codex_home, ®, mockAccountNameFetcher); try std.testing.expect(changed); try std.testing.expectEqual(@as(usize, 1), mock_account_name_fetch_count); From 0edb8354f40115a969be993c6af912acdf6eab7c Mon Sep 17 00:00:00 2001 From: Loongphy Date: Fri, 27 Mar 2026 16:27:33 +0800 Subject: [PATCH 20/22] fix(auto): bootstrap daemon account-name refresh Force the first daemon cycle to sync auth.json into stored account snapshots before grouped account-name refresh runs. Also free duplicated candidate user ids when appending a refresh candidate fails. --- src/account_name_refresh.zig | 4 +++- src/auto.zig | 3 +++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/account_name_refresh.zig b/src/account_name_refresh.zig index dda2cbe..0b201a0 100644 --- a/src/account_name_refresh.zig +++ b/src/account_name_refresh.zig @@ -40,8 +40,10 @@ pub fn collectCandidates( if (hasCandidate(candidates.items, rec.chatgpt_user_id)) continue; if (!registry.shouldFetchTeamAccountNamesForUser(reg, rec.chatgpt_user_id)) continue; + const duped_id = try allocator.dupe(u8, rec.chatgpt_user_id); + errdefer allocator.free(duped_id); try candidates.append(allocator, .{ - .chatgpt_user_id = try allocator.dupe(u8, rec.chatgpt_user_id), + .chatgpt_user_id = duped_id, }); } diff --git a/src/auto.zig b/src/auto.zig index 4861960..29fed68 100644 --- a/src/auto.zig +++ b/src/auto.zig @@ -367,6 +367,9 @@ pub const DaemonRefreshState = struct { fn ensureRegistryLoaded(self: *DaemonRefreshState, allocator: std.mem.Allocator, codex_home: []const u8) !*registry.Registry { if (self.current_reg == null) { try self.reloadRegistryState(allocator, codex_home); + // Force the first daemon cycle to sync auth.json into accounts/ snapshots + // before grouped account-name refresh looks for stored auth contexts. + self.auth_mtime_ns = -1; } else { try self.reloadRegistryStateIfChanged(allocator, codex_home); } From 7805b60fcf020ce503cb298b0a1b93bac364153f Mon Sep 17 00:00:00 2001 From: Loongphy Date: Fri, 27 Mar 2026 16:48:59 +0800 Subject: [PATCH 21/22] refactor: scope account name sync by chatgpt user id --- docs/api-refresh.md | 5 +++-- plans/2026-03-26-account-name.md | 10 +++++----- review.md | 13 +++++++++++-- src/registry.zig | 11 ++--------- src/tests/auto_test.zig | 10 +++++----- src/tests/main_test.zig | 26 +++++++++++--------------- src/tests/registry_test.zig | 8 ++++---- 7 files changed, 41 insertions(+), 42 deletions(-) diff --git a/docs/api-refresh.md b/docs/api-refresh.md index 1c44a29..467bd2a 100644 --- a/docs/api-refresh.md +++ b/docs/api-refresh.md @@ -63,9 +63,10 @@ Grouped account-name refresh always operates on one `chatgpt_user_id` scope at a That scope includes: - all records with the same `chatgpt_user_id` -- all records whose email matches any email owned by that user -This means a `free`, `plus`, or `pro` record can still trigger a grouped Team-name refresh when it shares an email grouping with Team records. +`chatgpt_user_id` is the user identity for this flow. A single user may have multiple workspace `chatgpt_account_id` values, and those workspaces can include personal and Team records under the same email. + +This means a `free`, `plus`, or `pro` record can still trigger a grouped Team-name refresh when it belongs to the same `chatgpt_user_id` as Team records. `accounts/check` is attempted only when: diff --git a/plans/2026-03-26-account-name.md b/plans/2026-03-26-account-name.md index 5c3223c..3c9bb6d 100644 --- a/plans/2026-03-26-account-name.md +++ b/plans/2026-03-26-account-name.md @@ -51,10 +51,10 @@ This document records the shipped behavior for ChatGPT `account_name` sync and d - Refresh requires a usable auth context with: - `access_token` - `chatgpt_user_id` -- Refresh scope is broader than a single user ID: - - records owned by the same `chatgpt_user_id` - - plus records on emails also owned by that user -- This means a same-email plus/free account can trigger refresh for same-email team records. + - `chatgpt_account_id` +- Refresh scope is one `chatgpt_user_id`. +- One `chatgpt_user_id` represents one user and may contain multiple workspace `chatgpt_account_id` values. +- This means a plus/free workspace can trigger refresh for Team workspaces only when they belong to the same `chatgpt_user_id`. - A refresh is eligible only when the scoped records satisfy all of these: - there is more than one scoped account - at least one scoped Team account exists @@ -115,7 +115,7 @@ This document records the shipped behavior for ChatGPT `account_name` sync and d - old registries load with `account_name = null` - `account_name` round-trips for `null` and string values - `api.account` round-trips and backfills correctly - - same-email scoped updates apply to related Team records + - same-user scoped updates apply to related Team records - Display coverage: - singleton rows keep email labels - singleton/grouped behavior is decided from the rendered subset diff --git a/review.md b/review.md index e7cb499..2309114 100644 --- a/review.md +++ b/review.md @@ -1,5 +1,14 @@ ## Review Notes +## Account Model + +`chatgpt_user_id` is the primary user identity for account-name sync. + +- One `chatgpt_user_id` represents one user. +- A user can have multiple workspace `chatgpt_account_id` values. +- Those workspace records can cover personal and Team workspaces under the same email. +- We do not treat "same email but different `chatgpt_user_id`" as the grouping rule for this flow. + ### P2 Rejected for the reported downgrade scenario. @@ -32,10 +41,10 @@ Simpler direction: - let both `list` and `switch` trigger the same detached background refresh - make that background refresh scan registry snapshots instead of re-reading the current `auth.json` -- for each user scope that still has grouped Team accounts missing `account_name`, load a stored ChatGPT snapshot token and call the account API once +- for each `chatgpt_user_id` scope that still has grouped Team accounts missing `account_name`, load a stored ChatGPT snapshot token and call the account API once - apply returned names by `account_id` against the latest registry state -Same-email grouped accounts are allowed to resolve to the same `account_name`. In that case, duplicate child labels are acceptable, and we do not need to preserve the old grouped fallback labels such as `team #1` and `team #2` once a synced `account_name` is available. +Multiple workspace records under the same `chatgpt_user_id` are allowed to resolve to the same `account_name`. In that case, duplicate child labels are acceptable, and we do not need to preserve the old grouped fallback labels such as `team #1` and `team #2` once a synced `account_name` is available. Example: diff --git a/src/registry.zig b/src/registry.zig index e9299ac..eadbd9a 100644 --- a/src/registry.zig +++ b/src/registry.zig @@ -1778,16 +1778,9 @@ fn isTeamAccount(rec: *const AccountRecord) bool { return plan == .team; } -fn userOwnsEmail(reg: *const Registry, chatgpt_user_id: []const u8, email: []const u8) bool { - for (reg.accounts.items) |rec| { - if (!std.mem.eql(u8, rec.chatgpt_user_id, chatgpt_user_id)) continue; - if (std.mem.eql(u8, rec.email, email)) return true; - } - return false; -} - fn inAccountNameRefreshScope(reg: *const Registry, chatgpt_user_id: []const u8, rec: *const AccountRecord) bool { - return std.mem.eql(u8, rec.chatgpt_user_id, chatgpt_user_id) or userOwnsEmail(reg, chatgpt_user_id, rec.email); + _ = reg; + return std.mem.eql(u8, rec.chatgpt_user_id, chatgpt_user_id); } pub fn hasMissingAccountNameForUser(reg: *const Registry, chatgpt_user_id: []const u8) bool { diff --git a/src/tests/auto_test.zig b/src/tests/auto_test.zig index 9ddddc9..a558bdb 100644 --- a/src/tests/auto_test.zig +++ b/src/tests/auto_test.zig @@ -334,7 +334,7 @@ test "Scenario: Given auto-switch daemon with only another user missing grouped try std.testing.expectEqualStrings("Backup Workspace", loaded.accounts.items[3].account_name.?); } -test "Scenario: Given auto-switch daemon with same-email team names and only a stored plus snapshot when it runs then it updates the team records" { +test "Scenario: Given auto-switch daemon with grouped team names and only a stored plus snapshot for the same user when it runs then it updates the team records" { const gpa = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); @@ -352,14 +352,14 @@ test "Scenario: Given auto-switch daemon with same-email team names and only a s reg.accounts.items[0].account_name = try gpa.dupe(u8, "Active Workspace"); try appendGroupedAccount(gpa, ®, "user-active", "acct-active-b", "active@example.com", .team); reg.accounts.items[1].account_name = try gpa.dupe(u8, "Active Backup"); - try appendGroupedAccount(gpa, ®, "user-email-team", daemon_primary_account_id, "same-email@example.com", .team); - try appendGroupedAccount(gpa, ®, "user-email-team", daemon_secondary_account_id, "same-email@example.com", .team); + try appendGroupedAccount(gpa, ®, daemon_grouped_user_id, daemon_primary_account_id, "group@example.com", .team); + try appendGroupedAccount(gpa, ®, daemon_grouped_user_id, daemon_secondary_account_id, "group@example.com", .team); reg.accounts.items[3].account_name = try gpa.dupe(u8, "Old Backup Workspace"); - try appendGroupedAccount(gpa, ®, "user-email-plus", "acct-plus", "same-email@example.com", .plus); + try appendGroupedAccount(gpa, ®, daemon_grouped_user_id, "acct-plus", "group@example.com", .plus); try registry.setActiveAccountKey(gpa, ®, reg.accounts.items[0].account_key); try registry.saveRegistry(gpa, codex_home, ®); try writeActiveAuthWithIds(gpa, codex_home, "active@example.com", "team", "user-active", "acct-active-a"); - try writeAccountSnapshotWithIds(gpa, codex_home, "same-email@example.com", "plus", "user-email-plus", "acct-plus"); + try writeAccountSnapshotWithIds(gpa, codex_home, "group@example.com", "plus", daemon_grouped_user_id, "acct-plus"); resetDaemonAccountNameFetcher(); var refresh_state = auto.DaemonRefreshState{}; diff --git a/src/tests/main_test.zig b/src/tests/main_test.zig index 38b4982..af80898 100644 --- a/src/tests/main_test.zig +++ b/src/tests/main_test.zig @@ -338,15 +338,11 @@ test "Scenario: Given team name fetch candidates when checking grouped-account p try appendAccount(gpa, ®, primary_record_key, "same-user@example.com", "", .team); try appendAccount(gpa, ®, secondary_record_key, "same-user@example.com", "", .free); - try appendAccount(gpa, ®, "user-email-team::acct-email-team", "same-email@example.com", "", .team); - try appendAccount(gpa, ®, "user-email-plus::acct-email-plus", "same-email@example.com", "", .plus); try appendAccount(gpa, ®, standalone_team_record_key, "solo-team@example.com", "", .team); try appendAccount(gpa, ®, "user-plus-only::acct-plus-a", "plus-only@example.com", "", .plus); try appendAccount(gpa, ®, "user-plus-only::acct-plus-b", "plus-only-alt@example.com", "", .plus); try std.testing.expect(registry.shouldFetchTeamAccountNamesForUser(®, shared_user_id)); - try std.testing.expect(registry.shouldFetchTeamAccountNamesForUser(®, "user-email-team")); - try std.testing.expect(registry.shouldFetchTeamAccountNamesForUser(®, "user-email-plus")); try std.testing.expect(!registry.shouldFetchTeamAccountNamesForUser(®, standalone_team_user_id)); try std.testing.expect(!registry.shouldFetchTeamAccountNamesForUser(®, "user-plus-only")); } @@ -602,7 +598,7 @@ test "Scenario: Given grouped stored snapshots with multiple tokens when running try std.testing.expect(std.mem.eql(u8, loaded.accounts.items[1].account_name.?, "Backup Workspace")); } -test "Scenario: Given same-email grouped team names with only a stored plus snapshot when running background account-name refresh then it updates the team records" { +test "Scenario: Given grouped team names with only a stored plus snapshot for the same user when running background account-name refresh then it updates the team records" { const gpa = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); @@ -612,12 +608,12 @@ test "Scenario: Given same-email grouped team names with only a stored plus snap var reg = makeRegistry(); defer reg.deinit(gpa); - try appendAccount(gpa, ®, "user-email-team::" ++ primary_account_id, "same-email@example.com", "", .team); - try appendAccount(gpa, ®, "user-email-team::" ++ secondary_account_id, "same-email@example.com", "", .team); + try appendAccount(gpa, ®, shared_user_id ++ "::" ++ primary_account_id, "same-user@example.com", "", .team); + try appendAccount(gpa, ®, shared_user_id ++ "::" ++ secondary_account_id, "same-user@example.com", "", .team); reg.accounts.items[1].account_name = try gpa.dupe(u8, "Old Backup Workspace"); - try appendAccount(gpa, ®, "user-email-plus::" ++ tertiary_account_id, "same-email@example.com", "", .plus); + try appendAccount(gpa, ®, shared_user_id ++ "::" ++ tertiary_account_id, "same-user@example.com", "", .plus); try registry.saveRegistry(gpa, codex_home, ®); - try writeAccountSnapshotWithIds(gpa, codex_home, "same-email@example.com", "plus", "user-email-plus", tertiary_account_id); + try writeAccountSnapshotWithIds(gpa, codex_home, "same-user@example.com", "plus", shared_user_id, tertiary_account_id); resetMockAccountNameFetcher(); try main_mod.runBackgroundAccountNameRefresh(gpa, codex_home, mockAccountNameFetcher); @@ -719,7 +715,7 @@ test "Scenario: Given list refresh with missing active-user account names when r try std.testing.expectEqual(@as(usize, 0), mock_account_name_fetch_count); } -test "Scenario: Given list refresh with same-email team names missing under a different active user when refreshing metadata then it updates the team records" { +test "Scenario: Given list refresh with team names missing under the same user when refreshing metadata then it updates the team records" { const gpa = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); @@ -729,12 +725,12 @@ test "Scenario: Given list refresh with same-email team names missing under a di var reg = makeRegistry(); defer reg.deinit(gpa); - try appendAccount(gpa, ®, "user-email-team::" ++ primary_account_id, "same-email@example.com", "", .team); - try appendAccount(gpa, ®, "user-email-team::" ++ secondary_account_id, "same-email@example.com", "", .team); + try appendAccount(gpa, ®, shared_user_id ++ "::" ++ primary_account_id, "same-user@example.com", "", .team); + try appendAccount(gpa, ®, shared_user_id ++ "::" ++ secondary_account_id, "same-user@example.com", "", .team); reg.accounts.items[1].account_name = try gpa.dupe(u8, "Old Backup Workspace"); - try appendAccount(gpa, ®, "user-email-plus::" ++ tertiary_account_id, "same-email@example.com", "", .plus); - try registry.setActiveAccountKey(gpa, ®, "user-email-plus::" ++ tertiary_account_id); - try writeActiveAuthWithIds(gpa, codex_home, "same-email@example.com", "plus", "user-email-plus", tertiary_account_id); + try appendAccount(gpa, ®, shared_user_id ++ "::" ++ tertiary_account_id, "same-user@example.com", "", .plus); + try registry.setActiveAccountKey(gpa, ®, shared_user_id ++ "::" ++ tertiary_account_id); + try writeActiveAuthWithIds(gpa, codex_home, "same-user@example.com", "plus", shared_user_id, tertiary_account_id); resetMockAccountNameFetcher(); expected_mock_account_name_fetch_account_id = tertiary_account_id; diff --git a/src/tests/registry_test.zig b/src/tests/registry_test.zig index ef0c60e..0b7ed15 100644 --- a/src/tests/registry_test.zig +++ b/src/tests/registry_test.zig @@ -344,18 +344,18 @@ test "applyAccountNamesForUser preserves existing account_name when replacement try std.testing.expectEqualStrings("Primary Workspace", reg.accounts.items[0].account_name.?); } -test "applyAccountNamesForUser updates same-email team records for a different active user" { +test "applyAccountNamesForUser updates same-user records across personal and team workspaces" { const gpa = std.testing.allocator; var reg = makeEmptyRegistry(); defer reg.deinit(gpa); var team = try makeAccountRecord(gpa, "same@example.com", "", .team, .chatgpt, 1); - try setRecordIds(gpa, &team, "user-team", "acct-team"); + try setRecordIds(gpa, &team, "user-shared", "acct-team"); team.account_name = try gpa.dupe(u8, "Legacy Workspace"); try reg.accounts.append(gpa, team); var plus = try makeAccountRecord(gpa, "same@example.com", "", .plus, .chatgpt, 2); - try setRecordIds(gpa, &plus, "user-plus", "acct-plus"); + try setRecordIds(gpa, &plus, "user-shared", "acct-plus"); try reg.accounts.append(gpa, plus); var other = try makeAccountRecord(gpa, "other@example.com", "", .team, .chatgpt, 3); @@ -370,7 +370,7 @@ test "applyAccountNamesForUser updates same-email team records for a different a defer entry.deinit(gpa); const entries = [_]account_api.AccountEntry{entry}; - const changed = try registry.applyAccountNamesForUser(gpa, ®, "user-plus", &entries); + const changed = try registry.applyAccountNamesForUser(gpa, ®, "user-shared", &entries); try std.testing.expect(changed); try std.testing.expectEqualStrings("Primary Workspace", reg.accounts.items[0].account_name.?); try std.testing.expect(reg.accounts.items[1].account_name == null); From 3df98786c88eb09567f5347b10ab8fa6e7de0c44 Mon Sep 17 00:00:00 2001 From: Loongphy Date: Fri, 27 Mar 2026 20:53:37 +0800 Subject: [PATCH 22/22] fix: coordinate account name refresh selection and locking --- src/account_name_refresh.zig | 151 ++++++++++++++++++++++++++++++----- src/main.zig | 59 ++++++++++++++ src/registry.zig | 2 + src/tests/registry_test.zig | 3 + 4 files changed, 196 insertions(+), 19 deletions(-) diff --git a/src/account_name_refresh.zig b/src/account_name_refresh.zig index 0b201a0..f213105 100644 --- a/src/account_name_refresh.zig +++ b/src/account_name_refresh.zig @@ -1,7 +1,35 @@ +const builtin = @import("builtin"); const std = @import("std"); const auth = @import("auth.zig"); const registry = @import("registry.zig"); +pub const BackgroundRefreshLock = struct { + file: std.fs.File, + + pub fn acquire(allocator: std.mem.Allocator, codex_home: []const u8) !?BackgroundRefreshLock { + try registry.ensureAccountsDir(allocator, codex_home); + const path = try std.fs.path.join(allocator, &[_][]const u8{ + codex_home, + "accounts", + registry.account_name_refresh_lock_file_name, + }); + defer allocator.free(path); + + var file = try std.fs.cwd().createFile(path, .{ .read = true, .truncate = false }); + errdefer file.close(); + if (!(try tryExclusiveLock(file))) { + file.close(); + return null; + } + return .{ .file = file }; + } + + pub fn release(self: *BackgroundRefreshLock) void { + self.file.unlock(); + self.file.close(); + } +}; + pub const Candidate = struct { chatgpt_user_id: []u8, @@ -23,6 +51,63 @@ fn candidateIsNewer(candidate: *const auth.AuthInfo, best: *const auth.AuthInfo) return std.mem.order(u8, candidate_refresh, best_refresh) == .gt; } +fn tryExclusiveLock(file: std.fs.File) !bool { + if (builtin.os.tag == .windows) { + const windows = std.os.windows; + const range_off: windows.LARGE_INTEGER = 0; + const range_len: windows.LARGE_INTEGER = 1; + var io_status_block: windows.IO_STATUS_BLOCK = undefined; + windows.LockFile( + file.handle, + null, + null, + null, + &io_status_block, + &range_off, + &range_len, + null, + windows.TRUE, + windows.TRUE, + ) catch |err| switch (err) { + error.WouldBlock => return false, + else => |e| return e, + }; + return true; + } + + return try file.tryLock(.exclusive); +} + +fn storedAuthInfoSupportsAccountNameRefresh(info: *const auth.AuthInfo) bool { + return info.access_token != null and info.chatgpt_account_id != null; +} + +fn considerStoredAuthInfoForRefresh( + allocator: std.mem.Allocator, + best_info: *?auth.AuthInfo, + info: auth.AuthInfo, +) void { + if (!storedAuthInfoSupportsAccountNameRefresh(&info)) { + var skipped = info; + skipped.deinit(allocator); + return; + } + + if (best_info.* == null) { + best_info.* = info; + return; + } + + if (candidateIsNewer(&info, &best_info.*.?)) { + var previous = best_info.*.?; + previous.deinit(allocator); + best_info.* = info; + } else { + var rejected = info; + rejected.deinit(allocator); + } +} + pub fn collectCandidates( allocator: std.mem.Allocator, reg: *registry.Registry, @@ -74,26 +159,54 @@ pub fn loadStoredAuthInfoForUser( continue; }, }; - if (info.access_token == null) { - var owned_info = info; - owned_info.deinit(allocator); - continue; - } - - if (best_info == null) { - best_info = info; - continue; - } - - if (candidateIsNewer(&info, &best_info.?)) { - var previous = best_info.?; - previous.deinit(allocator); - best_info = info; - } else { - var rejected = info; - rejected.deinit(allocator); - } + considerStoredAuthInfoForRefresh(allocator, &best_info, info); } return best_info; } + +fn makeStoredAuthInfoForTest( + allocator: std.mem.Allocator, + access_token: []const u8, + chatgpt_account_id: ?[]const u8, + last_refresh: []const u8, +) !auth.AuthInfo { + return .{ + .email = null, + .chatgpt_account_id = if (chatgpt_account_id) |account_id| try allocator.dupe(u8, account_id) else null, + .chatgpt_user_id = try allocator.dupe(u8, "user-1"), + .record_key = null, + .access_token = try allocator.dupe(u8, access_token), + .last_refresh = try allocator.dupe(u8, last_refresh), + .plan = null, + .auth_mode = .chatgpt, + }; +} + +test "stored auth selection skips newer snapshots missing account id" { + const gpa = std.testing.allocator; + + var best_info: ?auth.AuthInfo = null; + defer if (best_info) |*info| info.deinit(gpa); + + const valid = try makeStoredAuthInfoForTest( + gpa, + "stale-token", + "acct-stale", + "2026-03-20T00:00:00Z", + ); + considerStoredAuthInfoForRefresh(gpa, &best_info, valid); + + const missing_account_id = try makeStoredAuthInfoForTest( + gpa, + "fresh-token", + null, + "2026-03-21T00:00:00Z", + ); + considerStoredAuthInfoForRefresh(gpa, &best_info, missing_account_id); + + try std.testing.expect(best_info != null); + try std.testing.expect(std.mem.eql(u8, best_info.?.access_token.?, "stale-token")); + try std.testing.expect(std.mem.eql(u8, best_info.?.chatgpt_account_id.?, "acct-stale")); + try std.testing.expect(std.mem.eql(u8, best_info.?.last_refresh.?, "2026-03-20T00:00:00Z")); +} diff --git a/src/main.zig b/src/main.zig index 920be0f..9de688f 100644 --- a/src/main.zig +++ b/src/main.zig @@ -16,6 +16,10 @@ const AccountFetchFn = *const fn ( access_token: []const u8, account_id: []const u8, ) anyerror!account_api.FetchResult; +const BackgroundRefreshLockAcquirer = *const fn ( + allocator: std.mem.Allocator, + codex_home: []const u8, +) anyerror!?account_name_refresh.BackgroundRefreshLock; pub fn main() !void { var exit_code: u8 = 0; @@ -317,6 +321,23 @@ pub fn runBackgroundAccountNameRefresh( codex_home: []const u8, fetcher: AccountFetchFn, ) !void { + return try runBackgroundAccountNameRefreshWithLockAcquirer( + allocator, + codex_home, + fetcher, + account_name_refresh.BackgroundRefreshLock.acquire, + ); +} + +fn runBackgroundAccountNameRefreshWithLockAcquirer( + allocator: std.mem.Allocator, + codex_home: []const u8, + fetcher: AccountFetchFn, + lock_acquirer: BackgroundRefreshLockAcquirer, +) !void { + var refresh_lock = (try lock_acquirer(allocator, codex_home)) orelse return; + defer refresh_lock.release(); + var reg = try registry.loadRegistry(allocator, codex_home); defer reg.deinit(allocator); var candidates = try account_name_refresh.collectCandidates(allocator, ®); @@ -799,6 +820,44 @@ fn handleClean(allocator: std.mem.Allocator, codex_home: []const u8) !void { try out.flush(); } +test "background account-name refresh returns early when another refresh holds the lock" { + const TestState = struct { + var fetch_count: usize = 0; + + fn lockUnavailable(_: std.mem.Allocator, _: []const u8) !?account_name_refresh.BackgroundRefreshLock { + return null; + } + + fn unexpectedFetcher( + allocator: std.mem.Allocator, + access_token: []const u8, + account_id: []const u8, + ) !account_api.FetchResult { + _ = allocator; + _ = access_token; + _ = account_id; + fetch_count += 1; + return error.TestUnexpectedFetch; + } + }; + + const gpa = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + const codex_home = try tmp.dir.realpathAlloc(gpa, "."); + defer gpa.free(codex_home); + + TestState.fetch_count = 0; + try runBackgroundAccountNameRefreshWithLockAcquirer( + gpa, + codex_home, + TestState.unexpectedFetcher, + TestState.lockUnavailable, + ); + try std.testing.expectEqual(@as(usize, 0), TestState.fetch_count); +} + // Tests live in separate files but are pulled in by main.zig for zig test. test { _ = @import("tests/auth_test.zig"); diff --git a/src/registry.zig b/src/registry.zig index eadbd9a..a1d5fca 100644 --- a/src/registry.zig +++ b/src/registry.zig @@ -11,6 +11,7 @@ pub const current_schema_version: u32 = 3; pub const min_supported_schema_version: u32 = 2; pub const default_auto_switch_threshold_5h_percent: u8 = 10; pub const default_auto_switch_threshold_weekly_percent: u8 = 5; +pub const account_name_refresh_lock_file_name = "account-name-refresh.lock"; fn normalizeEmailAlloc(allocator: std.mem.Allocator, email: []const u8) ![]u8 { var buf = try allocator.alloc(u8, email.len); @@ -555,6 +556,7 @@ fn isAllowedCurrentSnapshot(reg: *const Registry, entry_name: []const u8) bool { fn isAllowedAccountsEntry(reg: *const Registry, entry_name: []const u8) bool { if (std.mem.eql(u8, entry_name, "registry.json")) return true; if (std.mem.eql(u8, entry_name, "auto-switch.lock")) return true; + if (std.mem.eql(u8, entry_name, account_name_refresh_lock_file_name)) return true; if (std.mem.eql(u8, entry_name, "backups")) return true; return isAllowedCurrentSnapshot(reg, entry_name); } diff --git a/src/tests/registry_test.zig b/src/tests/registry_test.zig index 0b7ed15..dda61eb 100644 --- a/src/tests/registry_test.zig +++ b/src/tests/registry_test.zig @@ -871,6 +871,7 @@ test "clean uses a whitelist and only removes non-current entries under accounts try tmp.dir.writeFile(.{ .sub_path = "accounts/auth.json.bak.3", .data = "a3" }); try tmp.dir.writeFile(.{ .sub_path = "accounts/registry.json.bak.1", .data = "r1" }); try tmp.dir.writeFile(.{ .sub_path = "accounts/registry.json.bak.2", .data = "r2" }); + try tmp.dir.writeFile(.{ .sub_path = "accounts/" ++ registry.account_name_refresh_lock_file_name, .data = "" }); try tmp.dir.writeFile(.{ .sub_path = keep_rel_path, .data = "keep" }); try tmp.dir.writeFile(.{ .sub_path = "accounts/bGVnYWN5QGV4YW1wbGUuY29t.auth.json", .data = "legacy" }); try tmp.dir.writeFile(.{ .sub_path = "accounts/notes.txt", .data = "junk" }); @@ -890,6 +891,8 @@ test "clean uses a whitelist and only removes non-current entries under accounts try std.testing.expect(try countBackups(accounts, "registry.json") == 0); var kept = try tmp.dir.openFile(keep_rel_path, .{}); kept.close(); + var refresh_lock = try tmp.dir.openFile("accounts/" ++ registry.account_name_refresh_lock_file_name, .{}); + refresh_lock.close(); try std.testing.expectError(error.FileNotFound, tmp.dir.openFile("accounts/bGVnYWN5QGV4YW1wbGUuY29t.auth.json", .{})); try std.testing.expectError(error.FileNotFound, tmp.dir.openFile("accounts/notes.txt", .{})); try std.testing.expectError(error.FileNotFound, tmp.dir.openFile("accounts/tmpdir/old.txt", .{}));