diff --git a/MANIFEST.sha256 b/MANIFEST.sha256 index 194f981..88685b7 100644 --- a/MANIFEST.sha256 +++ b/MANIFEST.sha256 @@ -53,16 +53,17 @@ b97c626ce8e220598d12f7bd0cce5099d331aae71a45f692a9eb57d669d0c038 ./check-update 78823831a732ebf870c45b9acc92abddfdddbdbb47db6db81a38ada0497de00f ./docs/OPERATING-MANUAL.md 97dd789229fa55905bd20290c2767ef7bd274cc81965a6f4eb1136dda9ed0adb ./docs/OPTIONAL-REVIEW-TOOLS.md 9bee78762d4d1f446cfb816878e36671cdd823ab62521d32833cc915a572796e ./docs/REMOVE-CBM.md -d69f7436f838d6daa56b7a311cfdbe7f77fbbf1b9c6c2835a813709e75187416 ./docs/REPOWISE.md +6baabd9b2a4c0522d736e6852ddb0668d1fde443c809780fb65c17d5471fed35 ./docs/REPOWISE.md e27b3d72ff47ebf7230a96b3a5f2d6ea3f410e22ef382f8e7d9cd27c7bb59892 ./docs/REVIEW.md e86355a4eae8ec5cd42e7674461ebaf1acc4f14f20fa9600da2bb5248efb8e43 ./docs/SPEC-KIT.md f7298a2735489830b11a83c5a852984916147e8c3408214cc722d58a4f7a139f ./docs/WHY-THIS-SHAPE.md -e03065e7b874d15ba91cb73d2f76978421d1c459a12f19857bb3de02b71f8ed7 ./docs/roadmap.md +7870d79e9ea37e6e5dda6419d1e15537e5f44e439678bf7e07bbe1722d053b36 ./docs/plans/CPK-068-windows-install.md +305bd7b53c9bc86eea7cc16b4b3a462a3d1a87df316c4140a401a102da775610 ./docs/roadmap.md 6f8f14ca5fd8ad935eee66256b3f7c49d5c40957eedff938ba21365e30869719 ./doctor.ps1 ab56a88d9188979f06ea142d1f58ea0d6ad9088e83a965bd324209d83da513ad ./doctor.sh 8087bdb3ee654c0baac278a8e3bf85e33e314f8acfd486e7ebf675126a697058 ./install.ps1 3466d2e34145cfdaa83d793960794b2aefc96f3d9c48d22fd2af930ec819e9c4 ./install.sh -54daa83ee5e415bfb5fac1e30d06309c667f104ed3b59b3aed1a10f0aad51dd2 ./kit.py +2149d19f7de831542b2a9e3a3bd6f5a0a33a09606fa8935a2998f91bc5fd2491 ./kit.py bbc6440e9686b886bac407dffea3d3a21dc175d784f2645b0b97305214f96023 ./run-tests.ps1 88528842a28f08ec8d96eb014eb93a248d2dede495c6d553151840ec1f9688e2 ./run-tests.sh 85a2ba72e2b0cd1ab6c25a51f39059502415c3ac62a6bffa613d7e31b6422cd4 ./setup-repo.ps1 @@ -73,9 +74,9 @@ e1e231aac51822f7e1bb3aa61aba138cf8c5df9cefd3079b0c1b5a98eea03bd9 ./tests/fixtur e65a14cd4d2744b0a9ee0dc9d2d74afa7e1cac835d832419b34be1baacc94379 ./tests/fixtures/cpk056-agent-cases.json e32941eb74be1f2cdcb6c82cf71e113b841651bf0d01786f364176de2da1b9c5 ./tests/fixtures/cpk056-agent-grading.json 289367b7090a0d71936b41f08c4a354bc2758013dceb709819de36a1b73d9aa6 ./tests/live_plan_history.py -5947183183063da3141431ae2b9247f4033ce2a839d16a559fa8c1abb585d50d ./tests/test_kit.py -dbeb390655fbe5321578256db17b6a7c9edc1a4a7e7499ff5fe524dccbfc1719 ./tests/test_ponytail.py -8fb5aefac7fa8a9cef45296f2dacbcc0663bed19a0daee5d94f71cebce15b012 ./tests/test_updates.py +9bb6bc13f0454f83049d99cc228dbbd2b9f290dea0cb0dece249f74a9e60bf2d ./tests/test_kit.py +f8d4f2bc09562660e441af9c2d20c6a73e7ad92bb105b12f0c6174af95c8c781 ./tests/test_ponytail.py +00127b8f5bd1074949a00181359b5f536995e47de0dc752cf5f5546f3e228172 ./tests/test_updates.py 54673ace20ec6333c73ead405c82d52560848bc8b9aa3a0102fc39935cc3ab87 ./tests/update_subprocess_fixture.py d16f7eadc8c668e82a1d0d2554ed04def15cb0a4b2ce13ac1ce1339bc0e62161 ./uninstall.ps1 f12cff94ef7f9b0ca489cbe43e1de737011476966e04c1578f4977177b0da217 ./uninstall.sh diff --git a/docs/REPOWISE.md b/docs/REPOWISE.md index 3568b02..d4a9b56 100644 --- a/docs/REPOWISE.md +++ b/docs/REPOWISE.md @@ -60,6 +60,8 @@ Update application reuses an external RepoWise executable when its version match On Windows, reinstall also repairs a failed `repowise.exe` when the previous kit manifest records the fixed user-bin path. +Before Windows repair or upgrade, the installer checks for processes that use the selected RepoWise environment or launcher. It stops before replacement and lists matching process IDs. Close Codex sessions and stop RepoWise watchers before you rerun the command from a separate PowerShell window. The installer does not terminate processes. It reuses a healthy matching version without replacement. + The installer adds a user-level RepoWise MCP server. If Codex starts in a completely empty folder, the server command initializes Git first. It does not initialize Git in a non-empty folder. The first start in a Git repository initializes a no-prose index when `.repowise` is absent. Each start then installs RepoWise's marker-delimited `post-commit` hook and catches up the index. diff --git a/docs/plans/CPK-068-windows-install.md b/docs/plans/CPK-068-windows-install.md new file mode 100644 index 0000000..f60b63b --- /dev/null +++ b/docs/plans/CPK-068-windows-install.md @@ -0,0 +1,74 @@ +# Repair Windows installation safely + + +Windows installation must avoid replacing RepoWise files while its processes hold them open. The installer must identify those processes before it calls uv. After those processes stop, installation must repair the owned runtime and succeed. A healthy runtime must remain reusable. Reinstallation must preserve native plugin configuration, including tables that Codex inserts before a toolkit closing marker. + +## Context and isolation + + +The task branch is `fix/windows-install-repowise`. It branches from `main` at `af509d29c353ed143fbd297e481e4bec1b61891e`. This task uses the existing checkout as one writable stream. Existing untracked `.mcp.json`, `.repowise/`, and `.vscode/` belong to the user. + +The user reported a Node error during Ponytail installation, followed by a failed RepoWise repair. The second attempt failed when uv removed the Windows `Scripts` directory. Initial diagnosis found active RepoWise watcher and MCP processes in that environment, and RepoWise failed with `ModuleNotFoundError`. Node worked. A later installation exposed native Ponytail tables lost during marker replacement. The system Python also reports a library-prefix warning. Its configuration is outside this repository change. + +The user authorized direct source access after RepoWise lookup failed. No Plan history or earlier task specification exists in this checkout. + +Publish this bug fix as source version `0.26.1`. Keep the managed component versions unchanged. + +## Product boundary and owners + + +The accepted source is the user's Windows installation report and instruction to continue. Existing product behavior is documented in `README.md` (Install and Ponytail) and `docs/REPOWISE.md` (Runtime ownership). Roadmap CPK-009 declines complete transactional recovery. + +`install.ps1` dispatches to `kit.py`. The shared runtime owner is `ensure_repowise`, reached through `ensure_repowise_runtime` by installation, setup, and managed updates. It reads the kit manifest, checks the command version, and installs through uv with explicit tool and executable directories. These owners compose the change. The lock file, plugin installation, MCP bootstrap, and skill copying are opaque: preserve their current behavior. System Python installation is outside the change. + +The existing marker writers also compose this task. Native Codex 0.163.0 inserts marketplace and plugin tables before the final comment in `config.toml`. That comment can be the RepoWise or hooks closing marker. Native plugin state remains opaque, but toolkit replacement and removal must preserve those unrelated tables. This direct reproduction expands the initial configuration boundary; it does not add a new plugin lifecycle. + +Use [Scope boundaries](../../assets/skills/design-preflight/references/scope-boundaries.md) and [Supported model](../../assets/skills/design-preflight/references/supported-model.md). Active RepoWise processes and a second Windows installation attempt are explicitly included by this incident. Do not add automatic process termination, rollback, retries, component version changes, external packages, or a replacement runtime layout. + +## Implementation and proof boundary + + +One atomic subtask, S1, belongs to `kit.py::ensure_repowise`. Before a Windows uv tool installation, use PowerShell 7 and `Get-CimInstance Win32_Process` to identify executable paths inside the selected RepoWise environment or at its exact user-bin launcher. Pass paths through the existing installation environment, not shell interpolation. Use a directory separator when matching the environment prefix. Compare paths without case sensitivity. + +If any matching processes exist, raise `KitError` with their process IDs and instructions to close Codex sessions and stop RepoWise before retrying. Do not call uv or terminate processes. Without matches, preserve the existing install and shell-update sequence. A healthy matching version returns before the scan. The POSIX path remains unchanged. A failed process query must stop through the existing `run` error behavior. + +Add direct regression checks in `tests/test_kit.py`. Execute the actual PowerShell script with a process-query fixture to prove path selection. Set `$ErrorActionPreference = 'Stop'` in that script. Update the Runtime ownership section of `docs/REPOWISE.md` and matching entries in `MANIFEST.sha256`. Complete validation and one full native review before any local task commit. + +Extend that atomic subtask to the shared marker helpers, `marker_block` and `remove_marker_block`. For the two known TOML marker types, remove only the toolkit-owned table sections: `hooks` or `mcp_servers.repowise`. Preserve other table sections, even when Codex places them inside the textual markers. Append the replacement managed block after that preserved configuration. Generic Markdown marker behavior remains unchanged. Avoid a general TOML writer or new dependency; the section syntax comes from the toolkit and native Codex serializers. + +Extend the native-command fixture in `tests/test_ponytail.py` to reproduce Codex's insertion location. Prove install, reinstall, and uninstall preserve marketplace/plugin tables and enabled state. Exercise the same owner through `apply-updates` in `tests/test_updates.py`. Add direct marker checks for unrelated tables interleaved with owned tables, including similarly named tables. Preserve native settings across repository setup and removal by using their shared marker owner. + +## Boundary inventory and Scenario Proof + + +Apply [Scenario discrimination](../../assets/skills/design-preflight/references/scenario-discrimination.md). Sources have these dispositions: the Windows failure and safe repair requirement map to B1 and B2; existing healthy reuse and ownership map to B3; existing POSIX behavior maps to B4. Existing plugin and lock behavior are opaque. The prefix warning is diagnostic context, not a new installer boundary. Complete transaction recovery is deferred by CPK-009. + +| Boundary | Discriminator and contrast | Production path and required oracle | Runnable check | +| --- | --- | --- | --- | +| B1: Detect active Windows RepoWise before mutation | Selected environment or exact launcher versus unrelated and sibling-prefix executable paths; zero, one, and multiple matches; equivalent paths with different case | `ensure_repowise` -> actual PowerShell script -> `KitError`; report all matching IDs, preserve runtime bytes, make no uv calls, and do not terminate processes | New `RepoWiseRuntimeTests.test_windows_repowise_repair_checks_process_paths` | +| B2: Repair after blockers stop | Same owned broken runtime with active records, then no matching records; query error versus successful empty query | `ensure_repowise` -> process query -> uv install -> shell update -> returned launcher; no install while blocked or when the query fails | New process-path check and `test_windows_repowise_process_query_errors_stop_install`, plus existing `test_install_repairs_manifest_owned_broken_windows_repowise` | +| B3: Preserve reuse and ownership | Healthy matching version versus broken owned and unowned commands | Existing version/manifest gates return healthy command or reject an unowned replacement before mutation | Existing matching-version and ownership tests in `RepoWiseRuntimeTests` | +| B4: Preserve POSIX behavior | POSIX selection versus Windows selection with the same broken owned runtime | Existing POSIX force install and shell update; no Windows process query | Existing `test_manifest_owned_broken_posix_repowise_is_repaired` | +| B5: Preserve native configuration while replacing toolkit TOML | Unrelated native tables inside versus outside the two managed spans; similar-prefix table versus owned table; repeated replacement and removal | Install/reinstall/update/uninstall/setup/remove-repo -> shared marker owner -> parsed configuration; retain every unrelated table and exact native settings. Core uninstall removes owned integration. Repository removal retains its required disabled RepoWise override | New marker preservation check, native fixture install/reinstall/uninstall tests, composed update check, and `IntegrationTests.test_setup_preserves_roadmap_and_unrelated_blocks` with native tables reseeded inside the marker before each entry point | + +The Windows process query also retains the existing one-attempt error contract: a query error prevents uv execution. No retained state, background worker, or additional collection result exists. The process collection must include every matching ID; an unrelated prefix must not hide a later matching process. + +Run the focused checks from the repository root: + + python -B -m unittest discover -s tests -p test_kit.py -k RepoWiseRuntimeTests -v + python -B -m unittest discover -s tests -p test_ponytail.py -v + +Run `pwsh -File .\run-tests.ps1` for the complete suite and syntax checks. Every applicable test must pass. Verify every listed `MANIFEST.sha256` digest. Store actual results in `.agent/test-results/CPK-068-windows-install.md`. + +For local recovery, inspect the exact active RepoWise paths again, stop only the diagnosed broken RepoWise processes, and run the installer once. Verify RepoWise's version, ordinary reinstall, and Doctor. Use the already installed healthy uv-managed Python for diagnosis if the system Python warning interferes. Do not alter the system Python installation. + +## Decision Log + + +Use a read-only process guard instead of automatic termination or a new versioned environment layout. Windows can hold executable files open; uv tool installation recreates environments. The existing uv owner remains responsible for installation. See [uv tool management](https://docs.astral.sh/uv/concepts/tools/). + +Keep the native Ponytail lifecycle unchanged. Preserve its configuration through toolkit updates because a live reinstall exposed the marker-span deletion. Keep the system Python warning separate because the RepoWise environment already selects a uv-managed interpreter. OpenAI's [plugin documentation](https://developers.openai.com/plugins/build/plugins) identifies native marketplace commands and user-level plugin settings as their owners. + +The independent preflight requires explicit case variation and terminating CIM errors. Accept both. Use a fixture at the native process-query seam to exercise the actual PowerShell selector without copying Python executables and DLLs. A separate local run against the diagnosed live processes proves native discovery, followed by one repair after those processes stop. + +The configuration preflight requires explicit repository setup and removal proof because these paths share the changed marker owner. Reseed the native table inside the marker before each command. Preserve the repository removal disable override. This adds proof of existing behavior, not a new lifecycle. diff --git a/docs/roadmap.md b/docs/roadmap.md index 220562b..1e1e52d 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -33,6 +33,7 @@ No residual risk is accepted. ## Completed +- ✅ **CPK-068 — Repair Windows installation safely.** Detect active RepoWise processes before replacement, preserve native plugin configuration during reinstall, and restore the local installation. - ✅ **CPK-067 — Keep current work lean and let Git own history.** Consolidate useful decisions, remove obsolete planning files and spent output, prevent capture replay, and validate without bytecode clutter. - ✅ **CPK-066 — Correct runtime identity and install output.** - ✅ **CPK-065 — Restore the requested finished-thread archival.** diff --git a/kit.py b/kit.py index fb0cb84..999e2ba 100755 --- a/kit.py +++ b/kit.py @@ -25,7 +25,7 @@ from typing import Any, Iterable KIT_ID = "codex-practical-kit" -KIT_VERSION = "0.26.0" +KIT_VERSION = "0.26.1" ROOT = Path(__file__).resolve().parent AGENTS_START = "" AGENTS_END = "" @@ -189,6 +189,8 @@ def json_load(path: Path, default: Any) -> Any: def marker_block(text: str, start: str, end: str, body: str) -> str: + if start in (HOOKS_START, REPOWISE_START): + text = remove_marker_block(text, start, end) block = f"{start}\n{body.rstrip()}\n{end}" pattern = re.compile(re.escape(start) + r".*?" + re.escape(end), re.DOTALL) if pattern.search(text): @@ -199,7 +201,20 @@ def marker_block(text: str, start: str, end: str, body: str) -> str: def remove_marker_block(text: str, start: str, end: str) -> str: pattern = re.compile(r"\n?" + re.escape(start) + r".*?" + re.escape(end) + r"\n?", re.DOTALL) - result = pattern.sub("\n", text, count=1) + def replacement(match: re.Match[str]) -> str: + if start not in (HOOKS_START, REPOWISE_START): + return "\n" + # Codex can insert unrelated TOML tables before a managed closing comment. + owner = "hooks" if start == HOOKS_START else "mcp_servers.repowise" + owned_header = re.compile(r"\[\[?" + re.escape(owner) + r"(?:\.|\])") + sections = re.split(r"(?m)(?=^\[)", match.group()) + return "\n" + "".join( + section.replace(end, "") + for section in sections[1:] + if not owned_header.match(section) + ) + + result = pattern.sub(replacement, text, count=1) result = re.sub(r"\n{3,}", "\n\n", result).strip() return result + ("\n" if result else "") @@ -1029,6 +1044,28 @@ def ensure_repowise( env["HOME"] = str(paths.home) env["UV_TOOL_BIN_DIR"] = str(destination) env["UV_TOOL_DIR"] = str(paths.home / ".local" / "share" / "uv" / "tools") + if is_windows(): + # Windows cannot replace a tool environment while its executables are running. + script = """ +$ErrorActionPreference = 'Stop' +$toolPrefix = (Join-Path $env:UV_TOOL_DIR 'repowise') + [IO.Path]::DirectorySeparatorChar +$launcher = Join-Path $env:UV_TOOL_BIN_DIR 'repowise.exe' +@(Get-CimInstance Win32_Process | Where-Object { + $_.ExecutablePath -and ( + $_.ExecutablePath.StartsWith($toolPrefix, [StringComparison]::OrdinalIgnoreCase) -or + $_.ExecutablePath.Equals($launcher, [StringComparison]::OrdinalIgnoreCase) + ) +}).ProcessId -join ', ' +""" + running = run( + ["pwsh", "-NoProfile", "-NonInteractive", "-Command", script], env=env, timeout=30 + ).stdout.strip() + if running: + raise KitError( + f"RepoWise is still running (process IDs: {running}). " + "Close Codex sessions and stop RepoWise watchers, then rerun this command " + "from a separate PowerShell window. No RepoWise files were changed." + ) command = [uv, "tool", "install"] if force: command.append("--force") diff --git a/tests/test_kit.py b/tests/test_kit.py index 00d1b34..eeb1608 100644 --- a/tests/test_kit.py +++ b/tests/test_kit.py @@ -37,6 +37,34 @@ def commit(self, path: str, text: str): class MarkerTests(unittest.TestCase): + def test_toml_markers_preserve_every_unrelated_table(self): + foreign = ( + '[marketplaces.ponytail]\nsource_type = "git"\n' + '[plugins."ponytail@ponytail"]\nenabled = false\n' + '[hooks_extra]\nvalue = "keep"\n' + '[mcp_servers.repowise_extra]\ncommand = "keep"\n' + '[sentinel.last]\nvalue = "later distinct table"\n' + ) + expected = tomllib.loads(foreign) + for start, end, old, new in ( + (kit.HOOKS_START, kit.HOOKS_END, '[[hooks.Stop]]\nmatcher = "old"\n', + '[[hooks.SessionStart]]\nmatcher = "new"\n'), + (kit.REPOWISE_START, kit.REPOWISE_END, '[mcp_servers.repowise]\ncommand = "old"\n', + '[mcp_servers.repowise]\ncommand = "new"\n'), + ): + tail = old.replace("old", "second") if start == kit.HOOKS_START else '[mcp_servers.repowise.env]\nOLD = "stale"\n' + text = start + "\n" + old + foreign + tail + end + "\n" + for _ in range(2): + text = kit.marker_block(text, start, end, new) + self.assertEqual(text.count(start), 1) + config = tomllib.loads(text) + self.assertEqual(config["marketplaces"], expected["marketplaces"]) + self.assertEqual(config["plugins"], expected["plugins"]) + self.assertEqual(config["sentinel"], expected["sentinel"]) + self.assertEqual(config["hooks_extra"], expected["hooks_extra"]) + self.assertEqual(config["mcp_servers"]["repowise_extra"], expected["mcp_servers"]["repowise_extra"]) + self.assertEqual(tomllib.loads(kit.remove_marker_block(text, start, end)), expected) + def test_managed_block_round_trip(self): original = "before\nafter\n" installed = kit.marker_block(original, "# start", "# end", "managed") @@ -282,7 +310,11 @@ def test_reinstall_removes_duplicate_managed_hooks(self): paths = self.paths(Path(temp)) config = paths.codex_home / "config.toml" kit.install_hooks(paths) - config.write_text(config.read_text() + "\n\n" + kit.hooks_config_block(paths) + "\n") + foreign = '[plugins."ponytail@ponytail"]\nenabled = false\n' + config.write_text( + config.read_text().replace(kit.HOOKS_END, foreign + kit.HOOKS_END) + + "\n\n" + kit.hooks_config_block(paths) + "\n" + ) kit.install_hooks(paths) @@ -291,6 +323,7 @@ def test_reinstall_removes_duplicate_managed_hooks(self): for event in kit.MANAGED_HOOK_EVENTS: self.assertEqual(len(hooks[event]), 1) self.assertNotIn(kit.HOOKS_START, text) + self.assertFalse(tomllib.loads(text)["plugins"]["ponytail@ponytail"]["enabled"]) def test_reinstall_removes_stop_context_limit(self): with tempfile.TemporaryDirectory() as temp: @@ -1612,6 +1645,93 @@ def fake_run(command, **kwargs): repowise, str(paths.home / ".local" / "bin" / "repowise.exe") ) + @unittest.skipUnless(os.name == "nt" and shutil.which("pwsh"), "Windows PowerShell process query") + def test_windows_repowise_repair_checks_process_paths(self): + with tempfile.TemporaryDirectory(prefix="Kit's test ") as temp: + paths = self.paths(Path(temp)) + launcher = kit.user_bin(paths) / "repowise.exe" + tool = paths.home / ".local/share/uv/tools/repowise" + kit.write_file(launcher, "broken launcher") + kit.write_file(tool / "keep.txt", "unchanged runtime") + kit.write_file(kit.manifest_path(paths), json.dumps({"repowise": str(launcher)})) + python = str(tool / "Scripts/python.exe") + unrelated = {"ProcessId": 10, "ExecutablePath": str(tool) + "-other\\Scripts\\python.exe"} + matching = [ + {"ProcessId": 11, "ExecutablePath": python}, + {"ProcessId": 12, "ExecutablePath": python}, + {"ProcessId": 13, "ExecutablePath": str(launcher)}, + ] + inventory = [] + uv_calls = [] + real_run = kit.run + + def run(command, **kwargs): + if command == [str(launcher), "--version"]: + return subprocess.CompletedProcess(command, 1, "", "missing module") + if command[0] == "pwsh": + # Replace only the OS inventory; run the production selector and error handling. + script = "function Get-CimInstance { $env:CPK_TEST_PROCESSES | ConvertFrom-Json };\n" + return real_run( + [*command[:-1], script + command[-1]], + **{**kwargs, "env": {**kwargs["env"], "CPK_TEST_PROCESSES": json.dumps(inventory)}}, + ) + uv_calls.append(command) + return subprocess.CompletedProcess(command, 0, "", "") + + with mock.patch.object(kit, "find_runtime_command", return_value=str(launcher)), mock.patch.object( + kit, "run", side_effect=run + ): + # An unrelated prefix before duplicate matching paths must not hide the launcher. + for records in ([matching[0]], [matching[2]], [unrelated, *matching]): + inventory[:] = records + with self.subTest(records=records), self.assertRaises(kit.KitError) as caught: + kit.ensure_repowise(paths, "fake-uv") + message = str(caught.exception) + for record in records: + if record is not unrelated: + self.assertIn(str(record["ProcessId"]), message) + self.assertIn("Close", message) + self.assertEqual(uv_calls, []) + self.assertEqual(launcher.read_text(), "broken launcher") + self.assertEqual((tool / "keep.txt").read_text(), "unchanged runtime") + + # The same process records must match equivalent selected paths with different case. + inventory[:] = matching + different_case = kit.InstallPaths( + Path(str(paths.home).swapcase()), paths.codex_home, paths.skills_home, paths.install_root + ) + with self.assertRaises(kit.KitError) as caught: + kit.ensure_repowise(different_case, "fake-uv") + for record in matching: + self.assertIn(str(record["ProcessId"]), str(caught.exception)) + self.assertEqual(uv_calls, []) + + # Once blockers exit, empty or unrelated inventories permit the ordinary repair. + for records in ([], [unrelated, {"ProcessId": 20, "ExecutablePath": None}]): + inventory[:] = records + uv_calls.clear() + self.assertEqual(kit.ensure_repowise(paths, "fake-uv"), str(launcher)) + self.assertEqual(uv_calls, [ + ["fake-uv", "tool", "install", "--force", f"repowise=={kit.load_lock()['runtime_tools']['repowise']['version']}"], + ["fake-uv", "tool", "update-shell"], + ]) + + @unittest.skipUnless(os.name == "nt" and shutil.which("pwsh"), "Windows PowerShell process query") + def test_windows_repowise_process_query_errors_stop_install(self): + with tempfile.TemporaryDirectory() as temp: + paths = self.paths(Path(temp)) + real_run = kit.run + + def run(command, **kwargs): + self.assertEqual(command[0], "pwsh", "uv ran without a valid process inventory") + script = "function Get-CimInstance { Write-Error 'CIM query failed' };\n" + return real_run([*command[:-1], script + command[-1]], **kwargs) + + with mock.patch.object(kit, "find_runtime_command", return_value=None), mock.patch.object( + kit, "run", side_effect=run + ), self.assertRaisesRegex(kit.KitError, "CIM query failed"): + kit.ensure_repowise(paths, "fake-uv") + def test_install_repairs_manifest_owned_broken_windows_repowise(self): with tempfile.TemporaryDirectory() as temp: paths = self.paths(Path(temp)) @@ -2640,6 +2760,12 @@ def fake_repowise(repo: Path, *args: str, **_kwargs): return subprocess.CompletedProcess([], 0, "", "") paths = kit.InstallPaths(root, root / "codex", root / "skills", root / "kit") + config_path = root / ".codex/config.toml" + foreign = '[plugins."sentinel@local"]\nenabled = false\n' + config_path.write_text(kit.marker_block( + config_path.read_text(), kit.REPOWISE_START, kit.REPOWISE_END, + '[mcp_servers.repowise]\ncommand = "old"\n' + foreign, + ), encoding="utf-8") with mock.patch.object(kit, "repowise_command", fake_repowise), mock.patch.object( kit, "ensure_repowise_runtime", return_value=("/usr/bin/uv", "/usr/bin/repowise") ): @@ -2650,6 +2776,8 @@ def fake_repowise(repo: Path, *args: str, **_kwargs): repo_config["mcp_servers"]["repowise"]["args"], [str(kit.repowise_bootstrap_path(paths)), "/usr/bin/repowise"], ) + self.assertEqual(repo_config["mcp_servers"]["repowise"]["cwd"], str(root)) + self.assertFalse(repo_config["plugins"]["sentinel@local"]["enabled"]) self.assertTrue(kit.repowise_bootstrap_path(paths).is_file()) self.assertTrue(any(call[:2] == ("hook", "install") for call in calls)) self.assertFalse(any(call and call[0] == "update" for call in calls)) @@ -2658,6 +2786,11 @@ def fake_repowise(repo: Path, *args: str, **_kwargs): with mock.patch.object(kit, "repowise_command", fake_repowise), mock.patch.object( kit, "ensure_repowise_runtime", return_value=("/usr/bin/uv", "/usr/bin/repowise") ): + # Move the native table back inside the marker before the removal entry point. + config_path.write_text( + config_path.read_text().replace(foreign, "").replace(kit.REPOWISE_END, foreign + kit.REPOWISE_END), + encoding="utf-8", + ) view = root / ".codex/roadmap-view.md" view.write_text(kit.ROADMAP_VIEW_MARKER + "\nowned\n", encoding="utf-8") exclude = root / ".git/info/exclude" @@ -2671,6 +2804,9 @@ def fake_repowise(repo: Path, *args: str, **_kwargs): kit.remove_repo(Namespace(repo=str(root), delete_index=False), paths) self.assertEqual((root / "AGENTS.md").read_text(), "# Project rules\n") self.assertIn("enabled = false", (root / ".codex" / "config.toml").read_text()) + removed_config = tomllib.loads(config_path.read_text()) + self.assertFalse(removed_config["plugins"]["sentinel@local"]["enabled"]) + self.assertFalse(removed_config["mcp_servers"]["repowise"]["enabled"]) self.assertTrue(any(call[:2] == ("hook", "uninstall") for call in calls)) self.assertFalse(view.exists()) self.assertNotIn(kit.ROADMAP_EXCLUDE_START, kit.read_text(exclude)) @@ -3835,7 +3971,7 @@ def test_lifecycle_skills_and_version(self): self.assertIn("`pwsh -File .\\doctor.ps1`", publication) self.assertIn("from the reviewed candidate", publication) self.assertIn("`Result: ready`", publication) - self.assertEqual(kit.KIT_VERSION, "0.26.0") + self.assertEqual(kit.KIT_VERSION, "0.26.1") self.assertNotIn("Version `0.22.0`", (ROOT / "README.md").read_text()) self.assertNotIn("version 0.22.0", (ROOT / "CODEX-INSTALL-PROMPT.md").read_text()) diff --git a/tests/test_ponytail.py b/tests/test_ponytail.py index 6109992..362cabf 100644 --- a/tests/test_ponytail.py +++ b/tests/test_ponytail.py @@ -8,6 +8,7 @@ import re import subprocess import tempfile +import tomllib import unittest from unittest import mock @@ -74,7 +75,12 @@ def seed_plugin(self, *, enabled=True, source=None, home=None, name="ponytail"): config = home / "config.toml" pattern = rf'(?m)^\[plugins\."{re.escape(plugin_id)}"\]\nenabled = (?:true|false)\n' text = re.sub(pattern, '', kit.read_text(config)) - kit.write_file(config, text + f'\n[plugins."{plugin_id}"]\nenabled = ' + str(enabled).lower() + '\n') + text = re.sub(rf'(?m)^\[marketplaces\.{re.escape(marketplace)}\]\nsource_type = "git"\nsource = .*\n', '', text) + block = f'\n[plugins."{plugin_id}"]\nenabled = ' + str(enabled).lower() + '\n' + block += f'\n[marketplaces.{marketplace}]\nsource_type = "git"\nsource = {json.dumps(source)}\n' + # Native Codex 0.163.0 inserts new tables before the final trailing comment. + end = max(text.rfind(kit.HOOKS_END), text.rfind(kit.REPOWISE_END)) + kit.write_file(config, text[:end] + block + text[end:] if end >= 0 else text + block) if name == "ponytail": kit.write_file(home / "plugins/data/ponytail-ponytail/.ponytail-active", "ultra") kit.write_file(self.paths.home / ".config/ponytail/config.json", '{"defaultMode":"off"}') @@ -104,10 +110,17 @@ def native_run(self, command, **kwargs): args = command[2:] state = self.state(home) if args == ["marketplace", "list", "--json"]: - output = {"marketplaces": state["marketplaces"]} + config = tomllib.loads(kit.read_text(home / "config.toml")) + output = {"marketplaces": [item for item in state["marketplaces"] if item["name"] in config.get("marketplaces", {})]} elif len(args) == 4 and args[:2] == ["list", "--marketplace"] and args[3] == "--json": marketplace = args[2] - output = {"installed": [item for item in state["installed"] if item["pluginId"].endswith("@" + marketplace)], "available": []} + config = tomllib.loads(kit.read_text(home / "config.toml")) + output = {"installed": [ + {**item, "enabled": config["plugins"][item["pluginId"]]["enabled"]} + for item in state["installed"] + if item["pluginId"].endswith("@" + marketplace) + and item["pluginId"] in config.get("plugins", {}) + ], "available": []} elif args[:2] == ["marketplace", "add"] and args[-1] == "--json": source = args[2] marketplace = "ponytail" if "ponytail" in source.lower() else "12ui-plugin" @@ -224,6 +237,23 @@ def test_existing_official_plugin_is_reused(self): self.assertIn("enabled" if enabled else "disabled by user", output) self.assertEqual(self.mutations(), []) + def test_install_preserves_native_tables_inside_either_marker(self): + for start, end, body in ( + (kit.HOOKS_START, kit.HOOKS_END, kit.hooks_config_block(self.paths)), + (kit.REPOWISE_START, kit.REPOWISE_END, kit.repowise_config_block("fake-repowise", kit.repowise_bootstrap_path(self.paths))), + ): + with self.subTest(marker=start): + # The plugin is absent when native add writes its tables into existing kit config. + self.write_state({"marketplaces": [], "installed": []}) + config = self.paths.codex_home / "config.toml" + kit.write_file(config, f"{start}\n{body}\n{end}\n") + self.assertEqual(self.cli("install")[0], 0) + self.assertEqual(self.cli("doctor")[0], 0) + self.assertEqual(self.cli("install")[0], 0) + parsed = tomllib.loads(config.read_text()) + self.assertIn("ponytail", parsed["marketplaces"]) + self.assertTrue(parsed["plugins"]["ponytail@ponytail"]["enabled"]) + def test_native_plugin_survives_reinstall_and_uninstall(self): for existing in (False, True): for purge in (False, True): @@ -235,11 +265,15 @@ def test_native_plugin_survives_reinstall_and_uninstall(self): before = self.snapshot(paths.codex_home / "plugins") settings = (self.paths.home / ".config/ponytail/config.json").read_bytes() self.assertEqual(self.cli("install", paths=paths)[0], 0) + self.seed_plugin(home=paths.codex_home, enabled=not existing) self.assertEqual(self.cli("uninstall", *(["--purge"] if purge else []), paths=paths)[0], 0) self.assertEqual(self.snapshot(paths.codex_home / "plugins"), before) self.assertEqual((self.paths.home / ".config/ponytail/config.json").read_bytes(), settings) self.assertIn('[plugins."ponytail@ponytail"]', (paths.codex_home / "config.toml").read_text()) self.assertIn('enabled = ' + str(not existing).lower(), (paths.codex_home / "config.toml").read_text()) + _, retained = kit.ponytail_status(paths) + self.assertIsNotNone(retained) + self.assertEqual(retained["enabled"], not existing) def test_plugin_commands_use_selected_codex_home(self): default = self.root / "default" diff --git a/tests/test_updates.py b/tests/test_updates.py index 6fd5830..2b753bf 100644 --- a/tests/test_updates.py +++ b/tests/test_updates.py @@ -11,6 +11,7 @@ import subprocess import sys import tempfile +import tomllib import unittest import urllib.parse from unittest import mock @@ -39,7 +40,7 @@ def test_apply_repowise_selected_version(self): with tempfile.TemporaryDirectory() as temp: paths = self.paths(Path(temp)) uv = paths.home / "bin/uv" - repowise = paths.home / ".local/bin/repowise" + repowise = paths.home / ".local/bin" / kit.runtime_executable("repowise") uv.parent.mkdir(parents=True) uv.write_text("uv") repowise.parent.mkdir(parents=True) @@ -352,7 +353,8 @@ def test_bash_update_launchers_complete_path(self): def test_powershell_update_launchers_complete_path(self): with tempfile.TemporaryDirectory(prefix="update launchers ") as temp: shim = Path(temp) / "python" - shim.symlink_to(sys.executable) + if os.name != "nt": + shim.symlink_to(sys.executable) env = {**os.environ, "PATH": temp + os.pathsep + os.environ["PATH"]} for script in ("check-updates.ps1", "apply-updates.ps1"): success = subprocess.run(["pwsh", "-NoProfile", "-File", str(ROOT / script), "--help"], cwd=temp, env=env, text=True, capture_output=True) @@ -416,6 +418,13 @@ def setUp(self): ], "installed": [self.plugin("ponytail", enabled=False, user="keep")], } + # Actual Codex 0.163.0 can append native tables before this trailing marker. + kit.write_file(self.paths.codex_home / "config.toml", kit.marker_block( + "", kit.REPOWISE_START, kit.REPOWISE_END, + '[mcp_servers.repowise]\ncommand = "old"\n' + '[marketplaces.ponytail]\nsource_type = "git"\nsource = "https://github.com/DietrichGebert/ponytail.git"\n' + '[plugins."ponytail@ponytail"]\nenabled = false\n', + )) self.fail_upgrade = None self.repowise_version = "9.9.9" self.skill_payloads = { @@ -520,6 +529,8 @@ def fake_run(self, command, **kwargs): suffix = ".exe" if executable == "pwsh" else "" (target / f"uv{suffix}").write_text("fixture") return subprocess.CompletedProcess(command, 0, "", "") + if executable == "pwsh": + return subprocess.CompletedProcess(command, 0, "", "") if executable in {"uv", "uv.exe"}: if command[1:] == ["--version"]: return subprocess.CompletedProcess(command, 0, "uv 8.8.8\n", "") @@ -540,13 +551,16 @@ def fake_run(self, command, **kwargs): return subprocess.CompletedProcess(command, 0, "Logged in\n", "") args = command[2:-1] if args == ["marketplace", "list"]: - output = {"marketplaces": self.plugin_state["marketplaces"]} + config = tomllib.loads(kit.read_text(self.paths.codex_home / "config.toml")) + output = {"marketplaces": [item for item in self.plugin_state["marketplaces"] + if item["name"] != "ponytail" or "ponytail" in config.get("marketplaces", {})]} elif args[:2] == ["list", "--marketplace"]: marketplace = args[2] output = { "installed": [ item for item in self.plugin_state["installed"] if item["pluginId"].endswith("@" + marketplace) + and item["pluginId"] in tomllib.loads(kit.read_text(self.paths.codex_home / "config.toml")).get("plugins", {}) ] } elif args[:2] == ["marketplace", "add"]: @@ -651,6 +665,7 @@ def test_apply_main_reconciles_complete_installation_and_plugins(self): ponytail = next(item for item in self.plugin_state["installed"] if item["name"] == "ponytail") self.assertFalse(ponytail["enabled"]) self.assertEqual(ponytail["user"], "keep") + self.assertFalse(tomllib.loads(kit.read_text(self.paths.codex_home / "config.toml"))["plugins"]["ponytail@ponytail"]["enabled"]) router = (self.paths.codex_home / "AGENTS.md").read_text() self.assertIn("Use 12ui-design only for website projects", router) self.assertNotIn("all non-trivial UI", router.lower())