claude webhook with tunnel (#7)

* claude webhook with tunnel

* comments

* bump

---------

Co-authored-by: Kartik Sarangmath <kartiksarangmath@Kartiks-MacBook-Air.local>
This commit is contained in:
ksarangmath
2025-07-11 12:54:37 -07:00
committed by GitHub
parent d2c4101b4f
commit 166565d975
4 changed files with 313 additions and 60 deletions

152
omnara/cli.py Normal file
View File

@@ -0,0 +1,152 @@
"""Omnara Main Entry Point
This is the main entry point for the omnara command that dispatches to either:
- MCP stdio server (default or with --stdio)
- Claude Code webhook server (with --claude-code-webhook)
"""
import argparse
import sys
import subprocess
import time
def run_stdio_server(args):
"""Run the MCP stdio server with the provided arguments"""
cmd = [
sys.executable,
"-m",
"servers.mcp_server.stdio_server",
"--api-key",
args.api_key,
]
if args.base_url:
cmd.extend(["--base-url", args.base_url])
subprocess.run(cmd)
def run_webhook_server(cloudflare_tunnel=False):
"""Run the Claude Code webhook FastAPI server"""
cloudflared_process = None
if cloudflare_tunnel:
try:
test_cmd = ["cloudflared", "--version"]
subprocess.run(test_cmd, capture_output=True, check=True)
print("[INFO] Starting Cloudflare tunnel...")
cloudflared_cmd = [
"cloudflared",
"tunnel",
"--url",
"http://localhost:6662",
]
cloudflared_process = subprocess.Popen(cloudflared_cmd)
# Give cloudflared a moment to start
time.sleep(3)
if cloudflared_process.poll() is not None:
print("\n[ERROR] Cloudflare tunnel failed to start")
sys.exit(1)
except (subprocess.CalledProcessError, FileNotFoundError):
print("\n[ERROR] cloudflared is not installed!")
print("Please install cloudflared to use the --cloudflare-tunnel option.")
print(
"Visit: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/"
)
print("for installation instructions.")
sys.exit(1)
cmd = [
sys.executable,
"-m",
"uvicorn",
"webhooks.claude_code:app",
"--host",
"0.0.0.0",
"--port",
"6662",
]
print("[INFO] Starting Claude Code webhook server on port 6662")
try:
subprocess.run(cmd)
finally:
if cloudflared_process:
cloudflared_process.terminate()
cloudflared_process.wait()
def main():
"""Main entry point that dispatches based on command line arguments"""
parser = argparse.ArgumentParser(
description="Omnara - AI Agent Dashboard and Tools",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# Run MCP stdio server (default)
omnara --api-key YOUR_API_KEY
# Run MCP stdio server explicitly
omnara --stdio --api-key YOUR_API_KEY
# Run Claude Code webhook server
omnara --claude-code-webhook
# Run webhook server with Cloudflare tunnel
omnara --claude-code-webhook --cloudflare-tunnel
# Run with custom API base URL
omnara --stdio --api-key YOUR_API_KEY --base-url http://localhost:8000
""",
)
# Add mutually exclusive group for server modes
mode_group = parser.add_mutually_exclusive_group()
mode_group.add_argument(
"--stdio",
action="store_true",
help="Run the MCP stdio server (default if no mode specified)",
)
mode_group.add_argument(
"--claude-code-webhook",
action="store_true",
help="Run the Claude Code webhook server",
)
# Arguments for webhook mode
parser.add_argument(
"--cloudflare-tunnel",
action="store_true",
help="Run Cloudflare tunnel for the webhook server (webhook mode only)",
)
# Arguments for stdio mode
parser.add_argument(
"--api-key", help="API key for authentication (required for stdio mode)"
)
parser.add_argument(
"--base-url",
default="https://agent-dashboard-mcp.onrender.com",
help="Base URL of the Omnara API server (stdio mode only)",
)
args = parser.parse_args()
if args.cloudflare_tunnel and not args.claude_code_webhook:
parser.error("--cloudflare-tunnel can only be used with --claude-code-webhook")
if args.claude_code_webhook:
run_webhook_server(cloudflare_tunnel=args.cloudflare_tunnel)
else:
if not args.api_key:
parser.error("--api-key is required for stdio mode")
run_stdio_server(args)
if __name__ == "__main__":
main()

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "omnara"
version = "1.2.0"
version = "1.3.0"
description = "Omnara Agent Dashboard - MCP Server and Python SDK"
readme = "README.md"
requires-python = ">=3.11"
@@ -21,12 +21,13 @@ classifiers = [
"Programming Language :: Python :: 3.12",
]
dependencies = [
# Core SDK dependencies
"requests>=2.25.0",
"urllib3>=1.26.0",
"aiohttp>=3.8.0",
# MCP server dependency
"fastmcp==2.9.2",
"fastapi>=0.100.0",
"uvicorn>=0.20.0",
"pydantic>=2.0.0",
]
[project.urls]
@@ -35,10 +36,10 @@ Repository = "https://github.com/omnara-ai/omnara"
Issues = "https://github.com/omnara-ai/omnara/issues"
[project.scripts]
omnara = "servers.mcp_server.stdio_server:main"
omnara = "omnara.cli:main"
[tool.setuptools.packages.find]
include = ["omnara*", "servers.mcp_server*", "servers.shared*"]
include = ["omnara*", "servers.mcp_server*", "servers.shared*", "webhooks*"]
[tool.pytest.ini_options]

1
webhooks/__init__.py Normal file
View File

@@ -0,0 +1 @@
# Webhooks package

View File

@@ -1,85 +1,184 @@
from fastapi import FastAPI, Request
from fastapi import FastAPI, HTTPException, Header, Request
import subprocess
import shlex
from datetime import datetime
import jwt
import secrets
import string
import os
from datetime import timezone, timedelta
import re
import uuid
import uvicorn
from contextlib import asynccontextmanager
from pydantic import BaseModel, field_validator
MAX_PROMPT_LENGTH = 10000
class WebhookRequest(BaseModel):
agent_instance_id: str
prompt: str
name: str | None = None
@field_validator("agent_instance_id")
def validate_instance_id(cls, v):
try:
uuid.UUID(v)
return v
except ValueError:
raise ValueError("Invalid UUID format for agent_instance_id")
@field_validator("prompt")
def validate_prompt(cls, v):
if len(v) > MAX_PROMPT_LENGTH:
raise ValueError(f"Prompt too long (max {MAX_PROMPT_LENGTH} characters)")
return v
@field_validator("name")
def validate_name(cls, v):
if v is not None:
if not re.match(r"^[a-zA-Z0-9-]+$", v):
raise ValueError("Name must contain only letters, numbers, and hyphens")
if len(v) > 50:
raise ValueError("Name must be 50 characters or less")
return v
@asynccontextmanager
async def lifespan(app: FastAPI):
token = create_bearer_token()
print(f"[remote_cc] Bearer token: {token}")
secret = os.environ.get("CLAUDE_WEBHOOK_SECRET")
if not secret:
secret = secrets.token_urlsafe(12)
app.state.webhook_secret = secret
print(f"[IMPORTANT] Webhook secret: {secret}")
print("[IMPORTANT] Use this secret in the Authorization header as: Bearer <secret>")
yield
if hasattr(app.state, "webhook_secret"):
delattr(app.state, "webhook_secret")
app = FastAPI(lifespan=lifespan)
SYSTEM_PROMPT = """
Use the Omnara MCP server to log steps and ask questions.
Ensure this is the only way you communicate with the user from now on,
until the task is complete. Start by asking the user what they want to do, through Omnara.
Use the Omnara MCP server to log steps and ask questions.
Ensure this is the only way you communicate with the user from now on,
until the task is complete. Start by asking the user what they want to do, through Omnara.
If a session has already started, continue to communicate through Omnara.
The following agent-instance-id must be used in Omnara: {{agent_instance_id}}
"""
def generate_secret():
if "CLAUDE_SECRET" not in os.environ:
alphabet = string.ascii_letters + string.digits
random_alphanumeric = "".join(secrets.choice(alphabet) for _ in range(16))
os.environ["CLAUDE_SECRET"] = random_alphanumeric
return os.environ["CLAUDE_SECRET"]
def create_bearer_token():
secret = generate_secret()
return jwt.encode(
{"secret": secret, "exp": datetime.now(timezone.utc) + timedelta(hours=24)},
secret,
algorithm="HS256",
)
def verify_bearer_token(token: str):
try:
jwt.decode(token, os.environ["CLAUDE_SECRET"], algorithms=["HS256"])
return True
except jwt.ExpiredSignatureError:
def verify_auth(request: Request, authorization: str = Header(None)) -> bool:
"""Verify the authorization header contains the correct secret"""
if not authorization:
return False
parts = authorization.split()
if len(parts) != 2 or parts[0].lower() != "bearer":
return False
provided_secret = parts[1]
expected_secret = getattr(request.app.state, "webhook_secret", None)
if not expected_secret:
return False
return secrets.compare_digest(provided_secret, expected_secret)
@app.post("/")
async def start_claude(request: Request):
data = await request.json()
agent_instance_id = data.get("agent_instance_id")
prompt = (
SYSTEM_PROMPT.replace("{{agent_instance_id}}", str(agent_instance_id))
+ f"\n\n\n{data.get('prompt')}"
)
async def start_claude(
request: Request, webhook_data: WebhookRequest, authorization: str = Header(None)
):
if not verify_auth(request, authorization):
raise HTTPException(status_code=401, detail="Invalid or missing authorization")
agent_instance_id = webhook_data.agent_instance_id
prompt = webhook_data.prompt
name = webhook_data.name
safe_prompt = SYSTEM_PROMPT.replace("{{agent_instance_id}}", agent_instance_id)
safe_prompt += f"\n\n\n{prompt}"
now = datetime.now()
timestamp_str = now.strftime("%Y%m%d%H%M%S")
feature_branch_name = f"claude-feature-{timestamp_str}"
subprocess.run(
[
"git",
"worktree",
"add",
f"./{feature_branch_name}",
"-b",
feature_branch_name,
]
)
subprocess.Popen(
["claude", "--dangerously-skip-permissions", prompt],
cwd=f"./{feature_branch_name}",
)
safe_timestamp = re.sub(r"[^a-zA-Z0-9-]", "", timestamp_str)
return {"message": "Successfully started claude!"}
prefix = name if name else "omnara-claude"
feature_branch_name = f"{prefix}-{safe_timestamp}"
work_dir = os.path.abspath(f"./{feature_branch_name}")
base_dir = os.path.abspath(".")
if not work_dir.startswith(base_dir):
raise HTTPException(status_code=400, detail="Invalid working directory")
try:
result = subprocess.run(
[
"git",
"worktree",
"add",
work_dir,
"-b",
feature_branch_name,
],
capture_output=True,
text=True,
timeout=30,
cwd=base_dir,
)
if result.returncode != 0:
raise HTTPException(
status_code=500, detail=f"Failed to create worktree: {result.stderr}"
)
screen_prefix = name if name else "omnara-claude"
screen_name = f"{screen_prefix}-{safe_timestamp}"
escaped_prompt = shlex.quote(safe_prompt)
claude_cmd = f"claude --dangerously-skip-permissions {escaped_prompt}"
screen_result = subprocess.run(
["screen", "-dmS", screen_name, "bash", "-c", claude_cmd],
cwd=work_dir,
capture_output=True,
text=True,
timeout=10,
env={**os.environ, "CLAUDE_INSTANCE_ID": agent_instance_id},
)
if screen_result.returncode != 0:
raise HTTPException(
status_code=500,
detail=f"Failed to start screen session: {screen_result.stderr}",
)
print(f"[INFO] Started screen session: {screen_name}")
print(f"[INFO] To attach: screen -r {screen_name}")
return {
"message": "Successfully started claude",
"branch": feature_branch_name,
"screen_session": screen_name,
}
except subprocess.TimeoutExpired:
raise HTTPException(status_code=500, detail="Git operation timed out")
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to start claude: {str(e)}")
@app.get("/health")
async def health_check():
"""Health check endpoint - no auth required"""
return {"status": "healthy"}
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=6662)