mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-19 21:07:28 +08:00
Merge pull request #2957 from AaronZ345/feat/local-data-backup
feat(cli): add portable data backup and restore
This commit is contained in:
@@ -7,6 +7,7 @@ from cli.commands.process import start, stop, restart, self_restart, update, sta
|
||||
from cli.commands.context import context
|
||||
from cli.commands.install import install_browser
|
||||
from cli.commands.knowledge import knowledge
|
||||
from cli.commands.backup import backup_command, restore_command
|
||||
|
||||
|
||||
HELP_TEXT = """Usage: cow COMMAND [ARGS]...
|
||||
@@ -24,6 +25,8 @@ Commands:
|
||||
logs View CowAgent logs.
|
||||
skill Manage CowAgent skills.
|
||||
knowledge Manage knowledge base.
|
||||
backup Back up config and agent workspace.
|
||||
restore Restore a CowAgent backup.
|
||||
install-browser Install browser tool (Playwright + Chromium).
|
||||
|
||||
Tip: Memory index management lives in chat — send /memory status or
|
||||
@@ -74,6 +77,8 @@ main.add_command(status)
|
||||
main.add_command(logs)
|
||||
main.add_command(context)
|
||||
main.add_command(knowledge)
|
||||
main.add_command(backup_command)
|
||||
main.add_command(restore_command)
|
||||
main.add_command(install_browser)
|
||||
|
||||
|
||||
|
||||
332
cli/commands/backup.py
Normal file
332
cli/commands/backup.py
Normal file
@@ -0,0 +1,332 @@
|
||||
"""Portable local backup and restore commands for CowAgent user data."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import stat
|
||||
import tempfile
|
||||
import zipfile
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Iterable, Optional, Set
|
||||
|
||||
import click
|
||||
|
||||
from cli.utils import get_project_root
|
||||
|
||||
|
||||
BACKUP_FORMAT = "cowagent-backup"
|
||||
BACKUP_VERSION = 1
|
||||
_SKIP_DIRS = {".git", "__pycache__", "tmp"}
|
||||
_SKIP_FILES = {".DS_Store"}
|
||||
|
||||
|
||||
def _data_root() -> Path:
|
||||
configured = os.environ.get("COW_DATA_DIR")
|
||||
return Path(configured).expanduser().resolve() if configured else Path(get_project_root()).resolve()
|
||||
|
||||
|
||||
def _read_config(data_root: Path) -> dict:
|
||||
path = data_root / "config.json"
|
||||
if not path.is_file():
|
||||
return {}
|
||||
try:
|
||||
with path.open("r", encoding="utf-8") as handle:
|
||||
value = json.load(handle)
|
||||
return value if isinstance(value, dict) else {}
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
|
||||
|
||||
def _workspace_from_config(config: dict) -> Path:
|
||||
return Path(config.get("agent_workspace") or "~/cow").expanduser().resolve()
|
||||
|
||||
|
||||
def _legacy_user_data_path(data_root: Path, config: dict) -> Path:
|
||||
appdata_dir = config.get("appdata_dir") or ""
|
||||
return (data_root / appdata_dir / "user_datas.pkl").resolve()
|
||||
|
||||
|
||||
def _is_within(path: Path, root: Path) -> bool:
|
||||
try:
|
||||
return os.path.commonpath([str(path.resolve()), str(root.resolve())]) == str(root.resolve())
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def _iter_workspace_files(workspace: Path, excluded: Set[Path]):
|
||||
if not workspace.is_dir():
|
||||
return
|
||||
for current, dirnames, filenames in os.walk(str(workspace), followlinks=False):
|
||||
current_path = Path(current)
|
||||
dirnames[:] = [
|
||||
name for name in dirnames
|
||||
if name not in _SKIP_DIRS and not (current_path / name).is_symlink()
|
||||
]
|
||||
for name in filenames:
|
||||
path = current_path / name
|
||||
if name in _SKIP_FILES or name.endswith((".pyc", ".pyo")):
|
||||
continue
|
||||
if path.is_symlink() or path.resolve() in excluded:
|
||||
continue
|
||||
if path.is_file():
|
||||
yield path
|
||||
|
||||
|
||||
def create_backup_archive(
|
||||
output: Path,
|
||||
data_root: Path,
|
||||
workspace: Path,
|
||||
excluded_paths: Optional[Iterable[Path]] = None,
|
||||
) -> dict:
|
||||
"""Create a portable archive containing config and the agent workspace."""
|
||||
output = Path(output).expanduser().resolve()
|
||||
data_root = Path(data_root).expanduser().resolve()
|
||||
workspace = Path(workspace).expanduser().resolve()
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
excluded = {Path(path).expanduser().resolve() for path in (excluded_paths or [])}
|
||||
excluded.add(output)
|
||||
|
||||
config_path = data_root / "config.json"
|
||||
config = _read_config(data_root)
|
||||
legacy_path = _legacy_user_data_path(data_root, config)
|
||||
workspace_files = list(_iter_workspace_files(workspace, excluded))
|
||||
total_bytes = sum(path.stat().st_size for path in workspace_files)
|
||||
|
||||
manifest = {
|
||||
"format": BACKUP_FORMAT,
|
||||
"version": BACKUP_VERSION,
|
||||
"created_at": datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z"),
|
||||
"workspace_source": str(workspace),
|
||||
"contents": {
|
||||
"config": config_path.is_file(),
|
||||
"legacy_user_data": legacy_path.is_file(),
|
||||
"workspace_files": len(workspace_files),
|
||||
"workspace_bytes": total_bytes,
|
||||
},
|
||||
}
|
||||
|
||||
temp_dir = Path(tempfile.mkdtemp(prefix="cowagent-backup-"))
|
||||
temp_archive = temp_dir / "backup.zip"
|
||||
try:
|
||||
with zipfile.ZipFile(
|
||||
str(temp_archive), "w", compression=zipfile.ZIP_DEFLATED, allowZip64=True
|
||||
) as archive:
|
||||
archive.writestr(
|
||||
"manifest.json",
|
||||
json.dumps(manifest, ensure_ascii=False, indent=2) + "\n",
|
||||
)
|
||||
if config_path.is_file():
|
||||
archive.write(str(config_path), "data/config.json")
|
||||
if legacy_path.is_file():
|
||||
archive.write(str(legacy_path), "data/user_datas.pkl")
|
||||
for path in workspace_files:
|
||||
relative = path.relative_to(workspace).as_posix()
|
||||
archive.write(str(path), "workspace/" + relative)
|
||||
os.replace(str(temp_archive), str(output))
|
||||
try:
|
||||
os.chmod(str(output), stat.S_IRUSR | stat.S_IWUSR)
|
||||
except OSError:
|
||||
pass
|
||||
finally:
|
||||
shutil.rmtree(str(temp_dir), ignore_errors=True)
|
||||
|
||||
manifest["archive"] = str(output)
|
||||
return manifest
|
||||
|
||||
|
||||
def _validate_archive(archive: zipfile.ZipFile) -> dict:
|
||||
names = {info.filename for info in archive.infolist()}
|
||||
if "manifest.json" not in names:
|
||||
raise ValueError("archive is missing manifest.json")
|
||||
try:
|
||||
manifest = json.loads(archive.read("manifest.json").decode("utf-8"))
|
||||
except (ValueError, UnicodeDecodeError) as exc:
|
||||
raise ValueError("archive manifest is invalid") from exc
|
||||
if manifest.get("format") != BACKUP_FORMAT or manifest.get("version") != BACKUP_VERSION:
|
||||
raise ValueError("unsupported CowAgent backup format or version")
|
||||
|
||||
for info in archive.infolist():
|
||||
name = info.filename
|
||||
path = PurePosixPath(name)
|
||||
if not name or path.is_absolute() or ".." in path.parts or "\\" in name:
|
||||
raise ValueError(f"unsafe archive path: {name!r}")
|
||||
mode = (info.external_attr >> 16) & 0o170000
|
||||
if mode == stat.S_IFLNK:
|
||||
raise ValueError(f"symbolic links are not allowed in backups: {name!r}")
|
||||
if name != "manifest.json" and not name.startswith(("data/", "workspace/")):
|
||||
raise ValueError(f"unexpected archive entry: {name!r}")
|
||||
return manifest
|
||||
|
||||
|
||||
def _extract_validated(archive: zipfile.ZipFile, destination: Path) -> None:
|
||||
for info in archive.infolist():
|
||||
target = destination.joinpath(*PurePosixPath(info.filename).parts)
|
||||
if info.is_dir():
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
continue
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
with archive.open(info, "r") as source, target.open("wb") as output:
|
||||
shutil.copyfileobj(source, output)
|
||||
|
||||
|
||||
def _atomic_copy(source: Path, destination: Path, private: bool = False) -> None:
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, temp_name = tempfile.mkstemp(prefix=destination.name + ".", dir=str(destination.parent))
|
||||
os.close(fd)
|
||||
try:
|
||||
shutil.copy2(str(source), temp_name)
|
||||
os.replace(temp_name, str(destination))
|
||||
if private:
|
||||
try:
|
||||
os.chmod(str(destination), stat.S_IRUSR | stat.S_IWUSR)
|
||||
except OSError:
|
||||
pass
|
||||
finally:
|
||||
if os.path.exists(temp_name):
|
||||
os.remove(temp_name)
|
||||
|
||||
|
||||
def restore_backup_archive(
|
||||
archive_path: Path,
|
||||
data_root: Path,
|
||||
workspace: Optional[Path] = None,
|
||||
) -> dict:
|
||||
"""Merge a validated backup into the selected data root and workspace."""
|
||||
archive_path = Path(archive_path).expanduser().resolve()
|
||||
data_root = Path(data_root).expanduser().resolve()
|
||||
current_config = _read_config(data_root)
|
||||
|
||||
temp_dir = Path(tempfile.mkdtemp(prefix="cowagent-restore-"))
|
||||
try:
|
||||
with zipfile.ZipFile(str(archive_path), "r") as archive:
|
||||
manifest = _validate_archive(archive)
|
||||
_extract_validated(archive, temp_dir)
|
||||
|
||||
archived_config_path = temp_dir / "data" / "config.json"
|
||||
archived_config = {}
|
||||
if archived_config_path.is_file():
|
||||
with archived_config_path.open("r", encoding="utf-8") as handle:
|
||||
value = json.load(handle)
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError("archived config.json must contain an object")
|
||||
archived_config = value
|
||||
|
||||
if workspace is not None:
|
||||
target_workspace = Path(workspace).expanduser().resolve()
|
||||
elif current_config.get("agent_workspace"):
|
||||
target_workspace = _workspace_from_config(current_config)
|
||||
else:
|
||||
# Do not trust an archive-controlled absolute destination on a
|
||||
# fresh machine. Portable restores default to the standard local
|
||||
# workspace unless the operator supplies --workspace.
|
||||
target_workspace = Path("~/cow").expanduser().resolve()
|
||||
|
||||
restored_config = dict(archived_config)
|
||||
if restored_config:
|
||||
restored_config["agent_workspace"] = str(target_workspace)
|
||||
appdata_dir = restored_config.get("appdata_dir") or ""
|
||||
if appdata_dir:
|
||||
archived_appdata = (data_root / appdata_dir).resolve()
|
||||
if not _is_within(archived_appdata, data_root):
|
||||
# Keep legacy user data under the selected data root
|
||||
# instead of writing to an archive-controlled path.
|
||||
restored_config["appdata_dir"] = ""
|
||||
config_temp = temp_dir / "restored-config.json"
|
||||
with config_temp.open("w", encoding="utf-8") as handle:
|
||||
json.dump(restored_config, handle, ensure_ascii=False, indent=2)
|
||||
handle.write("\n")
|
||||
_atomic_copy(config_temp, data_root / "config.json", private=True)
|
||||
|
||||
workspace_root = temp_dir / "workspace"
|
||||
restored_files = 0
|
||||
if workspace_root.is_dir():
|
||||
for source in _iter_workspace_files(workspace_root, set()):
|
||||
relative = source.relative_to(workspace_root)
|
||||
destination = target_workspace / relative
|
||||
if not _is_within(destination, target_workspace):
|
||||
raise ValueError(f"unsafe workspace destination: {relative}")
|
||||
_atomic_copy(source, destination)
|
||||
restored_files += 1
|
||||
|
||||
legacy_source = temp_dir / "data" / "user_datas.pkl"
|
||||
if legacy_source.is_file():
|
||||
effective_config = restored_config or current_config
|
||||
legacy_destination = _legacy_user_data_path(data_root, effective_config)
|
||||
_atomic_copy(legacy_source, legacy_destination, private=True)
|
||||
|
||||
return {
|
||||
"manifest": manifest,
|
||||
"workspace": str(target_workspace),
|
||||
"workspace_files": restored_files,
|
||||
"config_restored": bool(restored_config),
|
||||
"legacy_user_data_restored": legacy_source.is_file(),
|
||||
}
|
||||
finally:
|
||||
shutil.rmtree(str(temp_dir), ignore_errors=True)
|
||||
|
||||
|
||||
@click.command("backup")
|
||||
@click.option(
|
||||
"--output",
|
||||
"-o",
|
||||
type=click.Path(dir_okay=False, path_type=Path),
|
||||
help="Output .zip path (default: ./cow-backup-<timestamp>.zip).",
|
||||
)
|
||||
def backup_command(output: Optional[Path]):
|
||||
"""Back up config, persona, memory, skills, knowledge, and schedules."""
|
||||
data_root = _data_root()
|
||||
config = _read_config(data_root)
|
||||
workspace = _workspace_from_config(config)
|
||||
if output is None:
|
||||
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
output = Path.cwd() / f"cow-backup-{stamp}.zip"
|
||||
result = create_backup_archive(output, data_root, workspace)
|
||||
click.echo(click.style("✓ Backup created", fg="green"))
|
||||
click.echo(f" Archive: {result['archive']}")
|
||||
click.echo(f" Workspace files: {result['contents']['workspace_files']}")
|
||||
click.echo(" Keep this archive private: it may contain API keys and personal data.")
|
||||
|
||||
|
||||
@click.command("restore")
|
||||
@click.argument("archive", type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
||||
@click.option(
|
||||
"--workspace",
|
||||
type=click.Path(file_okay=False, path_type=Path),
|
||||
help="Restore workspace files to this directory.",
|
||||
)
|
||||
@click.option("--yes", is_flag=True, help="Confirm overwriting matching files.")
|
||||
def restore_command(archive: Path, workspace: Optional[Path], yes: bool):
|
||||
"""Restore a backup without deleting unrelated destination files."""
|
||||
from cli.commands.process import _read_pid
|
||||
|
||||
pid = _read_pid()
|
||||
if pid:
|
||||
raise click.ClickException(
|
||||
f"CowAgent is running (PID: {pid}). Run 'cow stop' before restoring."
|
||||
)
|
||||
if not yes:
|
||||
click.confirm(
|
||||
"Restore this archive and overwrite matching config/workspace files?",
|
||||
abort=True,
|
||||
)
|
||||
|
||||
data_root = _data_root()
|
||||
current_config = _read_config(data_root)
|
||||
current_workspace = _workspace_from_config(current_config)
|
||||
has_current_data = (data_root / "config.json").is_file() or current_workspace.is_dir()
|
||||
if has_current_data:
|
||||
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
rollback = archive.resolve().parent / f"cow-pre-restore-{stamp}.zip"
|
||||
create_backup_archive(
|
||||
rollback,
|
||||
data_root,
|
||||
current_workspace,
|
||||
excluded_paths={archive.resolve()},
|
||||
)
|
||||
click.echo(f"Rollback backup: {rollback}")
|
||||
|
||||
result = restore_backup_archive(archive, data_root, workspace)
|
||||
click.echo(click.style("✓ Backup restored", fg="green"))
|
||||
click.echo(f" Workspace: {result['workspace']}")
|
||||
click.echo(f" Restored files: {result['workspace_files']}")
|
||||
51
docs/cli/backup.mdx
Normal file
51
docs/cli/backup.mdx
Normal file
@@ -0,0 +1,51 @@
|
||||
---
|
||||
title: Backup and Restore
|
||||
description: Export and restore CowAgent configuration and agent workspace data
|
||||
---
|
||||
|
||||
CowAgent can create a portable local archive for migration or disaster recovery.
|
||||
|
||||
## Create a backup
|
||||
|
||||
```bash
|
||||
cow backup
|
||||
cow backup --output /safe/location/cow-backup.zip
|
||||
```
|
||||
|
||||
The archive contains:
|
||||
|
||||
- `config.json`
|
||||
- Agent persona and user files such as `AGENT.md`, `USER.md`, and `MEMORY.md`
|
||||
- Daily memory, session history, knowledge, custom skills, and scheduled tasks in the configured workspace
|
||||
- Legacy `user_datas.pkl`, when present
|
||||
|
||||
Transient `tmp/` data, caches, Git metadata, and symbolic links are skipped. The
|
||||
archive is written with owner-only permissions where the operating system
|
||||
supports them.
|
||||
|
||||
<Warning>
|
||||
A backup may contain API keys, conversation history, and other personal data.
|
||||
Store and transfer it as a secret. The ZIP file is not encrypted.
|
||||
</Warning>
|
||||
|
||||
## Restore a backup
|
||||
|
||||
Stop CowAgent before restoring:
|
||||
|
||||
```bash
|
||||
cow stop
|
||||
cow restore /safe/location/cow-backup.zip
|
||||
```
|
||||
|
||||
Use `--workspace` to migrate workspace files to a different location:
|
||||
|
||||
```bash
|
||||
cow restore cow-backup.zip --workspace ~/cow-restored
|
||||
```
|
||||
|
||||
Restore validates the archive format and paths before writing anything. It
|
||||
overwrites matching files but does not delete unrelated files already present
|
||||
at the destination. When current CowAgent data exists, the command first
|
||||
creates a `cow-pre-restore-*.zip` rollback archive beside the selected backup.
|
||||
|
||||
For unattended scripts, pass `--yes` to acknowledge overwrites.
|
||||
@@ -44,6 +44,10 @@ Memory & Knowledge:
|
||||
memory Memory distillation (dream)
|
||||
knowledge View knowledge base stats and structure
|
||||
|
||||
Data portability:
|
||||
backup Back up config and agent workspace
|
||||
restore Restore a CowAgent backup
|
||||
|
||||
Others:
|
||||
help Show this help message
|
||||
version Show version
|
||||
@@ -90,6 +94,7 @@ In the Web console or any connected channel, type `/` to see command suggestions
|
||||
| start / stop / restart | ✓ | ✗ |
|
||||
| update | ✓ | ✗ |
|
||||
| install-browser | ✓ | ✗ |
|
||||
| backup / restore | ✓ | ✗ |
|
||||
|
||||
<Note>
|
||||
`context` only shows a hint in the terminal to use it in chat. `config` is only available in chat.
|
||||
|
||||
@@ -246,6 +246,7 @@
|
||||
"cli/process",
|
||||
"cli/skill",
|
||||
"cli/memory-knowledge",
|
||||
"cli/backup",
|
||||
"cli/general"
|
||||
]
|
||||
}
|
||||
@@ -473,6 +474,7 @@
|
||||
"zh/cli/process",
|
||||
"zh/cli/skill",
|
||||
"zh/cli/memory-knowledge",
|
||||
"zh/cli/backup",
|
||||
"zh/cli/general"
|
||||
]
|
||||
}
|
||||
|
||||
45
docs/zh/cli/backup.mdx
Normal file
45
docs/zh/cli/backup.mdx
Normal file
@@ -0,0 +1,45 @@
|
||||
---
|
||||
title: 备份与恢复
|
||||
description: 导出和恢复 CowAgent 配置及 Agent 工作区数据
|
||||
---
|
||||
|
||||
CowAgent 可以生成便携的本地归档,用于设备迁移或灾难恢复。
|
||||
|
||||
## 创建备份
|
||||
|
||||
```bash
|
||||
cow backup
|
||||
cow backup --output /safe/location/cow-backup.zip
|
||||
```
|
||||
|
||||
归档包含:
|
||||
|
||||
- `config.json`
|
||||
- `AGENT.md`、`USER.md`、`MEMORY.md` 等 Agent 人格和用户文件
|
||||
- 工作区内的每日记忆、会话历史、知识库、自定义技能和定时任务
|
||||
- 旧版 `user_datas.pkl`(如存在)
|
||||
|
||||
临时 `tmp/` 数据、缓存、Git 元数据和符号链接不会写入归档。在操作系统支持的情况下,归档文件权限会限制为仅当前用户可读写。
|
||||
|
||||
<Warning>
|
||||
备份中可能含有 API 密钥、对话历史及其他个人数据,应按密钥文件保存和传输。ZIP 文件本身没有加密。
|
||||
</Warning>
|
||||
|
||||
## 恢复备份
|
||||
|
||||
恢复前先停止 CowAgent:
|
||||
|
||||
```bash
|
||||
cow stop
|
||||
cow restore /safe/location/cow-backup.zip
|
||||
```
|
||||
|
||||
迁移到不同工作区时,可以显式指定路径:
|
||||
|
||||
```bash
|
||||
cow restore cow-backup.zip --workspace ~/cow-restored
|
||||
```
|
||||
|
||||
恢复命令会在写入前校验归档格式和所有路径。它会覆盖同名文件,但不会删除目标目录中无关的现有文件。如果检测到当前 CowAgent 数据,会先在所选归档旁创建 `cow-pre-restore-*.zip` 回滚备份。
|
||||
|
||||
无人值守脚本可传入 `--yes` 确认覆盖。
|
||||
@@ -44,6 +44,10 @@ Memory & Knowledge:
|
||||
memory Memory distillation (dream)
|
||||
knowledge View knowledge base stats and structure
|
||||
|
||||
Data portability:
|
||||
backup Back up config and agent workspace
|
||||
restore Restore a CowAgent backup
|
||||
|
||||
Others:
|
||||
help Show this help message
|
||||
version Show version
|
||||
@@ -92,6 +96,7 @@ Others:
|
||||
| start / stop / restart | ✓ | ✗ |
|
||||
| update | ✓ | ✗ |
|
||||
| install-browser | ✓ | ✗ |
|
||||
| backup / restore | ✓ | ✗ |
|
||||
|
||||
<Note>
|
||||
`context` 在终端中仅提示到对话中使用。`config` 仅支持在对话中修改。
|
||||
|
||||
110
tests/test_cli_backup.py
Normal file
110
tests/test_cli_backup.py
Normal file
@@ -0,0 +1,110 @@
|
||||
"""Tests for portable CowAgent backup archives."""
|
||||
|
||||
import json
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from cli.commands.backup import create_backup_archive, restore_backup_archive
|
||||
|
||||
|
||||
def _write_json(path: Path, value: dict):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(value), encoding="utf-8")
|
||||
|
||||
|
||||
def test_backup_restore_round_trip(tmp_path):
|
||||
source_data = tmp_path / "source-data"
|
||||
source_workspace = tmp_path / "source-workspace"
|
||||
_write_json(source_data / "config.json", {
|
||||
"agent_workspace": str(source_workspace),
|
||||
"open_ai_api_key": "secret-value",
|
||||
})
|
||||
(source_workspace / "memory").mkdir(parents=True)
|
||||
(source_workspace / "scheduler").mkdir()
|
||||
(source_workspace / "knowledge").mkdir()
|
||||
(source_workspace / "USER.md").write_text("# User\n", encoding="utf-8")
|
||||
(source_workspace / "MEMORY.md").write_text("remember this\n", encoding="utf-8")
|
||||
(source_workspace / "memory" / "2026-07-17.md").write_text("daily\n", encoding="utf-8")
|
||||
(source_workspace / "scheduler" / "tasks.json").write_text("{}\n", encoding="utf-8")
|
||||
(source_workspace / "knowledge" / "index.md").write_text("# Index\n", encoding="utf-8")
|
||||
(source_workspace / "tmp").mkdir()
|
||||
(source_workspace / "tmp" / "scratch.txt").write_text("skip", encoding="utf-8")
|
||||
|
||||
archive = tmp_path / "cow-backup.zip"
|
||||
summary = create_backup_archive(archive, source_data, source_workspace)
|
||||
assert summary["contents"]["workspace_files"] == 5
|
||||
assert archive.exists()
|
||||
|
||||
target_data = tmp_path / "target-data"
|
||||
target_workspace = tmp_path / "target-workspace"
|
||||
result = restore_backup_archive(archive, target_data, target_workspace)
|
||||
|
||||
restored_config = json.loads((target_data / "config.json").read_text(encoding="utf-8"))
|
||||
assert restored_config["agent_workspace"] == str(target_workspace.resolve())
|
||||
assert restored_config["open_ai_api_key"] == "secret-value"
|
||||
assert (target_workspace / "MEMORY.md").read_text(encoding="utf-8") == "remember this\n"
|
||||
assert (target_workspace / "scheduler" / "tasks.json").exists()
|
||||
assert (target_workspace / "knowledge" / "index.md").exists()
|
||||
assert not (target_workspace / "tmp" / "scratch.txt").exists()
|
||||
assert result["workspace_files"] == 5
|
||||
|
||||
|
||||
def test_restore_merges_without_deleting_unrelated_files(tmp_path):
|
||||
source_data = tmp_path / "source-data"
|
||||
source_workspace = tmp_path / "source-workspace"
|
||||
_write_json(source_data / "config.json", {"agent_workspace": str(source_workspace)})
|
||||
source_workspace.mkdir()
|
||||
(source_workspace / "MEMORY.md").write_text("new\n", encoding="utf-8")
|
||||
archive = tmp_path / "cow-backup.zip"
|
||||
create_backup_archive(archive, source_data, source_workspace)
|
||||
|
||||
target_workspace = tmp_path / "target-workspace"
|
||||
target_workspace.mkdir()
|
||||
(target_workspace / "MEMORY.md").write_text("old\n", encoding="utf-8")
|
||||
(target_workspace / "keep.txt").write_text("keep\n", encoding="utf-8")
|
||||
|
||||
restore_backup_archive(archive, tmp_path / "target-data", target_workspace)
|
||||
assert (target_workspace / "MEMORY.md").read_text(encoding="utf-8") == "new\n"
|
||||
assert (target_workspace / "keep.txt").read_text(encoding="utf-8") == "keep\n"
|
||||
|
||||
|
||||
def test_restore_rejects_path_traversal(tmp_path):
|
||||
archive = tmp_path / "malicious.zip"
|
||||
manifest = {"format": "cowagent-backup", "version": 1}
|
||||
with zipfile.ZipFile(str(archive), "w") as output:
|
||||
output.writestr("manifest.json", json.dumps(manifest))
|
||||
output.writestr("workspace/../../escape.txt", "nope")
|
||||
|
||||
with pytest.raises(ValueError, match="unsafe archive path"):
|
||||
restore_backup_archive(archive, tmp_path / "data", tmp_path / "workspace")
|
||||
|
||||
|
||||
def test_fresh_restore_ignores_archive_controlled_destinations(tmp_path, monkeypatch):
|
||||
source_data = tmp_path / "source-data"
|
||||
source_workspace = tmp_path / "source-workspace"
|
||||
outside_appdata = tmp_path / "outside-appdata"
|
||||
archive_workspace = tmp_path / "archive-controlled-workspace"
|
||||
_write_json(source_data / "config.json", {
|
||||
"agent_workspace": str(archive_workspace),
|
||||
"appdata_dir": str(outside_appdata),
|
||||
})
|
||||
source_workspace.mkdir()
|
||||
(source_workspace / "MEMORY.md").write_text("portable\n", encoding="utf-8")
|
||||
outside_appdata.mkdir()
|
||||
(outside_appdata / "user_datas.pkl").write_bytes(b"legacy")
|
||||
archive = tmp_path / "cow-backup.zip"
|
||||
create_backup_archive(archive, source_data, source_workspace)
|
||||
|
||||
fake_home = tmp_path / "home"
|
||||
monkeypatch.setenv("HOME", str(fake_home))
|
||||
target_data = tmp_path / "target-data"
|
||||
result = restore_backup_archive(archive, target_data)
|
||||
|
||||
assert result["workspace"] == str((fake_home / "cow").resolve())
|
||||
assert (fake_home / "cow" / "MEMORY.md").exists()
|
||||
assert not archive_workspace.exists()
|
||||
assert (target_data / "user_datas.pkl").read_bytes() == b"legacy"
|
||||
restored_config = json.loads((target_data / "config.json").read_text(encoding="utf-8"))
|
||||
assert restored_config["appdata_dir"] == ""
|
||||
Reference in New Issue
Block a user