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
25 changes: 20 additions & 5 deletions agent/bootstrap.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
Expand All @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
13 changes: 12 additions & 1 deletion agent/box_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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})


Expand Down
146 changes: 146 additions & 0 deletions agent/bux-miniapp-tunnel
Original file line number Diff line number Diff line change
@@ -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())
21 changes: 21 additions & 0 deletions agent/bux-miniapp-tunnel.service
Original file line number Diff line number Diff line change
@@ -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
42 changes: 41 additions & 1 deletion agent/telegram_bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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 = ""
Expand Down Expand Up @@ -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."
Expand Down
Loading
Loading