@@ -9,10 +9,35 @@ from pathlib import Path
99
1010import uvicorn
1111from starlette .applications import Starlette
12+ from starlette .middleware import Middleware
13+ from starlette .middleware .base import BaseHTTPMiddleware
1214from starlette .responses import JSONResponse , RedirectResponse , StreamingResponse
1315from starlette .routing import Mount , Route
1416from starlette .staticfiles import StaticFiles
1517
18+
19+ # Security model: This server is designed for localhost-only use (binds to 127.0.0.1).
20+ # It has no authentication, no CORS restrictions, and serves all session data to any
21+ # connecting client. Do not expose to a network without adding auth and access controls.
22+
23+
24+ class SecurityHeadersMiddleware (BaseHTTPMiddleware ):
25+ async def dispatch (self , request , call_next ):
26+ response = await call_next (request )
27+ response .headers ["Content-Security-Policy" ] = (
28+ "default-src 'self'; "
29+ "script-src 'self' https://cdn.jsdelivr.net; "
30+ "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; "
31+ "font-src https://fonts.gstatic.com; "
32+ "connect-src 'self'; "
33+ "img-src 'self' data:; "
34+ "object-src 'none'; "
35+ "base-uri 'none'; "
36+ "frame-ancestors 'none'; "
37+ )
38+ response .headers ["X-Content-Type-Options" ] = "nosniff"
39+ return response
40+
1641SCRIPT_DIR = os .path .dirname (os .path .abspath (__file__ ))
1742PROJECT_DIR = os .path .dirname (SCRIPT_DIR )
1843
@@ -22,6 +47,8 @@ WALLS_DIR = os.path.expanduser("~/.goosetown/walls")
2247CHILD_PATTERN = re .compile (r"Task (\d{8}_\d+) started in background" )
2348GTWALL_ID_PATTERN = re .compile (r"Your gtwall ID is (\S+)" )
2449DELEGATE_NAME_PATTERN = re .compile (r"You are (\S+)\." )
50+ # IMPORTANT: These patterns are duplicated in ui/js/buildings.js (inferRole function).
51+ # If you change patterns here, update the JS version to match.
2552ROLE_PATTERNS = {
2653 "orchestrator" : re .compile (r"orchestrat" , re .I ),
2754 "researcher" : re .compile (r"research" , re .I ),
@@ -395,6 +422,8 @@ async def sessions_tree_watcher():
395422
396423# ── SSE Endpoint ────────────────────────────────────────────────────────────
397424async def sse_endpoint (request ):
425+ if len (clients ) > 50 :
426+ return JSONResponse ({"error" : "too many connections" }, status_code = 503 )
398427 queue : asyncio .Queue = asyncio .Queue (maxsize = 1000 )
399428 clients .append (queue )
400429 last_event_id = request .headers .get ("Last-Event-ID" )
@@ -438,10 +467,12 @@ async def sse_endpoint(request):
438467# ── REST ────────────────────────────────────────────────────────────────────
439468async def messages_endpoint (request ):
440469 sid = request .path_params ["session_id" ]
470+ if not re .match (r'^[a-zA-Z0-9_-]{1,128}$' , sid ):
471+ return JSONResponse ({"error" : "invalid session id" }, status_code = 400 )
441472 before = request .query_params .get ("before" )
442473
443474 try :
444- limit = min (int (request .query_params .get ("limit" , "100" )), 500 )
475+ limit = max ( 1 , min (int (request .query_params .get ("limit" , "100" )), 500 ) )
445476 with db () as cur :
446477 if before :
447478 cur .execute (
@@ -458,14 +489,49 @@ async def messages_endpoint(request):
458489 )
459490 rows = [{"id" : r [0 ], "role" : r [1 ], "content_json" : r [2 ], "created_timestamp" : r [3 ]} for r in cur .fetchall ()]
460491 except Exception as e :
461- return JSONResponse ({"error" : str (e )}, status_code = 500 )
492+ print (f"⚠️ messages_endpoint error: { e } " , file = sys .stderr )
493+ return JSONResponse ({"error" : "internal error" }, status_code = 500 )
462494
463495 rows .reverse ()
464496 return JSONResponse ({"session_id" : sid , "messages" : rows , "has_more" : len (rows ) == limit })
465497
466498
499+ async def wall_post_endpoint (request ):
500+ """Post a message to gtwall. Shells out to ./gtwall — no duplicated logic."""
501+ try :
502+ body = await request .json ()
503+ message = body .get ("message" , "" ).strip ()
504+ if not message :
505+ return JSONResponse ({"error" : "empty message" }, status_code = 400 )
506+ if len (message ) > 4096 :
507+ return JSONResponse ({"error" : "message too long (max 4096 chars)" }, status_code = 400 )
508+
509+ wall_file = config .get ("wall_file" , "" )
510+ if not wall_file :
511+ return JSONResponse ({"error" : "no wall file configured" }, status_code = 503 )
512+
513+ gtwall_path = os .path .join (PROJECT_DIR , "gtwall" )
514+ env = {** os .environ , "GOOSE_GTWALL_FILE" : wall_file }
515+ proc = await asyncio .create_subprocess_exec (
516+ gtwall_path , "user" , message ,
517+ env = env ,
518+ stdout = asyncio .subprocess .PIPE ,
519+ stderr = asyncio .subprocess .PIPE ,
520+ )
521+ await proc .wait ()
522+ if proc .returncode != 0 :
523+ stderr_output = (await proc .stderr .read ()).decode ().strip ()
524+ print (f"⚠️ gtwall failed (rc={ proc .returncode } ): { stderr_output } " , file = sys .stderr )
525+ return JSONResponse ({"error" : "failed to post message" }, status_code = 500 )
526+ return JSONResponse ({"ok" : True })
527+ except Exception as e :
528+ print (f"⚠️ wall_post error: { e } " , file = sys .stderr )
529+ return JSONResponse ({"error" : "internal error" }, status_code = 500 )
530+
531+
467532async def config_endpoint (request ):
468- return JSONResponse (config )
533+ safe_keys = ("wall_id" , "port" , "parent_session_id" )
534+ return JSONResponse ({k : config [k ] for k in safe_keys if k in config })
469535
470536
471537async def root_redirect (request ):
@@ -480,10 +546,11 @@ def create_app() -> Starlette:
480546 Route ("/" , root_redirect ),
481547 Route ("/events" , sse_endpoint ),
482548 Route ("/api/messages/{session_id}" , messages_endpoint ),
549+ Route ("/api/wall" , wall_post_endpoint , methods = ["POST" ]),
483550 Route ("/api/config" , config_endpoint ),
484551 Mount ("/ui" , StaticFiles (directory = ui_dir ), name = "ui" ),
485552 ],
486-
553+ middleware = [ Middleware ( SecurityHeadersMiddleware )],
487554 on_startup = [start_background_tasks ],
488555 )
489556
0 commit comments