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
This commit is contained in:
parent
a10a2ba53c
commit
7ca3e670c5
|
|
@ -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')
|
||||
}))
|
||||
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 []
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1873,8 +1873,8 @@ export function AccountsPane({
|
|||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{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.'
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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 でこれを見つけます (例:",
|
||||
|
|
|
|||
|
|
@ -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에서 이를 찾으세요(예:",
|
||||
|
|
|
|||
|
|
@ -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 中找到此内容(例如",
|
||||
|
|
|
|||
Loading…
Reference in New Issue