diff --git a/agent/bootstrap.sh b/agent/bootstrap.sh index 4b67333..a2642f3 100755 --- a/agent/bootstrap.sh +++ b/agent/bootstrap.sh @@ -93,8 +93,11 @@ fi # the symlinks track agent/ as new helpers ship. Idempotent (ln -sfn). ln -sfn "$REPO_DIR/agent/tg-send" /usr/local/bin/tg-send ln -sfn "$REPO_DIR/agent/tg-buttons" /usr/local/bin/tg-buttons +ln -sfn "$REPO_DIR/agent/tg-schedule" /usr/local/bin/tg-schedule +ln -sfn "$REPO_DIR/agent/tg-schedule-fire" /usr/local/bin/tg-schedule-fire ln -sfn "$REPO_DIR/agent/agency-report" /usr/local/bin/agency-report ln -sfn "$REPO_DIR/agent/bux-restart" /usr/local/bin/bux-restart +ln -sfn "$REPO_DIR/agent/bux-miniapp-tunnel" /usr/local/bin/bux-miniapp-tunnel # --- ~/AGENTS.md symlink for codex ----------------------------------------- # install.sh creates `/home/bux/AGENTS.md → /home/bux/CLAUDE.md` on first @@ -126,6 +129,11 @@ chown -h bux:bux /home/bux/.claude/skills/agency/SKILL.md # first use). Make sure the directory is writable by `bux` so any # agency-report invocation can init the schema without sudo. install -d -o bux -g bux -m 0755 /var/lib/bux +install -d -o bux -g bux -m 0755 /var/lib/bux/miniapp-tunnel +for agency_db_file in /var/lib/bux/agency.db*; do + [ -e "$agency_db_file" ] || continue + chown bux:bux "$agency_db_file" +done # --- Cloud Composio MCP server (cloud-side proxy) ------------------------- # Why MCP at all: cloud holds the platform's Composio API key plus every @@ -237,11 +245,12 @@ polkit.addRule(function(action, subject) { // bux-tg: agent restarts after writing /etc/bux/tg.env on install. // box-agent: agent restarts itself at the tail of self-update so // the new code takes effect. - // bux-browser-keeper / bux-ttyd / bux-miniapp: same self-update path. + // bux-browser-keeper / bux-ttyd / bux-miniapp / tunnel: same self-update path. if (unit == "bux-tg.service" || unit == "box-agent.service" || unit == "bux-browser-keeper.service" || unit == "bux-miniapp.service" || + unit == "bux-miniapp-tunnel.service" || unit == "bux-ttyd.service") { return polkit.Result.YES; } @@ -253,7 +262,7 @@ chmod 644 /etc/polkit-1/rules.d/50-bux-chat.rules # --- systemd units -------------------------------------------------------- # Symlink rather than copy so a `git pull` propagates without re-running # bootstrap. systemd reads via the symlink fine. -for unit in box-agent.service bux-ttyd.service bux-browser-keeper.service bux-tg.service bux-miniapp.service; do +for unit in box-agent.service bux-ttyd.service bux-browser-keeper.service bux-tg.service bux-miniapp.service bux-miniapp-tunnel.service; do ln -sf "$AGENT_DIR/$unit" "/etc/systemd/system/$unit" done @@ -273,7 +282,7 @@ cat > /etc/systemd/system/bux-boot-update.service <<'UNITEOF' Description=bux boot-time git pull + bootstrap After=network-online.target Wants=network-online.target -Before=box-agent.service bux-tg.service bux-browser-keeper.service bux-ttyd.service bux-miniapp.service +Before=box-agent.service bux-tg.service bux-browser-keeper.service bux-ttyd.service bux-miniapp.service bux-miniapp-tunnel.service [Service] Type=oneshot @@ -308,10 +317,11 @@ systemctl enable box-agent.service systemctl enable bux-ttyd.service systemctl enable bux-browser-keeper.service -# bux-tg and bux-miniapp stay enabled-but-conditional — only run once /etc/bux/tg.env -# is written by the agent's tg_install handler. +# bux-tg / bux-miniapp / bux-miniapp-tunnel stay enabled-but-conditional — +# only run once /etc/bux/tg.env is written by the agent's tg_install handler. systemctl enable bux-tg.service systemctl enable bux-miniapp.service +systemctl enable bux-miniapp-tunnel.service # Boot-time pull runs ahead of the others on every reboot. systemctl enable bux-boot-update.service @@ -339,6 +349,11 @@ fi if systemctl is-active --quiet bux-miniapp.service; then systemctl restart bux-miniapp.service fi +if systemctl is-active --quiet bux-miniapp-tunnel.service; then + systemctl restart bux-miniapp-tunnel.service +elif [ -f /etc/bux/tg.env ]; then + systemctl start bux-miniapp-tunnel.service 2>/dev/null || true +fi if systemctl is-active --quiet bux-browser-keeper.service; then systemctl restart bux-browser-keeper.service fi diff --git a/agent/box_agent.py b/agent/box_agent.py index 5d3d88b..3df0b52 100644 --- a/agent/box_agent.py +++ b/agent/box_agent.py @@ -1487,8 +1487,19 @@ async def _tg_install( rc = await start_proc.wait() if rc != 0: LOG.warning('systemctl start bux-tg exited rc=%s', rc) + for unit in ('bux-miniapp.service', 'bux-miniapp-tunnel.service'): + start_proc = await asyncio.create_subprocess_exec( + 'systemctl', + 'start', + unit, + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.DEVNULL, + ) + rc = await start_proc.wait() + if rc != 0: + LOG.warning('systemctl start %s exited rc=%s', unit, rc) except Exception: - LOG.exception('start bux-tg failed') + LOG.exception('start Telegram services failed') await self._send({'type': 'ack', 'cmd': 'tg_install', 'ok': True}) diff --git a/agent/bux-miniapp-tunnel b/agent/bux-miniapp-tunnel new file mode 100755 index 0000000..d13d3af --- /dev/null +++ b/agent/bux-miniapp-tunnel @@ -0,0 +1,146 @@ +#!/opt/bux/venv/bin/python +"""Keep a public HTTPS quick tunnel pointed at the local Mini App. + +The Telegram Mini App backend listens only on 127.0.0.1:8787, but Telegram +needs an HTTPS URL that the user's phone can reach. This wrapper runs +cloudflared as a long-lived systemd service, captures the generated +trycloudflare.com URL, persists it to /etc/bux/tg.env, and restarts bux-tg +when the URL changes so /agency can reuse it instead of minting a tunnel in +the command handler. +""" +from __future__ import annotations + +import os +import re +import shutil +import signal +import subprocess +import sys +import time +import urllib.error +import urllib.request +from pathlib import Path + + +TG_ENV = Path(os.environ.get("BUX_TG_ENV", "/etc/bux/tg.env")) +STATE_DIR = Path(os.environ.get("BUX_MINIAPP_TUNNEL_DIR", "/var/lib/bux/miniapp-tunnel")) +URL_FILE = STATE_DIR / "url" +LOCAL_SERVICE_URL = os.environ.get("BUX_MINIAPP_SERVICE_URL", "http://127.0.0.1:8787") +LOCAL_HEALTH_URL = os.environ.get("BUX_MINIAPP_HEALTH_URL", f"{LOCAL_SERVICE_URL}/health") +PUBLIC_URL_KEY = "BUX_MINIAPP_PUBLIC_URL" +TUNNEL_URL_RE = re.compile(r"https://[A-Za-z0-9-]+\.trycloudflare\.com") + + +def _log(message: str) -> None: + print(message, flush=True) + + +def _extract_tunnel_url(line: str) -> str | None: + match = TUNNEL_URL_RE.search(line) + return match.group(0) if match else None + + +def _write_env_value(path: Path, key: str, value: str) -> bool: + old_text = path.read_text(encoding="utf-8") if path.exists() else "" + lines = old_text.splitlines() + prefix = f"{key}=" + out: list[str] = [] + written = False + for line in lines: + if line.strip().startswith(prefix): + if not written: + out.append(f"{key}={value}") + written = True + continue + out.append(line) + if not written: + out.append(f"{key}={value}") + + new_text = "\n".join(out) + "\n" + if old_text == new_text: + return False + + stat = path.stat() if path.exists() else None + tmp = path.with_name(f".{path.name}.tmp.{os.getpid()}") + tmp.write_text(new_text, encoding="utf-8") + if stat is not None: + os.chown(tmp, stat.st_uid, stat.st_gid) + os.chmod(tmp, stat.st_mode & 0o777) + else: + os.chmod(tmp, 0o640) + os.replace(tmp, path) + return True + + +def _wait_for_local_backend(timeout_sec: float = 30.0) -> bool: + deadline = time.time() + timeout_sec + while time.time() < deadline: + try: + with urllib.request.urlopen(LOCAL_HEALTH_URL, timeout=1) as resp: + if resp.status < 500: + return True + except (OSError, urllib.error.URLError): + pass + time.sleep(1) + return False + + +def _persist_url(url: str) -> bool: + STATE_DIR.mkdir(parents=True, exist_ok=True) + URL_FILE.write_text(url + "\n", encoding="utf-8") + changed = _write_env_value(TG_ENV, PUBLIC_URL_KEY, url) + _log(f"miniapp tunnel URL {'changed' if changed else 'unchanged'}: {url}") + if changed: + result = subprocess.run( + ["systemctl", "restart", "bux-tg.service"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ) + if result.returncode != 0: + _log(f"systemctl restart bux-tg.service exited rc={result.returncode}") + return changed + + +def main() -> int: + cloudflared = os.environ.get("CLOUDFLARED") or shutil.which("cloudflared") + if not cloudflared: + _log("cloudflared not found") + return 127 + + STATE_DIR.mkdir(parents=True, exist_ok=True) + if not _wait_for_local_backend(): + _log(f"Mini App backend is not healthy at {LOCAL_HEALTH_URL}") + return 1 + + proc = subprocess.Popen( + [cloudflared, "tunnel", "--url", LOCAL_SERVICE_URL, "--no-autoupdate"], + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + text=True, + ) + + def _stop(_signum: int, _frame: object) -> None: + if proc.poll() is None: + proc.terminate() + + signal.signal(signal.SIGTERM, _stop) + signal.signal(signal.SIGINT, _stop) + + assert proc.stderr is not None + for raw in proc.stderr: + line = raw.rstrip() + if line: + _log(f"cloudflared: {line}") + url = _extract_tunnel_url(line) + if url: + try: + _persist_url(url) + except Exception as exc: + _log(f"failed to persist Mini App tunnel URL: {exc}") + + return proc.wait() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/agent/bux-miniapp-tunnel.service b/agent/bux-miniapp-tunnel.service new file mode 100644 index 0000000..d3c8873 --- /dev/null +++ b/agent/bux-miniapp-tunnel.service @@ -0,0 +1,21 @@ +[Unit] +Description=bux Mini App public quick tunnel +After=network-online.target bux-miniapp.service +Wants=network-online.target bux-miniapp.service +ConditionPathExists=/etc/bux/tg.env +ConditionPathExists=/usr/local/bin/cloudflared + +[Service] +Type=simple +User=root +Group=root +EnvironmentFile=/etc/bux/tg.env +WorkingDirectory=/opt/bux/agent +ExecStart=/opt/bux/venv/bin/python /opt/bux/agent/bux-miniapp-tunnel +Restart=always +RestartSec=5 +StandardOutput=append:/var/log/bux/miniapp-tunnel.log +StandardError=append:/var/log/bux/miniapp-tunnel.log + +[Install] +WantedBy=multi-user.target diff --git a/agent/telegram_bot.py b/agent/telegram_bot.py index 7210b77..33016eb 100644 --- a/agent/telegram_bot.py +++ b/agent/telegram_bot.py @@ -75,6 +75,9 @@ STATE_FILE = Path("/etc/bux/tg-state.json") QUEUE_FILE = Path("/etc/bux/tg-queue.json") MINIAPP_DB = Path(os.environ.get("BUX_MINIAPP_DB", "/var/lib/bux/miniapp.db")) +MINIAPP_TUNNEL_URL_FILE = Path( + os.environ.get("BUX_MINIAPP_TUNNEL_URL_FILE", "/var/lib/bux/miniapp-tunnel/url") +) # Marker for "I've already told the user about this SHA". Lets transient # bux-tg restarts (systemd flaps, polling backoff) stay silent while @@ -801,6 +804,29 @@ def _write_tg_env_value(key: str, value: str) -> None: _chmod_root_bux_640(TG_ENV) +def _miniapp_public_url_from_env() -> str: + url = ( + os.environ.get("BUX_MINIAPP_PUBLIC_URL", "").strip() + or _read_kv(TG_ENV).get("BUX_MINIAPP_PUBLIC_URL", "").strip() + ) + if url: + return url + try: + return MINIAPP_TUNNEL_URL_FILE.read_text().strip() + except Exception: + return "" + + +def _wait_for_miniapp_public_url(timeout_sec: float = 10.0) -> str: + deadline = time.time() + timeout_sec + while time.time() < deadline: + url = _miniapp_public_url_from_env() + if url: + return url + time.sleep(0.25) + return _miniapp_public_url_from_env() + + def _start_miniapp_service() -> None: try: subprocess.run( @@ -814,6 +840,19 @@ def _start_miniapp_service() -> None: LOG.exception("miniapp: failed to start bux-miniapp.service before launch") +def _start_miniapp_tunnel_service() -> None: + try: + subprocess.run( + ["systemctl", "start", "bux-miniapp-tunnel.service"], + timeout=5, + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + except Exception: + LOG.exception("miniapp: failed to start bux-miniapp-tunnel.service before launch") + + def _miniapp_local_error() -> str | None: deadline = time.time() + 5 last = "" @@ -841,11 +880,12 @@ def _miniapp_public_error(url: str) -> str | None: def _ensure_miniapp_public_url() -> tuple[str, str | None]: _start_miniapp_service() + _start_miniapp_tunnel_service() local_error = _miniapp_local_error() if local_error: return "", local_error - url = os.environ.get("BUX_MINIAPP_PUBLIC_URL", "").strip() + url = _wait_for_miniapp_public_url() if url: if not url.startswith("https://"): return "", "`BUX_MINIAPP_PUBLIC_URL` must start with https:// for Telegram." diff --git a/agent/test_bux_miniapp_tunnel.py b/agent/test_bux_miniapp_tunnel.py new file mode 100644 index 0000000..dcdc392 --- /dev/null +++ b/agent/test_bux_miniapp_tunnel.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +import importlib.machinery +import importlib.util +import os +import tempfile +import unittest +from pathlib import Path + + +AGENT_DIR = Path(__file__).resolve().parent + + +def _load_module(): + loader = importlib.machinery.SourceFileLoader( + "bux_miniapp_tunnel", str(AGENT_DIR / "bux-miniapp-tunnel") + ) + spec = importlib.util.spec_from_loader(loader.name, loader) + assert spec is not None + module = importlib.util.module_from_spec(spec) + loader.exec_module(module) + return module + + +class MiniAppTunnelTest(unittest.TestCase): + def setUp(self) -> None: + self.module = _load_module() + self.tmp = tempfile.TemporaryDirectory() + self.env_path = Path(self.tmp.name) / "tg.env" + + def tearDown(self) -> None: + self.tmp.cleanup() + + def test_extract_tunnel_url_from_cloudflared_line(self) -> None: + line = "| https://personals-success-sharon-guidelines.trycloudflare.com |" + + self.assertEqual( + self.module._extract_tunnel_url(line), + "https://personals-success-sharon-guidelines.trycloudflare.com", + ) + + def test_write_env_value_adds_url_and_preserves_existing_values(self) -> None: + self.env_path.write_text("TG_BOT_TOKEN=token\nTG_OWNER_ID=42\n", encoding="utf-8") + + changed = self.module._write_env_value( + self.env_path, + "BUX_MINIAPP_PUBLIC_URL", + "https://example.trycloudflare.com", + ) + + self.assertTrue(changed) + self.assertEqual( + self.env_path.read_text(encoding="utf-8"), + "TG_BOT_TOKEN=token\nTG_OWNER_ID=42\nBUX_MINIAPP_PUBLIC_URL=https://example.trycloudflare.com\n", + ) + + def test_write_env_value_replaces_duplicates_once(self) -> None: + os.chmod(self.env_path.parent, 0o700) + self.env_path.write_text( + "TG_BOT_TOKEN=token\n" + "BUX_MINIAPP_PUBLIC_URL=https://old.trycloudflare.com\n" + "BUX_MINIAPP_PUBLIC_URL=https://older.trycloudflare.com\n", + encoding="utf-8", + ) + + changed = self.module._write_env_value( + self.env_path, + "BUX_MINIAPP_PUBLIC_URL", + "https://new.trycloudflare.com", + ) + + self.assertTrue(changed) + self.assertEqual( + self.env_path.read_text(encoding="utf-8"), + "TG_BOT_TOKEN=token\nBUX_MINIAPP_PUBLIC_URL=https://new.trycloudflare.com\n", + ) + + def test_write_env_value_is_noop_when_unchanged(self) -> None: + self.env_path.write_text( + "BUX_MINIAPP_PUBLIC_URL=https://same.trycloudflare.com\n", + encoding="utf-8", + ) + + changed = self.module._write_env_value( + self.env_path, + "BUX_MINIAPP_PUBLIC_URL", + "https://same.trycloudflare.com", + ) + + self.assertFalse(changed) + + def test_write_env_value_dedupes_even_when_first_value_matches(self) -> None: + self.env_path.write_text( + "BUX_MINIAPP_PUBLIC_URL=https://same.trycloudflare.com\n" + "BUX_MINIAPP_PUBLIC_URL=https://stale.trycloudflare.com\n", + encoding="utf-8", + ) + + changed = self.module._write_env_value( + self.env_path, + "BUX_MINIAPP_PUBLIC_URL", + "https://same.trycloudflare.com", + ) + + self.assertTrue(changed) + self.assertEqual( + self.env_path.read_text(encoding="utf-8"), + "BUX_MINIAPP_PUBLIC_URL=https://same.trycloudflare.com\n", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/agent/test_telegram_bot.py b/agent/test_telegram_bot.py index df5009c..ab68d41 100644 --- a/agent/test_telegram_bot.py +++ b/agent/test_telegram_bot.py @@ -1,7 +1,9 @@ from __future__ import annotations import json +import os import sys +import tempfile import unittest from pathlib import Path from unittest import mock @@ -90,6 +92,46 @@ def test_auth_and_quota_errors_trigger_login_picker_detection(self) -> None: self.assertTrue(telegram_bot._is_codex_auth_error("usage limit reached")) +class MiniAppLaunchTest(unittest.TestCase): + def test_public_url_can_be_read_from_tg_env_when_process_env_is_stale(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + tg_env = Path(tmp) / "tg.env" + tg_env.write_text( + "TG_BOT_TOKEN=token\n" + "BUX_MINIAPP_PUBLIC_URL=https://stable.trycloudflare.com\n", + encoding="utf-8", + ) + with mock.patch.object(telegram_bot, "TG_ENV", tg_env): + old = os.environ.pop("BUX_MINIAPP_PUBLIC_URL", None) + try: + self.assertEqual( + telegram_bot._miniapp_public_url_from_env(), + "https://stable.trycloudflare.com", + ) + finally: + if old is not None: + os.environ["BUX_MINIAPP_PUBLIC_URL"] = old + + def test_public_url_can_be_read_from_tunnel_url_file(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + tg_env = Path(tmp) / "tg.env" + tunnel_url = Path(tmp) / "url" + tunnel_url.write_text("https://file.trycloudflare.com\n", encoding="utf-8") + with ( + mock.patch.object(telegram_bot, "TG_ENV", tg_env), + mock.patch.object(telegram_bot, "MINIAPP_TUNNEL_URL_FILE", tunnel_url), + ): + old = os.environ.pop("BUX_MINIAPP_PUBLIC_URL", None) + try: + self.assertEqual( + telegram_bot._miniapp_public_url_from_env(), + "https://file.trycloudflare.com", + ) + finally: + if old is not None: + os.environ["BUX_MINIAPP_PUBLIC_URL"] = old + + class AgencyButtonPromptTest(unittest.TestCase): def test_custom_button_prompt_includes_card_context(self) -> None: prompt = telegram_bot._agency_build_custom_dispatch_prompt(