mirror of
https://github.com/omnara-ai/omnara.git
synced 2025-08-12 20:39:09 +03:00
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:
152
omnara/cli.py
Normal file
152
omnara/cli.py
Normal 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()
|
||||
@@ -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
1
webhooks/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Webhooks package
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user