Merge pull request #2957 from AaronZ345/feat/local-data-backup

feat(cli): add portable data backup and restore
This commit is contained in:
zhayujie
2026-07-19 12:02:25 +08:00
committed by GitHub
8 changed files with 555 additions and 0 deletions

View File

@@ -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
View 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
View 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.

View File

@@ -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.

View File

@@ -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
View 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` 确认覆盖。

View File

@@ -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
View 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"] == ""