896 lines
25 KiB
TypeScript
896 lines
25 KiB
TypeScript
/* eslint-disable max-lines -- Why: launch-home materialization needs path
|
|
safety, link/copy fallback, reconciliation, and cleanup in one place so
|
|
auth-only account isolation cannot drift across platforms. */
|
|
import {
|
|
cpSync,
|
|
existsSync,
|
|
lstatSync,
|
|
linkSync,
|
|
mkdirSync,
|
|
readFileSync,
|
|
readlinkSync,
|
|
readdirSync,
|
|
renameSync,
|
|
rmdirSync,
|
|
rmSync,
|
|
statSync,
|
|
symlinkSync,
|
|
unlinkSync,
|
|
writeFileSync
|
|
} from 'node:fs'
|
|
import { execFileSync } from 'node:child_process'
|
|
import { createHash } from 'node:crypto'
|
|
import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
|
|
import { parseWslUncPath } from '../../shared/wsl-paths'
|
|
import { getOrcaManagedCodexHomePath } from './codex-home-paths'
|
|
|
|
const LAUNCH_HOME_MARKER = '.orca-managed-launch-home'
|
|
const LAUNCH_HOME_LINK_MARKERS_DIR = '.orca-launch-home-links'
|
|
const LAUNCH_HOME_MARKER_VERSION = 1
|
|
const SHARED_LAUNCH_ENTRY_NAMES = new Set([
|
|
'config.toml',
|
|
'hooks.json',
|
|
'history.jsonl',
|
|
'sessions',
|
|
'skills',
|
|
'plugins',
|
|
'plugin-state',
|
|
'profile-v2',
|
|
'themes',
|
|
'prompts'
|
|
])
|
|
const MUTABLE_SHARED_FILE_ENTRIES = new Set([
|
|
'config.toml',
|
|
'hooks.json',
|
|
'history.jsonl',
|
|
'profile-v2'
|
|
])
|
|
const MUTABLE_SHARED_DIRECTORY_ENTRIES = new Set(['sessions', 'plugin-state', 'profile-v2'])
|
|
|
|
type LaunchEntryMarker = {
|
|
version: number
|
|
sourcePath: string
|
|
mode: 'link' | 'copy'
|
|
targetDigest: string | null
|
|
sourceDigest: string | null
|
|
}
|
|
|
|
export function getOrcaCodexLaunchHomePath(accountId: string | null): string {
|
|
return getScopedCodexLaunchHomePath(getOrcaCodexLaunchHostRootPath(), accountId)
|
|
}
|
|
|
|
export function ensureOrcaCodexLaunchHome(accountId: string | null): string {
|
|
return ensureScopedCodexLaunchHome(getOrcaCodexLaunchHostRootPath(), accountId)
|
|
}
|
|
|
|
export function materializeOrcaCodexLaunchHome(accountId: string | null): string {
|
|
return materializeScopedCodexLaunchHome(
|
|
getOrcaManagedCodexHomePath(),
|
|
getOrcaCodexLaunchHostRootPath(),
|
|
accountId
|
|
)
|
|
}
|
|
|
|
export function removeOrcaCodexLaunchHome(accountId: string): void {
|
|
removeScopedCodexLaunchHome(
|
|
getOrcaCodexLaunchHostRootPathWithOptions({ create: false }),
|
|
accountId
|
|
)
|
|
}
|
|
|
|
export function materializeOrcaCodexActiveHome(launchHomePath: string): string {
|
|
return pointActiveCodexHomeAtLaunchHome(getOrcaCodexActiveHostHomePath(), launchHomePath)
|
|
}
|
|
|
|
export function pointActiveCodexHomeAtLaunchHome(
|
|
activeHomePath: string,
|
|
launchHomePath: string
|
|
): string {
|
|
if (pointWslActiveCodexHomeAtLaunchHome(activeHomePath, launchHomePath)) {
|
|
return activeHomePath
|
|
}
|
|
mkdirSync(dirname(activeHomePath), { recursive: true })
|
|
if (activeHomeAlreadyPointsToLaunchHome(activeHomePath, launchHomePath)) {
|
|
return activeHomePath
|
|
}
|
|
|
|
const nextLinkPath = `${activeHomePath}.next-${process.pid}-${Date.now()}`
|
|
removeActiveHomeLinkIfOwned(nextLinkPath)
|
|
try {
|
|
createSharedEntryLink(launchHomePath, nextLinkPath)
|
|
replaceActiveHomeLink(activeHomePath, nextLinkPath)
|
|
return activeHomePath
|
|
} catch (error) {
|
|
removeActiveHomeLinkIfOwned(nextLinkPath)
|
|
console.warn('[codex-home] Failed to point active Codex home at launch home:', error)
|
|
return launchHomePath
|
|
}
|
|
}
|
|
|
|
function pointWslActiveCodexHomeAtLaunchHome(
|
|
activeHomePath: string,
|
|
launchHomePath: string
|
|
): boolean {
|
|
const paths = getSameDistroWslPaths(launchHomePath, activeHomePath)
|
|
if (!paths) {
|
|
return false
|
|
}
|
|
const nextLinuxPath = `${paths.targetLinuxPath}.next-${process.pid}-${Date.now()}`
|
|
try {
|
|
execFileSync(
|
|
'wsl.exe',
|
|
[
|
|
'-d',
|
|
paths.distro,
|
|
'--',
|
|
'bash',
|
|
'-lc',
|
|
'mkdir -p "$(dirname "$2")" && rm -f -- "$3" && ln -s -- "$1" "$3" && mv -Tf -- "$3" "$2"',
|
|
'sh',
|
|
paths.sourceLinuxPath,
|
|
paths.targetLinuxPath,
|
|
nextLinuxPath
|
|
],
|
|
{
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
timeout: 5000
|
|
}
|
|
)
|
|
return true
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
export function getScopedCodexLaunchHomePath(
|
|
launchRootPath: string,
|
|
accountId: string | null
|
|
): string {
|
|
const launchHomePath = resolveCodexLaunchHomePath(launchRootPath, accountId)
|
|
mkdirSync(launchHomePath, { recursive: true })
|
|
return launchHomePath
|
|
}
|
|
|
|
export function ensureScopedCodexLaunchHome(
|
|
launchRootPath: string,
|
|
accountId: string | null
|
|
): string {
|
|
const launchHomePath = getScopedCodexLaunchHomePath(launchRootPath, accountId)
|
|
writeLaunchHomeMarker(launchHomePath, accountId)
|
|
return launchHomePath
|
|
}
|
|
|
|
export function materializeScopedCodexLaunchHome(
|
|
sharedHomePath: string,
|
|
launchRootPath: string,
|
|
accountId: string | null
|
|
): string {
|
|
reconcileMutableLaunchHomeFilesIntoSharedHome(sharedHomePath, launchRootPath)
|
|
const launchHomePath = getScopedCodexLaunchHomePath(launchRootPath, accountId)
|
|
writeLaunchHomeMarker(launchHomePath, accountId)
|
|
|
|
const sharedEntries = new Set<string>()
|
|
for (const entryName of listSharedLaunchEntryNames(sharedHomePath)) {
|
|
sharedEntries.add(entryName)
|
|
linkSharedEntryIntoLaunchHome(sharedHomePath, launchHomePath, entryName)
|
|
}
|
|
removeStaleLaunchHomeEntries(launchHomePath, sharedHomePath, sharedEntries)
|
|
return launchHomePath
|
|
}
|
|
|
|
export function removeScopedCodexLaunchHome(launchRootPath: string, accountId: string): void {
|
|
const launchHomePath = resolveCodexLaunchHomePath(launchRootPath, accountId)
|
|
if (!existsSync(launchHomePath)) {
|
|
return
|
|
}
|
|
const launchHomeStat = lstatSync(launchHomePath)
|
|
if (!launchHomeStat.isDirectory() || launchHomeStat.isSymbolicLink()) {
|
|
console.warn('[codex-home] Refusing to remove unexpected launch-home root:', launchHomePath)
|
|
return
|
|
}
|
|
if (!isMarkedLaunchHomeForAccount(launchHomePath, accountId)) {
|
|
// Why: older builds could write auth before the launch-home marker existed.
|
|
// Remove only the deterministic credential file, not an unmarked directory.
|
|
rmSync(join(launchHomePath, 'auth.json'), { force: true })
|
|
return
|
|
}
|
|
if (!isContainedPath(launchRootPath, launchHomePath)) {
|
|
console.warn('[codex-home] Refusing to remove launch home outside host root:', launchHomePath)
|
|
return
|
|
}
|
|
if (removeWslPathIfPossible(launchHomePath)) {
|
|
return
|
|
}
|
|
rmSync(launchHomePath, { recursive: true, force: true })
|
|
}
|
|
|
|
function getOrcaCodexLaunchHostRootPath(): string {
|
|
return getOrcaCodexLaunchHostRootPathWithOptions({ create: true })
|
|
}
|
|
|
|
function getOrcaCodexLaunchHostRootPathWithOptions(options: { create: boolean }): string {
|
|
const rootPath = join(dirname(getOrcaManagedCodexHomePath()), 'launch', 'host')
|
|
if (options.create) {
|
|
mkdirSync(rootPath, { recursive: true })
|
|
}
|
|
return rootPath
|
|
}
|
|
|
|
function getOrcaCodexActiveHostHomePath(): string {
|
|
return join(dirname(getOrcaManagedCodexHomePath()), 'active', 'host', 'home')
|
|
}
|
|
|
|
function resolveCodexLaunchHomePath(launchRootPath: string, accountId: string | null): string {
|
|
return join(launchRootPath, getLaunchSelectionSegment(accountId), 'home')
|
|
}
|
|
|
|
function getLaunchSelectionSegment(accountId: string | null): string {
|
|
if (accountId === null) {
|
|
return 'system'
|
|
}
|
|
return `account-${createHash('sha256').update(accountId).digest('hex').slice(0, 32)}`
|
|
}
|
|
|
|
function listSharedLaunchEntryNames(sharedHomePath: string): string[] {
|
|
try {
|
|
const sharedEntries = new Set<string>()
|
|
for (const entryName of readdirSync(sharedHomePath)) {
|
|
if (!isSharedLaunchEntryName(entryName)) {
|
|
continue
|
|
}
|
|
sharedEntries.add(entryName)
|
|
if (entryName.endsWith('.sqlite')) {
|
|
sharedEntries.add(`${entryName}-wal`)
|
|
sharedEntries.add(`${entryName}-shm`)
|
|
}
|
|
}
|
|
return [...sharedEntries].sort()
|
|
} catch {
|
|
return []
|
|
}
|
|
}
|
|
|
|
function isSharedLaunchEntryName(entryName: string): boolean {
|
|
return SHARED_LAUNCH_ENTRY_NAMES.has(entryName) || isCodexSqliteEntryName(entryName)
|
|
}
|
|
|
|
function isCodexSqliteEntryName(entryName: string): boolean {
|
|
return (
|
|
entryName.endsWith('.sqlite') ||
|
|
entryName.endsWith('.sqlite-wal') ||
|
|
entryName.endsWith('.sqlite-shm')
|
|
)
|
|
}
|
|
|
|
function isCodexSqliteSidecarEntryName(entryName: string): boolean {
|
|
return entryName.endsWith('.sqlite-wal') || entryName.endsWith('.sqlite-shm')
|
|
}
|
|
|
|
function getCodexSqliteMainEntryName(entryName: string): string | null {
|
|
if (entryName.endsWith('.sqlite-wal')) {
|
|
return entryName.slice(0, -'-wal'.length)
|
|
}
|
|
if (entryName.endsWith('.sqlite-shm')) {
|
|
return entryName.slice(0, -'-shm'.length)
|
|
}
|
|
return null
|
|
}
|
|
|
|
function linkSharedEntryIntoLaunchHome(
|
|
sharedHomePath: string,
|
|
launchHomePath: string,
|
|
entryName: string
|
|
): void {
|
|
const sourcePath = join(sharedHomePath, entryName)
|
|
const targetPath = join(launchHomePath, entryName)
|
|
const existingMarker = readLaunchEntryMarker(launchHomePath, entryName)
|
|
reconcileMutableLaunchEntryIfNeeded(sourcePath, targetPath, existingMarker)
|
|
|
|
if (!existsSync(sourcePath) && !canLinkMissingSharedEntry(sharedHomePath, entryName)) {
|
|
removeLaunchEntryIfOwned(targetPath, launchHomePath, entryName, sourcePath)
|
|
return
|
|
}
|
|
materializeMissingSharedEntryIfNeeded(sourcePath, entryName)
|
|
if (targetAlreadyPointsToSource(targetPath, sourcePath)) {
|
|
markLaunchEntry(launchHomePath, entryName, sourcePath, 'link')
|
|
return
|
|
}
|
|
|
|
const ownedTarget =
|
|
existingMarker?.sourcePath === sourcePath && targetExistsForLaunchRemoval(targetPath)
|
|
if (targetExistsForLaunchRemoval(targetPath) && !ownedTarget) {
|
|
if (!replaceUnownedLaunchEntryAllowed(launchHomePath, entryName)) {
|
|
return
|
|
}
|
|
removeLaunchEntry(targetPath)
|
|
}
|
|
if (ownedTarget) {
|
|
removeLaunchEntry(targetPath)
|
|
}
|
|
|
|
try {
|
|
createSharedEntryLink(sourcePath, targetPath)
|
|
markLaunchEntry(launchHomePath, entryName, sourcePath, 'link')
|
|
} catch (error) {
|
|
if (!copyFallbackAllowed(sourcePath, entryName)) {
|
|
console.warn('[codex-home] Failed to link shared Codex launch entry:', entryName, error)
|
|
return
|
|
}
|
|
try {
|
|
removeLaunchEntry(targetPath)
|
|
cpSync(sourcePath, targetPath, {
|
|
recursive: true,
|
|
force: false,
|
|
errorOnExist: true,
|
|
dereference: true
|
|
})
|
|
markLaunchEntry(launchHomePath, entryName, sourcePath, 'copy')
|
|
} catch {
|
|
console.warn('[codex-home] Failed to copy shared Codex launch entry:', entryName, error)
|
|
}
|
|
}
|
|
}
|
|
|
|
function materializeMissingSharedEntryIfNeeded(sourcePath: string, entryName: string): void {
|
|
if (
|
|
process.platform !== 'win32' ||
|
|
!isCodexSqliteSidecarEntryName(entryName) ||
|
|
existsSync(sourcePath)
|
|
) {
|
|
return
|
|
}
|
|
try {
|
|
writeFileSync(sourcePath, '', { flag: 'wx', mode: 0o600 })
|
|
} catch (error) {
|
|
if (existsSync(sourcePath)) {
|
|
return
|
|
}
|
|
throw error
|
|
}
|
|
}
|
|
|
|
function createSharedEntryLink(sourcePath: string, targetPath: string): void {
|
|
if (createWslSymlinkIfPossible(sourcePath, targetPath)) {
|
|
return
|
|
}
|
|
const sourceStat = existsSync(sourcePath) ? lstatSync(sourcePath) : null
|
|
if (
|
|
sourceStat?.isFile() &&
|
|
process.platform === 'win32' &&
|
|
createHardLinkIfPossible(sourcePath, targetPath)
|
|
) {
|
|
return
|
|
}
|
|
if (targetAlreadyPointsToSource(targetPath, sourcePath)) {
|
|
return
|
|
}
|
|
try {
|
|
symlinkSync(
|
|
sourcePath,
|
|
targetPath,
|
|
sourceStat?.isDirectory() && process.platform === 'win32' ? 'junction' : undefined
|
|
)
|
|
} catch (error) {
|
|
if (targetAlreadyPointsToSource(targetPath, sourcePath)) {
|
|
return
|
|
}
|
|
if (
|
|
sourceStat?.isFile() &&
|
|
process.platform === 'win32' &&
|
|
createHardLinkIfPossible(sourcePath, targetPath)
|
|
) {
|
|
return
|
|
}
|
|
if (targetAlreadyPointsToSource(targetPath, sourcePath)) {
|
|
return
|
|
}
|
|
throw error
|
|
}
|
|
}
|
|
|
|
function createHardLinkIfPossible(sourcePath: string, targetPath: string): boolean {
|
|
try {
|
|
linkSync(sourcePath, targetPath)
|
|
return true
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
function createWslSymlinkIfPossible(sourcePath: string, targetPath: string): boolean {
|
|
const paths = getSameDistroWslPaths(sourcePath, targetPath)
|
|
if (!paths) {
|
|
return false
|
|
}
|
|
execFileSync(
|
|
'wsl.exe',
|
|
['-d', paths.distro, '--', 'ln', '-s', paths.sourceLinuxPath, paths.targetLinuxPath],
|
|
{
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
timeout: 5000
|
|
}
|
|
)
|
|
return true
|
|
}
|
|
|
|
function getSameDistroWslPaths(
|
|
sourcePath: string,
|
|
targetPath: string
|
|
): { distro: string; sourceLinuxPath: string; targetLinuxPath: string } | null {
|
|
if (process.platform !== 'win32') {
|
|
return null
|
|
}
|
|
const sourceWsl = parseWslUncPath(sourcePath)
|
|
const targetWsl = parseWslUncPath(targetPath)
|
|
if (!sourceWsl || !targetWsl || sourceWsl.distro !== targetWsl.distro) {
|
|
return null
|
|
}
|
|
return {
|
|
distro: sourceWsl.distro,
|
|
sourceLinuxPath: sourceWsl.linuxPath,
|
|
targetLinuxPath: targetWsl.linuxPath
|
|
}
|
|
}
|
|
|
|
function runWslPathCommand(distro: string, args: string[]): boolean {
|
|
try {
|
|
execFileSync('wsl.exe', ['-d', distro, '--', ...args], {
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
timeout: 5000
|
|
})
|
|
return true
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
function wslPathExists(targetPath: string): boolean | null {
|
|
if (process.platform !== 'win32') {
|
|
return null
|
|
}
|
|
const targetWsl = parseWslUncPath(targetPath)
|
|
if (!targetWsl) {
|
|
return null
|
|
}
|
|
return runWslPathCommand(targetWsl.distro, [
|
|
'sh',
|
|
'-c',
|
|
'test -e "$1" || test -L "$1"',
|
|
'sh',
|
|
targetWsl.linuxPath
|
|
])
|
|
}
|
|
|
|
function removeWslPathIfPossible(targetPath: string): boolean {
|
|
if (process.platform !== 'win32') {
|
|
return false
|
|
}
|
|
const targetWsl = parseWslUncPath(targetPath)
|
|
if (!targetWsl) {
|
|
return false
|
|
}
|
|
return runWslPathCommand(targetWsl.distro, ['rm', '-rf', '--', targetWsl.linuxPath])
|
|
}
|
|
|
|
function activeHomeAlreadyPointsToLaunchHome(
|
|
activeHomePath: string,
|
|
launchHomePath: string
|
|
): boolean {
|
|
if (targetAlreadyPointsToSource(activeHomePath, launchHomePath)) {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
function replaceActiveHomeLink(activeHomePath: string, nextLinkPath: string): void {
|
|
try {
|
|
renameSync(nextLinkPath, activeHomePath)
|
|
} catch (error) {
|
|
if (!activeHomeLinkIsReplaceable(activeHomePath)) {
|
|
throw error
|
|
}
|
|
removeActiveHomeLinkIfOwned(activeHomePath)
|
|
renameSync(nextLinkPath, activeHomePath)
|
|
}
|
|
}
|
|
|
|
function activeHomeLinkIsReplaceable(activeHomePath: string): boolean {
|
|
try {
|
|
const stat = lstatSync(activeHomePath)
|
|
return stat.isSymbolicLink() || isWindowsReadableLink(activeHomePath)
|
|
} catch {
|
|
return true
|
|
}
|
|
}
|
|
|
|
function removeActiveHomeLinkIfOwned(activeHomePath: string): void {
|
|
try {
|
|
const stat = lstatSync(activeHomePath)
|
|
if (stat.isSymbolicLink()) {
|
|
unlinkSync(activeHomePath)
|
|
} else if (isWindowsReadableLink(activeHomePath)) {
|
|
rmdirSync(activeHomePath)
|
|
}
|
|
} catch {
|
|
// Missing or inaccessible temporary links are handled by the caller.
|
|
}
|
|
}
|
|
|
|
function isWindowsReadableLink(targetPath: string): boolean {
|
|
if (process.platform !== 'win32') {
|
|
return false
|
|
}
|
|
try {
|
|
readlinkSync(targetPath)
|
|
return true
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
function readWslSymlinkTarget(targetPath: string): string | null {
|
|
if (process.platform !== 'win32') {
|
|
return null
|
|
}
|
|
const targetWsl = parseWslUncPath(targetPath)
|
|
if (!targetWsl) {
|
|
return null
|
|
}
|
|
try {
|
|
return execFileSync(
|
|
'wsl.exe',
|
|
['-d', targetWsl.distro, '--', 'readlink', targetWsl.linuxPath],
|
|
{
|
|
encoding: 'utf-8',
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
timeout: 5000
|
|
}
|
|
).trim()
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
function copyFallbackAllowed(sourcePath: string, entryName: string): boolean {
|
|
if (entryName === 'hooks.json') {
|
|
return false
|
|
}
|
|
if (isCodexSqliteEntryName(entryName)) {
|
|
// Why: copying SQLite/WAL/SHM files forks Codex state and breaks lock coherence.
|
|
return false
|
|
}
|
|
const sourceStat = lstatSync(sourcePath)
|
|
return !sourceStat.isDirectory() || !MUTABLE_SHARED_DIRECTORY_ENTRIES.has(entryName)
|
|
}
|
|
|
|
function canLinkMissingSharedEntry(sharedHomePath: string, entryName: string): boolean {
|
|
const mainEntryName = getCodexSqliteMainEntryName(entryName)
|
|
return (
|
|
isCodexSqliteSidecarEntryName(entryName) &&
|
|
mainEntryName !== null &&
|
|
existsSync(join(sharedHomePath, mainEntryName))
|
|
)
|
|
}
|
|
|
|
function replaceUnownedLaunchEntryAllowed(launchHomePath: string, entryName: string): boolean {
|
|
// Why: older launch-home builds let Codex create local DB forks before the
|
|
// shared SQLite link policy existed; those forks must be replaced in place.
|
|
return isCodexSqliteEntryName(entryName) && existsSync(join(launchHomePath, LAUNCH_HOME_MARKER))
|
|
}
|
|
|
|
function isContainedPath(rootPath: string, candidatePath: string): boolean {
|
|
const relativePath = relative(resolve(rootPath), resolve(candidatePath))
|
|
return (
|
|
Boolean(relativePath) &&
|
|
relativePath !== '..' &&
|
|
!isAbsolute(relativePath) &&
|
|
!relativePath.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`)
|
|
)
|
|
}
|
|
|
|
function reconcileMutableLaunchHomeFilesIntoSharedHome(
|
|
sharedHomePath: string,
|
|
launchRootPath: string
|
|
): void {
|
|
let selectionEntries: string[]
|
|
try {
|
|
selectionEntries = readdirSync(launchRootPath)
|
|
} catch {
|
|
return
|
|
}
|
|
for (const selectionEntry of selectionEntries.sort()) {
|
|
const launchHomePath = join(launchRootPath, selectionEntry, 'home')
|
|
if (!existsSync(join(launchHomePath, LAUNCH_HOME_MARKER))) {
|
|
continue
|
|
}
|
|
reconcileMarkedMutableFiles(sharedHomePath, launchHomePath)
|
|
}
|
|
}
|
|
|
|
function reconcileMarkedMutableFiles(sharedHomePath: string, launchHomePath: string): void {
|
|
const markerDir = join(launchHomePath, LAUNCH_HOME_LINK_MARKERS_DIR)
|
|
let markerFiles: string[]
|
|
try {
|
|
markerFiles = readdirSync(markerDir)
|
|
} catch {
|
|
return
|
|
}
|
|
for (const markerFile of markerFiles.sort()) {
|
|
const entryName = markerFile.replace(/\.json$/, '')
|
|
const marker = readLaunchEntryMarker(launchHomePath, entryName)
|
|
if (!marker || !MUTABLE_SHARED_FILE_ENTRIES.has(entryName)) {
|
|
continue
|
|
}
|
|
if (marker.sourcePath !== join(sharedHomePath, entryName)) {
|
|
continue
|
|
}
|
|
reconcileMutableLaunchEntryIfNeeded(marker.sourcePath, join(launchHomePath, entryName), marker)
|
|
}
|
|
}
|
|
|
|
function reconcileMutableLaunchEntryIfNeeded(
|
|
sourcePath: string,
|
|
targetPath: string,
|
|
marker: LaunchEntryMarker | null
|
|
): void {
|
|
if (!marker || !MUTABLE_SHARED_FILE_ENTRIES.has(targetPath.split(/[\\/]/).at(-1) ?? '')) {
|
|
return
|
|
}
|
|
if (!targetExistsForLaunchRemoval(targetPath)) {
|
|
return
|
|
}
|
|
if (targetAlreadyPointsToSource(targetPath, sourcePath)) {
|
|
return
|
|
}
|
|
try {
|
|
if (lstatSync(targetPath).isSymbolicLink() || !statSync(targetPath).isFile()) {
|
|
return
|
|
}
|
|
const targetDigest = digestFile(targetPath)
|
|
if (targetDigest === marker.targetDigest) {
|
|
return
|
|
}
|
|
const sourceDigest = existsSync(sourcePath) ? digestFile(sourcePath) : null
|
|
if (
|
|
sourceDigest !== null &&
|
|
marker.sourceDigest !== null &&
|
|
sourceDigest !== marker.sourceDigest &&
|
|
statSync(sourcePath).mtimeMs > statSync(targetPath).mtimeMs
|
|
) {
|
|
return
|
|
}
|
|
mkdirSync(dirname(sourcePath), { recursive: true })
|
|
cpSync(targetPath, sourcePath, { force: true })
|
|
} catch (error) {
|
|
console.warn('[codex-home] Failed to reconcile launch-home Codex entry:', targetPath, error)
|
|
}
|
|
}
|
|
|
|
function removeStaleLaunchHomeEntries(
|
|
launchHomePath: string,
|
|
sharedHomePath: string,
|
|
sharedEntries: Set<string>
|
|
): void {
|
|
const markerDir = join(launchHomePath, LAUNCH_HOME_LINK_MARKERS_DIR)
|
|
let markerFiles: string[]
|
|
try {
|
|
markerFiles = readdirSync(markerDir)
|
|
} catch {
|
|
return
|
|
}
|
|
for (const markerFile of markerFiles) {
|
|
const entryName = markerFile.replace(/\.json$/, '')
|
|
if (!sharedEntries.has(entryName)) {
|
|
removeLaunchEntryIfOwned(
|
|
join(launchHomePath, entryName),
|
|
launchHomePath,
|
|
entryName,
|
|
join(sharedHomePath, entryName)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
function removeLaunchEntryIfOwned(
|
|
targetPath: string,
|
|
launchHomePath: string,
|
|
entryName: string,
|
|
sourcePath: string
|
|
): void {
|
|
const marker = readLaunchEntryMarker(launchHomePath, entryName)
|
|
if (marker?.sourcePath !== sourcePath) {
|
|
return
|
|
}
|
|
removeLaunchEntry(targetPath)
|
|
rmSync(getLaunchEntryMarkerPath(launchHomePath, entryName), { force: true })
|
|
}
|
|
|
|
function removeLaunchEntry(targetPath: string): void {
|
|
if (!targetExistsForLaunchRemoval(targetPath)) {
|
|
return
|
|
}
|
|
try {
|
|
if (removeWslPathIfPossible(targetPath)) {
|
|
return
|
|
}
|
|
const stat = lstatSync(targetPath)
|
|
if (stat.isSymbolicLink()) {
|
|
try {
|
|
unlinkSync(targetPath)
|
|
} catch (error) {
|
|
if (process.platform !== 'win32') {
|
|
throw error
|
|
}
|
|
rmdirSync(targetPath)
|
|
}
|
|
return
|
|
}
|
|
rmSync(targetPath, { recursive: stat.isDirectory(), force: true })
|
|
} catch (error) {
|
|
console.warn('[codex-home] Failed to remove owned launch-home entry:', targetPath, error)
|
|
}
|
|
}
|
|
|
|
function targetExistsForLaunchRemoval(targetPath: string): boolean {
|
|
const wslExists = wslPathExists(targetPath)
|
|
if (wslExists !== null) {
|
|
return wslExists
|
|
}
|
|
try {
|
|
lstatSync(targetPath)
|
|
return true
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
function writeLaunchHomeMarker(launchHomePath: string, accountId: string | null): void {
|
|
writeFileSync(
|
|
join(launchHomePath, LAUNCH_HOME_MARKER),
|
|
`${JSON.stringify({ version: LAUNCH_HOME_MARKER_VERSION, accountId }, null, 2)}\n`,
|
|
{ encoding: 'utf-8', mode: 0o600 }
|
|
)
|
|
}
|
|
|
|
function isMarkedLaunchHomeForAccount(launchHomePath: string, accountId: string): boolean {
|
|
try {
|
|
const parsed: unknown = JSON.parse(
|
|
readFileSync(join(launchHomePath, LAUNCH_HOME_MARKER), 'utf-8')
|
|
)
|
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
return false
|
|
}
|
|
const marker = parsed as { version?: unknown; accountId?: unknown }
|
|
return marker.version === LAUNCH_HOME_MARKER_VERSION && marker.accountId === accountId
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
function markLaunchEntry(
|
|
launchHomePath: string,
|
|
entryName: string,
|
|
sourcePath: string,
|
|
mode: 'link' | 'copy'
|
|
): void {
|
|
const markerPath = getLaunchEntryMarkerPath(launchHomePath, entryName)
|
|
mkdirSync(dirname(markerPath), { recursive: true })
|
|
const shouldTrackDigests = MUTABLE_SHARED_FILE_ENTRIES.has(entryName)
|
|
writeFileSync(
|
|
markerPath,
|
|
`${JSON.stringify(
|
|
{
|
|
version: LAUNCH_HOME_MARKER_VERSION,
|
|
sourcePath,
|
|
mode,
|
|
// Why: digests are only used to reconcile mutable config-style files;
|
|
// hashing SQLite databases on every PTY launch can read gigabytes.
|
|
sourceDigest: shouldTrackDigests ? digestPathIfFile(sourcePath) : null,
|
|
targetDigest: shouldTrackDigests ? digestPathIfFile(join(launchHomePath, entryName)) : null
|
|
} satisfies LaunchEntryMarker,
|
|
null,
|
|
2
|
|
)}\n`,
|
|
{ encoding: 'utf-8', mode: 0o600 }
|
|
)
|
|
}
|
|
|
|
function readLaunchEntryMarker(
|
|
launchHomePath: string,
|
|
entryName: string
|
|
): LaunchEntryMarker | null {
|
|
try {
|
|
const parsed: unknown = JSON.parse(
|
|
readFileSync(getLaunchEntryMarkerPath(launchHomePath, entryName), 'utf-8')
|
|
)
|
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
return null
|
|
}
|
|
const marker = parsed as Partial<LaunchEntryMarker>
|
|
if (
|
|
marker.version !== LAUNCH_HOME_MARKER_VERSION ||
|
|
typeof marker.sourcePath !== 'string' ||
|
|
(marker.mode !== 'link' && marker.mode !== 'copy')
|
|
) {
|
|
return null
|
|
}
|
|
return {
|
|
version: marker.version,
|
|
sourcePath: marker.sourcePath,
|
|
mode: marker.mode,
|
|
sourceDigest: typeof marker.sourceDigest === 'string' ? marker.sourceDigest : null,
|
|
targetDigest: typeof marker.targetDigest === 'string' ? marker.targetDigest : null
|
|
}
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
function targetAlreadyPointsToSource(targetPath: string, sourcePath: string): boolean {
|
|
const paths = getSameDistroWslPaths(sourcePath, targetPath)
|
|
if (paths) {
|
|
return readWslSymlinkTarget(targetPath) === paths.sourceLinuxPath
|
|
}
|
|
try {
|
|
const targetStat = lstatSync(targetPath)
|
|
if (targetStat.isSymbolicLink()) {
|
|
return linkTargetsMatch(readlinkSync(targetPath), sourcePath)
|
|
}
|
|
if (process.platform === 'win32') {
|
|
try {
|
|
if (linkTargetsMatch(readlinkSync(targetPath), sourcePath)) {
|
|
return true
|
|
}
|
|
} catch {
|
|
// Non-link files fall through to the hard-link identity check below.
|
|
}
|
|
}
|
|
if (!targetStat.isFile() || !existsSync(sourcePath)) {
|
|
return false
|
|
}
|
|
const sourceStat = statSync(sourcePath)
|
|
return (
|
|
sourceStat.isFile() &&
|
|
targetStat.dev === sourceStat.dev &&
|
|
targetStat.ino === sourceStat.ino &&
|
|
targetStat.nlink > 1
|
|
)
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
function linkTargetsMatch(actualTarget: string, expectedTarget: string): boolean {
|
|
const expectedWsl = parseWslUncPath(expectedTarget)
|
|
if (expectedWsl && actualTarget === expectedWsl.linuxPath) {
|
|
return true
|
|
}
|
|
if (process.platform !== 'win32') {
|
|
return actualTarget === expectedTarget
|
|
}
|
|
return normalizeWindowsLinkTarget(actualTarget) === normalizeWindowsLinkTarget(expectedTarget)
|
|
}
|
|
|
|
function normalizeWindowsLinkTarget(linkTarget: string): string {
|
|
return linkTarget.replace(/^\\\\\?\\/, '').toLowerCase()
|
|
}
|
|
|
|
function getLaunchEntryMarkerPath(launchHomePath: string, entryName: string): string {
|
|
return join(launchHomePath, LAUNCH_HOME_LINK_MARKERS_DIR, `${entryName}.json`)
|
|
}
|
|
|
|
function digestPathIfFile(targetPath: string): string | null {
|
|
try {
|
|
if (!statSync(targetPath).isFile()) {
|
|
return null
|
|
}
|
|
return digestFile(targetPath)
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
function digestFile(targetPath: string): string {
|
|
return createHash('sha256').update(readFileSync(targetPath)).digest('hex')
|
|
}
|