tenfyzhong commited on
Commit
d8c63f6
·
unverified ·
1 Parent(s): 46b953b

feat(backup): include root tool directories in backup and restore (#15)

Browse files

* feat(backup): include root config in backup and restore

Signed-off-by: tenfyzhong <tenfy@tenfy.cn>

* feat(backup): include codex claude and agents directories

Signed-off-by: tenfyzhong <tenfy@tenfy.cn>

* feat(runtime): preinstall tools and extend backup roots

Signed-off-by: tenfyzhong <tenfy@tenfy.cn>

---------

Signed-off-by: tenfyzhong <tenfy@tenfy.cn>

Dockerfile CHANGED
@@ -12,16 +12,19 @@ RUN printf 'Acquire::Retries "5";\nAcquire::http::Timeout "30";\nAcquire::https:
12
  for attempt in 1 2 3 4 5; do \
13
  if apt-get update && apt-get install -y --no-install-recommends \
14
  ca-certificates \
 
15
  cmake \
16
  cron \
17
  curl \
18
  g++ \
 
19
  git \
20
  gnupg \
21
  gosu \
22
  hostname \
23
  jq \
24
  make \
 
25
  openssl \
26
  procps \
27
  python3 \
@@ -82,7 +85,14 @@ RUN /bin/bash -lc 'set -euo pipefail; \
82
  "/usr/local/bin/$cmd" --help >/dev/null; \
83
  done'
84
 
85
- RUN python3 -m pip install --no-cache-dir --break-system-packages "huggingface_hub[cli]>=0.31.1"
 
 
 
 
 
 
 
86
 
87
  RUN HOME=/root /bin/bash -lc "curl -sSf https://sshx.io/get | sh" && \
88
  if [ -x /root/.local/bin/sshx ]; then ln -sf /root/.local/bin/sshx /usr/local/bin/sshx; fi && \
@@ -119,6 +129,12 @@ ENV OPENCLAW_LLM_API=openai-completions
119
  ENV OPENCLAW_BACKUP_ENABLED=true
120
  ENV OPENCLAW_BACKUP_CRON="*/30 * * * *"
121
  ENV OPENCLAW_BACKUP_SOURCE_DIR=/root/.openclaw
 
 
 
 
 
 
122
  ENV OPENCLAW_SSHX_AUTO_START=false
123
  ENV OPENCLAW_BACKUP_PRIVATE=true
124
  ENV OPENCLAW_BACKUP_PATH_PREFIX=backups
 
12
  for attempt in 1 2 3 4 5; do \
13
  if apt-get update && apt-get install -y --no-install-recommends \
14
  ca-certificates \
15
+ chromium-headless-shell \
16
  cmake \
17
  cron \
18
  curl \
19
  g++ \
20
+ gh \
21
  git \
22
  gnupg \
23
  gosu \
24
  hostname \
25
  jq \
26
  make \
27
+ neovim \
28
  openssl \
29
  procps \
30
  python3 \
 
85
  "/usr/local/bin/$cmd" --help >/dev/null; \
86
  done'
87
 
88
+ RUN python3 -m pip install --no-cache-dir --break-system-packages "huggingface_hub[cli]>=0.31.1" "uv>=0.6.0" && \
89
+ hf --help >/dev/null && \
90
+ uv --version >/dev/null
91
+
92
+ RUN python3 --version >/dev/null && \
93
+ gh --version >/dev/null && \
94
+ nvim --version >/dev/null && \
95
+ command -v chromium-headless-shell >/dev/null
96
 
97
  RUN HOME=/root /bin/bash -lc "curl -sSf https://sshx.io/get | sh" && \
98
  if [ -x /root/.local/bin/sshx ]; then ln -sf /root/.local/bin/sshx /usr/local/bin/sshx; fi && \
 
129
  ENV OPENCLAW_BACKUP_ENABLED=true
130
  ENV OPENCLAW_BACKUP_CRON="*/30 * * * *"
131
  ENV OPENCLAW_BACKUP_SOURCE_DIR=/root/.openclaw
132
+ ENV OPENCLAW_BACKUP_ROOT_CONFIG_DIR=/root/.config
133
+ ENV OPENCLAW_BACKUP_ROOT_CODEX_DIR=/root/.codex
134
+ ENV OPENCLAW_BACKUP_ROOT_CLAUDE_DIR=/root/.claude
135
+ ENV OPENCLAW_BACKUP_ROOT_AGENTS_DIR=/root/.agents
136
+ ENV OPENCLAW_BACKUP_ROOT_SSH_DIR=/root/.ssh
137
+ ENV OPENCLAW_BACKUP_ROOT_NPM_DIR=/root/.npm
138
  ENV OPENCLAW_SSHX_AUTO_START=false
139
  ENV OPENCLAW_BACKUP_PRIVATE=true
140
  ENV OPENCLAW_BACKUP_PATH_PREFIX=backups
README.md CHANGED
@@ -18,7 +18,7 @@ This setup is designed to provide the following:
18
  - Store OpenClaw config/workspace under `/root/.openclaw`
19
  - Restore state automatically from a Hugging Face Dataset on startup
20
  - Run scheduled backups of OpenClaw data to a Hugging Face Dataset via `cron` (as `root` user)
21
- - Preinstall `vim`, `opencode`, `codex`, `claude` (Claude Code CLI), and `sshx` in the image for interactive terminal use
22
 
23
  ## Repository Layout
24
 
@@ -66,7 +66,13 @@ In that case, you can still configure from inside the container (for example via
66
  - `OPENCLAW_CONFIG_PATH` (default: `/root/.openclaw/openclaw.json`)
67
  - `OPENCLAW_WORKSPACE_DIR` (default: `/root/.openclaw/workspace`)
68
  - `OPENCLAW_BACKUP_CRON` (default: `*/30 * * * *`, backup every 30 minutes)
69
- - `OPENCLAW_BACKUP_SOURCE_DIR` (default: `/root/.openclaw`, backup/restore base directory)
 
 
 
 
 
 
70
  - `OPENCLAW_BACKUP_PATH_PREFIX` (default: `backups`)
71
  - `OPENCLAW_BACKUP_LATEST_NAME` (default: `latest-backup.tar.gz`)
72
  - `OPENCLAW_BACKUP_KEEP_COUNT` (default: `48`, keep newest N timestamped backup archives; older ones are auto-deleted)
@@ -295,11 +301,25 @@ For this project, if you need stable dashboard access without cold starts, use p
295
 
296
  ## Backup/Restore Flow
297
 
298
- - Startup restore: on container startup, it tries to fetch `latest-backup.tar.gz` from the Dataset and restore it to `OPENCLAW_BACKUP_SOURCE_DIR` (default `/root/.openclaw`)
 
 
 
 
 
 
 
299
  - Scheduled backup: cron runs based on `OPENCLAW_BACKUP_CRON`
300
  - Shutdown backup: when the container receives a stop signal, one final backup is uploaded before exit
301
  - Each backup upload includes:
302
  - `backups/openclaw-backup-<timestamp>.tar.gz`
 
 
 
 
 
 
 
303
  - `latest-backup.tar.gz`
304
  - `latest-backup.json`
305
  - Retention: after upload, only the newest `OPENCLAW_BACKUP_KEEP_COUNT` timestamped archives are kept (default `48`); older timestamped archives are deleted automatically
 
18
  - Store OpenClaw config/workspace under `/root/.openclaw`
19
  - Restore state automatically from a Hugging Face Dataset on startup
20
  - Run scheduled backups of OpenClaw data to a Hugging Face Dataset via `cron` (as `root` user)
21
+ - Preinstall `python3`, `uv`, `vim`, `neovim`, `chromium-headless-shell`, `gh`, `hf`, `opencode`, `codex`, `claude` (Claude Code CLI), and `sshx` in the image for interactive terminal use
22
 
23
  ## Repository Layout
24
 
 
66
  - `OPENCLAW_CONFIG_PATH` (default: `/root/.openclaw/openclaw.json`)
67
  - `OPENCLAW_WORKSPACE_DIR` (default: `/root/.openclaw/workspace`)
68
  - `OPENCLAW_BACKUP_CRON` (default: `*/30 * * * *`, backup every 30 minutes)
69
+ - `OPENCLAW_BACKUP_SOURCE_DIR` (default: `/root/.openclaw`, backup/restore base directory for `openclaw-state`)
70
+ - `OPENCLAW_BACKUP_ROOT_CONFIG_DIR` (default: `/root/.config`, additional backup/restore directory for `root-config`)
71
+ - `OPENCLAW_BACKUP_ROOT_CODEX_DIR` (default: `/root/.codex`, additional backup/restore directory for `root-codex`)
72
+ - `OPENCLAW_BACKUP_ROOT_CLAUDE_DIR` (default: `/root/.claude`, additional backup/restore directory for `root-claude`)
73
+ - `OPENCLAW_BACKUP_ROOT_AGENTS_DIR` (default: `/root/.agents`, additional backup/restore directory for `root-agents`)
74
+ - `OPENCLAW_BACKUP_ROOT_SSH_DIR` (default: `/root/.ssh`, additional backup/restore directory for `root-ssh`)
75
+ - `OPENCLAW_BACKUP_ROOT_NPM_DIR` (default: `/root/.npm`, additional backup/restore directory for `root-npm`)
76
  - `OPENCLAW_BACKUP_PATH_PREFIX` (default: `backups`)
77
  - `OPENCLAW_BACKUP_LATEST_NAME` (default: `latest-backup.tar.gz`)
78
  - `OPENCLAW_BACKUP_KEEP_COUNT` (default: `48`, keep newest N timestamped backup archives; older ones are auto-deleted)
 
301
 
302
  ## Backup/Restore Flow
303
 
304
+ - Startup restore: on container startup, it tries to fetch `latest-backup.tar.gz` from the Dataset and restore:
305
+ - `openclaw-state` -> `OPENCLAW_BACKUP_SOURCE_DIR` (default `/root/.openclaw`)
306
+ - `root-config` -> `OPENCLAW_BACKUP_ROOT_CONFIG_DIR` (default `/root/.config`, restored only when present in archive)
307
+ - `root-codex` -> `OPENCLAW_BACKUP_ROOT_CODEX_DIR` (default `/root/.codex`, restored only when present in archive)
308
+ - `root-claude` -> `OPENCLAW_BACKUP_ROOT_CLAUDE_DIR` (default `/root/.claude`, restored only when present in archive)
309
+ - `root-agents` -> `OPENCLAW_BACKUP_ROOT_AGENTS_DIR` (default `/root/.agents`, restored only when present in archive)
310
+ - `root-ssh` -> `OPENCLAW_BACKUP_ROOT_SSH_DIR` (default `/root/.ssh`, restored only when present in archive)
311
+ - `root-npm` -> `OPENCLAW_BACKUP_ROOT_NPM_DIR` (default `/root/.npm`, restored only when present in archive)
312
  - Scheduled backup: cron runs based on `OPENCLAW_BACKUP_CRON`
313
  - Shutdown backup: when the container receives a stop signal, one final backup is uploaded before exit
314
  - Each backup upload includes:
315
  - `backups/openclaw-backup-<timestamp>.tar.gz`
316
+ - archive root `openclaw-state/`
317
+ - archive root `root-config/` (included when `OPENCLAW_BACKUP_ROOT_CONFIG_DIR` exists)
318
+ - archive root `root-codex/` (included when `OPENCLAW_BACKUP_ROOT_CODEX_DIR` exists)
319
+ - archive root `root-claude/` (included when `OPENCLAW_BACKUP_ROOT_CLAUDE_DIR` exists)
320
+ - archive root `root-agents/` (included when `OPENCLAW_BACKUP_ROOT_AGENTS_DIR` exists)
321
+ - archive root `root-ssh/` (included when `OPENCLAW_BACKUP_ROOT_SSH_DIR` exists)
322
+ - archive root `root-npm/` (included when `OPENCLAW_BACKUP_ROOT_NPM_DIR` exists)
323
  - `latest-backup.tar.gz`
324
  - `latest-backup.json`
325
  - Retention: after upload, only the newest `OPENCLAW_BACKUP_KEEP_COUNT` timestamped archives are kept (default `48`); older timestamped archives are deleted automatically
openclaw_hf/backup.py CHANGED
@@ -48,6 +48,12 @@ class BackupConfig:
48
  path_prefix: str
49
  latest_name: str
50
  private: bool
 
 
 
 
 
 
51
  keep_count: int = 48
52
 
53
  @classmethod
@@ -62,6 +68,20 @@ class BackupConfig:
62
  backup_source_dir = Path(backup_source_raw).resolve()
63
  else:
64
  backup_source_dir = state_dir
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
  work_dir = Path(os.getenv("OPENCLAW_BACKUP_WORK_DIR", "/tmp/openclaw-backup")).resolve()
66
  repo_type = (os.getenv("OPENCLAW_BACKUP_REPO_TYPE") or "dataset").strip() or "dataset"
67
  path_prefix = (os.getenv("OPENCLAW_BACKUP_PATH_PREFIX") or "backups").strip("/")
@@ -94,6 +114,12 @@ class BackupConfig:
94
  path_prefix=path_prefix,
95
  latest_name=latest_name,
96
  private=private,
 
 
 
 
 
 
97
  keep_count=keep_count,
98
  )
99
 
@@ -121,6 +147,16 @@ class OpenClawBackup:
121
  exist_ok=True,
122
  )
123
 
 
 
 
 
 
 
 
 
 
 
124
  def create_archive(self, timestamp: Optional[str] = None) -> Path:
125
  if not self.config.backup_source_dir.exists():
126
  raise FileNotFoundError(f"backup source directory does not exist: {self.config.backup_source_dir}")
@@ -131,6 +167,9 @@ class OpenClawBackup:
131
  archive = self.config.work_dir / f"openclaw-backup-{stamp}.tar.gz"
132
  with tarfile.open(archive, "w:gz") as tar:
133
  tar.add(str(self.config.backup_source_dir), arcname="openclaw-state")
 
 
 
134
 
135
  return archive
136
 
@@ -162,6 +201,7 @@ class OpenClawBackup:
162
  "latest": self.config.latest_name,
163
  "created_at_utc": utc_now().isoformat(timespec="seconds"),
164
  "state_root": "openclaw-state",
 
165
  }
166
  self.api.upload_file(
167
  repo_id=self.config.dataset_repo,
@@ -294,6 +334,13 @@ class OpenClawBackup:
294
 
295
  self.replace_dir_contents(extracted_state, self.config.backup_source_dir)
296
 
 
 
 
 
 
 
 
297
  print(f"restore completed from {self.config.dataset_repo}/{self.config.latest_name}")
298
  return True
299
 
 
48
  path_prefix: str
49
  latest_name: str
50
  private: bool
51
+ root_config_dir: Path = Path("/root/.config").resolve()
52
+ root_codex_dir: Path = Path("/root/.codex").resolve()
53
+ root_claude_dir: Path = Path("/root/.claude").resolve()
54
+ root_agents_dir: Path = Path("/root/.agents").resolve()
55
+ root_ssh_dir: Path = Path("/root/.ssh").resolve()
56
+ root_npm_dir: Path = Path("/root/.npm").resolve()
57
  keep_count: int = 48
58
 
59
  @classmethod
 
68
  backup_source_dir = Path(backup_source_raw).resolve()
69
  else:
70
  backup_source_dir = state_dir
71
+ root_config_dir = Path(
72
+ (os.getenv("OPENCLAW_BACKUP_ROOT_CONFIG_DIR") or "/root/.config").strip() or "/root/.config"
73
+ ).resolve()
74
+ root_codex_dir = Path(
75
+ (os.getenv("OPENCLAW_BACKUP_ROOT_CODEX_DIR") or "/root/.codex").strip() or "/root/.codex"
76
+ ).resolve()
77
+ root_claude_dir = Path(
78
+ (os.getenv("OPENCLAW_BACKUP_ROOT_CLAUDE_DIR") or "/root/.claude").strip() or "/root/.claude"
79
+ ).resolve()
80
+ root_agents_dir = Path(
81
+ (os.getenv("OPENCLAW_BACKUP_ROOT_AGENTS_DIR") or "/root/.agents").strip() or "/root/.agents"
82
+ ).resolve()
83
+ root_ssh_dir = Path((os.getenv("OPENCLAW_BACKUP_ROOT_SSH_DIR") or "/root/.ssh").strip() or "/root/.ssh").resolve()
84
+ root_npm_dir = Path((os.getenv("OPENCLAW_BACKUP_ROOT_NPM_DIR") or "/root/.npm").strip() or "/root/.npm").resolve()
85
  work_dir = Path(os.getenv("OPENCLAW_BACKUP_WORK_DIR", "/tmp/openclaw-backup")).resolve()
86
  repo_type = (os.getenv("OPENCLAW_BACKUP_REPO_TYPE") or "dataset").strip() or "dataset"
87
  path_prefix = (os.getenv("OPENCLAW_BACKUP_PATH_PREFIX") or "backups").strip("/")
 
114
  path_prefix=path_prefix,
115
  latest_name=latest_name,
116
  private=private,
117
+ root_config_dir=root_config_dir,
118
+ root_codex_dir=root_codex_dir,
119
+ root_claude_dir=root_claude_dir,
120
+ root_agents_dir=root_agents_dir,
121
+ root_ssh_dir=root_ssh_dir,
122
+ root_npm_dir=root_npm_dir,
123
  keep_count=keep_count,
124
  )
125
 
 
147
  exist_ok=True,
148
  )
149
 
150
+ def _extra_root_dirs(self) -> dict[str, Path]:
151
+ return {
152
+ "root-config": self.config.root_config_dir,
153
+ "root-codex": self.config.root_codex_dir,
154
+ "root-claude": self.config.root_claude_dir,
155
+ "root-agents": self.config.root_agents_dir,
156
+ "root-ssh": self.config.root_ssh_dir,
157
+ "root-npm": self.config.root_npm_dir,
158
+ }
159
+
160
  def create_archive(self, timestamp: Optional[str] = None) -> Path:
161
  if not self.config.backup_source_dir.exists():
162
  raise FileNotFoundError(f"backup source directory does not exist: {self.config.backup_source_dir}")
 
167
  archive = self.config.work_dir / f"openclaw-backup-{stamp}.tar.gz"
168
  with tarfile.open(archive, "w:gz") as tar:
169
  tar.add(str(self.config.backup_source_dir), arcname="openclaw-state")
170
+ for archive_root, local_dir in self._extra_root_dirs().items():
171
+ if local_dir.exists():
172
+ tar.add(str(local_dir), arcname=archive_root)
173
 
174
  return archive
175
 
 
201
  "latest": self.config.latest_name,
202
  "created_at_utc": utc_now().isoformat(timespec="seconds"),
203
  "state_root": "openclaw-state",
204
+ "extra_roots": sorted(self._extra_root_dirs().keys()),
205
  }
206
  self.api.upload_file(
207
  repo_id=self.config.dataset_repo,
 
334
 
335
  self.replace_dir_contents(extracted_state, self.config.backup_source_dir)
336
 
337
+ for archive_root, local_dir in self._extra_root_dirs().items():
338
+ extracted_extra_dir = tmp_dir / archive_root
339
+ if extracted_extra_dir.exists():
340
+ if not extracted_extra_dir.is_dir():
341
+ raise RuntimeError(f"backup archive {archive_root} is not a directory")
342
+ self.replace_dir_contents(extracted_extra_dir, local_dir)
343
+
344
  print(f"restore completed from {self.config.dataset_repo}/{self.config.latest_name}")
345
  return True
346
 
scripts/openclaw-entrypoint.sh CHANGED
@@ -19,6 +19,12 @@ OPENCLAW_LLM_API_KEY_ENV="${OPENCLAW_LLM_API_KEY_ENV:-OPENCLAW_LLM_API_KEY}"
19
  OPENCLAW_BACKUP_ENABLED="${OPENCLAW_BACKUP_ENABLED:-true}"
20
  OPENCLAW_BACKUP_CRON="${OPENCLAW_BACKUP_CRON:-*/30 * * * *}"
21
  OPENCLAW_BACKUP_SOURCE_DIR="${OPENCLAW_BACKUP_SOURCE_DIR:-${OPENCLAW_STATE_DIR}}"
 
 
 
 
 
 
22
  OPENCLAW_BACKUP_ENV_FILE_PATH="${OPENCLAW_BACKUP_ENV_FILE_PATH:-/etc/openclaw-backup.env}"
23
  OPENCLAW_STDOUT_LOG_PATH="${OPENCLAW_STDOUT_LOG_PATH:-/var/log/openclaw/gateway.stdout.log}"
24
  OPENCLAW_STDERR_LOG_PATH="${OPENCLAW_STDERR_LOG_PATH:-/var/log/openclaw/gateway.stderr.log}"
@@ -337,6 +343,12 @@ write_backup_env_file() {
337
  OPENCLAW_BACKUP_PRIVATE
338
  OPENCLAW_BACKUP_WORK_DIR
339
  OPENCLAW_BACKUP_SOURCE_DIR
 
 
 
 
 
 
340
  OPENCLAW_STATE_DIR
341
  )
342
 
 
19
  OPENCLAW_BACKUP_ENABLED="${OPENCLAW_BACKUP_ENABLED:-true}"
20
  OPENCLAW_BACKUP_CRON="${OPENCLAW_BACKUP_CRON:-*/30 * * * *}"
21
  OPENCLAW_BACKUP_SOURCE_DIR="${OPENCLAW_BACKUP_SOURCE_DIR:-${OPENCLAW_STATE_DIR}}"
22
+ OPENCLAW_BACKUP_ROOT_CONFIG_DIR="${OPENCLAW_BACKUP_ROOT_CONFIG_DIR:-/root/.config}"
23
+ OPENCLAW_BACKUP_ROOT_CODEX_DIR="${OPENCLAW_BACKUP_ROOT_CODEX_DIR:-/root/.codex}"
24
+ OPENCLAW_BACKUP_ROOT_CLAUDE_DIR="${OPENCLAW_BACKUP_ROOT_CLAUDE_DIR:-/root/.claude}"
25
+ OPENCLAW_BACKUP_ROOT_AGENTS_DIR="${OPENCLAW_BACKUP_ROOT_AGENTS_DIR:-/root/.agents}"
26
+ OPENCLAW_BACKUP_ROOT_SSH_DIR="${OPENCLAW_BACKUP_ROOT_SSH_DIR:-/root/.ssh}"
27
+ OPENCLAW_BACKUP_ROOT_NPM_DIR="${OPENCLAW_BACKUP_ROOT_NPM_DIR:-/root/.npm}"
28
  OPENCLAW_BACKUP_ENV_FILE_PATH="${OPENCLAW_BACKUP_ENV_FILE_PATH:-/etc/openclaw-backup.env}"
29
  OPENCLAW_STDOUT_LOG_PATH="${OPENCLAW_STDOUT_LOG_PATH:-/var/log/openclaw/gateway.stdout.log}"
30
  OPENCLAW_STDERR_LOG_PATH="${OPENCLAW_STDERR_LOG_PATH:-/var/log/openclaw/gateway.stderr.log}"
 
343
  OPENCLAW_BACKUP_PRIVATE
344
  OPENCLAW_BACKUP_WORK_DIR
345
  OPENCLAW_BACKUP_SOURCE_DIR
346
+ OPENCLAW_BACKUP_ROOT_CONFIG_DIR
347
+ OPENCLAW_BACKUP_ROOT_CODEX_DIR
348
+ OPENCLAW_BACKUP_ROOT_CLAUDE_DIR
349
+ OPENCLAW_BACKUP_ROOT_AGENTS_DIR
350
+ OPENCLAW_BACKUP_ROOT_SSH_DIR
351
+ OPENCLAW_BACKUP_ROOT_NPM_DIR
352
  OPENCLAW_STATE_DIR
353
  )
354
 
tests/test_backup.py CHANGED
@@ -39,6 +39,12 @@ class BackupTests(unittest.TestCase):
39
 
40
  self.assertEqual(config.state_dir, Path("/root/.openclaw").resolve())
41
  self.assertEqual(config.backup_source_dir, config.state_dir)
 
 
 
 
 
 
42
  self.assertEqual(config.keep_count, 48)
43
 
44
  def test_from_env_defaults_backup_source_to_explicit_state_dir_when_unset(self):
@@ -73,6 +79,52 @@ class BackupTests(unittest.TestCase):
73
 
74
  self.assertEqual(config.backup_source_dir, backup_source.resolve())
75
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
  def test_from_env_uses_explicit_backup_keep_count(self):
77
  env = {
78
  "OPENCLAW_BACKUP_DATASET_REPO": "user/demo-backup",
@@ -94,6 +146,7 @@ class BackupTests(unittest.TestCase):
94
  dataset_repo="user/demo-backup",
95
  state_dir=state_dir,
96
  backup_source_dir=state_dir,
 
97
  work_dir=tmp_path / "work",
98
  repo_type="dataset",
99
  path_prefix="backups",
@@ -124,6 +177,7 @@ class BackupTests(unittest.TestCase):
124
  dataset_repo="user/demo-backup",
125
  state_dir=state_dir,
126
  backup_source_dir=source_dir,
 
127
  work_dir=tmp_path / "work",
128
  repo_type="dataset",
129
  path_prefix="backups",
@@ -140,6 +194,63 @@ class BackupTests(unittest.TestCase):
140
 
141
  self.assertIn("openclaw-state/from-home-node.txt", members)
142
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
143
  def test_upload_backup_writes_timestamp_and_latest(self):
144
  with tempfile.TemporaryDirectory() as tmp:
145
  tmp_path = Path(tmp)
@@ -151,6 +262,7 @@ class BackupTests(unittest.TestCase):
151
  dataset_repo="user/demo-backup",
152
  state_dir=tmp_path / "state",
153
  backup_source_dir=tmp_path / "state",
 
154
  work_dir=tmp_path / "work",
155
  repo_type="dataset",
156
  path_prefix="backups",
@@ -189,6 +301,7 @@ class BackupTests(unittest.TestCase):
189
  dataset_repo="user/demo-backup",
190
  state_dir=tmp_path / "state",
191
  backup_source_dir=tmp_path / "state",
 
192
  work_dir=tmp_path / "work",
193
  repo_type="dataset",
194
  path_prefix="backups",
@@ -209,6 +322,166 @@ class BackupTests(unittest.TestCase):
209
  ],
210
  )
211
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
212
  def test_replace_dir_contents_clears_old_files(self):
213
  with tempfile.TemporaryDirectory() as tmp:
214
  tmp_path = Path(tmp)
 
39
 
40
  self.assertEqual(config.state_dir, Path("/root/.openclaw").resolve())
41
  self.assertEqual(config.backup_source_dir, config.state_dir)
42
+ self.assertEqual(config.root_config_dir, Path("/root/.config").resolve())
43
+ self.assertEqual(config.root_codex_dir, Path("/root/.codex").resolve())
44
+ self.assertEqual(config.root_claude_dir, Path("/root/.claude").resolve())
45
+ self.assertEqual(config.root_agents_dir, Path("/root/.agents").resolve())
46
+ self.assertEqual(config.root_ssh_dir, Path("/root/.ssh").resolve())
47
+ self.assertEqual(config.root_npm_dir, Path("/root/.npm").resolve())
48
  self.assertEqual(config.keep_count, 48)
49
 
50
  def test_from_env_defaults_backup_source_to_explicit_state_dir_when_unset(self):
 
79
 
80
  self.assertEqual(config.backup_source_dir, backup_source.resolve())
81
 
82
+ def test_from_env_uses_explicit_root_config_dir(self):
83
+ with tempfile.TemporaryDirectory() as tmp:
84
+ tmp_path = Path(tmp)
85
+ root_config = tmp_path / "root-config"
86
+ root_config.mkdir()
87
+
88
+ env = {
89
+ "OPENCLAW_BACKUP_DATASET_REPO": "user/demo-backup",
90
+ "OPENCLAW_BACKUP_ROOT_CONFIG_DIR": str(root_config),
91
+ }
92
+ with mock.patch.dict("os.environ", env, clear=True):
93
+ config = BackupConfig.from_env()
94
+
95
+ self.assertEqual(config.root_config_dir, root_config.resolve())
96
+
97
+ def test_from_env_uses_explicit_other_root_dirs(self):
98
+ with tempfile.TemporaryDirectory() as tmp:
99
+ tmp_path = Path(tmp)
100
+ root_codex = tmp_path / "root-codex"
101
+ root_claude = tmp_path / "root-claude"
102
+ root_agents = tmp_path / "root-agents"
103
+ root_ssh = tmp_path / "root-ssh"
104
+ root_npm = tmp_path / "root-npm"
105
+ root_codex.mkdir()
106
+ root_claude.mkdir()
107
+ root_agents.mkdir()
108
+ root_ssh.mkdir()
109
+ root_npm.mkdir()
110
+
111
+ env = {
112
+ "OPENCLAW_BACKUP_DATASET_REPO": "user/demo-backup",
113
+ "OPENCLAW_BACKUP_ROOT_CODEX_DIR": str(root_codex),
114
+ "OPENCLAW_BACKUP_ROOT_CLAUDE_DIR": str(root_claude),
115
+ "OPENCLAW_BACKUP_ROOT_AGENTS_DIR": str(root_agents),
116
+ "OPENCLAW_BACKUP_ROOT_SSH_DIR": str(root_ssh),
117
+ "OPENCLAW_BACKUP_ROOT_NPM_DIR": str(root_npm),
118
+ }
119
+ with mock.patch.dict("os.environ", env, clear=True):
120
+ config = BackupConfig.from_env()
121
+
122
+ self.assertEqual(config.root_codex_dir, root_codex.resolve())
123
+ self.assertEqual(config.root_claude_dir, root_claude.resolve())
124
+ self.assertEqual(config.root_agents_dir, root_agents.resolve())
125
+ self.assertEqual(config.root_ssh_dir, root_ssh.resolve())
126
+ self.assertEqual(config.root_npm_dir, root_npm.resolve())
127
+
128
  def test_from_env_uses_explicit_backup_keep_count(self):
129
  env = {
130
  "OPENCLAW_BACKUP_DATASET_REPO": "user/demo-backup",
 
146
  dataset_repo="user/demo-backup",
147
  state_dir=state_dir,
148
  backup_source_dir=state_dir,
149
+ root_config_dir=tmp_path / "root-config",
150
  work_dir=tmp_path / "work",
151
  repo_type="dataset",
152
  path_prefix="backups",
 
177
  dataset_repo="user/demo-backup",
178
  state_dir=state_dir,
179
  backup_source_dir=source_dir,
180
+ root_config_dir=tmp_path / "root-config",
181
  work_dir=tmp_path / "work",
182
  repo_type="dataset",
183
  path_prefix="backups",
 
194
 
195
  self.assertIn("openclaw-state/from-home-node.txt", members)
196
 
197
+ def test_create_archive_contains_extra_root_trees_when_exist(self):
198
+ with tempfile.TemporaryDirectory() as tmp:
199
+ tmp_path = Path(tmp)
200
+ state_dir = tmp_path / "state"
201
+ root_config_dir = tmp_path / "root-config"
202
+ root_codex_dir = tmp_path / "root-codex"
203
+ root_claude_dir = tmp_path / "root-claude"
204
+ root_agents_dir = tmp_path / "root-agents"
205
+ root_ssh_dir = tmp_path / "root-ssh"
206
+ root_npm_dir = tmp_path / "root-npm"
207
+ state_dir.mkdir(parents=True)
208
+ root_config_dir.mkdir(parents=True)
209
+ root_codex_dir.mkdir(parents=True)
210
+ root_claude_dir.mkdir(parents=True)
211
+ root_agents_dir.mkdir(parents=True)
212
+ root_ssh_dir.mkdir(parents=True)
213
+ root_npm_dir.mkdir(parents=True)
214
+ (state_dir / "state.txt").write_text("state\n", encoding="utf-8")
215
+ (root_config_dir / "settings.json").write_text('{"key":"value"}\n', encoding="utf-8")
216
+ (root_codex_dir / "config.toml").write_text("theme = \"dark\"\n", encoding="utf-8")
217
+ (root_claude_dir / "profile.json").write_text('{"provider":"claude"}\n', encoding="utf-8")
218
+ (root_agents_dir / "agent.yaml").write_text("name: default\n", encoding="utf-8")
219
+ (root_ssh_dir / "config").write_text("Host *\n ServerAliveInterval 60\n", encoding="utf-8")
220
+ (root_npm_dir / "cache-index.json").write_text('{"npm":"cache"}\n', encoding="utf-8")
221
+
222
+ config = BackupConfig(
223
+ dataset_repo="user/demo-backup",
224
+ state_dir=state_dir,
225
+ backup_source_dir=state_dir,
226
+ root_config_dir=root_config_dir,
227
+ root_codex_dir=root_codex_dir,
228
+ root_claude_dir=root_claude_dir,
229
+ root_agents_dir=root_agents_dir,
230
+ root_ssh_dir=root_ssh_dir,
231
+ root_npm_dir=root_npm_dir,
232
+ work_dir=tmp_path / "work",
233
+ repo_type="dataset",
234
+ path_prefix="backups",
235
+ latest_name="latest-backup.tar.gz",
236
+ private=True,
237
+ )
238
+ runner = OpenClawBackup(config=config, api=FakeApi(), token="hf_test")
239
+
240
+ archive = runner.create_archive(timestamp="20260324-120000")
241
+ self.assertTrue(archive.exists())
242
+
243
+ with tarfile.open(archive, "r:gz") as tar:
244
+ members = tar.getnames()
245
+
246
+ self.assertIn("openclaw-state/state.txt", members)
247
+ self.assertIn("root-config/settings.json", members)
248
+ self.assertIn("root-codex/config.toml", members)
249
+ self.assertIn("root-claude/profile.json", members)
250
+ self.assertIn("root-agents/agent.yaml", members)
251
+ self.assertIn("root-ssh/config", members)
252
+ self.assertIn("root-npm/cache-index.json", members)
253
+
254
  def test_upload_backup_writes_timestamp_and_latest(self):
255
  with tempfile.TemporaryDirectory() as tmp:
256
  tmp_path = Path(tmp)
 
262
  dataset_repo="user/demo-backup",
263
  state_dir=tmp_path / "state",
264
  backup_source_dir=tmp_path / "state",
265
+ root_config_dir=tmp_path / "root-config",
266
  work_dir=tmp_path / "work",
267
  repo_type="dataset",
268
  path_prefix="backups",
 
301
  dataset_repo="user/demo-backup",
302
  state_dir=tmp_path / "state",
303
  backup_source_dir=tmp_path / "state",
304
+ root_config_dir=tmp_path / "root-config",
305
  work_dir=tmp_path / "work",
306
  repo_type="dataset",
307
  path_prefix="backups",
 
322
  ],
323
  )
324
 
325
+ def test_restore_restores_extra_root_dirs_when_present_in_archive(self):
326
+ with tempfile.TemporaryDirectory() as tmp:
327
+ tmp_path = Path(tmp)
328
+ state_target = tmp_path / "state-target"
329
+ root_config_target = tmp_path / "root-config-target"
330
+ root_codex_target = tmp_path / "root-codex-target"
331
+ root_claude_target = tmp_path / "root-claude-target"
332
+ root_agents_target = tmp_path / "root-agents-target"
333
+ root_ssh_target = tmp_path / "root-ssh-target"
334
+ root_npm_target = tmp_path / "root-npm-target"
335
+ state_target.mkdir(parents=True)
336
+ root_config_target.mkdir(parents=True)
337
+ root_codex_target.mkdir(parents=True)
338
+ root_claude_target.mkdir(parents=True)
339
+ root_agents_target.mkdir(parents=True)
340
+ root_ssh_target.mkdir(parents=True)
341
+ root_npm_target.mkdir(parents=True)
342
+ (state_target / "old-state.txt").write_text("old-state\n", encoding="utf-8")
343
+ (root_config_target / "old-config.json").write_text('{"old":true}\n', encoding="utf-8")
344
+ (root_codex_target / "old-codex.toml").write_text("old = true\n", encoding="utf-8")
345
+ (root_claude_target / "old-claude.json").write_text('{"old":true}\n', encoding="utf-8")
346
+ (root_agents_target / "old-agent.yaml").write_text("old: true\n", encoding="utf-8")
347
+ (root_ssh_target / "old-ssh-config").write_text("Host old\n", encoding="utf-8")
348
+ (root_npm_target / "old-cache.json").write_text('{"old":"cache"}\n', encoding="utf-8")
349
+
350
+ restore_payload = tmp_path / "restore-payload"
351
+ restore_state = restore_payload / "openclaw-state"
352
+ restore_root_config = restore_payload / "root-config"
353
+ restore_root_codex = restore_payload / "root-codex"
354
+ restore_root_claude = restore_payload / "root-claude"
355
+ restore_root_agents = restore_payload / "root-agents"
356
+ restore_root_ssh = restore_payload / "root-ssh"
357
+ restore_root_npm = restore_payload / "root-npm"
358
+ restore_state.mkdir(parents=True)
359
+ restore_root_config.mkdir(parents=True)
360
+ restore_root_codex.mkdir(parents=True)
361
+ restore_root_claude.mkdir(parents=True)
362
+ restore_root_agents.mkdir(parents=True)
363
+ restore_root_ssh.mkdir(parents=True)
364
+ restore_root_npm.mkdir(parents=True)
365
+ (restore_state / "new-state.txt").write_text("new-state\n", encoding="utf-8")
366
+ (restore_root_config / "new-config.json").write_text('{"new":true}\n', encoding="utf-8")
367
+ (restore_root_codex / "new-codex.toml").write_text("new = true\n", encoding="utf-8")
368
+ (restore_root_claude / "new-claude.json").write_text('{"new":true}\n', encoding="utf-8")
369
+ (restore_root_agents / "new-agent.yaml").write_text("new: true\n", encoding="utf-8")
370
+ (restore_root_ssh / "config").write_text("Host *\n", encoding="utf-8")
371
+ (restore_root_npm / "cache.json").write_text('{"new":"cache"}\n', encoding="utf-8")
372
+
373
+ archive = tmp_path / "latest-backup.tar.gz"
374
+ with tarfile.open(archive, "w:gz") as tar:
375
+ tar.add(str(restore_state), arcname="openclaw-state")
376
+ tar.add(str(restore_root_config), arcname="root-config")
377
+ tar.add(str(restore_root_codex), arcname="root-codex")
378
+ tar.add(str(restore_root_claude), arcname="root-claude")
379
+ tar.add(str(restore_root_agents), arcname="root-agents")
380
+ tar.add(str(restore_root_ssh), arcname="root-ssh")
381
+ tar.add(str(restore_root_npm), arcname="root-npm")
382
+
383
+ config = BackupConfig(
384
+ dataset_repo="user/demo-backup",
385
+ state_dir=state_target,
386
+ backup_source_dir=state_target,
387
+ root_config_dir=root_config_target,
388
+ root_codex_dir=root_codex_target,
389
+ root_claude_dir=root_claude_target,
390
+ root_agents_dir=root_agents_target,
391
+ root_ssh_dir=root_ssh_target,
392
+ root_npm_dir=root_npm_target,
393
+ work_dir=tmp_path / "work",
394
+ repo_type="dataset",
395
+ path_prefix="backups",
396
+ latest_name="latest-backup.tar.gz",
397
+ private=True,
398
+ )
399
+ runner = OpenClawBackup(config=config, api=FakeApi(), token="hf_test")
400
+
401
+ with mock.patch.object(runner, "download_latest_archive", return_value=archive):
402
+ restored = runner.restore()
403
+
404
+ self.assertTrue(restored)
405
+ self.assertTrue((state_target / "new-state.txt").exists())
406
+ self.assertFalse((state_target / "old-state.txt").exists())
407
+ self.assertTrue((root_config_target / "new-config.json").exists())
408
+ self.assertFalse((root_config_target / "old-config.json").exists())
409
+ self.assertTrue((root_codex_target / "new-codex.toml").exists())
410
+ self.assertFalse((root_codex_target / "old-codex.toml").exists())
411
+ self.assertTrue((root_claude_target / "new-claude.json").exists())
412
+ self.assertFalse((root_claude_target / "old-claude.json").exists())
413
+ self.assertTrue((root_agents_target / "new-agent.yaml").exists())
414
+ self.assertFalse((root_agents_target / "old-agent.yaml").exists())
415
+ self.assertTrue((root_ssh_target / "config").exists())
416
+ self.assertFalse((root_ssh_target / "old-ssh-config").exists())
417
+ self.assertTrue((root_npm_target / "cache.json").exists())
418
+ self.assertFalse((root_npm_target / "old-cache.json").exists())
419
+
420
+ def test_restore_keeps_extra_root_dirs_untouched_when_archive_missing_them(self):
421
+ with tempfile.TemporaryDirectory() as tmp:
422
+ tmp_path = Path(tmp)
423
+ state_target = tmp_path / "state-target"
424
+ root_config_target = tmp_path / "root-config-target"
425
+ root_codex_target = tmp_path / "root-codex-target"
426
+ root_claude_target = tmp_path / "root-claude-target"
427
+ root_agents_target = tmp_path / "root-agents-target"
428
+ root_ssh_target = tmp_path / "root-ssh-target"
429
+ root_npm_target = tmp_path / "root-npm-target"
430
+ state_target.mkdir(parents=True)
431
+ root_config_target.mkdir(parents=True)
432
+ root_codex_target.mkdir(parents=True)
433
+ root_claude_target.mkdir(parents=True)
434
+ root_agents_target.mkdir(parents=True)
435
+ root_ssh_target.mkdir(parents=True)
436
+ root_npm_target.mkdir(parents=True)
437
+ (state_target / "old-state.txt").write_text("old-state\n", encoding="utf-8")
438
+ (root_config_target / "old-config.json").write_text('{"old":true}\n', encoding="utf-8")
439
+ (root_codex_target / "old-codex.toml").write_text("old = true\n", encoding="utf-8")
440
+ (root_claude_target / "old-claude.json").write_text('{"old":true}\n', encoding="utf-8")
441
+ (root_agents_target / "old-agent.yaml").write_text("old: true\n", encoding="utf-8")
442
+ (root_ssh_target / "old-ssh-config").write_text("Host old\n", encoding="utf-8")
443
+ (root_npm_target / "old-cache.json").write_text('{"old":"cache"}\n', encoding="utf-8")
444
+
445
+ restore_payload = tmp_path / "restore-payload"
446
+ restore_state = restore_payload / "openclaw-state"
447
+ restore_state.mkdir(parents=True)
448
+ (restore_state / "new-state.txt").write_text("new-state\n", encoding="utf-8")
449
+
450
+ archive = tmp_path / "latest-backup.tar.gz"
451
+ with tarfile.open(archive, "w:gz") as tar:
452
+ tar.add(str(restore_state), arcname="openclaw-state")
453
+
454
+ config = BackupConfig(
455
+ dataset_repo="user/demo-backup",
456
+ state_dir=state_target,
457
+ backup_source_dir=state_target,
458
+ root_config_dir=root_config_target,
459
+ root_codex_dir=root_codex_target,
460
+ root_claude_dir=root_claude_target,
461
+ root_agents_dir=root_agents_target,
462
+ root_ssh_dir=root_ssh_target,
463
+ root_npm_dir=root_npm_target,
464
+ work_dir=tmp_path / "work",
465
+ repo_type="dataset",
466
+ path_prefix="backups",
467
+ latest_name="latest-backup.tar.gz",
468
+ private=True,
469
+ )
470
+ runner = OpenClawBackup(config=config, api=FakeApi(), token="hf_test")
471
+
472
+ with mock.patch.object(runner, "download_latest_archive", return_value=archive):
473
+ restored = runner.restore()
474
+
475
+ self.assertTrue(restored)
476
+ self.assertTrue((state_target / "new-state.txt").exists())
477
+ self.assertFalse((state_target / "old-state.txt").exists())
478
+ self.assertTrue((root_config_target / "old-config.json").exists())
479
+ self.assertTrue((root_codex_target / "old-codex.toml").exists())
480
+ self.assertTrue((root_claude_target / "old-claude.json").exists())
481
+ self.assertTrue((root_agents_target / "old-agent.yaml").exists())
482
+ self.assertTrue((root_ssh_target / "old-ssh-config").exists())
483
+ self.assertTrue((root_npm_target / "old-cache.json").exists())
484
+
485
  def test_replace_dir_contents_clears_old_files(self):
486
  with tempfile.TemporaryDirectory() as tmp:
487
  tmp_path = Path(tmp)
tests/test_runtime_user_defaults.py CHANGED
@@ -25,6 +25,12 @@ class RuntimeUserDefaultsTests(unittest.TestCase):
25
  self.assertIn("ENV OPENCLAW_USER=root", dockerfile)
26
  self.assertIn("ENV OPENCLAW_GROUP=root", dockerfile)
27
  self.assertIn("ENV OPENCLAW_BACKUP_SOURCE_DIR=/root/.openclaw", dockerfile)
 
 
 
 
 
 
28
  self.assertIn('OPENCLAW_BIN="$(command -v openclaw || true)"', dockerfile)
29
  self.assertIn('if [[ -z "$OPENCLAW_BIN" ]] && [[ -x /root/.npm-global/bin/openclaw ]]; then', dockerfile)
30
  self.assertIn('elif [[ -z "$OPENCLAW_BIN" ]] && [[ -x /root/.local/bin/openclaw ]]; then', dockerfile)
@@ -40,7 +46,11 @@ class RuntimeUserDefaultsTests(unittest.TestCase):
40
 
41
  def test_dockerfile_preinstalls_common_cli_tools(self):
42
  dockerfile = self._dockerfile_path().read_text(encoding="utf-8")
 
43
  self.assertIn("vim; then", dockerfile)
 
 
 
44
  self.assertIn(
45
  "npm install -g --no-audit --no-fund opencode-ai @openai/codex @anthropic-ai/claude-code",
46
  dockerfile,
@@ -48,6 +58,8 @@ class RuntimeUserDefaultsTests(unittest.TestCase):
48
  self.assertIn("for cmd in opencode codex claude; do", dockerfile)
49
  self.assertIn('ln -sf "$CLI_BIN" "/usr/local/bin/$cmd"', dockerfile)
50
  self.assertIn('"/usr/local/bin/$cmd" --help >/dev/null', dockerfile)
 
 
51
 
52
  def test_entrypoint_defaults_to_root_runtime_user(self):
53
  entrypoint = self._entrypoint_path().read_text(encoding="utf-8")
@@ -55,6 +67,12 @@ class RuntimeUserDefaultsTests(unittest.TestCase):
55
  self.assertIn('OPENCLAW_GROUP="${OPENCLAW_GROUP:-root}"', entrypoint)
56
  self.assertIn('OPENCLAW_HOME="${OPENCLAW_HOME:-/root}"', entrypoint)
57
  self.assertIn('OPENCLAW_BACKUP_SOURCE_DIR="${OPENCLAW_BACKUP_SOURCE_DIR:-${OPENCLAW_STATE_DIR}}"', entrypoint)
 
 
 
 
 
 
58
 
59
  def test_backup_cron_uses_openclaw_user(self):
60
  entrypoint = self._entrypoint_path().read_text(encoding="utf-8")
@@ -83,6 +101,12 @@ class RuntimeUserDefaultsTests(unittest.TestCase):
83
  def test_backup_env_file_includes_keep_count(self):
84
  entrypoint = self._entrypoint_path().read_text(encoding="utf-8")
85
  self.assertIn("OPENCLAW_BACKUP_KEEP_COUNT", entrypoint)
 
 
 
 
 
 
86
 
87
  def test_sshx_auto_start_does_not_use_command_substitution(self):
88
  entrypoint = self._entrypoint_path().read_text(encoding="utf-8")
 
25
  self.assertIn("ENV OPENCLAW_USER=root", dockerfile)
26
  self.assertIn("ENV OPENCLAW_GROUP=root", dockerfile)
27
  self.assertIn("ENV OPENCLAW_BACKUP_SOURCE_DIR=/root/.openclaw", dockerfile)
28
+ self.assertIn("ENV OPENCLAW_BACKUP_ROOT_CONFIG_DIR=/root/.config", dockerfile)
29
+ self.assertIn("ENV OPENCLAW_BACKUP_ROOT_CODEX_DIR=/root/.codex", dockerfile)
30
+ self.assertIn("ENV OPENCLAW_BACKUP_ROOT_CLAUDE_DIR=/root/.claude", dockerfile)
31
+ self.assertIn("ENV OPENCLAW_BACKUP_ROOT_AGENTS_DIR=/root/.agents", dockerfile)
32
+ self.assertIn("ENV OPENCLAW_BACKUP_ROOT_SSH_DIR=/root/.ssh", dockerfile)
33
+ self.assertIn("ENV OPENCLAW_BACKUP_ROOT_NPM_DIR=/root/.npm", dockerfile)
34
  self.assertIn('OPENCLAW_BIN="$(command -v openclaw || true)"', dockerfile)
35
  self.assertIn('if [[ -z "$OPENCLAW_BIN" ]] && [[ -x /root/.npm-global/bin/openclaw ]]; then', dockerfile)
36
  self.assertIn('elif [[ -z "$OPENCLAW_BIN" ]] && [[ -x /root/.local/bin/openclaw ]]; then', dockerfile)
 
46
 
47
  def test_dockerfile_preinstalls_common_cli_tools(self):
48
  dockerfile = self._dockerfile_path().read_text(encoding="utf-8")
49
+ self.assertIn("python3", dockerfile)
50
  self.assertIn("vim; then", dockerfile)
51
+ self.assertIn("neovim", dockerfile)
52
+ self.assertIn("chromium-headless-shell", dockerfile)
53
+ self.assertIn("gh", dockerfile)
54
  self.assertIn(
55
  "npm install -g --no-audit --no-fund opencode-ai @openai/codex @anthropic-ai/claude-code",
56
  dockerfile,
 
58
  self.assertIn("for cmd in opencode codex claude; do", dockerfile)
59
  self.assertIn('ln -sf "$CLI_BIN" "/usr/local/bin/$cmd"', dockerfile)
60
  self.assertIn('"/usr/local/bin/$cmd" --help >/dev/null', dockerfile)
61
+ self.assertIn('hf --help >/dev/null', dockerfile)
62
+ self.assertIn("uv --version >/dev/null", dockerfile)
63
 
64
  def test_entrypoint_defaults_to_root_runtime_user(self):
65
  entrypoint = self._entrypoint_path().read_text(encoding="utf-8")
 
67
  self.assertIn('OPENCLAW_GROUP="${OPENCLAW_GROUP:-root}"', entrypoint)
68
  self.assertIn('OPENCLAW_HOME="${OPENCLAW_HOME:-/root}"', entrypoint)
69
  self.assertIn('OPENCLAW_BACKUP_SOURCE_DIR="${OPENCLAW_BACKUP_SOURCE_DIR:-${OPENCLAW_STATE_DIR}}"', entrypoint)
70
+ self.assertIn('OPENCLAW_BACKUP_ROOT_CONFIG_DIR="${OPENCLAW_BACKUP_ROOT_CONFIG_DIR:-/root/.config}"', entrypoint)
71
+ self.assertIn('OPENCLAW_BACKUP_ROOT_CODEX_DIR="${OPENCLAW_BACKUP_ROOT_CODEX_DIR:-/root/.codex}"', entrypoint)
72
+ self.assertIn('OPENCLAW_BACKUP_ROOT_CLAUDE_DIR="${OPENCLAW_BACKUP_ROOT_CLAUDE_DIR:-/root/.claude}"', entrypoint)
73
+ self.assertIn('OPENCLAW_BACKUP_ROOT_AGENTS_DIR="${OPENCLAW_BACKUP_ROOT_AGENTS_DIR:-/root/.agents}"', entrypoint)
74
+ self.assertIn('OPENCLAW_BACKUP_ROOT_SSH_DIR="${OPENCLAW_BACKUP_ROOT_SSH_DIR:-/root/.ssh}"', entrypoint)
75
+ self.assertIn('OPENCLAW_BACKUP_ROOT_NPM_DIR="${OPENCLAW_BACKUP_ROOT_NPM_DIR:-/root/.npm}"', entrypoint)
76
 
77
  def test_backup_cron_uses_openclaw_user(self):
78
  entrypoint = self._entrypoint_path().read_text(encoding="utf-8")
 
101
  def test_backup_env_file_includes_keep_count(self):
102
  entrypoint = self._entrypoint_path().read_text(encoding="utf-8")
103
  self.assertIn("OPENCLAW_BACKUP_KEEP_COUNT", entrypoint)
104
+ self.assertIn("OPENCLAW_BACKUP_ROOT_CONFIG_DIR", entrypoint)
105
+ self.assertIn("OPENCLAW_BACKUP_ROOT_CODEX_DIR", entrypoint)
106
+ self.assertIn("OPENCLAW_BACKUP_ROOT_CLAUDE_DIR", entrypoint)
107
+ self.assertIn("OPENCLAW_BACKUP_ROOT_AGENTS_DIR", entrypoint)
108
+ self.assertIn("OPENCLAW_BACKUP_ROOT_SSH_DIR", entrypoint)
109
+ self.assertIn("OPENCLAW_BACKUP_ROOT_NPM_DIR", entrypoint)
110
 
111
  def test_sshx_auto_start_does_not_use_command_substitution(self):
112
  entrypoint = self._entrypoint_path().read_text(encoding="utf-8")