Skip to content
Closed
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
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
LAYERCODE_API_KEY=
LAYERCODE_WEBHOOK_SECRET=

# Optional LayerCode agent ID (for --unsafe-update-webhook)
LAYERCODE_AGENT_ID=

# Optional observability
LOGFIRE_TOKEN=

Expand Down
35 changes: 35 additions & 0 deletions src/layercode_create_app/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ def run(
tunnel: bool,
verbose: bool,
env_file: str,
agent_id: str | None,
unsafe_update_webhook: bool,
) -> None:
"""Start the FastAPI server with optional Cloudflare tunnel."""

Expand All @@ -57,6 +59,9 @@ def run(
"authorize_route": authorize_route,
}

if agent_id:
overrides["layercode_agent_id"] = agent_id

settings = base_settings.model_copy(update=overrides)

if not settings.layercode_api_key:
Expand All @@ -67,6 +72,18 @@ def run(
print("Error: Missing LAYERCODE_WEBHOOK_SECRET in environment or overrides.")
sys.exit(1)

# Validate unsafe_update_webhook requirements
if unsafe_update_webhook:
if not tunnel:
print("Error: --unsafe-update-webhook requires --tunnel flag")
sys.exit(1)
if not settings.layercode_agent_id:
print(
"Error: --unsafe-update-webhook requires LAYERCODE_AGENT_ID "
"env var or --agent-id argument"
)
sys.exit(1)

chosen_model = model if model else settings.default_model

try:
Expand Down Expand Up @@ -98,6 +115,9 @@ async def serve() -> None:
settings.port,
settings.agent_route,
settings.cloudflare_bin,
settings.layercode_agent_id,
settings.layercode_api_key,
unsafe_update_webhook,
)

async def run_server() -> None:
Expand Down Expand Up @@ -172,6 +192,19 @@ def main() -> None:
action="store_true",
help="Launch a Cloudflare tunnel alongside the server",
)
run_parser.add_argument(
"--agent-id",
default=None,
help="Agent ID for webhook updates (overrides LAYERCODE_AGENT_ID env var)",
)
run_parser.add_argument(
"--unsafe-update-webhook",
action="store_true",
help=(
"Automatically update agent webhook URL when using --tunnel "
"(requires --agent-id or LAYERCODE_AGENT_ID)"
),
)
run_parser.add_argument(
"--verbose",
"-v",
Expand Down Expand Up @@ -199,6 +232,8 @@ def main() -> None:
tunnel=args.tunnel,
verbose=args.verbose,
env_file=args.env_file,
agent_id=args.agent_id,
unsafe_update_webhook=args.unsafe_update_webhook,
)
else:
parser.print_help()
Expand Down
1 change: 1 addition & 0 deletions src/layercode_create_app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ class AppSettings(BaseSettings):

layercode_api_key: str | None = None
layercode_webhook_secret: str | None = None
layercode_agent_id: str | None = None

logfire_token: str | None = None

Expand Down
176 changes: 160 additions & 16 deletions src/layercode_create_app/tunnel.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,31 +8,100 @@
import shutil
from datetime import datetime
from pathlib import Path
from typing import Any
from typing import Any, cast

import httpx
from loguru import logger

TUNNEL_PATTERN = re.compile(r"https://[a-z0-9-]+\.trycloudflare\.com", re.IGNORECASE)
LAYERCODE_API_BASE = "https://api.layercode.com/v1"


class CloudflareTunnelLauncher:
"""Launches a Cloudflare quick tunnel and surfaces the webhook URL."""

def __init__(self, host: str, port: int, agent_route: str, binary: str = "cloudflared") -> None:
def __init__(
self,
host: str,
port: int,
agent_route: str,
binary: str = "cloudflared",
agent_id: str | None = None,
api_key: str | None = None,
update_webhook: bool = False,
) -> None:
self.host = host
self.port = port
self.agent_route = agent_route
self.binary = binary
self.agent_id = agent_id
self.api_key = api_key
self.update_webhook = update_webhook
self.process: asyncio.subprocess.Process | None = None
self._stdout_task: asyncio.Task[None] | None = None
self._stderr_task: asyncio.Task[None] | None = None
self._tunnel_url: str | None = None
self._log_file_handle: Any | None = None
self._previous_webhook_url: str | None = None
self._http_client: httpx.AsyncClient | None = None

# Create log file for tunnel output
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
self.log_file_path = Path(f"cloudflare_tunnel_{timestamp}.log")

async def _get_agent_details(self) -> dict[str, Any] | None:
"""Fetch current agent details from Layercode API."""
if not self.agent_id or not self.api_key:
return None

if not self._http_client:
self._http_client = httpx.AsyncClient(timeout=httpx.Timeout(15.0, connect=5.0))

try:
response = await self._http_client.get(
f"{LAYERCODE_API_BASE}/agents/{self.agent_id}",
headers={"Authorization": f"Bearer {self.api_key}"},
)
response.raise_for_status()
return cast(dict[str, Any], response.json())
except httpx.HTTPStatusError as exc:
logger.warning(
f"Failed to fetch agent details (status {exc.response.status_code}): "
f"{exc.response.text}"
)
return None
except httpx.RequestError as exc:
logger.warning(f"Failed to reach Layercode API: {exc}")
return None

async def _update_agent_webhook(self, webhook_url: str) -> bool:
"""Update agent webhook URL via Layercode API."""
if not self.agent_id or not self.api_key:
return False

if not self._http_client:
self._http_client = httpx.AsyncClient(timeout=httpx.Timeout(15.0, connect=5.0))

try:
response = await self._http_client.post(
f"{LAYERCODE_API_BASE}/agents/{self.agent_id}",
headers={
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
},
json={"webhook_url": webhook_url},
)
response.raise_for_status()
return True
except httpx.HTTPStatusError as exc:
logger.error(
f"Failed to update webhook (status {exc.response.status_code}): {exc.response.text}"
)
return False
except httpx.RequestError as exc:
logger.error(f"Failed to reach Layercode API: {exc}")
return False

async def start(self, timeout_seconds: float = 30.0) -> str:
"""Start the tunnel and return the webhook URL."""

Expand Down Expand Up @@ -114,7 +183,29 @@ async def monitor_streams() -> None:

logger.info("Tunnel established successfully")
logger.info(f"Webhook URL: {webhook_url}")
_print_banner(tunnel_url, webhook_url)

# Update webhook if requested
if self.update_webhook and self.agent_id:
logger.info(f"Fetching current webhook for agent {self.agent_id}...")
agent_details = await self._get_agent_details()
if agent_details:
self._previous_webhook_url = agent_details.get("webhook_url")
if self._previous_webhook_url:
logger.warning(
f"⚠️ Updating webhook from {self._previous_webhook_url} to {webhook_url}"
)
else:
logger.info(f"Agent has no previous webhook, setting to {webhook_url}")

success = await self._update_agent_webhook(webhook_url)
if success:
logger.info("✓ Webhook updated successfully")
else:
logger.error("✗ Failed to update webhook - continuing with tunnel anyway")
else:
logger.warning("Could not fetch agent details - skipping webhook update")

_print_banner(tunnel_url, webhook_url, self.update_webhook)
return webhook_url

async def _scan_for_url(
Expand Down Expand Up @@ -158,6 +249,44 @@ async def _drain_stream(self, stream: asyncio.StreamReader, stream_name: str) ->

async def stop(self) -> None:
"""Stop the tunnel process and clean up resources."""
# Restore webhook if it was updated and hasn't been changed by someone else
if self.update_webhook and self.agent_id and self._tunnel_url:
logger.info("Checking if webhook should be restored...")
agent_details = await self._get_agent_details()
if agent_details:
current_webhook = agent_details.get("webhook_url")
webhook_path = self.agent_route.lstrip("/")
our_webhook = (
f"{self._tunnel_url}/{webhook_path}" if webhook_path else self._tunnel_url
)

if current_webhook == our_webhook:
# Webhook is still ours, restore the previous one
if self._previous_webhook_url:
logger.info(f"Restoring webhook to {self._previous_webhook_url}")
success = await self._update_agent_webhook(self._previous_webhook_url)
if success:
logger.info("✓ Webhook restored successfully")
else:
logger.warning("✗ Failed to restore webhook")
elif self._previous_webhook_url is None:
# There was no previous webhook, clear it
logger.info("Clearing webhook (no previous webhook existed)")
success = await self._update_agent_webhook("")
if success:
logger.info("✓ Webhook cleared successfully")
else:
logger.warning("✗ Failed to clear webhook")
else:
logger.info(f"Webhook has been changed to {current_webhook}, leaving it as is")
else:
logger.warning("Could not fetch agent details - skipping webhook restore")

# Close HTTP client
if self._http_client:
await self._http_client.aclose()
self._http_client = None

if self.process and self.process.returncode is None:
self.process.terminate()
try:
Expand All @@ -182,19 +311,34 @@ async def stop(self) -> None:
logger.info(f"Tunnel logs saved to: {self.log_file_path.absolute()}")


def _print_banner(tunnel_url: str, webhook_url: str) -> None:
def _print_banner(tunnel_url: str, webhook_url: str, webhook_updated: bool = False) -> None:
"""Print a prominent banner with tunnel URLs."""
border = "=" * 70
message = (
f"\n\n{border}\n"
f"{border}\n"
" ✓ CLOUDFLARE TUNNEL ESTABLISHED\n"
f"{border}\n\n"
f" Webhook URL: {webhook_url}\n\n"
f"{border}\n"
" IMPORTANT: Add this webhook URL to your LayerCode agent:\n"
" https://dash.layercode.com/\n"
f"{border}\n"
f"{border}\n\n"
)

if webhook_updated:
message = (
f"\n\n{border}\n"
f"{border}\n"
" ✓ CLOUDFLARE TUNNEL ESTABLISHED\n"
f"{border}\n\n"
f" Webhook URL: {webhook_url}\n"
" Status: ✓ Webhook automatically updated\n\n"
f"{border}\n"
f"{border}\n\n"
)
else:
message = (
f"\n\n{border}\n"
f"{border}\n"
" ✓ CLOUDFLARE TUNNEL ESTABLISHED\n"
f"{border}\n\n"
f" Webhook URL: {webhook_url}\n\n"
f"{border}\n"
" IMPORTANT: Add this webhook URL to your LayerCode agent:\n"
" https://dash.layercode.com/\n\n"
" 💡 TIP: Use --unsafe-update-webhook to automatically update\n"
" the webhook for your agent (requires LAYERCODE_AGENT_ID)\n"
f"{border}\n"
f"{border}\n\n"
)
print(message, flush=True)
Loading