diff --git a/AGENTS.md b/AGENTS.md index 7149898..7c7e62c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,7 +9,8 @@ Claude Code Recover is an independent, offline, evidence-first Claude Code proje - A `--route` argument records an explicit CLI input; the agent must still show it to the user and obtain confirmation before recovery work. - Do not add runtime networking, telemetry, update checks, dependency downloads, or shell-based command construction. - Keep bootstrap version behavior explicit: the same canonical version is force-reinstalled from the newly verified snapshot, older canonical installs require `--upgrade`, newer installs are never silently downgraded, active virtual environments must not receive `--user`, and an RC1 `acgm-recover` distribution must return `MIGRATION_REQUIRED` before any mutation. -- Use `claude-code-recover` / `python -m claude_code_recover` as canonical RC2 entrypoints. Keep `acgm-recover` / `python -m acgm_recover` working only as the documented one-RC compatibility aliases. +- Outside virtual environments, detect the PEP 668 marker before installation. Use `--break-system-packages` only when the selected pip advertises it and the canonical install command also contains `--user`; otherwise fail closed before mutation. If that override path fails, do not run an automatic uninstall because pip uninstall has no equivalent `--user` scope. +- Use `claude-code-recover` / `python -m claude_code_recover` as canonical RC3 entrypoints. Keep `acgm-recover` / `python -m acgm_recover` working only as documented transition aliases. - Use only synthetic fixtures in tests. Never commit real transcripts, credentials, account identifiers, or private absolute paths. - On Windows, only bootstrap, installation, `--version`, `doctor`, and `guide` are currently in scope. Do not run or claim support for core `discover`, `inspect`, `build`, or `verify` until the Windows filesystem and Git safety port is complete. diff --git a/CHANGELOG.md b/CHANGELOG.md index c809ff5..fb4a6c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,13 @@ # Changelog -## 0.1.0-rc.2 — Unreleased +## 0.1.0-rc.3 — Unreleased + +- Fixed automatic user installation on PEP 668 `EXTERNALLY-MANAGED` interpreters such as current Homebrew Python by detecting the marker and pairing pip's supported override with `--user`. +- Added fail-closed dry-run/JSON reporting when the marker or pip capability cannot be verified; inherited `PIP_*` and Python user-site redirection variables are removed. +- Preserved `MIGRATION_REQUIRED` as the earlier, zero-mutation RC1 distribution gate and disabled automatic uninstall after a failed externally-managed user install. +- Added ordinary-interpreter, virtual-environment, supported/unsupported PEP 668, rollback, and dual-Python regression coverage. + +## 0.1.0-rc.2 — Public development preview - Renamed the product to Claude Code Recover so the recovery target is explicit: Claude Code project continuity, not an ACGM installation. - Moved the canonical repository contract to `johnrucnapier-sketch/Claude-Code-Recover`. @@ -11,7 +18,7 @@ ## 0.1.0-rc.1 — Development preview -- Added the original cross-platform `python -m acgm_recover` entrypoint, now retained as an RC2 legacy alias. +- Added the original cross-platform `python -m acgm_recover` entrypoint, now retained as a legacy transition alias. - Added an offline `guide` command that reports observable capabilities, requires explicit route input, and never inspects account/provider/model identity. - Added an offline, user-scoped `scripts/bootstrap.py` installer with dry-run, source-manifest validation, post-install verification, and failure cleanup guidance. - Added Agent-assisted clone/install instructions while keeping evidence discovery and route confirmation as separate authorizations. diff --git a/PACKAGE_MANIFEST.json b/PACKAGE_MANIFEST.json index d375ea4..49c7bc7 100644 --- a/PACKAGE_MANIFEST.json +++ b/PACKAGE_MANIFEST.json @@ -26,15 +26,15 @@ "license": "CC-BY-4.0", "mode": "0644", "path": "AGENTS.md", - "sha256": "dcd656d40eedf1c9e61083b3f75485324dc3ff937f15ad8ec1e79a72f3a07b5a", - "size": 2141 + "sha256": "d0824e2f185fc249a771f20ff1a403ec78129900054d08e4bcc079f1646862f7", + "size": 2493 }, { "license": "CC-BY-4.0", "mode": "0644", "path": "CHANGELOG.md", - "sha256": "c46dc674527922db2786e275e4c1061c1080e1c3893bc03dcbb7530ebabc3b31", - "size": 3134 + "sha256": "6e56bc14310d699fcccbe0d94aea731496972809fd8a847e1e9e284970086dfd", + "size": 3842 }, { "license": "CC-BY-4.0", @@ -75,22 +75,22 @@ "license": "CC-BY-4.0", "mode": "0644", "path": "README.en.md", - "sha256": "3bf3f9f4b507d8da4d17a5eea5bf0f01a33f1383435a93539b73bb935547d5e9", - "size": 10379 + "sha256": "2d5469fb890246d0a62b01b8fa33a0081e23bcddb4c9b26e83017ad758a517fd", + "size": 10799 }, { "license": "CC-BY-4.0", "mode": "0644", "path": "README.md", - "sha256": "6c66e0e5f6d0ff8c2cd643243e82b22586ca2e61ac0296e758f5d313e672cfe4", - "size": 12285 + "sha256": "ef87f8c03bc83dfaa638924b058eb00b27b2ca0066bd264f2fd333771931c44f", + "size": 12650 }, { "license": "CC-BY-4.0", "mode": "0644", "path": "RELEASING.md", - "sha256": "3b45db8a06e7bd3d47467906cd10f370cd300809a42dc3b0f4c17d350f8fc38c", - "size": 1913 + "sha256": "0c20b29a2fba36959d870721ef3d483bb1467650a9d8cf0bf08bdd2a5e47f1b1", + "size": 2214 }, { "license": "MIT", @@ -103,14 +103,14 @@ "license": "CC-BY-4.0", "mode": "0644", "path": "SECURITY.md", - "sha256": "a393c1b4d785e593c31f57f45dceda77648bfdcd44626fddded0506e51e33c34", - "size": 7736 + "sha256": "3fe67fd4e38ebd52f03b03b305247eb70a84dd493d906f874cb63fcbd7fac3ab", + "size": 8486 }, { "license": "MIT", "mode": "0644", "path": "VERSION", - "sha256": "b0b5813e7156d4ad22becc185a387f84a901d471c07592ecd5d4cf48e601b60d", + "sha256": "93f893b9b51cd2ee055332a64d4656e5bbbb652788acbaeaa7d1a6b32099d23f", "size": 11 }, { @@ -131,15 +131,15 @@ "license": "CC-BY-4.0", "mode": "0644", "path": "docs/CLI_REFERENCE.md", - "sha256": "f6a7556f3b0a1bc2ed979d22c558257d9415034810884c8d959d51cd39cd56b4", - "size": 5832 + "sha256": "21ee8a74c4c7d453bf93cbf05f1da76a61be2606cbabace24e4cfa0f54437329", + "size": 5823 }, { "license": "CC-BY-4.0", "mode": "0644", "path": "docs/INSTALLATION.md", - "sha256": "f7b774a9b36f6f83c0470f9f10f1b15e3d40d8e9e87a83b0959138ab4d5e4e7a", - "size": 8434 + "sha256": "28fc1fa59b1245b02a90f51c1eb903fb0aced0f694da4d82332d0e2016715b5d", + "size": 10045 }, { "license": "CC-BY-4.0", @@ -152,15 +152,15 @@ "license": "MIT", "mode": "0644", "path": "pyproject.toml", - "sha256": "81aa10cae7fd521c3474a7c417415bd351ab0cbce1fd8a2494667ff6120fad2c", + "sha256": "b824c2f6a3c5db6e4453b95674735c74b41a75fced343ca4e54db331ad117ea9", "size": 1147 }, { "license": "MIT", "mode": "0755", "path": "scripts/bootstrap.py", - "sha256": "c78999ee0ea58721dcd2e504f05d29c0d36dfbae620d8513ebd7b506d0d3d838", - "size": 26804 + "sha256": "bf8a10616c146476f1320b0c1e18d6ceb508fc7fa6093ad732255c6946c7cc26", + "size": 35200 }, { "license": "MIT", @@ -180,8 +180,8 @@ "license": "MIT", "mode": "0755", "path": "scripts/release_check.py", - "sha256": "ffc8245ea1641bf9685403e48e2a742de0721762644b094e9908de7bf59b0ca5", - "size": 10835 + "sha256": "69169d89e79d6434ca9d037aaeebfd2c4e83715d3b0aa8aa5f33f2010b2a84c0", + "size": 11219 }, { "license": "MIT", @@ -306,7 +306,7 @@ "license": "MIT", "mode": "0644", "path": "src/claude_code_recover/constants.py", - "sha256": "6b5b311548ae2490a4dffad05c1120433005b79c15d77d51de70038c4f224936", + "sha256": "6c6fefe1d6852556837ba776ad8c81df3d99cf7bc1fb43de40c3bcbe879ea287", "size": 2454 }, { @@ -376,8 +376,8 @@ "license": "MIT", "mode": "0644", "path": "tests/test_bootstrap.py", - "sha256": "6b7abf9f21e8b46e2d3211633dd7390afbac7351d6c94f9fbe9b58299b6ef312", - "size": 13385 + "sha256": "02cfa4e9c2bdfd76e47093c977d2a504b07431f543478b2cb82736d4e3f9e4a7", + "size": 27232 }, { "license": "MIT", @@ -390,7 +390,7 @@ "license": "MIT", "mode": "0644", "path": "tests/test_onboarding.py", - "sha256": "62abe395a1a5c4caeea50bcfe4efec0638ce6765552e9bbfbc5cace8ef651ca0", + "sha256": "8f7c32dc7e41498ac2fed7e40807a365bb6a94c4334664bda2857f9e9cd2cdc0", "size": 7489 }, { @@ -431,5 +431,5 @@ ], "package": "claude-code-recover", "schema_version": "1.0", - "version": "0.1.0-rc.2" + "version": "0.1.0-rc.3" } diff --git a/README.en.md b/README.en.md index 1ab02e6..4406fe0 100644 --- a/README.en.md +++ b/README.en.md @@ -2,7 +2,7 @@ **When Claude Code, the original account, or the original platform is no longer available, rebuild a trustworthy, explainable, continuable project from surviving local code, Git, worktrees, session metadata, and transcript structure.** -Current version: `0.1.0-rc.2`. The code is now a public development preview. No formal GitHub Release has been published, and a real-friend Claude Code end-to-end acceptance run is still pending. +Current version: `0.1.0-rc.3`. The code is now a public development preview. No formal GitHub Release has been published, and a real-friend Claude Code end-to-end acceptance run is still pending. Claude Code Recover is an independent open-source tool. It is not affiliated with, endorsed by, or an official product of Anthropic. @@ -35,6 +35,8 @@ It cannot restore an inaccessible vendor-side Session or recover an account. It Only Python 3.10+ (including pip) and Git are required. The installer validates `PACKAGE_MANIFEST.json`, builds a controlled local wheel in a temporary directory using only the Python standard library, then performs an offline user installation. It does not require setuptools/wheel, scan evidence, inspect an account, download dependencies, or select a route. +Bootstrap recognizes PEP 668 `EXTERNALLY-MANAGED` interpreters, including current Homebrew Python. Only when the selected pip explicitly supports it does bootstrap combine `--break-system-packages` with `--user` for the offline user install. Ordinary interpreters and virtual environments never receive that override. If support cannot be verified, bootstrap fails closed before installation and explains the result in JSON. + When an Agent clones and installs in the same task, instruct it to read `AGENTS.md` and `SECURITY.md` explicitly after the clone. A newly downloaded instruction file does not automatically reload an already-running Agent, and Git clone does not and should @@ -63,7 +65,7 @@ bin/claude-code-recover verify --bundle "/path/to/new-bundle" --check-sources The repository wrapper can be replaced by the current interpreter's module entrypoint: commonly `python3 -m claude_code_recover` on macOS/Linux or `py -3 -m claude_code_recover` on Windows. Bootstrap uses the same interpreter that launched it and does not assume a fixed alias. -For one RC cycle, RC2 keeps the RC1 compatibility aliases `acgm-recover` and `python -m acgm_recover`. They are legacy; new documentation and automation must use the canonical names. When the old distribution is detected, bootstrap returns a non-executable `MIGRATION_REQUIRED` plan before any mutation; cross-distribution uninstall is never hidden inside `--upgrade`. The old repository URL `https://github.com/johnrucnapier-sketch/ACGM-Recover` is documented only to identify or migrate an old checkout, not as the RC2 installation source. +RC3 continues to provide the RC1 transition aliases `acgm-recover` and `python -m acgm_recover`. They are legacy; new documentation and automation must use the canonical names. When the old distribution is detected, bootstrap returns a non-executable `MIGRATION_REQUIRED` plan before any mutation; cross-distribution uninstall is never hidden inside `--upgrade`. The old repository URL `https://github.com/johnrucnapier-sketch/ACGM-Recover` is documented only to identify or migrate an old checkout, not as the RC3 installation source. Default source locations target macOS. On Linux or custom layouts, use `--no-default-sources` with explicit `--claude-projects-root`, `--metadata-root`, and `--auxiliary-root` values. diff --git a/README.md b/README.md index 215d38f..81973d5 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ **当 Claude Code、原账号或原平台已经不可用时,从幸存的本机代码、Git、worktree、Session metadata 和 transcript 结构中,重建一个可信、可解释、可继续开发的项目。** -当前版本:`0.1.0-rc.2`。代码现作为公开开发预览;尚未发布正式 GitHub Release,也尚未经过真实 Claude Code 朋友端到端验收。 +当前版本:`0.1.0-rc.3`。代码现作为公开开发预览;尚未发布正式 GitHub Release,也尚未经过真实 Claude Code 朋友端到端验收。 Claude Code Recover 是独立开源工具,与 Anthropic 不存在隶属关系,也不代表 Anthropic 的官方产品或背书。 @@ -35,6 +35,8 @@ Claude Code Recover 不是事前备份,也不是把聊天导成 HTML。 安装前只要求 Python 3.10+(含 pip)和 Git。安装器会先校验 `PACKAGE_MANIFEST.json`,再用 Python 标准库在临时目录构建受控本地 wheel,并以当前用户身份离线安装;不要求预装 setuptools/wheel,不会扫描证据、读取账号、联网下载依赖或自动选择路线。 +安装器会识别 PEP 668 `EXTERNALLY-MANAGED` Python(包括当前 Homebrew Python)。只有在当前 pip 明确支持时,它才会把 `--break-system-packages` 与 `--user` 同时用于离线用户级安装;普通 Python 和虚拟环境不会使用该 override。无法确认支持时会在任何安装修改前失败关闭,并在 JSON 中说明检测结果。 + 让 Agent 在同一任务中代为下载和安装时,必须明确要求它在 clone 完成后主动读取 `AGENTS.md` 与 `SECURITY.md`。新下载的仓库规则不会让已经运行中的 Agent 自动重载, Git clone 本身也不会、且不应自动执行安装代码。 @@ -73,7 +75,7 @@ bin/claude-code-recover verify \ 仓库 wrapper 之外也可以使用当前解释器的 module 入口:macOS/Linux 常见写法是 `python3 -m claude_code_recover`,Windows 常见写法是 `py -3 -m claude_code_recover`。安装器实际使用的是启动它的同一个 Python,不依赖某个固定别名。 -RC2 为已有 RC1 安装保留一个发布周期的兼容别名:旧命令 `acgm-recover`、旧 module `python -m acgm_recover` 仍可使用,但已标记为 legacy;新文档和自动化必须使用 canonical 名称。检测到旧 distribution 时,bootstrap 会在任何修改前返回非可执行的 `MIGRATION_REQUIRED` 计划,不会把跨 distribution 卸载藏进 `--upgrade`。旧仓库地址 `https://github.com/johnrucnapier-sketch/ACGM-Recover` 仅用于识别和迁移旧 checkout,不是 RC2 的安装地址。 +RC3 继续为已有 RC1 安装保留过渡兼容别名:旧命令 `acgm-recover`、旧 module `python -m acgm_recover` 仍可使用,但已标记为 legacy;新文档和自动化必须使用 canonical 名称。检测到旧 distribution 时,bootstrap 会在任何修改前返回非可执行的 `MIGRATION_REQUIRED` 计划,不会把跨 distribution 卸载藏进 `--upgrade`。旧仓库地址 `https://github.com/johnrucnapier-sketch/ACGM-Recover` 仅用于识别和迁移旧 checkout,不是 RC3 的安装地址。 当前默认本机数据路径针对 macOS;Linux 或自定义位置可显式传入: diff --git a/RELEASING.md b/RELEASING.md index a8eba07..25afd00 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -2,7 +2,7 @@ No stable release is authorized by this RC repository state. -The canonical RC2 product, distribution, archive prefix, tag documentation, and repository URL must use `Claude Code Recover`, `claude-code-recover`, and `https://github.com/johnrucnapier-sketch/Claude-Code-Recover`. The RC1 names are compatibility aliases only and must not become the primary release identity. +The canonical RC3 product, distribution, archive prefix, tag documentation, and repository URL must use `Claude Code Recover`, `claude-code-recover`, and `https://github.com/johnrucnapier-sketch/Claude-Code-Recover`. The RC1 names are compatibility aliases only and must not become the primary release identity. A release candidate may be published only after: @@ -14,8 +14,9 @@ A release candidate may be published only after: 6. `PACKAGE_MANIFEST.json` is current. 7. The result remains honest about `STRUCTURAL_ONLY` / `REVIEW_REQUIRED` / `HANDOFF_READY`; checksum success is not presented as handoff readiness. 8. `scripts/bootstrap.py --dry-run --json` and a clean user installation pass without network access or evidence scanning. -9. The canonical module/CLI entrypoints, one-RC legacy aliases, no-route `guide`, and all three explicit route arguments are tested; an installed RC1 distribution must produce `MIGRATION_REQUIRED` with zero pip mutation. -10. Windows onboarding tests confirm installation support while also confirming `recovery_runtime_supported: false` and the absence of generated `discover/build` commands. -11. The worktree is clean and the intended release commit, tag, and artifact are reviewed. +9. Ordinary Python, virtual-environment, and PEP 668 supported/unsupported paths are tested. The override must appear only beside `--user`; an unsupported path must fail before wheel build or pip mutation, and a failed override install must not trigger automatic uninstall. +10. The canonical module/CLI entrypoints, transition aliases, no-route `guide`, and all three explicit route arguments are tested; an installed RC1 distribution must produce `MIGRATION_REQUIRED` before the PEP 668 probe and with zero pip mutation. +11. Windows onboarding tests confirm installation support while also confirming `recovery_runtime_supported: false` and the absence of generated `discover/build` commands. +12. The worktree is clean and the intended release commit, tag, and artifact are reviewed. Do not describe an RC as stable. Do not publish from a dirty worktree. Do not reuse ACGM V3 tags or overwrite the original ACGM repository. Do not describe Windows bootstrap success as Windows core recovery support. diff --git a/SECURITY.md b/SECURITY.md index f821081..9207b46 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -28,7 +28,7 @@ Git reads clear repository-selection/config/trace environment variables; set `GI ## Installation and consent boundary -`scripts/bootstrap.py` is an offline, user-scoped installer. It captures a byte snapshot verified against `PACKAGE_MANIFEST.json`, builds the temporary wheel only from that captured allowlist, invokes pip with `--user --no-deps --no-build-isolation --no-index` (omitting `--user` inside an active virtual environment), and verifies the canonical installed module through `--version`, `doctor --no-default-sources`, and `guide --no-default-sources`. It uses argument arrays with `shell=False` and suppresses pip index/config behavior. Same-version canonical reruns force-reinstall the newly verified snapshot, canonical upgrades require `--upgrade`, and downgrades are refused. If installed-distribution metadata cannot be read exactly, bootstrap stops before mutation instead of treating the state as empty. If RC1 `acgm-recover` metadata exists, bootstrap returns `MIGRATION_REQUIRED` with a non-executable, unauthorized plan before any mutation; cross-distribution uninstall requires separate user authorization. A fresh RC2 installation provides the legacy module and CLI aliases for one RC cycle. The manifest detects mismatch against a trusted manifest; it is not a signature and cannot authenticate a maliciously replaced repository. +`scripts/bootstrap.py` is an offline, user-scoped installer. It captures a byte snapshot verified against `PACKAGE_MANIFEST.json`, builds the temporary wheel only from that captured allowlist, invokes pip with `--user --no-deps --no-build-isolation --no-index` (omitting `--user` inside an active virtual environment), and verifies the canonical installed module through `--version`, `doctor --no-default-sources`, and `guide --no-default-sources`. It uses argument arrays with `shell=False` and removes every inherited `PIP_*` variable plus `PYTHONUSERBASE` and `PYTHONNOUSERSITE` before setting exactly `PIP_DISABLE_PIP_VERSION_CHECK=1`, `PIP_NO_INDEX=1`, and `PIP_CONFIG_FILE` to the platform null device. Outside a virtual environment it checks the PEP 668 marker through the selected interpreter's `sysconfig`; only when a regular marker exists and the same pip advertises `--break-system-packages` does the install command pair that override with `--user`. A missing marker is an ordinary interpreter; an unreadable or non-regular marker fails closed without an executable install command. If the override path later fails, bootstrap never broadens cleanup into an automatic `pip uninstall`, because uninstall has no equivalent `--user` scope. Same-version canonical reruns force-reinstall the newly verified snapshot, canonical upgrades require `--upgrade`, and downgrades are refused. If installed-distribution metadata cannot be read exactly, bootstrap stops before mutation instead of treating the state as empty. If RC1 `acgm-recover` metadata exists, bootstrap returns `MIGRATION_REQUIRED` with a non-executable, unauthorized plan before the PEP 668 probe or any pip mutation; cross-distribution uninstall requires separate user authorization. A fresh RC3 installation provides the legacy module and CLI aliases as transition entrypoints. The manifest detects mismatch against a trusted manifest; it is not a signature and cannot authenticate a maliciously replaced repository. Installing Claude Code Recover does not authorize evidence discovery, transcript access, route selection, account inspection, or changes to a surviving project. An Agent may clone and install in one task only when the user's explicit authorization names the repository and covers both actions. The no-route guide stops at `selection_required`. diff --git a/VERSION b/VERSION index f2e984b..0d15162 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0-rc.2 +0.1.0-rc.3 diff --git a/docs/CLI_REFERENCE.md b/docs/CLI_REFERENCE.md index 3cb01e7..501ec84 100644 --- a/docs/CLI_REFERENCE.md +++ b/docs/CLI_REFERENCE.md @@ -6,7 +6,7 @@ All commands are available through the installed cross-platform module entrypoin PYTHON -m claude_code_recover COMMAND ``` -The repository-local `bin/claude-code-recover` wrapper is available on macOS/Linux. For RC2 only, `bin/acgm-recover`, the installed `acgm-recover` command, and `PYTHON -m acgm_recover` remain legacy compatibility aliases. Installation and upgrade instructions are in [INSTALLATION.md](INSTALLATION.md). +The repository-local `bin/claude-code-recover` wrapper is available on macOS/Linux. In RC3, `bin/acgm-recover`, the installed `acgm-recover` command, and `PYTHON -m acgm_recover` remain legacy transition aliases. Installation and upgrade instructions are in [INSTALLATION.md](INSTALLATION.md). ## `guide [--route ROUTE]` diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index 9b60142..ddfefc2 100644 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -47,14 +47,24 @@ py -3 -m claude_code_recover guide --no-default-sources `bootstrap.py` first checks Python, pip, and every source hash listed in `PACKAGE_MANIFEST.json`. Outside a virtual environment it performs this user installation without shell interpolation: ```text -PYTHON -m pip install --user --no-deps --no-build-isolation --no-index VERIFIED_LOCAL_WHEEL +PYTHON -m pip install --no-deps --no-build-isolation --no-index --user VERIFIED_LOCAL_WHEEL ``` -Inside an active virtual environment, bootstrap intentionally omits `--user` and installs into that environment. It never requests administrator privileges. +For a PEP 668 `EXTERNALLY-MANAGED` interpreter, bootstrap locates the marker through the selected interpreter's `sysconfig` and probes that same pip with `pip install --help`. If and only if pip advertises the option, the executable plan combines the user scope and override: + +```text +PYTHON -m pip install --no-deps --no-build-isolation --no-index --user --break-system-packages VERIFIED_LOCAL_WHEEL +``` + +This remains a current-user install; bootstrap never uses the override without `--user`. If the marker check fails, pip help fails, or the option is absent, both dry-run and installation fail closed with `install_command_executable: false` and no wheel build or pip mutation. Inside an active virtual environment, bootstrap checks neither the base interpreter marker nor the override, omits `--user`, and installs into that environment. It never requests administrator privileges. + +For every pip subprocess, bootstrap removes all inherited `PIP_*` variables, `PYTHONUSERBASE`, and `PYTHONNOUSERSITE`, then sets only `PIP_DISABLE_PIP_VERSION_CHECK=1`, `PIP_NO_INDEX=1`, and `PIP_CONFIG_FILE` to the platform null device. Environment values such as `PIP_TARGET`, `PIP_PREFIX`, `PIP_ROOT`, `PIP_USER`, or `PIP_BREAK_SYSTEM_PACKAGES` therefore cannot silently change the reviewed argv or redirect the user installation. + +对于 Homebrew 等带有 `EXTERNALLY-MANAGED` 标记的 Python,安装器只会在当前 pip 明确支持时,把 `--user` 与 `--break-system-packages` 成对加入安装命令。无法确认时会在修改前停止。若这种用户级 override 安装随后失败,bootstrap 不会自动调用没有 `--user` 范围的 `pip uninstall`;它会报告 `externally_managed_no_automatic_cleanup`,等待检查和另行授权。 同版本重复执行时会从刚通过 manifest 校验的源码强制重装,避免继续运行同版本但来源不明、残缺或已被修改的旧包;发现较旧版本时必须显式增加 `--upgrade`;发现已安装版本比源码新时拒绝降级。bootstrap 不会把 pip 的隐式行为当成升级策略。 -After installation it clears `PYTHONPATH`/`PYTHONHOME`, changes to a temporary directory outside the checkout, verifies that the imported module is not the repository copy, and then verifies `python -m claude_code_recover --version`, `doctor --no-default-sources`, `guide --no-default-sources`, canonical distribution metadata, and the one-RC legacy module alias. It does not depend on the console script being present in `PATH`. +After installation it clears Python path/user-site overrides, changes to a temporary directory outside the checkout, verifies that the imported module is not the repository copy, and then verifies `python -m claude_code_recover --version`, `doctor --no-default-sources`, `guide --no-default-sources`, canonical distribution metadata, and the legacy transition module alias. It does not depend on the console script being present in `PATH`. 安装完成后,它会验证版本、`doctor` 与 `guide`,不会因为用户级脚本目录没有加入 `PATH` 而误报安装失败。 @@ -109,9 +119,9 @@ The installer never downloads source updates itself. It cannot silently replace ### RC1 name migration / RC1 名称迁移 -If `acgm-recover` RC1 is installed, RC2 returns `MIGRATION_REQUIRED` before any pip mutation—even when `--upgrade` is supplied. The response contains a non-executable plan whose uninstall and rerun steps are all marked unauthorized. A user must separately review and authorize removal of the old distribution, then rerun bootstrap from the verified RC2 tree. Cross-distribution uninstall is deliberately not hidden inside `--upgrade` because the two distributions can own overlapping compatibility files. +If `acgm-recover` RC1 is installed, RC3 returns `MIGRATION_REQUIRED` before any PEP 668 capability probe or pip mutation—even when `--upgrade` is supplied. The response contains a non-executable plan whose uninstall and rerun steps are all marked unauthorized. A user must separately review and authorize removal of the old distribution, then rerun bootstrap from the verified RC3 tree. Cross-distribution uninstall is deliberately not hidden inside `--upgrade` because the two distributions can own overlapping compatibility files. -旧 checkout 地址 `https://github.com/johnrucnapier-sketch/ACGM-Recover` 只用于识别 RC1 来源。不要继续从旧地址安装 RC2。用户另行授权卸载 RC1 后,从 RC2 fresh install 安装的新 distribution 会继续提供旧 CLI `acgm-recover` 与旧 module `python -m acgm_recover`,但兼容入口只保留一个 RC 周期。 +旧 checkout 地址 `https://github.com/johnrucnapier-sketch/ACGM-Recover` 只用于识别 RC1 来源。不要继续从旧地址安装 RC3。用户另行授权卸载 RC1 后,从 RC3 fresh install 安装的新 distribution 会继续提供旧 CLI `acgm-recover` 与旧 module `python -m acgm_recover` 作为过渡入口。 ## Uninstall / 卸载 diff --git a/pyproject.toml b/pyproject.toml index 0d0dcd0..e2c96a7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "claude-code-recover" -version = "0.1.0rc2" +version = "0.1.0rc3" description = "Independent Claude Code recovery tool; not affiliated with or endorsed by Anthropic" readme = "README.en.md" requires-python = ">=3.10" diff --git a/scripts/bootstrap.py b/scripts/bootstrap.py index 6ad564c..be612ba 100755 --- a/scripts/bootstrap.py +++ b/scripts/bootstrap.py @@ -12,8 +12,10 @@ import os import re import shutil +import stat import subprocess import sys +import sysconfig import tempfile import zipfile from pathlib import Path, PurePosixPath @@ -115,7 +117,13 @@ def _safe_text(value: str) -> str: def _run(arguments: list[str], *, cwd: Path = ROOT) -> subprocess.CompletedProcess[str]: - environment = os.environ.copy() + # No inherited pip setting may change the target, scope, index, isolation, + # or override behavior represented by the reviewed argv. + environment = { + key: value + for key, value in os.environ.items() + if not key.upper().startswith("PIP_") + } environment.update( { "PIP_DISABLE_PIP_VERSION_CHECK": "1", @@ -123,12 +131,12 @@ def _run(arguments: list[str], *, cwd: Path = ROOT) -> subprocess.CompletedProce "PIP_CONFIG_FILE": os.devnull, } ) - environment.pop("PIP_INDEX_URL", None) - environment.pop("PIP_EXTRA_INDEX_URL", None) # Neither installation nor post-install verification may import the # checkout through caller-provided Python path configuration. environment.pop("PYTHONPATH", None) environment.pop("PYTHONHOME", None) + environment.pop("PYTHONUSERBASE", None) + environment.pop("PYTHONNOUSERSITE", None) return subprocess.run( arguments, cwd=cwd, @@ -320,9 +328,129 @@ def _prerequisites() -> dict[str, Any]: } -def _rollback(previous_version: str | None) -> dict[str, Any]: +def _externally_managed_marker_present() -> bool: + """Apply the PEP 668 marker lookup without reading marker contents.""" + + scheme = sysconfig.get_default_scheme() + standard_library = sysconfig.get_path("stdlib", scheme) + if not standard_library: + raise OSError("stdlib_path_unavailable") + marker = Path(standard_library) / "EXTERNALLY-MANAGED" + try: + marker_stat = marker.stat() + except FileNotFoundError: + return False + # Any other OSError propagates to the fail-closed policy. Treat any + # unexpected object type as invalid rather than enabling a risky override. + if not stat.S_ISREG(marker_stat.st_mode): + raise OSError("externally_managed_marker_not_regular") + return True + + +def _pip_install_break_system_packages_support() -> tuple[bool | None, str]: + """Probe the selected pip's install options without mutating any state.""" + + process = _run([sys.executable, "-m", "pip", "install", "--help"]) + if process.returncode != 0: + return None, "capability_check_failed" + output = f"{process.stdout}\n{process.stderr}" + if re.search(r"(?m)^\s*--break-system-packages(?:\s|$)", output): + return True, "supported" + return False, "unsupported" + + +def _installation_environment(in_virtual_environment: bool) -> dict[str, Any]: + """Build a fail-closed install policy for the selected interpreter.""" + + base: dict[str, Any] = { + "policy": "pep668_user_scope_v1", + "in_virtual_environment": in_virtual_environment, + "install_scope": ( + "virtual_environment" if in_virtual_environment else "current_user" + ), + "user_scope_flag_enabled": not in_virtual_environment, + "externally_managed_marker_checked": not in_virtual_environment, + "externally_managed": False, + "pip_break_system_packages_check": "not_required", + "pip_break_system_packages_support_checked": False, + "pip_break_system_packages_supported": None, + "pip_break_system_packages_enabled": False, + "safe_install_plan_available": True, + "network_used": False, + } + if in_virtual_environment: + base["status"] = "virtual_environment" + return base + + try: + externally_managed = _externally_managed_marker_present() + except (OSError, TypeError, ValueError): + base.update( + { + "status": "externally_managed_marker_check_failed", + "externally_managed": None, + "safe_install_plan_available": False, + } + ) + return base + base["externally_managed"] = externally_managed + if not externally_managed: + base["status"] = "ordinary_interpreter" + return base + + supported, check_status = _pip_install_break_system_packages_support() + base.update( + { + "pip_break_system_packages_check": check_status, + "pip_break_system_packages_support_checked": True, + "pip_break_system_packages_supported": supported, + "pip_break_system_packages_enabled": supported is True, + "safe_install_plan_available": supported is True, + "status": ( + "externally_managed_user_override_supported" + if supported is True + else "externally_managed_user_override_unavailable" + ), + } + ) + return base + + +def _rollback( + previous_version: str | None, + *, + installation_environment: dict[str, Any], +) -> dict[str, Any]: current_distributions = _installed_distribution_versions() uninstall = [sys.executable, "-m", "pip", "uninstall", "-y", PACKAGE] + externally_managed_user_install = bool( + installation_environment.get("user_scope_flag_enabled") is True + and installation_environment.get("pip_break_system_packages_enabled") is True + ) + # pip uninstall does not support --user, so it cannot prove that removal is + # limited to the user scheme. Never turn the install override into a + # broader automatic cleanup command. + if externally_managed_user_install: + current_version = None + if current_distributions is not None: + current_version = current_distributions.get(PACKAGE) or current_distributions.get( + LEGACY_PACKAGE + ) + return { + "status": "externally_managed_no_automatic_cleanup", + "previous_version": previous_version, + "current_version": current_version, + "installed_state_readable": current_distributions is not None, + "automatic_cleanup_attempted": False, + "pip_break_system_packages_enabled": False, + "manual_command_argv": None, + "guidance": ( + "The failed install used a PEP 668 override only with --user. pip uninstall " + "has no equivalent --user scope, so bootstrap did not remove anything " + "automatically. Inspect the selected interpreter's user installation and " + "obtain separate authorization before cleanup." + ), + } if current_distributions is None: return { "status": "installed_state_unavailable_no_automatic_cleanup", @@ -333,6 +461,8 @@ def _rollback(previous_version: str | None) -> dict[str, Any]: "No automatic uninstall was attempted; inspect the interpreter state first." ), "manual_command_argv": _display_command(uninstall), + "automatic_cleanup_attempted": False, + "pip_break_system_packages_enabled": False, } current_version = current_distributions.get(PACKAGE) or current_distributions.get( LEGACY_PACKAGE @@ -350,6 +480,8 @@ def _rollback(previous_version: str | None) -> dict[str, Any]: ), "installed_state_readable": after is not None, "manual_command_argv": _display_command(uninstall), + "automatic_cleanup_attempted": True, + "pip_break_system_packages_enabled": False, } return { "status": "previous_installation_not_removed", @@ -357,6 +489,8 @@ def _rollback(previous_version: str | None) -> dict[str, Any]: "current_version": current_version, "guidance": "Re-run bootstrap from the previously trusted source tree, or uninstall explicitly.", "manual_command_argv": _display_command(uninstall), + "automatic_cleanup_attempted": False, + "pip_break_system_packages_enabled": False, } @@ -430,19 +564,6 @@ def _verification(route: str | None) -> tuple[dict[str, Any], bool]: def install(*, dry_run: bool, route: str | None, upgrade: bool) -> tuple[dict[str, Any], int]: prerequisites = _prerequisites() in_virtual_environment = sys.prefix != getattr(sys, "base_prefix", sys.prefix) or hasattr(sys, "real_prefix") - command = [ - sys.executable, - "-m", - "pip", - "install", - "--no-deps", - "--no-build-isolation", - "--no-index", - ] - if not in_virtual_environment: - command.append("--user") - if upgrade: - command.append("--upgrade") installed_distributions_before = _installed_distribution_versions() if installed_distributions_before is None: return { @@ -451,6 +572,8 @@ def install(*, dry_run: bool, route: str | None, upgrade: bool) -> tuple[dict[st "status": "installed_distribution_state_unavailable", "dry_run": dry_run, "prerequisites": prerequisites, + "install_scope": "virtual_environment" if in_virtual_environment else "current_user", + "install_command_executable": False, "source_version": _source_version(), "mutation_performed": False, "network_used": False, @@ -466,19 +589,16 @@ def install(*, dry_run: bool, route: str | None, upgrade: bool) -> tuple[dict[st ) source_version = _source_version() version_action, version_allowed = _version_policy(previous_version, source_version, upgrade) - if version_action == "same_version_reinstall": - command.append("--force-reinstall") - command.append("VERIFIED_LOCAL_WHEEL") base: dict[str, Any] = { "tool": "Claude Code Recover bootstrap", "dry_run": dry_run, "prerequisites": prerequisites, - "install_command_argv": _display_command(command), "install_scope": "virtual_environment" if in_virtual_environment else "current_user", + "install_command_executable": False, "source_version": source_version, "installed_version_before": previous_version, "installed_distributions_before": installed_distributions_before, - "legacy_alias_policy": "provided_by_rc2_for_one_rc_cycle", + "legacy_alias_policy": "provided_by_rc3_for_transition_cycle", "version_action": version_action, "route_argument": route, "route_selected_automatically": False, @@ -527,13 +647,13 @@ def install(*, dry_run: bool, route: str | None, upgrade: bool) -> tuple[dict[st ], }, { - "action": "rerun_verified_rc2_bootstrap", + "action": "rerun_verified_rc3_bootstrap", "authorized": False, }, ], }, "guidance": ( - "RC2 will not mutate or uninstall the RC1 distribution automatically. " + "RC3 will not mutate or uninstall the RC1 distribution automatically. " "Obtain separate user authorization for the reviewed migration plan, " "then rerun bootstrap from this verified source tree." ), @@ -548,11 +668,83 @@ def install(*, dry_run: bool, route: str | None, upgrade: bool) -> tuple[dict[st }[version_action] base.update({"ok": False, "status": version_action, "guidance": guidance}) return base, 2 + + installation_environment = _installation_environment(in_virtual_environment) + base["installation_environment"] = installation_environment + if not installation_environment["safe_install_plan_available"]: + marker_check_failed = ( + installation_environment.get("status") + == "externally_managed_marker_check_failed" + ) + base.update( + { + "ok": False, + "status": ( + "externally_managed_marker_check_failed" + if marker_check_failed + else "externally_managed_user_install_unavailable" + ), + "mutation_performed": False, + "install_command_argv": None, + "guidance": ( + ( + "Bootstrap could not safely determine whether the selected Python " + "has a valid EXTERNALLY-MANAGED marker. Inspect that interpreter or " + "activate a virtual environment, then rerun bootstrap. No " + "installation was attempted." + ) + if marker_check_failed + else ( + "The selected Python is externally managed, but bootstrap could not " + "verify a supported pip --break-system-packages install option. " + "Use a newer pip for this interpreter or activate a virtual environment, " + "then rerun bootstrap. No installation was attempted." + ) + ), + } + ) + return base, 2 + + command = [ + sys.executable, + "-m", + "pip", + "install", + "--no-deps", + "--no-build-isolation", + "--no-index", + ] + if installation_environment["user_scope_flag_enabled"]: + command.append("--user") + if installation_environment["pip_break_system_packages_enabled"]: + if "--user" not in command: + base.update( + { + "ok": False, + "status": "installation_environment_policy_invalid", + "mutation_performed": False, + "install_command_argv": None, + "guidance": ( + "Bootstrap refused an inconsistent PEP 668 plan because the " + "override was not paired with current-user scope." + ), + } + ) + return base, 2 + command.append("--break-system-packages") + if upgrade: + command.append("--upgrade") + if version_action == "same_version_reinstall": + command.append("--force-reinstall") + command.append("VERIFIED_LOCAL_WHEEL") + base["install_command_argv"] = _display_command(command) + base["install_command_executable"] = True if dry_run: base.update( { "ok": True, "status": "dry_run_complete_no_changes", + "mutation_performed": False, "next_action": "Run the same command without --dry-run after reviewing this plan.", } ) @@ -572,6 +764,7 @@ def install(*, dry_run: bool, route: str | None, upgrade: bool) -> tuple[dict[st "ok": False, "status": "offline_wheel_build_failed", "error_code": type(error).__name__, + "mutation_performed": False, } ) return base, 3 @@ -582,7 +775,10 @@ def install(*, dry_run: bool, route: str | None, upgrade: bool) -> tuple[dict[st "status": "pip_install_failed", "pip_exit_code": process.returncode, "error_tail": _safe_text("\n".join(process.stderr.splitlines()[-12:])), - "rollback": _rollback(previous_version), + "rollback": _rollback( + previous_version, + installation_environment=installation_environment, + ), } ) return base, 3 @@ -610,7 +806,10 @@ def install(*, dry_run: bool, route: str | None, upgrade: bool) -> tuple[dict[st "ok": False, "status": "post_install_verification_failed", "verification": verification, - "rollback": _rollback(previous_version), + "rollback": _rollback( + previous_version, + installation_environment=installation_environment, + ), } ) return base, 4 diff --git a/scripts/release_check.py b/scripts/release_check.py index 0ce51ae..452f766 100755 --- a/scripts/release_check.py +++ b/scripts/release_check.py @@ -187,6 +187,13 @@ def check_onboarding_contract(errors: list[str], passed: list[str]) -> None: "stdlib_wheel_plus_pip", "upgrade_confirmation_required", "downgrade_refused", + "EXTERNALLY-MANAGED", + "--break-system-packages", + "externally_managed_user_install_unavailable", + "externally_managed_no_automatic_cleanup", + 'startswith("PIP_")', + "PYTHONUSERBASE", + "PYTHONNOUSERSITE", "shell=False", ) required_onboarding = ( @@ -204,6 +211,9 @@ def check_onboarding_contract(errors: list[str], passed: list[str]) -> None: or "recovery_runtime_not_supported_on_platform" not in cli or "Windows boundary" not in installation or "force-reinstall" not in security + or "PEP 668" not in installation + or "PEP 668" not in security + or "--break-system-packages" not in agents or "force-reinstalled" not in agents or 'name = "claude-code-recover"' not in pyproject or 'claude-code-recover = "claude_code_recover.cli:main"' not in pyproject diff --git a/src/claude_code_recover/constants.py b/src/claude_code_recover/constants.py index b3155c9..dafc34e 100644 --- a/src/claude_code_recover/constants.py +++ b/src/claude_code_recover/constants.py @@ -4,7 +4,7 @@ from pathlib import Path -TOOL_VERSION = "0.1.0-rc.2" +TOOL_VERSION = "0.1.0-rc.3" SCHEMA_VERSION = "1.0" ROUTES = ( diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index faf35dc..ecbbc8c 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -25,19 +25,19 @@ class BootstrapTests(unittest.TestCase): def test_version_policy_is_idempotent_explicit_and_no_downgrade(self) -> None: self.assertEqual( - bootstrap._version_policy("0.1.0rc2", "0.1.0-rc.2", False), + bootstrap._version_policy("0.1.0rc3", "0.1.0-rc.3", False), ("same_version_reinstall", True), ) self.assertEqual( - bootstrap._version_policy("0.1.0rc1", "0.1.0-rc.2", False), + bootstrap._version_policy("0.1.0rc1", "0.1.0-rc.3", False), ("upgrade_confirmation_required", False), ) self.assertEqual( - bootstrap._version_policy("0.1.0rc1", "0.1.0-rc.2", True), + bootstrap._version_policy("0.1.0rc1", "0.1.0-rc.3", True), ("explicit_upgrade", True), ) self.assertEqual( - bootstrap._version_policy("0.2.0", "0.1.0-rc.2", True), + bootstrap._version_policy("0.2.0", "0.1.0-rc.3", True), ("downgrade_refused", False), ) @@ -47,20 +47,20 @@ def test_source_manifest_is_verified_before_install(self) -> None: def test_stdlib_wheel_is_complete_and_does_not_need_build_packages(self) -> None: with tempfile.TemporaryDirectory() as tmp: - wheel = bootstrap._build_offline_wheel(Path(tmp), "0.1.0-rc.2") + wheel = bootstrap._build_offline_wheel(Path(tmp), "0.1.0-rc.3") with zipfile.ZipFile(wheel) as archive: names = set(archive.namelist()) metadata = archive.read( - "claude_code_recover-0.1.0rc2.dist-info/METADATA" + "claude_code_recover-0.1.0rc3.dist-info/METADATA" ).decode("utf-8") entry_points = archive.read( - "claude_code_recover-0.1.0rc2.dist-info/entry_points.txt" + "claude_code_recover-0.1.0rc3.dist-info/entry_points.txt" ).decode("utf-8") self.assertIn("claude_code_recover/__main__.py", names) self.assertIn("acgm_recover/__main__.py", names) - self.assertIn("claude_code_recover-0.1.0rc2.dist-info/RECORD", names) + self.assertIn("claude_code_recover-0.1.0rc3.dist-info/RECORD", names) self.assertIn("Name: claude-code-recover", metadata) - self.assertIn("Version: 0.1.0rc2", metadata) + self.assertIn("Version: 0.1.0rc3", metadata) self.assertIn("claude-code-recover = claude_code_recover.cli:main", entry_points) self.assertIn("acgm-recover = acgm_recover.cli:main", entry_points) self.assertNotIn("setuptools", "\n".join(names)) @@ -79,7 +79,7 @@ def test_wheel_uses_manifest_verified_snapshot_not_a_second_source_read(self) -> return_value=(snapshot, None), ), ): - wheel = bootstrap._build_offline_wheel(Path(tmp), "0.1.0-rc.2") + wheel = bootstrap._build_offline_wheel(Path(tmp), "0.1.0-rc.3") with zipfile.ZipFile(wheel) as archive: installed = archive.read("claude_code_recover/__init__.py") self.assertEqual(installed, b"SNAPSHOT_SENTINEL = True\n") @@ -87,12 +87,12 @@ def test_wheel_uses_manifest_verified_snapshot_not_a_second_source_read(self) -> def test_unlisted_source_file_is_rejected(self) -> None: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) - files = {"VERSION": b"0.1.0-rc.2\n", "trusted.py": b"pass\n"} + files = {"VERSION": b"0.1.0-rc.3\n", "trusted.py": b"pass\n"} for name, payload in files.items(): (root / name).write_bytes(payload) manifest = { "package": "claude-code-recover", - "version": "0.1.0-rc.2", + "version": "0.1.0-rc.3", "file_count": len(files), "files": [ { @@ -138,6 +138,13 @@ def test_dry_run_is_offline_and_does_not_authorize_discovery(self) -> None: self.assertNotIn("--user", payload["install_command_argv"]) else: self.assertIn("--user", payload["install_command_argv"]) + environment = payload["installation_environment"] + if environment["pip_break_system_packages_enabled"]: + self.assertTrue(environment["externally_managed"]) + self.assertIn("--user", payload["install_command_argv"]) + self.assertIn("--break-system-packages", payload["install_command_argv"]) + else: + self.assertNotIn("--break-system-packages", payload["install_command_argv"]) self.assertEqual(bootstrap._installed_version(), before) def test_dry_run_keeps_explicit_route_as_argument_only(self) -> None: @@ -152,6 +159,219 @@ def test_dry_run_keeps_explicit_route_as_argument_only(self) -> None: self.assertFalse(result["route_selected_automatically"]) self.assertNotIn("discover", " ".join(result["install_command_argv"])) + def test_pep668_marker_uses_the_selected_interpreter_stdlib(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + marker = Path(tmp) / "EXTERNALLY-MANAGED" + marker.write_text("[externally-managed]\n", encoding="utf-8") + with ( + mock.patch.object(bootstrap.sysconfig, "get_default_scheme", return_value="test"), + mock.patch.object(bootstrap.sysconfig, "get_path", return_value=tmp) as get_path, + ): + self.assertTrue(bootstrap._externally_managed_marker_present()) + get_path.assert_called_once_with("stdlib", "test") + + def test_pep668_marker_permission_error_fails_closed(self) -> None: + with ( + mock.patch.object(bootstrap.sysconfig, "get_default_scheme", return_value="test"), + mock.patch.object(bootstrap.sysconfig, "get_path", return_value="/unreadable"), + mock.patch.object(bootstrap.Path, "stat", side_effect=PermissionError("denied")), + ): + environment = bootstrap._installation_environment(False) + self.assertEqual(environment["status"], "externally_managed_marker_check_failed") + self.assertIsNone(environment["externally_managed"]) + self.assertFalse(environment["safe_install_plan_available"]) + + def test_pep668_non_regular_marker_fails_closed(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + (Path(tmp) / "EXTERNALLY-MANAGED").mkdir() + with ( + mock.patch.object(bootstrap.sysconfig, "get_default_scheme", return_value="test"), + mock.patch.object(bootstrap.sysconfig, "get_path", return_value=tmp), + ): + environment = bootstrap._installation_environment(False) + self.assertEqual(environment["status"], "externally_managed_marker_check_failed") + self.assertIsNone(environment["externally_managed"]) + self.assertFalse(environment["safe_install_plan_available"]) + + def test_pip_override_capability_probe_is_exact_and_fail_closed(self) -> None: + cases = ( + (0, " --break-system-packages Allow override\n", True, "supported"), + (0, "This pip has no matching option\n", False, "unsupported"), + (2, " --break-system-packages unavailable\n", None, "capability_check_failed"), + ) + for returncode, stdout, expected, status in cases: + with ( + self.subTest(returncode=returncode, stdout=stdout), + mock.patch.object( + bootstrap, + "_run", + return_value=subprocess.CompletedProcess( + args=[], returncode=returncode, stdout=stdout, stderr="" + ), + ) as run, + ): + supported, check_status = bootstrap._pip_install_break_system_packages_support() + self.assertIs(supported, expected) + self.assertEqual(check_status, status) + self.assertEqual( + run.call_args.args[0], + [sys.executable, "-m", "pip", "install", "--help"], + ) + + def test_installation_environment_matrix(self) -> None: + marker = mock.Mock(return_value=False) + capability = mock.Mock(return_value=(True, "supported")) + with ( + mock.patch.object(bootstrap, "_externally_managed_marker_present", marker), + mock.patch.object(bootstrap, "_pip_install_break_system_packages_support", capability), + ): + ordinary = bootstrap._installation_environment(False) + self.assertEqual(ordinary["status"], "ordinary_interpreter") + self.assertTrue(ordinary["user_scope_flag_enabled"]) + self.assertFalse(ordinary["pip_break_system_packages_enabled"]) + capability.assert_not_called() + + marker = mock.Mock(return_value=True) + capability = mock.Mock(return_value=(True, "supported")) + with ( + mock.patch.object(bootstrap, "_externally_managed_marker_present", marker), + mock.patch.object(bootstrap, "_pip_install_break_system_packages_support", capability), + ): + supported = bootstrap._installation_environment(False) + self.assertEqual(supported["status"], "externally_managed_user_override_supported") + self.assertTrue(supported["pip_break_system_packages_support_checked"]) + self.assertTrue(supported["pip_break_system_packages_enabled"]) + self.assertTrue(supported["safe_install_plan_available"]) + + marker = mock.Mock(return_value=True) + capability = mock.Mock(return_value=(False, "unsupported")) + with ( + mock.patch.object(bootstrap, "_externally_managed_marker_present", marker), + mock.patch.object(bootstrap, "_pip_install_break_system_packages_support", capability), + ): + unsupported = bootstrap._installation_environment(False) + self.assertFalse(unsupported["pip_break_system_packages_enabled"]) + self.assertFalse(unsupported["safe_install_plan_available"]) + + with mock.patch.object( + bootstrap, + "_externally_managed_marker_present", + side_effect=OSError("unreadable"), + ): + unreadable = bootstrap._installation_environment(False) + self.assertEqual(unreadable["status"], "externally_managed_marker_check_failed") + self.assertIsNone(unreadable["externally_managed"]) + self.assertFalse(unreadable["safe_install_plan_available"]) + + marker = mock.Mock() + capability = mock.Mock() + with ( + mock.patch.object(bootstrap, "_externally_managed_marker_present", marker), + mock.patch.object(bootstrap, "_pip_install_break_system_packages_support", capability), + ): + virtual = bootstrap._installation_environment(True) + self.assertEqual(virtual["status"], "virtual_environment") + self.assertFalse(virtual["user_scope_flag_enabled"]) + self.assertFalse(virtual["pip_break_system_packages_enabled"]) + marker.assert_not_called() + capability.assert_not_called() + + def test_install_plan_pairs_pep668_override_with_user_scope_only(self) -> None: + prerequisites = {"ok": True} + with ( + mock.patch.object(bootstrap, "_prerequisites", return_value=prerequisites), + mock.patch.object(bootstrap, "_installed_distribution_versions", return_value={}), + mock.patch.object( + bootstrap, + "_installation_environment", + return_value={ + "safe_install_plan_available": True, + "user_scope_flag_enabled": True, + "pip_break_system_packages_enabled": True, + "externally_managed": True, + }, + ), + ): + result, code = bootstrap.install(dry_run=True, route=None, upgrade=False) + self.assertEqual(code, 0) + command = result["install_command_argv"] + self.assertIn("--user", command) + self.assertIn("--break-system-packages", command) + self.assertLess(command.index("--user"), command.index("--break-system-packages")) + + def test_ordinary_install_plan_never_uses_pep668_override(self) -> None: + prerequisites = {"ok": True} + with ( + mock.patch.object(bootstrap, "_prerequisites", return_value=prerequisites), + mock.patch.object(bootstrap, "_installed_distribution_versions", return_value={}), + mock.patch.object( + bootstrap, + "_installation_environment", + return_value={ + "safe_install_plan_available": True, + "user_scope_flag_enabled": True, + "pip_break_system_packages_enabled": False, + "externally_managed": False, + }, + ), + ): + result, code = bootstrap.install(dry_run=True, route=None, upgrade=False) + self.assertEqual(code, 0) + self.assertIn("--user", result["install_command_argv"]) + self.assertNotIn("--break-system-packages", result["install_command_argv"]) + + def test_unsupported_pep668_install_fails_closed_before_dry_run_or_install(self) -> None: + prerequisites = {"ok": True} + unavailable = { + "safe_install_plan_available": False, + "user_scope_flag_enabled": True, + "pip_break_system_packages_enabled": False, + "externally_managed": True, + "pip_break_system_packages_support_checked": True, + "pip_break_system_packages_supported": False, + } + for dry_run in (True, False): + with ( + self.subTest(dry_run=dry_run), + mock.patch.object(bootstrap, "_prerequisites", return_value=prerequisites), + mock.patch.object(bootstrap, "_installed_distribution_versions", return_value={}), + mock.patch.object(bootstrap, "_installation_environment", return_value=unavailable), + mock.patch.object(bootstrap, "_build_offline_wheel") as build, + mock.patch.object(bootstrap, "_run") as run, + ): + result, code = bootstrap.install(dry_run=dry_run, route=None, upgrade=False) + self.assertEqual(code, 2) + self.assertEqual(result["status"], "externally_managed_user_install_unavailable") + self.assertFalse(result["install_command_executable"]) + self.assertIsNone(result["install_command_argv"]) + self.assertFalse(result["mutation_performed"]) + build.assert_not_called() + run.assert_not_called() + + def test_marker_check_failure_has_truthful_top_level_status(self) -> None: + prerequisites = {"ok": True} + unavailable = { + "status": "externally_managed_marker_check_failed", + "safe_install_plan_available": False, + "user_scope_flag_enabled": True, + "pip_break_system_packages_enabled": False, + "externally_managed": None, + } + with ( + mock.patch.object(bootstrap, "_prerequisites", return_value=prerequisites), + mock.patch.object(bootstrap, "_installed_distribution_versions", return_value={}), + mock.patch.object(bootstrap, "_installation_environment", return_value=unavailable), + mock.patch.object(bootstrap, "_build_offline_wheel") as build, + mock.patch.object(bootstrap, "_run") as run, + ): + result, code = bootstrap.install(dry_run=False, route=None, upgrade=False) + self.assertEqual(code, 2) + self.assertEqual(result["status"], "externally_managed_marker_check_failed") + self.assertIn("could not safely determine", result["guidance"]) + self.assertFalse(result["mutation_performed"]) + build.assert_not_called() + run.assert_not_called() + def test_virtual_environment_install_plan_omits_user_scope_flag(self) -> None: prerequisites = {"ok": True} with ( @@ -164,6 +384,7 @@ def test_virtual_environment_install_plan_omits_user_scope_flag(self) -> None: self.assertEqual(code, 0) self.assertEqual(result["install_scope"], "virtual_environment") self.assertNotIn("--user", result["install_command_argv"]) + self.assertNotIn("--break-system-packages", result["install_command_argv"]) def test_same_version_plan_forces_verified_source_reinstall(self) -> None: prerequisites = {"ok": True} @@ -172,7 +393,7 @@ def test_same_version_plan_forces_verified_source_reinstall(self) -> None: mock.patch.object( bootstrap, "_installed_distribution_versions", - return_value={"claude-code-recover": "0.1.0rc2"}, + return_value={"claude-code-recover": "0.1.0rc3"}, ), ): result, code = bootstrap.install(dry_run=True, route=None, upgrade=False) @@ -191,6 +412,8 @@ def test_rc1_distribution_requires_separate_migration_without_mutation(self) -> "_installed_distribution_versions", return_value={"acgm-recover": "0.1.0rc1"}, ), + mock.patch.object(bootstrap, "_installation_environment") as install_environment, + mock.patch.object(bootstrap, "_build_offline_wheel") as build, mock.patch.object(bootstrap, "_run") as run, ): result, code = bootstrap.install( @@ -206,18 +429,72 @@ def test_rc1_distribution_requires_separate_migration_without_mutation(self) -> self.assertFalse(plan["executable"]) self.assertTrue(plan["requires_separate_user_authorization"]) self.assertTrue(all(step["authorized"] is False for step in plan["steps"])) + install_environment.assert_not_called() + build.assert_not_called() run.assert_not_called() def test_run_removes_python_path_configuration(self) -> None: fake = mock.Mock(returncode=0, stdout="", stderr="") with ( - mock.patch.dict(os.environ, {"PYTHONPATH": "untrusted", "PYTHONHOME": "untrusted"}), + mock.patch.dict( + os.environ, + { + "PYTHONPATH": "untrusted", + "PYTHONHOME": "untrusted", + "PIP_BREAK_SYSTEM_PACKAGES": "1", + "PIP_USER": "1", + "PIP_TARGET": "/untrusted-target", + "PIP_PREFIX": "/untrusted-prefix", + "PIP_ROOT": "/untrusted-root", + "PIP_REQUIRE_VIRTUALENV": "1", + "PIP_REQUIREMENT": "/untrusted-requirements.txt", + "PIP_FIND_LINKS": "https://untrusted.invalid/packages", + "PYTHONUSERBASE": "/untrusted-user-base", + "PYTHONNOUSERSITE": "1", + }, + ), mock.patch.object(bootstrap.subprocess, "run", return_value=fake) as run, ): bootstrap._run([sys.executable, "--version"]) environment = run.call_args.kwargs["env"] self.assertNotIn("PYTHONPATH", environment) self.assertNotIn("PYTHONHOME", environment) + self.assertNotIn("PYTHONUSERBASE", environment) + self.assertNotIn("PYTHONNOUSERSITE", environment) + pip_environment = { + key: value for key, value in environment.items() if key.upper().startswith("PIP_") + } + self.assertEqual( + pip_environment, + { + "PIP_DISABLE_PIP_VERSION_CHECK": "1", + "PIP_NO_INDEX": "1", + "PIP_CONFIG_FILE": os.devnull, + }, + ) + + def test_pep668_failure_never_runs_automatic_uninstall(self) -> None: + installation_environment = { + "user_scope_flag_enabled": True, + "pip_break_system_packages_enabled": True, + } + with ( + mock.patch.object( + bootstrap, + "_installed_distribution_versions", + return_value={"claude-code-recover": "0.1.0rc3"}, + ), + mock.patch.object(bootstrap, "_run") as run, + ): + result = bootstrap._rollback( + None, + installation_environment=installation_environment, + ) + self.assertEqual(result["status"], "externally_managed_no_automatic_cleanup") + self.assertFalse(result["automatic_cleanup_attempted"]) + self.assertFalse(result["pip_break_system_packages_enabled"]) + self.assertIsNone(result["manual_command_argv"]) + run.assert_not_called() def test_installed_version_query_uses_clean_external_process(self) -> None: completed = subprocess.CompletedProcess( @@ -282,12 +559,12 @@ def test_canonical_distribution_version_takes_precedence_over_legacy(self) -> No returncode=0, stdout=( '{"acgm-recover": "0.1.0rc1", ' - '"claude-code-recover": "0.1.0rc2"}\n' + '"claude-code-recover": "0.1.0rc3"}\n' ), stderr="", ) with mock.patch.object(bootstrap, "_run", return_value=completed): - self.assertEqual(bootstrap._installed_version(), "0.1.0rc2") + self.assertEqual(bootstrap._installed_version(), "0.1.0rc3") if __name__ == "__main__": diff --git a/tests/test_onboarding.py b/tests/test_onboarding.py index e92e39a..7bd097e 100644 --- a/tests/test_onboarding.py +++ b/tests/test_onboarding.py @@ -108,7 +108,7 @@ def test_module_entrypoint_and_custom_source_options(self) -> None: {"configured_locations": 0, "visible_directories": 0}, ) - def test_legacy_module_alias_remains_available_for_rc2(self) -> None: + def test_legacy_module_alias_remains_available_for_rc3(self) -> None: environment = os.environ.copy() environment["PYTHONPATH"] = str(ROOT / "src") process = subprocess.run( @@ -123,7 +123,7 @@ def test_legacy_module_alias_remains_available_for_rc2(self) -> None: check=False, ) self.assertEqual(process.returncode, 0, process.stderr) - self.assertEqual(process.stdout.strip(), "Claude Code Recover 0.1.0-rc.2") + self.assertEqual(process.stdout.strip(), "Claude Code Recover 0.1.0-rc.3") def test_canonical_repository_wrapper_invokes_cli(self) -> None: process = subprocess.run( @@ -137,7 +137,7 @@ def test_canonical_repository_wrapper_invokes_cli(self) -> None: check=False, ) self.assertEqual(process.returncode, 0, process.stderr) - self.assertEqual(process.stdout.strip(), "Claude Code Recover 0.1.0-rc.2") + self.assertEqual(process.stdout.strip(), "Claude Code Recover 0.1.0-rc.3") def test_windows_core_commands_fail_before_source_access(self) -> None: commands = (