🚚 Codex 在 Windows 上迁移到非系统盘:完整、安全、可回滚指南

适用范围:Windows 10/11,Windows 原生 Codex/ChatGPT 桌面应用、Codex CLI、IDE 扩展。
文档版本:2026-08-20。执行前请先完整阅读,尤其是“迁移边界”和“回滚”部分。

🧭 一、先说结论

最稳妥的做法不是手工搬动 WindowsApps,也不是创建目录联接,而是:

  1. 彻底退出 ChatGPT/Codex、IDE 和所有 codex 进程;
  2. 把当前 CODEX_HOME(默认 %USERPROFILE%\.codex)复制到非系统盘;
  3. 对相对路径、文件大小做逐项核验,可选 SHA-256 深度核验;
  4. 将用户级环境变量 CODEX_HOME 指向新目录;
  5. 重启应用并运行检查脚本;
  6. 保留旧目录一段时间,确认稳定后再手工清理。

OpenAI 官方文档说明,CODEX_HOME 的默认值为 ~/.codex,它是 Codex 配置、认证、日志、会话、技能和独立安装包元数据的根目录;若显式设置,新目录必须已经存在。Windows 桌面应用与 Windows 原生 Codex 默认共用 %USERPROFILE%\.codex。参见 环境变量Windows 桌面应用配置基础

📦 能迁移与不能直接迁移的内容

内容推荐办法说明
配置、登录状态、会话、日志、技能、插件/独立包元数据设置 CODEX_HOME本文主要迁移对象
SQLite 状态通常随 CODEX_HOME 迁移CODEX_SQLITE_HOME 默认等于 CODEX_HOME;若显式设置,脚本会谨慎处理
独立版 codex.exe 的可见命令目录设置 CODEX_INSTALL_DIR 后重新运行官方安装器Windows 默认是 %LOCALAPPDATA%\Programs\OpenAI\Codex\bin;包缓存仍在 CODEX_HOME\packages\standalone
Microsoft Store/MSIX 桌面应用本体由 Windows“已安装的应用”管理不要取得 WindowsApps 所有权后手工剪切;能否选择其他盘取决于 Windows/组织策略
项目仓库、源码、构建缓存单独移动项目不属于 CODEX_HOME;移动后需在应用中重新打开项目,并注意 Git/工具链中的绝对路径
WSL 内部的 ~/.codex单独配置 WSLWindows 环境变量不会自动进入 WSL;文末给出做法

“完整迁移”在本文中指 Codex 的可配置用户状态和可选的独立 CLI 命令位置,不代表强行搬动 Microsoft Store/MSIX 应用包、Windows 沙箱账户或系统级依赖。

🗺️ 迁移后的总体架构

💾 非系统盘:D:/CodexData/.codex

🖥️ Codex 使用入口

仍由 Windows 管理

独立于 CODEX_HOME

ChatGPT / Codex
Windows 桌面应用

⌨️ Codex CLI

🧩 IDE 扩展

⚙️ app-server / 安装器

CODEX_HOME

config.toml
profiles

认证状态

会话 / SQLite 状态

skills / plugins / packages

logs / .sandbox

📦 Microsoft Store / MSIX 应用本体

📁 D:/Projects 等项目目录

图中实线表示多个 Codex 入口共享的新状态根目录;虚线表示“有关联但不随 CODEX_HOME 一起搬迁”的内容。

🔑 关键变量速查

变量默认位置本文建议是否必须设置
CODEX_HOME%USERPROFILE%\.codexD:\CodexData\.codex✅ 是,迁移核心
CODEX_SQLITE_HOME跟随 CODEX_HOME保持未设置,除非原本显式配置❌ 通常不需要
CODEX_INSTALL_DIR%LOCALAPPDATA%\Programs\OpenAI\Codex\binD:\Apps\OpenAI\Codex\bin🟡 仅迁移独立 CLI 时需要

⚡ 最短执行路线

阶段要做什么成功标志失败时怎么办
1️⃣ 准备退出应用、IDE、CLI,确认目标为本机 NTFS 非系统盘没有 codex/ChatGPT 进程停止迁移,先关闭进程
2️⃣ 迁移运行 Move-CodexHome.ps1复制核验通过,用户变量已切换不手工切变量,修正问题后重试
3️⃣ 验证新开 PowerShell,运行 Test-CodexMigration.ps1Failures = 0,实际任务正常保留两边数据,排错或回滚
4️⃣ 观察使用数天,确认只写入新目录新目录持续更新,旧目录不再更新运行回滚脚本
5️⃣ 清理先把旧目录改名/离线备份,再延迟删除系统盘空间释放且应用稳定从备份恢复

🧰 二、推荐目录与迁移前检查

建议使用本机固定 NTFS 盘,例如:

D:\CodexData\.codex
D:\Apps\OpenAI\Codex\bin       # 可选:独立 CLI 的命令目录
D:\Projects                      # 可选:项目仓库,和 CODEX_HOME 分开

不要把 CODEX_HOME 放在 FAT32/exFAT、网络共享、临时盘、容易被拔出的移动盘或云盘“仅联机”目录中。认证资料和会话可能具有敏感性;非系统盘最好启用 BitLocker,并只允许当前用户和管理员访问。

迁移前确认:

  • 非系统盘空间充足、盘符长期固定;
  • 当前 Codex 工作已经保存,没有仍在运行的任务;
  • ChatGPT/Codex 桌面应用、VS Code/JetBrains 等 IDE、所有 codex 终端进程均已关闭;
  • 不要在 Codex 自己仍运行时执行迁移脚本;
  • 公司设备若受组策略管理,先确认允许用户环境变量和目标盘读写;
  • 记住目标路径。下文示例统一使用 D:\CodexData\.codex,请按实际情况替换。

🚦 迁移前风险信号

信号风险处理方式
🔴 Codex/ChatGPT/IDE 仍在运行SQLite、日志或会话可能在复制时变化必须完全退出后再执行
🔴 目标目录已有文件两套状态可能被错误合并换用新的空目录
🔴 目标是网络盘、移动盘或 exFAT权限、锁、掉盘和原子写入不可靠改用本机固定 NTFS 盘
🟠 源目录含符号链接/目录联接复制边界不明确,可能越界或丢内容人工检查后单独处理
🟠 公司设备受策略管理用户变量、ACL 或沙箱可能被限制先咨询管理员
🟡 使用 WSL CLIWindows 与 WSL 的 home 默认不同阅读“WSL 用户”章节

🔄 端到端迁移流程

🚀 开始

保存工作并退出
ChatGPT / Codex / IDE / CLI

仍有相关进程?

⛔ 停止:彻底退出或重启 Windows

目标是空的本机
NTFS 非系统盘目录?

🛠️ 更换或清空目标路径

📋 复制 CODEX_HOME
旧目录保持不动

路径/大小核验通过?

⛔ 不切换变量
保留证据并排错

🔐 写入迁移清单

🔀 设置用户级 CODEX_HOME

🔄 新开 PowerShell
重启应用

✅ 运行检查脚本与人工验收

连续使用数天均正常?

↩️ 执行回滚脚本

🧹 先备份/改名旧目录
延迟清理

🎉 完成

可先在一个新开的普通 PowerShell 中查看当前状态:

[pscustomobject]@{
    Process_CODEX_HOME = $env:CODEX_HOME
    User_CODEX_HOME    = [Environment]::GetEnvironmentVariable('CODEX_HOME', 'User')
    Default_HOME       = Join-Path $env:USERPROFILE '.codex'
    Codex_Command      = (Get-Command codex -ErrorAction SilentlyContinue).Source
} | Format-List

输出可能包含用户名和路径,但不应显示令牌。不要把 auth.json.sandbox-secrets 或任何访问令牌发给他人。

🚛 三、迁移脚本(复制、核验、切换,默认不删除源目录)

将下面完整代码另存为 Move-CodexHome.ps1,在已关闭所有 Codex/ChatGPT/IDE 进程后,从普通 PowerShell 执行。脚本不会删除旧目录,也不会操作桌面应用包。

#requires -Version 5.1
[CmdletBinding(SupportsShouldProcess = $true)]
param(
    [Parameter(Mandatory = $true)]
    [string]$DestinationPath,

    [switch]$DeepVerify
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

function Get-FullPath([string]$Path) {
    return [IO.Path]::GetFullPath([Environment]::ExpandEnvironmentVariables($Path)).TrimEnd('\')
}

function Test-IsSameOrChild([string]$Candidate, [string]$Parent) {
    $c = (Get-FullPath $Candidate) + '\'
    $p = (Get-FullPath $Parent) + '\'
    return $c.StartsWith($p, [StringComparison]::OrdinalIgnoreCase)
}

function Get-Inventory([string]$Root, [switch]$Hash) {
    $rootFull = Get-FullPath $Root
    $items = Get-ChildItem -LiteralPath $rootFull -File -Recurse -Force |
        Where-Object { $_.FullName -notlike (Join-Path $rootFull '.migration\*') } |
        ForEach-Object {
            $relative = $_.FullName.Substring($rootFull.Length).TrimStart('\')
            $entry = [ordered]@{
                RelativePath = $relative
                Length       = [int64]$_.Length
            }
            if ($Hash) {
                $entry.SHA256 = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash
            }
            [pscustomobject]$entry
        }
    return @($items | Sort-Object RelativePath)
}

function Compare-Inventory($SourceInventory, $DestinationInventory, [switch]$Hash) {
    $src = @{}
    foreach ($item in $SourceInventory) { $src[$item.RelativePath] = $item }
    $dst = @{}
    foreach ($item in $DestinationInventory) { $dst[$item.RelativePath] = $item }

    $errors = [Collections.Generic.List[string]]::new()
    foreach ($key in $src.Keys) {
        if (-not $dst.ContainsKey($key)) {
            $errors.Add("目标缺少:$key")
            continue
        }
        if ($src[$key].Length -ne $dst[$key].Length) {
            $errors.Add("大小不同:$key")
        } elseif ($Hash -and $src[$key].SHA256 -ne $dst[$key].SHA256) {
            $errors.Add("哈希不同:$key")
        }
    }
    foreach ($key in $dst.Keys) {
        if (-not $src.ContainsKey($key)) { $errors.Add("目标多出:$key") }
    }
    return @($errors)
}

$running = @(Get-Process -Name 'codex', 'ChatGPT' -ErrorAction SilentlyContinue)
if ($running.Count -gt 0) {
    $names = ($running | Select-Object -ExpandProperty ProcessName -Unique) -join ', '
    throw "检测到仍在运行的进程:$names。请彻底退出应用、IDE 和 CLI 后重试。"
}

$userHomeValue = [Environment]::GetEnvironmentVariable('CODEX_HOME', 'User')
$userHomeWasSet = $null -ne $userHomeValue
if (-not [string]::IsNullOrWhiteSpace($env:CODEX_HOME)) {
    $source = Get-FullPath $env:CODEX_HOME
} elseif ($userHomeWasSet -and -not [string]::IsNullOrWhiteSpace($userHomeValue)) {
    $source = Get-FullPath $userHomeValue
} else {
    $source = Get-FullPath (Join-Path $env:USERPROFILE '.codex')
}
$destination = Get-FullPath $DestinationPath

if (-not (Test-Path -LiteralPath $source -PathType Container)) {
    throw "源 CODEX_HOME 不存在:$source"
}
if ($source.Equals($destination, [StringComparison]::OrdinalIgnoreCase)) {
    throw '源目录和目标目录相同,无需迁移。'
}
if ((Test-IsSameOrChild $destination $source) -or (Test-IsSameOrChild $source $destination)) {
    throw '源目录与目标目录不能互相包含。请选择完全独立的目标目录。'
}
if ($destination.StartsWith('\\')) {
    throw '不建议把 CODEX_HOME 放到 UNC/网络路径;请选择本机固定 NTFS 盘。'
}
$destinationRoot = [IO.Path]::GetPathRoot($destination)
if ($destinationRoot.TrimEnd('\').Equals($env:SystemDrive, [StringComparison]::OrdinalIgnoreCase)) {
    throw "目标仍位于系统盘 $env:SystemDrive。请指定非系统盘。"
}
$volume = Get-Volume -DriveLetter $destinationRoot.Substring(0, 1) -ErrorAction Stop
if ($volume.FileSystem -ne 'NTFS') {
    throw "目标卷文件系统为 $($volume.FileSystem),建议且脚本要求使用 NTFS。"
}

$reparsePoints = @(Get-ChildItem -LiteralPath $source -Recurse -Force -Attributes ReparsePoint -ErrorAction Stop)
if ($reparsePoints.Count -gt 0) {
    throw "源目录包含 $($reparsePoints.Count) 个重解析点/符号链接。请先人工检查,脚本为避免复制到意外位置而停止。"
}

$sourceBytes = [int64](Get-ChildItem -LiteralPath $source -File -Recurse -Force |
    Measure-Object -Property Length -Sum).Sum
$drive = Get-PSDrive -Name $destinationRoot.Substring(0, 1) -ErrorAction Stop
if ($drive.Free -lt ($sourceBytes + 100MB)) {
    throw "目标盘空间不足。数据约 $sourceBytes 字节,建议至少额外保留 100 MB。"
}

if (-not (Test-Path -LiteralPath $destination)) {
    if ($PSCmdlet.ShouldProcess($destination, '创建目标 CODEX_HOME')) {
        New-Item -ItemType Directory -Path $destination -Force | Out-Null
    }
}
if ((Get-ChildItem -LiteralPath $destination -Force -ErrorAction Stop | Measure-Object).Count -gt 0) {
    throw "目标目录不是空目录:$destination。请换一个新目录,避免覆盖未知数据。"
}

Write-Host "源目录:$source"
Write-Host "目标目录:$destination"
Write-Host '正在复制(不会删除源目录)……'

if ($PSCmdlet.ShouldProcess($destination, '复制 Codex 状态')) {
    & robocopy.exe $source $destination /E /COPY:DAT /DCOPY:DAT /R:2 /W:1 /XJ /NP
    $robocopyCode = $LASTEXITCODE
    if ($robocopyCode -ge 8) {
        throw "Robocopy 失败,退出码:$robocopyCode。环境变量尚未切换。"
    }
}

Write-Host ($(if ($DeepVerify) { '正在执行 SHA-256 深度核验……' } else { '正在核验相对路径和文件大小……' }))
$sourceInventory = Get-Inventory $source -Hash:$DeepVerify
$destinationInventory = Get-Inventory $destination -Hash:$DeepVerify
$differences = Compare-Inventory $sourceInventory $destinationInventory -Hash:$DeepVerify
if ($differences.Count -gt 0) {
    $preview = ($differences | Select-Object -First 20) -join [Environment]::NewLine
    throw "复制核验失败,共 $($differences.Count) 项差异。前 20 项:`n$preview`n环境变量尚未切换。"
}

$oldSqliteHome = [Environment]::GetEnvironmentVariable('CODEX_SQLITE_HOME', 'User')
$oldSqliteWasSet = $null -ne $oldSqliteHome
$newSqliteHome = $oldSqliteHome
if ($oldSqliteWasSet -and -not [string]::IsNullOrWhiteSpace($oldSqliteHome)) {
    $oldSqliteFull = Get-FullPath $oldSqliteHome
    if ($oldSqliteFull.Equals($source, [StringComparison]::OrdinalIgnoreCase)) {
        $newSqliteHome = $destination
    } elseif (Test-IsSameOrChild $oldSqliteFull $source) {
        $suffix = $oldSqliteFull.Substring($source.Length).TrimStart('\')
        $newSqliteHome = Join-Path $destination $suffix
    }
}

$manifestDir = Join-Path $destination '.migration'
New-Item -ItemType Directory -Path $manifestDir -Force | Out-Null
$manifestPath = Join-Path $manifestDir 'codex-home-migration.json'
$manifest = [ordered]@{
    SchemaVersion          = 1
    MigratedAtUtc          = [DateTime]::UtcNow.ToString('o')
    OriginalHome           = $source
    DestinationHome        = $destination
    OldUserCodexHomeWasSet = $userHomeWasSet
    OldUserCodexHome       = $userHomeValue
    OldUserSqliteWasSet    = $oldSqliteWasSet
    OldUserSqliteHome      = $oldSqliteHome
    NewUserSqliteHome      = $newSqliteHome
    VerifiedWithSHA256     = [bool]$DeepVerify
    SourceFileCount        = $sourceInventory.Count
    SourceBytes            = $sourceBytes
}
$manifest | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $manifestPath -Encoding UTF8

if ($PSCmdlet.ShouldProcess('用户环境变量 CODEX_HOME', "设置为 $destination")) {
    [Environment]::SetEnvironmentVariable('CODEX_HOME', $destination, 'User')
    $env:CODEX_HOME = $destination
    if ($oldSqliteWasSet -and $newSqliteHome -ne $oldSqliteHome) {
        [Environment]::SetEnvironmentVariable('CODEX_SQLITE_HOME', $newSqliteHome, 'User')
        $env:CODEX_SQLITE_HOME = $newSqliteHome
    }
}

Write-Host ''
Write-Host '迁移复制、核验和环境变量切换已完成。' -ForegroundColor Green
Write-Host "迁移清单:$manifestPath"
Write-Host '请关闭当前 PowerShell,新开 PowerShell,再启动 Codex/ChatGPT 并运行检查脚本。'
Write-Host "旧目录仍保留在:$source"

普通核验运行示例:

Set-ExecutionPolicy -Scope Process Bypass
.\Move-CodexHome.ps1 -DestinationPath 'D:\CodexData\.codex'

逐文件 SHA-256 深度核验(数据多时会明显变慢):

.\Move-CodexHome.ps1 -DestinationPath 'D:\CodexData\.codex' -DeepVerify

脚本的安全特性:目标必须是非系统盘的本机 NTFS 空目录;源和目标不能互相包含;发现运行中的 Codex/ChatGPT 或重解析点会停止;复制核验通过后才切换环境变量;源目录不会被删除。

🛡️ 脚本保护机制

保护机制防止的问题触发后的行为
进程检查复制过程中数据库和日志继续变化立即停止,不复制
空目标检查两套 Codex 状态混合立即停止,要求新目录
路径包含检查把目标放进源目录导致递归复制立即停止
NTFS/非系统盘检查权限不兼容或迁移目标选错立即停止
重解析点检查跟随目录联接复制到意外位置立即停止,要求人工检查
Robocopy 退出码检查部分文件未复制不切换环境变量
文件清单或 SHA-256 核验静默损坏、漏文件不切换环境变量
保留旧目录登录、会话或沙箱异常可完整回滚

✅ 四、重启与检查脚本

迁移脚本完成后:

  1. 关闭执行迁移的 PowerShell;
  2. 新开 PowerShell,确认 $env:CODEX_HOME 已指向新盘;
  3. 启动 ChatGPT/Codex;
  4. 确认历史任务、设置、技能和登录状态可用;
  5. 退出应用,再运行下面的检查脚本。检查时退出应用可以避免 SQLite/日志文件变化导致误判。

将下面代码另存为 Test-CodexMigration.ps1

#requires -Version 5.1
[CmdletBinding()]
param(
    [string]$ExpectedHome,
    [switch]$DeepVerifyInitialCopy
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$failures = [Collections.Generic.List[string]]::new()
$warnings = [Collections.Generic.List[string]]::new()

function Full([string]$Path) {
    [IO.Path]::GetFullPath([Environment]::ExpandEnvironmentVariables($Path)).TrimEnd('\')
}

$userHome = [Environment]::GetEnvironmentVariable('CODEX_HOME', 'User')
if ([string]::IsNullOrWhiteSpace($ExpectedHome)) {
    if (-not [string]::IsNullOrWhiteSpace($userHome)) {
        $ExpectedHome = $userHome
    } elseif (-not [string]::IsNullOrWhiteSpace($env:CODEX_HOME)) {
        $ExpectedHome = $env:CODEX_HOME
    } else {
        $failures.Add('未设置 CODEX_HOME,当前仍会使用默认系统盘目录。')
        $ExpectedHome = Join-Path $env:USERPROFILE '.codex'
    }
}
$expected = Full $ExpectedHome
$processHome = if ([string]::IsNullOrWhiteSpace($env:CODEX_HOME)) { '' } else { Full $env:CODEX_HOME }
$userHomeFull = if ([string]::IsNullOrWhiteSpace($userHome)) { '' } else { Full $userHome }

if ($processHome -ne $expected) { $failures.Add("当前进程 CODEX_HOME 不正确:$processHome") }
if ($userHomeFull -ne $expected) { $failures.Add("用户级 CODEX_HOME 不正确:$userHomeFull") }
if (-not (Test-Path -LiteralPath $expected -PathType Container)) {
    $failures.Add("目标目录不存在:$expected")
} else {
    $root = [IO.Path]::GetPathRoot($expected)
    if ($root.TrimEnd('\').Equals($env:SystemDrive, [StringComparison]::OrdinalIgnoreCase)) {
        $failures.Add("CODEX_HOME 仍在系统盘:$expected")
    }
    try {
        $probe = Join-Path $expected ('.write-test-' + [Guid]::NewGuid().ToString('N') + '.tmp')
        [IO.File]::WriteAllText($probe, 'Codex migration write test')
        Remove-Item -LiteralPath $probe -Force
    } catch {
        $failures.Add("目标目录不可写:$($_.Exception.Message)")
    }
}

$manifestPath = Join-Path $expected '.migration\codex-home-migration.json'
$manifest = $null
if (Test-Path -LiteralPath $manifestPath) {
    $manifest = Get-Content -Raw -LiteralPath $manifestPath | ConvertFrom-Json
    if ((Full $manifest.DestinationHome) -ne $expected) {
        $failures.Add('迁移清单中的目标目录与当前 CODEX_HOME 不一致。')
    }
} else {
    $warnings.Add("未找到迁移清单:$manifestPath")
}

$sqliteUser = [Environment]::GetEnvironmentVariable('CODEX_SQLITE_HOME', 'User')
if (-not [string]::IsNullOrWhiteSpace($sqliteUser) -and $manifest) {
    $sqliteFull = Full $sqliteUser
    $oldFull = Full $manifest.OriginalHome
    if ($sqliteFull.Equals($oldFull, [StringComparison]::OrdinalIgnoreCase) -or
        $sqliteFull.StartsWith($oldFull + '\', [StringComparison]::OrdinalIgnoreCase)) {
        $failures.Add("CODEX_SQLITE_HOME 仍指向旧目录:$sqliteFull")
    }
}

$config = Join-Path $expected 'config.toml'
if (-not (Test-Path -LiteralPath $config)) {
    $warnings.Add('未发现 config.toml;如果迁移前从未创建过用户配置,这属于正常情况。')
}

$codexCommand = Get-Command codex -ErrorAction SilentlyContinue
if ($null -eq $codexCommand) {
    $warnings.Add('PATH 中未找到 codex 命令;若只使用桌面应用,可忽略。')
} else {
    try {
        $version = (& codex --version 2>&1 | Out-String).Trim()
    } catch {
        $warnings.Add("codex --version 执行失败:$($_.Exception.Message)")
    }
}

if ($DeepVerifyInitialCopy -and $manifest) {
    $old = Full $manifest.OriginalHome
    if (-not (Test-Path -LiteralPath $old -PathType Container)) {
        $warnings.Add('旧目录已不存在,无法做初始文件的深度复核。')
    } else {
        # 只核验迁移清单记录时已存在、且两边目前仍同名同大小的文件。
        # 使用新 Codex 后目标目录会产生新日志/会话,因此两边出现新增差异是正常的。
        $oldFiles = Get-ChildItem -LiteralPath $old -File -Recurse -Force
        foreach ($oldFile in $oldFiles) {
            $relative = $oldFile.FullName.Substring($old.Length).TrimStart('\')
            $newFile = Join-Path $expected $relative
            if (-not (Test-Path -LiteralPath $newFile -PathType Leaf)) {
                $failures.Add("目标缺少迁移前文件:$relative")
                continue
            }
            if ($oldFile.Length -ne (Get-Item -LiteralPath $newFile).Length) {
                $warnings.Add("迁移后已发生变化:$relative")
                continue
            }
            $oldHash = (Get-FileHash -LiteralPath $oldFile.FullName -Algorithm SHA256).Hash
            $newHash = (Get-FileHash -LiteralPath $newFile -Algorithm SHA256).Hash
            if ($oldHash -ne $newHash) { $failures.Add("同大小但哈希不同:$relative") }
        }
    }
}

[pscustomobject]@{
    Expected_CODEX_HOME = $expected
    Process_CODEX_HOME  = $processHome
    User_CODEX_HOME     = $userHomeFull
    CODEX_SQLITE_HOME   = $sqliteUser
    ConfigExists        = Test-Path -LiteralPath $config
    CodexCommand        = if ($codexCommand) { $codexCommand.Source } else { $null }
    CodexVersion        = if (Get-Variable version -ErrorAction SilentlyContinue) { $version } else { $null }
    Failures            = $failures.Count
    Warnings            = $warnings.Count
} | Format-List

foreach ($item in $warnings) { Write-Warning $item }
foreach ($item in $failures) { Write-Error $item -ErrorAction Continue }
if ($failures.Count -gt 0) { exit 1 }
Write-Host '检查通过。请再启动应用,确认历史任务、设置、技能、登录和新建任务均正常。' -ForegroundColor Green
exit 0

运行:

Set-ExecutionPolicy -Scope Process Bypass
.\Test-CodexMigration.ps1 -ExpectedHome 'D:\CodexData\.codex'

如需对旧目录与新目录中仍可比较的初始文件做 SHA-256 复核:

.\Test-CodexMigration.ps1 -ExpectedHome 'D:\CodexData\.codex' -DeepVerifyInitialCopy

☑️ 人工验收清单

  • 新 PowerShell 中 $env:CODEX_HOME 是新路径;
  • codex --version 正常(若安装了 CLI);
  • 桌面应用能打开,登录状态正常;
  • 旧任务/会话、设置、技能和插件可见;
  • 新建一个测试任务后,新目录的修改时间发生变化,旧目录不再持续写入;
  • Windows 沙箱可正常启动,没有新的权限或错误 1385 提示;
  • 日志应位于新 CODEX_HOME 下。官方文档也将沙箱日志定位为 CODEX_HOME/.sandbox/sandbox.log,并提醒不要对外发送 .sandbox-secrets 的内容,参见 Windows 沙箱

📊 检查结果判读

输出/现象含义结论
Failures = 0 且无关键 Warning路径、变量、写权限和基础命令均正常✅ 可进入观察期
Failures > 0至少一项核心检查失败❌ 不要删除旧目录
仅提示没有 config.toml迁移前可能从未创建用户配置🟡 通常可忽略
仅提示 PATH 中没有 codex只使用桌面应用,或 CLI 未安装🟡 按实际需求处理
CODEX_SQLITE_HOME 仍指向旧目录数据库仍可能写系统盘❌ 修正变量/配置后重查
应用能启动但历史内容或登录异常可能是进程继承、权限或凭据存储问题❌ 优先排错,必要时回滚

🧪 运行检查脚本

Failures = 0?

⛔ 保留旧目录
不要清理

能快速修复?

修复变量 / 权限 / 路径

↩️ 运行回滚脚本

应用人工验收通过?

🕒 进入数天观察期

旧目录停止写入?

✅ 可备份并延迟清理旧目录

⌨️ 五、可选:把独立版 Codex CLI 命令也安装到非系统盘

这一节只针对官方独立安装脚本安装的 CLI。CODEX_INSTALL_DIR 控制可见 codex 命令的位置;独立包缓存仍在 CODEX_HOME\packages\standalone。不要把它与 Microsoft Store/MSIX 桌面应用的位置混为一谈。

在完成 CODEX_HOME 迁移并检查通过后,新开 PowerShell:

$cliDir = 'D:\Apps\OpenAI\Codex\bin'
New-Item -ItemType Directory -Path $cliDir -Force | Out-Null
[Environment]::SetEnvironmentVariable('CODEX_INSTALL_DIR', $cliDir, 'User')
$env:CODEX_INSTALL_DIR = $cliDir

# 仅在你信任并能访问官方安装地址时执行:
irm https://chatgpt.com/codex/install.ps1 | iex

Get-Command codex | Format-List Source
codex --version

如果安装器提示 PATH 变更,关闭并重新打开 PowerShell。确认新命令工作后,再通过 Windows“已安装的应用”或原安装方式处理旧版本;不要直接猜测并删除目录。

桌面应用本体由 Windows 管理。官方 Windows 安装命令是:

winget install --id 9PLM9XGG6VKS -s msstore

如 Windows 设置的“应用 > 已安装的应用 > ChatGPT > 移动”可用,可由 Windows 执行移动;若按钮不存在或被组织策略禁用,就保留应用本体在系统盘。切勿手工改 C:\Program Files\WindowsApps 的所有权、复制包文件或建立联接,这会影响签名、更新、权限和卸载。

↩️ 六、回滚脚本(先把迁移后的新增数据复制回旧目录,再恢复变量)

回滚前同样要退出 ChatGPT/Codex、IDE 和所有 CLI。此脚本读取迁移时写入的清单,将新目录内容覆盖复制回原目录,核验后恢复迁移前的用户环境变量;它不会删除新目录。

将下面代码另存为 Undo-CodexHomeMigration.ps1

#requires -Version 5.1
[CmdletBinding(SupportsShouldProcess = $true)]
param(
    [string]$DestinationPath,
    [switch]$DeepVerify
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

function Full([string]$Path) {
    [IO.Path]::GetFullPath([Environment]::ExpandEnvironmentVariables($Path)).TrimEnd('\')
}

if ([string]::IsNullOrWhiteSpace($DestinationPath)) {
    $DestinationPath = [Environment]::GetEnvironmentVariable('CODEX_HOME', 'User')
}
if ([string]::IsNullOrWhiteSpace($DestinationPath)) {
    throw '无法确定迁移后的目录。请用 -DestinationPath 明确指定。'
}
$destination = Full $DestinationPath
$manifestPath = Join-Path $destination '.migration\codex-home-migration.json'
if (-not (Test-Path -LiteralPath $manifestPath -PathType Leaf)) {
    throw "未找到迁移清单:$manifestPath。为防止覆盖错误目录,脚本停止。"
}
$manifest = Get-Content -Raw -LiteralPath $manifestPath | ConvertFrom-Json
if ([int]$manifest.SchemaVersion -ne 1) { throw '不支持的迁移清单版本。' }
if ((Full $manifest.DestinationHome) -ne $destination) {
    throw '指定目录与迁移清单中的目标目录不一致。'
}
$original = Full $manifest.OriginalHome
if ($original.Equals($destination, [StringComparison]::OrdinalIgnoreCase)) {
    throw '原目录与目标目录相同,拒绝回滚。'
}

$running = @(Get-Process -Name 'codex', 'ChatGPT' -ErrorAction SilentlyContinue)
if ($running.Count -gt 0) {
    $names = ($running | Select-Object -ExpandProperty ProcessName -Unique) -join ', '
    throw "检测到仍在运行的进程:$names。请彻底退出后重试。"
}

if (-not (Test-Path -LiteralPath $original)) {
    if ($PSCmdlet.ShouldProcess($original, '重建原 CODEX_HOME')) {
        New-Item -ItemType Directory -Path $original -Force | Out-Null
    }
}

Write-Host "迁移后目录:$destination"
Write-Host "回滚到目录:$original"
Write-Host '正在把迁移后的新增/变更数据复制回原目录(不会删除任一目录)……'
if ($PSCmdlet.ShouldProcess($original, '回复制 Codex 状态')) {
    & robocopy.exe $destination $original /E /COPY:DAT /DCOPY:DAT /R:2 /W:1 /XJ /NP /XD (Join-Path $destination '.migration')
    $code = $LASTEXITCODE
    if ($code -ge 8) { throw "Robocopy 回复制失败,退出码:$code。环境变量尚未恢复。" }
}

$destinationFiles = @(Get-ChildItem -LiteralPath $destination -File -Recurse -Force |
    Where-Object { $_.FullName -notlike (Join-Path $destination '.migration\*') })
$errors = [Collections.Generic.List[string]]::new()
foreach ($file in $destinationFiles) {
    $relative = $file.FullName.Substring($destination.Length).TrimStart('\')
    $restored = Join-Path $original $relative
    if (-not (Test-Path -LiteralPath $restored -PathType Leaf)) {
        $errors.Add("原目录缺少:$relative")
        continue
    }
    if ($file.Length -ne (Get-Item -LiteralPath $restored).Length) {
        $errors.Add("文件大小不同:$relative")
        continue
    }
    if ($DeepVerify) {
        $a = (Get-FileHash -LiteralPath $file.FullName -Algorithm SHA256).Hash
        $b = (Get-FileHash -LiteralPath $restored -Algorithm SHA256).Hash
        if ($a -ne $b) { $errors.Add("哈希不同:$relative") }
    }
}
if ($errors.Count -gt 0) {
    $preview = ($errors | Select-Object -First 20) -join [Environment]::NewLine
    throw "回复制核验失败,共 $($errors.Count) 项。前 20 项:`n$preview`n环境变量尚未恢复。"
}

if ($PSCmdlet.ShouldProcess('用户环境变量', '恢复迁移前的 Codex 路径设置')) {
    if ([bool]$manifest.OldUserCodexHomeWasSet) {
        [Environment]::SetEnvironmentVariable('CODEX_HOME', [string]$manifest.OldUserCodexHome, 'User')
        $env:CODEX_HOME = [string]$manifest.OldUserCodexHome
    } else {
        [Environment]::SetEnvironmentVariable('CODEX_HOME', $null, 'User')
        Remove-Item Env:CODEX_HOME -ErrorAction SilentlyContinue
    }

    if ([bool]$manifest.OldUserSqliteWasSet) {
        [Environment]::SetEnvironmentVariable('CODEX_SQLITE_HOME', [string]$manifest.OldUserSqliteHome, 'User')
        $env:CODEX_SQLITE_HOME = [string]$manifest.OldUserSqliteHome
    } else {
        [Environment]::SetEnvironmentVariable('CODEX_SQLITE_HOME', $null, 'User')
        Remove-Item Env:CODEX_SQLITE_HOME -ErrorAction SilentlyContinue
    }
}

Write-Host '回滚完成。关闭当前 PowerShell,新开 PowerShell,再启动应用并检查。' -ForegroundColor Green
Write-Host "恢复后的目录:$original"
Write-Host "迁移后目录仍保留在:$destination"

运行:

Set-ExecutionPolicy -Scope Process Bypass
.\Undo-CodexHomeMigration.ps1 -DestinationPath 'D:\CodexData\.codex'

深度核验:

.\Undo-CodexHomeMigration.ps1 -DestinationPath 'D:\CodexData\.codex' -DeepVerify

回滚后,新开 PowerShell 执行:

$env:CODEX_HOME
[Environment]::GetEnvironmentVariable('CODEX_HOME', 'User')
codex --version

若迁移前没有显式设置 CODEX_HOME,回滚脚本会删除用户级变量,Codex 将重新使用默认 %USERPROFILE%\.codex

🔁 回滚数据流

⚠️ 决定回滚

退出 ChatGPT / Codex / IDE / CLI

读取新目录中的迁移清单

清单存在且路径匹配?

⛔ 停止,避免覆盖错误目录

📥 将新目录的新增/变更
覆盖复制回原目录

大小或 SHA-256
核验通过?

⛔ 不恢复变量
保留两边数据排错

🔀 恢复迁移前的
CODEX_HOME / CODEX_SQLITE_HOME

🔄 新开 PowerShell并重启应用

✅ 验证旧目录恢复工作

🗄️ 新目录继续保留
直到确认无需再次恢复

🧹 七、确认稳定后如何释放系统盘空间

至少观察数天,并确认检查脚本、人工验收和实际任务都正常。然后:

  1. 再次彻底退出应用、IDE 和 CLI;
  2. 确认新目录最近仍在更新,旧目录不再更新;
  3. 将旧目录先改名为 .codex.old-日期 或移入离线备份,而不是立刻永久删除;
  4. 再使用一段时间;
  5. 确认无需回滚后,才手工删除旧备份。

本文脚本故意不自动删除旧目录。删除包含认证、会话和技能的目录属于不可逆操作,且实际旧路径因用户配置而异,不应交给通用脚本猜测。

🐧 八、WSL 用户

Windows 原生应用与 WSL CLI 默认不共用 home。完成 Windows 侧迁移后,如果希望 WSL 使用同一份状态,在 WSL 中设置:

export CODEX_HOME=/mnt/d/CodexData/.codex

需要永久生效时写入 ~/.bashrc~/.zshrc,然后重新打开 WSL shell。注意:

  • Windows 路径 D:\CodexData\.codex 对应 WSL 路径 /mnt/d/CodexData/.codex
  • 不要同时从 Windows 和 WSL 并发写同一 SQLite/会话状态;
  • 如果 WSL 使用独立状态更符合你的工作流,就迁移 WSL 自己的 ~/.codex,不要强行共享;
  • Windows 原生代理使用 Windows 文件系统中的项目通常更可靠,官方文档也建议原生代理通过 /mnt/<drive>/... 与 WSL 共享 Windows 项目。

🩺 九、常见问题

症状最可能原因首选动作
🔄 新变量未生效应用/终端继承了旧环境完全退出后新开进程
🔐 被要求重新登录路径、权限或凭据状态异常保留旧目录,运行检查脚本
🔒 数据库锁定/复制差异仍有进程在写入退出相关进程或重启 Windows
🧱 沙箱错误迁移后 ACL/沙箱状态需重建重启并重新运行沙箱设置
🐧 WSL 看不到新状态WSL 有独立环境变量和 home在 WSL 中单独设置 CODEX_HOME

🔄 1. 新 PowerShell 中变量正确,但已打开的应用仍用旧目录

环境变量只在进程启动时继承。彻底退出应用(包括托盘/后台进程)、IDE 和终端后重开;必要时注销 Windows 再登录。不要只关闭窗口。

🔐 2. 迁移后要求重新登录

先不要删除旧目录。退出应用,运行检查脚本,确认 CODEX_HOME、文件数量和权限。认证可能依赖 Windows 凭据存储,不能仅凭是否存在某个文件判断。若仍异常,可直接用回滚脚本恢复。

🔒 3. 出现数据库锁定、文件持续变化或复制差异

通常说明仍有 Codex、ChatGPT、IDE 扩展或 CLI 进程在写入。退出所有相关程序,必要时重启 Windows,然后重新选择一个空目标目录执行迁移。不要在差异未解决时强行切变量。

🧱 4. Windows 沙箱迁移后报错

先重启应用并重新运行沙箱设置。目标盘必须保持稳定、权限不能过宽。官方说明移动仓库、改变权限或策略后沙箱可能需要重新设置;持续失败时检查新 CODEX_HOME\.sandbox\sandbox.log。不要发送 .sandbox-secrets

🔗 5. 能否用 mklink /J%USERPROFILE%\.codex 联接到 D 盘?

技术上可能,但不推荐作为首选:权限、备份工具、沙箱和故障排查都会多一层不透明映射。官方已经提供 CODEX_HOME,直接设置变量更清晰、更容易回滚。

🗃️ 6. 能否把 CODEX_SQLITE_HOME 单独放到另一个盘?

可以,官方变量支持这样做,但普通迁移没有必要。它默认跟随 CODEX_HOME;配置项 sqlite_home 的优先级还高于环境变量。若你曾显式配置 sqlite_home,请同时检查 config.toml,避免数据库仍写到系统盘。

📂 7. 为什么脚本拒绝已有内容的目标目录?

这是为了防止把两套认证、会话、插件或缓存合并。请使用新建的空目录。若此前迁移失败,先人工保存失败目录,再换一个新路径重试。

🧑‍💻 8. 项目也要迁移吗?

项目不在 CODEX_HOME 中。可单独移动到 D:\Projects,然后在桌面应用/IDE 中重新打开;检查 Git remote、虚拟环境、Node/Python 缓存、构建脚本和任何绝对路径。移动项目后 Windows 沙箱可能需要重新初始化。

📚 十、官方依据与最后提醒

  • Codex 环境变量CODEX_HOMECODEX_SQLITE_HOMECODEX_INSTALL_DIR 的公开定义和默认值。
  • Codex 配置基础:用户配置默认位于 ~/.codex/config.toml,CLI 与 IDE 扩展共享配置层。
  • Codex 配置参考:配置、profile 和日志等路径与 $CODEX_HOME 的关系。
  • Windows 桌面应用:Windows 默认 Codex home、应用安装方式、原生/WSL 行为。
  • Windows 沙箱:迁移或权限变化后的沙箱排错及敏感目录提醒。

关键原则只有三条:先复制并核验,再切换;旧目录先保留;任何异常优先回滚,不要边运行边搬动。

Logo

葡萄城是专业的软件开发技术和低代码平台提供商,聚焦软件开发技术,以“赋能开发者”为使命,致力于通过表格控件、低代码和BI等各类软件开发工具和服务,一站式满足开发者需求,帮助企业提升开发效率并创新开发模式。

更多推荐