Security and multi-instance improvements
captain_api.py: - Generate random JWT_SECRET if not configured (with warning) - Restrict CORS to specific origins - Add POST /sessions endpoint to create screen sessions - Sanitize session names (prevent command injection) - Validate file paths to upload directory only - Improve JWT error handling - Set TERM environment for screen sessions flow-ui: Add multi-instance support with server validation mindlink: Add multi-instance support with category filtering Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -24,13 +24,17 @@ from pydantic import BaseModel
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
# Configuration
|
||||
JWT_SECRET = os.getenv("JWT_SECRET", "captain-claude-secret-key-change-in-production")
|
||||
import secrets
|
||||
_default_secret = secrets.token_hex(32) # Generate random secret if not configured
|
||||
JWT_SECRET = os.getenv("JWT_SECRET", _default_secret)
|
||||
if JWT_SECRET == _default_secret:
|
||||
print("WARNING: Using random JWT_SECRET. Set JWT_SECRET env var for production.")
|
||||
JWT_ALGORITHM = "HS256"
|
||||
JWT_EXPIRATION_DAYS = 7
|
||||
API_USER = os.getenv("API_USER", "captain")
|
||||
API_PASSWORD = os.getenv("API_PASSWORD", "tzzr2025")
|
||||
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://captain:captain@localhost/captain_mobile")
|
||||
CLAUDE_CMD = os.getenv("CLAUDE_CMD", "/home/architect/.claude/local/claude")
|
||||
CLAUDE_CMD = os.getenv("CLAUDE_CMD", "/home/architect/.npm-global/bin/claude")
|
||||
|
||||
# Database pool
|
||||
db_pool: Optional[asyncpg.Pool] = None
|
||||
@@ -57,7 +61,7 @@ app = FastAPI(
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_origins=["https://captain.tzzrarchitect.me", "http://localhost:3000"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
@@ -90,6 +94,9 @@ class ScreenSession(BaseModel):
|
||||
pid: str
|
||||
attached: bool
|
||||
|
||||
class CreateSessionRequest(BaseModel):
|
||||
name: str
|
||||
|
||||
# Database initialization
|
||||
async def init_database():
|
||||
if not db_pool:
|
||||
@@ -177,6 +184,48 @@ async def list_sessions(user: str = Depends(verify_token)) -> list[ScreenSession
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@app.post("/sessions", response_model=ScreenSession)
|
||||
async def create_session(request: CreateSessionRequest, user: str = Depends(verify_token)):
|
||||
"""Create a new screen session with Claude Code"""
|
||||
import re
|
||||
try:
|
||||
# Sanitize name - only allow alphanumeric and hyphens
|
||||
name = request.name.replace(" ", "-").lower()
|
||||
name = re.sub(r'[^a-z0-9-]', '', name)
|
||||
|
||||
if not name or len(name) > 50 or len(name) < 2:
|
||||
raise HTTPException(status_code=400, detail="Invalid session name (2-50 chars, alphanumeric and hyphens only)")
|
||||
|
||||
# Check if session with same name already exists
|
||||
ls_check = subprocess.run(["screen", "-ls"], capture_output=True, text=True)
|
||||
if f".{name}" in ls_check.stdout:
|
||||
raise HTTPException(status_code=409, detail=f"Session '{name}' already exists")
|
||||
|
||||
# Start screen session with claude in detached mode
|
||||
result = subprocess.run(
|
||||
["screen", "-dmS", name, CLAUDE_CMD],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd="/home/architect/captain-claude"
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to create session: {result.stderr}")
|
||||
|
||||
# Get the PID of the new session
|
||||
await asyncio.sleep(1.0) # Wait for screen to start
|
||||
ls_result = subprocess.run(["screen", "-ls"], capture_output=True, text=True)
|
||||
for line in ls_result.stdout.split("\n"):
|
||||
if f".{name}" in line:
|
||||
parts = line.strip().split("\t")[0].split(".")
|
||||
if len(parts) >= 2:
|
||||
return ScreenSession(pid=parts[0], name=name, attached=False)
|
||||
|
||||
raise HTTPException(status_code=500, detail="Session created but could not find PID")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@app.get("/history")
|
||||
async def get_history(user: str = Depends(verify_token), limit: int = 20) -> list[Conversation]:
|
||||
"""Get conversation history"""
|
||||
@@ -291,10 +340,13 @@ async def websocket_chat(websocket: WebSocket):
|
||||
# Build claude command
|
||||
cmd = [CLAUDE_CMD, "-p", user_message, "--output-format", "stream-json"]
|
||||
|
||||
# Add file context if provided
|
||||
# Add file context if provided (only from uploads dir)
|
||||
upload_dir = "/tmp/captain-uploads"
|
||||
for file_path in context_files:
|
||||
if os.path.exists(file_path):
|
||||
cmd.extend(["--file", file_path])
|
||||
# Security: Only allow files from upload directory
|
||||
real_path = os.path.realpath(file_path)
|
||||
if real_path.startswith(upload_dir) and os.path.exists(real_path):
|
||||
cmd.extend(["--file", real_path])
|
||||
|
||||
# Stream response from claude
|
||||
try:
|
||||
@@ -394,21 +446,38 @@ async def websocket_terminal(websocket: WebSocket, session_name: str):
|
||||
|
||||
try:
|
||||
jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
|
||||
except:
|
||||
except (jwt.InvalidTokenError, jwt.ExpiredSignatureError):
|
||||
await websocket.close(code=4001, reason="Invalid token")
|
||||
return
|
||||
|
||||
# Validate session_name format (prevent command injection)
|
||||
import re
|
||||
if not re.match(r'^[0-9]+\.[a-z0-9-]+$', session_name):
|
||||
await websocket.close(code=4003, reason="Invalid session name format")
|
||||
return
|
||||
|
||||
# Verify session exists before connecting
|
||||
ls_result = subprocess.run(["screen", "-ls"], capture_output=True, text=True)
|
||||
if session_name not in ls_result.stdout:
|
||||
await websocket.close(code=4004, reason=f"Session '{session_name}' not found")
|
||||
return
|
||||
|
||||
await websocket.send_json({"type": "connected", "session": session_name})
|
||||
|
||||
# Create PTY and attach to screen session
|
||||
master_fd, slave_fd = pty.openpty()
|
||||
|
||||
# Set TERM environment for screen
|
||||
env = os.environ.copy()
|
||||
env["TERM"] = "xterm-256color"
|
||||
|
||||
process = subprocess.Popen(
|
||||
["screen", "-x", session_name],
|
||||
stdin=slave_fd,
|
||||
stdout=slave_fd,
|
||||
stderr=slave_fd,
|
||||
preexec_fn=os.setsid
|
||||
preexec_fn=os.setsid,
|
||||
env=env
|
||||
)
|
||||
|
||||
os.close(slave_fd)
|
||||
|
||||
Reference in New Issue
Block a user