From 7ca3e670c56ad6e929f48d8586cb20947f81ed2d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 21 Jul 2026 13:28:34 -0700 Subject: [PATCH] fix(codex): count per-account homes in usage and state removal blast radius (#9763) * fix(codex): count per-account homes in usage and state removal blast radius - usage scanner now includes codex-accounts/*/home/sessions so multi-account usage is no longer silently undercounted (audit F2) - account-removal dialog copy now states that session history and MCP logins are permanently deleted with the managed home (audit F1 mitigation) * fix(codex): harden account usage discovery --- .../scanner-large-directory.test.ts | 1 + src/main/codex-usage/scanner-paths.test.ts | 76 +++++++++++++++++++ src/main/codex-usage/scanner.ts | 14 ++-- .../codex/codex-account-home-discovery.ts | 34 +++++++++ src/main/codex/codex-home-paths.ts | 2 +- .../src/components/settings/AccountsPane.tsx | 4 +- src/renderer/src/i18n/locales/en.json | 2 +- src/renderer/src/i18n/locales/es.json | 2 +- src/renderer/src/i18n/locales/ja.json | 2 +- src/renderer/src/i18n/locales/ko.json | 2 +- src/renderer/src/i18n/locales/zh.json | 2 +- 11 files changed, 128 insertions(+), 13 deletions(-) create mode 100644 src/main/codex/codex-account-home-discovery.ts diff --git a/src/main/codex-usage/scanner-large-directory.test.ts b/src/main/codex-usage/scanner-large-directory.test.ts index 84191bc55..1ef57ff9d 100644 --- a/src/main/codex-usage/scanner-large-directory.test.ts +++ b/src/main/codex-usage/scanner-large-directory.test.ts @@ -28,6 +28,7 @@ const RUNTIME_BULK_DIR = join(RUNTIME_SESSIONS_ROOT, 'bulk') vi.mock('../codex/codex-home-paths', () => ({ getOrcaManagedCodexHomePath: () => join(FAKE_ROOT, 'runtime'), + getOrcaUserDataPath: () => FAKE_ROOT, getSystemCodexHomePath: () => join(FAKE_ROOT, 'system') })) diff --git a/src/main/codex-usage/scanner-paths.test.ts b/src/main/codex-usage/scanner-paths.test.ts index ecdc03e13..3b1298746 100644 --- a/src/main/codex-usage/scanner-paths.test.ts +++ b/src/main/codex-usage/scanner-paths.test.ts @@ -5,6 +5,7 @@ import { mkdirSync, mkdtempSync, rmSync, + symlinkSync, unlinkSync, writeFileSync } from 'node:fs' @@ -156,6 +157,81 @@ describe('listCodexSessionFiles', () => { expect(await listCodexSessionFiles()).toEqual([runtimeSessionPath, systemSessionPath].sort()) }) + it('scans per-account self-contained Codex homes that hold sessions', async () => { + const runtimeSessionsDir = join(userDataDir, 'codex-runtime-home', 'home', 'sessions') + const systemSessionsDir = join(fakeHomeDir, '.codex', 'sessions') + const accountSessionsDir = join(userDataDir, 'codex-accounts', 'acct-1', 'home', 'sessions') + mkdirSync(runtimeSessionsDir, { recursive: true }) + mkdirSync(systemSessionsDir, { recursive: true }) + mkdirSync(accountSessionsDir, { recursive: true }) + writeFileSync( + join(userDataDir, 'codex-accounts', 'acct-1', 'home', '.orca-managed-home'), + 'acct-1\n', + 'utf-8' + ) + // Why: an account home without a sessions tree (auth-only) must not add a scan root. + mkdirSync(join(userDataDir, 'codex-accounts', 'acct-2', 'home'), { recursive: true }) + const accountSessionPath = join(accountSessionsDir, 'account.jsonl') + writeFileSync( + accountSessionPath, + [ + `${JSON.stringify({ + type: 'session_meta', + payload: { id: 'account-session', cwd: join(fakeHomeDir, 'repo') } + })}\n`, + usageRecord('2026-07-21T12:00:00.000Z', 42) + ].join(''), + 'utf-8' + ) + + expect(getCodexSessionDirectories()).toEqual([ + runtimeSessionsDir, + systemSessionsDir, + accountSessionsDir + ]) + expect(await listCodexSessionFiles()).toEqual([accountSessionPath]) + const result = await scanCodexUsageFiles([], []) + expect(result.sessions).toHaveLength(1) + expect(result.dailyAggregates).toEqual([ + expect.objectContaining({ totalTokens: 42, eventCount: 1 }) + ]) + }) + + it('does not scan an account home redirected outside managed storage', async () => { + const accountDir = join(userDataDir, 'codex-accounts', 'acct-redirected') + const externalHome = join(fakeHomeDir, 'redirected-account-home') + const externalSessionsDir = join(externalHome, 'sessions') + mkdirSync(accountDir, { recursive: true }) + mkdirSync(externalSessionsDir, { recursive: true }) + writeFileSync(join(externalHome, '.orca-managed-home'), 'acct-redirected\n', 'utf-8') + writeFileSync(join(externalSessionsDir, 'unrelated.jsonl'), '{}\n', 'utf-8') + symlinkSync( + externalHome, + join(accountDir, 'home'), + process.platform === 'win32' ? 'junction' : 'dir' + ) + + expect(getCodexSessionDirectories()).not.toContain(join(accountDir, 'home', 'sessions')) + expect(await listCodexSessionFiles()).toEqual([]) + }) + + it('does not scan a sessions root redirected outside an owned account home', async () => { + const accountHome = join(userDataDir, 'codex-accounts', 'acct-redirected-sessions', 'home') + const externalSessionsDir = join(fakeHomeDir, 'redirected-sessions') + mkdirSync(accountHome, { recursive: true }) + mkdirSync(externalSessionsDir, { recursive: true }) + writeFileSync(join(accountHome, '.orca-managed-home'), 'acct-redirected-sessions\n', 'utf-8') + writeFileSync(join(externalSessionsDir, 'unrelated.jsonl'), '{}\n', 'utf-8') + symlinkSync( + externalSessionsDir, + join(accountHome, 'sessions'), + process.platform === 'win32' ? 'junction' : 'dir' + ) + + expect(getCodexSessionDirectories()).not.toContain(join(accountHome, 'sessions')) + expect(await listCodexSessionFiles()).toEqual([]) + }) + it('dedupes managed session aliases that point at system sessions', async () => { const runtimeSessionsDir = join(userDataDir, 'codex-runtime-home', 'home', 'sessions') const systemSessionsDir = join(fakeHomeDir, '.codex', 'sessions') diff --git a/src/main/codex-usage/scanner.ts b/src/main/codex-usage/scanner.ts index 97e8f6f7b..eb5fbcaab 100644 --- a/src/main/codex-usage/scanner.ts +++ b/src/main/codex-usage/scanner.ts @@ -6,6 +6,7 @@ import { createInterface } from 'node:readline' import type { Repo } from '../../shared/types' import { areWorktreePathsEqual } from '../ipc/worktree-logic' import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from '../codex/codex-home-paths' +import { getCodexAccountHomeSessionDirectories } from '../codex/codex-account-home-discovery' import { getLegacyCopiedCodexSessionBridgeScanPreference } from '../codex/codex-session-bridge' import { canonicalizeUsageWorktreePaths } from '../usage-worktree-canonicalizer' import type { @@ -133,11 +134,14 @@ export function getCodexSessionsDirectory(): string { } export function getCodexSessionDirectories(): string[] { - // Why: upgraded users still have ordinary Codex history under ~/.codex, while - // new Orca-launched sessions are written under Orca's managed runtime home. - return [getCodexSessionsDirectory(), join(getSystemCodexHomePath(), 'sessions')].filter( - (dirPath, index, allDirPaths) => allDirPaths.indexOf(dirPath) === index - ) + // Why: sessions now live in three lanes — the shared runtime mirror, the real + // ~/.codex, and per-account self-contained homes; missing any lane silently + // undercounts usage for multi-account users. + return [ + getCodexSessionsDirectory(), + join(getSystemCodexHomePath(), 'sessions'), + ...getCodexAccountHomeSessionDirectories() + ].filter((dirPath, index, allDirPaths) => allDirPaths.indexOf(dirPath) === index) } function hasLegacyCopiedSessionBridgeMarkers(): boolean { diff --git a/src/main/codex/codex-account-home-discovery.ts b/src/main/codex/codex-account-home-discovery.ts new file mode 100644 index 000000000..d8d30bcd9 --- /dev/null +++ b/src/main/codex/codex-account-home-discovery.ts @@ -0,0 +1,34 @@ +import { lstatSync, readdirSync } from 'node:fs' +import { join } from 'node:path' +import { getOrcaUserDataPath, getSystemCodexHomePath } from './codex-home-paths' +import { assertOwnedHostCodexManagedHomePath } from '../codex-accounts/host-codex-managed-home-ownership' + +/** Session roots of per-account self-contained host Codex homes present on disk. + * Why disk-enumerated, not settings-driven: rollouts retained after an account + * change must still be counted, and CLI callers have no settings store. WSL + * account homes live inside their distro and are scanned by their own lane. */ +export function getCodexAccountHomeSessionDirectories(): string[] { + const accountsRoot = join(getOrcaUserDataPath(), 'codex-accounts') + try { + return readdirSync(accountsRoot, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .flatMap((entry) => { + const accountHome = join(accountsRoot, entry.name, 'home') + try { + assertOwnedHostCodexManagedHomePath({ + candidatePath: accountHome, + managedAccountsRoot: accountsRoot, + systemCodexHomePath: getSystemCodexHomePath(), + expectedAccountId: entry.name + }) + const sessionsPath = join(accountHome, 'sessions') + // Why: a redirected sessions root could make usage scan unrelated, unbounded trees. + return lstatSync(sessionsPath).isDirectory() ? [sessionsPath] : [] + } catch { + return [] + } + }) + } catch { + return [] + } +} diff --git a/src/main/codex/codex-home-paths.ts b/src/main/codex/codex-home-paths.ts index 1a6a8ba0b..61e2eba1e 100644 --- a/src/main/codex/codex-home-paths.ts +++ b/src/main/codex/codex-home-paths.ts @@ -42,7 +42,7 @@ export function getCodexSessionBackfillStateDirPath(): string { return join(getOrcaUserDataPath(), 'codex-session-backfill') } -function getOrcaUserDataPath(): string { +export function getOrcaUserDataPath(): string { if (process.env.ORCA_USER_DATA_PATH) { return process.env.ORCA_USER_DATA_PATH } diff --git a/src/renderer/src/components/settings/AccountsPane.tsx b/src/renderer/src/components/settings/AccountsPane.tsx index 5e065c483..396d0f923 100644 --- a/src/renderer/src/components/settings/AccountsPane.tsx +++ b/src/renderer/src/components/settings/AccountsPane.tsx @@ -1873,8 +1873,8 @@ export function AccountsPane({ {translate( - 'auto.components.settings.AccountsPane.99c8f9e498', - 'Orca will delete the managed Codex home for this saved account. If it is currently active, Orca falls back to the system default Codex login.' + 'auto.components.settings.AccountsPane.380a7736cc', + 'Removing this account permanently deletes its managed Codex home, including all Codex session history and MCP logins stored inside. This cannot be undone. If the account is currently active, Orca falls back to the system default Codex login.' )} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index c4e7e0d8b..1251bbaed 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -4887,7 +4887,7 @@ "dbb9626ed1": "Cancel", "854ebbcc45": "Orca will delete the managed Claude auth for this saved account. If it is currently active, Orca falls back to the system default Claude login.", "63843e37e2": "Remove Claude Account?", - "99c8f9e498": "Orca will delete the managed Codex home for this saved account. If it is currently active, Orca falls back to the system default Codex login.", + "380a7736cc": "Removing this account permanently deletes its managed Codex home, including all Codex session history and MCP logins stored inside. This cannot be undone. If the account is currently active, Orca falls back to the system default Codex login.", "0d47394635": "Remove Codex Account?", "ae3b21eb6c": "opencode.ai/workspace/wrk_…/go", "51c9104e13": "Find this in the URL after logging into opencode.ai (e.g.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 22d747769..8433bc9e4 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -4864,7 +4864,7 @@ "dbb9626ed1": "Cancelar", "854ebbcc45": "Orca eliminará la autenticación de Claude gestionada para esta cuenta guardada. Si está activa actualmente, Orca volverá al login predeterminado de Claude del sistema.", "63843e37e2": "¿Eliminar la cuenta de Claude?", - "99c8f9e498": "Orca eliminará el home de Codex gestionado para esta cuenta guardada. Si está activo actualmente, Orca volverá al login predeterminado de Codex del sistema.", + "380a7736cc": "Eliminar esta cuenta borra permanentemente su home de Codex gestionado, incluido todo el historial de sesiones de Codex y los inicios de sesión de MCP almacenados dentro. Esto no se puede deshacer. Si la cuenta está activa, Orca vuelve al login predeterminado de Codex del sistema.", "0d47394635": "¿Eliminar cuenta de Codex?", "ae3b21eb6c": "opencode.ai/workspace/wrk__…/go", "51c9104e13": "Encuéntralo en la URL después de iniciar sesión en opencode.ai (p. ej.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 042b0a308..f4f2a4673 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -4849,7 +4849,7 @@ "dbb9626ed1": "キャンセル", "854ebbcc45": "Orca は、この保存されたアカウントの管理対象 Claude 認証を削除します。現在アクティブな場合、Orca はシステムのデフォルトの Claude ログインに戻ります。", "63843e37e2": "Claude アカウントを削除しますか?", - "99c8f9e498": "Orca は、この保存されたアカウントの管理対象 Codex ホームを削除します。現在アクティブな場合、Orca はシステムのデフォルトの Codex ログインに戻ります。", + "380a7736cc": "このアカウントを削除すると、管理対象の Codex ホームと、その中に保存されているすべての Codex セッション履歴および MCP ログインが完全に削除されます。この操作は元に戻せません。アカウントが現在アクティブな場合、Orca はシステムのデフォルトの Codex ログインに戻ります。", "0d47394635": "Codex アカウントを削除しますか?", "ae3b21eb6c": "opencode.ai/workspace/wrk_…/go", "51c9104e13": "opencode.ai にログインした後、URL でこれを見つけます (例:", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 38cf08a5a..41bf29b84 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -4849,7 +4849,7 @@ "dbb9626ed1": "취소", "854ebbcc45": "Orca는 저장된 계정에 대해 관리되는 Claude 인증을 삭제합니다. 현재 활성화된 경우 Orca는 시스템 기본 Claude 로그인으로 대체됩니다.", "63843e37e2": "Claude 계정을 삭제하시겠습니까?", - "99c8f9e498": "Orca는 저장된 계정에 대해 관리되는 Codex 홈을 삭제합니다. 현재 활성화된 경우 Orca는 시스템 기본 Codex 로그인으로 대체됩니다.", + "380a7736cc": "이 계정을 제거하면 관리되는 Codex 홈과 그 안에 저장된 모든 Codex 세션 기록 및 MCP 로그인이 영구적으로 삭제됩니다. 이 작업은 실행 취소할 수 없습니다. 계정이 현재 활성 상태인 경우 Orca는 시스템 기본 Codex 로그인으로 전환됩니다.", "0d47394635": "Codex 계정을 제거하시겠습니까?", "ae3b21eb6c": "opencode.ai/workspace/wrk_…/go", "51c9104e13": "opencode.ai에 로그인한 후 URL에서 이를 찾으세요(예:", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 489afcf2c..83200a2f5 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -4849,7 +4849,7 @@ "dbb9626ed1": "取消", "854ebbcc45": "Orca 将删除此已保存账户的托管 Claude 身份验证。如果当前处于活动状态,Orca 将回退到系统默认的 Claude 登录名。", "63843e37e2": "删除 Claude 账户?", - "99c8f9e498": "Orca 将删除此已保存账户的托管 Codex 主目录。如果当前处于活动状态,Orca 将回退到系统默认的 Codex 登录。", + "380a7736cc": "移除此账户会永久删除其托管的 Codex 主目录,包括其中存储的所有 Codex 会话历史记录和 MCP 登录信息。此操作无法撤销。如果该账户当前处于活动状态,Orca 将回退到系统默认的 Codex 登录。", "0d47394635": "删除 Codex 账户?", "ae3b21eb6c": "opencode.ai/workspace/wrk_…/go", "51c9104e13": "登录 opencode.ai 后在 URL 中找到此内容(例如",