fix(codex): keep the Codex app's bundled plugins in Orca-managed homes (#33621)

* fix(codex): keep the Codex app's bundled plugins in Orca-managed homes

Codex only loads the openai-bundled marketplace when its source sits under
the active CODEX_HOME. The config mirror copied ~/.codex's source verbatim,
so managed homes silently dropped bundled plugins such as visualize.
Link ~/.codex/.tmp/bundled-marketplaces into managed homes and rebase the
mirrored source onto it.

* fix(codex): rebase bundled marketplace onto the canonical managed home

Codex canonicalizes CODEX_HOME before comparing the bundled marketplace
source, so a managed home reached through a symlink (symlinked userData,
/tmp on macOS) was still rejected.
This commit is contained in:
Neil
2026-10-27 13:19:22 -07:00
committed by GitHub
parent f57d00f045
commit 43c8ee5e50
4 changed files with 192 additions and 33 deletions
@@ -1,5 +1,12 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import {
existsSync,
mkdirSync,
readFileSync,
realpathSync,
symlinkSync,
writeFileSync
} from 'node:fs'
import { join } from 'node:path'
import { createSettings } from './runtime-home-settings-test-fixtures'
import {
@@ -59,6 +66,85 @@ describe('CodexRuntimeHomeService', () => {
)
})
it('keeps the Codex app bundled marketplace loadable from the managed home', async () => {
const systemCodexHome = getSystemCodexHomePath()
const systemMarketplace = join(
systemCodexHome,
'.tmp',
'bundled-marketplaces',
'openai-bundled'
)
mkdirSync(systemMarketplace, { recursive: true })
const userMarketplace = join(testState.fakeHomeDir, 'marketplaces', 'mine')
const canonicalConfig = [
'[marketplaces.openai-bundled]',
'source_type = "local"',
`source = '${systemMarketplace}'`,
'',
'[marketplaces.mine]',
'source_type = "local"',
`source = '${userMarketplace}'`,
''
].join('\n')
writeFileSync(join(systemCodexHome, 'config.toml'), canonicalConfig, 'utf-8')
const store = createStore(createSettings())
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
const service = new CodexRuntimeHomeService(store as never)
service.prepareForCodexLaunch()
service.prepareForCodexLaunch()
// Codex rejects openai-bundled unless its source is under the active CODEX_HOME.
const runtimeMarketplace = join(
realpathSync.native(getRuntimeCodexHomePath()),
'.tmp',
'bundled-marketplaces',
'openai-bundled'
)
const runtimeConfig = readFileSync(join(getRuntimeCodexHomePath(), 'config.toml'), 'utf-8')
expect(runtimeConfig).toContain(`source = '${runtimeMarketplace}'`)
expect(runtimeConfig).toContain(`source = '${userMarketplace}'`)
expect(existsSync(runtimeMarketplace)).toBe(true)
expect(readFileSync(join(systemCodexHome, 'config.toml'), 'utf-8')).toBe(canonicalConfig)
})
it('rebases the bundled marketplace onto the canonical path of a symlinked managed home', async () => {
const systemMarketplace = join(
getSystemCodexHomePath(),
'.tmp',
'bundled-marketplaces',
'openai-bundled'
)
mkdirSync(systemMarketplace, { recursive: true })
writeFileSync(
join(getSystemCodexHomePath(), 'config.toml'),
`[marketplaces.openai-bundled]\nsource_type = "local"\nsource = '${systemMarketplace}'\n`,
'utf-8'
)
const linkedUserData = join(testState.fakeHomeDir, 'linked-user-data')
symlinkSync(testState.userDataDir, linkedUserData, 'junction')
process.env.ORCA_USER_DATA_PATH = linkedUserData
const store = createStore(createSettings())
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
const service = new CodexRuntimeHomeService(store as never)
service.prepareForCodexLaunch()
// Codex compares against its canonicalized CODEX_HOME, so the source must not keep the link.
const canonicalMarketplace = join(
realpathSync.native(testState.userDataDir),
'codex-runtime-home',
'home',
'.tmp',
'bundled-marketplaces',
'openai-bundled'
)
expect(readFileSync(join(getRuntimeCodexHomePath(), 'config.toml'), 'utf-8')).toContain(
`source = '${canonicalMarketplace}'`
)
expect(existsSync(canonicalMarketplace)).toBe(true)
})
it('launches the system-default custom provider without requiring OAuth auth', async () => {
const systemCodexHome = getSystemCodexHomePath()
const canonicalConfigPath = join(systemCodexHome, 'config.toml')
+33 -9
View File
@@ -7,7 +7,10 @@ import {
writeFileAtomicallyIfUnchanged
} from '../codex-accounts/fs-utils'
import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from './codex-home-paths'
import { rewriteRelativePathConfigValues } from './codex-config-path-reference-rewrite'
import {
rebaseBundledMarketplaceSources,
rewriteRelativePathConfigValues
} from './codex-config-path-reference-rewrite'
import { normalizeDeprecatedCodexHookFeatureFlag } from './config-toml-deprecated-hook-flag'
import { parseWslUncPath } from '../../shared/wsl-paths'
import {
@@ -144,11 +147,19 @@ export function syncSystemConfigIntoLegacySharedCodexHome(
runtimeConfigBeforeMirror !== null
? mergeSystemCodexConfigIntoRuntime(
runtimeConfigBeforeMirror,
prepareSystemConfigForRuntimeMirror(rawSystemConfig, sourceConfigDir),
prepareSystemConfigForRuntimeMirror(
rawSystemConfig,
sourceConfigDir,
homes.runtimeHomePath
),
sourceConfigDir,
...readLegacySharedHomeMcpOwnership(homes.runtimeHomePath)
)
: prepareSystemConfigForFreshRuntimeMirror(rawSystemConfig, sourceConfigDir)
: prepareSystemConfigForFreshRuntimeMirror(
rawSystemConfig,
sourceConfigDir,
homes.runtimeHomePath
)
}
const nextRuntimeConfig = applyCodexDaemonSocketGuard(
mirroredRuntimeConfig,
@@ -226,7 +237,7 @@ function syncSystemConfigIntoManagedCodexHomeUnsafe(
const sourceConfigDir = resolveCodexConfigMirrorSourceDirectory(systemHomePath, systemConfigDir)
if (!runtimeConfigExists) {
const freshRuntimeConfig = applyCodexDaemonSocketGuard(
prepareSystemConfigForFreshRuntimeMirror(rawSystemConfig, sourceConfigDir),
prepareSystemConfigForFreshRuntimeMirror(rawSystemConfig, sourceConfigDir, runtimeHomePath),
runtimeHomePath
)
const ownership = readMcpServerTomlOwnership(freshRuntimeConfig)
@@ -239,7 +250,11 @@ function syncSystemConfigIntoManagedCodexHomeUnsafe(
}
}
const systemConfig = prepareSystemConfigForRuntimeMirror(rawSystemConfig, sourceConfigDir)
const systemConfig = prepareSystemConfigForRuntimeMirror(
rawSystemConfig,
sourceConfigDir,
runtimeHomePath
)
const { names: mirroredMcpServerNames, ownsRoot: mirroredMcpServerRoot } =
readMcpServerTomlOwnership(systemConfig)
// Reuse the observed snapshot rather than mixing reads across path-state changes.
@@ -277,21 +292,30 @@ export function resolveCodexConfigMirrorSourceDirectory(
)
}
function prepareSystemConfigForRuntimeMirror(config: string, systemConfigDir: string): string {
return rewriteRelativePathConfigValues(
function prepareSystemConfigForRuntimeMirror(
config: string,
systemConfigDir: string,
runtimeHomePath?: string
): string {
const prepared = rewriteRelativePathConfigValues(
normalizeDeprecatedCodexHookFeatureFlag(config),
systemConfigDir
)
// WSL guest homes do not receive host resource links.
return runtimeHomePath && !parseWslUncPath(runtimeHomePath)
? rebaseBundledMarketplaceSources(prepared, systemConfigDir, runtimeHomePath)
: prepared
}
// Install re-keys user-hook trust; plugin/project trust carries unchanged.
// WSL systemConfigDir must name the Linux-side config directory.
export function prepareSystemConfigForFreshRuntimeMirror(
config: string,
systemConfigDir: string
systemConfigDir: string,
runtimeHomePath?: string
): string {
return stripRuntimeOwnedTomlSections(
prepareSystemConfigForRuntimeMirror(config, systemConfigDir),
prepareSystemConfigForRuntimeMirror(config, systemConfigDir, runtimeHomePath),
new Set(),
{ systemHomeDir: systemConfigDir, runtimeHookTrustKeys: new Set() }
)
@@ -1,4 +1,9 @@
import { posix as pathPosix, win32 as pathWin32 } from 'node:path'
import { realpathSync } from 'node:fs'
import { join, posix as pathPosix, resolve, win32 as pathWin32 } from 'node:path'
import {
CODEX_BUNDLED_MARKETPLACE_NAMES,
CODEX_BUNDLED_MARKETPLACES_ENTRY
} from './codex-home-paths'
import {
createTomlLineScanState,
getTomlTableHeader,
@@ -23,6 +28,56 @@ const EXACT_PATH_CONFIG_KEYS = new Set([
// Preserve original path targets when moving config.toml to a managed home.
// Inline tables, quoted keys and multiline strings pass through unchanged.
export function rewriteRelativePathConfigValues(config: string, sourceConfigDir: string): string {
// WSL reads config over UNC but consumes it in Linux; join using the source home's path style.
const path = sourceConfigDir.startsWith('/') ? pathPosix : pathWin32
return rewriteTomlStringSettings(config, (keyPath, value) =>
isPathConfigKey(keyPath) && shouldRewriteRelativePath(value)
? path.join(sourceConfigDir, value)
: null
)
}
// Codex only trusts openai-bundled marketplaces whose source sits under the canonical CODEX_HOME.
export function rebaseBundledMarketplaceSources(
config: string,
systemHomeDir: string,
runtimeHomeDir: string
): string {
const systemHomeDirs = [systemHomeDir, resolveRealPath(systemHomeDir)]
const canonicalRuntimeHomeDir = resolveRealPath(runtimeHomeDir)
return rewriteTomlStringSettings(config, (keyPath, value) => {
const name = CODEX_BUNDLED_MARKETPLACE_NAMES.find(
(candidate) => keyPath === `marketplaces.${candidate}.source`
)
const pointsAtSystemHome =
name !== undefined &&
systemHomeDirs.some((dir) =>
isSamePath(value, join(dir, CODEX_BUNDLED_MARKETPLACES_ENTRY, name))
)
return pointsAtSystemHome
? join(canonicalRuntimeHomeDir, CODEX_BUNDLED_MARKETPLACES_ENTRY, name)
: null
})
}
function resolveRealPath(path: string): string {
try {
return realpathSync.native(path)
} catch {
return path
}
}
function isSamePath(left: string, right: string): boolean {
return process.platform === 'win32'
? resolve(left).toLowerCase() === resolve(right).toLowerCase()
: resolve(left) === resolve(right)
}
function rewriteTomlStringSettings(
config: string,
rewrite: (keyPath: string, value: string) => string | null
): string {
const lines = config.split('\n')
let tablePath = ''
let scanState = createTomlLineScanState()
@@ -32,9 +87,9 @@ export function rewriteRelativePathConfigValues(config: string, sourceConfigDir:
if (isTomlStructuralLine(scanState)) {
const header = getTomlTableHeader(line)
if (header) {
tablePath = getTomlHeaderPath(header)
tablePath = normalizeTomlPathExpression(getTomlHeaderPath(header))
} else {
lines[index] = rewriteRelativePathConfigLine(line, tablePath, sourceConfigDir)
lines[index] = rewriteTomlStringSettingLine(line, tablePath, rewrite)
}
}
scanState = updateTomlLineScanState(scanState, line)
@@ -43,37 +98,25 @@ export function rewriteRelativePathConfigValues(config: string, sourceConfigDir:
return lines.join('\n')
}
function rewriteRelativePathConfigLine(
function rewriteTomlStringSettingLine(
line: string,
tablePath: string,
sourceConfigDir: string
rewrite: (keyPath: string, value: string) => string | null
): string {
const equalsIndex = line.indexOf('=')
if (equalsIndex === -1) {
return line
}
const key = line.slice(0, equalsIndex).trim()
if (!isPathConfigKey(tablePath, key)) {
return line
}
const key = normalizeTomlPathExpression(line.slice(0, equalsIndex).trim())
const parsed = parseTomlSingleLineStringValue(line, equalsIndex + 1)
if (!parsed || !shouldRewriteRelativePath(parsed.value)) {
const rewritten = parsed && rewrite(tablePath ? `${tablePath}.${key}` : key, parsed.value)
if (!parsed || rewritten === null) {
return line
}
// WSL reads config over UNC but consumes it in Linux; join using the source home's path style.
const path = sourceConfigDir.startsWith('/') ? pathPosix : pathWin32
const absolutePath = path.join(sourceConfigDir, parsed.value)
return `${line.slice(0, parsed.start)}${quoteTomlPath(absolutePath)}${line.slice(parsed.end)}`
return `${line.slice(0, parsed.start)}${quoteTomlPath(rewritten)}${line.slice(parsed.end)}`
}
function isPathConfigKey(tablePath: string, key: string): boolean {
const normalizedKey = normalizeTomlPathExpression(key)
const fullPath = tablePath
? `${normalizeTomlPathExpression(tablePath)}.${normalizedKey}`
: normalizedKey
function isPathConfigKey(fullPath: string): boolean {
if (EXACT_PATH_CONFIG_KEYS.has(fullPath)) {
return true
}
+7 -1
View File
@@ -11,7 +11,7 @@ import {
unlinkSync
} from 'node:fs'
import { homedir } from 'node:os'
import { join } from 'node:path'
import { dirname, join } from 'node:path'
import {
clearCopiedResourceMarker,
markCopiedResource,
@@ -22,6 +22,10 @@ import { observe, observeResolvedPathEntry } from './codex-path-observation'
const CODEX_GLOBAL_INSTRUCTIONS_ENTRY = 'AGENTS.md'
// The Codex app installs these under ~/.codex; Codex rejects them from any other home.
export const CODEX_BUNDLED_MARKETPLACES_ENTRY = join('.tmp', 'bundled-marketplaces')
export const CODEX_BUNDLED_MARKETPLACE_NAMES = ['openai-bundled', 'openai-bundled-alpha'] as const
const CODEX_SYSTEM_RESOURCE_ENTRIES = [
'skills',
'hooks',
@@ -30,6 +34,7 @@ const CODEX_SYSTEM_RESOURCE_ENTRIES = [
'profile-v2',
'themes',
'prompts',
CODEX_BUNDLED_MARKETPLACES_ENTRY,
CODEX_GLOBAL_INSTRUCTIONS_ENTRY
] as const
@@ -155,6 +160,7 @@ function linkSystemCodexResource(
rmSync(targetPath, { recursive: true, force: true })
}
mkdirSync(dirname(targetPath), { recursive: true })
if (preferCopy) {
copySystemCodexResourceAsOwnedFallback(sourcePath, targetPath, managedHomePath, entryName)
return