diff --git a/src/main/codex-accounts/runtime-home-system-resource-materialization.test.ts b/src/main/codex-accounts/runtime-home-system-resource-materialization.test.ts index b4389c0eea1..f95b3b81996 100644 --- a/src/main/codex-accounts/runtime-home-system-resource-materialization.test.ts +++ b/src/main/codex-accounts/runtime-home-system-resource-materialization.test.ts @@ -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') diff --git a/src/main/codex/codex-config-mirror.ts b/src/main/codex/codex-config-mirror.ts index 6ead142a9e4..0722810c72e 100644 --- a/src/main/codex/codex-config-mirror.ts +++ b/src/main/codex/codex-config-mirror.ts @@ -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() } ) diff --git a/src/main/codex/codex-config-path-reference-rewrite.ts b/src/main/codex/codex-config-path-reference-rewrite.ts index 7fbbf1a5000..0916f1d3b1f 100644 --- a/src/main/codex/codex-config-path-reference-rewrite.ts +++ b/src/main/codex/codex-config-path-reference-rewrite.ts @@ -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 } diff --git a/src/main/codex/codex-home-paths.ts b/src/main/codex/codex-home-paths.ts index d7e8ddecb96..8cf433c6689 100644 --- a/src/main/codex/codex-home-paths.ts +++ b/src/main/codex/codex-home-paths.ts @@ -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