mirror of
https://github.com/NicolasBohn/NexQuant.git
synced 2026-07-27 15:37:44 +00:00
732361bb90
- Path injection (B614): centralized safe_resolve_path in core/utils.py, refactored 6 UI modules to use it with safe_root validation - B701: added explicit autoescape=select_autoescape() to Jinja2 Environment() calls in 3 files - B101: replaced assert statements with proper if/raise patterns in 12+ files (partial) - B112: added logger.warning() to bare except:continue blocks in 5 files
198 lines
6.9 KiB
Python
198 lines
6.9 KiB
Python
"""
|
||
RL Post-training Timeline Viewer
|
||
Hierarchical view: Session > Loop > Stage > Events
|
||
|
||
Run:
|
||
streamlit run rdagent/app/rl/ui/app.py
|
||
"""
|
||
|
||
import os
|
||
from pathlib import Path
|
||
|
||
import streamlit as st
|
||
from streamlit import session_state as state
|
||
|
||
from rdagent.app.rl.ui.components import render_session, render_summary
|
||
from rdagent.app.rl.ui.config import ALWAYS_VISIBLE_TYPES, OPTIONAL_TYPES
|
||
from rdagent.app.rl.ui.data_loader import get_summary, get_valid_sessions, load_session
|
||
from rdagent.app.rl.ui.rl_summary import render_job_summary
|
||
from rdagent.core.utils import safe_resolve_path
|
||
|
||
DEFAULT_LOG_BASE = "log/"
|
||
|
||
|
||
def _safe_resolve(user_input: str | None, safe_root: Path) -> Path:
|
||
safe_root = safe_root.expanduser().resolve()
|
||
if not user_input:
|
||
return safe_root
|
||
if "\x00" in user_input:
|
||
raise ValueError("Invalid path: contains null byte")
|
||
try:
|
||
normalized = os.path.normpath(user_input.strip())
|
||
drive, _ = os.path.splitdrive(normalized)
|
||
if drive:
|
||
raise ValueError("Absolute paths with drive letters are not allowed")
|
||
if os.path.isabs(normalized):
|
||
raise ValueError("Absolute paths are not allowed")
|
||
joined = safe_root / normalized
|
||
return safe_resolve_path(joined, safe_root)
|
||
except (OSError, ValueError) as exc:
|
||
raise ValueError(f"Invalid path outside of allowed root: {user_input}") from exc
|
||
|
||
|
||
def get_job_options(base_path: Path, safe_root: Path | None = None) -> list[str]:
|
||
"""
|
||
Scan directory and return job options list.
|
||
|
||
Security: Validates base_path to prevent path traversal attacks.
|
||
If safe_root is provided, validates against it; otherwise uses CWD.
|
||
"""
|
||
options = []
|
||
has_root_tasks = False
|
||
job_dirs = []
|
||
|
||
# Security fix: Validate base_path to prevent path traversal
|
||
try:
|
||
base_path_resolved = base_path.expanduser().resolve() # nosec B614 — validated against safe_root below via relative_to()
|
||
|
||
if safe_root is not None:
|
||
safe_root_resolved = safe_root.expanduser().resolve()
|
||
# Reconstruct from trusted root to break taint chain.
|
||
base_path_resolved = safe_root_resolved / base_path_resolved.relative_to(safe_root_resolved)
|
||
else:
|
||
cwd_resolved = Path.cwd().resolve()
|
||
base_path_resolved = cwd_resolved / base_path_resolved.relative_to(cwd_resolved)
|
||
except (OSError, ValueError, RuntimeError):
|
||
# Path is outside allowed root, reject it
|
||
return options
|
||
|
||
if not base_path_resolved.exists():
|
||
return options
|
||
|
||
for d in base_path_resolved.iterdir():
|
||
if not d.is_dir():
|
||
continue
|
||
if (d / "__session__").exists():
|
||
has_root_tasks = True
|
||
else:
|
||
try:
|
||
if any((sub / "__session__").exists() for sub in d.iterdir() if sub.is_dir()):
|
||
job_dirs.append(d.name)
|
||
except PermissionError:
|
||
pass
|
||
|
||
job_dirs.sort(reverse=True)
|
||
options.extend(job_dirs)
|
||
if has_root_tasks:
|
||
options.append(". (Current)")
|
||
|
||
return options
|
||
|
||
|
||
def main():
|
||
st.set_page_config(layout="wide", page_title="RL Timeline", page_icon="🤖")
|
||
|
||
with st.sidebar:
|
||
view_mode = st.radio("View Mode", ["Job Summary", "Single Task"], horizontal=True)
|
||
st.divider()
|
||
|
||
default_log = os.environ.get("RL_LOG_PATH", DEFAULT_LOG_BASE)
|
||
safe_root = Path(default_log).expanduser().resolve()
|
||
job_folder = str(safe_root)
|
||
selected_types = ALWAYS_VISIBLE_TYPES.copy()
|
||
is_root_job = False
|
||
|
||
if view_mode == "Job Summary":
|
||
st.header("Job")
|
||
base_folder = st.text_input("Base Folder", value=default_log, key="base_folder_input")
|
||
try:
|
||
base_path = _safe_resolve(base_folder, safe_root)
|
||
except ValueError as e:
|
||
st.error(str(e))
|
||
return
|
||
|
||
job_options = get_job_options(base_path, safe_root) # nosec B614 – validated by _safe_resolve
|
||
if job_options:
|
||
selected_job = st.selectbox("Select Job", job_options, key="job_select")
|
||
if selected_job.startswith("."):
|
||
job_folder = str(base_path)
|
||
is_root_job = True
|
||
else:
|
||
job_folder = str(base_path / selected_job)
|
||
state.selected_job_folder = job_folder
|
||
else:
|
||
st.warning("No jobs found in this directory")
|
||
job_folder = str(base_path)
|
||
|
||
if st.button("Refresh", type="primary", key="refresh_job"):
|
||
st.rerun()
|
||
else:
|
||
st.header("Session")
|
||
default_path = getattr(state, "selected_job_folder", default_log)
|
||
log_folder = st.text_input("Log Folder", value=default_path)
|
||
try:
|
||
log_path = _safe_resolve(log_folder, safe_root)
|
||
except ValueError as e:
|
||
st.error(str(e))
|
||
return
|
||
|
||
sessions = get_valid_sessions(log_path)
|
||
if not sessions:
|
||
st.warning("No valid sessions found")
|
||
return
|
||
|
||
selected_session = st.selectbox("Session", sessions)
|
||
|
||
if st.button("Load", type="primary") or "session" not in state:
|
||
with st.spinner("Loading..."):
|
||
state.session = load_session(log_path / selected_session)
|
||
state.session_name = selected_session
|
||
|
||
st.divider()
|
||
|
||
st.subheader("Show More")
|
||
selected_types = ALWAYS_VISIBLE_TYPES.copy()
|
||
for event_type, (label, default) in OPTIONAL_TYPES.items():
|
||
if st.toggle(label, value=default, key=f"toggle_{event_type}"):
|
||
selected_types.append(event_type)
|
||
|
||
st.divider()
|
||
|
||
if "session" in state:
|
||
summary = get_summary(state.session)
|
||
st.subheader("Summary")
|
||
st.metric("Loops", summary.get("loop_count", 0))
|
||
st.metric("LLM Calls", summary.get("llm_call_count", 0))
|
||
success = summary.get("docker_success", 0)
|
||
fail = summary.get("docker_fail", 0)
|
||
st.metric("Docker", f"{success}✓ / {fail}✗")
|
||
|
||
if view_mode == "Job Summary":
|
||
st.title("📊 RL Job Summary")
|
||
try:
|
||
job_path = _safe_resolve(job_folder, safe_root)
|
||
except ValueError as e:
|
||
st.warning(str(e))
|
||
return
|
||
if job_path.exists():
|
||
render_job_summary(job_path, safe_root, is_root=is_root_job)
|
||
else:
|
||
st.warning(f"Job folder not found: {job_folder}")
|
||
return
|
||
|
||
st.title("🤖 RL Timeline Viewer")
|
||
|
||
if "session" not in state:
|
||
st.info("Select a session and click **Load** to view")
|
||
return
|
||
|
||
session = state.session
|
||
summary = get_summary(session)
|
||
render_summary(summary)
|
||
st.divider()
|
||
render_session(session, selected_types)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|