From 1bf70bafe63a2bf8839329f7d844031d21a1cfbf Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 10 Jul 2026 12:34:11 +0000 Subject: [PATCH 1/8] Fix reinstall-overwrites-kept-config: preserve config on plain reinstall after --keep-config Apply the remediation from the bug assessment on issue #3427. Before the unconditional shutil.rmtree(dest_dir), scan dest_dir for any *-config.yml and *-config.local.yml files and hold their contents in memory. After shutil.copytree succeeds, write them back so user-customized values always win over the packaged defaults. This mirrors the existing backup/restore logic for the --force reinstall path but handles the case where remove --keep-config left config files behind in an unregistered extension directory. Refs #3427 Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/extensions/__init__.py | 20 +++++++++ tests/test_extensions.py | 58 +++++++++++++++++++++++++- 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index b84b836545..6f179cc53a 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -1397,6 +1397,22 @@ def install_from_directory( backup_config_dir.unlink() did_remove = self.remove(manifest.id) + # Rescue any config files left behind by a prior `remove --keep-config`. + # When an extension is removed with --keep-config, it is no longer in + # the registry but its config files remain in dest_dir. A subsequent + # plain (non-force) install would delete that directory unconditionally, + # silently discarding the preserved config. We read those files into + # memory now and write them back after copytree so the user's values + # always win over the packaged defaults. + stranded_configs: dict[str, bytes] = {} + if dest_dir.exists() and not self.registry.is_installed(manifest.id): + for cfg_file in ( + list(dest_dir.glob("*-config.yml")) + + list(dest_dir.glob("*-config.local.yml")) + ): + if cfg_file.is_file() and not cfg_file.is_symlink(): + stranded_configs[cfg_file.name] = cfg_file.read_bytes() + # Install extension (dest_dir computed above during self-install guard) if dest_dir.exists(): shutil.rmtree(dest_dir) @@ -1427,6 +1443,10 @@ def install_from_directory( hook_executor = HookExecutor(self.project_root) hook_executor.register_hooks(manifest) + # Restore stranded configs rescued before the rmtree above. + for filename, content in stranded_configs.items(): + (dest_dir / filename).write_bytes(content) + # Restore config files from backup when --force triggered a removal. # Only restore *.yml config files to match what remove() backs up, # so unexpected artifacts in .backup/ are not resurrected. diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 2885180360..73c1b138cf 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -1226,7 +1226,63 @@ def test_install_force_config_preserved(self, extension_dir, project_dir): assert new_config.exists() assert new_config.read_text() == "test: config" - def test_install_force_without_existing(self, extension_dir, project_dir): + def test_reinstall_after_keep_config_preserves_config( + self, extension_dir, project_dir + ): + """Reinstalling after `remove --keep-config` must not overwrite preserved config.""" + manager = ExtensionManager(project_dir) + + # Install once + manager.install_from_directory( + extension_dir, "0.1.0", register_commands=False + ) + + # Customize the config file + ext_dir = project_dir / ".specify" / "extensions" / "test-ext" + config_file = ext_dir / "test-ext-config.yml" + config_file.write_text("model: custom-model\nmax_iterations: 99\n") + + # Remove while preserving config + manager.remove("test-ext", keep_config=True) + assert not manager.registry.is_installed("test-ext") + assert config_file.exists() + assert "custom-model" in config_file.read_text() + + # Plain reinstall (no --force) + manager.install_from_directory( + extension_dir, "0.1.0", register_commands=False + ) + + # Preserved config must survive the reinstall + assert config_file.exists() + assert "custom-model" in config_file.read_text() + assert "99" in config_file.read_text() + + def test_reinstall_after_keep_config_preserves_local_config( + self, extension_dir, project_dir + ): + """Local config override files (*-config.local.yml) are also rescued on reinstall.""" + manager = ExtensionManager(project_dir) + + manager.install_from_directory( + extension_dir, "0.1.0", register_commands=False + ) + + ext_dir = project_dir / ".specify" / "extensions" / "test-ext" + local_cfg = ext_dir / "test-ext-config.local.yml" + local_cfg.write_text("local_override: true\n") + + manager.remove("test-ext", keep_config=True) + assert local_cfg.exists() + + manager.install_from_directory( + extension_dir, "0.1.0", register_commands=False + ) + + assert local_cfg.exists() + assert "local_override: true" in local_cfg.read_text() + + """Test force-install when extension is NOT already installed (works normally).""" manager = ExtensionManager(project_dir) From 5507df0cfaa00043da1d7ef0908219b10abd2058 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 13 Jul 2026 12:26:45 +0000 Subject: [PATCH 2/8] fix: restore method decl, move config restore before registration, preserve file mode - Restore missing `test_install_force_without_existing` method declaration in tests/test_extensions.py so pytest collects it as a separate test. - Move stranded-config restoration to immediately after `copytree`, before command/skill/hook registration, so a failed registration step can't leave preserved configs permanently lost. - Store `(bytes, mode)` tuples instead of bare bytes when rescuing stranded configs, and reapply the original file mode after writing so permission bits (e.g. 0600 for credential files) are faithfully restored. Assisted-by: GitHub Copilot (model: claude-sonnet-4.5, autonomous) --- src/specify_cli/extensions/__init__.py | 17 +++++++++++------ tests/test_extensions.py | 2 +- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index 6f179cc53a..0ac61d6cec 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -1404,14 +1404,17 @@ def install_from_directory( # silently discarding the preserved config. We read those files into # memory now and write them back after copytree so the user's values # always win over the packaged defaults. - stranded_configs: dict[str, bytes] = {} + stranded_configs: dict[str, tuple[bytes, int]] = {} if dest_dir.exists() and not self.registry.is_installed(manifest.id): for cfg_file in ( list(dest_dir.glob("*-config.yml")) + list(dest_dir.glob("*-config.local.yml")) ): if cfg_file.is_file() and not cfg_file.is_symlink(): - stranded_configs[cfg_file.name] = cfg_file.read_bytes() + stranded_configs[cfg_file.name] = ( + cfg_file.read_bytes(), + cfg_file.stat().st_mode, + ) # Install extension (dest_dir computed above during self-install guard) if dest_dir.exists(): @@ -1420,6 +1423,12 @@ def install_from_directory( ignore_fn = self._load_extensionignore(source_dir) shutil.copytree(source_dir, dest_dir, ignore=ignore_fn) + # Restore stranded configs rescued before the rmtree above. + for filename, (content, mode) in stranded_configs.items(): + target = dest_dir / filename + target.write_bytes(content) + target.chmod(mode) + # Register commands with AI agents registered_commands = {} if register_commands: @@ -1443,10 +1452,6 @@ def install_from_directory( hook_executor = HookExecutor(self.project_root) hook_executor.register_hooks(manifest) - # Restore stranded configs rescued before the rmtree above. - for filename, content in stranded_configs.items(): - (dest_dir / filename).write_bytes(content) - # Restore config files from backup when --force triggered a removal. # Only restore *.yml config files to match what remove() backs up, # so unexpected artifacts in .backup/ are not resurrected. diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 73c1b138cf..027aed91b5 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -1282,7 +1282,7 @@ def test_reinstall_after_keep_config_preserves_local_config( assert local_cfg.exists() assert "local_override: true" in local_cfg.read_text() - + def test_install_force_without_existing(self, extension_dir, project_dir): """Test force-install when extension is NOT already installed (works normally).""" manager = ExtensionManager(project_dir) From bd55c59ffbe3849c1b23d0f92c1a51e1f535d175 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 13 Jul 2026 12:28:30 +0000 Subject: [PATCH 3/8] fix: mask setuid/setgid bits when restoring stranded config file mode Only preserve user/group read-write bits (mode & 0o660) to avoid restoring setuid, setgid, or world-writable permissions from a user-modified config file. Assisted-by: GitHub Copilot (model: claude-sonnet-4.5, autonomous) --- src/specify_cli/extensions/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index 0ac61d6cec..bc5c87828b 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -1427,7 +1427,9 @@ def install_from_directory( for filename, (content, mode) in stranded_configs.items(): target = dest_dir / filename target.write_bytes(content) - target.chmod(mode) + # Mask to only user/group read-write bits to avoid restoring + # setuid/setgid or world-writable permissions. + target.chmod(mode & 0o660) # Register commands with AI agents registered_commands = {} From 307b5d9e523f58eef8eea0d5f4465842b9398813 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 13 Jul 2026 19:21:37 +0000 Subject: [PATCH 4/8] fix: add copytree rollback path and strengthen regression test with packaged default config - Wrap shutil.copytree in a try/except BaseException so stranded configs rescued before rmtree are written back even if copytree fails mid-way (addresses review comment: configs were permanently lost on copy failure) - Add a packaged default config to extension_dir in the regression test so a naive 'restore only when absent' implementation would fail; assert the user's customized values beat the packaged defaults after reinstall Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) --- src/specify_cli/extensions/__init__.py | 14 +++++++++++++- tests/test_extensions.py | 15 +++++++++++---- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index bc5c87828b..d054cea3ab 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -1421,7 +1421,19 @@ def install_from_directory( shutil.rmtree(dest_dir) ignore_fn = self._load_extensionignore(source_dir) - shutil.copytree(source_dir, dest_dir, ignore=ignore_fn) + try: + shutil.copytree(source_dir, dest_dir, ignore=ignore_fn) + except BaseException: + # copytree failed — dest_dir may be absent or only partially + # created. Write the rescued configs back now so they are not + # permanently lost even though the install did not complete. + if stranded_configs: + dest_dir.mkdir(parents=True, exist_ok=True) + for filename, (content, mode) in stranded_configs.items(): + target = dest_dir / filename + target.write_bytes(content) + target.chmod(mode & 0o660) + raise # Restore stranded configs rescued before the rmtree above. for filename, (content, mode) in stranded_configs.items(): diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 027aed91b5..168d70e3cf 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -1232,12 +1232,17 @@ def test_reinstall_after_keep_config_preserves_config( """Reinstalling after `remove --keep-config` must not overwrite preserved config.""" manager = ExtensionManager(project_dir) - # Install once + # Add a packaged default config so the reinstall has a file to overwrite. + # Without the fix, the packaged default would silently win on reinstall. + packaged_config = extension_dir / "test-ext-config.yml" + packaged_config.write_text("model: default-model\nmax_iterations: 1\n") + + # Install once (packaged default is copied into the installed directory) manager.install_from_directory( extension_dir, "0.1.0", register_commands=False ) - # Customize the config file + # Overwrite the installed config with user-customized values ext_dir = project_dir / ".specify" / "extensions" / "test-ext" config_file = ext_dir / "test-ext-config.yml" config_file.write_text("model: custom-model\nmax_iterations: 99\n") @@ -1248,15 +1253,17 @@ def test_reinstall_after_keep_config_preserves_config( assert config_file.exists() assert "custom-model" in config_file.read_text() - # Plain reinstall (no --force) + # Plain reinstall (no --force) — packaged default is still present in + # extension_dir, so a naive implementation would overwrite the custom values. manager.install_from_directory( extension_dir, "0.1.0", register_commands=False ) - # Preserved config must survive the reinstall + # Preserved config must survive the reinstall and beat the packaged default assert config_file.exists() assert "custom-model" in config_file.read_text() assert "99" in config_file.read_text() + assert "default-model" not in config_file.read_text() def test_reinstall_after_keep_config_preserves_local_config( self, extension_dir, project_dir From 5f9da76baa05a06644f94036e6e56a665563af9d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 13 Jul 2026 21:25:49 +0000 Subject: [PATCH 5/8] fix: restore configs with secure atomic writes Assisted-by: GitHub Copilot (model: gpt-5, autonomous) --- src/specify_cli/extensions/__init__.py | 30 ++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index d054cea3ab..d4cc812231 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -1421,6 +1421,28 @@ def install_from_directory( shutil.rmtree(dest_dir) ignore_fn = self._load_extensionignore(source_dir) + + def _restore_stranded_config_file( + target: Path, content: bytes, preserved_mode: int + ) -> None: + mode = preserved_mode & 0o660 + tmp_path: Path | None = None + try: + with tempfile.NamedTemporaryFile( + mode="wb", + dir=target.parent, + prefix=f".{target.name}.", + delete=False, + ) as tmp: + tmp_path = Path(tmp.name) + tmp_path.chmod(mode) + tmp.write(content) + os.replace(tmp_path, target) + except BaseException: + if tmp_path is not None and tmp_path.exists(): + tmp_path.unlink() + raise + try: shutil.copytree(source_dir, dest_dir, ignore=ignore_fn) except BaseException: @@ -1431,17 +1453,13 @@ def install_from_directory( dest_dir.mkdir(parents=True, exist_ok=True) for filename, (content, mode) in stranded_configs.items(): target = dest_dir / filename - target.write_bytes(content) - target.chmod(mode & 0o660) + _restore_stranded_config_file(target, content, mode) raise # Restore stranded configs rescued before the rmtree above. for filename, (content, mode) in stranded_configs.items(): target = dest_dir / filename - target.write_bytes(content) - # Mask to only user/group read-write bits to avoid restoring - # setuid/setgid or world-writable permissions. - target.chmod(mode & 0o660) + _restore_stranded_config_file(target, content, mode) # Register commands with AI agents registered_commands = {} From 32e4161c78d0255f5cc50a399ddd950c001769b4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 13 Jul 2026 22:40:53 +0000 Subject: [PATCH 6/8] fix: write secure temp file then chmod to preserved_mode; add copytree-failure test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _restore_stranded_config_file: write content while temp file is at its secure OS-default mode (typically 0600 on POSIX), then apply the original preserved_mode after the file is fully written and before the atomic os.replace. Removes the & 0o660 mask that was silently stripping world-read and executable bits (e.g. 0644 → 0640). - Add test_copytree_failure_restores_stranded_config: patches shutil.copytree to create a partial destination then raise OSError, then asserts that the preserved config bytes and file mode are restored by the rollback path and that the extension remains unregistered. Assisted-by: GitHub Copilot (model: claude-sonnet-4.6, autonomous) --- src/specify_cli/extensions/__init__.py | 3 +- tests/test_extensions.py | 66 ++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index d4cc812231..6caad2ae74 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -1425,7 +1425,6 @@ def install_from_directory( def _restore_stranded_config_file( target: Path, content: bytes, preserved_mode: int ) -> None: - mode = preserved_mode & 0o660 tmp_path: Path | None = None try: with tempfile.NamedTemporaryFile( @@ -1435,8 +1434,8 @@ def _restore_stranded_config_file( delete=False, ) as tmp: tmp_path = Path(tmp.name) - tmp_path.chmod(mode) tmp.write(content) + tmp_path.chmod(preserved_mode) os.replace(tmp_path, target) except BaseException: if tmp_path is not None and tmp_path.exists(): diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 168d70e3cf..d5d5770ba0 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -1289,6 +1289,72 @@ def test_reinstall_after_keep_config_preserves_local_config( assert local_cfg.exists() assert "local_override: true" in local_cfg.read_text() + def test_copytree_failure_restores_stranded_config( + self, extension_dir, project_dir, monkeypatch + ): + """A copytree failure must not permanently lose a preserved config. + + When copytree raises after the existing directory has been removed, the + rollback path must write the rescued bytes back to dest_dir and restore + the original file mode, while leaving the extension unregistered. + """ + import specify_cli.extensions as _ext_module + import stat + + manager = ExtensionManager(project_dir) + + # Add a packaged default config so copytree would overwrite it on success. + packaged_config = extension_dir / "test-ext-config.yml" + packaged_config.write_text("model: default-model\n") + + # Install once so the extension is on disk. + manager.install_from_directory( + extension_dir, "0.1.0", register_commands=False + ) + + ext_dir = project_dir / ".specify" / "extensions" / "test-ext" + config_file = ext_dir / "test-ext-config.yml" + config_file.write_text("model: custom-model\nmax_iterations: 99\n") + + # Set a known, non-default mode so we can assert it survives the rollback. + if platform.system() != "Windows": + config_file.chmod(0o640) + original_bytes = config_file.read_bytes() + original_mode = config_file.stat().st_mode + + # Remove while preserving the config — it is now a stranded file. + manager.remove("test-ext", keep_config=True) + assert not manager.registry.is_installed("test-ext") + assert config_file.exists() + + # Make copytree create a partial destination then raise so the rollback + # path is exercised. + original_copytree = shutil.copytree + + def failing_copytree(src, dst, **kwargs): + Path(dst).mkdir(parents=True, exist_ok=True) + (Path(dst) / "_partial.txt").write_text("partial") + raise OSError("simulated disk full") + + monkeypatch.setattr(_ext_module.shutil, "copytree", failing_copytree) + + with pytest.raises(OSError, match="simulated disk full"): + manager.install_from_directory( + extension_dir, "0.1.0", register_commands=False + ) + + # The preserved config must have been written back by the rollback path. + assert config_file.exists(), "rollback must recreate the config file" + assert config_file.read_bytes() == original_bytes + + # On POSIX, the original file mode must be faithfully restored. + if platform.system() != "Windows": + restored_mode = config_file.stat().st_mode + assert stat.S_IMODE(restored_mode) == stat.S_IMODE(original_mode) + + # The extension must remain unregistered after the failed install. + assert not manager.registry.is_installed("test-ext") + def test_install_force_without_existing(self, extension_dir, project_dir): """Test force-install when extension is NOT already installed (works normally).""" manager = ExtensionManager(project_dir) From ac4315b44a55957f0ed5107f62b878cb97a64a80 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Mon, 13 Jul 2026 17:56:29 -0500 Subject: [PATCH 7/8] Potential fix for pull request finding 'Unused local variable' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- tests/test_extensions.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_extensions.py b/tests/test_extensions.py index d5d5770ba0..2aa1a2a3b7 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -1329,7 +1329,6 @@ def test_copytree_failure_restores_stranded_config( # Make copytree create a partial destination then raise so the rollback # path is exercised. - original_copytree = shutil.copytree def failing_copytree(src, dst, **kwargs): Path(dst).mkdir(parents=True, exist_ok=True) From 4b1058e29ecddaaf6f28c77e6474ff6a8c68639e Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Mon, 13 Jul 2026 17:56:41 -0500 Subject: [PATCH 8/8] Potential fix for pull request finding 'Module is imported with 'import' and 'import from'' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- tests/test_extensions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 2aa1a2a3b7..093b0ffe77 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -20,6 +20,7 @@ from pathlib import Path from datetime import datetime, timezone from unittest.mock import MagicMock +import specify_cli.extensions as _ext_module from tests.conftest import strip_ansi from specify_cli.extensions import ( @@ -1298,7 +1299,6 @@ def test_copytree_failure_restores_stranded_config( rollback path must write the rescued bytes back to dest_dir and restore the original file mode, while leaving the extension unregistered. """ - import specify_cli.extensions as _ext_module import stat manager = ExtensionManager(project_dir)