mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 00:03:20 +00:00
fix(codex): bind managed homes to account ownership
This commit is contained in:
@@ -1444,6 +1444,13 @@ describe('CodexAccountService config sync', () => {
|
||||
execFileSync: vi.fn((_command: string, args: string[]) => {
|
||||
const script = decodeEncodedWslBashCommand(String(args.at(-1)))
|
||||
if (script.includes('readlink -f')) {
|
||||
expect(script).toContain("expected_marker='account-1'")
|
||||
expect(script).toContain(
|
||||
'test "$candidate_real" = "$managed_root_real/$expected_marker/home"'
|
||||
)
|
||||
expect(script).toContain(
|
||||
'test "$(cat "$candidate_real/.orca-managed-home")" = "$expected_marker"'
|
||||
)
|
||||
return `${wslLinuxHomePath}\n`
|
||||
}
|
||||
return ''
|
||||
@@ -1724,6 +1731,46 @@ describe('CodexAccountService config sync', () => {
|
||||
expect(runtimeHome.syncForCurrentSelection).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses to remove a managed home owned by a different account', async () => {
|
||||
const otherAccountHome = createManagedHome(
|
||||
testState.userDataDir,
|
||||
'account-2',
|
||||
'',
|
||||
'{"account":"other"}\n'
|
||||
)
|
||||
const settings = createSettings({
|
||||
codexManagedAccounts: [
|
||||
{
|
||||
id: 'account-1',
|
||||
email: 'user@example.com',
|
||||
managedHomePath: otherAccountHome,
|
||||
providerAccountId: null,
|
||||
workspaceLabel: null,
|
||||
workspaceAccountId: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
lastAuthenticatedAt: 1
|
||||
}
|
||||
]
|
||||
})
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const { CodexAccountService } = await import('./service')
|
||||
const service = new CodexAccountService(
|
||||
createStore(settings) as never,
|
||||
createRateLimits() as never,
|
||||
createRuntimeHome() as never
|
||||
)
|
||||
|
||||
await service.removeAccount('account-1')
|
||||
|
||||
expect(readFileSync(join(otherAccountHome, 'auth.json'), 'utf-8')).toBe('{"account":"other"}\n')
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
'[codex-accounts] Refusing to remove untrusted managed home:',
|
||||
expect.any(Error)
|
||||
)
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('lists accounts with normalizeActiveSelection', async () => {
|
||||
const managedHomePath = createManagedHome(
|
||||
testState.userDataDir,
|
||||
|
||||
@@ -142,9 +142,9 @@ export class CodexAccountService {
|
||||
try {
|
||||
const canonicalConfig = this.readCanonicalConfigForManagedHome(managedHomePath)
|
||||
this.assertOAuthAccountAddAllowed(canonicalConfig)
|
||||
this.safeSyncCanonicalConfigIntoManagedHome(managedHomePath, canonicalConfig)
|
||||
this.safeSyncCanonicalConfigIntoManagedHome(managedHomePath, canonicalConfig, accountId)
|
||||
await this.runCodexLogin(managedHomePath)
|
||||
const identity = this.readIdentityFromHome(managedHomePath)
|
||||
const identity = this.readIdentityFromHome(managedHomePath, accountId)
|
||||
if (!identity.email) {
|
||||
throw new Error('Codex login completed, but Orca could not resolve the account email.')
|
||||
}
|
||||
@@ -188,7 +188,7 @@ export class CodexAccountService {
|
||||
await this.rateLimits.refreshForCodexAccountChange(outgoingAccountId, targetSelection)
|
||||
return this.getSnapshot()
|
||||
} catch (error) {
|
||||
this.safeRemoveManagedHome(managedHomePath)
|
||||
this.safeRemoveManagedHome(managedHomePath, accountId)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -197,9 +197,9 @@ export class CodexAccountService {
|
||||
const account = this.requireAccount(accountId)
|
||||
const managedHomePath = this.ensureManagedHomeForReauthentication(account)
|
||||
|
||||
this.safeSyncCanonicalConfigIntoManagedHome(managedHomePath)
|
||||
this.safeSyncCanonicalConfigIntoManagedHome(managedHomePath, undefined, account.id)
|
||||
await this.runCodexLogin(managedHomePath)
|
||||
const identity = this.readIdentityFromHome(managedHomePath)
|
||||
const identity = this.readIdentityFromHome(managedHomePath, account.id)
|
||||
if (!identity.email) {
|
||||
throw new Error('Codex login completed, but Orca could not resolve the account email.')
|
||||
}
|
||||
@@ -255,7 +255,7 @@ export class CodexAccountService {
|
||||
})
|
||||
this.runtimeHome.syncForCurrentSelection()
|
||||
|
||||
this.safeRemoveManagedHome(account.managedHomePath)
|
||||
this.safeRemoveManagedHome(account.managedHomePath, account.id)
|
||||
// Why: a removed account can no longer appear in the switcher dropdown,
|
||||
// so purge its cached usage to avoid stale entries.
|
||||
this.rateLimits.evictInactiveCodexCache(accountId)
|
||||
@@ -469,7 +469,7 @@ export class CodexAccountService {
|
||||
// prove the path belongs to Orca before deleting anything.
|
||||
writeFileSync(join(managedHomePath, '.orca-managed-home'), `${accountId}\n`, 'utf-8')
|
||||
return {
|
||||
managedHomePath: this.assertManagedHomePath(managedHomePath),
|
||||
managedHomePath: this.assertManagedHomePath(managedHomePath, accountId),
|
||||
managedHomeRuntime: 'host',
|
||||
wslDistro: null,
|
||||
wslLinuxHomePath: null
|
||||
@@ -518,7 +518,7 @@ export class CodexAccountService {
|
||||
const managedHomePath = toWindowsWslPath(wslLinuxHomePath, distro)
|
||||
let trustedManagedHomePath: string
|
||||
try {
|
||||
trustedManagedHomePath = this.assertManagedHomePath(managedHomePath)
|
||||
trustedManagedHomePath = this.assertManagedHomePath(managedHomePath, accountId)
|
||||
} catch (error) {
|
||||
this.safeRemoveWslManagedHomeCandidate(distro, wslLinuxHomePath, accountId)
|
||||
throw error
|
||||
@@ -542,10 +542,11 @@ export class CodexAccountService {
|
||||
|
||||
private safeSyncCanonicalConfigIntoManagedHome(
|
||||
managedHomePath: string,
|
||||
canonicalConfig?: CanonicalCodexConfig | null
|
||||
canonicalConfig?: CanonicalCodexConfig | null,
|
||||
expectedAccountId?: string
|
||||
): void {
|
||||
try {
|
||||
this.syncCanonicalConfigIntoManagedHome(managedHomePath, canonicalConfig)
|
||||
this.syncCanonicalConfigIntoManagedHome(managedHomePath, canonicalConfig, expectedAccountId)
|
||||
} catch (error) {
|
||||
console.warn('[codex-accounts] Failed to seed managed config:', error)
|
||||
}
|
||||
@@ -555,7 +556,7 @@ export class CodexAccountService {
|
||||
const settings = this.store.getSettings()
|
||||
for (const account of settings.codexManagedAccounts) {
|
||||
try {
|
||||
this.syncCanonicalConfigIntoManagedHome(account.managedHomePath)
|
||||
this.syncCanonicalConfigIntoManagedHome(account.managedHomePath, undefined, account.id)
|
||||
} catch (error) {
|
||||
console.warn('[codex-accounts] Failed to sync managed config:', error)
|
||||
}
|
||||
@@ -573,13 +574,14 @@ export class CodexAccountService {
|
||||
|
||||
private syncCanonicalConfigIntoManagedHome(
|
||||
managedHomePath: string,
|
||||
canonicalConfig = this.readCanonicalConfigForManagedHome(managedHomePath)
|
||||
canonicalConfig = this.readCanonicalConfigForManagedHome(managedHomePath),
|
||||
expectedAccountId?: string
|
||||
): void {
|
||||
if (canonicalConfig === null) {
|
||||
return
|
||||
}
|
||||
|
||||
const trustedManagedHomePath = this.assertManagedHomePath(managedHomePath)
|
||||
const trustedManagedHomePath = this.assertManagedHomePath(managedHomePath, expectedAccountId)
|
||||
if (this.isSelfContainedHostManagedHome(trustedManagedHomePath)) {
|
||||
// Why: this home is codex's live CODEX_HOME, so mirror config with the
|
||||
// trust-preserving merge — the plain overwrite below would wipe the
|
||||
@@ -703,11 +705,11 @@ export class CodexAccountService {
|
||||
const wslInfo = parseWslUncPath(account.managedHomePath)
|
||||
if (wslInfo && process.platform === 'win32') {
|
||||
this.ensureExpectedWslManagedHomeForReauthentication(account, wslInfo)
|
||||
return this.assertManagedHomePath(account.managedHomePath)
|
||||
return this.assertManagedHomePath(account.managedHomePath, account.id)
|
||||
}
|
||||
|
||||
try {
|
||||
return this.assertManagedHomePath(account.managedHomePath)
|
||||
return this.assertManagedHomePath(account.managedHomePath, account.id)
|
||||
} catch (error) {
|
||||
if (!this.isMissingManagedHomeError(error)) {
|
||||
throw error
|
||||
@@ -729,7 +731,7 @@ export class CodexAccountService {
|
||||
// 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)
|
||||
return this.assertManagedHomePath(expectedManagedHomePath, account.id)
|
||||
}
|
||||
|
||||
private ensureExpectedWslManagedHomeForReauthentication(
|
||||
@@ -786,7 +788,7 @@ export class CodexAccountService {
|
||||
return resolvedLeft === resolvedRight
|
||||
}
|
||||
|
||||
private assertManagedHomePath(candidatePath: string): string {
|
||||
private assertManagedHomePath(candidatePath: string, expectedAccountId?: string): string {
|
||||
const wslInfo = parseWslUncPath(candidatePath)
|
||||
if (wslInfo) {
|
||||
if (
|
||||
@@ -795,6 +797,12 @@ export class CodexAccountService {
|
||||
) {
|
||||
throw new Error('Managed WSL Codex home is outside Orca account storage.')
|
||||
}
|
||||
if (
|
||||
expectedAccountId !== undefined &&
|
||||
!wslInfo.linuxPath.endsWith(`/.local/share/orca/codex-accounts/${expectedAccountId}/home`)
|
||||
) {
|
||||
throw new Error('Managed WSL Codex home does not match its persisted account ID.')
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
try {
|
||||
@@ -814,7 +822,16 @@ export class CodexAccountService {
|
||||
'candidate_real=$(readlink -f -- "$candidate")',
|
||||
'managed_root_real=$(readlink -f -- "$managed_root")',
|
||||
'test -f "$candidate_real/.orca-managed-home"',
|
||||
'case "$candidate_real" in "$managed_root_real"/*/home) printf "%s\\n" "$candidate_real" ;; *) exit 35 ;; esac'
|
||||
...(expectedAccountId === undefined
|
||||
? [
|
||||
'case "$candidate_real" in "$managed_root_real"/*/home) printf "%s\\n" "$candidate_real" ;; *) exit 35 ;; esac'
|
||||
]
|
||||
: [
|
||||
`expected_marker=${shellQuote(expectedAccountId)}`,
|
||||
'test "$candidate_real" = "$managed_root_real/$expected_marker/home"',
|
||||
'test "$(cat "$candidate_real/.orca-managed-home")" = "$expected_marker"',
|
||||
'printf "%s\\n" "$candidate_real"'
|
||||
])
|
||||
].join('\n')
|
||||
)
|
||||
],
|
||||
@@ -840,13 +857,21 @@ export class CodexAccountService {
|
||||
if (!existsSync(join(candidatePath, '.orca-managed-home'))) {
|
||||
throw new Error('Managed Codex home is missing Orca ownership marker.')
|
||||
}
|
||||
if (
|
||||
expectedAccountId !== undefined &&
|
||||
readFileSync(join(candidatePath, '.orca-managed-home'), 'utf-8').trim() !==
|
||||
expectedAccountId
|
||||
) {
|
||||
throw new Error('Managed WSL Codex home ownership marker does not match its account ID.')
|
||||
}
|
||||
return candidatePath
|
||||
}
|
||||
|
||||
return assertOwnedHostCodexManagedHomePath({
|
||||
candidatePath,
|
||||
managedAccountsRoot: this.getManagedAccountsRoot(),
|
||||
systemCodexHomePath: getSystemCodexHomePath()
|
||||
systemCodexHomePath: getSystemCodexHomePath(),
|
||||
expectedAccountId
|
||||
})
|
||||
}
|
||||
|
||||
@@ -892,10 +917,10 @@ export class CodexAccountService {
|
||||
}
|
||||
}
|
||||
|
||||
private safeRemoveManagedHome(candidatePath: string): void {
|
||||
private safeRemoveManagedHome(candidatePath: string, expectedAccountId: string): void {
|
||||
let managedHomePath: string
|
||||
try {
|
||||
managedHomePath = this.assertManagedHomePath(candidatePath)
|
||||
managedHomePath = this.assertManagedHomePath(candidatePath, expectedAccountId)
|
||||
} catch (error) {
|
||||
console.warn('[codex-accounts] Refusing to remove untrusted managed home:', error)
|
||||
return
|
||||
@@ -1060,8 +1085,13 @@ export class CodexAccountService {
|
||||
}
|
||||
}
|
||||
|
||||
private readIdentityFromHome(managedHomePath: string): ResolvedCodexIdentity {
|
||||
return this.resolveIdentityFromCredentials(this.loadOAuthCredentials(managedHomePath))
|
||||
private readIdentityFromHome(
|
||||
managedHomePath: string,
|
||||
expectedAccountId: string
|
||||
): ResolvedCodexIdentity {
|
||||
return this.resolveIdentityFromCredentials(
|
||||
this.loadOAuthCredentials(managedHomePath, expectedAccountId)
|
||||
)
|
||||
}
|
||||
|
||||
private resolveIdentityFromCredentials(
|
||||
@@ -1092,8 +1122,14 @@ export class CodexAccountService {
|
||||
}
|
||||
}
|
||||
|
||||
private loadOAuthCredentials(managedHomePath: string): CodexOAuthCredentials {
|
||||
const authFilePath = join(this.assertManagedHomePath(managedHomePath), 'auth.json')
|
||||
private loadOAuthCredentials(
|
||||
managedHomePath: string,
|
||||
expectedAccountId: string
|
||||
): CodexOAuthCredentials {
|
||||
const authFilePath = join(
|
||||
this.assertManagedHomePath(managedHomePath, expectedAccountId),
|
||||
'auth.json'
|
||||
)
|
||||
return this.extractOAuthCredentials(
|
||||
JSON.parse(readFileSync(authFilePath, 'utf-8')) as Record<string, unknown>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user