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
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
# Configuration
|
# 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_ALGORITHM = "HS256"
|
||||||
JWT_EXPIRATION_DAYS = 7
|
JWT_EXPIRATION_DAYS = 7
|
||||||
API_USER = os.getenv("API_USER", "captain")
|
API_USER = os.getenv("API_USER", "captain")
|
||||||
API_PASSWORD = os.getenv("API_PASSWORD", "tzzr2025")
|
API_PASSWORD = os.getenv("API_PASSWORD", "tzzr2025")
|
||||||
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://captain:captain@localhost/captain_mobile")
|
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
|
# Database pool
|
||||||
db_pool: Optional[asyncpg.Pool] = None
|
db_pool: Optional[asyncpg.Pool] = None
|
||||||
@@ -57,7 +61,7 @@ app = FastAPI(
|
|||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=["*"],
|
allow_origins=["https://captain.tzzrarchitect.me", "http://localhost:3000"],
|
||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
@@ -90,6 +94,9 @@ class ScreenSession(BaseModel):
|
|||||||
pid: str
|
pid: str
|
||||||
attached: bool
|
attached: bool
|
||||||
|
|
||||||
|
class CreateSessionRequest(BaseModel):
|
||||||
|
name: str
|
||||||
|
|
||||||
# Database initialization
|
# Database initialization
|
||||||
async def init_database():
|
async def init_database():
|
||||||
if not db_pool:
|
if not db_pool:
|
||||||
@@ -177,6 +184,48 @@ async def list_sessions(user: str = Depends(verify_token)) -> list[ScreenSession
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=str(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")
|
@app.get("/history")
|
||||||
async def get_history(user: str = Depends(verify_token), limit: int = 20) -> list[Conversation]:
|
async def get_history(user: str = Depends(verify_token), limit: int = 20) -> list[Conversation]:
|
||||||
"""Get conversation history"""
|
"""Get conversation history"""
|
||||||
@@ -291,10 +340,13 @@ async def websocket_chat(websocket: WebSocket):
|
|||||||
# Build claude command
|
# Build claude command
|
||||||
cmd = [CLAUDE_CMD, "-p", user_message, "--output-format", "stream-json"]
|
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:
|
for file_path in context_files:
|
||||||
if os.path.exists(file_path):
|
# Security: Only allow files from upload directory
|
||||||
cmd.extend(["--file", file_path])
|
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
|
# Stream response from claude
|
||||||
try:
|
try:
|
||||||
@@ -394,21 +446,38 @@ async def websocket_terminal(websocket: WebSocket, session_name: str):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
|
jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
|
||||||
except:
|
except (jwt.InvalidTokenError, jwt.ExpiredSignatureError):
|
||||||
await websocket.close(code=4001, reason="Invalid token")
|
await websocket.close(code=4001, reason="Invalid token")
|
||||||
return
|
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})
|
await websocket.send_json({"type": "connected", "session": session_name})
|
||||||
|
|
||||||
# Create PTY and attach to screen session
|
# Create PTY and attach to screen session
|
||||||
master_fd, slave_fd = pty.openpty()
|
master_fd, slave_fd = pty.openpty()
|
||||||
|
|
||||||
|
# Set TERM environment for screen
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["TERM"] = "xterm-256color"
|
||||||
|
|
||||||
process = subprocess.Popen(
|
process = subprocess.Popen(
|
||||||
["screen", "-x", session_name],
|
["screen", "-x", session_name],
|
||||||
stdin=slave_fd,
|
stdin=slave_fd,
|
||||||
stdout=slave_fd,
|
stdout=slave_fd,
|
||||||
stderr=slave_fd,
|
stderr=slave_fd,
|
||||||
preexec_fn=os.setsid
|
preexec_fn=os.setsid,
|
||||||
|
env=env
|
||||||
)
|
)
|
||||||
|
|
||||||
os.close(slave_fd)
|
os.close(slave_fd)
|
||||||
|
|||||||
Submodule apps/flow-ui updated: f0c09b10ad...0b49fc77e2
Submodule apps/mindlink updated: 40c0944cf7...1cf3453ad1
Reference in New Issue
Block a user