tenfyzhong commited on
Commit
416a5ed
·
unverified ·
1 Parent(s): d1a336d

feat(lark-cli): install CLI and back up /root/.lark-cli (#22)

Browse files

* feat(lark-cli): install CLI and back up /root/.lark-cli

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

* ci: add PR workflow for tests and docker build

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

* test(backup): avoid /root permission dependency in unit tests

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

* refactor(script): simplify openclaw-gateway restart command

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

---------

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

.github/workflows/pr-ci.yml ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: PR CI
2
+
3
+ on:
4
+ pull_request:
5
+ branches:
6
+ - main
7
+ types:
8
+ - opened
9
+ - synchronize
10
+ - reopened
11
+ - ready_for_review
12
+
13
+ permissions:
14
+ contents: read
15
+
16
+ concurrency:
17
+ group: pr-ci-${{ github.event.pull_request.number || github.ref }}
18
+ cancel-in-progress: true
19
+
20
+ jobs:
21
+ unit-tests:
22
+ name: Unit Tests
23
+ runs-on: ubuntu-latest
24
+ timeout-minutes: 15
25
+ steps:
26
+ - name: Checkout
27
+ uses: actions/checkout@v4
28
+
29
+ - name: Setup Python
30
+ uses: actions/setup-python@v5
31
+ with:
32
+ python-version: "3.12"
33
+
34
+ - name: Run tests
35
+ run: python3 -m unittest discover -s tests -p 'test_*.py'
36
+
37
+ docker-build:
38
+ name: Docker Build
39
+ runs-on: ubuntu-latest
40
+ timeout-minutes: 60
41
+ steps:
42
+ - name: Checkout
43
+ uses: actions/checkout@v4
44
+
45
+ - name: Set up Docker Buildx
46
+ uses: docker/setup-buildx-action@v3
47
+
48
+ - name: Build image
49
+ uses: docker/build-push-action@v6
50
+ with:
51
+ context: .
52
+ file: ./Dockerfile
53
+ platforms: linux/amd64
54
+ push: false
55
+ tags: openclaw-hf:pr-${{ github.sha }}
56
+ build-args: |
57
+ OPENCLAW_VERSION=latest
58
+ cache-from: type=gha
59
+ cache-to: type=gha,mode=max
Dockerfile CHANGED
@@ -103,7 +103,8 @@ RUN /bin/bash -lc 'set -euo pipefail; \
103
  echo "npm command not found; cannot install developer CLIs" >&2; \
104
  exit 1; \
105
  fi; \
106
- npm install -g --no-audit --no-fund opencode-ai @openai/codex @anthropic-ai/claude-code; \
 
107
  NPM_PREFIX="$(npm config get prefix)"; \
108
  NPM_BIN="${NPM_PREFIX%/}/bin"; \
109
  for cmd in opencode codex claude; do \
@@ -174,6 +175,7 @@ ENV OPENCLAW_BACKUP_ROOT_CLAUDE_DIR=/root/.claude
174
  ENV OPENCLAW_BACKUP_ROOT_AGENTS_DIR=/root/.agents
175
  ENV OPENCLAW_BACKUP_ROOT_SSH_DIR=/root/.ssh
176
  ENV OPENCLAW_BACKUP_ROOT_NPM_DIR=/root/.npm
 
177
  ENV OPENCLAW_SSHX_AUTO_START=false
178
  ENV OPENCLAW_BACKUP_PRIVATE=true
179
  ENV OPENCLAW_BACKUP_PATH_PREFIX=backups
 
103
  echo "npm command not found; cannot install developer CLIs" >&2; \
104
  exit 1; \
105
  fi; \
106
+ npm install -g --no-audit --no-fund opencode-ai @openai/codex @anthropic-ai/claude-code @larksuite/cli; \
107
+ npx skills add larksuite/cli -y -g; \
108
  NPM_PREFIX="$(npm config get prefix)"; \
109
  NPM_BIN="${NPM_PREFIX%/}/bin"; \
110
  for cmd in opencode codex claude; do \
 
175
  ENV OPENCLAW_BACKUP_ROOT_AGENTS_DIR=/root/.agents
176
  ENV OPENCLAW_BACKUP_ROOT_SSH_DIR=/root/.ssh
177
  ENV OPENCLAW_BACKUP_ROOT_NPM_DIR=/root/.npm
178
+ ENV OPENCLAW_BACKUP_ROOT_LARK_CLI_DIR=/root/.lark-cli
179
  ENV OPENCLAW_SSHX_AUTO_START=false
180
  ENV OPENCLAW_BACKUP_PRIVATE=true
181
  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 `python3`, `uv`, `vim`, `neovim`, `chromium` (via Chrome for Testing archive), `gh`, `hf`, `opencode`, `codex`, `claude` (Claude Code CLI), and `sshx` in the image for interactive terminal use
22
 
23
  ## Repository Layout
24
 
@@ -74,6 +74,7 @@ In that case, you can still configure from inside the container (for example via
74
  - `OPENCLAW_BACKUP_ROOT_AGENTS_DIR` (default: `/root/.agents`, additional backup/restore directory for `root-agents`)
75
  - `OPENCLAW_BACKUP_ROOT_SSH_DIR` (default: `/root/.ssh`, additional backup/restore directory for `root-ssh`)
76
  - `OPENCLAW_BACKUP_ROOT_NPM_DIR` (default: `/root/.npm`, additional backup/restore directory for `root-npm`)
 
77
  - `OPENCLAW_BACKUP_PATH_PREFIX` (default: `backups`)
78
  - `OPENCLAW_BACKUP_LATEST_NAME` (default: `latest-backup.tar.gz`)
79
  - `OPENCLAW_BACKUP_KEEP_COUNT` (default: `48`, keep newest N timestamped backup archives; older ones are auto-deleted)
@@ -204,6 +205,7 @@ For this project, if you need stable dashboard access without cold starts, use p
204
  - `root-agents` -> `OPENCLAW_BACKUP_ROOT_AGENTS_DIR` (default `/root/.agents`, restored only when present in archive)
205
  - `root-ssh` -> `OPENCLAW_BACKUP_ROOT_SSH_DIR` (default `/root/.ssh`, restored only when present in archive)
206
  - `root-npm` -> `OPENCLAW_BACKUP_ROOT_NPM_DIR` (default `/root/.npm`, restored only when present in archive)
 
207
  - Scheduled backup: cron runs based on `OPENCLAW_BACKUP_CRON`
208
  - Shutdown backup: when the container receives a stop signal, one final backup is uploaded before exit
209
  - Each backup upload includes:
@@ -215,6 +217,7 @@ For this project, if you need stable dashboard access without cold starts, use p
215
  - archive root `root-agents/` (included when `OPENCLAW_BACKUP_ROOT_AGENTS_DIR` exists)
216
  - archive root `root-ssh/` (included when `OPENCLAW_BACKUP_ROOT_SSH_DIR` exists)
217
  - archive root `root-npm/` (included when `OPENCLAW_BACKUP_ROOT_NPM_DIR` exists)
 
218
  - `latest-backup.tar.gz`
219
  - `latest-backup.json`
220
  - Retention: after upload, only the newest `OPENCLAW_BACKUP_KEEP_COUNT` timestamped archives are kept (default `48`); older timestamped archives are deleted automatically
@@ -256,6 +259,10 @@ pkill -TERM -f '(^|/)sshx($| )'
256
  python3 -m unittest discover -s tests -p 'test_*.py'
257
  ```
258
 
 
 
 
 
259
  ## License
260
 
261
  MIT. See `LICENSE`.
 
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` (via Chrome for Testing archive), `gh`, `hf`, `opencode`, `codex`, `claude` (Claude Code CLI), `@larksuite/cli` (with `npx skills add larksuite/cli -y -g`), and `sshx` in the image for interactive terminal use
22
 
23
  ## Repository Layout
24
 
 
74
  - `OPENCLAW_BACKUP_ROOT_AGENTS_DIR` (default: `/root/.agents`, additional backup/restore directory for `root-agents`)
75
  - `OPENCLAW_BACKUP_ROOT_SSH_DIR` (default: `/root/.ssh`, additional backup/restore directory for `root-ssh`)
76
  - `OPENCLAW_BACKUP_ROOT_NPM_DIR` (default: `/root/.npm`, additional backup/restore directory for `root-npm`)
77
+ - `OPENCLAW_BACKUP_ROOT_LARK_CLI_DIR` (default: `/root/.lark-cli`, additional backup/restore directory for `root-lark-cli`)
78
  - `OPENCLAW_BACKUP_PATH_PREFIX` (default: `backups`)
79
  - `OPENCLAW_BACKUP_LATEST_NAME` (default: `latest-backup.tar.gz`)
80
  - `OPENCLAW_BACKUP_KEEP_COUNT` (default: `48`, keep newest N timestamped backup archives; older ones are auto-deleted)
 
205
  - `root-agents` -> `OPENCLAW_BACKUP_ROOT_AGENTS_DIR` (default `/root/.agents`, restored only when present in archive)
206
  - `root-ssh` -> `OPENCLAW_BACKUP_ROOT_SSH_DIR` (default `/root/.ssh`, restored only when present in archive)
207
  - `root-npm` -> `OPENCLAW_BACKUP_ROOT_NPM_DIR` (default `/root/.npm`, restored only when present in archive)
208
+ - `root-lark-cli` -> `OPENCLAW_BACKUP_ROOT_LARK_CLI_DIR` (default `/root/.lark-cli`, restored only when present in archive)
209
  - Scheduled backup: cron runs based on `OPENCLAW_BACKUP_CRON`
210
  - Shutdown backup: when the container receives a stop signal, one final backup is uploaded before exit
211
  - Each backup upload includes:
 
217
  - archive root `root-agents/` (included when `OPENCLAW_BACKUP_ROOT_AGENTS_DIR` exists)
218
  - archive root `root-ssh/` (included when `OPENCLAW_BACKUP_ROOT_SSH_DIR` exists)
219
  - archive root `root-npm/` (included when `OPENCLAW_BACKUP_ROOT_NPM_DIR` exists)
220
+ - archive root `root-lark-cli/` (included when `OPENCLAW_BACKUP_ROOT_LARK_CLI_DIR` exists)
221
  - `latest-backup.tar.gz`
222
  - `latest-backup.json`
223
  - Retention: after upload, only the newest `OPENCLAW_BACKUP_KEEP_COUNT` timestamped archives are kept (default `48`); older timestamped archives are deleted automatically
 
259
  python3 -m unittest discover -s tests -p 'test_*.py'
260
  ```
261
 
262
+ Pull Requests to `main` run GitHub Actions CI automatically (`.github/workflows/pr-ci.yml`):
263
+ - Unit tests: `python3 -m unittest discover -s tests -p 'test_*.py'`
264
+ - Docker image build: `docker build` (via Buildx) with `OPENCLAW_VERSION=latest`
265
+
266
  ## License
267
 
268
  MIT. See `LICENSE`.
openclaw_hf/backup.py CHANGED
@@ -54,6 +54,7 @@ class BackupConfig:
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
@@ -82,6 +83,9 @@ class BackupConfig:
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("/")
@@ -120,6 +124,7 @@ class BackupConfig:
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
 
@@ -155,6 +160,7 @@ class OpenClawBackup:
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:
 
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
+ root_lark_cli_dir: Path = Path("/root/.lark-cli").resolve()
58
  keep_count: int = 48
59
 
60
  @classmethod
 
83
  ).resolve()
84
  root_ssh_dir = Path((os.getenv("OPENCLAW_BACKUP_ROOT_SSH_DIR") or "/root/.ssh").strip() or "/root/.ssh").resolve()
85
  root_npm_dir = Path((os.getenv("OPENCLAW_BACKUP_ROOT_NPM_DIR") or "/root/.npm").strip() or "/root/.npm").resolve()
86
+ root_lark_cli_dir = Path(
87
+ (os.getenv("OPENCLAW_BACKUP_ROOT_LARK_CLI_DIR") or "/root/.lark-cli").strip() or "/root/.lark-cli"
88
+ ).resolve()
89
  work_dir = Path(os.getenv("OPENCLAW_BACKUP_WORK_DIR", "/tmp/openclaw-backup")).resolve()
90
  repo_type = (os.getenv("OPENCLAW_BACKUP_REPO_TYPE") or "dataset").strip() or "dataset"
91
  path_prefix = (os.getenv("OPENCLAW_BACKUP_PATH_PREFIX") or "backups").strip("/")
 
124
  root_agents_dir=root_agents_dir,
125
  root_ssh_dir=root_ssh_dir,
126
  root_npm_dir=root_npm_dir,
127
+ root_lark_cli_dir=root_lark_cli_dir,
128
  keep_count=keep_count,
129
  )
130
 
 
160
  "root-agents": self.config.root_agents_dir,
161
  "root-ssh": self.config.root_ssh_dir,
162
  "root-npm": self.config.root_npm_dir,
163
+ "root-lark-cli": self.config.root_lark_cli_dir,
164
  }
165
 
166
  def create_archive(self, timestamp: Optional[str] = None) -> Path:
scripts/openclaw-entrypoint.sh CHANGED
@@ -25,6 +25,7 @@ OPENCLAW_BACKUP_ROOT_CLAUDE_DIR="${OPENCLAW_BACKUP_ROOT_CLAUDE_DIR:-/root/.claud
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}"
@@ -349,6 +350,7 @@ write_backup_env_file() {
349
  OPENCLAW_BACKUP_ROOT_AGENTS_DIR
350
  OPENCLAW_BACKUP_ROOT_SSH_DIR
351
  OPENCLAW_BACKUP_ROOT_NPM_DIR
 
352
  OPENCLAW_STATE_DIR
353
  )
354
 
 
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_ROOT_LARK_CLI_DIR="${OPENCLAW_BACKUP_ROOT_LARK_CLI_DIR:-/root/.lark-cli}"
29
  OPENCLAW_BACKUP_ENV_FILE_PATH="${OPENCLAW_BACKUP_ENV_FILE_PATH:-/etc/openclaw-backup.env}"
30
  OPENCLAW_STDOUT_LOG_PATH="${OPENCLAW_STDOUT_LOG_PATH:-/var/log/openclaw/gateway.stdout.log}"
31
  OPENCLAW_STDERR_LOG_PATH="${OPENCLAW_STDERR_LOG_PATH:-/var/log/openclaw/gateway.stderr.log}"
 
350
  OPENCLAW_BACKUP_ROOT_AGENTS_DIR
351
  OPENCLAW_BACKUP_ROOT_SSH_DIR
352
  OPENCLAW_BACKUP_ROOT_NPM_DIR
353
+ OPENCLAW_BACKUP_ROOT_LARK_CLI_DIR
354
  OPENCLAW_STATE_DIR
355
  )
356
 
scripts/openclaw-gateway-restart CHANGED
@@ -1,2 +1,2 @@
1
  #!/usr/bin/env bash
2
- ps -ef | grep openclaw-gateway | grep -v grep | awk '{print $2}' | xargs -I {} kill {}
 
1
  #!/usr/bin/env bash
2
+ pgrep -f openclaw-gateway | xargs -I {} kill {}
tests/test_backup.py CHANGED
@@ -45,6 +45,7 @@ class BackupTests(unittest.TestCase):
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):
@@ -102,11 +103,13 @@ class BackupTests(unittest.TestCase):
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",
@@ -115,6 +118,7 @@ class BackupTests(unittest.TestCase):
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()
@@ -124,6 +128,7 @@ class BackupTests(unittest.TestCase):
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 = {
@@ -147,6 +152,12 @@ class BackupTests(unittest.TestCase):
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",
@@ -178,6 +189,12 @@ class BackupTests(unittest.TestCase):
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",
@@ -204,6 +221,7 @@ class BackupTests(unittest.TestCase):
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)
@@ -211,6 +229,7 @@ class BackupTests(unittest.TestCase):
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")
@@ -218,6 +237,7 @@ class BackupTests(unittest.TestCase):
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",
@@ -229,6 +249,7 @@ class BackupTests(unittest.TestCase):
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",
@@ -250,6 +271,7 @@ class BackupTests(unittest.TestCase):
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:
@@ -332,6 +354,7 @@ class BackupTests(unittest.TestCase):
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)
@@ -339,6 +362,7 @@ class BackupTests(unittest.TestCase):
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")
@@ -346,6 +370,7 @@ class BackupTests(unittest.TestCase):
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"
@@ -355,6 +380,7 @@ class BackupTests(unittest.TestCase):
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)
@@ -362,6 +388,7 @@ class BackupTests(unittest.TestCase):
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")
@@ -369,6 +396,7 @@ class BackupTests(unittest.TestCase):
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:
@@ -379,6 +407,7 @@ class BackupTests(unittest.TestCase):
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",
@@ -390,6 +419,7 @@ class BackupTests(unittest.TestCase):
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",
@@ -416,6 +446,8 @@ class BackupTests(unittest.TestCase):
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:
@@ -427,6 +459,7 @@ class BackupTests(unittest.TestCase):
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)
@@ -434,6 +467,7 @@ class BackupTests(unittest.TestCase):
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")
@@ -441,6 +475,7 @@ class BackupTests(unittest.TestCase):
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"
@@ -461,6 +496,7 @@ class BackupTests(unittest.TestCase):
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",
@@ -481,6 +517,7 @@ class BackupTests(unittest.TestCase):
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:
 
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.root_lark_cli_dir, Path("/root/.lark-cli").resolve())
49
  self.assertEqual(config.keep_count, 48)
50
 
51
  def test_from_env_defaults_backup_source_to_explicit_state_dir_when_unset(self):
 
103
  root_agents = tmp_path / "root-agents"
104
  root_ssh = tmp_path / "root-ssh"
105
  root_npm = tmp_path / "root-npm"
106
+ root_lark_cli = tmp_path / "root-lark-cli"
107
  root_codex.mkdir()
108
  root_claude.mkdir()
109
  root_agents.mkdir()
110
  root_ssh.mkdir()
111
  root_npm.mkdir()
112
+ root_lark_cli.mkdir()
113
 
114
  env = {
115
  "OPENCLAW_BACKUP_DATASET_REPO": "user/demo-backup",
 
118
  "OPENCLAW_BACKUP_ROOT_AGENTS_DIR": str(root_agents),
119
  "OPENCLAW_BACKUP_ROOT_SSH_DIR": str(root_ssh),
120
  "OPENCLAW_BACKUP_ROOT_NPM_DIR": str(root_npm),
121
+ "OPENCLAW_BACKUP_ROOT_LARK_CLI_DIR": str(root_lark_cli),
122
  }
123
  with mock.patch.dict("os.environ", env, clear=True):
124
  config = BackupConfig.from_env()
 
128
  self.assertEqual(config.root_agents_dir, root_agents.resolve())
129
  self.assertEqual(config.root_ssh_dir, root_ssh.resolve())
130
  self.assertEqual(config.root_npm_dir, root_npm.resolve())
131
+ self.assertEqual(config.root_lark_cli_dir, root_lark_cli.resolve())
132
 
133
  def test_from_env_uses_explicit_backup_keep_count(self):
134
  env = {
 
152
  state_dir=state_dir,
153
  backup_source_dir=state_dir,
154
  root_config_dir=tmp_path / "root-config",
155
+ root_codex_dir=tmp_path / "root-codex",
156
+ root_claude_dir=tmp_path / "root-claude",
157
+ root_agents_dir=tmp_path / "root-agents",
158
+ root_ssh_dir=tmp_path / "root-ssh",
159
+ root_npm_dir=tmp_path / "root-npm",
160
+ root_lark_cli_dir=tmp_path / "root-lark-cli",
161
  work_dir=tmp_path / "work",
162
  repo_type="dataset",
163
  path_prefix="backups",
 
189
  state_dir=state_dir,
190
  backup_source_dir=source_dir,
191
  root_config_dir=tmp_path / "root-config",
192
+ root_codex_dir=tmp_path / "root-codex",
193
+ root_claude_dir=tmp_path / "root-claude",
194
+ root_agents_dir=tmp_path / "root-agents",
195
+ root_ssh_dir=tmp_path / "root-ssh",
196
+ root_npm_dir=tmp_path / "root-npm",
197
+ root_lark_cli_dir=tmp_path / "root-lark-cli",
198
  work_dir=tmp_path / "work",
199
  repo_type="dataset",
200
  path_prefix="backups",
 
221
  root_agents_dir = tmp_path / "root-agents"
222
  root_ssh_dir = tmp_path / "root-ssh"
223
  root_npm_dir = tmp_path / "root-npm"
224
+ root_lark_cli_dir = tmp_path / "root-lark-cli"
225
  state_dir.mkdir(parents=True)
226
  root_config_dir.mkdir(parents=True)
227
  root_codex_dir.mkdir(parents=True)
 
229
  root_agents_dir.mkdir(parents=True)
230
  root_ssh_dir.mkdir(parents=True)
231
  root_npm_dir.mkdir(parents=True)
232
+ root_lark_cli_dir.mkdir(parents=True)
233
  (state_dir / "state.txt").write_text("state\n", encoding="utf-8")
234
  (root_config_dir / "settings.json").write_text('{"key":"value"}\n', encoding="utf-8")
235
  (root_codex_dir / "config.toml").write_text("theme = \"dark\"\n", encoding="utf-8")
 
237
  (root_agents_dir / "agent.yaml").write_text("name: default\n", encoding="utf-8")
238
  (root_ssh_dir / "config").write_text("Host *\n ServerAliveInterval 60\n", encoding="utf-8")
239
  (root_npm_dir / "cache-index.json").write_text('{"npm":"cache"}\n', encoding="utf-8")
240
+ (root_lark_cli_dir / "config.json").write_text('{"cli":"lark"}\n', encoding="utf-8")
241
 
242
  config = BackupConfig(
243
  dataset_repo="user/demo-backup",
 
249
  root_agents_dir=root_agents_dir,
250
  root_ssh_dir=root_ssh_dir,
251
  root_npm_dir=root_npm_dir,
252
+ root_lark_cli_dir=root_lark_cli_dir,
253
  work_dir=tmp_path / "work",
254
  repo_type="dataset",
255
  path_prefix="backups",
 
271
  self.assertIn("root-agents/agent.yaml", members)
272
  self.assertIn("root-ssh/config", members)
273
  self.assertIn("root-npm/cache-index.json", members)
274
+ self.assertIn("root-lark-cli/config.json", members)
275
 
276
  def test_upload_backup_writes_timestamp_and_latest(self):
277
  with tempfile.TemporaryDirectory() as tmp:
 
354
  root_agents_target = tmp_path / "root-agents-target"
355
  root_ssh_target = tmp_path / "root-ssh-target"
356
  root_npm_target = tmp_path / "root-npm-target"
357
+ root_lark_cli_target = tmp_path / "root-lark-cli-target"
358
  state_target.mkdir(parents=True)
359
  root_config_target.mkdir(parents=True)
360
  root_codex_target.mkdir(parents=True)
 
362
  root_agents_target.mkdir(parents=True)
363
  root_ssh_target.mkdir(parents=True)
364
  root_npm_target.mkdir(parents=True)
365
+ root_lark_cli_target.mkdir(parents=True)
366
  (state_target / "old-state.txt").write_text("old-state\n", encoding="utf-8")
367
  (root_config_target / "old-config.json").write_text('{"old":true}\n', encoding="utf-8")
368
  (root_codex_target / "old-codex.toml").write_text("old = true\n", encoding="utf-8")
 
370
  (root_agents_target / "old-agent.yaml").write_text("old: true\n", encoding="utf-8")
371
  (root_ssh_target / "old-ssh-config").write_text("Host old\n", encoding="utf-8")
372
  (root_npm_target / "old-cache.json").write_text('{"old":"cache"}\n', encoding="utf-8")
373
+ (root_lark_cli_target / "old-lark-cli.json").write_text('{"old":"lark-cli"}\n', encoding="utf-8")
374
 
375
  restore_payload = tmp_path / "restore-payload"
376
  restore_state = restore_payload / "openclaw-state"
 
380
  restore_root_agents = restore_payload / "root-agents"
381
  restore_root_ssh = restore_payload / "root-ssh"
382
  restore_root_npm = restore_payload / "root-npm"
383
+ restore_root_lark_cli = restore_payload / "root-lark-cli"
384
  restore_state.mkdir(parents=True)
385
  restore_root_config.mkdir(parents=True)
386
  restore_root_codex.mkdir(parents=True)
 
388
  restore_root_agents.mkdir(parents=True)
389
  restore_root_ssh.mkdir(parents=True)
390
  restore_root_npm.mkdir(parents=True)
391
+ restore_root_lark_cli.mkdir(parents=True)
392
  (restore_state / "new-state.txt").write_text("new-state\n", encoding="utf-8")
393
  (restore_root_config / "new-config.json").write_text('{"new":true}\n', encoding="utf-8")
394
  (restore_root_codex / "new-codex.toml").write_text("new = true\n", encoding="utf-8")
 
396
  (restore_root_agents / "new-agent.yaml").write_text("new: true\n", encoding="utf-8")
397
  (restore_root_ssh / "config").write_text("Host *\n", encoding="utf-8")
398
  (restore_root_npm / "cache.json").write_text('{"new":"cache"}\n', encoding="utf-8")
399
+ (restore_root_lark_cli / "config.json").write_text('{"new":"lark-cli"}\n', encoding="utf-8")
400
 
401
  archive = tmp_path / "latest-backup.tar.gz"
402
  with tarfile.open(archive, "w:gz") as tar:
 
407
  tar.add(str(restore_root_agents), arcname="root-agents")
408
  tar.add(str(restore_root_ssh), arcname="root-ssh")
409
  tar.add(str(restore_root_npm), arcname="root-npm")
410
+ tar.add(str(restore_root_lark_cli), arcname="root-lark-cli")
411
 
412
  config = BackupConfig(
413
  dataset_repo="user/demo-backup",
 
419
  root_agents_dir=root_agents_target,
420
  root_ssh_dir=root_ssh_target,
421
  root_npm_dir=root_npm_target,
422
+ root_lark_cli_dir=root_lark_cli_target,
423
  work_dir=tmp_path / "work",
424
  repo_type="dataset",
425
  path_prefix="backups",
 
446
  self.assertFalse((root_ssh_target / "old-ssh-config").exists())
447
  self.assertTrue((root_npm_target / "cache.json").exists())
448
  self.assertFalse((root_npm_target / "old-cache.json").exists())
449
+ self.assertTrue((root_lark_cli_target / "config.json").exists())
450
+ self.assertFalse((root_lark_cli_target / "old-lark-cli.json").exists())
451
 
452
  def test_restore_keeps_extra_root_dirs_untouched_when_archive_missing_them(self):
453
  with tempfile.TemporaryDirectory() as tmp:
 
459
  root_agents_target = tmp_path / "root-agents-target"
460
  root_ssh_target = tmp_path / "root-ssh-target"
461
  root_npm_target = tmp_path / "root-npm-target"
462
+ root_lark_cli_target = tmp_path / "root-lark-cli-target"
463
  state_target.mkdir(parents=True)
464
  root_config_target.mkdir(parents=True)
465
  root_codex_target.mkdir(parents=True)
 
467
  root_agents_target.mkdir(parents=True)
468
  root_ssh_target.mkdir(parents=True)
469
  root_npm_target.mkdir(parents=True)
470
+ root_lark_cli_target.mkdir(parents=True)
471
  (state_target / "old-state.txt").write_text("old-state\n", encoding="utf-8")
472
  (root_config_target / "old-config.json").write_text('{"old":true}\n', encoding="utf-8")
473
  (root_codex_target / "old-codex.toml").write_text("old = true\n", encoding="utf-8")
 
475
  (root_agents_target / "old-agent.yaml").write_text("old: true\n", encoding="utf-8")
476
  (root_ssh_target / "old-ssh-config").write_text("Host old\n", encoding="utf-8")
477
  (root_npm_target / "old-cache.json").write_text('{"old":"cache"}\n', encoding="utf-8")
478
+ (root_lark_cli_target / "old-lark-cli.json").write_text('{"old":"lark-cli"}\n', encoding="utf-8")
479
 
480
  restore_payload = tmp_path / "restore-payload"
481
  restore_state = restore_payload / "openclaw-state"
 
496
  root_agents_dir=root_agents_target,
497
  root_ssh_dir=root_ssh_target,
498
  root_npm_dir=root_npm_target,
499
+ root_lark_cli_dir=root_lark_cli_target,
500
  work_dir=tmp_path / "work",
501
  repo_type="dataset",
502
  path_prefix="backups",
 
517
  self.assertTrue((root_agents_target / "old-agent.yaml").exists())
518
  self.assertTrue((root_ssh_target / "old-ssh-config").exists())
519
  self.assertTrue((root_npm_target / "old-cache.json").exists())
520
+ self.assertTrue((root_lark_cli_target / "old-lark-cli.json").exists())
521
 
522
  def test_replace_dir_contents_clears_old_files(self):
523
  with tempfile.TemporaryDirectory() as tmp:
tests/test_runtime_user_defaults.py CHANGED
@@ -31,6 +31,7 @@ class RuntimeUserDefaultsTests(unittest.TestCase):
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)
@@ -62,6 +63,7 @@ class RuntimeUserDefaultsTests(unittest.TestCase):
62
  self.assertIn("for cmd in opencode codex claude; do", dockerfile)
63
  self.assertIn('ln -sf "$CLI_BIN" "/usr/local/bin/$cmd"', dockerfile)
64
  self.assertIn('"/usr/local/bin/$cmd" --help >/dev/null', dockerfile)
 
65
  self.assertIn('hf --help >/dev/null', dockerfile)
66
  self.assertIn("uv --version >/dev/null", dockerfile)
67
 
@@ -77,6 +79,7 @@ class RuntimeUserDefaultsTests(unittest.TestCase):
77
  self.assertIn('OPENCLAW_BACKUP_ROOT_AGENTS_DIR="${OPENCLAW_BACKUP_ROOT_AGENTS_DIR:-/root/.agents}"', entrypoint)
78
  self.assertIn('OPENCLAW_BACKUP_ROOT_SSH_DIR="${OPENCLAW_BACKUP_ROOT_SSH_DIR:-/root/.ssh}"', entrypoint)
79
  self.assertIn('OPENCLAW_BACKUP_ROOT_NPM_DIR="${OPENCLAW_BACKUP_ROOT_NPM_DIR:-/root/.npm}"', entrypoint)
 
80
 
81
  def test_backup_cron_uses_openclaw_user(self):
82
  entrypoint = self._entrypoint_path().read_text(encoding="utf-8")
@@ -111,6 +114,7 @@ class RuntimeUserDefaultsTests(unittest.TestCase):
111
  self.assertIn("OPENCLAW_BACKUP_ROOT_AGENTS_DIR", entrypoint)
112
  self.assertIn("OPENCLAW_BACKUP_ROOT_SSH_DIR", entrypoint)
113
  self.assertIn("OPENCLAW_BACKUP_ROOT_NPM_DIR", entrypoint)
 
114
 
115
  def test_sshx_auto_start_does_not_use_command_substitution(self):
116
  entrypoint = self._entrypoint_path().read_text(encoding="utf-8")
 
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("ENV OPENCLAW_BACKUP_ROOT_LARK_CLI_DIR=/root/.lark-cli", dockerfile)
35
  self.assertIn('OPENCLAW_BIN="$(command -v openclaw || true)"', dockerfile)
36
  self.assertIn('if [[ -z "$OPENCLAW_BIN" ]] && [[ -x /root/.npm-global/bin/openclaw ]]; then', dockerfile)
37
  self.assertIn('elif [[ -z "$OPENCLAW_BIN" ]] && [[ -x /root/.local/bin/openclaw ]]; then', dockerfile)
 
63
  self.assertIn("for cmd in opencode codex claude; do", dockerfile)
64
  self.assertIn('ln -sf "$CLI_BIN" "/usr/local/bin/$cmd"', dockerfile)
65
  self.assertIn('"/usr/local/bin/$cmd" --help >/dev/null', dockerfile)
66
+ self.assertIn("npx skills add larksuite/cli -y -g", dockerfile)
67
  self.assertIn('hf --help >/dev/null', dockerfile)
68
  self.assertIn("uv --version >/dev/null", dockerfile)
69
 
 
79
  self.assertIn('OPENCLAW_BACKUP_ROOT_AGENTS_DIR="${OPENCLAW_BACKUP_ROOT_AGENTS_DIR:-/root/.agents}"', entrypoint)
80
  self.assertIn('OPENCLAW_BACKUP_ROOT_SSH_DIR="${OPENCLAW_BACKUP_ROOT_SSH_DIR:-/root/.ssh}"', entrypoint)
81
  self.assertIn('OPENCLAW_BACKUP_ROOT_NPM_DIR="${OPENCLAW_BACKUP_ROOT_NPM_DIR:-/root/.npm}"', entrypoint)
82
+ self.assertIn('OPENCLAW_BACKUP_ROOT_LARK_CLI_DIR="${OPENCLAW_BACKUP_ROOT_LARK_CLI_DIR:-/root/.lark-cli}"', entrypoint)
83
 
84
  def test_backup_cron_uses_openclaw_user(self):
85
  entrypoint = self._entrypoint_path().read_text(encoding="utf-8")
 
114
  self.assertIn("OPENCLAW_BACKUP_ROOT_AGENTS_DIR", entrypoint)
115
  self.assertIn("OPENCLAW_BACKUP_ROOT_SSH_DIR", entrypoint)
116
  self.assertIn("OPENCLAW_BACKUP_ROOT_NPM_DIR", entrypoint)
117
+ self.assertIn("OPENCLAW_BACKUP_ROOT_LARK_CLI_DIR", entrypoint)
118
 
119
  def test_sshx_auto_start_does_not_use_command_substitution(self):
120
  entrypoint = self._entrypoint_path().read_text(encoding="utf-8")