mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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')
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user