diff --git a/.env.example b/.env.example index 3ff2bcd..ec61290 100644 --- a/.env.example +++ b/.env.example @@ -31,6 +31,20 @@ AGENT_ADMIN_PASSWORD_HASH= # === Web Session ============================================================= WEB_SECRET_KEY=replace-with-random-hex +# === Web 服务 ================================================================ +# 监听端口(统一来源,main.py / server.app / state 均读取此项) +WEB_SERVER_PORT=8091 +# 监听地址:多用户/服务器用 0.0.0.0;单机便携包建议 127.0.0.1 只听本机 +WEB_SERVER_HOST=0.0.0.0 +# 调试模式(同时控制 Flask reloader),生产保持 0 +WEB_SERVER_DEBUG=0 + +# === 运行环境二进制(便携 release 包用,开发环境留默认即可)================== +# 子智能体使用的 node 可执行文件;留 "node" 表示走系统 PATH +AGENT_NODE_BIN=node +# Python 解释器路径;留空表示使用当前进程的 sys.executable +AGENT_PYTHON_BIN= + # === 终端容器沙箱 ============================================================= # 模式:docker / host TERMINAL_SANDBOX_MODE=docker diff --git a/.gitignore b/.gitignore index f591fd2..d96fb36 100644 --- a/.gitignore +++ b/.gitignore @@ -40,9 +40,12 @@ _experiments/ # Ignore docs doc/ -# Host workspace config (local, use .example) +# 部署级配置(含密钥引用 / 机器特定,本地保留,仓库仅留 .example 种子) config/host_workspaces.json config/custom_models.json +config/auto_approval.json +config/goal_review.json +config/host_sandbox_policy.json # Claude Code local artifacts (settings, worktrees) .claude/ diff --git a/AGENTS.md b/AGENTS.md index c0e36c0..b18be1a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,7 +55,9 @@ 具体目录变量支持相对路径(相对仓库根目录展开)、绝对路径与 `~`。 -> 注意:`config/*.json`(`custom_models.json`、`host_workspaces.json`、`auto_approval.json` 等)属于**配置而非运行态数据**,仍锚定源码树,不随运行态根目录迁移。`prompts/`、`agentskills/` 同理。 +> 注意:`config/*.json` 分两类: +> - **程序能力**(`docker_risk_markers.json`、`skill_hints.json`):是程序行为的一部分,随版本演进,仍锚定源码树。`prompts/`、`agentskills/` 同理。 +> - **部署级配置**(`custom_models`、`host_workspaces`、`auto_approval`、`goal_review`、`forbidden_commands`、`host_sandbox_policy`):因部署/机器而异或含密钥,外置到 `~/.agents//config/`(即 `DEPLOY_CONFIG_DIR`,可单独用该环境变量覆盖)。读取走 `config.resolve_deploy_config(name)`,回退链:部署目录 → 源码树 `.json` → 源码树 `.json.example`,因此开发环境不必先跑 setup 也能用源码树种子。含密钥/机器特定的 5 个(除 `forbidden_commands`)不纳入 git,仓库仅留 `.example`。 ### 1.5.3 日志策略(2026-06) diff --git a/CLAUDE.md b/CLAUDE.md index 8df5abd..f22dd81 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -159,7 +159,7 @@ tail -f ~/.agents/web/logs/container_stats.log 2. 模式根变量 `AGENTS_HOST_HOME` / `AGENTS_WEB_HOME`(整体搬迁该模式数据) 3. 兜底 `~/.agents/` -> `config/*.json`、`prompts/`、`agentskills/` 属于配置/资源,仍锚定源码树,不随运行态迁移。 +> `config/*.json` 分两类:**程序能力**(`docker_risk_markers.json`、`skill_hints.json`)与 `prompts/`、`agentskills/` 一样锚定源码树;**部署级配置**(`custom_models`、`host_workspaces`、`auto_approval`、`goal_review`、`forbidden_commands`、`host_sandbox_policy`)外置到 `~/.agents//config/`(`DEPLOY_CONFIG_DIR`),读取走 `config.resolve_deploy_config(name)`,回退链:部署目录 → 源码树 `.json` → 源码树 `.json.example`。含密钥/机器特定的 5 个不进 git,仓库仅留 `.example`。 **日志策略**: - API 请求体 dump 默认**关闭**,`AGENT_API_DUMP_ENABLED=1` 开启。 diff --git a/config/__init__.py b/config/__init__.py index 5443ee3..561e1e3 100644 --- a/config/__init__.py +++ b/config/__init__.py @@ -50,6 +50,7 @@ from . import uploads as _uploads from . import sub_agent as _sub_agent from . import custom_tools as _custom_tools from . import mcp as _mcp +from . import server as _server from .api import * from .search import * @@ -67,9 +68,10 @@ from .uploads import * from .sub_agent import * from .custom_tools import * from .mcp import * +from .server import * __all__ = [] -for module in (_api, _search, _paths, _limits, _terminal, _conversation, _security, _ui, _memory, _ocr, _todo, _auth, _uploads, _sub_agent, _custom_tools, _mcp): +for module in (_api, _search, _paths, _limits, _terminal, _conversation, _security, _ui, _memory, _ocr, _todo, _auth, _uploads, _sub_agent, _custom_tools, _mcp, _server): __all__ += getattr(module, "__all__", []) -del _api, _search, _paths, _limits, _terminal, _conversation, _security, _ui, _memory, _ocr, _todo, _auth, _uploads, _sub_agent, _custom_tools, _mcp +del _api, _search, _paths, _limits, _terminal, _conversation, _security, _ui, _memory, _ocr, _todo, _auth, _uploads, _sub_agent, _custom_tools, _mcp, _server diff --git a/config/api.py b/config/api.py index 88cdad6..6aced9b 100644 --- a/config/api.py +++ b/config/api.py @@ -1,43 +1,19 @@ -"""API 和外部服务配置。""" +"""API 和外部服务配置。 + +模型的 API 端点 / 密钥 / 模型 ID 现已统一由 ``config/custom_models.json`` +(经 ``config/model_profiles.py`` 解析为模型档案)描述,并在运行时通过 +``DeepSeekClient.apply_profile`` 注入。旧的 ``AGENT_API_BASE_URL`` / +``AGENT_API_KEY`` / ``AGENT_MODEL_ID``(以及 THINKING / TITLE 三件套)属于早期 +把模型逻辑硬编码在程序里的残留,已移除。没有任何可用模型时, +``model_profiles.get_default_model_key`` 会直接报错提示去配置模型。 +""" import os -def _env(name: str, default: str = "", required: bool = False) -> str: - value = os.environ.get(name, default) - if required and not value: - raise RuntimeError(f"缺少必要环境变量: {name}") - return value - - -API_BASE_URL = _env("AGENT_API_BASE_URL", "https://api.example.com") -API_KEY = _env("AGENT_API_KEY", "") -MODEL_ID = _env("AGENT_MODEL_ID", "") - -# 推理模型配置(智能思考模式使用) -THINKING_API_BASE_URL = _env("AGENT_THINKING_API_BASE_URL", API_BASE_URL) -THINKING_API_KEY = _env("AGENT_THINKING_API_KEY", API_KEY) -THINKING_MODEL_ID = _env("AGENT_THINKING_MODEL_ID", MODEL_ID) - -from .search import TAVILY_API_KEY - -# 对话标题生成模型(可选,留空则回退到基础快速模型) -TITLE_API_BASE_URL = _env("AGENT_TITLE_API_BASE_URL", API_BASE_URL) -TITLE_API_KEY = _env("AGENT_TITLE_API_KEY", API_KEY) -TITLE_MODEL_ID = _env("AGENT_TITLE_MODEL_ID", MODEL_ID) - -# 默认响应 token 限制 +# 默认响应 token 限制(与具体模型无关,保留为全局上限) DEFAULT_RESPONSE_MAX_TOKENS = int(os.environ.get("AGENT_DEFAULT_RESPONSE_MAX_TOKENS", "32768")) __all__ = [ - "API_BASE_URL", - "API_KEY", - "MODEL_ID", "DEFAULT_RESPONSE_MAX_TOKENS", - "THINKING_API_BASE_URL", - "THINKING_API_KEY", - "THINKING_MODEL_ID", - "TITLE_API_BASE_URL", - "TITLE_API_KEY", - "TITLE_MODEL_ID", ] diff --git a/config/auto_approval.json b/config/auto_approval.json.example similarity index 74% rename from config/auto_approval.json rename to config/auto_approval.json.example index ebb0657..180fd92 100644 --- a/config/auto_approval.json +++ b/config/auto_approval.json.example @@ -1,8 +1,8 @@ { "name": "auto-approval-agent", - "url": "https://opencode.ai/zen/go/v1", + "url": "https://api.example.com/v1", "key": "${API_KEY_DEEPSEEK}", - "model": "deepseek-v4-flash", + "model": "your-model-id", "extra_params": { "thinking": { "type": "enabled" diff --git a/config/goal_review.json b/config/goal_review.json.example similarity index 72% rename from config/goal_review.json rename to config/goal_review.json.example index 66c105f..5595b88 100644 --- a/config/goal_review.json +++ b/config/goal_review.json.example @@ -1,8 +1,8 @@ { "name": "goal-review-agent", - "url": "https://opencode.ai/zen/go/v1", + "url": "https://api.example.com/v1", "key": "${API_KEY_DEEPSEEK}", - "model": "deepseek-v4-flash", + "model": "your-model-id", "extra_params": { "thinking": { "type": "enabled" diff --git a/config/host_sandbox_policy.json b/config/host_sandbox_policy.json deleted file mode 100644 index 997ecfe..0000000 --- a/config/host_sandbox_policy.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "macos_writable_paths": [ - "/Users/jojo/Desktop" - ], - "macos_readable_extra_paths": [ - "/Users/jojo/Desktop" - ] -} \ No newline at end of file diff --git a/config/host_sandbox_policy.json.example b/config/host_sandbox_policy.json.example new file mode 100644 index 0000000..d5ffe06 --- /dev/null +++ b/config/host_sandbox_policy.json.example @@ -0,0 +1,4 @@ +{ + "macos_writable_paths": [], + "macos_readable_extra_paths": [] +} diff --git a/config/model_profiles.py b/config/model_profiles.py index dc2c5e9..0232da5 100644 --- a/config/model_profiles.py +++ b/config/model_profiles.py @@ -3,6 +3,8 @@ import os from pathlib import Path from typing import Any, Dict, List, Optional +from .paths import resolve_deploy_config + def _env_optional(name: str) -> Optional[str]: value = os.environ.get(name) @@ -160,7 +162,7 @@ def _build_custom_profile(item: Dict[str, Any]) -> Optional[Dict[str, Any]]: def _load_custom_models() -> Dict[str, Dict[str, Any]]: - custom_path = Path(__file__).resolve().parent / "custom_models.json" + custom_path = Path(resolve_deploy_config("custom_models.json")) if not custom_path.exists(): return {} try: diff --git a/config/paths.py b/config/paths.py index 5b95c43..a31c294 100644 --- a/config/paths.py +++ b/config/paths.py @@ -16,8 +16,16 @@ 整体搬迁该模式下的全部运行态数据; 3. 兜底默认值 ``~/.agents/``。 -注意:``config/*.json``(custom_models、host_workspaces、auto_approval 等)属于 -配置而非运行态数据,仍锚定在源码树内,不随运行态根目录迁移。 +注意:``config/*.json`` 分两类,不再一概锚定源码树: + +- **开发者配置 / 程序能力**(``docker_risk_markers.json`` / ``skill_hints.json``): + 是程序行为的一部分,随版本演进,仍锚定源码树。 +- **部署者自定义配置**(``custom_models`` / ``host_workspaces`` / + ``auto_approval`` / ``goal_review`` / ``forbidden_commands`` / + ``host_sandbox_policy``):因部署/机器而异、或含密钥,外置到 + ``~/.agents//config/``(即 ``DEPLOY_CONFIG_DIR``)。读取走 + ``resolve_deploy_config``,回退链为:部署目录 -> 源码树 ``.json`` -> + 源码树 ``.json.example``。这样开发环境不必先跑 setup 也能用源码树种子。 """ import os @@ -85,6 +93,44 @@ USER_SPACE_DIR = _runtime_dir("USER_SPACE_DIR", "users") # API 专用用户与工作区(与网页用户隔离) API_USER_SPACE_DIR = _runtime_dir("API_USER_SPACE_DIR", "api/users") +# ── 部署级配置目录(部署者自定义的 config/*.json 外置到这里)── +# 默认 ~/.agents//config,可用 DEPLOY_CONFIG_DIR 覆盖。 +DEPLOY_CONFIG_DIR = _runtime_dir("DEPLOY_CONFIG_DIR", "config") +# 源码树内的配置种子目录(首次运行/开发环境的默认内容来源)。 +_SEED_CONFIG_DIR = _REPO_ROOT / "config" + + +def deploy_config_path(filename: str) -> str: + """部署级配置文件应写入/读取的位置(``~/.agents//config`` 下)。 + + 用于会被运行时写回的文件(如 host_workspaces / host_sandbox_policy), + 始终指向部署目录,不回退源码树。 + """ + return str(Path(DEPLOY_CONFIG_DIR) / filename) + + +def resolve_deploy_config(filename: str) -> str: + """解析部署级配置的只读路径,回退链(高→低): + + 1. ``~/.agents//config/``(部署者真实配置) + 2. 源码树 ``config/``(仓库内种子) + 3. 源码树 ``config/.example``(示例兜底) + + 都不存在时返回部署目录下的目标路径,由调用方处理「文件不存在」。 + 仅用于定位,不在此做拷贝(拷贝交给 setup / 迁移脚本)。 + """ + deployed = Path(DEPLOY_CONFIG_DIR) / filename + if deployed.exists(): + return str(deployed) + seed = _SEED_CONFIG_DIR / filename + if seed.exists(): + return str(seed) + example = _SEED_CONFIG_DIR / f"{filename}.example" + if example.exists(): + return str(example) + return str(deployed) + + # ── 启动工作区 ── # 默认项目路径,可通过环境变量覆盖以指向宿主机任意目录。 DEFAULT_PROJECT_PATH = _resolve_repo_path(os.environ.get("DEFAULT_PROJECT_PATH", ""), "./project") @@ -95,8 +141,12 @@ HOST_PROJECT_PATH = _resolve_repo_path(os.environ.get("HOST_PROJECT_PATH", ""), PROMPTS_DIR = _resolve_repo_path(os.environ.get("PROMPTS_DIR", ""), "./prompts") AGENT_SKILLS_DIR = _resolve_repo_path(os.environ.get("AGENT_SKILLS_DIR", ""), "./agentskills") WORKSPACE_SKILLS_DIRNAME = ".agents/skills" -# 宿主机模式工作区配置文件(JSON,属于配置,锚定源码树)。 -HOST_WORKSPACES_FILE = _resolve_repo_path(os.environ.get("HOST_WORKSPACES_FILE", ""), "./config/host_workspaces.json") +# 宿主机模式工作区配置文件(JSON)。属于部署级配置且会被运行时写回, +# 因此指向部署目录 ~/.agents//config(HOST_WORKSPACES_FILE 显式设置时优先)。 +HOST_WORKSPACES_FILE = _resolve_repo_path( + os.environ.get("HOST_WORKSPACES_FILE", ""), + deploy_config_path("host_workspaces.json"), +) # ── 基于 DATA_DIR 派生的数据文件 ── USERS_DB_FILE = f"{DATA_DIR}/users.json" @@ -124,4 +174,7 @@ __all__ = [ "API_USERS_DB_FILE", "API_TOKENS_FILE", "API_USAGE_FILE", + "DEPLOY_CONFIG_DIR", + "deploy_config_path", + "resolve_deploy_config", ] diff --git a/config/security.py b/config/security.py index 29537cb..0705fab 100644 --- a/config/security.py +++ b/config/security.py @@ -3,9 +3,15 @@ from pathlib import Path import json +from .paths import resolve_deploy_config + def _load_forbidden_commands() -> list[str]: - """从独立 JSON 文件加载命令拦截关键词。""" + """从独立 JSON 文件加载命令拦截关键词。 + + 属于部署级配置(部署者可调宽松/严苛),优先读 ~/.agents//config, + 回退源码树种子。 + """ fallback = [ "rm -rf /", "rm -rf ~", @@ -15,7 +21,7 @@ def _load_forbidden_commands() -> list[str]: "kill -9", "dd if=", ] - cfg_path = Path(__file__).resolve().parent / "forbidden_commands.json" + cfg_path = Path(resolve_deploy_config("forbidden_commands.json")) if not cfg_path.exists(): return fallback try: diff --git a/config/server.py b/config/server.py new file mode 100644 index 0000000..ece6f82 --- /dev/null +++ b/config/server.py @@ -0,0 +1,42 @@ +"""Web 服务与运行环境配置(单一事实源)。 + +集中所有"部署/运行"相关配置,避免端口、监听地址、运行二进制路径等散落多处、 +各读各的。全项目应统一从 ``config`` 导入这里导出的常量,禁止再硬编码。 + +解析优先级:环境变量 > 此处默认值;命令行参数(如 ``--port``)可在入口层再覆盖。 +""" + +import os + + +def _env(name: str, default: str = "") -> str: + return str(os.environ.get(name, "") or "").strip() or default + + +def _flag(name: str, default: str = "0") -> bool: + return _env(name, default).lower() in ("1", "true", "yes", "on") + + +# === Web 服务 =============================================================== +# 监听端口(历史上散落在 main.py / server.app_legacy / server.state / cli)。 +WEB_SERVER_PORT = int(_env("WEB_SERVER_PORT", "8091")) +# 监听地址:多用户/服务器用 0.0.0.0;单机便携包建议设 127.0.0.1 只听本机。 +WEB_SERVER_HOST = _env("WEB_SERVER_HOST", "0.0.0.0") +# 调试模式(同时控制 Flask reloader)。 +WEB_SERVER_DEBUG = _flag("WEB_SERVER_DEBUG", "0") + +# === 运行环境二进制 ========================================================= +# 便携 release 包启动脚本会注入这两个绝对路径,使主程序与子智能体使用内置运行时。 +# 留空/默认时回退到系统 PATH 上的 node(开发环境或系统已安装时)。 +NODE_BIN = _env("AGENT_NODE_BIN", "node") +# Python 解释器路径;留空表示使用当前进程的 sys.executable。 +PYTHON_BIN = _env("AGENT_PYTHON_BIN", "") + + +__all__ = [ + "WEB_SERVER_PORT", + "WEB_SERVER_HOST", + "WEB_SERVER_DEBUG", + "NODE_BIN", + "PYTHON_BIN", +] diff --git a/main.py b/main.py index b34fc62..a56b2a0 100644 --- a/main.py +++ b/main.py @@ -195,7 +195,7 @@ class AgentSystem: print(f"\n{OUTPUT_FORMATS['success']} 正在启动Web服务器...") print(f"{OUTPUT_FORMATS['info']} 项目路径: {self.project_path}") - port = int(os.environ.get("WEB_SERVER_PORT", "8091")) + port = WEB_SERVER_PORT # 运行服务器(这会阻塞) run_server( diff --git a/modules/approval_agent.py b/modules/approval_agent.py index ac6fa01..7d5e269 100644 --- a/modules/approval_agent.py +++ b/modules/approval_agent.py @@ -9,13 +9,16 @@ from typing import Any, Callable, Dict, List, Optional import httpx +from config import resolve_deploy_config, LOGS_DIR -CONFIG_PATH = Path(__file__).resolve().parents[1] / "config" / "auto_approval.json" + +# 部署级配置(审批智能体 url/key/model,含密钥引用)→ 优先 ~/.agents//config +CONFIG_PATH = Path(resolve_deploy_config("auto_approval.json")) DEFAULT_MAX_ROUNDS = 3 DEFAULT_TIMEOUT_SECONDS = 60 DEFAULT_MAX_COMMAND_TIMEOUT = 20 DEBUG_SAVE_APPROVAL_AGENT_TRANSCRIPT = True -DEBUG_TRANSCRIPT_DIR = Path(__file__).resolve().parents[1] / "logs" / "approval_agent" +DEBUG_TRANSCRIPT_DIR = Path(LOGS_DIR) / "approval_agent" def _resolve_env_token(value: str) -> str: diff --git a/modules/goal_review_agent.py b/modules/goal_review_agent.py index c589393..604e1b6 100644 --- a/modules/goal_review_agent.py +++ b/modules/goal_review_agent.py @@ -23,16 +23,17 @@ from typing import Any, Callable, Dict, List, Optional import httpx -from config import PROMPTS_DIR +from config import PROMPTS_DIR, resolve_deploy_config, LOGS_DIR from modules.goal_state_manager import REVIEW_MODE_ACTIVE, REVIEW_MODE_READONLY -CONFIG_PATH = Path(__file__).resolve().parents[1] / "config" / "goal_review.json" +# 部署级配置(目标审核智能体 url/key/model,含密钥引用)→ 优先 ~/.agents//config +CONFIG_PATH = Path(resolve_deploy_config("goal_review.json")) PROMPT_NAME = "goal_review_agent.txt" DEFAULT_MAX_ROUNDS = 3 DEFAULT_TIMEOUT_SECONDS = 60 DEFAULT_MAX_COMMAND_TIMEOUT = 60 DEBUG_SAVE_GOAL_REVIEW_TRANSCRIPT = True -DEBUG_TRANSCRIPT_DIR = Path(__file__).resolve().parents[1] / "logs" / "goal_review_agent" +DEBUG_TRANSCRIPT_DIR = Path(LOGS_DIR) / "goal_review_agent" # 兜底续命消息(审核智能体未能产出明确结论时使用) FALLBACK_CONTINUE_MESSAGE = "审核未能给出明确结论。请重新对照目标核查当前进度,找出尚未完成的部分并继续推进。" diff --git a/modules/host_sandbox_policy.py b/modules/host_sandbox_policy.py index 4a026b6..f66d7e9 100644 --- a/modules/host_sandbox_policy.py +++ b/modules/host_sandbox_policy.py @@ -5,10 +5,11 @@ import threading from pathlib import Path from typing import Dict, List -from config import HOST_SANDBOX_MACOS_WRITABLE_PATHS +from config import HOST_SANDBOX_MACOS_WRITABLE_PATHS, deploy_config_path _LOCK = threading.Lock() -_POLICY_PATH = Path(__file__).resolve().parents[1] / "config" / "host_sandbox_policy.json" +# 部署级配置(机器特定可读写路径,会被运行时写回)→ ~/.agents//config +_POLICY_PATH = Path(deploy_config_path("host_sandbox_policy.json")) def _ensure_file() -> None: diff --git a/modules/sub_agent_manager.py b/modules/sub_agent_manager.py index 78d61be..76856ff 100644 --- a/modules/sub_agent_manager.py +++ b/modules/sub_agent_manager.py @@ -19,6 +19,7 @@ from config import ( SUB_AGENT_STATE_FILE, SUB_AGENT_STATUS_POLL_INTERVAL, SUB_AGENT_TASKS_BASE_DIR, + NODE_BIN, ) from utils.logger import setup_logger import logging @@ -382,7 +383,7 @@ class SubAgentManager: thinking_mode: Optional[str], ) -> List[str]: cmd = [ - "node", + NODE_BIN, batch_path, "--workspace", workspace_path, "--task-file", task_file, diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 0000000..0c2db40 --- /dev/null +++ b/scripts/__init__.py @@ -0,0 +1 @@ +"""运维与初始化脚本包。""" diff --git a/scripts/migrate_runtime_data.py b/scripts/migrate_runtime_data.py index 56e6e7e..2510549 100644 --- a/scripts/migrate_runtime_data.py +++ b/scripts/migrate_runtime_data.py @@ -14,6 +14,11 @@ 先打包备份到 ``<运行态根上级>/backup_<时间戳>.tar.gz``, 校验备份成功后再复制到目标位置;复制采用「不覆盖已存在目标」的 保守策略,全程复制而非移动,旧目录保留,便于回滚。 +- 部署级配置文件(custom_models / host_workspaces / auto_approval / goal_review / + forbidden_commands / host_sandbox_policy) + 从源码树 ``config/`` 复制到部署配置目录 ``~/.agents//config/``, + 同样先备份、不覆盖已存在目标。docker_risk_markers、skill_hints 属于 + 程序能力,保留源码树,不迁。 本脚本只做「复制 + 备份」,不删除任何源数据,确保完全可回滚。 确认新目录工作正常后,可自行手动删除源码树内的旧目录。 @@ -52,6 +57,30 @@ def _migrate_targets() -> dict[str, Path]: } +# 部署级配置文件:源码树 config/ -> 部署配置目录 ~/.agents//config/。 +# 仅迁移部署者自定义类(含密钥/机器特定/可调策略);docker_risk_markers、skill_hints +# 属于程序能力,保留源码树,不迁。 +_DEPLOY_CONFIG_FILES = ( + "custom_models.json", + "host_workspaces.json", + "auto_approval.json", + "goal_review.json", + "forbidden_commands.json", + "host_sandbox_policy.json", +) + + +def _migrate_config_files() -> list[tuple[Path, Path]]: + """返回 (源码树配置文件, 部署目录目标) 列表,仅含实际存在的源文件。""" + deploy_dir = Path(config.DEPLOY_CONFIG_DIR) + pairs: list[tuple[Path, Path]] = [] + for name in _DEPLOY_CONFIG_FILES: + src = REPO_ROOT / "config" / name + if src.is_file(): + pairs.append((src, deploy_dir / name)) + return pairs + + def _human_size(num: int) -> str: value = float(num) for unit in ("B", "KB", "MB", "GB", "TB"): @@ -111,6 +140,17 @@ def _copy_tree(src: Path, dst: Path, *, dry_run: bool) -> None: shutil.copytree(src, dst) +def _copy_file(src: Path, dst: Path, *, dry_run: bool) -> None: + if dst.exists(): + print(f"[skip] 目标已存在,跳过以避免覆盖:{dst}") + return + print(f"[copy] {src} -> {dst} ({_human_size(src.stat().st_size)})") + if dry_run: + return + dst.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(src, dst) + + def main() -> int: parser = argparse.ArgumentParser(description="迁移运行态数据到运行态根目录(~/.agents/)") parser.add_argument("--dry-run", action="store_true", help="仅打印将要执行的操作,不实际写入") @@ -132,19 +172,27 @@ def main() -> int: else: print(f"[absent] 源目录不存在或为空,跳过:{src}") - if not present: + # 收集需迁移的部署级配置文件(源码树 config/*.json -> 部署目录) + config_files = _migrate_config_files() + for src, dst in config_files: + print(f"[config] 待迁移配置:{src.name} -> {dst}") + if not config_files: + print("[config] 无部署级配置文件需迁移。") + + if not present and not config_files: print("没有需要迁移的运行态数据。") return 0 # 1) 备份(备份到运行态根的上级目录,与运行态根同级) backup_root = runtime_root.parent + backup_sources = [s for s, _ in present] + [s for s, _ in config_files] try: - _backup([s for s, _ in present], backup_root, dry_run=args.dry_run) + _backup(backup_sources, backup_root, dry_run=args.dry_run) except Exception as exc: print(f"[error] 备份失败,已中止迁移(未改动任何数据):{exc}", file=sys.stderr) return 1 - # 2) 复制(不覆盖已有目标,源目录保留) + # 2) 复制(不覆盖已有目标,源保留) for src, dst in present: try: _copy_tree(src, dst, dry_run=args.dry_run) @@ -152,6 +200,13 @@ def main() -> int: print(f"[error] 复制失败:{src} -> {dst}:{exc}", file=sys.stderr) print(" 已迁移的部分保留,源数据未删除,可重跑或手动处理。", file=sys.stderr) return 1 + for src, dst in config_files: + try: + _copy_file(src, dst, dry_run=args.dry_run) + except Exception as exc: + print(f"[error] 复制配置失败:{src} -> {dst}:{exc}", file=sys.stderr) + print(" 已迁移的部分保留,源文件未删除,可重跑或手动处理。", file=sys.stderr) + return 1 print("-" * 60) if args.dry_run: diff --git a/scripts/setup.py b/scripts/setup.py new file mode 100644 index 0000000..edd8de2 --- /dev/null +++ b/scripts/setup.py @@ -0,0 +1,329 @@ +#!/usr/bin/env python3 +"""首次启动初始化向导(终端交互)。 + +在启动 Web 服务之前,于终端一次性设置好"必须在程序启动前确定"的内容, +并写出项目根目录的 ``.env``: + +- 运行模式 host / web(决定数据目录与子容器策略,在 import config 时即锁定) +- Web 监听端口 / 地址(服务 listen 前必须确定) +- 管理员账号(用户名 + 密码哈希,与 user_manager._ensure_admin_user 兼容) +- 会话与 Token 密钥(自动随机生成,不打扰用户) +- 基础 API 三件套(base_url / key / model_id) +- 可选的 thinking / title 模型 + +设计要点: + +* **单一线性流程**:逐项询问,每项带默认值,回车即取默认。 +* **不联网校验**:API 配置只写入,不当场测试连通性。 +* **以 .env.example 为模板**:逐行读取模板,仅替换被本向导接管的键的值, + 其余行(包括全部注释与未涉及的配置项)原样保留——保证生成的 .env + 仍带完整文档,也不会丢任何配置开关。 +* **不 import config**:向导在 .env 尚不存在时运行,且模式选择会影响路径解析, + 因此刻意不依赖 config,避免过早锁定路径或触发 .env 加载。 + +用法: + python -m scripts.setup # 交互式向导 + python scripts/setup.py # 同上 + python -m scripts.setup --force # 跳过"已存在 .env"的确认(仍会备份) +""" + +from __future__ import annotations + +import argparse +import json +import secrets +import shutil +import sys +import time +from getpass import getpass +from pathlib import Path + +try: + from werkzeug.security import generate_password_hash +except ImportError: # pragma: no cover - 缺依赖时给出可读提示 + print("缺少依赖 werkzeug,请先安装:pip install -r requirements.txt") + sys.exit(1) + + +REPO_ROOT = Path(__file__).resolve().parents[1] +ENV_PATH = REPO_ROOT / ".env" +ENV_EXAMPLE_PATH = REPO_ROOT / ".env.example" + + +def _deploy_config_dir(mode: str, custom_data: str) -> Path: + """推导部署级配置目录(~/.agents//config)。 + + 必须与 config/paths.py 的解析保持一致: + - 自定义数据根(向导里填的)优先,作为运行态根目录; + - 否则默认 ~/.agents/; + - 配置目录 = 运行态根 / config。 + + setup 刻意不 import config(避免在 .env 写好前过早锁定路径), + 因此这里用向导现场收集到的 mode / custom_data 自行推导。 + """ + root = Path(custom_data).expanduser() if custom_data else (Path("~/.agents").expanduser() / mode) + return (root / "config").resolve() + + +# === 终端交互辅助 =========================================================== + +def _ask(prompt: str, default: str = "") -> str: + """询问一项,回车取默认。default 非空时在提示里展示。""" + suffix = f" [{default}]" if default else "" + try: + raw = input(f"{prompt}{suffix}: ").strip() + except EOFError: + raw = "" + return raw or default + + +def _ask_choice(prompt: str, choices: list[str], default: str) -> str: + """从有限选项中选择,输入不在集合内则重问。""" + choices_lower = {c.lower(): c for c in choices} + hint = "/".join(choices) + while True: + raw = _ask(f"{prompt}({hint})", default).lower() + if raw in choices_lower: + return choices_lower[raw] + print(f" 请输入 {hint} 之一。") + + +def _ask_required(prompt: str, default: str = "") -> str: + """必填项,留空则重问。""" + while True: + value = _ask(prompt, default) + if value: + return value + print(" 此项必填,不能为空。") + + +def _ask_password(prompt: str) -> str: + """隐藏输入密码,二次确认一致。""" + while True: + first = getpass(f"{prompt}: ") + if not first: + print(" 密码不能为空。") + continue + second = getpass("再次输入确认: ") + if first != second: + print(" 两次输入不一致,请重试。") + continue + return first + + +# === .env 生成 ============================================================= + +def _render_env(values: dict[str, str]) -> str: + """以 .env.example 为模板渲染 .env:仅替换被接管键的值,其余原样保留。 + + 被接管的键写入用户配置值;模板里未被接管的键与全部注释、空行原样保留。 + 若某接管键在模板中不存在(理论上不会),追加到末尾。 + """ + if not ENV_EXAMPLE_PATH.exists(): + # 没有模板时退化为按 values 直接生成(无注释) + return "".join(f"{k}={v}\n" for k, v in values.items()) + + remaining = dict(values) + out_lines: list[str] = [] + for raw in ENV_EXAMPLE_PATH.read_text(encoding="utf-8").splitlines(): + stripped = raw.strip() + if stripped and not stripped.startswith("#") and "=" in stripped: + key = stripped.split("=", 1)[0].strip() + if key in remaining: + out_lines.append(f"{key}={remaining.pop(key)}") + continue + out_lines.append(raw) + + # 模板里没有、但本向导要写的键(兜底,正常不会触发) + if remaining: + out_lines.append("") + out_lines.append("# === 由 setup 向导补充 ===") + for key, value in remaining.items(): + out_lines.append(f"{key}={value}") + + return "\n".join(out_lines) + "\n" + + +def _backup_existing_env() -> Path | None: + if not ENV_PATH.exists(): + return None + backup = ENV_PATH.with_name(f".env.bak_{time.strftime('%Y%m%d_%H%M%S')}") + shutil.copy2(ENV_PATH, backup) + return backup + + +# === custom_models.json 生成 ============================================== + +def _build_custom_model_entry( + *, model_name: str, url: str, api_key: str, model_id: str, supports_thinking: bool +) -> dict: + """构造一个最小可用的 custom_models 条目。 + + 字段与 config/model_profiles.py 的解析规则对齐: + - url / apikey 直接写字面量值(解析器对非全大写字符串按字面量处理); + - param_toggle 类型用 thinkmode_status.model_id 承载真实模型 ID; + - reasoning_capability 决定是否暴露思考能力。 + """ + capability = "fast,thinking" if supports_thinking else "fast" + entry: dict = { + "model_name": model_name, + "description": model_name, + "visible": True, + "url": url, + "apikey": api_key, + "multimodal": "none", + "reasoning_capability": capability, + "context_window": 128000, + "max_output_tokens": 32768, + "thinkmode_status": { + "type": "param_toggle", + "model_id": model_id, + "fast_extra_parameter": {}, + "thinking_extra_parameter": {}, + }, + "extra_parameter": {}, + "model_description": f"你的基础模型是 {model_name}。", + } + return entry + + +def _write_custom_models(target_path: Path, entry: dict) -> Path | None: + """写出 custom_models.json 到部署配置目录。已存在则先备份再覆盖。""" + backup = None + if target_path.exists(): + backup = target_path.with_name( + f"custom_models.json.bak_{time.strftime('%Y%m%d_%H%M%S')}" + ) + shutil.copy2(target_path, backup) + payload = {"models": [entry]} + target_path.parent.mkdir(parents=True, exist_ok=True) + target_path.write_text( + json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8" + ) + return backup + + +# === 主流程 ================================================================ + +def run_wizard(force: bool = False) -> int: + print("=" * 60) + print(" AI Agent 首次启动初始化向导") + print("=" * 60) + print("逐项设置启动前必须确定的内容,每项可回车使用 [默认值]。") + print() + + if ENV_PATH.exists() and not force: + print(f"检测到已存在配置文件:{ENV_PATH}") + ans = _ask_choice("是否备份后重新配置", ["y", "n"], "n") + if ans == "n": + print("已取消,未做任何修改。") + return 0 + + values: dict[str, str] = {} + + # --- 1. 运行模式 --- + print("\n[1/5] 运行模式") + print(" host = 单机本地模式,命令直接在宿主机沙箱执行") + print(" web = 多用户模式,每个用户分配独立 Docker 子容器") + mode = _ask_choice("选择运行模式", ["host", "web"], "host") + values["TERMINAL_SANDBOX_MODE"] = mode + default_host = "127.0.0.1" if mode == "host" else "0.0.0.0" + + # --- 2. Web 服务 --- + print("\n[2/5] Web 服务监听") + values["WEB_SERVER_PORT"] = _ask("监听端口", "8091") + print(" 监听地址:单机本地建议 127.0.0.1(只听本机);多用户/服务器用 0.0.0.0") + values["WEB_SERVER_HOST"] = _ask("监听地址", default_host) + + # --- 3. 数据目录(可选覆盖)--- + print("\n[3/5] 数据目录") + default_data = f"~/.agents/{mode}" + print(f" 运行态数据(对话/用户/记忆/日志)默认存放在 {default_data}") + custom_data = _ask("自定义数据根目录(留空用默认)", "") + if custom_data: + # 模式根变量:host -> AGENTS_HOST_HOME,web -> AGENTS_WEB_HOME + values["AGENTS_HOST_HOME" if mode == "host" else "AGENTS_WEB_HOME"] = custom_data + + # --- 4. 管理员账户 --- + print("\n[4/5] 管理员账户") + admin_user = _ask("管理员用户名", "admin") + admin_pw = _ask_password("管理员密码") + values["AGENT_ADMIN_USERNAME"] = admin_user + values["AGENT_ADMIN_PASSWORD_HASH"] = generate_password_hash(admin_pw) + + # --- 5. 模型配置(写入部署级配置目录 ~/.agents//config/custom_models.json)--- + print("\n[5/5] 模型配置") + print(" 配置一个可用模型(写入 ~/.agents//config/custom_models.json)。") + print(" 启动后还可在后台页面继续增删模型。") + model_name = _ask_required("模型显示名(界面上看到的名字)", "默认模型") + model_url = _ask_required("API 地址(Base URL)", "") + model_apikey = _ask_required("API Key", "") + model_id = _ask_required("模型 ID(请求里实际发送的 model 字段)", "") + supports_thinking = _ask_choice( + "该模型是否支持思考模式", ["y", "n"], "n" + ) == "y" + + model_entry = _build_custom_model_entry( + model_name=model_name, + url=model_url, + api_key=model_apikey, + model_id=model_id, + supports_thinking=supports_thinking, + ) + + # --- 自动生成密钥 --- + values["WEB_SECRET_KEY"] = secrets.token_hex(32) + values["API_TOKEN_SECRET"] = secrets.token_hex(32) + + # --- 写出 --- + rendered = _render_env(values) + backup = _backup_existing_env() + ENV_PATH.write_text(rendered, encoding="utf-8") + try: + ENV_PATH.chmod(0o600) # .env 含密钥,收紧权限(Windows 上静默忽略) + except (OSError, NotImplementedError): + pass + # 模型配置写到部署级配置目录(~/.agents//config),而非源码树 + custom_models_path = _deploy_config_dir(mode, custom_data) / "custom_models.json" + models_backup = _write_custom_models(custom_models_path, model_entry) + + # --- 摘要 --- + print("\n" + "=" * 60) + print(" 配置完成") + print("=" * 60) + print(f" 配置文件:{ENV_PATH}") + if backup: + print(f" 原配置已备份:{backup}") + print(f" 模型配置:{custom_models_path}") + if models_backup: + print(f" 原模型配置已备份:{models_backup}") + print(f" 运行模式:{mode}") + print(f" 监听地址:{values['WEB_SERVER_HOST']}:{values['WEB_SERVER_PORT']}") + print(f" 数据目录:{custom_data or default_data}") + print(f" 管理员: {admin_user}") + print(f" 模型: {model_name}({model_id})@ {model_url}") + print(f" 密钥: 已自动生成 WEB_SECRET_KEY / API_TOKEN_SECRET") + print() + print(" 下一步启动服务:") + print(" python -m server.app") + print() + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description="AI Agent 首次启动初始化向导") + parser.add_argument( + "--force", + action="store_true", + help="跳过『已存在 .env』的确认提示(仍会先备份再覆盖)", + ) + args = parser.parse_args() + try: + return run_wizard(force=args.force) + except KeyboardInterrupt: + print("\n已中断,未写入配置。") + return 130 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/server/app_legacy.py b/server/app_legacy.py index 2aae24a..cf62f20 100644 --- a/server/app_legacy.py +++ b/server/app_legacy.py @@ -228,6 +228,8 @@ from config import ( PROJECT_MAX_STORAGE_MB, PROJECT_MAX_STORAGE_BYTES, UPLOAD_SCAN_LOG_SUBDIR, + WEB_SERVER_PORT, + WEB_SERVER_HOST, ) from modules.user_manager import UserManager, UserWorkspace from modules.gui_file_manager import GuiFileManager @@ -325,7 +327,7 @@ ADMIN_CUSTOM_TOOLS_DIR = (Path(app.static_folder) / 'custom_tools').resolve() RECENT_UPLOAD_EVENT_LIMIT = 150 RECENT_UPLOAD_FEED_LIMIT = 60 -DEFAULT_PORT = 8091 +DEFAULT_PORT = WEB_SERVER_PORT THINKING_FAILURE_KEYWORDS = ["⚠️", "🛑", "失败", "错误", "异常", "终止", "error", "failed", "未完成", "超时", "强制"] CSRF_HEADER_NAME = state.CSRF_HEADER_NAME CSRF_SESSION_KEY = state.CSRF_SESSION_KEY @@ -1343,7 +1345,7 @@ def run_server(path: str, thinking_mode: bool = False, port: int = DEFAULT_PORT, start_background_jobs() socketio.run( app, - host='0.0.0.0', + host=WEB_SERVER_HOST, port=port, debug=debug, use_reloader=debug, diff --git a/server/chat_flow_helpers.py b/server/chat_flow_helpers.py index d04128e..e6f458e 100644 --- a/server/chat_flow_helpers.py +++ b/server/chat_flow_helpers.py @@ -8,7 +8,6 @@ from datetime import datetime from pathlib import Path from typing import Any, Dict, List, Optional -from config import TITLE_API_BASE_URL, TITLE_API_KEY, TITLE_MODEL_ID from core.web_terminal import WebTerminal from config import LOGS_DIR from utils.api_client import DeepSeekClient diff --git a/server/state.py b/server/state.py index f717d9d..e290672 100644 --- a/server/state.py +++ b/server/state.py @@ -6,7 +6,7 @@ from collections import defaultdict, deque from pathlib import Path from typing import Dict, Any, Optional -from config import LOGS_DIR, PROJECT_MAX_STORAGE_BYTES, PROJECT_MAX_STORAGE_MB +from config import LOGS_DIR, PROJECT_MAX_STORAGE_BYTES, PROJECT_MAX_STORAGE_MB, WEB_SERVER_PORT from core.web_terminal import WebTerminal from modules.custom_tool_registry import CustomToolRegistry from modules.mcp_server_registry import MCPServerRegistry @@ -60,7 +60,7 @@ PROJECT_STORAGE_CACHE: Dict[str, Dict[str, Any]] = {} PROJECT_STORAGE_CACHE_TTL_SECONDS = float(os.environ.get("PROJECT_STORAGE_CACHE_TTL", "30")) # 其他配置 -DEFAULT_PORT = 8091 +DEFAULT_PORT = WEB_SERVER_PORT THINKING_FAILURE_KEYWORDS = ["⚠️", "🛑", "失败", "错误", "异常", "终止", "error", "failed", "未完成", "超时", "强制"] CSRF_HEADER_NAME = "X-CSRF-Token" CSRF_SESSION_KEY = "_csrf_token" diff --git a/test/test_config_paths_resolution.py b/test/test_config_paths_resolution.py index e0984f6..c48403d 100644 --- a/test/test_config_paths_resolution.py +++ b/test/test_config_paths_resolution.py @@ -26,6 +26,7 @@ print(json.dumps({ "LOGS_DIR": config.LOGS_DIR, "USER_SPACE_DIR": config.USER_SPACE_DIR, "API_USER_SPACE_DIR": config.API_USER_SPACE_DIR, + "DEPLOY_CONFIG_DIR": config.DEPLOY_CONFIG_DIR, "SUB_AGENT_STATE_FILE": config.SUB_AGENT_STATE_FILE, "SUB_AGENT_TASKS_BASE_DIR": config.SUB_AGENT_TASKS_BASE_DIR, }, ensure_ascii=False)) @@ -37,6 +38,7 @@ print(json.dumps({ "TERMINAL_SANDBOX_MODE", "AGENTS_HOST_HOME", "AGENTS_WEB_HOME", "DATA_DIR", "LOGS_DIR", "USER_SPACE_DIR", "API_USER_SPACE_DIR", "DEFAULT_PROJECT_PATH", "HOST_WORKSPACES_FILE", "PROMPTS_DIR", + "DEPLOY_CONFIG_DIR", "SUB_AGENT_STATE_FILE", "SUB_AGENT_TASKS_BASE_DIR", ): env.pop(key, None) @@ -80,11 +82,11 @@ print(json.dumps({ self.assertEqual(data["LOGS_DIR"], str(host_home / "logs")) def test_source_tree_paths_stay_anchored_to_repo_root(self): - """配置类路径(提示词、host_workspaces.json、默认工作区)仍锚定源码树。""" + """配置类路径(提示词、默认工作区)仍锚定源码树。""" with tempfile.TemporaryDirectory() as td: data = self._load_paths(cwd=Path(td), extra_env={"TERMINAL_SANDBOX_MODE": "docker"}) - for key in ("DEFAULT_PROJECT_PATH", "HOST_WORKSPACES_FILE", "PROMPTS_DIR"): + for key in ("DEFAULT_PROJECT_PATH", "PROMPTS_DIR"): value = Path(data[key]) self.assertTrue(value.is_absolute(), f"{key} not absolute: {data[key]}") self.assertTrue( @@ -92,6 +94,30 @@ print(json.dumps({ f"{key} should anchor to repo root: {data[key]}", ) + def test_deploy_config_anchors_to_runtime_root(self): + """部署级配置目录与 host_workspaces.json 锚定到运行态根 ~/.agents//config。""" + with tempfile.TemporaryDirectory() as td: + data = self._load_paths(cwd=Path(td), extra_env={"TERMINAL_SANDBOX_MODE": "web"}) + + web_config = Path("~/.agents/web/config").expanduser().resolve() + self.assertEqual(data["DEPLOY_CONFIG_DIR"], str(web_config)) + self.assertEqual( + data["HOST_WORKSPACES_FILE"], str(web_config / "host_workspaces.json") + ) + + def test_deploy_config_follows_mode_home(self): + """模式根变量覆盖时,部署配置目录跟随该根目录。""" + with tempfile.TemporaryDirectory() as td: + custom = Path(td) / "web_root" + data = self._load_paths( + cwd=Path(td), + extra_env={ + "TERMINAL_SANDBOX_MODE": "web", + "AGENTS_WEB_HOME": str(custom), + }, + ) + self.assertEqual(data["DEPLOY_CONFIG_DIR"], str((custom / "config").resolve())) + def test_mode_home_env_overrides_root(self): """模式根变量(AGENTS_HOST_HOME / AGENTS_WEB_HOME)整体搬迁运行态目录。""" with tempfile.TemporaryDirectory() as td: diff --git a/utils/api_client.py b/utils/api_client.py index 0725bbf..840c82a 100644 --- a/utils/api_client.py +++ b/utils/api_client.py @@ -14,14 +14,8 @@ from pathlib import Path from typing import Tuple try: from config import ( - API_BASE_URL, - API_KEY, - MODEL_ID, OUTPUT_FORMATS, DEFAULT_RESPONSE_MAX_TOKENS, - THINKING_API_BASE_URL, - THINKING_API_KEY, - THINKING_MODEL_ID, LOGS_DIR, ) except ImportError: @@ -31,14 +25,8 @@ except ImportError: if str(project_root) not in sys.path: sys.path.insert(0, str(project_root)) from config import ( - API_BASE_URL, - API_KEY, - MODEL_ID, OUTPUT_FORMATS, DEFAULT_RESPONSE_MAX_TOKENS, - THINKING_API_BASE_URL, - THINKING_API_KEY, - THINKING_MODEL_ID, LOGS_DIR, ) @@ -51,15 +39,17 @@ def _api_dump_enabled() -> bool: class DeepSeekClient: # legacy name; generic OpenAI-compatible client def __init__(self, thinking_mode: bool = True, web_mode: bool = False): + # 模型 API 配置(端点 / 密钥 / 模型 ID)由 apply_profile 从 custom_models 注入; + # 这里仅初始化为空,未应用任何模型档案前不可直接发请求。 self.fast_api_config = { - "base_url": API_BASE_URL, - "api_key": API_KEY, - "model_id": MODEL_ID + "base_url": "", + "api_key": "", + "model_id": "" } self.thinking_api_config = { - "base_url": THINKING_API_BASE_URL or API_BASE_URL, - "api_key": THINKING_API_KEY or API_KEY, - "model_id": THINKING_MODEL_ID or MODEL_ID + "base_url": "", + "api_key": "", + "model_id": "" } self.fast_max_tokens = None self.thinking_max_tokens = None