mirror of
https://github.com/omnara-ai/omnara.git
synced 2025-08-12 20:39:09 +03:00
Claude code hooks (#16)
* intermediate commit * auth sent to webhook * mock * mock --------- Co-authored-by: Kartik Sarangmath <kartiksarangmath@Kartiks-MacBook-Air.local>
This commit is contained in:
@@ -5,13 +5,15 @@ Database queries for UserAgent operations.
|
|||||||
import httpx
|
import httpx
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
import hashlib
|
||||||
|
|
||||||
from shared.database import UserAgent, AgentInstance, AgentStatus
|
from shared.database import UserAgent, AgentInstance, AgentStatus, APIKey
|
||||||
from sqlalchemy import and_, func
|
from sqlalchemy import and_, func
|
||||||
from sqlalchemy.orm import Session, joinedload
|
from sqlalchemy.orm import Session, joinedload
|
||||||
|
|
||||||
from ..models import UserAgentRequest, WebhookTriggerResponse
|
from ..models import UserAgentRequest, WebhookTriggerResponse
|
||||||
from .queries import _format_instance
|
from .queries import _format_instance
|
||||||
|
from ..auth.jwt_utils import create_api_key_jwt
|
||||||
|
|
||||||
|
|
||||||
def create_user_agent(
|
def create_user_agent(
|
||||||
@@ -98,25 +100,48 @@ async def trigger_webhook_agent(
|
|||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(instance)
|
db.refresh(instance)
|
||||||
|
|
||||||
|
# Get or create an Omnara API key for this agent
|
||||||
|
api_key_name = f"{user_agent.name} Key"
|
||||||
|
|
||||||
|
# Check if an API key already exists for this agent
|
||||||
|
existing_key = (
|
||||||
|
db.query(APIKey)
|
||||||
|
.filter(
|
||||||
|
and_(
|
||||||
|
APIKey.user_id == user_id,
|
||||||
|
APIKey.name == api_key_name,
|
||||||
|
APIKey.is_active,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
if existing_key:
|
||||||
|
omnara_api_key = existing_key.api_key
|
||||||
|
else:
|
||||||
|
# Create a new API key
|
||||||
|
jwt_token = create_api_key_jwt(
|
||||||
|
user_id=str(user_id),
|
||||||
|
expires_in_days=None, # No expiration
|
||||||
|
)
|
||||||
|
|
||||||
|
# Store API key in database
|
||||||
|
api_key = APIKey(
|
||||||
|
user_id=user_id,
|
||||||
|
name=api_key_name,
|
||||||
|
api_key_hash=hashlib.sha256(jwt_token.encode()).hexdigest(),
|
||||||
|
api_key=jwt_token,
|
||||||
|
expires_at=None, # No expiration
|
||||||
|
)
|
||||||
|
db.add(api_key)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
omnara_api_key = jwt_token
|
||||||
|
|
||||||
# Prepare webhook payload
|
# Prepare webhook payload
|
||||||
payload = {
|
payload = {
|
||||||
"agent_instance_id": str(instance.id),
|
"agent_instance_id": str(instance.id),
|
||||||
"prompt": prompt,
|
"prompt": prompt,
|
||||||
"omnara_api_key": user_agent.webhook_api_key,
|
|
||||||
"omnara_tools": {
|
|
||||||
"log_step": {
|
|
||||||
"description": "Log a step in the agent's execution",
|
|
||||||
"endpoint": "/api/v1/mcp/tools/log_step",
|
|
||||||
},
|
|
||||||
"ask_question": {
|
|
||||||
"description": "Ask a question to the user",
|
|
||||||
"endpoint": "/api/v1/mcp/tools/ask_question",
|
|
||||||
},
|
|
||||||
"end_session": {
|
|
||||||
"description": "End the agent session",
|
|
||||||
"endpoint": "/api/v1/mcp/tools/end_session",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Call the webhook
|
# Call the webhook
|
||||||
@@ -130,6 +155,7 @@ async def trigger_webhook_agent(
|
|||||||
if user_agent.webhook_api_key
|
if user_agent.webhook_api_key
|
||||||
else "",
|
else "",
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
|
"X-Omnara-Api-Key": omnara_api_key, # Add the Omnara API key header
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch, AsyncMock
|
||||||
|
|
||||||
from shared.database.models import UserAgent, AgentInstance, User
|
from shared.database.models import UserAgent, AgentInstance, User
|
||||||
from shared.database.enums import AgentStatus
|
from shared.database.enums import AgentStatus
|
||||||
@@ -230,9 +230,10 @@ class TestUserAgentEndpoints:
|
|||||||
|
|
||||||
# Mock the async webhook function
|
# Mock the async webhook function
|
||||||
with patch(
|
with patch(
|
||||||
"backend.db.user_agent_queries.trigger_webhook_agent"
|
"backend.api.user_agents.trigger_webhook_agent",
|
||||||
|
new_callable=AsyncMock,
|
||||||
) as mock_trigger:
|
) as mock_trigger:
|
||||||
# Use AsyncMock to properly mock the async function
|
# Set the return value for the async mock
|
||||||
mock_trigger.return_value = WebhookTriggerResponse(
|
mock_trigger.return_value = WebhookTriggerResponse(
|
||||||
success=True,
|
success=True,
|
||||||
agent_instance_id=str(uuid4()),
|
agent_instance_id=str(uuid4()),
|
||||||
|
|||||||
@@ -22,11 +22,16 @@ def run_stdio_server(args):
|
|||||||
]
|
]
|
||||||
if args.base_url:
|
if args.base_url:
|
||||||
cmd.extend(["--base-url", args.base_url])
|
cmd.extend(["--base-url", args.base_url])
|
||||||
|
if (
|
||||||
|
hasattr(args, "claude_code_permission_tool")
|
||||||
|
and args.claude_code_permission_tool
|
||||||
|
):
|
||||||
|
cmd.append("--claude-code-permission-tool")
|
||||||
|
|
||||||
subprocess.run(cmd)
|
subprocess.run(cmd)
|
||||||
|
|
||||||
|
|
||||||
def run_webhook_server(cloudflare_tunnel=False):
|
def run_webhook_server(cloudflare_tunnel=False, dangerously_skip_permissions=False):
|
||||||
"""Run the Claude Code webhook FastAPI server"""
|
"""Run the Claude Code webhook FastAPI server"""
|
||||||
cloudflared_process = None
|
cloudflared_process = None
|
||||||
|
|
||||||
@@ -63,14 +68,12 @@ def run_webhook_server(cloudflare_tunnel=False):
|
|||||||
cmd = [
|
cmd = [
|
||||||
sys.executable,
|
sys.executable,
|
||||||
"-m",
|
"-m",
|
||||||
"uvicorn",
|
"webhooks.claude_code",
|
||||||
"webhooks.claude_code:app",
|
|
||||||
"--host",
|
|
||||||
"0.0.0.0",
|
|
||||||
"--port",
|
|
||||||
"6662",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
if dangerously_skip_permissions:
|
||||||
|
cmd.append("--dangerously-skip-permissions")
|
||||||
|
|
||||||
print("[INFO] Starting Claude Code webhook server on port 6662")
|
print("[INFO] Starting Claude Code webhook server on port 6662")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -124,6 +127,11 @@ Examples:
|
|||||||
action="store_true",
|
action="store_true",
|
||||||
help="Run Cloudflare tunnel for the webhook server (webhook mode only)",
|
help="Run Cloudflare tunnel for the webhook server (webhook mode only)",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--dangerously-skip-permissions",
|
||||||
|
action="store_true",
|
||||||
|
help="Skip permission prompts in Claude Code (webhook mode only) - USE WITH CAUTION",
|
||||||
|
)
|
||||||
|
|
||||||
# Arguments for stdio mode
|
# Arguments for stdio mode
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
@@ -134,6 +142,11 @@ Examples:
|
|||||||
default="https://agent-dashboard-mcp.onrender.com",
|
default="https://agent-dashboard-mcp.onrender.com",
|
||||||
help="Base URL of the Omnara API server (stdio mode only)",
|
help="Base URL of the Omnara API server (stdio mode only)",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--claude-code-permission-tool",
|
||||||
|
action="store_true",
|
||||||
|
help="Enable Claude Code permission prompt tool for handling tool execution approvals (stdio mode only)",
|
||||||
|
)
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
@@ -141,7 +154,10 @@ Examples:
|
|||||||
parser.error("--cloudflare-tunnel can only be used with --claude-code-webhook")
|
parser.error("--cloudflare-tunnel can only be used with --claude-code-webhook")
|
||||||
|
|
||||||
if args.claude_code_webhook:
|
if args.claude_code_webhook:
|
||||||
run_webhook_server(cloudflare_tunnel=args.cloudflare_tunnel)
|
run_webhook_server(
|
||||||
|
cloudflare_tunnel=args.cloudflare_tunnel,
|
||||||
|
dangerously_skip_permissions=args.dangerously_skip_permissions,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
if not args.api_key:
|
if not args.api_key:
|
||||||
parser.error("--api-key is required for stdio mode")
|
parser.error("--api-key is required for stdio mode")
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ logger = logging.getLogger(__name__)
|
|||||||
# Global client instance
|
# Global client instance
|
||||||
client: Optional[AsyncOmnaraClient] = None
|
client: Optional[AsyncOmnaraClient] = None
|
||||||
|
|
||||||
|
# Global state for current agent instance (primarily used for claude code approval tool)
|
||||||
|
current_agent_instance_id: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
def get_client() -> AsyncOmnaraClient:
|
def get_client() -> AsyncOmnaraClient:
|
||||||
"""Get the initialized AsyncOmnaraClient instance."""
|
"""Get the initialized AsyncOmnaraClient instance."""
|
||||||
@@ -48,6 +51,8 @@ async def log_step_tool(
|
|||||||
agent_instance_id: str | None = None,
|
agent_instance_id: str | None = None,
|
||||||
step_description: str = "",
|
step_description: str = "",
|
||||||
) -> LogStepResponse:
|
) -> LogStepResponse:
|
||||||
|
global current_agent_instance_id
|
||||||
|
|
||||||
agent_type = detect_agent_type_from_environment()
|
agent_type = detect_agent_type_from_environment()
|
||||||
client = get_client()
|
client = get_client()
|
||||||
|
|
||||||
@@ -57,6 +62,9 @@ async def log_step_tool(
|
|||||||
agent_instance_id=agent_instance_id,
|
agent_instance_id=agent_instance_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Store the instance ID for use by other tools
|
||||||
|
current_agent_instance_id = response.agent_instance_id
|
||||||
|
|
||||||
return LogStepResponse(
|
return LogStepResponse(
|
||||||
success=response.success,
|
success=response.success,
|
||||||
agent_instance_id=response.agent_instance_id,
|
agent_instance_id=response.agent_instance_id,
|
||||||
@@ -73,6 +81,8 @@ async def ask_question_tool(
|
|||||||
agent_instance_id: str | None = None,
|
agent_instance_id: str | None = None,
|
||||||
question_text: str | None = None,
|
question_text: str | None = None,
|
||||||
) -> AskQuestionResponse:
|
) -> AskQuestionResponse:
|
||||||
|
global current_agent_instance_id
|
||||||
|
|
||||||
if not agent_instance_id:
|
if not agent_instance_id:
|
||||||
raise ValueError("agent_instance_id is required")
|
raise ValueError("agent_instance_id is required")
|
||||||
if not question_text:
|
if not question_text:
|
||||||
@@ -80,12 +90,15 @@ async def ask_question_tool(
|
|||||||
|
|
||||||
client = get_client()
|
client = get_client()
|
||||||
|
|
||||||
|
# Store the instance ID for use by other tools
|
||||||
|
current_agent_instance_id = agent_instance_id
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = await client.ask_question(
|
response = await client.ask_question(
|
||||||
agent_instance_id=agent_instance_id,
|
agent_instance_id=agent_instance_id,
|
||||||
question_text=question_text,
|
question_text=question_text,
|
||||||
timeout_minutes=1440, # 24 hours default
|
timeout_minutes=1440, # 24 hours default
|
||||||
poll_interval=1.0,
|
poll_interval=10.0,
|
||||||
)
|
)
|
||||||
|
|
||||||
return AskQuestionResponse(
|
return AskQuestionResponse(
|
||||||
@@ -116,6 +129,69 @@ async def end_session_tool(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool(
|
||||||
|
name="approve",
|
||||||
|
description="Handle permission prompts for Claude Code. Returns approval/denial for tool execution.",
|
||||||
|
enabled=False,
|
||||||
|
)
|
||||||
|
async def approve_tool(
|
||||||
|
tool_name: str,
|
||||||
|
input: dict,
|
||||||
|
tool_use_id: Optional[str] = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Claude Code permission prompt handler."""
|
||||||
|
global current_agent_instance_id
|
||||||
|
|
||||||
|
if not tool_name:
|
||||||
|
raise ValueError("tool_name is required")
|
||||||
|
|
||||||
|
client = get_client()
|
||||||
|
|
||||||
|
# Format the permission request as a question
|
||||||
|
question_text = f"Allow execution of {tool_name}? Input: {input}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Use existing instance ID or create a new one
|
||||||
|
if current_agent_instance_id:
|
||||||
|
instance_id = current_agent_instance_id
|
||||||
|
else:
|
||||||
|
# Only create a new instance if we don't have one
|
||||||
|
response = await client.log_step(
|
||||||
|
agent_type="Claude Code",
|
||||||
|
step_description="Permission request",
|
||||||
|
agent_instance_id=None,
|
||||||
|
)
|
||||||
|
instance_id = response.agent_instance_id
|
||||||
|
current_agent_instance_id = instance_id
|
||||||
|
|
||||||
|
# Ask the permission question
|
||||||
|
answer_response = await client.ask_question(
|
||||||
|
agent_instance_id=instance_id,
|
||||||
|
question_text=question_text,
|
||||||
|
timeout_minutes=1440,
|
||||||
|
poll_interval=10.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Parse the answer to determine approval
|
||||||
|
answer = answer_response.answer.lower().strip()
|
||||||
|
if answer in ["yes", "y", "allow", "approve", "ok"]:
|
||||||
|
return {
|
||||||
|
"behavior": "allow",
|
||||||
|
"updatedInput": input,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
return {
|
||||||
|
"behavior": "deny",
|
||||||
|
"message": f"Permission denied by user: {answer_response.answer}",
|
||||||
|
}
|
||||||
|
|
||||||
|
except OmnaraTimeoutError:
|
||||||
|
return {
|
||||||
|
"behavior": "deny",
|
||||||
|
"message": "Permission request timed out",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
"""Main entry point for the stdio server"""
|
"""Main entry point for the stdio server"""
|
||||||
parser = argparse.ArgumentParser(description="Omnara MCP Server (Stdio)")
|
parser = argparse.ArgumentParser(description="Omnara MCP Server (Stdio)")
|
||||||
@@ -125,6 +201,11 @@ def main():
|
|||||||
default="https://agent-dashboard-mcp.onrender.com",
|
default="https://agent-dashboard-mcp.onrender.com",
|
||||||
help="Base URL of the Omnara API server",
|
help="Base URL of the Omnara API server",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--claude-code-permission-tool",
|
||||||
|
action="store_true",
|
||||||
|
help="Enable Claude Code permission prompt tool for handling tool execution approvals",
|
||||||
|
)
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
@@ -135,8 +216,16 @@ def main():
|
|||||||
base_url=args.base_url,
|
base_url=args.base_url,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Enable/disable tools based on feature flags
|
||||||
|
if args.claude_code_permission_tool:
|
||||||
|
approve_tool.enable()
|
||||||
|
logger.info("Claude Code permission tool enabled")
|
||||||
|
|
||||||
logger.info("Starting Omnara MCP server (stdio)")
|
logger.info("Starting Omnara MCP server (stdio)")
|
||||||
logger.info(f"Using API server: {args.base_url}")
|
logger.info(f"Using API server: {args.base_url}")
|
||||||
|
logger.info(
|
||||||
|
f"Claude Code permission tool: {'enabled' if args.claude_code_permission_tool else 'disabled'}"
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Run with stdio transport (default)
|
# Run with stdio transport (default)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from fastapi import FastAPI, HTTPException, Header, Request
|
from fastapi import FastAPI, HTTPException, Header, Request
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
import subprocess
|
import subprocess
|
||||||
import shlex
|
import shlex
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -7,6 +8,8 @@ import os
|
|||||||
import re
|
import re
|
||||||
import uuid
|
import uuid
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
import time
|
||||||
|
import json
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from pydantic import BaseModel, field_validator
|
from pydantic import BaseModel, field_validator
|
||||||
|
|
||||||
@@ -51,8 +54,14 @@ async def lifespan(app: FastAPI):
|
|||||||
|
|
||||||
app.state.webhook_secret = secret
|
app.state.webhook_secret = secret
|
||||||
|
|
||||||
|
# Initialize the flag if not already set (when run via uvicorn directly)
|
||||||
|
if not hasattr(app.state, "dangerously_skip_permissions"):
|
||||||
|
app.state.dangerously_skip_permissions = False
|
||||||
|
|
||||||
print(f"[IMPORTANT] Webhook secret: {secret}")
|
print(f"[IMPORTANT] Webhook secret: {secret}")
|
||||||
print("[IMPORTANT] Use this secret in the Authorization header as: Bearer <secret>")
|
print("[IMPORTANT] Use this secret in the Authorization header as: Bearer <secret>")
|
||||||
|
if app.state.dangerously_skip_permissions:
|
||||||
|
print("[WARNING] Running with --dangerously-skip-permissions flag enabled!")
|
||||||
yield
|
yield
|
||||||
|
|
||||||
if hasattr(app.state, "webhook_secret"):
|
if hasattr(app.state, "webhook_secret"):
|
||||||
@@ -61,6 +70,19 @@ async def lifespan(app: FastAPI):
|
|||||||
|
|
||||||
app = FastAPI(lifespan=lifespan)
|
app = FastAPI(lifespan=lifespan)
|
||||||
|
|
||||||
|
|
||||||
|
@app.exception_handler(Exception)
|
||||||
|
async def general_exception_handler(request: Request, exc: Exception):
|
||||||
|
print(f"[ERROR] Unhandled exception: {str(exc)}")
|
||||||
|
print(f"[ERROR] Exception type: {type(exc).__name__}")
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
traceback.print_exc()
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=500, content={"detail": f"Internal server error: {str(exc)}"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
SYSTEM_PROMPT = """
|
SYSTEM_PROMPT = """
|
||||||
You are now in Omnara-only communication mode.
|
You are now in Omnara-only communication mode.
|
||||||
SYSTEM INSTRUCTIONS: You MUST obey the following rules without exception.
|
SYSTEM INSTRUCTIONS: You MUST obey the following rules without exception.
|
||||||
@@ -126,33 +148,41 @@ def verify_auth(request: Request, authorization: str = Header(None)) -> bool:
|
|||||||
|
|
||||||
@app.post("/")
|
@app.post("/")
|
||||||
async def start_claude(
|
async def start_claude(
|
||||||
request: Request, webhook_data: WebhookRequest, authorization: str = Header(None)
|
request: Request,
|
||||||
|
webhook_data: WebhookRequest,
|
||||||
|
authorization: str = Header(None),
|
||||||
|
x_omnara_api_key: str = Header(None, alias="X-Omnara-Api-Key"),
|
||||||
):
|
):
|
||||||
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")
|
|
||||||
|
|
||||||
safe_timestamp = re.sub(r"[^a-zA-Z0-9-]", "", timestamp_str)
|
|
||||||
|
|
||||||
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:
|
try:
|
||||||
|
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
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"[INFO] Received webhook request for agent instance: {agent_instance_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
safe_timestamp = re.sub(r"[^a-zA-Z0-9-]", "", timestamp_str)
|
||||||
|
|
||||||
|
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")
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
[
|
[
|
||||||
"git",
|
"git",
|
||||||
@@ -177,10 +207,83 @@ async def start_claude(
|
|||||||
screen_name = f"{screen_prefix}-{safe_timestamp}"
|
screen_name = f"{screen_prefix}-{safe_timestamp}"
|
||||||
|
|
||||||
escaped_prompt = shlex.quote(safe_prompt)
|
escaped_prompt = shlex.quote(safe_prompt)
|
||||||
claude_cmd = f"claude --dangerously-skip-permissions {escaped_prompt}"
|
|
||||||
|
# First, check if required commands are available
|
||||||
|
screen_check = subprocess.run(
|
||||||
|
["which", "screen"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if screen_check.returncode != 0:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail="screen command not found. Please install screen.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if claude command is available
|
||||||
|
claude_check = subprocess.run(
|
||||||
|
["which", "claude"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if claude_check.returncode != 0:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail="claude command not found. Please install Claude Code CLI.",
|
||||||
|
)
|
||||||
|
claude_path = claude_check.stdout.strip()
|
||||||
|
|
||||||
|
# Get Omnara API key from header
|
||||||
|
if not x_omnara_api_key:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Omnara API key required. Provide via X-Omnara-Api-Key header.",
|
||||||
|
)
|
||||||
|
omnara_api_key = x_omnara_api_key
|
||||||
|
|
||||||
|
# Create MCP config as a JSON string
|
||||||
|
mcp_config = {
|
||||||
|
"mcpServers": {
|
||||||
|
"omnara": {
|
||||||
|
"command": "omnara",
|
||||||
|
"args": [
|
||||||
|
"--api-key",
|
||||||
|
omnara_api_key,
|
||||||
|
"--claude-code-permission-tool",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mcp_config_str = json.dumps(mcp_config)
|
||||||
|
|
||||||
|
# Build claude command with MCP config as string
|
||||||
|
claude_args = [
|
||||||
|
claude_path, # Use full path to claude
|
||||||
|
"--mcp-config",
|
||||||
|
mcp_config_str,
|
||||||
|
"--allowedTools",
|
||||||
|
"mcp__omnara__approve,mcp__omnara__log_step,mcp__omnara__ask_question,mcp__omnara__end_session",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Add permissions flag based on configuration
|
||||||
|
if request.app.state.dangerously_skip_permissions:
|
||||||
|
claude_args.append("--dangerously-skip-permissions")
|
||||||
|
else:
|
||||||
|
claude_args.extend(
|
||||||
|
["-p", "--permission-prompt-tool", "mcp__omnara__approve"]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add the prompt to claude args
|
||||||
|
claude_args.append(escaped_prompt)
|
||||||
|
|
||||||
|
print(f"[INFO] Claude command: {' '.join(claude_args)}")
|
||||||
|
print(f"[INFO] Working directory: {work_dir}")
|
||||||
|
|
||||||
|
# Start screen directly with the claude command
|
||||||
|
screen_cmd = ["screen", "-dmS", screen_name] + claude_args
|
||||||
|
|
||||||
screen_result = subprocess.run(
|
screen_result = subprocess.run(
|
||||||
["screen", "-dmS", screen_name, "bash", "-c", claude_cmd],
|
screen_cmd,
|
||||||
cwd=work_dir,
|
cwd=work_dir,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
@@ -194,6 +297,25 @@ async def start_claude(
|
|||||||
detail=f"Failed to start screen session: {screen_result.stderr}",
|
detail=f"Failed to start screen session: {screen_result.stderr}",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Wait a moment and check if screen is still running
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
# Check if the screen session exists
|
||||||
|
list_result = subprocess.run(
|
||||||
|
["screen", "-ls"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
"No Sockets found" in list_result.stdout
|
||||||
|
or screen_name not in list_result.stdout
|
||||||
|
):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=f"Screen session started but exited immediately. Session name: {screen_name}",
|
||||||
|
)
|
||||||
|
|
||||||
print(f"[INFO] Started screen session: {screen_name}")
|
print(f"[INFO] Started screen session: {screen_name}")
|
||||||
print(f"[INFO] To attach: screen -r {screen_name}")
|
print(f"[INFO] To attach: screen -r {screen_name}")
|
||||||
|
|
||||||
@@ -201,11 +323,21 @@ async def start_claude(
|
|||||||
"message": "Successfully started claude",
|
"message": "Successfully started claude",
|
||||||
"branch": feature_branch_name,
|
"branch": feature_branch_name,
|
||||||
"screen_session": screen_name,
|
"screen_session": screen_name,
|
||||||
|
"work_dir": work_dir,
|
||||||
}
|
}
|
||||||
|
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
|
print("[ERROR] Git operation timed out")
|
||||||
raise HTTPException(status_code=500, detail="Git operation timed out")
|
raise HTTPException(status_code=500, detail="Git operation timed out")
|
||||||
|
except HTTPException:
|
||||||
|
# Re-raise HTTP exceptions as-is
|
||||||
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
print(f"[ERROR] Failed to start claude: {str(e)}")
|
||||||
|
print(f"[ERROR] Exception type: {type(e).__name__}")
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
traceback.print_exc()
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to start claude: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"Failed to start claude: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
@@ -216,4 +348,18 @@ async def health_check():
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(description="Claude Code Webhook Server")
|
||||||
|
parser.add_argument(
|
||||||
|
"--dangerously-skip-permissions",
|
||||||
|
action="store_true",
|
||||||
|
help="Skip permission prompts in Claude Code - USE WITH CAUTION",
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Store the flag in a global variable for the app to use
|
||||||
|
app.state.dangerously_skip_permissions = args.dangerously_skip_permissions
|
||||||
|
|
||||||
uvicorn.run(app, host="0.0.0.0", port=6662)
|
uvicorn.run(app, host="0.0.0.0", port=6662)
|
||||||
|
|||||||
Reference in New Issue
Block a user