diff --git a/apps/captain-mobile/captain_api.py b/apps/captain-mobile/captain_api.py index 901aa01..23773c0 100644 --- a/apps/captain-mobile/captain_api.py +++ b/apps/captain-mobile/captain_api.py @@ -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) diff --git a/apps/flow-ui b/apps/flow-ui index f0c09b1..0b49fc7 160000 --- a/apps/flow-ui +++ b/apps/flow-ui @@ -1 +1 @@ -Subproject commit f0c09b10ad03018cbff0f3412098774234e98bf3 +Subproject commit 0b49fc77e2998a7ce6b105439b1b97cab3b4e0aa diff --git a/apps/mindlink b/apps/mindlink index 40c0944..1cf3453 160000 --- a/apps/mindlink +++ b/apps/mindlink @@ -1 +1 @@ -Subproject commit 40c0944cf7513920a14d370831b298f9e3f8ca1a +Subproject commit 1cf3453ad1daabd254d77254f476598e6f861ab0