diff --git a/docker/run/AGENTS.md b/docker/run/AGENTS.md index 9077e5a90..b5607ae69 100644 --- a/docker/run/AGENTS.md +++ b/docker/run/AGENTS.md @@ -22,6 +22,7 @@ - Do not bake secrets, local `.env` values, or user data into the image. - Runtime startup must ensure `/a0/usr/uploads` exists before supervised services start. - Runtime startup raises the soft open-file limit toward `A0_NOFILE_LIMIT` (default `65535`) before supervisord starts, bounded by the container hard limit. +- Self-update user-data backups skip Time Travel shadow history under `usr/.time_travel/` and transient Desktop agent state. ## Work Guidance diff --git a/docker/run/fs/exe/self_update_manager.py b/docker/run/fs/exe/self_update_manager.py index abf4e595f..4ae23ce28 100644 --- a/docker/run/fs/exe/self_update_manager.py +++ b/docker/run/fs/exe/self_update_manager.py @@ -405,6 +405,11 @@ def should_exclude_from_usr_backup( logger: AttemptLogger, ) -> bool: parts = relative_dir.parts + if parts and parts[0] == ".time_travel": + logger.log( + f"Skipping Time Travel history during usr backup: {Path('usr') / relative_dir}" + ) + return True if ( len(parts) >= 6 and parts[0] == "plugins" diff --git a/helpers/backup.py b/helpers/backup.py index 47e41c5f8..70ad7b082 100644 --- a/helpers/backup.py +++ b/helpers/backup.py @@ -63,6 +63,7 @@ class BackupService: return f"""# User data # All persistent user data is now centralized in /usr for easier backup and restore {agent_root}/usr/** +!{agent_root}/usr/.time_travel/** """ def _get_agent_zero_version(self) -> str: diff --git a/helpers/backup.py.dox.md b/helpers/backup.py.dox.md index e08f7a74e..5612447ec 100644 --- a/helpers/backup.py.dox.md +++ b/helpers/backup.py.dox.md @@ -26,6 +26,7 @@ - Observed side-effect areas: filesystem reads, filesystem writes, filesystem deletion, settings/state persistence, secret handling. - Imported dependency areas include: `datetime`, `helpers`, `helpers.localization`, `helpers.print_style`, `json`, `os`, `pathspec`, `platform`, `tempfile`, `typing`, `zipfile`. - `test_patterns(..., max_files=None)` is the unlimited scan mode. UI preview and dry-run callers may pass bounded limits, but real backup creation and restore clean-before-restore must use unlimited matching so archives and cleanup are not silently truncated. +- Default backup metadata includes persistent `/usr` data but excludes Time Travel shadow history under `usr/.time_travel/**`. ## Key Concepts diff --git a/tests/test_backup_large_archives.py b/tests/test_backup_large_archives.py index 7638fbbc5..e8c607655 100644 --- a/tests/test_backup_large_archives.py +++ b/tests/test_backup_large_archives.py @@ -16,6 +16,28 @@ class UploadedBackup: shutil.copyfile(self.path, target) +@pytest.mark.asyncio +async def test_default_backup_patterns_exclude_time_travel_history(tmp_path): + root = tmp_path / "a0" + usr = root / "usr" + time_travel = usr / ".time_travel" / "workspaces" / "demo" / "repo.git" + time_travel.mkdir(parents=True) + (usr / "settings.json").write_text('{"ok": true}\n', encoding="utf-8") + (time_travel / "objects.pack").write_text("history\n", encoding="utf-8") + + service = BackupService() + service.agent_zero_root = str(root) + service.base_paths = {str(root): str(root)} + metadata = service.get_default_backup_metadata() + + files = await service.test_patterns(metadata, max_files=None) + paths = {item["real_path"] for item in files} + + assert str(usr / "settings.json") in paths + assert str(time_travel / "objects.pack") not in paths + assert f"{root}/usr/.time_travel/**" in metadata["exclude_patterns"] + + @pytest.mark.asyncio async def test_pattern_scan_can_run_without_file_limit(tmp_path): root = tmp_path / "a0" diff --git a/tests/test_self_update_tag_filter.py b/tests/test_self_update_tag_filter.py index 864a471e5..2f351f4ad 100644 --- a/tests/test_self_update_tag_filter.py +++ b/tests/test_self_update_tag_filter.py @@ -829,6 +829,39 @@ def test_self_update_manager_usr_backup_skips_runtime_sockets(): ) +def test_self_update_manager_usr_backup_skips_time_travel_history(tmp_path): + manager = load_self_update_manager() + repo_dir = tmp_path / "repo" + usr_dir = repo_dir / "usr" + time_travel = usr_dir / ".time_travel" / "workspaces" / "demo" / "repo.git" + time_travel.mkdir(parents=True) + (usr_dir / "settings.json").write_text('{"ok": true}\n', encoding="utf-8") + (time_travel / "objects.pack").write_text("history\n", encoding="utf-8") + messages = [] + + class ListLogger: + def log(self, message=""): + messages.append(message) + + backup_path = manager.create_usr_backup( + repo_dir=repo_dir, + backup_path=str(tmp_path / "backups"), + backup_name="usr-backup.zip", + conflict_policy="rename", + logger=ListLogger(), + ) + + with zipfile.ZipFile(backup_path) as archive: + names = set(archive.namelist()) + + assert "usr/settings.json" in names + assert "usr/.time_travel/workspaces/demo/repo.git/objects.pack" not in names + assert any( + "Skipping Time Travel history during usr backup: usr/.time_travel" in message + for message in messages + ) + + def test_self_update_manager_usr_backup_skips_transient_desktop_ssh_agent_dir(tmp_path): manager = load_self_update_manager() repo_dir = tmp_path / "repo"