Repair missing Codex managed homes on reauth (#4404)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong 2026-06-01 16:42:38 -04:00 committed by GitHub
parent 57bae089d7
commit 65e6ddb59e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 384 additions and 1 deletions

View File

@ -503,6 +503,185 @@ describe('CodexAccountService config sync', () => {
expect(runtimeHome.syncForCurrentSelection).toHaveBeenCalledTimes(1)
})
it('recreates the expected missing managed home before reauthenticating', async () => {
vi.resetModules()
const canonicalConfigPath = join(testState.fakeHomeDir, '.codex', 'config.toml')
const canonicalConfig = 'sandbox_mode = "danger-full-access"\n'
writeFileSync(canonicalConfigPath, canonicalConfig, 'utf-8')
const managedHomePath = join(testState.userDataDir, 'codex-accounts', 'account-1', 'home')
const spawnMock = vi.fn(
(_command: string, _args: string[], options: { env: NodeJS.ProcessEnv }) => {
const loginHome = options.env.CODEX_HOME
expect(loginHome).toBeTruthy()
expect(readFileSync(join(loginHome!, '.orca-managed-home'), 'utf-8')).toBe(
'account-1\n'
)
expect(readFileSync(join(loginHome!, 'config.toml'), 'utf-8')).toBe(canonicalConfig)
const child = new EventEmitter() as EventEmitter & {
stdout: PassThrough
stderr: PassThrough
kill: () => void
}
child.stdout = new PassThrough()
child.stderr = new PassThrough()
child.kill = vi.fn()
writeFileSync(
join(loginHome!, 'auth.json'),
createCodexAuthJson('new@example.com', 'provider-account-1', 'refresh-token'),
'utf-8'
)
queueMicrotask(() => child.emit('close', 0))
return child
}
)
vi.doMock('node:child_process', () => ({
execFileSync: vi.fn(),
spawn: spawnMock
}))
vi.doMock('../codex-cli/command', () => ({
resolveCodexCommand: () => 'codex'
}))
const settings = createSettings({
codexManagedAccounts: [
{
id: 'account-1',
email: 'old@example.com',
managedHomePath,
providerAccountId: null,
workspaceLabel: null,
workspaceAccountId: null,
createdAt: 1,
updatedAt: 1,
lastAuthenticatedAt: 1
}
],
activeCodexManagedAccountId: 'account-1'
})
const store = createStore(settings)
const rateLimits = createRateLimits()
const runtimeHome = createRuntimeHome()
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const { CodexAccountService } = await import('./service')
const service = new CodexAccountService(
store as never,
rateLimits as never,
runtimeHome as never
)
const result = await service.reauthenticateAccount('account-1')
expect(result.accounts[0]).toMatchObject({
email: 'new@example.com',
providerAccountId: 'provider-account-1'
})
expect(existsSync(managedHomePath)).toBe(true)
expect(spawnMock).toHaveBeenCalledTimes(1)
expect(runtimeHome.syncForCurrentSelection).toHaveBeenCalledTimes(1)
warnSpy.mockRestore()
})
it('does not recreate a missing managed home at a different account path', async () => {
vi.resetModules()
const managedHomePath = join(testState.userDataDir, 'codex-accounts', 'other-account', 'home')
const expectedManagedHomePath = join(
testState.userDataDir,
'codex-accounts',
'account-1',
'home'
)
const spawnMock = vi.fn()
vi.doMock('node:child_process', () => ({
execFileSync: vi.fn(),
spawn: spawnMock
}))
const settings = createSettings({
codexManagedAccounts: [
{
id: 'account-1',
email: 'user@example.com',
managedHomePath,
providerAccountId: null,
workspaceLabel: null,
workspaceAccountId: null,
createdAt: 1,
updatedAt: 1,
lastAuthenticatedAt: 1
}
]
})
const store = createStore(settings)
const rateLimits = createRateLimits()
const runtimeHome = createRuntimeHome()
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const { CodexAccountService } = await import('./service')
const service = new CodexAccountService(
store as never,
rateLimits as never,
runtimeHome as never
)
await expect(service.reauthenticateAccount('account-1')).rejects.toThrow(
'Managed Codex home directory does not exist on disk.'
)
expect(existsSync(expectedManagedHomePath)).toBe(false)
expect(spawnMock).not.toHaveBeenCalled()
warnSpy.mockRestore()
})
it('does not trust an existing managed home that is missing its ownership marker', async () => {
vi.resetModules()
const managedHomePath = join(testState.userDataDir, 'codex-accounts', 'account-1', 'home')
mkdirSync(managedHomePath, { recursive: true })
const spawnMock = vi.fn()
vi.doMock('node:child_process', () => ({
execFileSync: vi.fn(),
spawn: spawnMock
}))
const settings = createSettings({
codexManagedAccounts: [
{
id: 'account-1',
email: 'user@example.com',
managedHomePath,
providerAccountId: null,
workspaceLabel: null,
workspaceAccountId: null,
createdAt: 1,
updatedAt: 1,
lastAuthenticatedAt: 1
}
]
})
const store = createStore(settings)
const rateLimits = createRateLimits()
const runtimeHome = createRuntimeHome()
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const { CodexAccountService } = await import('./service')
const service = new CodexAccountService(
store as never,
rateLimits as never,
runtimeHome as never
)
await expect(service.reauthenticateAccount('account-1')).rejects.toThrow(
'Managed Codex home is missing Orca ownership marker.'
)
expect(spawnMock).not.toHaveBeenCalled()
warnSpy.mockRestore()
})
it('adds a managed Codex account inside WSL when the account context is WSL', async () => {
vi.resetModules()
const originalPlatform = process.platform
@ -735,6 +914,122 @@ describe('CodexAccountService config sync', () => {
}
})
it('recreates the expected missing WSL managed home before reauthenticating', async () => {
vi.resetModules()
const originalPlatform = process.platform
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'win32'
})
const wslManagedHomePath = join(testState.userDataDir, 'wsl-account', 'home')
const wslLinuxHomePath = '/home/alice/.local/share/orca/codex-accounts/account-1/home'
const execFileSyncMock = vi.fn((_command: string, args: string[]) => {
const script = decodeEncodedWslBashCommand(String(args.at(-1)))
if (script.includes('mkdir -p -- "$candidate"')) {
mkdirSync(wslManagedHomePath, { recursive: true })
writeFileSync(join(wslManagedHomePath, '.orca-managed-home'), 'account-1\n', 'utf-8')
return ''
}
if (script.includes('readlink -f')) {
return `${wslLinuxHomePath}\n`
}
return ''
})
const spawnMock = vi.fn((command: string, args: string[]) => {
expect(command).toBe('wsl.exe')
expect(args).toEqual([
'-d',
'Ubuntu',
'--',
'bash',
'-lc',
`export CODEX_HOME='${wslLinuxHomePath}'; exec codex login`
])
expect(readFileSync(join(wslManagedHomePath, '.orca-managed-home'), 'utf-8')).toBe(
'account-1\n'
)
const child = new EventEmitter() as EventEmitter & {
stdout: PassThrough
stderr: PassThrough
kill: () => void
}
child.stdout = new PassThrough()
child.stderr = new PassThrough()
child.kill = vi.fn()
writeFileSync(
join(wslManagedHomePath, 'auth.json'),
createCodexAuthJson('new-wsl@example.com', 'provider-wsl-1', 'refresh-token'),
'utf-8'
)
queueMicrotask(() => child.emit('close', 0))
return child
})
vi.doMock('node:child_process', () => ({
execFileSync: execFileSyncMock,
spawn: spawnMock
}))
vi.doMock('../../shared/wsl-paths', () => ({
parseWslUncPath: (path: string) =>
path === wslManagedHomePath ? { distro: 'Ubuntu', linuxPath: wslLinuxHomePath } : null
}))
vi.doMock('../wsl', () => ({
toWindowsWslPath: () => wslManagedHomePath
}))
const settings = createSettings({
codexManagedAccounts: [
{
id: 'account-1',
email: 'old-wsl@example.com',
managedHomePath: wslManagedHomePath,
managedHomeRuntime: 'wsl',
wslDistro: 'Ubuntu',
wslLinuxHomePath,
providerAccountId: null,
workspaceLabel: null,
workspaceAccountId: null,
createdAt: 1,
updatedAt: 1,
lastAuthenticatedAt: 1
}
],
activeCodexManagedAccountId: 'account-1'
})
const store = createStore(settings)
const rateLimits = createRateLimits()
const runtimeHome = createRuntimeHome()
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
try {
const { CodexAccountService } = await import('./service')
const service = new CodexAccountService(
store as never,
rateLimits as never,
runtimeHome as never
)
const result = await service.reauthenticateAccount('account-1')
expect(result.accounts[0]).toMatchObject({
email: 'new-wsl@example.com',
providerAccountId: 'provider-wsl-1',
managedHomeRuntime: 'wsl',
wslDistro: 'Ubuntu'
})
expect(spawnMock).toHaveBeenCalledTimes(1)
expect(runtimeHome.syncForCurrentSelection).toHaveBeenCalled()
} finally {
warnSpy.mockRestore()
Object.defineProperty(process, 'platform', {
configurable: true,
value: originalPlatform
})
}
})
it('removes a WSL managed account only after canonical path validation', async () => {
vi.resetModules()
const originalPlatform = process.platform

View File

@ -170,8 +170,9 @@ export class CodexAccountService {
private async doReauthenticateAccount(accountId: string): Promise<CodexRateLimitAccountsState> {
const account = this.requireAccount(accountId)
const managedHomePath = this.assertManagedHomePath(account.managedHomePath)
const managedHomePath = this.ensureManagedHomeForReauthentication(account)
this.safeSyncCanonicalConfigIntoManagedHome(managedHomePath)
await this.runCodexLogin(managedHomePath)
const identity = this.readIdentityFromHome(managedHomePath)
if (!identity.email) {
@ -513,6 +514,93 @@ export class CodexAccountService {
return root
}
private ensureManagedHomeForReauthentication(account: CodexManagedAccount): string {
const wslInfo = parseWslUncPath(account.managedHomePath)
if (wslInfo && process.platform === 'win32') {
this.ensureExpectedWslManagedHomeForReauthentication(account, wslInfo)
return this.assertManagedHomePath(account.managedHomePath)
}
try {
return this.assertManagedHomePath(account.managedHomePath)
} catch (error) {
if (!this.isMissingManagedHomeError(error)) {
throw error
}
return this.recreateExpectedHostManagedHomeForReauthentication(account, error)
}
}
private recreateExpectedHostManagedHomeForReauthentication(
account: CodexManagedAccount,
originalError: unknown
): string {
const expectedManagedHomePath = join(this.getManagedAccountsRoot(), account.id, 'home')
if (!this.pathsEqual(account.managedHomePath, expectedManagedHomePath)) {
throw originalError
}
// Why: explicit re-auth is allowed to recover from a lost empty container,
// but only at the exact Orca-owned account path persisted for this account.
mkdirSync(expectedManagedHomePath, { recursive: true })
writeFileSync(join(expectedManagedHomePath, '.orca-managed-home'), `${account.id}\n`, 'utf-8')
return this.assertManagedHomePath(expectedManagedHomePath)
}
private ensureExpectedWslManagedHomeForReauthentication(
account: CodexManagedAccount,
wslInfo: { distro: string; linuxPath: string }
): void {
if (
account.managedHomeRuntime !== 'wsl' ||
account.wslDistro !== wslInfo.distro ||
account.wslLinuxHomePath !== wslInfo.linuxPath ||
!wslInfo.linuxPath.endsWith(`/.local/share/orca/codex-accounts/${account.id}/home`)
) {
return
}
execFileSync(
'wsl.exe',
[
'-d',
wslInfo.distro,
'--',
'bash',
'-lc',
buildEncodedWslBashCommand(
[
'set -euo pipefail',
`candidate=${shellQuote(wslInfo.linuxPath)}`,
`expected_marker=${shellQuote(account.id)}`,
'marker="$candidate/.orca-managed-home"',
'if [ -e "$candidate" ] && [ ! -f "$marker" ]; then exit 41; fi',
'if [ -f "$marker" ] && [ "$(cat "$marker")" != "$expected_marker" ]; then exit 42; fi',
'mkdir -p -- "$candidate"',
'printf "%s\\n" "$expected_marker" > "$marker"'
].join('\n')
)
],
{ encoding: 'utf-8', timeout: 5000 }
)
}
private isMissingManagedHomeError(error: unknown): boolean {
return (
error instanceof Error &&
error.message === 'Managed Codex home directory does not exist on disk.'
)
}
private pathsEqual(left: string, right: string): boolean {
const resolvedLeft = resolve(left)
const resolvedRight = resolve(right)
if (process.platform === 'win32') {
return resolvedLeft.toLowerCase() === resolvedRight.toLowerCase()
}
return resolvedLeft === resolvedRight
}
private assertManagedHomePath(candidatePath: string): string {
const wslInfo = parseWslUncPath(candidatePath)
if (wslInfo) {