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. diff --git a/README.md b/README.md index f624312..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` | Use API-backed fallback or local-only usage refresh | +| `codex-auth config api enable\|disable` | Enable or disable both usage refresh and team name refresh API calls | --- @@ -263,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). - ## Q&A ### Why is my usage limit not refreshing? @@ -337,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 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 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-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(`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 new file mode 100644 index 0000000..467bd2a --- /dev/null +++ b/docs/api-refresh.md @@ -0,0 +1,116 @@ +# 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: ` + - `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 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 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. +- 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 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 + +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: + +- all records with the same `chatgpt_user_id` + +`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: + +- 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 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/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/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/plans/2026-03-26-account-name.md b/plans/2026-03-26-account-name.md new file mode 100644 index 0000000..3c9bb6d --- /dev/null +++ b/plans/2026-03-26-account-name.md @@ -0,0 +1,125 @@ +--- +name: account-name +description: Finalized account-name sync behavior for accounts/check parsing, registry persistence, refresh policy, and list/switch/remove display rules +--- + +# 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: ` when present + - `User-Agent: codex-auth` +- Parsed fields: + - `accounts..account.account_id` + - `accounts..account.name` +- Ignored fields: + - `accounts.default` + - `account_ordering` + - all other payload fields +- 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` + - `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 + - at least one scoped Team account still has a missing `account_name` +- Refresh timing: + - `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 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: + - 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 => 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-user 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` diff --git a/review.md b/review.md new file mode 100644 index 0000000..2309114 --- /dev/null +++ b/review.md @@ -0,0 +1,62 @@ +## 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. + +`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. + +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 `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 + +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: + +- `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/account_api.zig b/src/account_api.zig new file mode 100644 index 0000000..cf4b25d --- /dev/null +++ b/src/account_api.zig @@ -0,0 +1,113 @@ +const std = @import("std"); +const chatgpt_http = @import("chatgpt_http.zig"); + +pub const default_account_endpoint = "https://chatgpt.com/backend-api/accounts/check/v4-2023-04-27"; + +pub const AccountEntry = struct { + account_id: []u8, + account_name: ?[]u8, + + 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: ?[]AccountEntry, + 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 fetchAccountsForTokenDetailed( + 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 parseAccountsResponse(allocator, http_result.body), + .status_code = http_result.status_code, + }; +} + +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, + }; + 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(AccountEntry).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/account_name_refresh.zig b/src/account_name_refresh.zig new file mode 100644 index 0000000..f213105 --- /dev/null +++ b/src/account_name_refresh.zig @@ -0,0 +1,212 @@ +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, + + 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; +} + +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; +} + +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, +) !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; + + const duped_id = try allocator.dupe(u8, rec.chatgpt_user_id); + errdefer allocator.free(duped_id); + try candidates.append(allocator, .{ + .chatgpt_user_id = duped_id, + }); + } + + return candidates; +} + +pub fn loadStoredAuthInfoForUser( + allocator: std.mem.Allocator, + codex_home: []const u8, + 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; + + 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; + }, + }; + 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/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/auto.zig b/src/auto.zig index 851bf86..29fed68 100644 --- a/src/auto.zig +++ b/src/auto.zig @@ -1,4 +1,7 @@ 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({ @cInclude("time.h"); @@ -31,6 +34,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"; @@ -241,6 +245,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, @@ -253,6 +259,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); @@ -276,6 +283,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); @@ -333,6 +348,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.?; } @@ -340,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); } @@ -513,6 +543,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 +572,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 +760,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, ®); } @@ -800,6 +836,117 @@ 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 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, + 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 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 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; + + var attempted = false; + var changed = false; + + 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; + 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, 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( allocator: std.mem.Allocator, codex_home: []const u8, @@ -1588,8 +1735,13 @@ 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 { - const reg = try refresh_state.ensureRegistryLoaded(allocator, codex_home); +fn daemonCycleWithAccountNameFetcher( + allocator: std.mem.Allocator, + codex_home: []const u8, + refresh_state: *DaemonRefreshState, + account_name_fetcher: anytype, +) !bool { + var reg = try refresh_state.ensureRegistryLoaded(allocator, codex_home); if (!reg.auto_switch.enabled) return false; var changed = false; @@ -1597,23 +1749,18 @@ fn daemonCycle(allocator: std.mem.Allocator, codex_home: []const u8, refresh_sta 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 (changed) { + try registry.saveRegistry(allocator, codex_home, reg); + try refresh_state.refreshTrackedFileMtims(allocator, codex_home); + changed = false; } - if (needs_refresh) { - try refresh_state.rebuildCandidateState(allocator); + + 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; @@ -1643,6 +1790,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/chatgpt_http.zig b/src/chatgpt_http.zig new file mode 100644 index 0000000..c27f608 --- /dev/null +++ b/src/chatgpt_http.zig @@ -0,0 +1,183 @@ +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 = 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); + + 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, + }); + try argv.appendSlice(allocator, &.{ "-H", account_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 = 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 = try std.fmt.allocPrint(allocator, "'ChatGPT-Account-Id' = '{s}'; ", .{escaped_account_id}); + 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/cli.zig b/src/cli.zig index 9214324..a8050c3 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -55,10 +55,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 }; @@ -322,8 +322,8 @@ pub fn parseArgs(allocator: std.mem.Allocator, args: []const [:0]const u8) !Pars } if (args.len != 4) return usageErrorResult(allocator, .config, "`config api` requires `enable` or `disable`.", .{}); 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 usageErrorResult(allocator, .config, "unknown action `{s}` for `config api`.", .{action}); } @@ -476,6 +476,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); @@ -502,8 +510,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; @@ -890,10 +898,18 @@ 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)) { + 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 }); try labels.append(allocator, label); } return labels; diff --git a/src/display_rows.zig b/src/display_rows.zig index 1cac84b..dc75477 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 allocator.dupe(u8, 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 72db2b5..9de688f 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1,4 +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"); @@ -6,6 +8,18 @@ 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, + 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; @@ -98,6 +112,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; @@ -173,27 +195,293 @@ fn maybeRefreshForegroundUsage( } } +fn defaultAccountFetcher( + 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 maybeRefreshAccountNamesForAuthInfo( + allocator: std.mem.Allocator, + reg: *registry.Registry, + info: *const auth.AuthInfo, + fetcher: AccountFetchFn, +) !bool { + 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, 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 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); + + 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: AccountFetchFn, +) !bool { + const active_user_id = registry.activeChatgptUserId(reg) orelse return false; + if (!shouldRefreshTeamAccountNamesForUserScope(reg, active_user_id)) return false; + + var info = (try loadActiveAuthInfoForAccountRefresh(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: AccountFetchFn, +) !bool { + return try maybeRefreshAccountNamesForAuthInfo(allocator, reg, info, fetcher); +} + +pub fn refreshAccountNamesAfterSwitch( + allocator: std.mem.Allocator, + codex_home: []const u8, + reg: *registry.Registry, + fetcher: AccountFetchFn, +) !bool { + return try refreshAccountNamesForActiveAuth(allocator, codex_home, reg, fetcher); +} + +pub fn refreshAccountNamesForList( + allocator: std.mem.Allocator, + codex_home: []const u8, + reg: *registry.Registry, + fetcher: AccountFetchFn, +) !bool { + 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 { + 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( + 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 { + 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, ®); + defer { + for (candidates.items) |*candidate| candidate.deinit(allocator); + candidates.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; + + 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; + 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; + }; + 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 { + 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; + if (!shouldScheduleBackgroundAccountNameRefresh(reg)) 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, + purge: bool, + render_kind: registry.ImportRenderKind, + info: ?*const auth.AuthInfo, + fetcher: AccountFetchFn, +) !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; + 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, ®)) { 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, ®); } fn handleLogin(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli.LoginOptions) !void { @@ -220,6 +508,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, defaultAccountFetcher); try registry.saveRegistry(allocator, codex_home, ®); } @@ -240,6 +529,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, + defaultAccountFetcher, + ); + } try registry.saveRegistry(allocator, codex_home, ®); } try cli.printImportReport(&report); @@ -279,12 +580,13 @@ fn handleSwitch(allocator: std.mem.Allocator, codex_home: []const u8, opts: cli. try registry.activateAccountByKey(allocator, codex_home, ®, account_key); try registry.saveRegistry(allocator, codex_home, ®); + maybeSpawnBackgroundAccountNameRefresh(allocator, ®); } 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), } } @@ -299,9 +601,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); } } @@ -514,10 +820,49 @@ 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"); _ = @import("tests/sessions_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 1cfbce9..a1d5fca 100644 --- a/src/registry.zig +++ b/src/registry.zig @@ -1,5 +1,6 @@ const std = @import("std"); const builtin = @import("builtin"); +const account_api = @import("account_api.zig"); const c_time = @cImport({ @cInclude("time.h"); }); @@ -10,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); @@ -51,6 +53,13 @@ pub const AutoSwitchConfig = struct { pub const ApiConfig = struct { usage: bool = true, + account: bool = true, +}; + +const ApiConfigParseResult = struct { + has_object: bool = false, + has_usage: bool = false, + has_account: bool = false, }; pub const AccountRecord = struct { @@ -59,6 +68,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 +115,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 +236,22 @@ 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; + const replacement = try cloneOptionalStringAlloc(allocator, value); + if (target.*) |existing| allocator.free(existing); + target.* = replacement; + 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, @@ -529,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); } @@ -1742,6 +1770,83 @@ 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 inAccountNameRefreshScope(reg: *const Registry, chatgpt_user_id: []const u8, rec: *const AccountRecord) bool { + _ = 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 { + for (reg.accounts.items) |rec| { + if (!inAccountNameRefreshScope(reg, chatgpt_user_id, &rec)) continue; + if (isTeamAccount(&rec) and !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; + + for (reg.accounts.items) |rec| { + if (!inAccountNameRefreshScope(reg, chatgpt_user_id, &rec)) continue; + + account_count += 1; + 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; +} + +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_api.AccountEntry, +) !bool { + var changed = false; + for (reg.accounts.items) |*rec| { + 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; + } + } + return changed; +} + pub fn activateAccountByKey( allocator: std.mem.Allocator, codex_home: []const u8, @@ -1802,6 +1907,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 +1930,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 +2059,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 +2091,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 +2183,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, @@ -2129,7 +2254,6 @@ fn loadLegacyRegistryV2( else => {}, } } - if (root_obj.get("accounts")) |v| { switch (v) { .array => |arr| { @@ -2181,7 +2305,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("accounts")) |v| { switch (v) { .array => |arr| { @@ -2226,6 +2349,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; } @@ -2442,16 +2570,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; + 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 { @@ -2532,48 +2689,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/account_api_test.zig b/src/tests/account_api_test.zig new file mode 100644 index 0000000..a04b0a8 --- /dev/null +++ b/src/tests/account_api_test.zig @@ -0,0 +1,140 @@ +const std = @import("std"); +const account_api = @import("../account_api.zig"); + +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_api.AccountEntry) 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" + \\ } + \\ }, + \\ "67fe2bbb-0de6-49a4-b2b3-d1df366d1faf": { + \\ "account": { + \\ "account_id": "67fe2bbb-0de6-49a4-b2b3-d1df366d1faf", + \\ "name": "Primary Workspace" + \\ } + \\ } + \\ }, + \\ "account_ordering": ["67fe2bbb-0de6-49a4-b2b3-d1df366d1faf"] + \\} + ; + + const entries = try account_api.parseAccountsResponse(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": { + \\ "67fe2bbb-0de6-49a4-b2b3-d1df366d1faf": { + \\ "account": { + \\ "account_id": "67fe2bbb-0de6-49a4-b2b3-d1df366d1faf", + \\ "name": "Primary Workspace" + \\ } + \\ }, + \\ "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" + \\ ] + \\} + ; + + const entries = try account_api.parseAccountsResponse(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 personal account response keeps null name as null" { + const gpa = std.testing.allocator; + const body = + \\{ + \\ "accounts": { + \\ "67fe2bbb-0de6-49a4-b2b3-d1df366d1faf": { + \\ "account": { + \\ "account_id": "67fe2bbb-0de6-49a4-b2b3-d1df366d1faf", + \\ "name": null + \\ } + \\ } + \\ }, + \\ "account_ordering": ["67fe2bbb-0de6-49a4-b2b3-d1df366d1faf"] + \\} + ; + + const entries = try account_api.parseAccountsResponse(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 personal account response normalizes empty name to null" { + const gpa = std.testing.allocator; + const body = + \\{ + \\ "accounts": { + \\ "67fe2bbb-0de6-49a4-b2b3-d1df366d1faf": { + \\ "account": { + \\ "account_id": "67fe2bbb-0de6-49a4-b2b3-d1df366d1faf", + \\ "name": "" + \\ } + \\ } + \\ }, + \\ "account_ordering": ["67fe2bbb-0de6-49a4-b2b3-d1df366d1faf"] + \\} + ; + + const entries = try account_api.parseAccountsResponse(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_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..a558bdb 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,365 @@ 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 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; +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 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; +} + +fn buildGroupedAccountNamesFetchResult(allocator: std.mem.Allocator) !account_api.FetchResult { + 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, + }; +} + +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(.{}); + 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); +} + +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.?); +} + +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 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(); + + 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); + reg.accounts.items[3].account_name = try gpa.dupe(u8, "Old Backup Workspace"); + 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, "group@example.com", "plus", daemon_grouped_user_id, "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, @@ -1234,6 +1591,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 +1599,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 +1614,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); } @@ -1755,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); -} 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/cli_bdd_test.zig b/src/tests/cli_bdd_test.zig index 883763d..628b0fc 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) { @@ -157,12 +196,14 @@ test "Scenario: Given help when rendering then login and command help 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, "Run `codex-auth --help` for command-specific usage details.") != null); try std.testing.expect(std.mem.indexOf(u8, help, "`config api enable` may trigger OpenAI account restrictions or suspension in some environments.") != null); @@ -356,7 +397,7 @@ test "Scenario: Given config api enable when parsing then api action is preserve switch (result) { .command => |cmd| switch (cmd) { .config => |opts| switch (opts) { - .api_usage => |action| try std.testing.expectEqual(cli.ApiUsageAction.enable, action), + .api => |action| try std.testing.expectEqual(cli.ApiAction.enable, action), else => return error.TestExpectedEqual, }, else => return error.TestExpectedEqual, @@ -374,7 +415,7 @@ test "Scenario: Given config api disable when parsing then api disable action is switch (result) { .command => |cmd| switch (cmd) { .config => |opts| switch (opts) { - .api_usage => |action| try std.testing.expectEqual(cli.ApiUsageAction.disable, action), + .api => |action| try std.testing.expectEqual(cli.ApiAction.disable, action), else => return error.TestExpectedEqual, }, else => return error.TestExpectedEqual, @@ -690,6 +731,48 @@ 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-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); + 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 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-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-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 }; + 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 b0e290c..55a2df2 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,94 @@ 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 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-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::a4021fa5-998b-4774-989f-784fa69c367b", "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); + + 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-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-9TwHs4KmP7xNc2LdVrQ6BjYe::d8f0f19d-7b6f-4db8-b7a8-07b9fbf5774a", "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, "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, "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-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-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-1ZdKr5NtV8mQx3LsHpW7CyFb::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[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" { + 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)") 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 57ce1cf..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; @@ -219,6 +221,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(), @@ -333,7 +336,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); @@ -794,6 +800,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); } @@ -1195,8 +1202,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" ++ + "- 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, @@ -1640,6 +1647,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 ab9167f..af80898 100644 --- a/src/tests/main_test.zig +++ b/src/tests/main_test.zig @@ -1,8 +1,33 @@ const std = @import("std"); +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"); +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"; +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; +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 { return .{ .schema_version = registry.current_schema_version, @@ -31,6 +56,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,15 +77,213 @@ fn writeSnapshot(allocator: std.mem.Allocator, codex_home: []const u8, email: [] try std.fs.cwd().writeFile(.{ .sub_path = snapshot_path, .data = auth_json }); } -test "Scenario: Given alias and email queries when finding matching accounts then both matching strategies still work" { +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 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, + 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 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 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, + account_id: []const u8, +) !account_api.FetchResult { + _ = access_token; + 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); + 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, + }; +} + +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); +} + +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(); 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); @@ -68,6 +292,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" { @@ -89,7 +318,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 = .top_level })); try std.testing.expect(!main_mod.shouldReconcileManagedService(.{ .status = {} })); try std.testing.expect(!main_mod.shouldReconcileManagedService(.{ .version = {} })); @@ -102,6 +331,421 @@ 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, ®, 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(®, 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 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 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(); + 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(); + 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); + 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(); + 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); + 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 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 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(); + 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 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 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(); + 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); + 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 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(); + + const codex_home = try tmp.dir.realpathAlloc(gpa, "."); + defer gpa.free(codex_home); + + var reg = makeRegistry(); + defer reg.deinit(gpa); + 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, ®, shared_user_id ++ "::" ++ tertiary_account_id, "same-user@example.com", "", .plus); + try registry.saveRegistry(gpa, codex_home, ®); + 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); + + 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(); + 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(); + 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); + 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 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(); + + const codex_home = try tmp.dir.realpathAlloc(gpa, "."); + defer gpa.free(codex_home); + + var reg = makeRegistry(); + defer reg.deinit(gpa); + 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, ®, 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; + 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/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 d4b3941..dda61eb 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"); @@ -105,6 +106,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, @@ -115,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(); @@ -182,16 +198,206 @@ 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); +} + +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 "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 "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-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-shared", "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-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); + 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(.{}); + 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.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, ®); + + 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); } test "registry load defaults missing auto threshold fields" { @@ -222,7 +428,85 @@ 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); + + 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" { @@ -587,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" }); @@ -606,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", .{})); 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; -}