@@ -228,6 +228,27 @@ def validate_service_id(handler, body: dict) -> str | None:
228228 return sid
229229
230230
231+ def _resolve_container_name (service_id : str ) -> str :
232+ """Resolve actual container name via Docker Compose labels.
233+
234+ Falls back to dream-{service_id} convention if label lookup fails.
235+ """
236+ try :
237+ result = subprocess .run (
238+ ["docker" , "ps" , "--filter" ,
239+ f"label=com.docker.compose.service={ service_id } " ,
240+ "--filter" , "label=com.docker.compose.project=dream-server" ,
241+ "--format" , "{{.Names}}" ],
242+ capture_output = True , text = True , timeout = 5 ,
243+ )
244+ names = result .stdout .strip ().splitlines ()
245+ if names :
246+ return names [0 ]
247+ except (subprocess .TimeoutExpired , OSError ):
248+ pass
249+ return f"dream-{ service_id } "
250+
251+
231252class AgentHandler (BaseHTTPRequestHandler ):
232253 def log_message (self , fmt , * args ):
233254 logger .info (fmt , * args )
@@ -317,6 +338,8 @@ def do_POST(self):
317338 self ._handle_logs ()
318339 elif self .path == "/v1/extension/setup-hook" :
319340 self ._handle_setup_hook ()
341+ elif self .path == "/v1/service/logs" :
342+ self ._handle_service_logs ()
320343 else :
321344 json_response (self , 404 , {"error" : "Not found" })
322345
@@ -391,6 +414,59 @@ def _handle_logs(self):
391414 json_response (self , 500 , {"error" : f"Failed to fetch logs: { exc } " })
392415
393416
417+ def _handle_service_logs (self ):
418+ """Read-only log access for ANY service (core + extensions).
419+
420+ Unlike _handle_logs() which uses validate_service_id() and blocks
421+ core services, this endpoint only validates the service_id format.
422+ """
423+ if not check_auth (self ):
424+ return
425+ body = read_json_body (self )
426+ if body is None :
427+ return
428+
429+ sid = body .get ("service_id" , "" )
430+ if not isinstance (sid , str ) or not SERVICE_ID_RE .match (sid ):
431+ json_response (self , 400 , {"error" : "Invalid service_id" })
432+ return
433+
434+ try :
435+ tail = min (max (int (body .get ("tail" , 100 )), 1 ), 500 )
436+ except (ValueError , TypeError ):
437+ tail = 100
438+
439+ container_name = _resolve_container_name (sid )
440+
441+ try :
442+ result = subprocess .run (
443+ ["docker" , "logs" , "--tail" , str (tail ), container_name ],
444+ capture_output = True , text = True , timeout = 5 ,
445+ )
446+ if result .returncode != 0 and "no such container" in (result .stderr or "" ).lower ():
447+ json_response (self , 200 , {
448+ "service_id" : sid ,
449+ "container_name" : container_name ,
450+ "logs" : "Container is not running." ,
451+ "lines" : 0 ,
452+ })
453+ return
454+ if result .returncode != 0 :
455+ json_response (self , 500 , {"error" : f"docker logs failed: { (result .stderr or '' )[:500 ]} " })
456+ return
457+ output = result .stdout or result .stderr or ""
458+ json_response (self , 200 , {
459+ "service_id" : sid ,
460+ "container_name" : container_name ,
461+ "logs" : output [- 50000 :],
462+ "lines" : tail ,
463+ })
464+ except subprocess .TimeoutExpired :
465+ json_response (self , 503 , {"error" : "Log fetch timed out" })
466+ except Exception as exc :
467+ json_response (self , 500 , {"error" : f"Failed to fetch logs: { exc } " })
468+
469+
394470 def _handle_setup_hook (self ):
395471 if not check_auth (self ):
396472 return
0 commit comments