diff --git a/config/__init__.py b/config/__init__.py index 6f49a30..9a006f5 100644 --- a/config/__init__.py +++ b/config/__init__.py @@ -9,6 +9,8 @@ def _load_dotenv(): env_path = Path(__file__).resolve().parents[1] / ".env" if not env_path.exists(): return + # 仅保护启动前已有的环境变量;.env 内重复键以后者为准 + pre_existing_keys = set(os.environ.keys()) try: for raw_line in env_path.read_text(encoding="utf-8").splitlines(): line = raw_line.strip() @@ -19,8 +21,12 @@ def _load_dotenv(): key, value = line.split("=", 1) key = key.strip() value = value.strip().strip('"').strip("'") - if key and key not in os.environ: - os.environ[key] = value + if not key: + continue + # 若在进程启动前已存在,则尊重外部环境;否则允许 .env 内后续行覆盖前面行 + if key in pre_existing_keys: + continue + os.environ[key] = value except Exception: # 加载失败时静默继续,保持兼容 pass diff --git a/config/paths.py b/config/paths.py index 81da5fc..6ae8675 100644 --- a/config/paths.py +++ b/config/paths.py @@ -1,6 +1,11 @@ """项目路径与目录配置。""" -DEFAULT_PROJECT_PATH = "./project" +import os + +# 默认项目路径,可通过环境变量覆盖以指向宿主机任意目录 +DEFAULT_PROJECT_PATH = os.environ.get("DEFAULT_PROJECT_PATH", "./project") +# 当终端运行在宿主机模式时,可显式指定工作目录;未设置时回退到 DEFAULT_PROJECT_PATH +HOST_PROJECT_PATH = os.environ.get("HOST_PROJECT_PATH", DEFAULT_PROJECT_PATH) PROMPTS_DIR = "./prompts" DATA_DIR = "./data" LOGS_DIR = "./logs" @@ -19,6 +24,7 @@ API_USAGE_FILE = f"{DATA_DIR}/api_usage.json" __all__ = [ "DEFAULT_PROJECT_PATH", + "HOST_PROJECT_PATH", "PROMPTS_DIR", "DATA_DIR", "LOGS_DIR", diff --git a/main.py b/main.py index 3aff226..b34fc62 100644 --- a/main.py +++ b/main.py @@ -114,7 +114,12 @@ class AgentSystem: async def setup_project_path(self): """设置项目路径""" - path_input = os.path.expanduser(str(DEFAULT_PROJECT_PATH)) + # 宿主机模式可通过 HOST_PROJECT_PATH 显式指定任意目录;否则使用默认 + if (TERMINAL_SANDBOX_MODE or "").lower() == "host": + path_input = os.environ.get("HOST_PROJECT_PATH") or os.environ.get("DEFAULT_PROJECT_PATH") or str(DEFAULT_PROJECT_PATH) + else: + path_input = os.environ.get("DEFAULT_PROJECT_PATH") or str(DEFAULT_PROJECT_PATH) + path_input = os.path.expanduser(str(path_input)) project_path = Path(path_input).resolve() if self.is_unsafe_path(str(project_path)): diff --git a/server/auth.py b/server/auth.py index 4df383d..78734cc 100644 --- a/server/auth.py +++ b/server/auth.py @@ -5,6 +5,13 @@ from flask import Blueprint, request, jsonify, session, redirect, send_from_dire from modules.personalization_manager import load_personalization_config from modules.user_manager import UserWorkspace +from config import ( + TERMINAL_SANDBOX_MODE, + HOST_PROJECT_PATH, + DATA_DIR, + LOGS_DIR, + UPLOAD_QUARANTINE_SUBDIR, +) from .auth_helpers import login_required, api_login_required, get_current_user_record, get_current_username from .security import ( @@ -95,6 +102,49 @@ def login(): return jsonify({"success": True}) +@auth_bp.route('/host-login', methods=['POST']) +def host_login(): + """宿主机模式一键进入(仅当 TERMINAL_SANDBOX_MODE=host 时可用)。""" + if (TERMINAL_SANDBOX_MODE or "").lower() != "host": + return jsonify({"success": False, "error": "宿主机模式未启用"}), 403 + if not state.container_manager.has_capacity("host"): + return jsonify({"success": False, "error": "资源繁忙,请稍后再试"}), 503 + + host_path = Path(HOST_PROJECT_PATH).expanduser().resolve() + host_path.mkdir(parents=True, exist_ok=True) + data_dir = Path(DATA_DIR).expanduser().resolve() + data_dir.mkdir(parents=True, exist_ok=True) + logs_dir = Path(LOGS_DIR).expanduser().resolve() + logs_dir.mkdir(parents=True, exist_ok=True) + uploads_dir = host_path / "user_upload" + uploads_dir.mkdir(parents=True, exist_ok=True) + quarantine_root = Path(UPLOAD_QUARANTINE_SUBDIR).expanduser() + if not quarantine_root.is_absolute(): + quarantine_root = (host_path.parent / UPLOAD_QUARANTINE_SUBDIR).resolve() + quarantine_root.mkdir(parents=True, exist_ok=True) + + # 初始化 session,跳过账号体系 + session.clear() + session['logged_in'] = True + session['username'] = 'host' + session['role'] = 'admin' + session['host_mode'] = True + default_thinking = current_app.config.get('DEFAULT_THINKING_MODE', False) + session['thinking_mode'] = default_thinking + session['run_mode'] = current_app.config.get('DEFAULT_RUN_MODE', "deep" if default_thinking else "fast") + session.permanent = True + + # 预先创建宿主机模式的终端/容器句柄(host 模式不会启动 Docker) + try: + state.container_manager.ensure_container("host", str(host_path), container_key="host") + except RuntimeError as exc: + session.clear() + return jsonify({"success": False, "error": str(exc)}), 503 + + get_csrf_token(force_new=True) + return jsonify({"success": True}) + + @auth_bp.route('/register', methods=['GET', 'POST']) def register(): if request.method == 'GET': diff --git a/server/context.py b/server/context.py index 7593c52..24856f7 100644 --- a/server/context.py +++ b/server/context.py @@ -11,6 +11,13 @@ from modules.personalization_manager import load_personalization_config import json from pathlib import Path from modules.usage_tracker import UsageTracker +from config import ( + HOST_PROJECT_PATH, + DATA_DIR, + LOGS_DIR, + TERMINAL_SANDBOX_MODE, + UPLOAD_QUARANTINE_SUBDIR, +) from . import state from .utils_common import debug_log @@ -45,6 +52,75 @@ def get_user_resources(username: Optional[str] = None, workspace_id: Optional[st username = (username or get_current_username()) if not username: return None, None + + # 宿主机免登录模式:使用 HOST_PROJECT_PATH 直接进入,不创建 /users//project + host_mode_session = bool(session.get("host_mode")) if has_request_context() else False + sandbox_is_host = (TERMINAL_SANDBOX_MODE or "host").lower() == "host" + if host_mode_session and sandbox_is_host: + project_path = Path(HOST_PROJECT_PATH).expanduser().resolve() + project_path.mkdir(parents=True, exist_ok=True) + data_dir = Path(DATA_DIR).expanduser().resolve() + data_dir.mkdir(parents=True, exist_ok=True) + logs_dir = Path(LOGS_DIR).expanduser().resolve() + logs_dir.mkdir(parents=True, exist_ok=True) + uploads_dir = project_path / "user_upload" + uploads_dir.mkdir(parents=True, exist_ok=True) + quarantine_root = Path(UPLOAD_QUARANTINE_SUBDIR).expanduser() + if not quarantine_root.is_absolute(): + quarantine_root = (project_path.parent / UPLOAD_QUARANTINE_SUBDIR).resolve() + quarantine_root.mkdir(parents=True, exist_ok=True) + + workspace = UserWorkspace( + username="host", + root=project_path.parent, + project_path=project_path, + data_dir=data_dir, + logs_dir=logs_dir, + uploads_dir=uploads_dir, + quarantine_dir=quarantine_root, + ) + if not hasattr(workspace, "workspace_id"): + workspace.workspace_id = "host" + + term_key = "host" + container_handle = state.container_manager.ensure_container("host", str(project_path), container_key=term_key) + usage_tracker = get_or_create_usage_tracker("host", workspace) + terminal = state.user_terminals.get(term_key) + if not terminal: + run_mode = session.get('run_mode') if has_request_context() else None + thinking_mode_flag = session.get('thinking_mode') if has_request_context() else None + if run_mode not in {"fast", "thinking", "deep"}: + run_mode = "fast" + thinking_mode_flag = False + thinking_mode = bool(thinking_mode_flag) if thinking_mode_flag is not None else (run_mode != "fast") + terminal = WebTerminal( + project_path=str(project_path), + thinking_mode=thinking_mode, + run_mode=run_mode, + message_callback=make_terminal_callback("host"), + data_dir=str(data_dir), + container_session=container_handle, + usage_tracker=usage_tracker + ) + if terminal.terminal_manager: + terminal.terminal_manager.broadcast = terminal.message_callback + state.user_terminals[term_key] = terminal + terminal.username = "host" + terminal.user_role = "admin" + terminal.quota_update_callback = None + if has_request_context(): + session['run_mode'] = terminal.run_mode + session['thinking_mode'] = terminal.thinking_mode + session['workspace_id'] = getattr(workspace, "workspace_id", None) + else: + terminal.update_container_session(container_handle) + attach_user_broadcast(terminal, "host") + terminal.username = "host" + terminal.user_role = "admin" + if has_request_context(): + session['workspace_id'] = getattr(workspace, "workspace_id", None) + return terminal, workspace + is_api_user = bool(session.get("is_api_user")) if has_request_context() else False # API 用户与网页用户使用不同的 manager if is_api_user: diff --git a/server/state.py b/server/state.py index 85c0037..4535314 100644 --- a/server/state.py +++ b/server/state.py @@ -51,7 +51,7 @@ THINKING_FAILURE_KEYWORDS = ["⚠️", "🛑", "失败", "错误", "异常", " CSRF_HEADER_NAME = "X-CSRF-Token" CSRF_SESSION_KEY = "_csrf_token" CSRF_SAFE_METHODS = {"GET", "HEAD", "OPTIONS", "TRACE"} -CSRF_PROTECTED_PATHS = {"/login", "/register", "/logout"} +CSRF_PROTECTED_PATHS = {"/login", "/register", "/logout", "/host-login"} CSRF_PROTECTED_PREFIXES = ("/api/",) CSRF_EXEMPT_PATHS = {"/api/csrf-token"} FAILED_LOGIN_LIMIT = 5 diff --git a/static/login.html b/static/login.html index 020093f..9af662c 100644 --- a/static/login.html +++ b/static/login.html @@ -71,6 +71,21 @@ opacity: 0.6; cursor: not-allowed; } + .ghost-btn { + margin-top: 12px; + width: 100%; + padding: 11px; + border-radius: 999px; + border: 1px solid #d8894c; + background: #fffefc; + color: #d8894c; + font-size: 0.95rem; + cursor: pointer; + } + .ghost-btn:disabled { + opacity: 0.6; + cursor: not-allowed; + } .error { margin-top: 14px; color: #c0392b; @@ -100,6 +115,7 @@ +