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/5] 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/5] 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/5] 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/5] 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/5] 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 = {}