macOS、Windows 与 WSL2 上的 Codex
先确认终端与仓库在哪一层,再处理安装、登录和权限。
- 原生 Windows 已支持沙箱,按项目需要选择 WSL2
- 每个平台独立核对 shell、命令路径和仓库目录
- 登录、子进程网络与文件权限分别排查
- 只分享最少的脱敏诊断信息
1. 先选项目要运行的环境
在 Mac 和 Windows 之间换机器,最容易丢掉的是终端里的上下文。看起来都输入了 codex,实际调用的版本、仓库位置和工具链可能已经变了。先确认项目在哪一层运行,后面的登录和权限问题才容易定位。
Mac 项目通常继续用原生终端。依赖 Windows 工具的仓库优先放在 Windows 原生环境;已有 Linux 构建脚本或 WSL 仓库的项目,可以在 WSL2 里运行。远程 Linux 服务器也走 Linux 路线。编辑器、Git、运行时尽量与仓库处在同一环境。
本文于 2026 年 10 月 5 日核对官方文档。本次实际验证限于 Linux 上 codex-cli 0.159.2 的版本与帮助、本文诊断程序及离线测试;没有在 Mac、Windows 或 WSL 安装、登录和运行模型,也没有这些系统的实机截图。
团队选型时,可以先找一条现有构建命令作为验收依据。项目依赖特定系统 SDK、桌面组件或本机服务,就在相应系统完成验证。只因为语言相同便迁移环境,容易漏掉原生扩展和脚本假设。跨平台协作需要在各自系统保存真实测试结果,不能用一台 Linux 机器上的成功覆盖其余平台。
2. 辨认终端,再确认命令路径
Windows Terminal 可以同时打开 PowerShell 和 Ubuntu 标签页。Mac 上的 Terminal、iTerm 也可能运行不同 shell。文章标为 Bash 的代码应放到 Bash 或兼容的 zsh;PowerShell 代码单独复制。不要把提示符、输出结果或另一平台的命令一起粘进去。
先查看命令来自哪里。Mac 上的 Homebrew、npm 和应用内置 CLI 可能并存;Windows 也可能同时存在原生安装和 WSL 安装。版本不一致时,保存各自的绝对路径再决定保留哪一种来源。重开终端后仍要重新检查。
pwd
command -v codex
type -a codex
codex --versionGet-Location
Get-Command codex -All | Select-Object CommandType, Source
codex --versionPATH 检查应在实际运行 Codex 的那个终端里做。编辑器启动得早,集成终端可能继承旧环境;SSH 会话、管理员终端和普通用户终端也未必相同。遇到同名 alias 或函数时,先读清解析结果,不要马上删除。排错笔记应记下操作系统、shell、程序绝对路径和版本,下一次复现才能对齐条件。
3. macOS:沿一个安装渠道走完
下面以已经装好 Homebrew 的 Mac 为例。先检查 brew,再安装官方文档列出的 codex cask。若找不到 brew,先使用 Homebrew 官方安装说明,或选择后面的 OpenAI 独立安装器;不要为了完成这一段再叠加几个 CLI 安装来源。
brew --version
brew install --cask codex
command -v codex
codex --version
codex --help
codex login
codex login status独立安装器也适用于 macOS。它会下载并执行 OpenAI 提供的安装脚本,在本机写入程序文件,可能提示调整 PATH。以下是另一条安装路线,已完成 Homebrew 安装的人不必重复执行。安装后新开终端,再运行上面的路径与版本检查。来源见官方 CLI 安装页。
curl -fsSL https://chatgpt.com/codex/install.sh | shmacOS 的沙箱使用系统 Seatbelt。若项目放在受系统隐私控制的文件夹,需检查终端或编辑器的实际访问权限。不要把整个磁盘访问权限作为通用修复;先用自己可访问的普通代码目录复现,再判断是否确实需要调整系统设置。
Apple silicon 与 Intel Mac 的程序架构也要对应。安装来源负责选择匹配产物,不要从搜索结果中的镜像站拿一个同名压缩包覆盖现有文件。系统提示来源或签名异常时,先回到官方渠道核对;不要用移除安全标记或关闭系统保护的办法让未知程序继续运行。
4. Linux:先检查沙箱前置条件
Linux 和 WSL2 的当前官方说明要求安装 bubblewrap。下面只适用于 Ubuntu、Debian 及其兼容发行版;其他系统应使用本发行版的软件包管理器。sudo 会修改系统软件,受管服务器应由管理员按既有流程处理。
sudo apt update
sudo apt install bubblewrap
bwrap --version
curl -fsSL https://chatgpt.com/codex/install.sh | sh安装器结束后新开终端,检查下面几项。服务器没有桌面浏览器时,先看后面的设备码登录说明。容器里能找到 bwrap,只证明程序存在;内核、namespace、seccomp 或容器配置仍可能阻止沙箱建立。
command -v codex
codex --version
codex --help
codex login
codex login status不要看到沙箱错误就加全权限参数。先记录具体失败命令和运行环境,交给容器或服务器管理员检查。官方的沙箱前置条件及平台隔离说明解释了这层差别。
5. Windows:原生 PowerShell 已有沙箱支持
截至本文核对日,官方推荐 Windows 11;较新的、已充分更新的 Windows 10 属于尽力支持。Windows 原生运行提供 elevated 与 unelevated 两种沙箱。旧文章里把 Windows 概括成只能用 WSL 或只有实验性支持,已不足以指导当前安装。
在新开的 PowerShell 窗口运行官方独立安装器。为了让单行不太长,先把官方命令中的脚本内容放入变量。ExecutionPolicy ByPass 只针对这次启动的 PowerShell 进程;不要因此把机器级策略长期设为绕过。企业策略拒绝执行时应停下来问 IT。
$install = 'irm https://chatgpt.com/codex/install.ps1 | iex'
powershell -ExecutionPolicy ByPass -Command $install关闭安装窗口,新开 PowerShell 后核查版本并登录。终端里看到安装成功但找不到命令,多半需要先检查 PATH 的实际解析结果;此时继续重复安装会让问题更难看清。
Get-Command codex -All | Select-Object CommandType, Source
codex --version
codex --help
codex login
codex login statuselevated 是首选沙箱,初始化涉及管理员批准的用户、权限及防火墙设置,沙箱内命令使用较低权限身份。公司设备若阻止设置,先保留错误;组织允许时才考虑较弱的 unelevated 回退。错误 1385 指向沙箱用户登录权利,需检查系统策略。依据当前 Windows 官方说明。
Windows 的管理员 PowerShell 与日常普通用户 PowerShell 要分开看。安装或沙箱初始化需要管理员批准,并不意味着之后每个任务都应以管理员身份启动。若只有管理员窗口可用,记录普通用户窗口的具体错误,检查程序目录和仓库权限。不要对整个用户目录授予 Everyone 写权限来试探问题。
6. WSL2:把 Linux 工具留在 Linux 内
需要 Linux 工具链时再进入 WSL2。先在 PowerShell 检查发行版与版本。新装 WSL 可能要求管理员权限和重启,也会安装 Linux 发行版;下面安装命令只供尚未完成这一步的机器使用。
wsl --status
wsl --list --verbose
# 未安装 WSL 时,再以管理员身份运行下一行
wsl --install
# 按提示重启并完成 Linux 用户设置,然后确认 VERSION 为 2
wsl --list --verbose
wsl进入 Ubuntu 后再运行下面的 Bash 命令。它为 WSL 单独安装 Linux 版 Codex。Windows 已经装过 codex.exe,不能据此认定 Linux 内的工具也已就绪。当前官方说明从 Codex 0.115 起不再支持 WSL1。
sudo apt update
sudo apt install bubblewrap
bwrap --version
curl -fsSL https://chatgpt.com/codex/install.sh | sh
# 新开 WSL 终端后继续
command -v codex
codex --version
codex login
codex login statusLinux 构建的仓库优先放在 /home 下。/mnt/c 是 Windows 磁盘的挂载位置,跨边界工作可能带来 I/O、权限和符号链接问题。通过资源管理器的 \\wsl$ 入口查看 Linux 文件,比混用两边的依赖目录更清楚。不要共用一份 node_modules 后假定两套环境都能运行;按目标平台重新安装依赖。详见官方 WSL 指南。
切换到 WSL 后,Git 的换行规则、大小写差异和文件执行位也可能改变。先查看仓库已有的 .gitattributes 与团队约定,再处理换行问题。看到整片文件都变了,先停止自动格式化,检查差异来源;不要先全局修改 Git 配置。若项目包含符号链接或本地二进制依赖,应单独安排验证,目录能够打开不足以说明运行一致。
7. 从仓库启动,别从整个主目录启动
下面的 demo 是路径示例,要换成已经存在的仓库。目录不存在就停止检查,不必顺手建一个同名空目录。先看 Git 根目录与已有修改;如果有别人或自己未完成的工作,记录下来再让代理接手。
cd "$HOME/code/demo" || exit 1
pwd
git rev-parse --show-toplevel
git status --short
codex -C "$HOME/code/demo" -s read-only -a on-requestSet-Location -LiteralPath "$HOME\code\demo" -ErrorAction Stop
Get-Location
git rev-parse --show-toplevel
git status --short
codex -C "$HOME\code\demo" -s read-only -a on-request进入 Codex 后用 /status 检查目录与权限。第一次先让它解释项目结构,确认读到正确仓库,再决定是否允许编辑。read-only 不会保证每个测试都能运行,测试框架常常写缓存。大写 -C 指定工作目录,小写 -c 覆盖配置,这两个参数不能互换。
需要修改项目时,可以重新启动 workspace-write 会话。代码目录可写也不代表所有子目录都能改,当前默认保护包括 .git、.agents 和 .codex。Git 提交或配置更新遇到审批,应审查具体动作。参数与边界见权限说明。
codex -s workspace-write -a on-request项目搬家后应结束旧会话,再从新位置启动。原会话保存的工作目录不会自动跟随资源管理器里的移动操作。外部共享文件夹确实需要加入工作范围时,尽量只增加那一个目录,并确认里面是否有与任务无关的资料。开始修改前留一个可恢复的 Git 检查点,结束时重新核对新增、暂存和未暂存变化。
8. 登录与网络错误,按发生位置排查
日常交互可以用 ChatGPT 登录;API key 走 API 账户计费与相应数据规则。先用 login status 确认认证方式。命令返回版本号不会验证账户额度,也不会证明模型请求能成功。本文没有执行这些线上验证。
浏览器登录完成而终端仍等待时,确认浏览器与 CLI 分别在哪台机器。远程服务器上的 localhost 回调不会因为本地浏览器打开了页面就自动连通。官方提供设备码路径,能否使用受账户或组织设置影响;仅在自己的终端发起后,使用它给出的官方页面与一次性码。
codex login --device-auth
codex login status公司网络出现证书错误,应向 IT 获取可信 PEM 证书包。先确认文件存在,再按对应 shell 设置 CODEX_CA_CERTIFICATE。不要关闭 TLS 校验,也不要把日志中的代理地址、访问令牌原样贴到公开问题中。官方文档说明这一变量同时用于登录、HTTPS 和安全 WebSocket 请求。
test -f "$HOME/certs/company-ca.pem" || exit 1
export CODEX_CA_CERTIFICATE="$HOME/certs/company-ca.pem"
codex login$ca = "$HOME\certs\company-ca.pem"
if (-not (Test-Path -LiteralPath $ca)) { throw 'CA file missing' }
$env:CODEX_CA_CERTIFICATE = $ca
codex login代理可达性还要分清客户端登录请求和沙箱内子进程联网。CLI 能登录,包管理器仍可能被当前沙箱网络策略挡住。检查正在使用的权限和企业代理要求,不要把关闭沙箱当成代理配置。认证缓存可能含访问令牌;auth.json、.env 和完整配置目录都不适合作为排错附件。依据认证文档与环境变量参考。
设备码流程也应由本人完成。不要接受陌生人发送的设备码或代登链接,不要把自己的码发到工单里。若登录成功后出现工作区或功能权限错误,先确认账户所在工作区与组织策略;重复登录通常不能解决管理员限制。只有准备切换或清除已存储登录时才执行 logout,避免顺手影响正在使用同一认证缓存的其他客户端。
9. 升级与卸载,先对准安装来源
升级前记下 codex --version 和命令绝对路径。Homebrew 安装就由 Homebrew 更新;npm 安装就由 npm 更新;独立安装器用户按同一平台的安装命令重新执行。不要仅凭版本仍旧就换一个包管理器覆盖安装。安装器路径、npm 全局目录和编辑器继承的 PATH 都可能不同。
brew update
brew upgrade --cask codex
codex --versionnode --version
npm --version
npm install -g @openai/codex@latest
codex --versionnode --version
npm.cmd --version
npm.cmd install -g @openai/codex@latest
codex --versionnpm 路线需要先从 Node.js 官方来源安装受支持版本。Windows 使用 npm.cmd 可避开把 npm.ps1 当脚本启动的问题,仍须遵守组织策略。上面的 latest 是执行时解析的版本,团队需要复现时应记录实际版本并由维护者选择固定版本。
卸载前先结束当前 Codex 任务。若要退出已存储登录,可以自行执行 codex logout,再用原来的包管理器卸载。以下两种卸载命令按来源二选一;它们不等于清理所有历史、配置和凭据。
codex logout
brew uninstall --cask codex
type -a codexcodex logout
npm uninstall -g @openai/codex
type -a codexcodex logout
npm.cmd uninstall -g @openai/codex
Get-Command codex -All -ErrorAction SilentlyContinue独立安装器的当前官方安装页未给出统一卸载命令。先核对安装时的记录与实际文件位置,再移除对应程序或请管理员处理。不要直接删除整个 .codex 目录,它还可能包含历史、配置与凭据。卸载后命令仍能运行,优先检查是否还有另一份安装。
升级后保留旧版本号与新版本号,重新打开一次帮助,检查文章里使用的参数是否仍存在。若某个参数被拒绝,按当前帮助调整,不要从旧教程拼出替代组合。回退版本应根据原安装渠道和团队验证记录决定;本文没有指定一个适用于所有系统的固定回退版本。
10. 留一份不含凭据的本地诊断
下面的 Python 标准库程序只检查系统类型、架构、Python 版本、目标目录,以及 codex 和 Git 的可执行路径与版本。Git 检查仅输出是否识别为工作树、是否存在变化,不返回文件名。它不读取 .env、auth.json 或仓库文件内容,不输出环境变量,不获取 IP,也不发起登录和模型请求。
程序仍会运行 PATH 中找到的 codex 与 git,所以先确认它们是可信安装。解析到仓库内部的程序会跳过;Windows 的 .cmd、.bat、.ps1 包装器也跳过。unknown 表示未取得结果,不能当作通过。输出包含本地路径,分享前仍应去掉用户名与私有项目名称。
python3 code/platform_probe.py --repo "$HOME/code/demo"
python3 -m unittest discover -s code -vpy -3 code/platform_probe.py --repo "$HOME\code\demo"
py -3 -m unittest discover -s code -v也可以把后面的两份完整源码保存到同一个 code 目录再运行,无需下载附件。程序只写标准输出;如要重定向报告,自己选一个不会覆盖项目文件的位置。测试使用人工构造输出,不调用模型,本次 16 项已通过,覆盖缺失程序、错误版本、超时、Git 状态失败和路径保护。
完整源码:platform_probe.py
"""Small local probe: no login, model request, network, or file-content scan."""
import argparse
import json
import platform
import re
import shutil
import subprocess
import sys
from pathlib import Path
def run(args, cwd):
try:
p = subprocess.run(
args, cwd=cwd, shell=False, timeout=8, check=False,
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
)
return p.returncode, p.stdout.decode("utf-8", errors="replace")
except subprocess.TimeoutExpired:
return "timeout", ""
except OSError:
return "unavailable", ""
def executable(name, repo, which=shutil.which):
found = which(name)
if not found:
return {"path": None, "state": "missing"}
path = Path(found).resolve()
if path == repo or repo in path.parents:
return {"path": str(path), "state": "inside_repo_skipped"}
if path.suffix.lower() in {".cmd", ".bat", ".ps1"}:
return {"path": str(path), "state": "wrapper_skipped"}
return {"path": str(path), "state": "ready"}
def version(info, name, cwd, runner=run):
if info["state"] != "ready":
return {**info, "version": None}
rc, out = runner([info["path"], "--version"], cwd)
pattern = {
"codex": r"^codex-cli ([0-9][0-9A-Za-z.+-]{0,63})$",
"git": r"^git version ([0-9][0-9A-Za-z.+-]{0,63})$",
}[name]
match = re.fullmatch(pattern, out.strip()) if rc == 0 else None
return {
**info, "version": match.group(1) if match else None,
"version_check": "ok" if match else "unknown",
}
def git_state(info, cwd, runner=run):
if info["state"] != "ready":
return {"repository": "unknown", "dirty": None}
base = [
info["path"], "--no-optional-locks", "-c", "core.fsmonitor=false",
"-C", str(cwd),
]
rc, out = runner(base + ["rev-parse", "--is-inside-work-tree"], cwd)
if rc != 0 or out.strip() != "true":
return {"repository": "unknown", "dirty": None}
rc, out = runner(base + [
"status", "--porcelain=v1", "-z", "--untracked-files=normal",
"--ignore-submodules=all",
], cwd)
return {"repository": "yes", "dirty": bool(out) if rc == 0 else None}
def probe(repo, runner=run, which=shutil.which):
cwd = Path(repo).expanduser().resolve()
if not cwd.is_dir():
raise ValueError("repo must be an existing directory")
entries = {
name: version(executable(name, cwd, which), name, cwd, runner)
for name in ("codex", "git")
}
return {
"schema": 1,
"os": platform.system(),
"architecture": platform.machine(),
"python": platform.python_version(),
"directory": str(cwd),
"executables": entries,
"git": git_state(entries["git"], cwd, runner),
"limits": "No auth, proxy, sandbox, or model connectivity test",
}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--repo", default=".")
args = parser.parse_args()
try:
result = probe(args.repo)
except (ValueError, OSError):
print("Cannot inspect the requested directory", file=sys.stderr)
return 2
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())完整源码:test_probe.py
import json
import subprocess
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
import platform_probe as p
class ProbeTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory(prefix="probe space ")
self.root = Path(self.tmp.name).resolve()
self.info = {"path": "/trusted/git", "state": "ready"}
def tearDown(self):
self.tmp.cleanup()
def test_missing(self):
self.assertEqual(p.executable("x", self.root, lambda _: None)[
"state"], "missing")
def test_repo_executable_skipped(self):
self.assertEqual(p.executable("x", self.root, lambda _: str(
self.root / "codex"))["state"], "inside_repo_skipped")
def test_wrapper_skipped(self):
self.assertEqual(p.executable("x", self.root, lambda _: "/a/x.cmd")[
"state"], "wrapper_skipped")
def test_version(self):
r = p.version(self.info, "git", self.root,
lambda *a: (0, "git version 2.50.0\n"))
self.assertEqual(r["version"], "2.50.0")
def test_codex_version(self):
r = p.version(self.info, "codex", self.root,
lambda *a: (0, "codex-cli 0.159.2\n"))
self.assertEqual(r["version"], "0.159.2")
def test_extra_output_not_retained(self):
r = p.version(self.info, "git", self.root,
lambda *a: (0, "git version 2.50.0\nPRIVATE"))
self.assertNotIn("PRIVATE", json.dumps(r))
self.assertIsNone(r["version"])
def test_failed_version(self):
r = p.version(self.info, "git", self.root,
lambda *a: (1, "git version 2.50.0"))
self.assertIsNone(r["version"])
def test_clean(self):
calls = iter([(0, "true\n"), (0, "")])
self.assertFalse(p.git_state(self.info, self.root,
lambda *a: next(calls))["dirty"])
def test_dirty_without_paths(self):
calls = iter([(0, "true\n"), (0, "?? private-file\0")])
r = p.git_state(self.info, self.root, lambda *a: next(calls))
self.assertTrue(r["dirty"])
self.assertNotIn("private-file", json.dumps(r))
def test_not_repository(self):
r = p.git_state(self.info, self.root, lambda *a: (128, ""))
self.assertIsNone(r["dirty"])
def test_status_failure(self):
calls = iter([(0, "true\n"), (128, "")])
r = p.git_state(self.info, self.root, lambda *a: next(calls))
self.assertIsNone(r["dirty"])
def test_timeout(self):
with patch.object(p.subprocess, "run", side_effect=
subprocess.TimeoutExpired(["git"], 8)):
self.assertEqual(p.run(["git"], self.root), ("timeout", ""))
def test_os_error(self):
with patch.object(p.subprocess, "run", side_effect=OSError()):
self.assertEqual(p.run(["git"], self.root), ("unavailable", ""))
def test_bad_directory(self):
with self.assertRaises(ValueError):
p.probe(self.root / "missing")
def test_probe_missing_tools(self):
r = p.probe(self.root, which=lambda _: None)
self.assertEqual(r["directory"], str(self.root))
self.assertIsNone(r["git"]["dirty"])
def test_safe_git_arguments(self):
calls = []
def runner(args, cwd):
calls.append(args)
return (0, "true\n") if len(calls) == 1 else (0, "")
p.git_state(self.info, self.root, runner)
self.assertIn("--no-optional-locks", calls[0])
self.assertIn("core.fsmonitor=false", calls[0])
self.assertIn("--ignore-submodules=all", calls[1])
if __name__ == "__main__":
unittest.main()如果 Codex 看似卡住,先在原终端取消当前操作并观察状态。不要直接杀掉所有同名进程,可能还有其他任务正在写文件。升级后完全退出旧会话和编辑器终端,再用新终端核对路径。求助时提供 shell、版本、失败步骤和脱敏错误,通常比一份整机环境导出更容易查明原因。
诊断中的 Git dirty 仅覆盖主工作树的普通状态,忽略子模块内部变化与被忽略的文件。它不会判断仓库能否构建,也不会检查端口、证书、代理和沙箱是否可用。程序执行成功只说明这些有限检查结束;下一步仍需在目标终端验证原先失败的那条命令,并保留实际退出结果。
11. 来源与核查范围
本文由 pan 整理。平台选择、排错顺序及诊断程序是本文的实践建议;平台支持和命令语义按以下官方资料核对。页面中的路径与提示词为教学示例,图片为原创图解。Mac、Windows、WSL 实机运行和浏览器登录仍需在自己的环境验证。
12. 跨平台搬仓库前:先检查文件名,而不是先改权限
还有一种迁移故障,安装和登录都正常也解决不了:Linux 仓库里同时存在 src/A.py 与 src/a.py,目标环境却不能按预期区分它们。另一个更隐蔽的例子是 Docs/intro.md 与 docs/other.md:末级文件名不同,但目录前缀已经发生大小写别名。只把完整路径转成小写再查重复,会漏掉后一种情况。
本节补一份独立的只读文件名审阅器。在搬仓库或让 Codex 跨平台修改前,先把某一 Git 树的路径导出为 NUL 分隔清单,再检查文件和目录前缀。它不读取文件正文、不启动 Codex、不改 Git 配置,也不重命名任何文件。与上文的安装诊断互补:前者回答命令从哪里来,本节回答这份路径集合有哪些需要人工确认的迁移风险。
全部例子是合成仓库。2026-10-14 在 Linux、Python 3.12.14、Git 2.52.0 上实际运行:31 项固定检查在普通与 -O 模式均通过;五种故障变异在两种模式下均被测试拒绝;真实临时 Git 树导出得到同样结果。没有在 Windows、macOS、WSL 或实际 Codex 会话中测试,不能据此宣称这些平台已经迁移成功。
13. 审阅合同:区分保守别名提示、Windows 名称风险和输入损坏
输入是相对仓库根目录的 UTF-8 路径,以 NUL 字节分隔,最后一项也必须有 NUL。空字节串代表空树;空路径、重复路径、绝对路径、空组件、. 与 .. 组件拒绝。文件名允许包含空格、制表符或换行,解析器不能先 strip 或 splitlines。换行虽然能被正确读取,随后仍会成为 Windows 字符风险。不可解码的 Git 路径返回 non-utf8-name,要求单独审查,绝不替换字符后假装名称完整。
别名键使用 NFC(casefold(路径前缀))。这是本文自定的保守团队审阅策略,不是 NTFS、APFS 或 HFS+ 的精确比较器;Unicode casefold 可能比目标文件系统的等价关系更宽,NFC 等价也不意味着每个卷都会合并这两个名字。报告只请求人工复核,不断言 checkout 必然失败。每个前缀同时保留原始拼写及 file/directory 类型,避免把不同目录默默当成一个目录。
名称风险单独列出:Windows 常规命名约定中的设备名称(含扩展名和 COM¹ 等形式)、末尾空格或句点、保留字符与控制字符。点开头的 .gitignore 合法,COM0 与 COM10 也不因本例设备规则被拒绝。这里采用常规应用兼容口径,不模拟所有命名空间或特殊 API。
输出 decision 为 review 或 no-listed-risk。后者只表示“未命中本工具所列规则”,不是跨平台兼容证书。没有覆盖双向文字控制字符与同形异义字造成的视觉混淆、路径总长度、目标卷大小写设置、Unicode 的全部平台例外、权限、符号链接目标、子模块内容、构建导入引用以及依赖包的原生二进制。路径清单也不含模式信息,因此此处 file 表示清单末端项,不推断它一定是普通文件。
每份清单最多 1 MiB、10,000 条路径;单路径最多 4,096 个 Python Unicode 码点、64 层。这些是本工具的处理上限,不是文件系统上限。退出码 0 表示没有所列风险,1 表示成功审阅但需要人工确认,2 表示输入或读取失败。失败只在 stderr 输出受控错误,stdout 不生成成功报告。输出含路径,可能暴露项目名;对外分享前仍要脱敏。
例如 Straße 与 STRASSE 会在本策略下得到同一个键,这说明它会提示比某些目标系统实际行为更宽的别名范围。相同拼写的 a 与 a/b 也会被标记:a 同时是清单末端项和另一条路径的目录前缀。这样的清单不一定来自合法 Git 树,仍应拒绝把它当成无风险输入。
不能仅凭“这是一台 Mac”判断大小写行为。Apple 的磁盘工具说明明确列出 APFS 与 APFS(区分大小写)等格式,同一容器中的卷也可采用不同格式。迁移验收应记录实际目标卷及其设置;本例不推断 macOS 的全部 Unicode 比较规则,也不把 NTFS、APFS 和 Python 的 casefold 当成同一算法。
14. 完整输入与独立预期:十三条路径,八处审阅项
把下面所有源码和 JSON 放进一个新的练习目录。fixture.json 中的 Unicode 转义表示组合重音,换行转义表示文件名里的一个换行;不要手工替换这些转义。JSON 用于让人读懂合成输入;实际审阅器只吃二进制 NUL 清单。
fixture.json
["src/A.py","src/a.py","Docs/intro.md","docs/other.md","café.txt","cafe\u0301.txt","NUL.txt","notes./x.txt","data/a:b.txt","line\nbreak.txt","Cache","cache/data.txt","safe dir/中文.txt"]
用以下转换生成清单,不能直接把 JSON 文件传给审阅器。recovered.json 稍后给出;第一次只需转换 fixture.json。二进制写入不受 shell 的文本编码和换行转换影响。
import json
from pathlib import Path
names = json.loads(Path('fixture.json').read_text(encoding='utf-8'))
Path('fixture.nul').write_bytes(b''.join(name.encode('utf-8') + b'\0' for name in names))固定预期先按案例人工列明:cache 为文件/目录别名;café.txt 两种 Unicode 拼写为保守别名;Docs/docs 是目录前缀别名;src/A.py 与 src/a.py 是末级文件别名。另有 NUL.txt、data/a:b.txt、换行文件名、notes. 目录四项名称风险。safe dir/中文.txt 不产生风险。测试里的 EXPECTED 是手写常量,不调用审阅器生成。
15. 可复用实现:保留原始拼写,逐层审阅路径
保存为 path_audit.py。代码只读取明确指定的清单,不扫描主目录,不访问认证文件,也不写回输入。JSON 输出使用转义形式展示换行和组合字符,避免把一个文件名渲染成两行诊断。报告顺序固定,因此同一批路径换序后输出仍相同。
path_audit.py
"""Read-only conservative filename audit; not a target-filesystem emulator."""
import json
import re
import sys
import unicodedata
from pathlib import Path
MAX_BYTES = 1048576
RESERVED = re.compile(r'^(CON|PRN|AUX|NUL|COM[1-9¹²³]|LPT[1-9¹²³])$', re.I)
FORBIDDEN = set('<>:"\\|?*')
class Rejected(ValueError):
pass
def key(text):
return unicodedata.normalize('NFC', text.casefold())
def audit(raw):
if len(raw) > MAX_BYTES:
raise Rejected('manifest-too-large')
if raw and not raw.endswith(b'\0'):
raise Rejected('missing-final-nul')
try:
names = raw[:-1].decode('utf-8').split('\0') if raw else []
except UnicodeDecodeError:
raise Rejected('non-utf8-name') from None
if len(names) > 10000:
raise Rejected('too-many-paths')
if len(set(names)) != len(names):
raise Rejected('duplicate-path')
nodes = {}
problems = []
for path in sorted(names):
parts = path.split('/')
if (len(path) > 4096 or len(parts) > 64 or
any(part in ('', '.', '..') for part in parts)):
raise Rejected('invalid-relative-path')
for index, part in enumerate(parts):
prefix = '/'.join(parts[:index + 1])
node = nodes.setdefault(key(prefix), {'spellings': set(), 'kinds': set()})
node['spellings'].add(prefix)
node['kinds'].add('file' if index == len(parts)-1 else 'directory')
reasons = []
if RESERVED.fullmatch(part.split('.')[0]):
reasons.append('windows-device-name')
if part.endswith((' ', '.')):
reasons.append('windows-trailing-space-or-dot')
if any(ch in FORBIDDEN or ord(ch) < 32 for ch in part):
reasons.append('windows-character')
for reason in reasons:
problems.append((prefix, reason))
collisions = []
for normalized, node in sorted(nodes.items()):
if len(node['spellings']) > 1 or len(node['kinds']) > 1:
collisions.append({'key': normalized,
'spellings': sorted(node['spellings']),
'kinds': sorted(node['kinds'])})
issues = [{'path': path, 'reason': reason}
for path, reason in sorted(set(problems))]
return {'schema': 1, 'policy': 'nfc-casefold-review-v1', 'paths': len(names),
'decision': 'review' if collisions or issues else 'no-listed-risk',
'collisions': collisions, 'issues': issues}
def main():
if len(sys.argv) != 2:
print(json.dumps({'error': 'usage: path_audit.py MANIFEST'}), file=sys.stderr)
return 2
try:
with Path(sys.argv[1]).open('rb') as stream:
result = audit(stream.read(MAX_BYTES + 1))
except (OSError, Rejected) as exc:
code = str(exc) if isinstance(exc, Rejected) else 'read-failed'
print(json.dumps({'error': code}), file=sys.stderr)
return 2
print(json.dumps(result, ensure_ascii=True, indent=2))
return 1 if result['decision'] == 'review' else 0
if __name__ == '__main__':
raise SystemExit(main())
运行 python3 path_audit.py fixture.nul,退出码应为 1。下面是本次实际 stdout;退出 1 在这里是发现待审项,不是程序崩溃。若外层工作流只接受退出 0,需要明确区分这三个退出状态,不能加一个无条件忽略错误的包装。
actual.json
{
"schema": 1,
"policy": "nfc-casefold-review-v1",
"paths": 13,
"decision": "review",
"collisions": [
{
"key": "cache",
"spellings": [
"Cache",
"cache"
],
"kinds": [
"directory",
"file"
]
},
{
"key": "caf\u00e9.txt",
"spellings": [
"cafe\u0301.txt",
"caf\u00e9.txt"
],
"kinds": [
"file"
]
},
{
"key": "docs",
"spellings": [
"Docs",
"docs"
],
"kinds": [
"directory"
]
},
{
"key": "src/a.py",
"spellings": [
"src/A.py",
"src/a.py"
],
"kinds": [
"file"
]
}
],
"issues": [
{
"path": "NUL.txt",
"reason": "windows-device-name"
},
{
"path": "data/a:b.txt",
"reason": "windows-character"
},
{
"path": "line\nbreak.txt",
"reason": "windows-character"
},
{
"path": "notes.",
"reason": "windows-trailing-space-or-dot"
}
]
}
16. 固定测试与负对照:不让错误实现也轻松通过
保存 test_paths.py 后运行 python3 test_paths.py 和 python3 -O test_paths.py。显式 check 会抛出 AssertionError,使用 -O 时不会像 assert 语句一样被移除。固定完整对象覆盖主要报告;额外检查包含顺序不敏感、空树、设备名称、空格和中文、制表符/换行边界、非法 UTF-8、重复路径、资源上限、CLI 状态与输入保持不变。
test_paths.py
import json
import subprocess
import sys
from pathlib import Path
from path_audit import audit, Rejected
HERE = Path(__file__).resolve().parent
RAW = (HERE / 'fixture.nul').read_bytes()
EXPECTED = {
'schema':1, 'policy':'nfc-casefold-review-v1', 'paths':13, 'decision':'review',
'collisions':[
{'key':'cache', 'spellings':['Cache','cache'], 'kinds':['directory','file']},
{'key':'café.txt', 'spellings':['cafe\u0301.txt','café.txt'], 'kinds':['file']},
{'key':'docs', 'spellings':['Docs','docs'], 'kinds':['directory']},
{'key':'src/a.py', 'spellings':['src/A.py','src/a.py'], 'kinds':['file']}],
'issues':[
{'path':'NUL.txt','reason':'windows-device-name'},
{'path':'data/a:b.txt','reason':'windows-character'},
{'path':'line\nbreak.txt','reason':'windows-character'},
{'path':'notes.','reason':'windows-trailing-space-or-dot'}]}
COUNT = 0
def check(value):
global COUNT
if not value:
raise AssertionError('fixed oracle mismatch')
COUNT += 1
def pack(names):
return b''.join(name.encode('utf-8') + b'\0' for name in names)
def rejected(raw, code):
try:
audit(raw)
except Rejected as error:
check(str(error) == code)
else:
raise AssertionError('invalid manifest accepted')
check(audit(RAW) == EXPECTED)
check(audit(pack(list(reversed(json.loads((HERE / 'fixture.json').read_text(encoding='utf-8')))))) == EXPECTED)
check(audit((HERE / 'recovered.nul').read_bytes()) == {
'schema':1, 'policy':'nfc-casefold-review-v1', 'paths':13,
'decision':'no-listed-risk', 'collisions':[], 'issues':[]})
check(audit(b'') == {'schema':1,'policy':'nfc-casefold-review-v1','paths':0,
'decision':'no-listed-risk','collisions':[],'issues':[]})
check(audit(pack(['a b.txt','中文.txt','.gitignore','COM0.txt','COM10.txt']))['issues'] == [])
for name in ['CON','con.txt','LPT9.log','COM¹.txt','aux']:
check(audit(pack([name]))['issues'] == [{'path':name,'reason':'windows-device-name'}])
check(audit(pack(['name ']))['issues'] == [{'path':'name ','reason':'windows-trailing-space-or-dot'}])
check(audit(pack(['a\\b']))['issues'] == [{'path':'a\\b','reason':'windows-character'}])
check(audit(pack(['a\tb']))['paths'] == 1)
check(audit(pack(['a\nb']))['paths'] == 1)
rejected(b'a','missing-final-nul')
rejected(b'\xff\0','non-utf8-name')
rejected(b'\0','invalid-relative-path')
rejected(b'a\0a\0','duplicate-path')
for name in ['/a','a//b','../a','a/./b','a/']:
rejected(pack([name]), 'invalid-relative-path')
rejected(b'x' * 1048577,'manifest-too-large')
rejected(pack([str(n) for n in range(10001)]),'too-many-paths')
rejected(pack(['x' * 4097]),'invalid-relative-path')
rejected(pack(['/'.join(['a'] * 65)]),'invalid-relative-path')
for file, code, expected in [('fixture.nul',1,EXPECTED),('recovered.nul',0,{
'schema':1,'policy':'nfc-casefold-review-v1','paths':13,'decision':'no-listed-risk',
'collisions':[],'issues':[]})]:
p = subprocess.run([sys.executable,str(HERE/'path_audit.py'),str(HERE/file)],
capture_output=True,text=True,timeout=5)
check(p.returncode == code and p.stderr == '' and json.loads(p.stdout) == expected)
p = subprocess.run([sys.executable,str(HERE/'path_audit.py'),str(HERE/'missing.nul')],
capture_output=True,text=True,timeout=5)
check(p.returncode == 2 and p.stdout == '' and json.loads(p.stderr) == {'error':'read-failed'})
check((HERE/'fixture.nul').read_bytes() == RAW)
print(f'PASS {COUNT} fixed checks')
mutations.py 在临时目录制作五份故障副本,不改基线源码。它们分别丢掉大小写折叠、丢掉 Unicode 规范化、跳过目录前缀、漏判带扩展名的设备名、按换行拆坏路径。每份先通过语法编译,再要求测试因固定答案不匹配而失败;语法错误或其他非预期失败不能算成功杀死变异。
mutations.py
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
HERE = Path(__file__).resolve().parent
source = (HERE/'path_audit.py').read_text(encoding='utf-8')
mutations = {
'lose-casefold':("text.casefold()", "text"),
'lose-normalization':("unicodedata.normalize('NFC', text.casefold())", "text.casefold()"),
'skip-directory-prefixes':("for index, part in enumerate(parts):", "for index, part in enumerate(parts):\n if index != len(parts)-1:\n continue"),
'forget-device-extension':("part.split('.')[0]", "part"),
'split-names-by-lines':("raw[:-1].decode('utf-8').split('\\0')", "raw[:-1].decode('utf-8').replace('\\n','\\0').split('\\0')"),
}
for name, (old,new) in mutations.items():
if source.count(old) != 1:
raise RuntimeError('mutation anchor mismatch: '+name)
mutant = source.replace(old,new)
compile(mutant,name,'exec')
with tempfile.TemporaryDirectory(prefix='path-audit-negative-') as folder:
dest=Path(folder)
for file in ['test_paths.py','fixture.json','fixture.nul','recovered.nul']:
shutil.copyfile(HERE/file,dest/file)
(dest/'path_audit.py').write_text(mutant, encoding='utf-8')
for flags in ([],['-O']):
p=subprocess.run([sys.executable,*flags,str(dest/'test_paths.py')],
capture_output=True,text=True,timeout=10)
if p.returncode != 1 or 'AssertionError: fixed oracle mismatch' not in p.stderr:
raise RuntimeError('unexpected mutation outcome: '+name+'\n'+p.stderr)
print(name+': rejected in normal and -O modes')
本次实测输出
PASS 31 fixed checks
PASS 31 fixed checks
lose-casefold: rejected in normal and -O modes
lose-normalization: rejected in normal and -O modes
skip-directory-prefixes: rejected in normal and -O modes
forget-device-extension: rejected in normal and -O modes
split-names-by-lines: rejected in normal and -O modes负对照证明这些指定错误会被发现,不证明程序已经覆盖所有平台文件名规则。若修改规则,需要同步版本化 policy 字段与人工审阅预期;不能为让测试变绿,直接把实际输出复制成新的 EXPECTED。
17. 失败后的恢复:先形成可审阅候选,再验证真实目标系统
原始清单得到 review 后,不应让程序自动合并两份文件或丢弃其中一份。下面是人工为本合成案例编写的替代清单:两个 Python 文件改为不同业务名称,两个重音名称分开命名,Docs/docs 明确采用同一 docs 目录,Cache 文件与 cache 目录拆开。它只是候选路径集合,不会执行迁移,也没有证明真实代码中的 import、文档链接或构建规则已更新。
recovered.json
["src/alpha.py","src/beta.py","docs/intro.md","docs/other.md","cafe-primary.txt","cafe-secondary.txt","null-device-notes.txt","notes/x.txt","data/a-b.txt","line-break.txt","cache-index.txt","cache/data.txt","safe dir/中文.txt"]
用前面的 JSON→NUL 转换将 recovered.json 写为 recovered.nul,再运行 python3 path_audit.py recovered.nul。本次实际结果为退出码 0,完整输出如下:
recovery-actual.json
{
"schema": 1,
"policy": "nfc-casefold-review-v1",
"paths": 13,
"decision": "no-listed-risk",
"collisions": [],
"issues": []
}
这不是一份可直接作用于真实项目的重命名计划。真实恢复应由仓库维护者先确认两份大小写或重音近似文件是否分别有业务含义,再选择明确名称并更新引用、检查差异和构建。只把清单变绿并不会修复代码;没有路径映射与目标系统测试时,迁移状态仍应记为待验证。碰到 input 错误应重新导出完整路径清单,不要用 errors=replace、删除异常行或补一个空成功对象绕过。
18. 怎样接真实 Git 树:保住 NUL,固定验收范围
Git 官方 ls-tree 支持 -r 递归和 -z NUL 终止;--name-only 只导出名字,--full-tree 避免当前子目录改变路径范围。在受信任仓库内,先由维护者选定一个完整 commit 或 tree 对象 ID,再执行 git ls-tree -r -z --name-only --full-tree 对象ID。这个快照不包含未提交改动、未跟踪文件,也不递归子模块内部。清单没有树 ID 字段,实际审阅记录应另保存所选对象 ID、Git 版本、清单来源和生成时间,防止审查之后换了输入却沿用旧结论。
不要把二进制输出通过文本管道转成字符串再写回,尤其不要依赖不同 PowerShell 版本的重定向行为。可以用 Python subprocess.run 的 bytes stdout 保存;必须先检查 Git 退出码,再写新文件。本文审阅器可以检查清单格式,无法证明一个格式正确的清单没有被人为删去几条路径。
下面的真实仓库接入片段需要先填写路径与对象 ID,只打印报告,不把 Git 输出交给 shell 文本重定向。它通过 check=True 拒绝 Git 失败,通过 bytes 保留名字边界。该接入片段是示例;本次真实 Git 验证使用后面的独立合成集成脚本。
import json
import re
import subprocess
from path_audit import audit
# 改成已经核实的仓库路径与完整 commit/tree 对象 ID。
repository = "/path/to/trusted/repository"
object_id = "REPLACE_WITH_FULL_OBJECT_ID"
if not re.fullmatch(r"(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})", object_id):
raise ValueError("需要完整十六进制对象 ID")
result = subprocess.run(
["git", "-C", repository, "ls-tree", "-r", "-z",
"--name-only", "--full-tree", object_id],
check=True, capture_output=True, timeout=30,
)
# stdout 始终为 bytes;不 splitlines、不 decode 后再编码。
report = audit(result.stdout)
print(json.dumps(report, ensure_ascii=True, indent=2))
# 此片段仅打印报告,不更改仓库、Git 配置或文件名。
下面是可独立重跑的 Linux 合成集成检查。它只在临时目录创建测试仓库,通过 add 与 write-tree 产生树对象,不创建提交、不配置作者、不执行网络操作。然后用真实 ls-tree 导出并与同一份手写 EXPECTED 比较。此脚本会故意创建大小写、重音、Windows 保留名和换行文件名,不要在 Windows/macOS 或真实仓库里照搬创建步骤;目标文件系统可能无法表示这些样本。
git_fixture.py
"""Synthetic Linux-only export check. Writes only a disposable repository."""
import json
import subprocess
import tempfile
from pathlib import Path
from path_audit import audit
from test_paths import EXPECTED
HERE=Path(__file__).resolve().parent
names=json.loads((HERE/'fixture.json').read_text(encoding='utf-8'))
with tempfile.TemporaryDirectory(prefix='path-audit-git-') as folder:
repo=Path(folder)
def git(*args):
return subprocess.run(['git','-C',str(repo),*args],check=True,
capture_output=True,timeout=10).stdout
git('init','-q')
for name in names:
target=repo/name
target.parent.mkdir(parents=True,exist_ok=True)
target.write_text('synthetic fixture\n')
git('add','--all')
tree=git('write-tree').decode().strip()
raw=git('ls-tree','-r','-z','--name-only','--full-tree',tree)
if audit(raw) != EXPECTED:
raise AssertionError('Git export disagrees with fixed oracle')
print('PASS real Git tree export: 13 paths, 4 collision groups, 4 naming issues')
print(subprocess.run(['git','--version'],check=True,capture_output=True,text=True).stdout.strip())
运行 python3 git_fixture.py 的本次输出如下。第一行来自导入 test_paths 时执行的固定测试;第二行才是真实 Git 集成结果。
PASS 31 fixed checks
PASS real Git tree export: 13 paths, 4 collision groups, 4 naming issues
git version 2.52.0交给 Codex 的任务单可以写成:“仅审阅这份已指定树对象导出的路径报告。逐项解释别名和名称风险,保留不确定项;不要改 Git 配置、权限、文件名或源码。任何实际迁移方案需另交维护者审阅,验收时在目标系统重新 checkout 并运行项目测试。”这让代理先给出可检查的证据,而不是把安装、权限和迁移问题混在一起。
19. 本节来源与尚未验证的部分
Git 官方 ls-tree 文档用于确认 NUL 输出、递归与路径范围;Microsoft 文件与目录命名说明用于核对常规 Windows 名称风险;Python unicodedata 文档用于核对 NFC 规范化含义。Apple 的文件系统格式说明用于确认 APFS 存在区分大小写的可选格式。资料查阅日为 2026-10-14。本文的规则组合、样本、固定答案和恢复候选是原创教学设计,不是上述项目发布的兼容性认证工具。
实测:Linux 上的只读审阅器、31 项固定检查双模式、五种源码故障变异双模式、真实临时 Git 树路径导出。合成:所有文件名、文件内容和候选恢复路径。未测:Windows/macOS/WSL 实际文件系统、真实项目迁移、Codex 模型行为、页面浏览器交互、checkout 后的构建与引用完整性。本节不修改前文已有平台支持说明,也不把离线结果扩大成这些平台的成功记录。
Windows 必须安装 WSL 吗?
不必。当前原生 Windows 提供 elevated 与 unelevated 沙箱;已有 Linux 工作流或需要 Linux 工具时可选择 WSL2。
为什么更新后还是旧版本?
先查看当前 shell 实际找到的 codex 路径;Homebrew、npm、独立安装和应用内置版本可能并存。
诊断程序会查看凭据吗?
不会。它不读取 .env、auth.json 或配置内容,只运行固定的版本和 Git 状态检查。输出仍含本地路径,分享前应脱敏。