From 37aa7a5ff37397cdfd603ec27df0689d85f4c047 Mon Sep 17 00:00:00 2001 From: GiantForestStudio <119655663+GiantForestStudio@users.noreply.github.com> Date: Wed, 9 Sep 2026 22:23:00 +0900 Subject: [PATCH 1/5] Add opt-in manual Codex account management with recoverable local switching --- PRIVACY.md | 8 +- README.md | 12 +- SECURITY.md | 2 + docs/manual-accounts-delivery.md | 22 + docs/manual-accounts-design.md | 35 + scripts/build.ps1 | 2 +- scripts/install.ps1 | 13 + scripts/uninstall.ps1 | 13 + src/AccountManagerForm.cs | 175 +++++ src/AppServerClient.cs | 423 ++++++++++++ src/CodexAccountRuntime.cs | 455 +++++++++++++ src/CodexAccountStore.cs | 628 ++++++++++++++++++ src/Program.cs | 508 ++++---------- src/WeeklyUsageIndicator.csproj | 2 +- .../AccountRuntimeTests.cs | 171 +++++ .../AccountStoreTests.cs | 374 +++++++++++ .../AccountUiSmoke.cs | 47 ++ .../AppServerLifecycleTests.cs | 149 +++++ tests/WeeklyUsageIndicator.Tests/Program.cs | 4 + 19 files changed, 2647 insertions(+), 396 deletions(-) create mode 100644 docs/manual-accounts-delivery.md create mode 100644 docs/manual-accounts-design.md create mode 100644 src/AccountManagerForm.cs create mode 100644 src/AppServerClient.cs create mode 100644 src/CodexAccountRuntime.cs create mode 100644 src/CodexAccountStore.cs create mode 100644 tests/WeeklyUsageIndicator.Tests/AccountRuntimeTests.cs create mode 100644 tests/WeeklyUsageIndicator.Tests/AccountStoreTests.cs create mode 100644 tests/WeeklyUsageIndicator.Tests/AccountUiSmoke.cs create mode 100644 tests/WeeklyUsageIndicator.Tests/AppServerLifecycleTests.cs diff --git a/PRIVACY.md b/PRIVACY.md index c7a4c57..d48a380 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -4,16 +4,18 @@ Codex + Claude Usage Indicator reads Codex rate-limit windows from the locally i The application: -- does not store usage history; +- does not store a timeline of usage history; optional Codex account management retains each account's latest usage snapshot; - stores the last window coordinates and the Claude visibility preference in a local `settings.json` file; - stores one latest successful Claude snapshot in local `claude-usage-cache.json`, containing only usage percentages, reset times, and the update time; - does not read, print, log, copy, or persist Claude authentication tokens; -- does not collect account identifiers; +- reads Codex account identifiers only after optional account registration, and stores them with labels and login snapshots in a Windows CurrentUser DPAPI encrypted vault; - does not include telemetry; - registers a per-user Windows logon/recovery task containing the local executable path and Windows user SID, with no stored password or elevated privileges; -- makes no outbound request for Codex usage; +- makes no direct outbound request for Codex usage; the official local app-server manages service communication; - makes no direct Claude network request; it invokes `claude.exe` in safe mode without a shell or persistent session, caches successful `/usage` results in memory for ten minutes, and deletes the local recovery snapshot after 24 hours, its Fable reset, or an authentication/schema failure. Codex Desktop, its local app-server, Claude Code, and Anthropic's API remain governed by their own terms and privacy practices. +Optional Codex account management reads local Codex `auth.json` for explicit registration, switching, and identity checks while refreshing registered-account usage. Replacement happens only on an explicit switch. Additional accounts use official Codex browser login in a restricted temporary home; temporary credentials are removed after import or cancellation. The separate account vault and recovery transaction are DPAPI encrypted and ACL restricted to the Windows user; the login staging directory also permits SYSTEM. They are never sent to this project's developers. DPAPI protects data at rest; it does not protect against other software already running as the same Windows user. The active Codex credential remains owned by the live authentication file: saved snapshots never override its newer tokens during recovery. Uninstall intentionally preserves the separate account vault to avoid losing saved logins. + When reporting a bug, do not attach Codex or Claude logs, credential/configuration files, tokens, or screenshots containing information you do not want to publish. diff --git a/README.md b/README.md index 5dea2db..6fdbefe 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,17 @@ An unofficial Windows widget that stays on top while Codex Desktop is running an - Hides while another foreground app is fullscreen, then returns at the saved position. - Uses a per-user Windows scheduled task at sign-in, so the widget runs independently of Codex. A lightweight supervisor restarts the widget after an abnormal exit, waiting one minute (up to 999 retries per supervisor run). -The widget does not read or store login tokens, account details, or usage history. It stores only the latest successful Claude percentages, reset times, and update time for short-lived recovery. Claude Code itself owns authentication and token refresh. See [PRIVACY.md](PRIVACY.md). +Account management is optional. After you register a Codex account, the widget stores account labels, identities, each account's latest usage snapshot, and Codex login snapshots encrypted with Windows CurrentUser DPAPI. The live authentication file remains authoritative for the active account. Claude credentials are never accessed; Claude Code owns its authentication and token refresh. See [PRIVACY.md](PRIVACY.md). + +## Manual Codex accounts + +Open **Codex 계정 관리…** from the right-click menu, or double-click the tray icon. Enter a label and register the current account first. Enter another label and choose **다른 계정 로그인**; complete the official browser login using the additional account. This login uses an isolated private `CODEX_HOME` and does not log the desktop out. + +To switch, finish your Codex work and close Codex Desktop and other Codex CLI/engine processes. Select the saved account and click **선택 계정으로 전환**. The widget stops its own usage helper, verifies that no Codex writers remain, saves the latest current login, and applies the selected login. It attempts to reopen the previously observed packaged desktop; if necessary, launch Codex from the Start menu and confirm the account there. File application and desktop login verification are separate outcomes. + +Only explicit selections cause a switch. There is no automatic quota rotation, proxy, inactive-account polling, or quota pooling. Inactive usage figures show the last observation and its time. A pending encrypted transaction blocks polling until **미완료 전환 복구** reconciles it with the actual live authentication; unknown third-party login changes are not overwritten. + +The first version supports local Windows file-based ChatGPT authentication. Unsupported keyring/managed configurations fail closed. The vault is stored separately at `%LOCALAPPDATA%\CodexWeeklyUsageIndicator.Accounts`; uninstall preserves it. Delete inactive accounts from the manager before removing the app if you no longer want their saved credentials. This convenience tool does not establish that any particular multi-account usage pattern is permitted by the service terms. > [!IMPORTANT] > This is an unofficial community project. It relies on an experimental local Codex app-server method (`account/rateLimits/read`) and the text output of Claude Code's built-in `/usage` command. Either may change without notice. diff --git a/SECURITY.md b/SECURITY.md index 7cae26d..0367a8d 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -5,3 +5,5 @@ Please open a GitHub issue for ordinary bugs. Do not include credentials, authen For a security-sensitive report, use GitHub's private vulnerability reporting feature when it is available for this repository. Release binaries are not code-signed. Verify the accompanying `SHA256SUMS.txt` file or build the application from source before running it. + +Optional Codex account switching handles local authentication secrets. Do not attach account-vault files, encrypted recovery transactions, or temporary login folders to an issue. Windows CurrentUser DPAPI and restrictive ACLs protect the vault at rest; same-user malware is outside that boundary. The switcher refuses to replace authentication while Codex engines are running and preserves an encrypted transaction for interrupted writes. It does not revoke or log out saved accounts when deleting local entries. diff --git a/docs/manual-accounts-delivery.md b/docs/manual-accounts-delivery.md new file mode 100644 index 0000000..3b73003 --- /dev/null +++ b/docs/manual-accounts-delivery.md @@ -0,0 +1,22 @@ +# Manual Codex accounts — delivery board + +Outcome: switch between the owner's paid Codex accounts manually from the existing Windows usage widget. No automatic quota switching, proxy routing, background inactive-account usage polling, or Claude credential handling. + +Current artifact: branch `codex/manual-account-switch`, local candidate 1.4.0. Installed candidate SHA-256: `2067643F3F5DE5614F883F3EBF501A2FDD1264347AF9576CE1E2C45AB58BFDEA`. Checksum matched the build; one supervisor and one widget were verified. The management window is open for user-assisted registration. + +| Owner | Write set | Status | +|---|---|---| +| Chair | Widget integration, account manager UI, docs, test harness | Local candidate installed; user acceptance pending | +| Auth worker | CodexAccountStore.cs, AccountStoreTests.cs | Complete | +| Runtime worker | CodexAccountRuntime.cs, AccountRuntimeTests.cs | Complete | +| Protocol worker | AppServerClient.cs, AppServerLifecycleTests.cs | Complete | + +Two inherited advisors reviewed authentication recovery and desktop lifecycle. A fresh, preference-blind red-team inspected the actual source and verified fixes. It reported no confirmed remaining P0; this is source review, not proof of real account switching. No Claude deliberation was used. + +Decisions: keep the 272 × 64 widget; use an optional separate management window and tray entry. Store inactive Codex credentials with Windows CurrentUser DPAPI outside the installation folder. The live auth file owns the active credential. Register additional accounts through official login in an isolated private home. Require Codex and other Codex engines to be closed before auth replacement; do not force-close the desktop. Persist an encrypted transaction before replacement and recover by actual identity, preserving refreshed credentials. + +Verified: all 23 harness groups passed (including the optional synthetic UI capture when enabled), release build succeeded, binary contains no checked username/build path, scoped whitespace check passed. Synthetic tests cover interrupted journal phases, token rotation, third-account refusal, corrupt encrypted data, private ACLs, junction rejection, helper cancellation/restart, login Job ownership, and browser-child survival. Synthetic and installed management windows were observed; clipped controls found in the first synthetic render were fixed. + +Next verification: actual current-account registration, second-account OAuth, then user-initiated A→B→A acceptance. Native UI automation failed to deliver input, so the user was asked to operate the already open management window. Current Desktop has not been closed or switched. + +Release: local candidate installed, public publication not requested. A prior executable backup exists in the session's temporary workspace for rollback. Real switching must be initiated outside the active implementation session. Do not claim production acceptance until the actual account cycle is observed. diff --git a/docs/manual-accounts-design.md b/docs/manual-accounts-design.md new file mode 100644 index 0000000..5c45cf5 --- /dev/null +++ b/docs/manual-accounts-design.md @@ -0,0 +1,35 @@ +# Manual account switching decisions + +The existing WinForms widget owns the small usage surface and opens a separate account manager. The user selects accounts explicitly. Inactive usage is a dated observation, not a background login or synthetic combined quota. The current widget geometry and Claude source/cache contracts remain intact. + +## Authentication and recovery + +The local live authentication file is authoritative for the active account. Every switch stops the widget's own app-server, checks for remaining native Codex writers, reads the latest source credential, writes an encrypted recovery transaction, saves the source, and atomically applies the selected credential. File replacement is read back. Saved auth JSON is treated as opaque data so future fields survive. + +Recovery examines the actual identity: a source identity reconciles the before-vault, a target identity reconciles the after-vault, and either keeps any newer live tokens. Missing, malformed, or unrelated third-account authentication stops recovery without replacing the live file. A flushed auth temporary file is scoped to this transaction and removed through recovery. Profile identity combines user and workspace information rather than equating an email or workspace alone with a person. + +The encrypted vault is separate from the installation folder. Windows CurrentUser DPAPI binds it to the Windows user. Private ACLs, non-reparse paths, bounded reads, atomic flushed writes, and a shared transaction mutex protect local operations. Same-user malware and unsupported external writers are outside this local mechanism's assurance. + +Additional registration uses official `codex login` with an isolated private home and explicit file credential storage. No token is passed in a command argument or UI field. Login output is discarded. A private Job closes the owned login process if the widget exits, with descendant breakaway so an OAuth browser survives. A narrow process-start/Job-attachment crash interval remains; owned staging is recoverable after writers exit. Managed requirements and unsupported credential modes fail closed. + +## Lifecycle and verification + +The prior helper's shared pending requests and reader finalizer could affect a replacement helper. The new client owns state per process, serializes requests, and suspends new starts before cancellation and confirmed exit. The widget rejects both late successes and errors from prior account generations. A changed live identity creates a fresh helper. `account/read` metadata is checked before/after usage; it does not expose full workspace identity and is not represented as proof of the desktop's account. + +The manager remains available from the tray when Desktop is closed. Version 1 requires the user to finish work and close Desktop/CLI engines before a switch; it does not force-close arbitrary processes or infer that a running task is idle. Applying the file and reopening Desktop are reported separately. A pending transaction prevents startup polling until explicit recovery. + +Installer and uninstaller share the account-operation mutex across process replacement. Login holds it through credential import and staging cleanup, so an upgrade cannot normally interrupt that sequence. The existing least-privilege supervisor and install-path validation remain in place. + +## Review disposition + +Inherited auth/lifecycle advisors identified stale-token and reader/start races; those were addressed in the store and session client. A fresh critic then inspected the actual implementation without the chair's recommendation. Its valid legacy `auth_mode` finding was adopted using the official fallback contract while rejecting competing credential types. Its parent-exit/login lifetime finding was addressed with the shared UI mutex, Job ownership, and explicit staging recovery. Final actual-source review found no confirmed remaining P0. + +The implementation has synthetic crash, process lifecycle, local ACL and UI checks. Those do not establish actual second-account OAuth or successful A→B→A Desktop use. User-assisted acceptance is tracked in the delivery board. + +## Implementation references + +- [OpenCodex native profile transaction](https://github.com/lidge-jun/opencodex/blob/2f3f736299dca38861f8fb9c4326a4b4d7c664bc/src/codex/native-profile-manager.ts): latest-source capture, durable journal, identity-based recovery. +- [Official Codex authentication storage at 0.153.4](https://github.com/openai/codex/blob/rust-v0.153.4/codex-rs/login/src/auth/storage.rs): file storage and optional mode contract. +- [Official Codex authentication](https://developers.openai.com/codex/auth/): official login and credential ownership. + +This tool does not determine whether a particular account usage pattern complies with service terms. It implements explicit local profile selection rather than automatic quota-driven routing. diff --git a/scripts/build.ps1 b/scripts/build.ps1 index 3834cb1..04162e0 100644 --- a/scripts/build.ps1 +++ b/scripts/build.ps1 @@ -8,7 +8,7 @@ $pathMap = "$repositoryRoot=/_/" dotnet run --project $testProjectPath -c Release --nologo if ($LASTEXITCODE -ne 0) { - throw "Claude usage regression tests failed with exit code $LASTEXITCODE." + throw "Usage and account regression tests failed with exit code $LASTEXITCODE." } dotnet clean $projectPath -c Release | Out-Null diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 73485c0..c2c1fe5 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -17,6 +17,14 @@ if (-not (Test-Path -LiteralPath $sourceExecutable -PathType Leaf)) { New-Item -ItemType Directory -Path $installDirectory -Force | Out-Null Assert-WidgetInstallPath -Path $installDirectory +# Share the account transaction gate before stopping any process. A pending +# encrypted journal remains recoverable after an abnormal widget exit. +$accountTransactionMutex = [Threading.Mutex]::new($false, 'Local\CodexWeeklyUsageIndicator.AccountTransaction') +$accountTransactionOwned = $false +try { + try { $accountTransactionOwned = $accountTransactionMutex.WaitOne(0) } + catch [Threading.AbandonedMutexException] { $accountTransactionOwned = $true } + if (-not $accountTransactionOwned) { throw 'An account operation is in progress. Finish it before installing or uninstalling.' } # Stop scheduler recovery before replacing the binary. Never stop another user's copy. $existingTask = Get-ScheduledTask -TaskName $taskName -TaskPath '\' -ErrorAction SilentlyContinue if ($existingTask) { @@ -65,3 +73,8 @@ if (Test-Path -LiteralPath $shortcutPath -PathType Leaf) { Write-Host "Installed: $installedExecutable" Write-Host "Logon and recovery task: $taskName" + +} finally { + if ($accountTransactionOwned) { $accountTransactionMutex.ReleaseMutex() } + $accountTransactionMutex.Dispose() +} \ No newline at end of file diff --git a/scripts/uninstall.ps1 b/scripts/uninstall.ps1 index ab47bd4..28cea6a 100644 --- a/scripts/uninstall.ps1 +++ b/scripts/uninstall.ps1 @@ -9,6 +9,14 @@ $userSid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value $taskName = "CodexWeeklyUsageIndicator-$userSid" Assert-WidgetInstallPath -Path $installDirectory +# Share the account transaction gate before stopping any process. A pending +# encrypted journal remains recoverable after an abnormal widget exit. +$accountTransactionMutex = [Threading.Mutex]::new($false, 'Local\CodexWeeklyUsageIndicator.AccountTransaction') +$accountTransactionOwned = $false +try { + try { $accountTransactionOwned = $accountTransactionMutex.WaitOne(0) } + catch [Threading.AbandonedMutexException] { $accountTransactionOwned = $true } + if (-not $accountTransactionOwned) { throw 'An account operation is in progress. Finish it before installing or uninstalling.' } # Remove recovery before stopping the app or deleting its files. $existingTask = Get-ScheduledTask -TaskName $taskName -TaskPath '\' -ErrorAction SilentlyContinue if ($existingTask) { @@ -38,3 +46,8 @@ if ((Test-Path -LiteralPath $resolvedInstall -PathType Container) -and } Write-Host 'Codex + Claude Usage Indicator uninstalled.' + +} finally { + if ($accountTransactionOwned) { $accountTransactionMutex.ReleaseMutex() } + $accountTransactionMutex.Dispose() +} \ No newline at end of file diff --git a/src/AccountManagerForm.cs b/src/AccountManagerForm.cs new file mode 100644 index 0000000..b678757 --- /dev/null +++ b/src/AccountManagerForm.cs @@ -0,0 +1,175 @@ +namespace WeeklyUsageIndicator; + +internal sealed class AccountManagerForm : Form +{ + private readonly CodexAccountStore _store; + private readonly Func _suspend; + private readonly Action _resume; + private readonly ListView _accounts = new() { View = View.Details, FullRowSelect = true, MultiSelect = false, HideSelection = false, Dock = DockStyle.Fill }; + private readonly TextBox _label = new() { Width = 170, PlaceholderText = "계정 별칭 (예: Pro A)" }; + private readonly Label _status = new() { Dock = DockStyle.Fill, AutoSize = false, Padding = new Padding(12), Text = "현재 계정을 등록한 다음 두 번째 계정을 추가하세요." }; + private readonly FlowLayoutPanel _buttons = new() { Dock = DockStyle.Fill, Padding = new Padding(8), WrapContents = true, AutoSize = true, AutoSizeMode = AutoSizeMode.GrowAndShrink }; + private readonly Button _cancel = new() { Text = "로그인 취소", AutoSize = true, Enabled = false }; + private CancellationTokenSource? _loginCancellation; + private bool _busy; + private string? _desktopPath; + internal bool IsOperationInProgress => _busy; + + public AccountManagerForm(CodexAccountStore store, Func suspend, Action resume) + { + _store = store; _suspend = suspend; _resume = resume; + Text = "Codex 계정 관리"; + AutoScaleMode = AutoScaleMode.Dpi; + ClientSize = new Size(940, 570); + MinimumSize = new Size(780, 520); + StartPosition = FormStartPosition.CenterScreen; + Font = new Font("맑은 고딕", 9f); + var layout = new TableLayoutPanel { Dock = DockStyle.Fill, RowCount = 4, ColumnCount = 1 }; + layout.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + layout.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); + layout.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 84)); + layout.Controls.Add(new Label { Dock = DockStyle.Fill, AutoSize = true, Padding = new Padding(12), Text = "계정은 직접 선택할 때만 전환됩니다.\n전환 전에는 작업을 마치고 Codex 앱과 터미널을 닫아주세요." }, 0, 0); + foreach (var column in new[] { ("계정", 180), ("구분", 200), ("상태", 130), ("주간 잔여", 145), ("마지막 확인", 190) }) + _accounts.Columns.Add(column.Item1, column.Item2); + layout.Controls.Add(_accounts, 0, 1); + _buttons.Controls.Add(_label); + AddButton("현재 계정 등록", RegisterAsync); + AddButton("다른 계정 로그인", LoginAsync); + AddButton("선택 계정으로 전환", SwitchAsync); + AddButton("미완료 전환 복구", RecoverAsync); + AddButton("선택 계정 삭제", RemoveAsync); + AddButton("목록 새로고침", () => { Reload(); return Task.CompletedTask; }); + _cancel.Click += (_, _) => _loginCancellation?.Cancel(); + _buttons.Controls.Add(_cancel); + layout.Controls.Add(_buttons, 0, 2); + layout.Controls.Add(_status, 0, 3); + Controls.Add(layout); + FormClosing += (_, e) => { if (_busy) { e.Cancel = true; _status.Text = "진행 중입니다. 로그인 취소 버튼으로 취소한 뒤 창을 닫아주세요."; } }; + Shown += (_, _) => Reload(); + } + + private void AddButton(string text, Func action) + { + var button = new Button { Text = text, AutoSize = true }; + button.Click += async (_, _) => await RunAsync(action); + _buttons.Controls.Add(button); + } + + private async Task RunAsync(Func action) + { + if (_busy) return; + _busy = true; + using var transactionGate = new Mutex(false, CodexAccountStore.TransactionMutexName); + var ownsGate = false; + foreach (Control control in _buttons.Controls) control.Enabled = false; + try + { + _desktopPath ??= CodexAccountRuntime.CaptureDesktopLaunchPath(); + await _suspend(); + try { ownsGate = transactionGate.WaitOne(0); } + catch (AbandonedMutexException) { ownsGate = true; } + if (!ownsGate) throw new InvalidOperationException("설치 또는 다른 계정 작업이 진행 중입니다. 완료 후 다시 시도하세요."); + await action(); + } + catch (OperationCanceledException) { _status.Text = "로그인을 취소했습니다. 현재 계정은 유지됩니다."; } + catch (Exception ex) { _status.Text = ex.Message; } + finally + { + // UI event continuations retain the WinForms thread; named mutex ownership + // spans browser login and import, so lifecycle scripts cannot kill either. + if (ownsGate) transactionGate.ReleaseMutex(); + _busy = false; + foreach (Control control in _buttons.Controls) control.Enabled = true; + _cancel.Enabled = false; + Reload(preserveStatus: true); + _resume(); + } + } + + private string AccountLabel => string.IsNullOrWhiteSpace(_label.Text) + ? throw new InvalidOperationException("계정을 구분할 별칭을 먼저 입력하세요.") : _label.Text.Trim(); + + private SavedCodexAccount Selected => _accounts.SelectedItems.Count == 1 + ? (SavedCodexAccount)_accounts.SelectedItems[0].Tag! : throw new InvalidOperationException("목록에서 계정을 선택하세요."); + + private Task RegisterAsync() + { + _store.RegisterCurrent(AccountLabel); + _status.Text = "현재 계정을 등록했습니다. 다음으로 다른 계정 로그인을 선택하세요."; + return Task.CompletedTask; + } + + private async Task LoginAsync() + { + var label = AccountLabel; + if (!_store.IsEnabled || _store.ListAccounts().Count == 0) + throw new InvalidOperationException("복귀할 수 있도록 현재 계정을 먼저 등록하세요."); + using var cancellation = new CancellationTokenSource(); + _loginCancellation = cancellation; + _cancel.Enabled = true; + _status.Text = "열린 공식 로그인 화면에서 추가할 계정으로 로그인하세요. 현재 Codex 앱의 계정은 바뀌지 않습니다."; + try + { + using var login = await CodexAccountRuntime.LoginAsync(_store.RootPath, cancellation.Token); + _store.ImportLoginFile(login.AuthPath, label); + _status.Text = "추가 계정을 저장했습니다. Codex를 닫은 뒤 목록에서 선택하여 전환할 수 있습니다."; + } + finally { _loginCancellation = null; } + } + + private Task SwitchAsync() + { + var selected = Selected; + if (selected.IsActive) throw new InvalidOperationException("이미 사용 중인 계정입니다."); + CodexAccountRuntime.AssertWritersStopped(); + CodexAccountRuntime.ClearStaleLoginDirectories(_store.RootPath); + _store.SwitchTo(selected.Id, CodexAccountRuntime.AssertWritersStopped); + _status.Text = $"{selected.Label} 계정을 적용했습니다. Codex를 실행한 뒤 로그인 계정을 확인하세요."; + try { CodexAccountRuntime.LaunchDesktop(_desktopPath); } + catch { _status.Text += " 앱 자동 실행은 완료하지 못했습니다. 시작 메뉴에서 Codex를 실행하세요."; } + return Task.CompletedTask; + } + + private Task RecoverAsync() + { + CodexAccountRuntime.AssertWritersStopped(); + if (_store.IsEnabled) CodexAccountRuntime.ClearStaleLoginDirectories(_store.RootPath); + _store.Recover(CodexAccountRuntime.AssertWritersStopped); + _status.Text = "복구 확인을 완료했습니다. 실제 인증 파일의 최신 로그인은 유지됩니다."; + return Task.CompletedTask; + } + + private Task RemoveAsync() + { + var selected = Selected; + if (MessageBox.Show(this, $"'{selected.Label}'의 저장된 로그인을 삭제할까요? 다시 사용하려면 재로그인이 필요합니다.", + "저장된 계정 삭제", MessageBoxButtons.OKCancel, MessageBoxIcon.Question) == DialogResult.OK) + { + _store.Remove(selected.Id); + _status.Text = "저장된 계정을 삭제했습니다."; + } + return Task.CompletedTask; + } + + private void Reload(bool preserveStatus = false) + { + try + { + var selectedId = _accounts.SelectedItems.Count == 1 ? ((SavedCodexAccount)_accounts.SelectedItems[0].Tag!).Id : null; + _accounts.Items.Clear(); + if (!_store.IsEnabled) return; + foreach (var account in _store.ListAccounts()) + { + var item = new ListViewItem(new[] { account.Label, account.IdentityHint, account.IsActive ? "현재 계정" : "저장됨", + account.Usage is { } usage ? $"{Math.Clamp(100 - usage.UsedPercent, 0, 100)}%" : "미확인", + account.ObservedAt?.ToLocalTime().ToString("MM-dd HH:mm") ?? "—" }) { Tag = account }; + _accounts.Items.Add(item); + if (account.Id == selectedId) item.Selected = true; + } + if (_store.HasPendingRecovery) _status.Text = "미완료 전환이 있습니다. Codex와 관련 엔진을 닫고 복구를 선택하세요. 사용량 조회는 중지되었습니다."; + else if (!preserveStatus) _status.Text = "비활성 계정은 마지막 확인값입니다. 사용량 확인을 위해 자동 전환하지 않습니다."; + } + catch (Exception ex) { _status.Text = ex.Message; } + } +} diff --git a/src/AppServerClient.cs b/src/AppServerClient.cs new file mode 100644 index 0000000..0c5607d --- /dev/null +++ b/src/AppServerClient.cs @@ -0,0 +1,423 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Text.Json; + +namespace WeeklyUsageIndicator; + +// account/read exposes no workspace/user ID. Email corroborates a session; it is not a full identity proof. +internal sealed record CodexAccountUsage(UsageSnapshot Usage, string? Email, string? PlanType, bool IsChatGpt); + +/// Owns one serialized, cancellable app-server session. No session owns another session's state. +internal sealed class AppServerClient : IDisposable +{ + private readonly object _stateLock = new(); + private readonly SemaphoreSlim _operationGate = new(1, 1); + private readonly Func _startInfoFactory; + private CancellationTokenSource _generation = new(); + private Session? _session; + private bool _suspended; + private bool _disposed; + private int _shutdowns; + + public AppServerClient() : this(CreateStartInfo) { } + + // Test seam: fake stdio server only; production always uses the official local executable. + internal AppServerClient(Func startInfoFactory) => _startInfoFactory = startInfoFactory; + + public async Task GetWeeklyUsageAsync(CancellationToken cancellationToken) => + (await ReadUsageAsync(includeAccount: false, cancellationToken).ConfigureAwait(false)).Usage; + + public Task GetWeeklyUsageWithAccountAsync(CancellationToken cancellationToken) => + ReadUsageAsync(includeAccount: true, cancellationToken); + + private async Task ReadUsageAsync(bool includeAccount, CancellationToken cancellationToken) + { + CancellationToken generation; + lock (_stateLock) + { + ThrowIfUnavailable(); + generation = _generation.Token; + } + + using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, generation); + await _operationGate.WaitAsync(linked.Token).ConfigureAwait(false); + try + { + linked.Token.ThrowIfCancellationRequested(); + var session = await EnsureStartedAsync(linked.Token).ConfigureAwait(false); + (string? Email, string? PlanType, bool IsChatGpt) account = (null, null, false); + if (includeAccount) + account = ParseAccount(await CallCoreAsync(session, "account/read", new { refreshToken = false }, linked.Token).ConfigureAwait(false)); + var result = await CallCoreAsync(session, "account/rateLimits/read", new { }, linked.Token).ConfigureAwait(false); + if (includeAccount) + { + var after = ParseAccount(await CallCoreAsync(session, "account/read", new { refreshToken = false }, linked.Token).ConfigureAwait(false)); + if (account != after) throw new IOException("The Codex helper account changed during the usage read. Refresh again."); + } + linked.Token.ThrowIfCancellationRequested(); + return new CodexAccountUsage(ParseWeeklyUsage(result), account.Email, account.PlanType, account.IsChatGpt); + } + finally { _operationGate.Release(); } + } + + private static (string? Email, string? PlanType, bool IsChatGpt) ParseAccount(JsonElement response) + { + if (response.ValueKind != JsonValueKind.Object || !response.TryGetProperty("account", out var account) || + account.ValueKind != JsonValueKind.Object) return (null, null, false); + static string? ReadString(JsonElement value, string key) => + value.TryGetProperty(key, out var field) && field.ValueKind == JsonValueKind.String && field.GetString() is { Length: <= 320 } text + ? text : null; + return (ReadString(account, "email"), ReadString(account, "planType"), ReadString(account, "type") == "chatgpt"); + } + + private async Task EnsureStartedAsync(CancellationToken token) + { + if (_session is { Initialized: true } existing && !existing.Process.HasExited && !existing.Disconnected) + return existing; + + await StopSessionAsync().ConfigureAwait(false); + Session session; + lock (_stateLock) + { + // Starting the child and publishing ownership are atomic with respect to suspension. + ThrowIfUnavailable(); + token.ThrowIfCancellationRequested(); + var process = new Process { StartInfo = _startInfoFactory(), EnableRaisingEvents = true }; + try + { + if (!process.Start()) throw new IOException("Codex app-server could not be started."); + session = new Session(process); + _session = session; + } + catch { process.Dispose(); throw; } + } + + session.Reader = ReadLoopAsync(session); + session.ErrorReader = DrainErrorAsync(session); + try + { + await CallCoreAsync(session, "initialize", new + { + clientInfo = new { name = "weekly-usage-indicator", title = "Weekly Usage Indicator", version = "1.4.0" }, + capabilities = new { experimentalApi = true } + }, token).ConfigureAwait(false); + await SendLineAsync(session, JsonSerializer.Serialize(new { method = "initialized" }), token).ConfigureAwait(false); + token.ThrowIfCancellationRequested(); + session.Initialized = true; + return session; + } + catch + { + await StopSessionAsync().ConfigureAwait(false); + throw; + } + } + + private static ProcessStartInfo CreateStartInfo() + { + var path = LocateCodexExecutable() + ?? throw new FileNotFoundException("codex.exe was not found. Install or open Codex Desktop first."); + return new ProcessStartInfo + { + FileName = path, + Arguments = "app-server --stdio", + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + WorkingDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) + }; + } + + private static async Task CallCoreAsync(Session session, string method, object parameters, CancellationToken token) + { + using var linked = CancellationTokenSource.CreateLinkedTokenSource(token, session.Lifetime.Token); + var id = ++session.NextRequestId; + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + if (!session.Pending.TryAdd(id, completion)) throw new IOException("Could not register a Codex request."); + try + { + await SendLineAsync(session, JsonSerializer.Serialize(new { id, method, @params = parameters }), linked.Token).ConfigureAwait(false); + return await completion.Task.WaitAsync(linked.Token).ConfigureAwait(false); + } + finally { session.Pending.TryRemove(id, out _); } + } + + private static async Task SendLineAsync(Session session, string line, CancellationToken token) + { + if (session.Disconnected) throw new IOException("Codex app-server disconnected."); + await session.Process.StandardInput.WriteLineAsync(line.AsMemory(), token).ConfigureAwait(false); + await session.Process.StandardInput.FlushAsync(token).ConfigureAwait(false); + } + + private static async Task ReadLoopAsync(Session session) + { + try + { + while (!session.Lifetime.IsCancellationRequested) + { + var line = await session.Process.StandardOutput.ReadLineAsync(session.Lifetime.Token).ConfigureAwait(false); + if (line is null) break; + if (string.IsNullOrWhiteSpace(line)) continue; + try + { + using var document = JsonDocument.Parse(line); + var root = document.RootElement; + if (root.ValueKind != JsonValueKind.Object || + !root.TryGetProperty("id", out var idElement) || idElement.ValueKind != JsonValueKind.Number || !idElement.TryGetInt32(out var id) || + !session.Pending.TryGetValue(id, out var completion)) continue; + if (root.TryGetProperty("error", out _)) + // Protocol errors can include sensitive server context. Never surface the raw payload. + completion.TrySetException(new IOException("Codex could not read account usage. Check the account login.")); + else if (root.TryGetProperty("result", out var result)) + completion.TrySetResult(result.Clone()); + else completion.TrySetException(new InvalidDataException("Codex returned an incomplete response.")); + } + catch (JsonException) { /* Ignore non-protocol diagnostics. */ } + } + } + catch (OperationCanceledException) when (session.Lifetime.IsCancellationRequested) { } + catch { /* The sanitized disconnect below is sufficient for callers. */ } + finally + { + session.Disconnected = true; + foreach (var completion in session.Pending.Values) + completion.TrySetException(new IOException("Codex app-server disconnected.")); + } + } + + private static async Task DrainErrorAsync(Session session) + { + try + { + while (await session.Process.StandardError.ReadLineAsync(session.Lifetime.Token).ConfigureAwait(false) is not null) { } + } + catch { /* Never retain or display stderr, which may contain account context. */ } + } + + /// Blocks starts immediately and returns only once the owned writer has exited. + public async Task SuspendAsync() + { + CancellationTokenSource generation; + lock (_stateLock) + { + _suspended = true; + _shutdowns++; + generation = _generation; + } + generation.Cancel(); + await _operationGate.WaitAsync().ConfigureAwait(false); + try { await StopSessionAsync().ConfigureAwait(false); } + finally + { + lock (_stateLock) { _shutdowns--; } + _operationGate.Release(); + } + } + + public void Resume() + { + lock (_stateLock) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (_shutdowns != 0) throw new InvalidOperationException("Codex helper shutdown is still in progress."); + if (!_suspended) return; + if (_session is not null) throw new IOException("The previous Codex helper has not stopped."); + _generation.Dispose(); + _generation = new CancellationTokenSource(); + _suspended = false; + } + } + + private async Task StopSessionAsync() + { + var session = _session; + if (session is null) return; + session.Lifetime.Cancel(); + try { session.Process.StandardInput.Close(); } catch { } + try + { + if (!session.Process.HasExited) session.Process.Kill(entireProcessTree: true); + } + catch (InvalidOperationException) when (session.Process.HasExited) { } + + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(8)); + try { await session.Process.WaitForExitAsync(timeout.Token).ConfigureAwait(false); } + catch (OperationCanceledException) + { + // Keep ownership and remain suspended. The caller must not change credentials after this failure. + throw new IOException("Codex helper did not exit. Account switching is blocked."); + } + await Task.WhenAll(session.Reader, session.ErrorReader).WaitAsync(TimeSpan.FromSeconds(3)).ConfigureAwait(false); + session.Process.Dispose(); + session.Lifetime.Dispose(); + _session = null; + } + + private void ThrowIfUnavailable() + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (_suspended) throw new OperationCanceledException("Codex usage is paused during account switching."); + } + + public void Pause() => SuspendAsync().GetAwaiter().GetResult(); + + public void Dispose() + { + lock (_stateLock) + { + if (_disposed) return; + _disposed = true; + } + try { SuspendAsync().GetAwaiter().GetResult(); } + catch { /* OS ownership checks also guard switching; Dispose must not tear down the UI with an exception. */ } + // Keep gates and generation valid for callers already unwinding their canceled operation. + } + + private sealed class Session(Process process) + { + internal readonly Process Process = process; + internal readonly CancellationTokenSource Lifetime = new(); + internal readonly ConcurrentDictionary> Pending = new(); + internal Task Reader = Task.CompletedTask; + internal Task ErrorReader = Task.CompletedTask; + internal int NextRequestId; + internal bool Initialized; + internal volatile bool Disconnected; + } + private static UsageSnapshot ParseWeeklyUsage(JsonElement response) + { + var snapshot = SelectCoreSnapshot(response); + var limitId = snapshot.TryGetProperty("limitId", out var idElement) && idElement.ValueKind == JsonValueKind.String + ? idElement.GetString() ?? "codex" + : "codex"; + + var windows = new List<(int Used, long? Duration, long? ResetsAt, string Name)>(); + AddWindow(snapshot, "primary", windows); + AddWindow(snapshot, "secondary", windows); + + if (windows.Count == 0) + throw new InvalidDataException("Codex did not return a usage window."); + + var weekly = windows + .OrderBy(window => WeeklyDistance(window.Duration)) + .ThenByDescending(window => window.Duration ?? 0) + .First(); + + DateTimeOffset? resetsAt = null; + if (weekly.ResetsAt is > 0) + resetsAt = DateTimeOffset.FromUnixTimeSeconds(weekly.ResetsAt.Value).ToLocalTime(); + + return new UsageSnapshot( + Math.Clamp(weekly.Used, 0, 100), + resetsAt, + weekly.Duration, + limitId); + } + + private static JsonElement SelectCoreSnapshot(JsonElement response) + { + if (response.TryGetProperty("rateLimitsByLimitId", out var byId) && byId.ValueKind == JsonValueKind.Object) + { + if (byId.TryGetProperty("codex", out var codex) && codex.ValueKind == JsonValueKind.Object) + return codex; + + foreach (var property in byId.EnumerateObject()) + { + if (property.Value.ValueKind != JsonValueKind.Object) continue; + if (!property.Value.TryGetProperty("limitName", out var name) || name.ValueKind == JsonValueKind.Null) + return property.Value; + } + } + + if (response.TryGetProperty("rateLimits", out var legacy) && legacy.ValueKind == JsonValueKind.Object) + return legacy; + + throw new InvalidDataException("Codex did not return rate-limit data."); + } + + private static void AddWindow( + JsonElement snapshot, + string propertyName, + ICollection<(int Used, long? Duration, long? ResetsAt, string Name)> windows) + { + if (!snapshot.TryGetProperty(propertyName, out var window) || window.ValueKind != JsonValueKind.Object) + return; + if (!window.TryGetProperty("usedPercent", out var usedElement) || !usedElement.TryGetInt32(out var used)) + return; + + long? duration = null; + if (window.TryGetProperty("windowDurationMins", out var durationElement) && + durationElement.ValueKind == JsonValueKind.Number && + durationElement.TryGetInt64(out var durationValue)) + { + duration = durationValue; + } + + long? resetsAt = null; + if (window.TryGetProperty("resetsAt", out var resetElement) && + resetElement.ValueKind == JsonValueKind.Number && + resetElement.TryGetInt64(out var resetValue)) + { + resetsAt = resetValue; + } + + windows.Add((used, duration, resetsAt, propertyName)); + } + + private static long WeeklyDistance(long? durationMinutes) + { + const long weekMinutes = 7 * 24 * 60; + return durationMinutes is null + ? long.MaxValue / 2 + : Math.Abs(durationMinutes.Value - weekMinutes); + } + + internal static string? LocateCodexExecutable() + { + var configured = Environment.GetEnvironmentVariable("CODEX_WEEKLY_INDICATOR_CODEX_PATH"); + if (!string.IsNullOrWhiteSpace(configured) && File.Exists(configured)) + return configured; + + var localBin = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "OpenAI", + "Codex", + "bin"); + + try + { + if (Directory.Exists(localBin)) + { + var localCodex = Directory + .EnumerateFiles(localBin, "codex.exe", SearchOption.AllDirectories) + .Select(path => new FileInfo(path)) + .OrderByDescending(file => file.LastWriteTimeUtc) + .FirstOrDefault(); + if (localCodex is not null) return localCodex.FullName; + } + } + catch + { + // Continue to PATH lookup. + } + + var pathValue = Environment.GetEnvironmentVariable("PATH") ?? string.Empty; + foreach (var directory in pathValue.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)) + { + try + { + var candidate = Path.Combine(directory.Trim().Trim('"'), "codex.exe"); + if (File.Exists(candidate)) return candidate; + } + catch + { + // Ignore malformed PATH entries. + } + } + + return null; + } + +} diff --git a/src/CodexAccountRuntime.cs b/src/CodexAccountRuntime.cs new file mode 100644 index 0000000..58babfb --- /dev/null +++ b/src/CodexAccountRuntime.cs @@ -0,0 +1,455 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Security.AccessControl; +using System.Security.Principal; +using Microsoft.Win32.SafeHandles; + +namespace WeeklyUsageIndicator; + +internal static class CodexAccountRuntime +{ + private static readonly SemaphoreSlim LoginGate = new(1, 1); + internal const string LoginOwnerMarker = "weekly-usage-indicator-login-v1"; + + internal static void AssertWritersStopped() + { + // All Codex processes are potential writers, including CLI/IDE helpers whose + // CODEX_HOME cannot be verified from another process. Never terminate them. + foreach (var name in new[] { "codex", "ChatGPT" }) + { + var processes = Process.GetProcessesByName(name); + try + { + foreach (var process in processes) + { + try + { + if (process.HasExited) continue; + var path = name == "codex" ? null : process.MainModule?.FileName; + if (name == "codex" || path is null || IsPackagedDesktopPath(path)) + throw WritersRunning(); + } + catch (System.ComponentModel.Win32Exception) { throw WritersRunning(); } + catch (InvalidOperationException) + { + // A process that disappeared is harmless; an accessible live + // process or an uninspectable process must block the write. + try { if (process.HasExited) continue; } + catch { } + throw WritersRunning(); + } + } + } + finally { foreach (var process in processes) process.Dispose(); } + } + } + + private static InvalidOperationException WritersRunning() => new( + "Codex 앱과 Codex CLI·IDE 작업을 모두 종료한 뒤 다시 시도하세요. 실행 중인 프로세스는 자동 종료하지 않습니다."); + + internal static string? CaptureDesktopLaunchPath() + { + var processes = Process.GetProcessesByName("ChatGPT"); + try + { + foreach (var process in processes) + { + try + { + var path = process.MainModule?.FileName; + if (IsPackagedDesktopPath(path) && File.Exists(path)) return path; + } + catch (System.ComponentModel.Win32Exception) { } + catch (InvalidOperationException) { } + } + } + finally { foreach (var process in processes) process.Dispose(); } + return null; + } + + internal static bool IsPackagedDesktopPath(string? path) + { + if (string.IsNullOrWhiteSpace(path) || !Path.IsPathFullyQualified(path)) return false; + try + { + var file = new FileInfo(Path.GetFullPath(path)); + var app = file.Directory; + var package = app?.Parent; + var windowsApps = package?.Parent; + return file.Name.Equals("ChatGPT.exe", StringComparison.OrdinalIgnoreCase) + && app?.Name.Equals("app", StringComparison.OrdinalIgnoreCase) == true + && package?.Name.StartsWith("OpenAI.Codex_", StringComparison.OrdinalIgnoreCase) == true + && package.Name.EndsWith("__2p2nqsd0c76g0", StringComparison.OrdinalIgnoreCase) + && windowsApps?.FullName.Equals( + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "WindowsApps"), + StringComparison.OrdinalIgnoreCase) == true; + } + catch (ArgumentException) { return false; } + catch (NotSupportedException) { return false; } + } + + internal static void LaunchDesktop(string? verifiedPath) + { + if (!IsPackagedDesktopPath(verifiedPath) || !File.Exists(verifiedPath)) + throw new InvalidOperationException("Codex 실행 경로가 없거나 앱이 업데이트되었습니다. 시작 메뉴에서 Codex를 열어 주세요."); + try + { + using var process = Process.Start(new ProcessStartInfo(verifiedPath!) { UseShellExecute = true }); + } + catch { throw new InvalidOperationException("Codex를 자동으로 열지 못했습니다. 시작 메뉴에서 열어 주세요."); } + } + + internal static async Task LoginAsync(string vaultRoot, CancellationToken cancellationToken) + { + if (!await LoginGate.WaitAsync(0, cancellationToken)) + throw new InvalidOperationException("이미 계정 로그인이 진행 중입니다."); + CodexLoginResult? staging = null; + try + { + AssertUnmanagedLoginEnvironment(); + var executable = AppServerClient.LocateCodexExecutable(); + if (executable is null || !File.Exists(executable)) + throw new InvalidOperationException("설치된 Codex CLI를 찾지 못했습니다. Codex 앱을 먼저 실행해 주세요."); + staging = CreateLoginStaging(vaultRoot); + using var process = new Process { StartInfo = CreateLoginStartInfo(executable, staging.DirectoryPath) }; + using var loginJob = new CodexLoginJob(); + var started = false; + try + { + cancellationToken.ThrowIfCancellationRequested(); + if (!process.Start()) throw new InvalidOperationException(); + started = true; + // Assign immediately, before touching pipes or awaiting. The job's + // noninheritable handle closes on widget crash and kills this child. + loginJob.Attach(process); + } + catch (OperationCanceledException) { throw; } + catch + { + // An assignment failure must not leave an uncontained login alive. + if (started) + { + loginJob.Dispose(); + if (!process.HasExited) process.Kill(entireProcessTree: false); + await process.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10)); + } + throw new InvalidOperationException("Codex 로그인 프로세스를 안전하게 시작하지 못했습니다."); + } + process.StandardInput.Close(); + // OAuth URLs and CLI diagnostics are not retained, logged, or shown. + var stdout = DiscardOutputAsync(process.StandardOutput); + var stderr = DiscardOutputAsync(process.StandardError); + try + { + await process.WaitForExitAsync(cancellationToken); + await Task.WhenAll(stdout, stderr).WaitAsync(TimeSpan.FromSeconds(5)); + cancellationToken.ThrowIfCancellationRequested(); + if (process.ExitCode != 0 || !File.Exists(staging.AuthPath)) + throw new InvalidOperationException("로그인이 완료되지 않았습니다. 브라우저에서 계정을 확인한 뒤 다시 시도하세요."); + AssertNoReparsePoints(staging.AuthPath); + var length = new FileInfo(staging.AuthPath).Length; + if (length is <= 0 or > 1024 * 1024) + throw new InvalidOperationException("로그인 결과의 형식이 올바르지 않습니다."); + var result = staging; + staging = null; + return result; + } + finally + { + // Closing the job kills only the owned login process. Descendants + // break away so a browser opened by OAuth is never terminated. + // Always await login exit before deleting staging, including cancel. + loginJob.Dispose(); + if (!process.HasExited) + { + await process.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10)); + } + try { await Task.WhenAll(stdout, stderr).WaitAsync(TimeSpan.FromSeconds(5)); } + catch { /* No captured diagnostics are surfaced. */ } + } + } + catch (OperationCanceledException) { throw; } + catch (InvalidOperationException) { throw; } + catch { throw new InvalidOperationException("Codex 로그인 처리에 실패했습니다. 계정 상태를 확인한 뒤 다시 시도하세요."); } + finally + { + try { staging?.Dispose(); } + finally { LoginGate.Release(); } + } + } + + internal static ProcessStartInfo CreateLoginStartInfo(string executable, string stagingPath) + { + var start = new ProcessStartInfo(executable) + { + UseShellExecute = false, CreateNoWindow = true, + RedirectStandardInput = true, RedirectStandardOutput = true, RedirectStandardError = true, + WorkingDirectory = stagingPath + }; + start.ArgumentList.Add("login"); + start.ArgumentList.Add("-c"); + start.ArgumentList.Add("cli_auth_credentials_store=\"file\""); + foreach (var key in start.Environment.Keys.ToArray()) + { + if (key.StartsWith("CODEX_", StringComparison.OrdinalIgnoreCase) + || key.StartsWith("OPENAI_", StringComparison.OrdinalIgnoreCase) + || key.StartsWith("CHATGPT_", StringComparison.OrdinalIgnoreCase) + || key.StartsWith("SSH_", StringComparison.OrdinalIgnoreCase) + || key.Equals("RUST_LOG", StringComparison.OrdinalIgnoreCase)) start.Environment.Remove(key); + } + start.Environment["CODEX_HOME"] = stagingPath; + start.Environment["RUST_LOG"] = "off"; + return start; + } + + private static void AssertUnmanagedLoginEnvironment() + { + // Managed requirements can override -c and select a shared keyring. Login + // revokes existing auth before opening OAuth, so fail BEFORE launching it. + var commonData = Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData); + foreach (var name in new[] { "requirements.toml", "config.toml" }) + { + var path = Path.Combine(commonData, "OpenAI", "Codex", name); + try + { + _ = File.GetAttributes(path); + throw new InvalidOperationException("관리형 Codex 설정이 감지되어 격리 로그인을 중단했습니다. 이 버전은 개인용 파일 인증 환경을 지원합니다."); + } + catch (FileNotFoundException) { } + catch (DirectoryNotFoundException) { } + catch (UnauthorizedAccessException) + { throw new InvalidOperationException("Codex 관리 설정을 확인할 수 없어 로그인을 시작하지 않았습니다."); } + } + } + + internal static CodexLoginResult CreateLoginStaging(string vaultRoot) + { + var root = Path.GetFullPath(vaultRoot); + AssertNoReparsePoints(root); + if (!Directory.Exists(root)) + throw new InvalidOperationException("계정 저장소를 먼저 만들어 주세요."); + var stage = Path.Combine(root, "login-" + Guid.NewGuid().ToString("N")); + var security = new DirectorySecurity(); + security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false); + var user = WindowsIdentity.GetCurrent().User + ?? throw new InvalidOperationException("현재 Windows 사용자를 확인할 수 없습니다."); + security.SetOwner(user); + foreach (var sid in new[] { user, new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null) }) + security.AddAccessRule(new FileSystemAccessRule(sid, FileSystemRights.FullControl, + InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, + PropagationFlags.None, AccessControlType.Allow)); + new DirectoryInfo(stage).Create(security); + var result = new CodexLoginResult(root, stage); + try + { + result.CreateOwnershipLease(); + File.WriteAllText(Path.Combine(stage, "config.toml"), "cli_auth_credentials_store = \"file\"\n", new System.Text.UTF8Encoding(false)); + return result; + } + catch { result.Dispose(); throw; } + } + + internal static void ClearStaleLoginDirectories(string vaultRoot) + { + // A crashed widget can leave its CLI login child alive. Do not clear any + // staging until every possible writer has exited, and skip held leases. + AssertWritersStopped(); + var root = Path.GetFullPath(vaultRoot); + AssertNoReparsePoints(root); + if (!Directory.Exists(root)) return; + foreach (var directory in Directory.EnumerateDirectories(root, "login-*", SearchOption.TopDirectoryOnly).Take(128)) + { + var name = Path.GetFileName(directory); + if (!Guid.TryParseExact(name[6..], "N", out _)) continue; + AssertNoReparsePoints(directory); + var marker = Path.Combine(directory, ".login-owner"); + AssertNoReparsePoints(marker); + if (!File.Exists(marker)) continue; + FileStream lease; + try { lease = new FileStream(marker, FileMode.Open, FileAccess.Read, FileShare.None); } + catch (IOException) { continue; } + bool owned; + using (lease) + { + if (lease.Length > 128) continue; + using var reader = new StreamReader(lease); + owned = reader.ReadToEnd() == LoginOwnerMarker; + } + if (!owned) continue; + AssertWritersStopped(); + using var stale = new CodexLoginResult(root, directory); + } + } + + internal static void AssertNoReparsePoints(string path) + { + for (var item = Path.GetFullPath(path); !string.IsNullOrEmpty(item); item = Path.GetDirectoryName(item)) + { + try + { + if ((File.GetAttributes(item) & FileAttributes.ReparsePoint) != 0) + throw new InvalidOperationException("연결된 폴더나 파일에서는 계정 로그인을 수행할 수 없습니다."); + } + catch (FileNotFoundException) { } + catch (DirectoryNotFoundException) { } + } + } + + private static async Task DiscardOutputAsync(StreamReader reader) + { + var buffer = new char[2048]; + try { while (await reader.ReadAsync(buffer.AsMemory()) != 0) Array.Clear(buffer); } + finally { Array.Clear(buffer); } + } +} + +internal sealed class CodexLoginJob : IDisposable +{ + private readonly SafeFileHandle _handle; + + internal CodexLoginJob() + { + // Null security attributes make this private unnamed handle noninheritable. + _handle = CreateJobObjectW(IntPtr.Zero, null); + if (_handle.IsInvalid) + { + _handle.Dispose(); + throw new InvalidOperationException("로그인 프로세스 보호를 준비하지 못했습니다."); + } + var limits = new ExtendedLimitInformation + { + BasicLimitInformation = new BasicLimitInformation + { + // KILL_ON_JOB_CLOSE | SILENT_BREAKAWAY_OK: own the login process, + // while allowing its browser launcher/children to live independently. + LimitFlags = 0x00002000 | 0x00001000 + } + }; + if (!SetInformationJobObject(_handle, 9, ref limits, (uint)Marshal.SizeOf())) + { + _handle.Dispose(); + throw new InvalidOperationException("로그인 프로세스 보호를 설정하지 못했습니다."); + } + } + + internal void Attach(Process ownedProcess) + { + if (!AssignProcessToJobObject(_handle, ownedProcess.SafeHandle)) + throw new InvalidOperationException("로그인 프로세스 보호를 연결하지 못했습니다."); + } + + public void Dispose() => _handle.Dispose(); + + [StructLayout(LayoutKind.Sequential)] + private struct BasicLimitInformation + { + internal long PerProcessUserTimeLimit; + internal long PerJobUserTimeLimit; + internal uint LimitFlags; + internal UIntPtr MinimumWorkingSetSize; + internal UIntPtr MaximumWorkingSetSize; + internal uint ActiveProcessLimit; + internal UIntPtr Affinity; + internal uint PriorityClass; + internal uint SchedulingClass; + } + + [StructLayout(LayoutKind.Sequential)] + private struct IoCounters + { + internal ulong ReadOperationCount; + internal ulong WriteOperationCount; + internal ulong OtherOperationCount; + internal ulong ReadTransferCount; + internal ulong WriteTransferCount; + internal ulong OtherTransferCount; + } + + [StructLayout(LayoutKind.Sequential)] + private struct ExtendedLimitInformation + { + internal BasicLimitInformation BasicLimitInformation; + internal IoCounters IoInfo; + internal UIntPtr ProcessMemoryLimit; + internal UIntPtr JobMemoryLimit; + internal UIntPtr PeakProcessMemoryUsed; + internal UIntPtr PeakJobMemoryUsed; + } + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern SafeFileHandle CreateJobObjectW(IntPtr attributes, string? name); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool SetInformationJobObject(SafeFileHandle job, int informationClass, + ref ExtendedLimitInformation information, uint length); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool AssignProcessToJobObject(SafeFileHandle job, SafeProcessHandle process); +} + +internal sealed class CodexLoginResult : IDisposable +{ + private readonly string _root; + private bool _disposed; + private FileStream? _ownershipLease; + internal string DirectoryPath { get; } + internal string AuthPath => Path.Combine(DirectoryPath, "auth.json"); + internal CodexLoginResult(string root, string directory) { _root = root; DirectoryPath = directory; } + + internal void CreateOwnershipLease() + { + _ownershipLease = new FileStream(Path.Combine(DirectoryPath, ".login-owner"), + FileMode.CreateNew, FileAccess.ReadWrite, FileShare.None); + var marker = System.Text.Encoding.UTF8.GetBytes(CodexAccountRuntime.LoginOwnerMarker); + _ownershipLease.Write(marker); + _ownershipLease.Flush(flushToDisk: true); + } + + public void Dispose() + { + if (_disposed) return; + if (!Directory.Exists(DirectoryPath)) { _disposed = true; return; } + var full = Path.GetFullPath(DirectoryPath); + if (!string.Equals(Path.GetDirectoryName(full), _root, StringComparison.OrdinalIgnoreCase) + || !Path.GetFileName(full).StartsWith("login-", StringComparison.Ordinal) + || !Guid.TryParseExact(Path.GetFileName(full)[6..], "N", out _)) + throw new InvalidOperationException("로그인 임시 폴더의 범위를 확인하지 못했습니다."); + try + { + _ownershipLease?.Dispose(); + _ownershipLease = null; + CodexAccountRuntime.AssertNoReparsePoints(full); + // Inspect every node before any deletion; no recursive API traverses a link. + var files = new List(); + var directories = new List(); + Collect(full, files, directories); + foreach (var file in files) + { + CodexAccountRuntime.AssertNoReparsePoints(file); + File.Delete(file); + } + foreach (var directory in directories.AsEnumerable().Reverse()) Directory.Delete(directory, false); + Directory.Delete(full, false); + _disposed = true; + } + catch { throw new InvalidOperationException("로그인 임시 파일을 정리하지 못했습니다. 계정 저장소의 login- 임시 폴더를 확인해 주세요."); } + } + + private static void Collect(string path, List files, List directories) + { + foreach (var entry in Directory.EnumerateFileSystemEntries(path)) + { + var attributes = File.GetAttributes(entry); + if ((attributes & FileAttributes.ReparsePoint) != 0) throw new InvalidOperationException(); + if ((attributes & FileAttributes.Directory) != 0) + { + directories.Add(entry); + Collect(entry, files, directories); + } + else files.Add(entry); + if (files.Count + directories.Count > 2048) throw new InvalidOperationException(); + } + } +} diff --git a/src/CodexAccountStore.cs b/src/CodexAccountStore.cs new file mode 100644 index 0000000..276a05a --- /dev/null +++ b/src/CodexAccountStore.cs @@ -0,0 +1,628 @@ +using System.ComponentModel; +using System.Runtime.InteropServices; +using System.Security.AccessControl; +using System.Security.Cryptography; +using System.Security.Principal; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace WeeklyUsageIndicator; + +internal sealed record AccountIdentity(string Key, string Hint); +internal sealed record SavedCodexAccount(string Id, string Label, string IdentityHint, bool IsActive, + UsageSnapshot? Usage, DateTimeOffset? ObservedAt); + +/// +/// Opt-in, local-only account storage. Live auth.json is authoritative for the active account. +/// This class never refreshes a token, starts Codex, changes config, or reads Claude credentials. +/// +internal sealed class CodexAccountStore +{ + internal const string TransactionMutexName = @"Local\CodexWeeklyUsageIndicator.AccountTransaction"; + private const int MaxAuthBytes = 1024 * 1024; + private const int MaxStoreBytes = 16 * 1024 * 1024; + private const int MaxAccounts = 20; + private static readonly byte[] Entropy = Encoding.UTF8.GetBytes("WeeklyUsageIndicator.Accounts.v1"); + private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = false }; + private readonly string _vaultPath; + private readonly string _journalPath; + private string AuthPath => Path.Combine(CodexHome, "auth.json"); + private string AuthTempPath => Path.Combine(CodexHome, ".gfs-account-auth.tmp"); + public string RootPath { get; } + public string CodexHome { get; } + public bool IsEnabled => File.Exists(_vaultPath); + public bool HasPendingRecovery => File.Exists(_journalPath); + + // Internal deterministic crash seam. Production never supplies a callback. + internal Action? Checkpoint { get; set; } + + public CodexAccountStore(string? root = null, string? codexHome = null) + { + RootPath = Path.GetFullPath(root ?? Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "CodexWeeklyUsageIndicator.Accounts")); + var configuredHome = Environment.GetEnvironmentVariable("CODEX_HOME"); + CodexHome = Path.GetFullPath(codexHome ?? (string.IsNullOrWhiteSpace(configuredHome) + ? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".codex") + : configuredHome)); + _vaultPath = Path.Combine(RootPath, "accounts.dpapi"); + _journalPath = Path.Combine(RootPath, "switch.dpapi"); + } + + public AccountIdentity GetCurrentIdentity() + { + CheckSupportedStore(); + return ParseIdentity(ReadBounded(AuthPath, MaxAuthBytes)); + } + + public IReadOnlyList ListAccounts() + { + if (!IsEnabled && !HasPendingRecovery) return Array.Empty(); + using var gate = AcquireLock(); + var vault = LoadVault(); + // Missing/logged-out auth is shown as no active account; invalid auth must remain visible as an error. + var key = File.Exists(AuthPath) ? GetCurrentIdentity().Key : null; + return vault.Accounts.Select(a => new SavedCodexAccount(a.Id, a.Label, a.Hint, + a.Key == key, a.Usage, a.ObservedAt)).ToArray(); + } + + public SavedCodexAccount RegisterCurrent(string label) + { + CheckSupportedStore(); + using var gate = AcquireLock(); + RequireNoRecovery(); + var bytes = ReadBounded(AuthPath, MaxAuthBytes); + var identity = ParseIdentity(bytes); + var vault = LoadVault(); + var entry = Upsert(vault, bytes, identity, label); + WriteEncrypted(_vaultPath, vault); + return PublicEntry(entry, true); + } + + public SavedCodexAccount ImportLoginFile(string path, string label) + { + CheckSupportedStore(); + using var gate = AcquireLock(); + RequireNoRecovery(); + var bytes = ReadBounded(Path.GetFullPath(path), MaxAuthBytes); + var identity = ParseIdentity(bytes); + var current = File.Exists(AuthPath) ? GetCurrentIdentity() : null; + // Never replace a live account's newer credentials with an isolated login's snapshot. + if (current?.Key == identity.Key) + throw new InvalidOperationException("현재 사용 중인 계정입니다. 현재 계정 등록을 사용하세요."); + var vault = LoadVault(); + var entry = Upsert(vault, bytes, identity, label); + WriteEncrypted(_vaultPath, vault); + return PublicEntry(entry, false); + } + + public void SwitchTo(string id, Action assertStopped) + { + ArgumentNullException.ThrowIfNull(assertStopped); + CheckSupportedStore(); + using var gate = AcquireLock(); + RequireNoRecovery(); + assertStopped(); + var beforeAuth = ReadBounded(AuthPath, MaxAuthBytes); + var source = ParseIdentity(beforeAuth); + var before = LoadVault(); + var target = before.Accounts.SingleOrDefault(a => a.Id == id) + ?? throw new InvalidOperationException("저장된 계정을 찾을 수 없습니다."); + if (source.Key == target.Key) return; + if (!before.Accounts.Any(a => a.Key == source.Key)) + throw new InvalidOperationException("현재 로그인한 계정을 먼저 등록하세요."); + var targetIdentity = ParseIdentity(target.Auth); + if (targetIdentity.Key != target.Key) throw CorruptStore(); + var after = Clone(before); + UpdateAuth(after, source.Key, beforeAuth); + var transaction = new SwitchJournal(1, source.Key, target.Key, beforeAuth, + target.Auth, Digest(beforeAuth), Digest(target.Auth), before, after); + // Journal is durable before either vault or active auth changes. No plaintext backups are made. + WriteEncrypted(_journalPath, transaction); + Checkpoint?.Invoke("journal-written"); + WriteEncrypted(_vaultPath, after); + Checkpoint?.Invoke("source-saved"); + assertStopped(); + CheckSupportedStore(); + RequireSameAuth(beforeAuth); + WriteAuth(target.Auth); + Checkpoint?.Invoke("auth-replaced"); + var actual = ReadBounded(AuthPath, MaxAuthBytes); + if (ParseIdentity(actual).Key != target.Key) + throw new InvalidOperationException("교체 중 다른 로그인이 발견되어 복구 기록을 보존했습니다."); + UpdateAuth(after, target.Key, actual); + WriteEncrypted(_vaultPath, after); + Checkpoint?.Invoke("vault-committed"); + DeleteChecked(_journalPath); + } + + public void Recover(Action assertStopped) + { + ArgumentNullException.ThrowIfNull(assertStopped); + CheckSupportedStore(); + using var gate = AcquireLock(); + if (!HasPendingRecovery) return; + assertStopped(); + var journal = ReadEncrypted(_journalPath); + ValidateJournal(journal); + if (!File.Exists(AuthPath)) + throw new InvalidOperationException("로그인 파일이 없어 자동 복구를 멈췄습니다. 원래 계정으로 로그인한 뒤 복구하세요."); + var live = ReadBounded(AuthPath, MaxAuthBytes); + var identity = ParseIdentity(live); + Vault result; + if (identity.Key == journal.SourceKey) + result = Clone(journal.Before); + else if (identity.Key == journal.TargetKey) + result = Clone(journal.After); + else + throw new InvalidOperationException("교체 대상과 다른 계정이 로그인되어 있습니다. 현재 로그인은 보존했고 자동 복구를 멈췄습니다."); + // Identity chooses rollback/complete; changed digest means the live token rotated and MUST win. + UpdateAuth(result, identity.Key, live); + assertStopped(); + RequireSameAuth(live); + WriteEncrypted(_vaultPath, result); + Checkpoint?.Invoke("recovery-saved"); + DeleteChecked(AuthTempPath); + DeleteChecked(_journalPath); + } + + public void Remove(string id) + { + using var gate = AcquireLock(); + RequireNoRecovery(); + var current = File.Exists(AuthPath) ? GetCurrentIdentity().Key : null; + var vault = LoadVault(); + var entry = vault.Accounts.SingleOrDefault(a => a.Id == id) + ?? throw new InvalidOperationException("저장된 계정을 찾을 수 없습니다."); + if (entry.Key == current) throw new InvalidOperationException("현재 사용 중인 계정은 삭제할 수 없습니다."); + vault.Accounts.Remove(entry); + WriteEncrypted(_vaultPath, vault); + } + + public void SaveUsage(string identityKey, UsageSnapshot snapshot) + { + if (!IsEnabled || HasPendingRecovery) return; + using var gate = AcquireLock(); + RequireNoRecovery(); + if (GetCurrentIdentity().Key != identityKey) return; + var vault = LoadVault(); + var entry = vault.Accounts.SingleOrDefault(a => a.Key == identityKey); + if (entry is null) return; + if (snapshot.UsedPercent is < 0 or > 100 || snapshot.LimitId.Length > 200) + throw new InvalidOperationException("사용량 값이 올바르지 않습니다."); + entry.Usage = snapshot; + entry.ObservedAt = DateTimeOffset.UtcNow; + WriteEncrypted(_vaultPath, vault); + } + + private static SavedCodexAccount PublicEntry(Entry entry, bool active) => + new(entry.Id, entry.Label, entry.Hint, active, entry.Usage, entry.ObservedAt); + + private static Entry Upsert(Vault vault, byte[] bytes, AccountIdentity identity, string label) + { + label = label.Trim(); + if (label.Length is < 1 or > 40 || label.Any(char.IsControl)) + throw new InvalidOperationException("계정 이름은 제어 문자 없이 1~40자로 입력하세요."); + var entry = vault.Accounts.SingleOrDefault(a => a.Key == identity.Key); + if (entry is null) + { + if (vault.Accounts.Count >= MaxAccounts) throw new InvalidOperationException("최대 20개 계정까지 저장할 수 있습니다."); + entry = new Entry { Id = Guid.NewGuid().ToString("N"), Key = identity.Key }; + vault.Accounts.Add(entry); + } + entry.Label = label; + entry.Hint = identity.Hint; + entry.Auth = bytes; + return entry; + } + + private static void UpdateAuth(Vault vault, string key, byte[] bytes) + { + var entry = vault.Accounts.SingleOrDefault(a => a.Key == key) ?? throw CorruptStore(); + entry.Auth = bytes; + } + + private Vault LoadVault() + { + if (!File.Exists(_vaultPath)) return new Vault(); + var result = ReadEncrypted(_vaultPath); + ValidateVault(result); + return result; + } + + private static void ValidateVault(Vault vault) + { + if (vault.Version != 1 || vault.Accounts is null || vault.Accounts.Count > MaxAccounts || + vault.Accounts.Select(a => a.Id).Distinct().Count() != vault.Accounts.Count || + vault.Accounts.Select(a => a.Key).Distinct().Count() != vault.Accounts.Count) + throw CorruptStore(); + foreach (var entry in vault.Accounts) + { + if (!Guid.TryParseExact(entry.Id, "N", out _) || string.IsNullOrWhiteSpace(entry.Label) || + entry.Label.Length > 40 || entry.Label.Any(char.IsControl) || entry.Auth is null || + ParseIdentity(entry.Auth).Key != entry.Key) + throw CorruptStore(); + } + } + + private static void ValidateJournal(SwitchJournal journal) + { + if (journal.Version != 1 || journal.SourceKey == journal.TargetKey || + ParseIdentity(journal.BeforeAuth).Key != journal.SourceKey || + ParseIdentity(journal.AfterAuth).Key != journal.TargetKey || + Digest(journal.BeforeAuth) != journal.BeforeDigest || Digest(journal.AfterAuth) != journal.AfterDigest) + throw CorruptStore(); + ValidateVault(journal.Before); + ValidateVault(journal.After); + if (!journal.Before.Accounts.Any(a => a.Key == journal.SourceKey) || + !journal.After.Accounts.Any(a => a.Key == journal.TargetKey)) throw CorruptStore(); + } + + private static Vault Clone(Vault vault) => JsonSerializer.Deserialize( + JsonSerializer.SerializeToUtf8Bytes(vault, JsonOptions), JsonOptions) ?? throw CorruptStore(); + + private void RequireNoRecovery() + { + if (HasPendingRecovery) throw new InvalidOperationException("미완료 계정 교체를 먼저 복구하세요."); + } + + private void RequireSameAuth(byte[] expected) + { + if (!CryptographicOperations.FixedTimeEquals(expected, ReadBounded(AuthPath, MaxAuthBytes))) + throw new InvalidOperationException("로그인 정보가 작업 중 변경되어 교체를 멈췄습니다. 복구 후 다시 시도하세요."); + } + + private void CheckSupportedStore() + { + // Machine requirements can override the apparent user config and route to a shared keyring. + // Support only the personal unmanaged file-store case; never rewrite a managed setting. + var commonData = Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData); + foreach (var name in new[] { "requirements.toml", "config.toml" }) + { + var path = Path.Combine(commonData, "OpenAI", "Codex", name); + try + { + _ = File.GetAttributes(path); + throw new InvalidOperationException("관리형 Codex 설정이 있어 계정 교체를 지원하지 않습니다. 설정은 변경하지 않았습니다."); + } + catch (FileNotFoundException) { } + catch (DirectoryNotFoundException) { } + catch (UnauthorizedAccessException) + { throw new InvalidOperationException("Codex 관리 설정을 확인할 수 없어 계정 교체를 중단했습니다."); } + } + RejectReparsePath(CodexHome); + if (!Directory.Exists(CodexHome)) throw new InvalidOperationException("Codex 사용자 폴더를 찾을 수 없습니다."); + if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CODEX_API_KEY"))) + throw new InvalidOperationException("CODEX_API_KEY가 설정된 환경에서는 계정 교체를 지원하지 않습니다."); + var config = Path.Combine(CodexHome, "config.toml"); + if (!File.Exists(config)) return; + var text = Encoding.UTF8.GetString(ReadBounded(config, MaxAuthBytes)); + // Be deliberately conservative about TOML syntax: only an unambiguous file store is supported. + foreach (var rawLine in text.Split('\n')) + { + var line = rawLine.Trim(); + if (line.StartsWith('#')) continue; + if (line.Contains("cli_auth_credentials_store", StringComparison.Ordinal) && + !Regex.IsMatch(line, "^cli_auth_credentials_store\\s*=\\s*([\\\"'])file\\1\\s*(#.*)?$")) + throw new InvalidOperationException("파일 방식 이외의 인증 저장 설정은 지원하지 않습니다. 설정은 변경하지 않았습니다."); + if (Regex.IsMatch(line, "^[\\\"']?(forced_chatgpt_workspace_id|forced_login_method)[\\\"']?\\s*=")) + throw new InvalidOperationException("로그인 또는 워크스페이스 제한이 있는 환경은 자동 교체를 지원하지 않습니다."); + } + } + + internal static AccountIdentity ParseIdentity(byte[] bytes) + { + if (bytes.Length is 0 or > MaxAuthBytes) throw InvalidAuth(); + try + { + using var document = JsonDocument.Parse(bytes, new JsonDocumentOptions { MaxDepth = 32 }); + var root = document.RootElement; + RejectDuplicateProperties(root); + if (root.TryGetProperty("auth_mode", out var mode) && mode.ValueKind != JsonValueKind.Null && + (mode.ValueKind != JsonValueKind.String || mode.GetString() != "chatgpt")) throw InvalidAuth(); + // Codex's managed ChatGPT legacy format omits auth_mode (or uses null). Accept it only + // when token data is complete and there is no competing credential mode to infer. + foreach (var field in new[] { "OPENAI_API_KEY", "personal_access_token", "agent_identity", "bedrock_api_key", "bedrock_access_keys" }) + if (root.TryGetProperty(field, out var credential) && credential.ValueKind != JsonValueKind.Null) + throw InvalidAuth(); + if (!root.TryGetProperty("tokens", out var tokens) || tokens.ValueKind != JsonValueKind.Object) + throw InvalidAuth(); + var idToken = Required(tokens, "id_token"); + _ = Required(tokens, "access_token"); + _ = Required(tokens, "refresh_token"); + var account = Required(tokens, "account_id"); + var pieces = idToken.Split('.'); + if (pieces.Length != 3 || pieces[1].Length > MaxAuthBytes) throw InvalidAuth(); + var payload = pieces[1].Replace('-', '+').Replace('_', '/'); + payload = payload.PadRight((payload.Length + 3) / 4 * 4, '='); + using var claimsDocument = JsonDocument.Parse(Convert.FromBase64String(payload)); + var claims = claimsDocument.RootElement; + RejectDuplicateProperties(claims); + var auth = claims.TryGetProperty("https://api.openai.com/auth", out var namespaced) + ? namespaced : default; + var claimAccount = StringValue(auth, "chatgpt_account_id"); + if (claimAccount is not null && claimAccount != account) throw InvalidAuth(); + var user = StringValue(auth, "chatgpt_user_id") ?? StringValue(auth, "user_id") ?? StringValue(claims, "sub"); + if (string.IsNullOrWhiteSpace(user) || user.Length > 512 || account.Length > 512) throw InvalidAuth(); + var key = Digest(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(new[] { user, account }))); + var email = StringValue(claims, "email"); + var hint = string.IsNullOrEmpty(email) || email.Any(char.IsControl) + ? "계정 · " + key[..8] + : email[..1] + "*** · " + key[..8]; + return new AccountIdentity(key, hint); + } + catch (Exception ex) when (ex is JsonException or FormatException or InvalidOperationException or ArgumentException) + { + throw InvalidAuth(); + } + } + + private static void RejectDuplicateProperties(JsonElement element) + { + if (element.ValueKind == JsonValueKind.Object) + { + var names = new HashSet(StringComparer.Ordinal); + foreach (var property in element.EnumerateObject()) + { + if (!names.Add(property.Name)) throw InvalidAuth(); + RejectDuplicateProperties(property.Value); + } + } + else if (element.ValueKind == JsonValueKind.Array) + foreach (var item in element.EnumerateArray()) RejectDuplicateProperties(item); + } + + private static string? StringValue(JsonElement element, string property) => + element.ValueKind == JsonValueKind.Object && element.TryGetProperty(property, out var value) && + value.ValueKind == JsonValueKind.String ? value.GetString() : null; + private static string Required(JsonElement element, string property) => + StringValue(element, property) is { Length: > 0 } value && !string.IsNullOrWhiteSpace(value) + ? value : throw InvalidAuth(); + private static string Digest(byte[] bytes) => Convert.ToHexString(SHA256.HashData(bytes)); + private static InvalidOperationException InvalidAuth() => + new("지원되는 ChatGPT 로그인 파일이 아닙니다. 공식 Codex 로그인으로 다시 등록하세요."); + private static InvalidOperationException CorruptStore() => + new("계정 보관함 또는 복구 기록을 읽을 수 없습니다. 원본 파일을 보존한 채 중단했습니다."); + + private IDisposable AcquireLock() + { + var mutex = new Mutex(false, TransactionMutexName); + bool acquired; + try { acquired = mutex.WaitOne(0); } + catch (AbandonedMutexException) { acquired = true; } + if (!acquired) + { + mutex.Dispose(); + throw new InvalidOperationException("다른 계정 관리 작업이 진행 중입니다. 잠시 후 다시 시도하세요."); + } + try + { + RejectReparsePath(RootPath); + var directory = new DirectoryInfo(RootPath); + if (!directory.Exists) FileSystemAclExtensions.Create(directory, PrivateDirectorySecurity()); + FileSystemAclExtensions.SetAccessControl(directory, PrivateDirectorySecurity()); + var lockPath = Path.Combine(RootPath, "store.lock"); + RejectReparsePath(lockPath); + try + { + if (!File.Exists(lockPath)) + { + using var created = CreatePrivateFile(lockPath); + } + FileSystemAclExtensions.SetAccessControl(new FileInfo(lockPath), PrivateFileSecurity()); + return new StoreLock(mutex, new FileStream(lockPath, FileMode.Open, FileAccess.ReadWrite, FileShare.None)); + } + catch (IOException) + { + throw new InvalidOperationException("다른 계정 관리 작업이 진행 중입니다. 잠시 후 다시 시도하세요."); + } + } + catch + { + mutex.ReleaseMutex(); + mutex.Dispose(); + throw; + } + } + + private sealed class StoreLock(Mutex mutex, FileStream stream) : IDisposable + { + public void Dispose() + { + stream.Dispose(); + mutex.ReleaseMutex(); + mutex.Dispose(); + } + } + + private static SecurityIdentifier CurrentSid => WindowsIdentity.GetCurrent().User + ?? throw new InvalidOperationException("Windows 사용자 SID를 확인할 수 없습니다."); + private static FileSecurity PrivateFileSecurity() + { + var security = new FileSecurity(); + security.SetOwner(CurrentSid); + security.SetAccessRuleProtection(true, false); + security.AddAccessRule(new FileSystemAccessRule(CurrentSid, FileSystemRights.FullControl, AccessControlType.Allow)); + return security; + } + private static DirectorySecurity PrivateDirectorySecurity() + { + var security = new DirectorySecurity(); + security.SetOwner(CurrentSid); + security.SetAccessRuleProtection(true, false); + security.AddAccessRule(new FileSystemAccessRule(CurrentSid, FileSystemRights.FullControl, + InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, PropagationFlags.None, AccessControlType.Allow)); + return security; + } + private static FileStream CreatePrivateFile(string path) => FileSystemAclExtensions.Create( + new FileInfo(path), FileMode.CreateNew, FileSystemRights.FullControl, FileShare.None, + 4096, FileOptions.WriteThrough, PrivateFileSecurity()); + + internal static void RejectReparsePath(string path) + { + var cursor = Path.GetFullPath(path); + while (!string.IsNullOrEmpty(cursor)) + { + try + { + if ((File.GetAttributes(cursor) & FileAttributes.ReparsePoint) != 0) + throw new InvalidOperationException("링크 또는 리파스 경로의 인증 파일은 지원하지 않습니다."); + } + catch (FileNotFoundException) { } + catch (DirectoryNotFoundException) { } + cursor = Path.GetDirectoryName(cursor); + } + } + + private static byte[] ReadBounded(string path, int maximum) + { + RejectReparsePath(path); + using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); + if (stream.Length is <= 0 || stream.Length > maximum) throw CorruptStore(); + var bytes = new byte[(int)stream.Length]; + stream.ReadExactly(bytes); + if (stream.ReadByte() != -1) throw CorruptStore(); + return bytes; + } + + private T ReadEncrypted(string path) + { + var encrypted = ReadBounded(path, MaxStoreBytes); + byte[]? plaintext = null; + try + { + plaintext = Dpapi(encrypted, false); + return JsonSerializer.Deserialize(plaintext, JsonOptions) ?? throw CorruptStore(); + } + catch (Exception ex) when (ex is JsonException or Win32Exception or CryptographicException) + { + throw CorruptStore(); + } + finally { if (plaintext is not null) CryptographicOperations.ZeroMemory(plaintext); } + } + + private void WriteEncrypted(string path, T value) + { + var plain = JsonSerializer.SerializeToUtf8Bytes(value, JsonOptions); + try + { + var encrypted = Dpapi(plain, true); + if (encrypted.Length > MaxStoreBytes) throw new InvalidOperationException("계정 보관함 크기 제한을 초과했습니다."); + WriteAtomic(path, encrypted); + } + finally { CryptographicOperations.ZeroMemory(plain); } + } + + private void WriteAuth(byte[] bytes) + { + _ = ParseIdentity(bytes); + WriteAtomic(AuthPath, bytes); + } + + private void WriteAtomic(string path, byte[] bytes) + { + RejectReparsePath(path); + var temporary = path.Equals(AuthPath, StringComparison.OrdinalIgnoreCase) ? AuthTempPath : + Path.Combine(Path.GetDirectoryName(path)!, ".gfs-account-" + Guid.NewGuid().ToString("N") + ".tmp"); + RejectReparsePath(temporary); + if (File.Exists(temporary)) + throw new InvalidOperationException("이전 인증 교체의 임시 파일이 있습니다. 복구를 먼저 실행하세요."); + try + { + using (var stream = CreatePrivateFile(temporary)) + { + stream.Write(bytes); + stream.Flush(true); + } + Checkpoint?.Invoke(Path.GetFileName(path) + "-temp-flushed"); + RejectReparsePath(path); + if (File.Exists(path)) + { + FileSystemAclExtensions.SetAccessControl(new FileInfo(path), PrivateFileSecurity()); + File.Replace(temporary, path, null, ignoreMetadataErrors: false); + } + else File.Move(temporary, path); + } + finally + { + // Auth replacement requires a private, short-lived plaintext temp on the same volume. + // It is never retained as a backup; encryption applies to all inactive/journal copies. + if (File.Exists(temporary)) DeleteChecked(temporary); + } + } + + private static void DeleteChecked(string path) + { + RejectReparsePath(path); + File.Delete(path); + } + + [StructLayout(LayoutKind.Sequential)] + private struct DataBlob { public int Length; public IntPtr Data; } + [DllImport("crypt32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CryptProtectData(ref DataBlob input, string? description, ref DataBlob entropy, + IntPtr reserved, IntPtr prompt, int flags, out DataBlob output); + [DllImport("crypt32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CryptUnprotectData(ref DataBlob input, IntPtr description, ref DataBlob entropy, + IntPtr reserved, IntPtr prompt, int flags, out DataBlob output); + [DllImport("kernel32.dll")] + private static extern IntPtr LocalFree(IntPtr memory); + + private static byte[] Dpapi(byte[] bytes, bool protect) + { + var input = new DataBlob { Length = bytes.Length, Data = Marshal.AllocHGlobal(bytes.Length) }; + var entropy = new DataBlob { Length = Entropy.Length, Data = Marshal.AllocHGlobal(Entropy.Length) }; + var output = new DataBlob(); + try + { + Marshal.Copy(bytes, 0, input.Data, bytes.Length); + Marshal.Copy(Entropy, 0, entropy.Data, Entropy.Length); + // CRYPTPROTECT_UI_FORBIDDEN; omitting LOCAL_MACHINE binds to CurrentUser. + var success = protect + ? CryptProtectData(ref input, null, ref entropy, IntPtr.Zero, IntPtr.Zero, 1, out output) + : CryptUnprotectData(ref input, IntPtr.Zero, ref entropy, IntPtr.Zero, IntPtr.Zero, 1, out output); + if (!success) throw new Win32Exception(Marshal.GetLastWin32Error(), "Windows 계정 암호화 작업에 실패했습니다."); + var result = new byte[output.Length]; + Marshal.Copy(output.Data, result, 0, result.Length); + return result; + } + finally + { + ZeroUnmanaged(input.Data, input.Length); + Marshal.FreeHGlobal(input.Data); + Marshal.FreeHGlobal(entropy.Data); + if (output.Data != IntPtr.Zero) + { + ZeroUnmanaged(output.Data, output.Length); + LocalFree(output.Data); + } + } + } + private static void ZeroUnmanaged(IntPtr pointer, int length) + { + var zeros = new byte[Math.Min(length, 4096)]; + for (var offset = 0; offset < length; offset += zeros.Length) + Marshal.Copy(zeros, 0, pointer + offset, Math.Min(zeros.Length, length - offset)); + } + + internal sealed class Vault + { + public int Version { get; set; } = 1; + public List Accounts { get; set; } = new(); + } + internal sealed class Entry + { + public string Id { get; set; } = ""; + public string Key { get; set; } = ""; + public string Label { get; set; } = ""; + public string Hint { get; set; } = ""; + public byte[] Auth { get; set; } = Array.Empty(); + public UsageSnapshot? Usage { get; set; } + public DateTimeOffset? ObservedAt { get; set; } + } + internal sealed record SwitchJournal(int Version, string SourceKey, string TargetKey, byte[] BeforeAuth, + byte[] AfterAuth, string BeforeDigest, string AfterDigest, Vault Before, Vault After); +} diff --git a/src/Program.cs b/src/Program.cs index 3ebe9c6..09a5bf9 100644 --- a/src/Program.cs +++ b/src/Program.cs @@ -12,6 +12,7 @@ internal static class Program [STAThread] private static int Main(string[] args) { + var openAccounts = args.Any(argument => argument.Equals("--accounts", StringComparison.OrdinalIgnoreCase)); if (args.Any(argument => argument.Equals("--supervise", StringComparison.OrdinalIgnoreCase))) return WidgetSupervisor.Run(); @@ -19,13 +20,21 @@ private static int Main(string[] args) initiallyOwned: true, name: @"Local\CodexWeeklyUsageIndicator", createdNew: out var isFirstInstance); - if (!isFirstInstance) return 0; + if (!isFirstInstance) + { + if (openAccounts) + { + using var signal = new EventWaitHandle(false, EventResetMode.AutoReset, @"Local\CodexWeeklyUsageIndicator.OpenAccounts"); + signal.Set(); + } + return 0; + } var previewMode = args.Any(argument => argument.Equals("--preview", StringComparison.OrdinalIgnoreCase)); ApplicationConfiguration.Initialize(); - Application.Run(new UsageIndicatorForm(previewMode)); + Application.Run(new UsageIndicatorForm(previewMode, openAccounts)); GC.KeepAlive(singleInstance); return 0; } @@ -72,10 +81,18 @@ internal sealed class UsageIndicatorForm : Form private bool _keepOnTop = true; private bool _showClaude; private bool _positionInitialized; + private readonly CodexAccountStore _accountStore = new(); + private AccountManagerForm? _accountManager; + private NotifyIcon? _accountTray; + private bool _accountBusy; + private long _accountGeneration; + private string? _activeAccountLabel; + private string? _helperIdentityKey; + private readonly EventWaitHandle _openAccountsSignal = new(false, EventResetMode.AutoReset, @"Local\CodexWeeklyUsageIndicator.OpenAccounts"); private Point _dragCursorStart; private Point _dragFormStart; - public UsageIndicatorForm(bool previewMode) + public UsageIndicatorForm(bool previewMode, bool openAccounts = false) { _previewMode = previewMode; _showClaude = IndicatorSettingsStore.LoadShowClaude(); @@ -96,11 +113,17 @@ public UsageIndicatorForm(bool previewMode) Opacity = 0; BuildContextMenu(); + _accountTray = new NotifyIcon { Icon = SystemIcons.Application, Text = "Codex 사용량 · 계정 관리", ContextMenuStrip = _contextMenu, Visible = !previewMode }; + _accountTray.DoubleClick += (_, _) => ShowAccountManager(); ApplyRoundedRegion(); _toolTip.SetToolTip(this, "Codex 및 Claude 사용량을 불러오는 중…"); _pollTimer.Tick += async (_, _) => await RefreshUsageAsync(); - _codexStateTimer.Tick += (_, _) => SyncCodexVisibility(); + _codexStateTimer.Tick += (_, _) => + { + if (_openAccountsSignal.WaitOne(0)) ShowAccountManager(); + SyncCodexVisibility(); + }; Shown += (_, _) => { if (_previewMode) @@ -112,8 +135,15 @@ public UsageIndicatorForm(bool previewMode) return; } + if (_accountStore.HasPendingRecovery) + { + _accountBusy = true; + ShowAccountManager(); + } + RefreshAccountLabel(); SyncCodexVisibility(); _codexStateTimer.Start(); + if (openAccounts) ShowAccountManager(); }; MouseEnter += (_, _) => { _isHovered = true; Invalidate(); }; @@ -123,8 +153,14 @@ public UsageIndicatorForm(bool previewMode) MouseUp += HandleMouseUp; DoubleClick += async (_, _) => await RefreshUsageAsync(force: true); Resize += (_, _) => ApplyRoundedRegion(); - FormClosing += (_, _) => + FormClosing += (_, e) => { + if (_accountManager is { IsOperationInProgress: true }) + { + e.Cancel = true; + _accountManager.Activate(); + return; + } if (_positionInitialized) IndicatorSettingsStore.SavePosition(Location); _pollTimer.Stop(); @@ -134,6 +170,9 @@ public UsageIndicatorForm(bool previewMode) _toolTip.Dispose(); _contextMenu.Dispose(); _valueFont.Dispose(); + _accountTray?.Dispose(); + _openAccountsSignal.Dispose(); + _accountManager?.Close(); }; } @@ -152,6 +191,8 @@ protected override CreateParams CreateParams private void BuildContextMenu() { + var accountsItem = new ToolStripMenuItem("Codex 계정 관리…"); + accountsItem.Click += (_, _) => ShowAccountManager(); var refreshItem = new ToolStripMenuItem("새로고침"); refreshItem.Click += async (_, _) => await RefreshUsageAsync(force: true); @@ -194,6 +235,7 @@ private void BuildContextMenu() showClaudeItem, topMostItem, copyItem, + accountsItem, new ToolStripSeparator(), exitItem ]); @@ -258,6 +300,7 @@ private void ReassertTopMost() private void SyncCodexVisibility() { + if (_accountBusy || _accountStore.HasPendingRecovery) return; var codexIsRunning = _codexStateReader.IsRunning(); if (!codexIsRunning) { @@ -276,6 +319,7 @@ private void SyncCodexVisibility() if (!_codexWasRunning) { _codexWasRunning = true; + _codexClient.Resume(); EnsurePositionInitialized(); _ = RefreshUsageAsync(force: true); _pollTimer.Start(); @@ -299,11 +343,13 @@ private void SyncCodexVisibility() private async Task RefreshUsageAsync(bool force = false) { + if (_accountBusy || _accountStore.HasPendingRecovery) return; if (!_previewMode && !_codexWasRunning) return; if (_isRefreshing && !force) return; if (_isRefreshing) return; _isRefreshing = true; + var refreshGeneration = _accountGeneration; Invalidate(); try @@ -326,6 +372,8 @@ private async Task RefreshUsageAsync(bool force = false) { _isRefreshing = false; Invalidate(); + if (refreshGeneration != _accountGeneration && !_accountBusy && !IsDisposed) + _ = RefreshUsageAsync(force: true); } } @@ -341,18 +389,76 @@ private void UpdateToolTip() private async Task RefreshCodexAsync() { + var generation = _accountGeneration; try { using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15)); - _codexSnapshot = await _codexClient.GetWeeklyUsageAsync(timeout.Token); + var identity = _accountStore.IsEnabled ? _accountStore.GetCurrentIdentity().Key : null; + if (identity is not null && identity != _helperIdentityKey) + { + await _codexClient.SuspendAsync(); + if (generation != _accountGeneration || _accountBusy) return; + _codexClient.Resume(); + _helperIdentityKey = identity; + _codexSnapshot = null; + } + UsageSnapshot snapshot; + if (identity is null) snapshot = await _codexClient.GetWeeklyUsageAsync(timeout.Token); + else + { + var observed = await _codexClient.GetWeeklyUsageWithAccountAsync(timeout.Token); + if (!observed.IsChatGpt) throw new InvalidOperationException("Codex 조회 프로세스의 ChatGPT 로그인을 확인할 수 없습니다."); + snapshot = observed.Usage; + } + if (generation != _accountGeneration || _accountBusy) return; + if (identity is not null && identity != _accountStore.GetCurrentIdentity().Key) return; + _codexSnapshot = snapshot; _codexError = null; + if (identity is not null && snapshot is not null) _accountStore.SaveUsage(identity, snapshot); + RefreshAccountLabel(); } catch (Exception ex) { + if (generation != _accountGeneration || _accountBusy) return; _codexError = FriendlyCodexError(ex); } } + private void ShowAccountManager() + { + if (_previewMode) return; + if (_accountManager is null || _accountManager.IsDisposed) + _accountManager = new AccountManagerForm(_accountStore, SuspendAccountsAsync, ResumeAccounts); + _accountManager.Show(); + _accountManager.Activate(); + } + + private async Task SuspendAccountsAsync() + { + _accountBusy = true; + _accountGeneration++; + _pollTimer.Stop(); + await _codexClient.SuspendAsync(); + } + + private void ResumeAccounts() + { + _accountBusy = _accountStore.HasPendingRecovery; + _codexSnapshot = null; + _helperIdentityKey = null; + _codexError = null; + _codexWasRunning = false; + RefreshAccountLabel(); + if (!_accountBusy) SyncCodexVisibility(); + Invalidate(); + } + + private void RefreshAccountLabel() + { + try { _activeAccountLabel = _accountStore.IsEnabled ? _accountStore.ListAccounts().FirstOrDefault(a => a.IsActive)?.Label : null; } + catch { _activeAccountLabel = "계정 확인 필요"; } + } + private async Task RefreshClaudeAsync() { try @@ -418,6 +524,12 @@ protected override void OnPaint(PaintEventArgs e) { DrawProvider(graphics, new RectangleF(0, 0, Width, Height), null, codexAccent); } + if (_activeAccountLabel is not null && (codexAvailable || !claudeAvailable)) + { + var labelWidth = claudeAvailable || (_isRefreshing && _showClaude) ? Width / 2 : Width; + TextRenderer.DrawText(graphics, _activeAccountLabel, Font, new Rectangle(8, 29, labelWidth - 16, 14), + Color.FromArgb(165, 175, 200), TextFormatFlags.HorizontalCenter | TextFormatFlags.EndEllipsis | TextFormatFlags.NoPrefix); + } } private void DrawDualProviders(Graphics graphics, double? codexUsed, double? claudeUsed) @@ -945,387 +1057,3 @@ public bool IsRunning() return false; } } - -internal sealed class AppServerClient : IDisposable -{ - private readonly ConcurrentDictionary> _pending = new(); - private readonly SemaphoreSlim _startGate = new(1, 1); - private readonly SemaphoreSlim _writeGate = new(1, 1); - private readonly CancellationTokenSource _lifetime = new(); - - private Process? _process; - private StreamWriter? _input; - private Task? _readerTask; - private int _nextRequestId; - private bool _initialized; - private bool _disposed; - - public async Task GetWeeklyUsageAsync(CancellationToken cancellationToken) - { - await EnsureStartedAsync(cancellationToken); - var result = await CallCoreAsync("account/rateLimits/read", new { }, cancellationToken); - return ParseWeeklyUsage(result); - } - - private async Task EnsureStartedAsync(CancellationToken cancellationToken) - { - if (_initialized && _process is { HasExited: false }) return; - - await _startGate.WaitAsync(cancellationToken); - try - { - if (_initialized && _process is { HasExited: false }) return; - StopProcess(); - - var codexPath = LocateCodexExecutable() - ?? throw new FileNotFoundException("codex.exe was not found. Install or open Codex Desktop first."); - - var startInfo = new ProcessStartInfo - { - FileName = codexPath, - Arguments = "app-server --stdio", - UseShellExecute = false, - CreateNoWindow = true, - RedirectStandardInput = true, - RedirectStandardOutput = true, - RedirectStandardError = true, - WorkingDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) - }; - - _process = new Process { StartInfo = startInfo, EnableRaisingEvents = true }; - if (!_process.Start()) - throw new InvalidOperationException("Codex app-server could not be started."); - - _input = _process.StandardInput; - _input.AutoFlush = true; - _readerTask = ReadLoopAsync(_process, _lifetime.Token); - _ = DrainErrorAsync(_process, _lifetime.Token); - - await CallCoreAsync( - "initialize", - new - { - clientInfo = new - { - name = "weekly-usage-indicator", - title = "Weekly Usage Indicator", - version = "1.1.1" - }, - capabilities = new { experimentalApi = true } - }, - cancellationToken, - requireInitialized: false); - - await SendNotificationAsync("initialized", cancellationToken); - _initialized = true; - } - catch - { - StopProcess(); - throw; - } - finally - { - _startGate.Release(); - } - } - - private async Task CallCoreAsync( - string method, - object parameters, - CancellationToken cancellationToken, - bool requireInitialized = true) - { - if (requireInitialized && !_initialized) - throw new InvalidOperationException("Codex app-server is not initialized."); - - var id = Interlocked.Increment(ref _nextRequestId); - var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - if (!_pending.TryAdd(id, completion)) - throw new InvalidOperationException("Could not register a Codex request."); - - try - { - await SendLineAsync(JsonSerializer.Serialize(new { id, method, @params = parameters }), cancellationToken); - return await completion.Task.WaitAsync(cancellationToken); - } - finally - { - _pending.TryRemove(id, out _); - } - } - - private Task SendNotificationAsync(string method, CancellationToken cancellationToken) => - SendLineAsync(JsonSerializer.Serialize(new { method }), cancellationToken); - - private async Task SendLineAsync(string line, CancellationToken cancellationToken) - { - await _writeGate.WaitAsync(cancellationToken); - try - { - var writer = _input ?? throw new IOException("Codex app-server input is unavailable."); - await writer.WriteLineAsync(line.AsMemory(), cancellationToken); - await writer.FlushAsync(cancellationToken); - } - finally - { - _writeGate.Release(); - } - } - - private async Task ReadLoopAsync(Process process, CancellationToken cancellationToken) - { - Exception? terminalError = null; - try - { - while (!cancellationToken.IsCancellationRequested && !process.HasExited) - { - var line = await process.StandardOutput.ReadLineAsync(cancellationToken); - if (line is null) break; - if (string.IsNullOrWhiteSpace(line)) continue; - - try - { - using var document = JsonDocument.Parse(line); - var root = document.RootElement; - if (!root.TryGetProperty("id", out var idElement) || - idElement.ValueKind != JsonValueKind.Number || - !idElement.TryGetInt32(out var id) || - !_pending.TryGetValue(id, out var completion)) - { - continue; - } - - if (root.TryGetProperty("error", out var error)) - { - completion.TrySetException(new InvalidOperationException(error.ToString())); - } - else if (root.TryGetProperty("result", out var result)) - { - completion.TrySetResult(result.Clone()); - } - else - { - completion.TrySetException(new InvalidDataException("Codex returned an incomplete response.")); - } - } - catch (JsonException) - { - // App-server stdout is expected to be JSONL. Ignore unrelated diagnostic lines. - } - } - - if (!cancellationToken.IsCancellationRequested) - terminalError = new IOException("Codex app-server disconnected."); - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - // Normal shutdown. - } - catch (Exception ex) - { - terminalError = ex; - } - finally - { - if (terminalError is not null) - { - _initialized = false; - foreach (var completion in _pending.Values) - completion.TrySetException(terminalError); - } - } - } - - private static async Task DrainErrorAsync(Process process, CancellationToken cancellationToken) - { - try - { - while (!cancellationToken.IsCancellationRequested && !process.HasExited) - { - if (await process.StandardError.ReadLineAsync(cancellationToken) is null) break; - } - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - // Normal shutdown. - } - catch - { - // Diagnostics must never stop usage updates. - } - } - - private static UsageSnapshot ParseWeeklyUsage(JsonElement response) - { - var snapshot = SelectCoreSnapshot(response); - var limitId = snapshot.TryGetProperty("limitId", out var idElement) && idElement.ValueKind == JsonValueKind.String - ? idElement.GetString() ?? "codex" - : "codex"; - - var windows = new List<(int Used, long? Duration, long? ResetsAt, string Name)>(); - AddWindow(snapshot, "primary", windows); - AddWindow(snapshot, "secondary", windows); - - if (windows.Count == 0) - throw new InvalidDataException("Codex did not return a usage window."); - - var weekly = windows - .OrderBy(window => WeeklyDistance(window.Duration)) - .ThenByDescending(window => window.Duration ?? 0) - .First(); - - DateTimeOffset? resetsAt = null; - if (weekly.ResetsAt is > 0) - resetsAt = DateTimeOffset.FromUnixTimeSeconds(weekly.ResetsAt.Value).ToLocalTime(); - - return new UsageSnapshot( - Math.Clamp(weekly.Used, 0, 100), - resetsAt, - weekly.Duration, - limitId); - } - - private static JsonElement SelectCoreSnapshot(JsonElement response) - { - if (response.TryGetProperty("rateLimitsByLimitId", out var byId) && byId.ValueKind == JsonValueKind.Object) - { - if (byId.TryGetProperty("codex", out var codex) && codex.ValueKind == JsonValueKind.Object) - return codex; - - foreach (var property in byId.EnumerateObject()) - { - if (property.Value.ValueKind != JsonValueKind.Object) continue; - if (!property.Value.TryGetProperty("limitName", out var name) || name.ValueKind == JsonValueKind.Null) - return property.Value; - } - } - - if (response.TryGetProperty("rateLimits", out var legacy) && legacy.ValueKind == JsonValueKind.Object) - return legacy; - - throw new InvalidDataException("Codex did not return rate-limit data."); - } - - private static void AddWindow( - JsonElement snapshot, - string propertyName, - ICollection<(int Used, long? Duration, long? ResetsAt, string Name)> windows) - { - if (!snapshot.TryGetProperty(propertyName, out var window) || window.ValueKind != JsonValueKind.Object) - return; - if (!window.TryGetProperty("usedPercent", out var usedElement) || !usedElement.TryGetInt32(out var used)) - return; - - long? duration = null; - if (window.TryGetProperty("windowDurationMins", out var durationElement) && - durationElement.ValueKind == JsonValueKind.Number && - durationElement.TryGetInt64(out var durationValue)) - { - duration = durationValue; - } - - long? resetsAt = null; - if (window.TryGetProperty("resetsAt", out var resetElement) && - resetElement.ValueKind == JsonValueKind.Number && - resetElement.TryGetInt64(out var resetValue)) - { - resetsAt = resetValue; - } - - windows.Add((used, duration, resetsAt, propertyName)); - } - - private static long WeeklyDistance(long? durationMinutes) - { - const long weekMinutes = 7 * 24 * 60; - return durationMinutes is null - ? long.MaxValue / 2 - : Math.Abs(durationMinutes.Value - weekMinutes); - } - - private static string? LocateCodexExecutable() - { - var configured = Environment.GetEnvironmentVariable("CODEX_WEEKLY_INDICATOR_CODEX_PATH"); - if (!string.IsNullOrWhiteSpace(configured) && File.Exists(configured)) - return configured; - - var localBin = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - "OpenAI", - "Codex", - "bin"); - - try - { - if (Directory.Exists(localBin)) - { - var localCodex = Directory - .EnumerateFiles(localBin, "codex.exe", SearchOption.AllDirectories) - .Select(path => new FileInfo(path)) - .OrderByDescending(file => file.LastWriteTimeUtc) - .FirstOrDefault(); - if (localCodex is not null) return localCodex.FullName; - } - } - catch - { - // Continue to PATH lookup. - } - - var pathValue = Environment.GetEnvironmentVariable("PATH") ?? string.Empty; - foreach (var directory in pathValue.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)) - { - try - { - var candidate = Path.Combine(directory.Trim().Trim('"'), "codex.exe"); - if (File.Exists(candidate)) return candidate; - } - catch - { - // Ignore malformed PATH entries. - } - } - - return null; - } - - private void StopProcess() - { - _initialized = false; - try { _input?.Close(); } catch { } - _input = null; - - if (_process is not null) - { - try - { - if (!_process.HasExited) _process.Kill(entireProcessTree: true); - } - catch { } - _process.Dispose(); - _process = null; - } - - foreach (var completion in _pending.Values) - completion.TrySetException(new IOException("Codex app-server was restarted.")); - _pending.Clear(); - } - - public void Pause() - { - if (_disposed) return; - StopProcess(); - } - - public void Dispose() - { - if (_disposed) return; - _disposed = true; - _lifetime.Cancel(); - StopProcess(); - _lifetime.Dispose(); - _startGate.Dispose(); - _writeGate.Dispose(); - } -} diff --git a/src/WeeklyUsageIndicator.csproj b/src/WeeklyUsageIndicator.csproj index f6aa3a2..e606bda 100644 --- a/src/WeeklyUsageIndicator.csproj +++ b/src/WeeklyUsageIndicator.csproj @@ -9,7 +9,7 @@ WeeklyUsageIndicator WeeklyUsageIndicator app.manifest - 1.3.4 + 1.4.0 true none false diff --git a/tests/WeeklyUsageIndicator.Tests/AccountRuntimeTests.cs b/tests/WeeklyUsageIndicator.Tests/AccountRuntimeTests.cs new file mode 100644 index 0000000..3009021 --- /dev/null +++ b/tests/WeeklyUsageIndicator.Tests/AccountRuntimeTests.cs @@ -0,0 +1,171 @@ +using System.Diagnostics; +using System.Security.AccessControl; +using System.Security.Principal; +using WeeklyUsageIndicator; + +internal static class AccountRuntimeTests +{ + internal static async Task RunAsync() + { + var stage = Path.Combine(Path.GetTempPath(), "isolated login fixture"); + var start = CodexAccountRuntime.CreateLoginStartInfo("fixture-codex.exe", stage); + Check(!start.UseShellExecute && start.CreateNoWindow, "login must have no shell or visible console"); + Check(start.RedirectStandardInput && start.RedirectStandardOutput && start.RedirectStandardError, + "login diagnostics must not reach parent console"); + Check(start.WorkingDirectory == stage && start.Environment["CODEX_HOME"] == stage, + "login must use only the isolated home"); + Check(start.ArgumentList.SequenceEqual(new[] { "login", "-c", "cli_auth_credentials_store=\"file\"" }), + "login must use official browser login and file storage, never token arguments"); + Check(!start.Environment.Keys.Any(key => + (key.StartsWith("CODEX_", StringComparison.OrdinalIgnoreCase) && key != "CODEX_HOME") + || key.StartsWith("OPENAI_", StringComparison.OrdinalIgnoreCase) + || key.StartsWith("CHATGPT_", StringComparison.OrdinalIgnoreCase)), + "inherited auth and endpoint overrides must not reach isolated login"); + Check(start.Environment["RUST_LOG"] == "off", "verbose Rust tracing must be disabled"); + + var packagePath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), + "WindowsApps", "OpenAI.Codex_1.2.3.0_x64__2p2nqsd0c76g0", "app", "ChatGPT.exe"); + Check(CodexAccountRuntime.IsPackagedDesktopPath(packagePath), "expected Windows package shape accepted"); + Check(!CodexAccountRuntime.IsPackagedDesktopPath(packagePath.Replace("2p2nqsd0c76g0", "otherpublisher")), + "unverified package publisher rejected"); + Check(!CodexAccountRuntime.IsPackagedDesktopPath(Path.Combine(stage, "WindowsApps", "OpenAI.Codex_1__2p2nqsd0c76g0", "app", "ChatGPT.exe")), + "lookalike directory outside Program Files rejected"); + Check(!CodexAccountRuntime.IsPackagedDesktopPath("ChatGPT.exe"), "relative desktop path rejected"); + try { CodexAccountRuntime.LaunchDesktop(null); throw new Exception("invalid launch unexpectedly succeeded"); } + catch (InvalidOperationException) { } + + var testBase = Environment.GetEnvironmentVariable("GFS_ACCOUNT_TEST_ROOT") + ?? Path.Combine(Path.GetTempPath(), "gfs-agent", "260909_codex-account-switch", "tests"); + var root = Path.Combine(testBase, "runtime-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + try + { + string created; + using (var result = CodexAccountRuntime.CreateLoginStaging(root)) + { + created = result.DirectoryPath; + Check(Path.GetDirectoryName(created) == root, "staging remains inside vault root"); + Check(result.AuthPath == Path.Combine(created, "auth.json"), "import path is isolated"); + var acl = new DirectoryInfo(created).GetAccessControl(); + Check(acl.AreAccessRulesProtected, "staging must not inherit broad parent permissions"); + var user = WindowsIdentity.GetCurrent().User!; + var system = new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null); + foreach (FileSystemAccessRule rule in acl.GetAccessRules(true, true, typeof(SecurityIdentifier))) + Check(rule.AccessControlType != AccessControlType.Allow + || rule.IdentityReference.Equals(user) || rule.IdentityReference.Equals(system), + "only current user and SYSTEM receive access"); + File.WriteAllText(result.AuthPath, "synthetic fixture, never a credential"); + var nested = Directory.CreateDirectory(Path.Combine(created, "log")); + File.WriteAllText(Path.Combine(nested.FullName, "test.log"), "synthetic"); + } + Check(!Directory.Exists(created), "dispose removes login output and nested diagnostics"); + var foreign = Directory.CreateDirectory(Path.Combine(root, "unrelated")); + using var invalid = new CodexLoginResult(root, foreign.FullName); + try { invalid.Dispose(); throw new Exception("unrelated cleanup unexpectedly succeeded"); } + catch (InvalidOperationException) { } + Check(Directory.Exists(foreign.FullName), "cleanup must preserve unrelated directories"); + // Avoid a second deliberate Dispose exception from the invalid fixture. + Directory.Delete(foreign.FullName); + } + finally { Directory.Delete(root, recursive: false); } + await TestOwnedJobAsync(); + await TestBrowserDescendantSurvivesAsync(testBase); + } + + private static async Task TestOwnedJobAsync() + { + var executable = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), + "WindowsPowerShell", "v1.0", "powershell.exe"); + var start = new ProcessStartInfo(executable) { UseShellExecute = false, CreateNoWindow = true }; + foreach (var argument in new[] { "-NoProfile", "-NonInteractive", "-Command", "Start-Sleep -Seconds 30" }) + start.ArgumentList.Add(argument); + using var owned = new Process { StartInfo = start }; + using var unrelated = new Process { StartInfo = start }; + using var job = new CodexLoginJob(); + try + { + Check(owned.Start() && unrelated.Start(), "fake sleepers must start"); + job.Attach(owned); + Check(!owned.HasExited && !unrelated.HasExited, "both fixture children are alive before disposal"); + job.Dispose(); + await owned.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10)); + Check(owned.HasExited, "closing job terminates owned login child"); + Check(!unrelated.HasExited, "closing job leaves an unrelated process running"); + } + finally + { + foreach (var process in new[] { owned, unrelated }) + { + try + { + if (!process.HasExited) process.Kill(entireProcessTree: true); + await process.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10)); + } + catch (InvalidOperationException) { } + } + } + } + + private static void Check(bool condition, string message) + { + if (!condition) throw new Exception(message); + } + + private static async Task TestBrowserDescendantSurvivesAsync(string testBase) + { + var root = Path.Combine(testBase, "job-descendant-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + var trigger = Path.Combine(root, "launch-child"); + var pidFile = Path.Combine(root, "child-pid"); + var script = Path.Combine(root, "parent.ps1"); + File.WriteAllText(script, """ + param([string] $TriggerPath, [string] $PidPath) + while (-not (Test-Path -LiteralPath $TriggerPath)) { Start-Sleep -Milliseconds 30 } + $child = Start-Process -FilePath (Join-Path $PSHOME 'powershell.exe') -ArgumentList '-NoProfile', '-NonInteractive', '-Command', 'Start-Sleep -Seconds 30' -WindowStyle Hidden -PassThru + [System.IO.File]::WriteAllText($PidPath, [string]$child.Id) + Start-Sleep -Seconds 30 + """); + var executable = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), + "WindowsPowerShell", "v1.0", "powershell.exe"); + var start = new ProcessStartInfo(executable) { UseShellExecute = false, CreateNoWindow = true }; + foreach (var argument in new[] { "-NoProfile", "-NonInteractive", "-File", script, trigger, pidFile }) + start.ArgumentList.Add(argument); + using var parent = new Process { StartInfo = start }; + using var job = new CodexLoginJob(); + Process? browserStandIn = null; + try + { + Check(parent.Start(), "fake login parent must start"); + job.Attach(parent); + // Parent cannot create its child until after it belongs to this job. + File.WriteAllText(trigger, "go"); + var deadline = Stopwatch.StartNew(); + while ((!File.Exists(pidFile) || new FileInfo(pidFile).Length == 0) + && deadline.Elapsed < TimeSpan.FromSeconds(10)) await Task.Delay(50); + Check(File.Exists(pidFile), "fake login must create a browser descendant"); + var childId = int.Parse(File.ReadAllText(pidFile)); + browserStandIn = Process.GetProcessById(childId); + Check(!browserStandIn.HasExited, "browser descendant is alive before job closes"); + job.Dispose(); + await parent.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10)); + Check(parent.HasExited, "closing job terminates fake login parent"); + Check(!browserStandIn.HasExited, "browser descendant breaks away and survives login termination"); + } + finally + { + foreach (var process in new[] { parent, browserStandIn }) + { + if (process is null) continue; + try + { + if (!process.HasExited) process.Kill(entireProcessTree: true); + await process.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10)); + } + catch (InvalidOperationException) { } + } + browserStandIn?.Dispose(); + foreach (var path in new[] { trigger, pidFile, script }) File.Delete(path); + Directory.Delete(root, recursive: false); + } + } +} diff --git a/tests/WeeklyUsageIndicator.Tests/AccountStoreTests.cs b/tests/WeeklyUsageIndicator.Tests/AccountStoreTests.cs new file mode 100644 index 0000000..5e39128 --- /dev/null +++ b/tests/WeeklyUsageIndicator.Tests/AccountStoreTests.cs @@ -0,0 +1,374 @@ +using System.Diagnostics; +using System.Security.AccessControl; +using System.Security.Principal; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using WeeklyUsageIndicator; + +internal static class AccountStoreTests +{ + public static async Task RunAsync() + { + using var fixture = new Fixture(); + TestOpaqueRoundTripAndRotation(fixture); + TestIdentityAndValidation(fixture); + TestUsageBindingAndRemoval(fixture); + TestCrashRecovery(fixture); + TestUnknownIdentityRecovery(fixture); + TestPreSwapRace(fixture); + TestDurabilityFailure(fixture); + TestCorruptionAndBounds(fixture); + TestPrivateAcl(fixture); + TestReparsePaths(fixture); + await TestConcurrentLockAsync(fixture); + } + + private static void TestOpaqueRoundTripAndRotation(Fixture fixture) + { + var (store, home) = fixture.NewStore(); + Assert(!store.IsEnabled && !Directory.Exists(store.RootPath), "construction and empty list do not opt in"); + Assert(store.ListAccounts().Count == 0 && !Directory.Exists(store.RootPath), "empty listing has no side effects"); + var aOriginal = Auth("user-a", "workspace-a", "original-a"); + WriteAuth(home, aOriginal); + var a = store.RegisterCurrent("Pro A"); + Assert(store.IsEnabled && a.IsActive, "registration opts in and identifies current account"); + var bBytes = Auth("user-b", "workspace-b", "original-b"); + var b = fixture.Import(store, bBytes, "Pro B"); + var aRotated = Auth("user-a", "workspace-a", "rotated-a"); + WriteAuth(home, aRotated); + var stopChecks = 0; + store.SwitchTo(b.Id, () => stopChecks++); + Assert(stopChecks >= 2, "stopped writers are checked before capture and before swap"); + Equal(ReadAuth(home), bBytes, "target auth preserves every opaque byte"); + store.SwitchTo(a.Id, () => { }); + Equal(ReadAuth(home), aRotated, "switching back uses latest source rotation, not registration snapshot"); + Assert(!store.HasPendingRecovery, "successful round trip closes journal"); + Assert(store.ListAccounts().Count(a2 => a2.IsActive) == 1, "exactly one live identity is active"); + AssertThrows(() => fixture.Import(store, aOriginal, "stale active"), "isolated login cannot overwrite live account tokens"); + } + + private static void TestIdentityAndValidation(Fixture fixture) + { + var a = CodexAccountStore.ParseIdentity(Auth("same-user", "workspace-a", "a")); + var b = CodexAccountStore.ParseIdentity(Auth("same-user", "workspace-b", "b")); + var c = CodexAccountStore.ParseIdentity(Auth("other-user", "workspace-a", "c")); + Assert(a.Key != b.Key && a.Key != c.Key, "identity includes both user and workspace"); + Assert(a.Hint.StartsWith("s***") && !a.Hint.Contains("@"), "hint masks identity"); + AssertThrows(() => CodexAccountStore.ParseIdentity(Encoding.UTF8.GetBytes("{}")), "empty auth rejected"); + var external = Encoding.UTF8.GetString(Auth("a", "b", "x")).Replace("\"chatgpt\"", "\"chatgptAuthTokens\""); + AssertThrows(() => CodexAccountStore.ParseIdentity(Encoding.UTF8.GetBytes(external)), "external token mode rejected"); + var duplicate = Encoding.UTF8.GetString(Auth("a", "b", "x")).Replace("\"auth_mode\":", "\"auth_mode\":\"apikey\",\"auth_mode\":"); + AssertThrows(() => CodexAccountStore.ParseIdentity(Encoding.UTF8.GetBytes(duplicate)), "ambiguous duplicate keys rejected"); + var mismatch = Encoding.UTF8.GetString(Auth("a", "b", "x")).Replace("\"account_id\":\"b\"", "\"account_id\":\"other\""); + AssertThrows(() => CodexAccountStore.ParseIdentity(Encoding.UTF8.GetBytes(mismatch)), "workspace mismatch rejected"); + foreach (var legacyMode in new[] { "missing", "null" }) + { + var legacy = JsonNode.Parse(Auth("a", "b", "legacy"))!.AsObject(); + if (legacyMode == "missing") legacy.Remove("auth_mode"); + else legacy["auth_mode"] = null; + var legacyBytes = JsonSerializer.SerializeToUtf8Bytes(legacy); + Assert(CodexAccountStore.ParseIdentity(legacyBytes).Key == + CodexAccountStore.ParseIdentity(Auth("a", "b", "modern")).Key, + "official legacy managed ChatGPT mode accepted: " + legacyMode); + foreach (var conflict in new[] { "OPENAI_API_KEY", "personal_access_token", "agent_identity", "bedrock_api_key", "bedrock_access_keys" }) + { + var mixed = JsonNode.Parse(legacyBytes)!.AsObject(); + mixed[conflict] = "synthetic-conflicting-credential"; + AssertThrows(() => CodexAccountStore.ParseIdentity(JsonSerializer.SerializeToUtf8Bytes(mixed)), + "legacy mode with competing credentials rejected: " + conflict); + } + } + var badMode = JsonNode.Parse(Auth("a", "b", "bad-mode"))!.AsObject(); + badMode["auth_mode"] = 42; + AssertThrows(() => CodexAccountStore.ParseIdentity(JsonSerializer.SerializeToUtf8Bytes(badMode)), "wrong type mode rejected"); + var (store, home) = fixture.NewStore(); + WriteAuth(home, Auth("a", "b", "x")); + File.WriteAllText(Path.Combine(home, "config.toml"), "cli_auth_credentials_store = \"keyring\"\n"); + AssertThrows(() => store.RegisterCurrent("A"), "unsupported keyring config rejected without mutation"); + Assert(!store.IsEnabled, "failed capability check leaves feature disabled"); + File.WriteAllText(Path.Combine(home, "config.toml"), "cli_auth_credentials_store = 'file' # explicit\n"); + store.RegisterCurrent("A"); + File.WriteAllText(Path.Combine(home, "config.toml"), "forced_chatgpt_workspace_id = \"locked\"\n"); + AssertThrows(() => store.GetCurrentIdentity(), "workspace policy fails closed"); + } + + private static void TestUsageBindingAndRemoval(Fixture fixture) + { + var (store, home) = fixture.NewStore(); + WriteAuth(home, Auth("user-a", "workspace", "a")); + var a = store.RegisterCurrent("A"); + var keyA = store.GetCurrentIdentity().Key; + var b = fixture.Import(store, Auth("user-b", "workspace", "b"), "B"); + var usage = new UsageSnapshot(31, DateTimeOffset.UtcNow.AddDays(4), 10080, "codex"); + store.SaveUsage(keyA, usage); + Assert(store.ListAccounts().Single(e => e.Id == a.Id).Usage == usage, "usage saved against active identity"); + AssertThrows(() => store.Remove(a.Id), "active credential cannot be deleted"); + store.SwitchTo(b.Id, () => { }); + store.SaveUsage(keyA, usage with { UsedPercent = 99 }); + Assert(store.ListAccounts().Single(e => e.Id == a.Id).Usage?.UsedPercent == 31, + "late A usage response cannot become B or alter inactive observation"); + Assert(store.ListAccounts().Single(e => e.Id == b.Id).Usage is null, "target never inherits old account usage"); + store.Remove(a.Id); + Assert(store.ListAccounts().Count == 1, "inactive credential can be removed"); + } + + private static void TestCrashRecovery(Fixture fixture) + { + foreach (var phase in new[] { "journal-written", "source-saved", "auth.json-temp-flushed", "auth-replaced", "vault-committed" }) + { + var (store, home) = fixture.NewStore(); + WriteAuth(home, Auth("a", "workspace", "a-original")); + var a = store.RegisterCurrent("A"); + var b = fixture.Import(store, Auth("b", "workspace", "b-original"), "B"); + store.Checkpoint = point => { if (point == phase) throw new SimulatedCrash(); }; + AssertThrows(() => store.SwitchTo(b.Id, () => { }), "injected transaction crash propagates"); + Assert(store.HasPendingRecovery, "durable journal survives " + phase); + var landedOnTarget = phase is "auth-replaced" or "vault-committed"; + var refreshed = Auth(landedOnTarget ? "b" : "a", "workspace", "post-crash-rotation"); + WriteAuth(home, refreshed); + var reopened = new CodexAccountStore(store.RootPath, home); + // Simulate the only plaintext temp a process-kill could leave. Journal recovery must remove it. + File.WriteAllBytes(Path.Combine(home, ".gfs-account-auth.tmp"), Auth("b", "workspace", "orphan")); + reopened.Recover(() => { }); + Equal(ReadAuth(home), refreshed, "recovery never replaces newer live auth at " + phase); + Assert(!reopened.HasPendingRecovery && !File.Exists(Path.Combine(home, ".gfs-account-auth.tmp")), + "recovery closes journal and removes auth temp at " + phase); + reopened.SwitchTo(landedOnTarget ? a.Id : b.Id, () => { }); + reopened.SwitchTo(landedOnTarget ? b.Id : a.Id, () => { }); + Equal(ReadAuth(home), refreshed, "newer rotated credential was saved at " + phase); + } + } + + private static void TestUnknownIdentityRecovery(Fixture fixture) + { + var (store, home) = fixture.NewStore(); + WriteAuth(home, Auth("a", "workspace", "a")); + store.RegisterCurrent("A"); + var b = fixture.Import(store, Auth("b", "workspace", "b"), "B"); + store.Checkpoint = point => { if (point == "source-saved") throw new SimulatedCrash(); }; + AssertThrows(() => store.SwitchTo(b.Id, () => { }), "create pending journal"); + store.Checkpoint = null; + var third = Auth("third", "workspace", "independent-login"); + WriteAuth(home, third); + AssertThrows(() => store.Recover(() => { }), "third identity fails closed"); + Equal(ReadAuth(home), third, "third identity is untouched"); + Assert(store.HasPendingRecovery, "third identity keeps recoverable journal"); + AssertThrows(() => store.RegisterCurrent("third"), "registration cannot bypass pending recovery"); + File.Delete(Path.Combine(home, "auth.json")); + AssertThrows(() => store.Recover(() => { }), "missing auth fails closed rather than silently reinstating login"); + Assert(!File.Exists(Path.Combine(home, "auth.json")), "missing auth is not invented"); + } + + private static void TestPreSwapRace(Fixture fixture) + { + var (store, home) = fixture.NewStore(); + WriteAuth(home, Auth("a", "workspace", "a")); + store.RegisterCurrent("A"); + var b = fixture.Import(store, Auth("b", "workspace", "b"), "B"); + var latest = Auth("a", "workspace", "raced-refresh"); + var checks = 0; + AssertThrows(() => store.SwitchTo(b.Id, () => + { + if (++checks == 2) WriteAuth(home, latest); + }), "concurrent refresh detected before replacement"); + Equal(ReadAuth(home), latest, "race does not clobber refreshed source"); + store.Recover(() => { }); + Assert(!store.HasPendingRecovery, "race recovers without swapping"); + } + + private static void TestDurabilityFailure(Fixture fixture) + { + foreach (var failure in new[] { "switch.dpapi-temp-flushed", "accounts.dpapi-temp-flushed" }) + { + var (store, home) = fixture.NewStore(); + var original = Auth("a", "workspace", "a"); + WriteAuth(home, original); + store.RegisterCurrent("A"); + var b = fixture.Import(store, Auth("b", "workspace", "b"), "B"); + store.Checkpoint = point => { if (point == failure) throw new IOException("Synthetic disk failure"); }; + AssertThrows(() => store.SwitchTo(b.Id, () => { }), "durable backup failure must stop auth replacement"); + Equal(ReadAuth(home), original, "backup failure leaves auth byte-for-byte intact"); + store.Checkpoint = null; + if (store.HasPendingRecovery) store.Recover(() => { }); + Assert(!Directory.EnumerateFiles(store.RootPath, "*.tmp").Any(), "failed writes clean their own temp files"); + } + } + + private static void TestCorruptionAndBounds(Fixture fixture) + { + var (store, home) = fixture.NewStore(); + var auth = Auth("a", "workspace", "a"); + WriteAuth(home, auth); + store.RegisterCurrent("A"); + var vaultPath = Path.Combine(store.RootPath, "accounts.dpapi"); + var encrypted = File.ReadAllBytes(vaultPath); + Assert(!Encoding.UTF8.GetString(encrypted).Contains("synthetic-refresh"), "inactive vault never contains plaintext tokens"); + encrypted[^1] ^= 0x55; + File.WriteAllBytes(vaultPath, encrypted); + AssertThrows(() => store.ListAccounts(), "DPAPI tampering is rejected"); + Equal(ReadAuth(home), auth, "corrupted vault never alters live auth"); + File.WriteAllBytes(Path.Combine(home, "auth.json"), new byte[1024 * 1024 + 1]); + AssertThrows(() => store.GetCurrentIdentity(), "oversized auth fails bounded read"); + } + + private static void TestPrivateAcl(Fixture fixture) + { + var (store, home) = fixture.NewStore(); + WriteAuth(home, Auth("a", "workspace", "a")); + store.RegisterCurrent("A"); + var b = fixture.Import(store, Auth("b", "workspace", "b"), "B"); + store.Checkpoint = point => + { + if (point == "auth.json-temp-flushed") AssertPrivateFile(Path.Combine(home, ".gfs-account-auth.tmp")); + }; + store.SwitchTo(b.Id, () => { }); + AssertPrivateFile(Path.Combine(home, "auth.json")); + foreach (var file in Directory.EnumerateFiles(store.RootPath)) AssertPrivateFile(file); + var acl = FileSystemAclExtensions.GetAccessControl(new DirectoryInfo(store.RootPath)); + Assert(acl.AreAccessRulesProtected, "vault directory disables inherited broad ACLs"); + AssertOnlyCurrentSid(acl.GetAccessRules(true, true, typeof(SecurityIdentifier))); + } + + private static void AssertPrivateFile(string path) + { + var acl = FileSystemAclExtensions.GetAccessControl(new FileInfo(path)); + Assert(acl.AreAccessRulesProtected, "credential file disables inherited ACLs"); + AssertOnlyCurrentSid(acl.GetAccessRules(true, true, typeof(SecurityIdentifier))); + } + + private static void AssertOnlyCurrentSid(AuthorizationRuleCollection rules) + { + var sid = WindowsIdentity.GetCurrent().User!.Value; + Assert(rules.Count > 0 && rules.Cast().All(rule => + rule.IdentityReference.Value == sid && rule.AccessControlType == AccessControlType.Allow), + "only current Windows user is granted file access"); + } + + private static void TestReparsePaths(Fixture fixture) + { + var (store, home) = fixture.NewStore(); + WriteAuth(home, Auth("a", "workspace", "a")); + var linkedHome = Path.Combine(Path.GetDirectoryName(home)!, "linked-home"); + try { Directory.CreateSymbolicLink(linkedHome, home); } + catch (Exception ex) when (ex is UnauthorizedAccessException || ex is IOException && (ex.HResult & 0xFFFF) == 1314) + { + // Junction creation needs no developer-mode privilege. Inputs are this fixture's + // exact paths and are rejected if they contain any cmd metacharacters. + Assert(!linkedHome.Concat(home).Any(c => "&|<>^%!\"\r\n".Contains(c)), "junction fixture paths are shell-safe"); + using var process = Process.Start(new ProcessStartInfo + { + FileName = "cmd.exe", + Arguments = $"/d /c mklink /J \"{linkedHome}\" \"{home}\"", + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true + }) ?? throw new InvalidOperationException("Could not create synthetic junction fixture"); + process.StandardOutput.ReadToEnd(); + process.StandardError.ReadToEnd(); + process.WaitForExit(); + Assert(process.ExitCode == 0, "synthetic junction creation succeeds"); + } + try + { + var linked = new CodexAccountStore(store.RootPath, linkedHome); + AssertThrows(() => linked.RegisterCurrent("A"), "ancestor reparse path rejected"); + Assert(!store.IsEnabled, "reparse rejection creates no vault"); + } + finally { Directory.Delete(linkedHome); } + } + + private static async Task TestConcurrentLockAsync(Fixture fixture) + { + var (store, home) = fixture.NewStore(); + WriteAuth(home, Auth("a", "workspace", "a")); + store.RegisterCurrent("A"); + using var entered = new ManualResetEventSlim(); + using var release = new ManualResetEventSlim(); + var holder = Task.Run(() => + { + using var mutex = new Mutex(false, CodexAccountStore.TransactionMutexName); + mutex.WaitOne(); + entered.Set(); + release.Wait(); + mutex.ReleaseMutex(); + }); + entered.Wait(); + try { AssertThrows(() => store.RegisterCurrent("A"), "installer/second-process mutex prevents store mutation"); } + finally { release.Set(); await holder; } + } + + private static byte[] Auth(string user, string account, string revision) + { + var claims = JsonSerializer.SerializeToUtf8Bytes(new Dictionary + { + ["sub"] = "subject-" + user, + ["email"] = user + "@example.invalid", + ["https://api.openai.com/auth"] = new Dictionary + { + ["chatgpt_user_id"] = user, + ["chatgpt_account_id"] = account + } + }); + var token = "synthetic-header." + Convert.ToBase64String(claims).TrimEnd('=').Replace('+', '-').Replace('/', '_') + ".synthetic-signature"; + var data = new + { + auth_mode = "chatgpt", + OPENAI_API_KEY = (string?)null, + tokens = new { id_token = token, access_token = "synthetic-access-" + revision, + refresh_token = "synthetic-refresh-" + revision, account_id = account }, + last_refresh = "2026-09-09T01:00:00Z", + unknown_future_field = new { preserve_me = revision } + }; + return Encoding.UTF8.GetBytes(" \n" + JsonSerializer.Serialize(data) + "\n "); + } + + private static void WriteAuth(string home, byte[] bytes) => File.WriteAllBytes(Path.Combine(home, "auth.json"), bytes); + private static byte[] ReadAuth(string home) => File.ReadAllBytes(Path.Combine(home, "auth.json")); + private static void Equal(byte[] actual, byte[] expected, string message) => Assert(actual.SequenceEqual(expected), message); + private static void Assert(bool condition, string message) + { + if (!condition) throw new InvalidOperationException("Account store assertion failed: " + message); + } + private static void AssertThrows(Action action, string message) + { + try { action(); } + catch (Exception ex) when (ex is InvalidOperationException or IOException or SimulatedCrash) { return; } + throw new InvalidOperationException("Account store expected rejection: " + message); + } + private sealed class SimulatedCrash : Exception; + + private sealed class Fixture : IDisposable + { + private readonly string _path; + public Fixture() + { + var parent = Environment.GetEnvironmentVariable("GFS_ACCOUNT_TEST_ROOT") ?? + Path.Combine(Path.GetTempPath(), "gfs-agent", "260909_codex-account-switch", "tests"); + _path = Path.GetFullPath(Path.Combine(parent, "account-store-" + Guid.NewGuid().ToString("N"))); + Directory.CreateDirectory(_path); + } + public (CodexAccountStore Store, string Home) NewStore() + { + var directory = Path.Combine(_path, Guid.NewGuid().ToString("N")); + var home = Path.Combine(directory, "synthetic-codex-home"); + Directory.CreateDirectory(home); + return (new CodexAccountStore(Path.Combine(directory, "vault"), home), home); + } + public SavedCodexAccount Import(CodexAccountStore store, byte[] bytes, string label) + { + var input = Path.Combine(_path, "synthetic-login-" + Guid.NewGuid().ToString("N") + ".json"); + File.WriteAllBytes(input, bytes); + try { return store.ImportLoginFile(input, label); } + finally { File.Delete(input); } + } + public void Dispose() + { + // Only this fixture's freshly generated, resolved subtree is ever recursively removed. + if (!Path.GetFileName(_path).StartsWith("account-store-", StringComparison.Ordinal)) + throw new InvalidOperationException("Invalid synthetic fixture root"); + if (Directory.Exists(_path)) Directory.Delete(_path, recursive: true); + } + } +} diff --git a/tests/WeeklyUsageIndicator.Tests/AccountUiSmoke.cs b/tests/WeeklyUsageIndicator.Tests/AccountUiSmoke.cs new file mode 100644 index 0000000..1fc1e5e --- /dev/null +++ b/tests/WeeklyUsageIndicator.Tests/AccountUiSmoke.cs @@ -0,0 +1,47 @@ +using System.Drawing; +using System.Drawing.Imaging; +using System.Reflection; +using System.Windows.Forms; +using WeeklyUsageIndicator; + +internal static class AccountUiSmoke +{ + internal static Task RunAsync() + { + var output = Environment.GetEnvironmentVariable("GFS_ACCOUNT_UI_CAPTURE"); + if (string.IsNullOrWhiteSpace(output)) return Task.CompletedTask; + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var thread = new Thread(() => + { + var root = Path.Combine(Path.GetDirectoryName(Path.GetFullPath(output))!, "ui-fixture-" + Guid.NewGuid().ToString("N")); + try + { + Application.SetHighDpiMode(HighDpiMode.PerMonitorV2); + Application.EnableVisualStyles(); + var home = Directory.CreateDirectory(Path.Combine(root, "home")).FullName; + var auth = typeof(AccountStoreTests).GetMethod("Auth", BindingFlags.Static | BindingFlags.NonPublic)!; + byte[] Fixture(string user) => (byte[])auth.Invoke(null, new object[] { user, "personal-workspace-" + user, "ui-only" })!; + File.WriteAllBytes(Path.Combine(home, "auth.json"), Fixture("a")); + var store = new CodexAccountStore(Path.Combine(root, "vault"), home); + store.RegisterCurrent("Pro A · 주 계정"); + store.SaveUsage(store.GetCurrentIdentity().Key, new UsageSnapshot(38, DateTimeOffset.Now.AddDays(3), 10080, "codex")); + var second = Path.Combine(root, "second.json"); + File.WriteAllBytes(second, Fixture("b")); + store.ImportLoginFile(second, "Pro B · 추가 계정"); + using var form = new AccountManagerForm(store, () => Task.CompletedTask, () => { }); + form.Show(); + Application.DoEvents(); + using var bitmap = new Bitmap(form.Width, form.Height); + form.DrawToBitmap(bitmap, new Rectangle(Point.Empty, bitmap.Size)); + bitmap.Save(output, ImageFormat.Png); + form.Close(); + completion.SetResult(); + } + catch (Exception ex) { completion.SetException(ex); } + finally { if (Directory.Exists(root)) Directory.Delete(root, true); } + }); + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + return completion.Task; + } +} diff --git a/tests/WeeklyUsageIndicator.Tests/AppServerLifecycleTests.cs b/tests/WeeklyUsageIndicator.Tests/AppServerLifecycleTests.cs new file mode 100644 index 0000000..e8e4690 --- /dev/null +++ b/tests/WeeklyUsageIndicator.Tests/AppServerLifecycleTests.cs @@ -0,0 +1,149 @@ +using System.Diagnostics; +using System.Text; +using WeeklyUsageIndicator; + +internal static class AppServerLifecycleTests +{ + public static async Task RunAsync() + { + var taskRoot = Environment.GetEnvironmentVariable("GFS_ACCOUNT_TEST_ROOT") + ?? Path.Combine(Path.GetTempPath(), "gfs-agent", "260909_codex-account-switch", "tests"); + var root = Path.Combine(taskRoot, "appserver-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + try + { + await CancelUsageAndResumeAsync(root); + await CancelInitializeAsync(root); + await AccountReadConsistencyAsync(root); + } + finally { Directory.Delete(root, recursive: true); } + } + + private static async Task AccountReadConsistencyAsync(string root) + { + using var limit = new CancellationTokenSource(TimeSpan.FromSeconds(20)); + using (var client = new AppServerClient(() => FakeServer(Path.Combine(root, "account.txt"), "none", 0))) + { + var response = await client.GetWeeklyUsageWithAccountAsync(limit.Token); + Check(response.IsChatGpt && response.Email == "fixture@example.invalid" && response.Usage.UsedPercent == 23, + "Combined read must associate usage with the same helper's sanitized account metadata."); + await client.SuspendAsync(); + } + using (var client = new AppServerClient(() => FakeServer(Path.Combine(root, "account-change.txt"), "accountChange", 0))) + { + try { await client.GetWeeklyUsageWithAccountAsync(limit.Token); } + catch (IOException ex) when (ex.Message.Contains("account changed", StringComparison.Ordinal)) { return; } + throw new InvalidOperationException("Usage spanning an account change must be rejected."); + } + } + + private static async Task CancelUsageAndResumeAsync(string root) + { + var marker = Path.Combine(root, "usage.txt"); + var starts = 0; + using var client = new AppServerClient(() => FakeServer(marker, "usage", ++starts == 1 ? 60000 : 0)); + using var limit = new CancellationTokenSource(TimeSpan.FromSeconds(20)); + var requests = Enumerable.Range(0, 5).Select(_ => client.GetWeeklyUsageAsync(limit.Token)).ToArray(); + var pid = await WaitForMarkerAsync(marker, limit.Token); + await client.SuspendAsync().WaitAsync(limit.Token); + foreach (var request in requests) await ExpectCancellationAsync(request); + Check(starts == 1, "Queued reads must not start children after suspension."); + Check(!IsRunning(pid), "Suspend must await actual child exit."); + await ExpectCancellationAsync(client.GetWeeklyUsageAsync(limit.Token)); + Check(starts == 1, "Suspended reads must remain blocked."); + + client.Resume(); + var usage = await client.GetWeeklyUsageAsync(limit.Token); + Check(usage.UsedPercent == 23 && usage.WindowDurationMinutes == 10080, + "A resumed session must receive its own weekly response after the old reader exits."); + Check(starts == 2, "Resume should create exactly one replacement child."); + await client.SuspendAsync(); + await client.SuspendAsync(); + client.Resume(); + } + + private static async Task CancelInitializeAsync(string root) + { + var marker = Path.Combine(root, "initialize.txt"); + using var client = new AppServerClient(() => FakeServer(marker, "initialize", 60000)); + using var limit = new CancellationTokenSource(TimeSpan.FromSeconds(20)); + var request = client.GetWeeklyUsageAsync(limit.Token); + var pid = await WaitForMarkerAsync(marker, limit.Token); + await client.SuspendAsync().WaitAsync(limit.Token); + await ExpectCancellationAsync(request); + Check(!IsRunning(pid), "Suspension during initialize must also stop the child before returning."); + } + + private static ProcessStartInfo FakeServer(string marker, string delayAt, int delayMilliseconds) + { + const string script = """ + $ErrorActionPreference = 'Stop' + $accountReads = 0 + while ($null -ne ($line = [Console]::ReadLine())) { + $message = $line | ConvertFrom-Json + if ($message.method -eq 'initialized') { continue } + if ($message.method -eq 'initialize') { + if ($env:GFS_FAKE_DELAY_AT -eq 'initialize') { + [IO.File]::WriteAllText($env:GFS_FAKE_MARKER, [string]$PID) + Start-Sleep -Milliseconds ([int]$env:GFS_FAKE_DELAY_MS) + } + $result = @{} + } elseif ($message.method -eq 'account/read') { + $accountReads++ + $email = 'fixture@example.invalid' + if ($env:GFS_FAKE_DELAY_AT -eq 'accountChange' -and $accountReads -gt 1) { $email = 'changed@example.invalid' } + $result = @{ account = @{ type = 'chatgpt'; email = $email; planType = 'pro' }; requiresOpenaiAuth = $true } + } else { + [IO.File]::WriteAllText($env:GFS_FAKE_MARKER, [string]$PID) + if ($env:GFS_FAKE_DELAY_AT -eq 'usage') { Start-Sleep -Milliseconds ([int]$env:GFS_FAKE_DELAY_MS) } + $result = @{ rateLimitsByLimitId = @{ codex = @{ limitId = 'codex'; primary = @{ usedPercent = 91; windowDurationMins = 300 }; secondary = @{ usedPercent = 23; windowDurationMins = 10080 } } } } + } + [Console]::WriteLine((@{id = $message.id; result = $result} | ConvertTo-Json -Compress -Depth 10)) + } + """; + var info = new ProcessStartInfo + { + FileName = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "WindowsPowerShell", "v1.0", "powershell.exe"), + Arguments = "-NoProfile -NonInteractive -EncodedCommand " + Convert.ToBase64String(Encoding.Unicode.GetBytes(script)), + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true + }; + info.Environment["GFS_FAKE_MARKER"] = marker; + info.Environment["GFS_FAKE_DELAY_AT"] = delayAt; + info.Environment["GFS_FAKE_DELAY_MS"] = delayMilliseconds.ToString(System.Globalization.CultureInfo.InvariantCulture); + return info; + } + + private static async Task WaitForMarkerAsync(string marker, CancellationToken token) + { + while (!File.Exists(marker)) await Task.Delay(25, token); + // The fixture may have created the file before its one short write finishes. + while (true) + { + try { if (int.TryParse(await File.ReadAllTextAsync(marker, token), out var pid)) return pid; } + catch (IOException) { } + await Task.Delay(25, token); + } + } + + private static async Task ExpectCancellationAsync(Task task) + { + try { await task; } + catch (OperationCanceledException) { return; } + throw new InvalidOperationException("Expected the suspended request to be canceled."); + } + + private static bool IsRunning(int pid) + { + try { using var process = Process.GetProcessById(pid); return !process.HasExited; } + catch (ArgumentException) { return false; } + } + + private static void Check(bool value, string message) + { + if (!value) throw new InvalidOperationException(message); + } +} diff --git a/tests/WeeklyUsageIndicator.Tests/Program.cs b/tests/WeeklyUsageIndicator.Tests/Program.cs index cad44c8..35084f8 100644 --- a/tests/WeeklyUsageIndicator.Tests/Program.cs +++ b/tests/WeeklyUsageIndicator.Tests/Program.cs @@ -3,6 +3,10 @@ var tests = new (string Name, Func Run)[] { + ("Codex app-server lifecycle isolation", AppServerLifecycleTests.RunAsync), + ("Codex account vault and crash recovery", AccountStoreTests.RunAsync), + ("isolated Codex login runtime", AccountRuntimeTests.RunAsync), + ("optional synthetic account UI capture", AccountUiSmoke.RunAsync), ("supervisor retries abnormal exits but respects normal Quit", TestSupervisorAsync), ("official Claude /usage output is parsed", TestObservedUsageOutputAsync), ("usage without reset times remains valid through client and tooltip", TestUsageWithoutResetsAsync), From c6626d8b9437d9cbcef7169dd2b54701b328e177 Mon Sep 17 00:00:00 2001 From: GiantForestStudio <119655663+GiantForestStudio@users.noreply.github.com> Date: Wed, 9 Sep 2026 22:32:20 +0900 Subject: [PATCH 2/5] Fix account registration feedback and nonactivating widget focus --- README.md | 2 +- docs/manual-accounts-delivery.md | 6 +- src/AccountManagerForm.cs | 30 +++++++--- src/AppServerClient.cs | 2 +- src/Program.cs | 6 +- src/WeeklyUsageIndicator.csproj | 2 +- .../AccountUiSmoke.cs | 56 ++++++++++++++++--- tests/WeeklyUsageIndicator.Tests/Program.cs | 2 +- 8 files changed, 82 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 6fdbefe..dd9998b 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ Account management is optional. After you register a Codex account, the widget s ## Manual Codex accounts -Open **Codex 계정 관리…** from the right-click menu, or double-click the tray icon. Enter a label and register the current account first. Enter another label and choose **다른 계정 로그인**; complete the official browser login using the additional account. This login uses an isolated private `CODEX_HOME` and does not log the desktop out. +Open **Codex 계정 관리…** from the right-click menu, or double-click the tray icon. Register the current account first. Names are optional: a blank field uses `계정 1`, `계정 2`, and so on; registering the current account again preserves its existing name. Choose **다른 계정 로그인** and complete the official browser login using the additional account. This login uses an isolated private `CODEX_HOME` and does not log the desktop out. The widget maintains its topmost position without activating itself, so typing in the manager or another app keeps focus. To switch, finish your Codex work and close Codex Desktop and other Codex CLI/engine processes. Select the saved account and click **선택 계정으로 전환**. The widget stops its own usage helper, verifies that no Codex writers remain, saves the latest current login, and applies the selected login. It attempts to reopen the previously observed packaged desktop; if necessary, launch Codex from the Start menu and confirm the account there. File application and desktop login verification are separate outcomes. diff --git a/docs/manual-accounts-delivery.md b/docs/manual-accounts-delivery.md index 3b73003..b5a5b9c 100644 --- a/docs/manual-accounts-delivery.md +++ b/docs/manual-accounts-delivery.md @@ -2,7 +2,7 @@ Outcome: switch between the owner's paid Codex accounts manually from the existing Windows usage widget. No automatic quota switching, proxy routing, background inactive-account usage polling, or Claude credential handling. -Current artifact: branch `codex/manual-account-switch`, local candidate 1.4.0. Installed candidate SHA-256: `2067643F3F5DE5614F883F3EBF501A2FDD1264347AF9576CE1E2C45AB58BFDEA`. Checksum matched the build; one supervisor and one widget were verified. The management window is open for user-assisted registration. +Current artifact: branch `codex/manual-account-switch`, local candidate 1.4.1. Installed candidate SHA-256: `ADCC0CA9C2F396CC9E974909D30F75DE76D73A680A35C57BCB3D21F78FA8D903`. Checksum matched the build; one supervisor and one widget were verified. The management window is open for user-assisted registration. | Owner | Write set | Status | |---|---|---| @@ -17,6 +17,8 @@ Decisions: keep the 272 × 64 widget; use an optional separate management window Verified: all 23 harness groups passed (including the optional synthetic UI capture when enabled), release build succeeded, binary contains no checked username/build path, scoped whitespace check passed. Synthetic tests cover interrupted journal phases, token rotation, third-account refusal, corrupt encrypted data, private ACLs, junction rejection, helper cancellation/restart, login Job ownership, and browser-child survival. Synthetic and installed management windows were observed; clipped controls found in the first synthetic render were fixed. -Next verification: actual current-account registration, second-account OAuth, then user-initiated A→B→A acceptance. Native UI automation failed to deliver input, so the user was asked to operate the already open management window. Current Desktop has not been closed or switched. +User feedback found two interaction failures: blank names blocked registration with an easy-to-miss status message, and the one-second visibility tick repeatedly assigned WinForms TopMost, interfering with input focus in the same-process manager. Version 1.4.1 allows blank names with unique defaults, preserves an existing current-account name, shows preparation/completion feedback, removes the repeating TopMost setter, and adds WS_EX_NOACTIVATE to the widget. The existing SWP_NOACTIVATE topmost maintenance remains. + +The UI regression now actually clicks the registration button with a blank name, verifies the stored account and immediate list update, and checks repeated/explicit-name behavior. All 23 test groups and the release build passed. The installed manager subsequently showed a registered current account and its usage. Current Desktop has not been closed or switched. Next: user confirmation of normal typing/focus, second-account OAuth, then user-initiated A→B→A acceptance. Release: local candidate installed, public publication not requested. A prior executable backup exists in the session's temporary workspace for rollback. Real switching must be initiated outside the active implementation session. Do not claim production acceptance until the actual account cycle is observed. diff --git a/src/AccountManagerForm.cs b/src/AccountManagerForm.cs index b678757..b6b6e9a 100644 --- a/src/AccountManagerForm.cs +++ b/src/AccountManagerForm.cs @@ -6,7 +6,7 @@ internal sealed class AccountManagerForm : Form private readonly Func _suspend; private readonly Action _resume; private readonly ListView _accounts = new() { View = View.Details, FullRowSelect = true, MultiSelect = false, HideSelection = false, Dock = DockStyle.Fill }; - private readonly TextBox _label = new() { Width = 170, PlaceholderText = "계정 별칭 (예: Pro A)" }; + private readonly TextBox _label = new() { Width = 210, PlaceholderText = "이름 (비우면 자동으로 지정)" }; private readonly Label _status = new() { Dock = DockStyle.Fill, AutoSize = false, Padding = new Padding(12), Text = "현재 계정을 등록한 다음 두 번째 계정을 추가하세요." }; private readonly FlowLayoutPanel _buttons = new() { Dock = DockStyle.Fill, Padding = new Padding(8), WrapContents = true, AutoSize = true, AutoSizeMode = AutoSizeMode.GrowAndShrink }; private readonly Button _cancel = new() { Text = "로그인 취소", AutoSize = true, Enabled = false }; @@ -60,6 +60,9 @@ private async Task RunAsync(Func action) { if (_busy) return; _busy = true; + _status.ForeColor = SystemColors.ControlText; + _status.Text = "계정 작업을 준비하고 있습니다…"; + UseWaitCursor = true; using var transactionGate = new Mutex(false, CodexAccountStore.TransactionMutexName); var ownsGate = false; foreach (Control control in _buttons.Controls) control.Enabled = false; @@ -73,13 +76,14 @@ private async Task RunAsync(Func action) await action(); } catch (OperationCanceledException) { _status.Text = "로그인을 취소했습니다. 현재 계정은 유지됩니다."; } - catch (Exception ex) { _status.Text = ex.Message; } + catch (Exception ex) { _status.ForeColor = Color.Firebrick; _status.Text = ex.Message; } finally { // UI event continuations retain the WinForms thread; named mutex ownership // spans browser login and import, so lifecycle scripts cannot kill either. if (ownsGate) transactionGate.ReleaseMutex(); _busy = false; + UseWaitCursor = false; foreach (Control control in _buttons.Controls) control.Enabled = true; _cancel.Enabled = false; Reload(preserveStatus: true); @@ -87,22 +91,34 @@ private async Task RunAsync(Func action) } } - private string AccountLabel => string.IsNullOrWhiteSpace(_label.Text) - ? throw new InvalidOperationException("계정을 구분할 별칭을 먼저 입력하세요.") : _label.Text.Trim(); + private string AccountLabel(bool registeringCurrent = false) + { + if (!string.IsNullOrWhiteSpace(_label.Text)) return _label.Text.Trim(); + var accounts = _store.IsEnabled ? _store.ListAccounts() : Array.Empty(); + if (registeringCurrent && accounts.FirstOrDefault(account => account.IsActive) is { } current) + return current.Label; + for (var number = 1; ; number++) + { + var candidate = $"계정 {number}"; + if (!accounts.Any(account => account.Label.Equals(candidate, StringComparison.OrdinalIgnoreCase))) + return candidate; + } + } private SavedCodexAccount Selected => _accounts.SelectedItems.Count == 1 ? (SavedCodexAccount)_accounts.SelectedItems[0].Tag! : throw new InvalidOperationException("목록에서 계정을 선택하세요."); private Task RegisterAsync() { - _store.RegisterCurrent(AccountLabel); - _status.Text = "현재 계정을 등록했습니다. 다음으로 다른 계정 로그인을 선택하세요."; + var account = _store.RegisterCurrent(AccountLabel(registeringCurrent: true)); + _status.ForeColor = Color.DarkGreen; + _status.Text = $"‘{account.Label}’ 등록 완료. 다음으로 ‘다른 계정 로그인’을 선택하세요. 이름은 비워도 됩니다."; return Task.CompletedTask; } private async Task LoginAsync() { - var label = AccountLabel; + var label = AccountLabel(); if (!_store.IsEnabled || _store.ListAccounts().Count == 0) throw new InvalidOperationException("복귀할 수 있도록 현재 계정을 먼저 등록하세요."); using var cancellation = new CancellationTokenSource(); diff --git a/src/AppServerClient.cs b/src/AppServerClient.cs index 0c5607d..390baf6 100644 --- a/src/AppServerClient.cs +++ b/src/AppServerClient.cs @@ -98,7 +98,7 @@ private async Task EnsureStartedAsync(CancellationToken token) { await CallCoreAsync(session, "initialize", new { - clientInfo = new { name = "weekly-usage-indicator", title = "Weekly Usage Indicator", version = "1.4.0" }, + clientInfo = new { name = "weekly-usage-indicator", title = "Weekly Usage Indicator", version = "1.4.1" }, capabilities = new { experimentalApi = true } }, token).ConfigureAwait(false); await SendLineAsync(session, JsonSerializer.Serialize(new { method = "initialized" }), token).ConfigureAwait(false); diff --git a/src/Program.cs b/src/Program.cs index 09a5bf9..41fec61 100644 --- a/src/Program.cs +++ b/src/Program.cs @@ -183,8 +183,9 @@ protected override CreateParams CreateParams get { const int WsExToolWindow = 0x00000080; + const int WsExNoActivate = 0x08000000; var parameters = base.CreateParams; - parameters.ExStyle |= WsExToolWindow; + parameters.ExStyle |= WsExToolWindow | WsExNoActivate; return parameters; } } @@ -336,7 +337,8 @@ private void SyncCodexVisibility() Opacity = 1; if (_keepOnTop) { - TopMost = true; + // The WinForms TopMost setter can activate this form even when it + // is already topmost. Timer maintenance must use SWP_NOACTIVATE. ReassertTopMost(); } } diff --git a/src/WeeklyUsageIndicator.csproj b/src/WeeklyUsageIndicator.csproj index e606bda..0e15533 100644 --- a/src/WeeklyUsageIndicator.csproj +++ b/src/WeeklyUsageIndicator.csproj @@ -9,7 +9,7 @@ WeeklyUsageIndicator WeeklyUsageIndicator app.manifest - 1.4.0 + 1.4.1 true none false diff --git a/tests/WeeklyUsageIndicator.Tests/AccountUiSmoke.cs b/tests/WeeklyUsageIndicator.Tests/AccountUiSmoke.cs index 1fc1e5e..f1aec89 100644 --- a/tests/WeeklyUsageIndicator.Tests/AccountUiSmoke.cs +++ b/tests/WeeklyUsageIndicator.Tests/AccountUiSmoke.cs @@ -9,11 +9,13 @@ internal static class AccountUiSmoke internal static Task RunAsync() { var output = Environment.GetEnvironmentVariable("GFS_ACCOUNT_UI_CAPTURE"); - if (string.IsNullOrWhiteSpace(output)) return Task.CompletedTask; var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var thread = new Thread(() => { - var root = Path.Combine(Path.GetDirectoryName(Path.GetFullPath(output))!, "ui-fixture-" + Guid.NewGuid().ToString("N")); + var parent = string.IsNullOrWhiteSpace(output) + ? Environment.GetEnvironmentVariable("GFS_ACCOUNT_TEST_ROOT") ?? Path.Combine(Path.GetTempPath(), "gfs-agent", "260909_codex-account-switch", "tests") + : Path.GetDirectoryName(Path.GetFullPath(output))!; + var root = Path.Combine(parent, "ui-fixture-" + Guid.NewGuid().ToString("N")); try { Application.SetHighDpiMode(HighDpiMode.PerMonitorV2); @@ -23,17 +25,39 @@ internal static Task RunAsync() byte[] Fixture(string user) => (byte[])auth.Invoke(null, new object[] { user, "personal-workspace-" + user, "ui-only" })!; File.WriteAllBytes(Path.Combine(home, "auth.json"), Fixture("a")); var store = new CodexAccountStore(Path.Combine(root, "vault"), home); - store.RegisterCurrent("Pro A · 주 계정"); + var suspended = 0; + var resumed = 0; + using var form = new AccountManagerForm(store, () => { suspended++; return Task.CompletedTask; }, () => resumed++); + form.Show(); + Application.DoEvents(); + var controls = Descendants(form).ToArray(); + var register = controls.OfType