Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
11 changes: 9 additions & 2 deletions README_CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
118 changes: 118 additions & 0 deletions scripts/bootstrap-linux.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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 <<EOF
Unsafe TelegramCodexBot checkout location:
checkout: $repo_path
project root: $unsafe_root

The systemd launcher points to this checkout. If a Codex session cleans the
project root, the bot can keep running from deleted files and fail on restart.

Clone TelegramCodexBot into a durable runtime path outside project roots, for example:
mkdir -p "$TELEGRAM_CODEX_BOT_DIR/app"
git clone https://github.com/Pigbibi/TelegramCodexBot.git "$TELEGRAM_CODEX_BOT_DIR/app/TelegramCodexBot"
cd "$TELEGRAM_CODEX_BOT_DIR/app/TelegramCodexBot"
./scripts/bootstrap-linux.sh

To intentionally allow this unsafe layout, set:
TELEGRAM_CODEX_BOT_ALLOW_PROJECTS_CHECKOUT=true
EOF
exit 1
fi
}

check_runtime_checkout_location

cat >"$LAUNCHER_PATH" <<EOF
#!/usr/bin/env bash
export PATH="$PATH_VALUE"
Expand Down
97 changes: 97 additions & 0 deletions tests/telegram_codex_bot/test_bootstrap_linux.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
from __future__ import annotations

import os
import shutil
import stat
import subprocess
from pathlib import Path


REPO_ROOT = Path(__file__).resolve().parents[2]
BOOTSTRAP_SCRIPT = REPO_ROOT / "scripts" / "bootstrap-linux.sh"


def _write_executable(path: Path, content: str) -> 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")
10 changes: 2 additions & 8 deletions tests/telegram_codex_bot/test_hook.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -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
Expand Down
Loading