Fix backup restore allowed origins
Preserve the destination ALLOWED_ORIGINS during restore so a backup created at another URL does not lock out the new instance. Restore backup credentials and other portable settings without reloading them into the active process.
Alessandro committed
Aug 19, 2026 at 04:26 UTC
e6123e476ad5fd655151c5d306e887e7c784d126
5 files changed
+84
-4
helpers/backup.py
+9
-1
@@ -8,7 +8,7 @@ from typing import List, Dict, Any, Optional
8
9
from pathspec import PathSpec
10
11
-from helpers import files, runtime, git
11
+from helpers import files, runtime, git, dotenv
12
from helpers.localization import Localization
13
from helpers.print_style import PrintStyle
14
@@ -608,6 +608,9 @@ class BackupService:
608
) -> Dict[str, Any]:
609
"""Restore files from backup archive"""
610
611
+ allowed_origins = dotenv.get_dotenv_value("ALLOWED_ORIGINS", "")
612
+ dotenv_path = os.path.abspath(dotenv.get_dotenv_file_path())
613
+
614
# Save uploaded file temporarily
615
temp_dir = tempfile.mkdtemp()
616
temp_file = os.path.join(temp_dir, "backup.zip")
@@ -725,6 +728,11 @@ class BackupService:
728
with zipf.open(archive_path) as source, open(target_path, 'wb') as target:
729
shutil.copyfileobj(source, target)
730
731
+ if os.path.abspath(target_path) == dotenv_path:
732
+ dotenv.save_dotenv_value(
733
+ "ALLOWED_ORIGINS", allowed_origins, reload_env=False
734
+ )
735
+
736
restored_files.append({
737
"archive_path": archive_path,
738
"original_path": original_path,
helpers/backup.py.dox.md
+1
@@ -27,6 +27,7 @@
27
- Imported dependency areas include: `datetime`, `helpers`, `helpers.localization`, `helpers.print_style`, `json`, `os`, `pathspec`, `platform`, `tempfile`, `typing`, `zipfile`.
28
- `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.
29
- Default backup metadata includes persistent `/usr` data but excludes Time Travel shadow history under `usr/.time_travel/**`.
30
+- Restoring `usr/.env` preserves the destination instance's allowed origins while restoring authentication and other portable configuration from the archive.
31
32
## Key Concepts
33
helpers/dotenv.py
+3
-2
@@ -21,7 +21,7 @@ def get_dotenv_value(key: str, default: Any = None):
21
# load_dotenv()
22
return os.getenv(key, default)
23
24
-def save_dotenv_value(key: str, value: str):
24
+def save_dotenv_value(key: str, value: str, reload_env: bool = True):
25
if value is None:
26
value = ""
27
dotenv_path = get_dotenv_file_path()
@@ -40,4 +40,5 @@ def save_dotenv_value(key: str, value: str):
40
f.seek(0)
41
f.writelines(lines)
42
f.truncate()
43
- load_dotenv()
43
+ if reload_env:
44
+ load_dotenv()
helpers/dotenv.py.dox.md
+2
-1
@@ -14,7 +14,7 @@
14
- `load_dotenv()`
15
- `get_dotenv_file_path()`
16
- `get_dotenv_value(key: str, default: Any=...)`
17
-- `save_dotenv_value(key: str, value: str)`
17
+- `save_dotenv_value(key: str, value: str, reload_env: bool=...)`
18
- Notable constants/configuration names: `KEY_AUTH_LOGIN`, `KEY_AUTH_PASSWORD`, `KEY_RFC_PASSWORD`, `KEY_ROOT_PASSWORD`.
19
20
## Runtime Contracts
@@ -23,6 +23,7 @@
23
- Update this file whenever public functions, classes, persistence behavior, path/security assumptions, side effects, or cross-module contracts change.
24
- Observed side-effect areas: filesystem reads, filesystem writes, secret handling.
25
- Imported dependency areas include: `dotenv`, `files`, `os`, `re`, `typing`.
26
+- `save_dotenv_value(..., reload_env=False)` updates the persisted file without changing the running process environment.
27
28
## Key Concepts
29
tests/test_backup_large_archives.py
+69
@@ -4,7 +4,9 @@ import zipfile
4
from pathlib import Path
5
6
import pytest
7
+from dotenv import dotenv_values
8
9
+from helpers import dotenv
10
from helpers.backup import BackupService
11
12
@@ -38,6 +40,73 @@ async def test_default_backup_patterns_exclude_time_travel_history(tmp_path):
40
assert f"{root}/usr/.time_travel/**" in metadata["exclude_patterns"]
41
42
43
+@pytest.mark.parametrize(
44
+ ("backup_credentials", "expected_credentials"),
45
+ [
46
+ (
47
+ "AUTH_LOGIN=backup\nAUTH_PASSWORD=backup-password\n",
48
+ {"AUTH_LOGIN": "backup", "AUTH_PASSWORD": "backup-password"},
49
+ ),
50
+ (
51
+ "AUTH_LOGIN=\nAUTH_PASSWORD=\n",
52
+ {"AUTH_LOGIN": "", "AUTH_PASSWORD": ""},
53
+ ),
54
+ ("", {}),
55
+ ],
56
+ ids=("credentials", "blank-credentials", "missing-credentials"),
57
+)
58
+@pytest.mark.asyncio
59
+async def test_restore_preserves_destination_origin_and_restores_backup_credentials(
60
+ tmp_path, monkeypatch, backup_credentials, expected_credentials
61
+):
62
+ old_root = "/old-a0"
63
+ destination_root = tmp_path / "a0"
64
+ destination_env = destination_root / "usr" / ".env"
65
+ destination_env.parent.mkdir(parents=True)
66
+ destination_env.write_text(
67
+ "AUTH_LOGIN=current\n"
68
+ "AUTH_PASSWORD=current-password\n"
69
+ "ALLOWED_ORIGINS=http://current.example\n",
70
+ encoding="utf-8",
71
+ )
72
+ monkeypatch.setenv("AUTH_LOGIN", "current")
73
+ monkeypatch.setenv("AUTH_PASSWORD", "current-password")
74
+ monkeypatch.setenv("ALLOWED_ORIGINS", "http://current.example")
75
+ monkeypatch.setattr(dotenv, "get_dotenv_file_path", lambda: str(destination_env))
76
+ monkeypatch.setattr(
77
+ dotenv,
78
+ "load_dotenv",
79
+ lambda: pytest.fail("restore must not reload unrelated environment values"),
80
+ )
81
+
82
+ zip_path = tmp_path / "backup.zip"
83
+ metadata = {
84
+ "environment_info": {"agent_zero_root": old_root},
85
+ "include_patterns": [f"{old_root}/usr/**"],
86
+ "exclude_patterns": [],
87
+ "include_hidden": True,
88
+ }
89
+ with zipfile.ZipFile(zip_path, "w") as archive:
90
+ archive.writestr("metadata.json", json.dumps(metadata))
91
+ archive.writestr(
92
+ "old-a0/usr/.env",
93
+ f"{backup_credentials}ALLOWED_ORIGINS=http://backup.example\n"
94
+ "PORTABLE_SETTING=restored\n",
95
+ )
96
+
97
+ service = BackupService()
98
+ service.agent_zero_root = str(destination_root)
99
+ result = await service.restore_backup(UploadedBackup(zip_path))
100
+
101
+ assert dotenv_values(destination_env) == {
102
+ **expected_credentials,
103
+ "ALLOWED_ORIGINS": "http://current.example",
104
+ "PORTABLE_SETTING": "restored",
105
+ }
106
+ assert len(result["restored_files"]) == 1
107
+ assert result["errors"] == []
108
+
109
+
110
@pytest.mark.asyncio
111
async def test_pattern_scan_can_run_without_file_limit(tmp_path):
112
root = tmp_path / "a0"