From dee558996361c58597a0a7ed7e99cd7671157ba9 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Tue, 2 Jun 2026 02:12:08 +0800 Subject: [PATCH] Protect Linux runtime checkout --- README.md | 11 +- README_CN.md | 11 +- scripts/bootstrap-linux.sh | 118 ++++++++++++++++++ .../test_bootstrap_linux.py | 97 ++++++++++++++ tests/telegram_codex_bot/test_hook.py | 10 +- 5 files changed, 235 insertions(+), 12 deletions(-) create mode 100644 tests/telegram_codex_bot/test_bootstrap_linux.py diff --git a/README.md b/README.md index c5912c6..3c27c56 100644 --- a/README.md +++ b/README.md @@ -121,12 +121,19 @@ tail -n 50 ~/.telegram-codex-bot/logs/telegram-codex-bot.err.log For a Linux workstation or a VPS with systemd: ```bash -git clone https://github.com/Pigbibi/TelegramCodexBot.git -cd TelegramCodexBot +mkdir -p ~/.telegram-codex-bot/app +git clone https://github.com/Pigbibi/TelegramCodexBot.git ~/.telegram-codex-bot/app/TelegramCodexBot +cd ~/.telegram-codex-bot/app/TelegramCodexBot chmod +x scripts/bootstrap-linux.sh ./scripts/bootstrap-linux.sh ``` +Keep the bot checkout outside the project roots that Codex sessions can browse +or clean, such as `~/Projects`. The Linux bootstrap refuses an unsafe checkout +inside `TELEGRAM_CODEX_BOT_DEFAULT_PROJECTS_PATH` or +`TELEGRAM_CODEX_BOT_PROJECT_ROOTS`, because the systemd launcher points back to +that checkout and a project cleanup would break the next restart. + The Linux helper: - runs `uv sync` diff --git a/README_CN.md b/README_CN.md index e9dbf3f..f97a530 100644 --- a/README_CN.md +++ b/README_CN.md @@ -119,12 +119,19 @@ tail -n 50 ~/.telegram-codex-bot/logs/telegram-codex-bot.err.log 如果目标机是 Linux 或带 systemd 的 VPS,可以直接这样装: ```bash -git clone https://github.com/Pigbibi/TelegramCodexBot.git -cd TelegramCodexBot +mkdir -p ~/.telegram-codex-bot/app +git clone https://github.com/Pigbibi/TelegramCodexBot.git ~/.telegram-codex-bot/app/TelegramCodexBot +cd ~/.telegram-codex-bot/app/TelegramCodexBot chmod +x scripts/bootstrap-linux.sh ./scripts/bootstrap-linux.sh ``` +VPS 上不要把 bot 自己的 checkout 放在 `~/Projects` 这类可被 Codex 会话浏览或清理的 +项目目录里。Linux bootstrap 会拒绝位于 +`TELEGRAM_CODEX_BOT_DEFAULT_PROJECTS_PATH` 或 `TELEGRAM_CODEX_BOT_PROJECT_ROOTS` +内部的 checkout,因为 systemd launcher 会指向这个 checkout;一旦项目目录被清理, +下一次重启就会失败。 + 这个脚本会: - 执行 `uv sync` diff --git a/scripts/bootstrap-linux.sh b/scripts/bootstrap-linux.sh index b588a8a..8a75438 100644 --- a/scripts/bootstrap-linux.sh +++ b/scripts/bootstrap-linux.sh @@ -27,6 +27,7 @@ require_cmd() { require_cmd uv require_cmd tmux require_cmd codex +require_cmd python3 mkdir -p "$BIN_DIR" "$LOG_DIR" "$SYSTEMD_DIR" @@ -37,6 +38,123 @@ else echo "Keeping existing $ENV_PATH" fi +read_env_value() { + local key="$1" + local default_value="$2" + local line="" + local value="" + + line="$(grep -E "^${key}=" "$ENV_PATH" | tail -n 1 || true)" + if [[ -z "$line" ]]; then + printf '%s' "$default_value" + return + fi + + value="${line#*=}" + value="${value%$'\r'}" + value="${value%\"}" + value="${value#\"}" + value="${value%\'}" + value="${value#\'}" + printf '%s' "$value" +} + +normalize_path() { + local path="$1" + + if [[ "$path" == "~" ]]; then + path="$HOME" + elif [[ "$path" == "~/"* ]]; then + path="${HOME}/${path#~/}" + elif [[ "$path" != /* ]]; then + path="$(pwd -P)/$path" + fi + + python3 - "$path" <<'PY' +import sys +from pathlib import Path + +print(Path(sys.argv[1]).resolve(strict=False)) +PY +} + +is_under_or_same() { + local child="$1" + local parent="$2" + + [[ "$child" == "$parent" || "$child" == "$parent/"* ]] +} + +truthy() { + local normalized + normalized="$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')" + case "$normalized" in + 1|true|yes|on|y) return 0 ;; + *) return 1 ;; + esac +} + +check_runtime_checkout_location() { + local allow + local repo_path + local default_root + local project_roots + local root_entry + local root_path + local unsafe_root="" + local -a root_entries=() + + allow="${TELEGRAM_CODEX_BOT_ALLOW_PROJECTS_CHECKOUT:-$(read_env_value TELEGRAM_CODEX_BOT_ALLOW_PROJECTS_CHECKOUT false)}" + if truthy "$allow"; then + return + fi + + repo_path="$(normalize_path "$REPO_DIR")" + default_root="$(read_env_value TELEGRAM_CODEX_BOT_DEFAULT_PROJECTS_PATH "$HOME/Projects")" + project_roots="$(read_env_value TELEGRAM_CODEX_BOT_PROJECT_ROOTS "")" + + root_path="$(normalize_path "$default_root")" + if is_under_or_same "$repo_path" "$root_path"; then + unsafe_root="$root_path" + fi + + if [[ -n "$project_roots" ]]; then + IFS=',' read -ra root_entries <<<"$project_roots" + for root_entry in "${root_entries[@]}"; do + root_entry="${root_entry#*=}" + [[ -z "$root_entry" ]] && continue + root_path="$(normalize_path "$root_entry")" + if is_under_or_same "$repo_path" "$root_path"; then + unsafe_root="$root_path" + break + fi + done + fi + + if [[ -n "$unsafe_root" ]]; then + cat >&2 <"$LAUNCHER_PATH" < None: + path.write_text(content, encoding="utf-8") + path.chmod(path.stat().st_mode | stat.S_IXUSR) + + +def _prepare_repo(path: Path) -> Path: + (path / "scripts").mkdir(parents=True) + shutil.copy2(BOOTSTRAP_SCRIPT, path / "scripts" / "bootstrap-linux.sh") + (path / ".env.example").write_text( + "\n".join( + [ + "TELEGRAM_BOT_TOKEN=your_bot_token_here", + "ALLOWED_USERS=123456789,987654321", + "", + ] + ), + encoding="utf-8", + ) + return path / "scripts" / "bootstrap-linux.sh" + + +def _prepare_env(tmp_path: Path, home: Path) -> dict[str, str]: + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + + _write_executable(bin_dir / "uname", "#!/usr/bin/env bash\necho Linux\n") + _write_executable(bin_dir / "uv", "#!/usr/bin/env bash\nexit 0\n") + _write_executable(bin_dir / "tmux", "#!/usr/bin/env bash\nexit 0\n") + _write_executable(bin_dir / "codex", "#!/usr/bin/env bash\nexit 0\n") + + env = os.environ.copy() + env.update( + { + "HOME": str(home), + "PATH": f"{bin_dir}{os.pathsep}{env.get('PATH', '')}", + "TELEGRAM_CODEX_BOT_DIR": str(home / ".telegram-codex-bot"), + } + ) + return env + + +def test_linux_bootstrap_rejects_checkout_inside_default_projects( + tmp_path: Path, +) -> None: + home = tmp_path / "home" + repo = home / "Projects" / "TelegramCodexBot" + script = _prepare_repo(repo) + env = _prepare_env(tmp_path, home) + + result = subprocess.run( + ["bash", str(script)], + cwd=repo, + env=env, + text=True, + capture_output=True, + check=False, + ) + + assert result.returncode == 1 + assert "Unsafe TelegramCodexBot checkout location" in result.stderr + assert str(home / "Projects") in result.stderr + + +def test_linux_bootstrap_allows_checkout_outside_project_roots( + tmp_path: Path, +) -> None: + home = tmp_path / "home" + repo = home / ".telegram-codex-bot" / "app" / "TelegramCodexBot" + script = _prepare_repo(repo) + env = _prepare_env(tmp_path, home) + + result = subprocess.run( + ["bash", str(script)], + cwd=repo, + env=env, + text=True, + capture_output=True, + check=False, + ) + + assert result.returncode == 0, result.stderr + launcher = home / ".telegram-codex-bot" / "bin" / "telegram-codex-bot-launch" + assert launcher.is_file() + assert f'cd "{repo}"' in launcher.read_text(encoding="utf-8") diff --git a/tests/telegram_codex_bot/test_hook.py b/tests/telegram_codex_bot/test_hook.py index d81f49f..b9e0172 100644 --- a/tests/telegram_codex_bot/test_hook.py +++ b/tests/telegram_codex_bot/test_hook.py @@ -280,10 +280,7 @@ def test_install_writes_config_and_hooks_json( assert _install_hook() == 0 - assert ( - config_file.read_text(encoding="utf-8") - == "[features]\nhooks = true\n" - ) + assert config_file.read_text(encoding="utf-8") == "[features]\nhooks = true\n" hooks_payload = json.loads(hooks_file.read_text(encoding="utf-8")) assert hooks_payload == { "hooks": { @@ -338,10 +335,7 @@ def test_install_is_idempotent_and_enables_feature( assert _install_hook() == 0 - assert ( - config_file.read_text(encoding="utf-8") - == "[features]\nhooks = true\n" - ) + assert config_file.read_text(encoding="utf-8") == "[features]\nhooks = true\n" hooks_payload = json.loads(hooks_file.read_text(encoding="utf-8")) assert len(hooks_payload["hooks"]["SessionStart"]) == 1 assert "Hook already installed" in capsys.readouterr().out