diff --git a/AGENTS.md b/AGENTS.md index 4c367e8c..cb215f28 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -110,6 +110,10 @@ - 可选参数:`python -m server.app --path ./project --port 8091 --debug --thinking-mode` - 兼容启动方式:`python web_server.py` - 统一入口:`python main.py`(当前实现会默认进入 Web 启动流程) +- **Headless 启动(CLI 自启动专用)**:`python -m server.headless_app --path --port 8091 --thinking-mode` + - 只挂运行时蓝图(gateway / tasks / status / approval / usage 五件套),不含 web 站点路由面(无 /login、无静态页、无会话管理);根路径 `/` 返回简单 HTML 告知页 + - 与 full 形态同进程模型(RuntimeService 单例/同一数据目录/同一端口 8091),只是启动时挂的路由面不同;**绝不能与 full 形态双进程并存**(MultiAgentState/门闸/审批 Map 是进程内单例) + - CLI(`cli/src/gateway.ts`)探测无服务时 spawn 的就是这个入口;python 解释器经依赖探测(import yaml/flask)从 .venv → homebrew 3.12/3.11 → python3 中选第一个可用的 ### Frontend(根目录) - 安装依赖:`npm install` @@ -613,7 +617,9 @@ Web 端实时通道曾长期双轨(REST 任务轮询为主 + Socket.IO 辅助 **硬性约束**: 1. 新增任何「实时通知」一律写任务事件流(`_append_event`)或扩 `/api/status` 载荷,**禁止**重新引入 socket 推送。 2. `server/extensions.py` 只剩 `run_background`(daemon 线程);不要再往里加推送类函数。 -3. `WebTerminal.broadcast()` / `terminal_manager.broadcast` 调用点保留但回调恒为 None(判空安全),新代码不要依赖它们产生用户可见效果。 +3. `WebTerminal.broadcast()` / `terminal_manager.broadcast` 调用点保留但回调恒为 None(判空安全),新代码不要依赖它们产生用户可见效果。`make_terminal_callback(username)`(`server/context/resources.py`)同理:Socket.IO 移除后是恒返 None 的兼容空操作,保留仅为维持历史 import 路径(`workflow_runtime_api.py`、`resources.py` 自身构造 WebTerminal)不炸。 + +**Headless 形态(2026-09-11 新增)**:`server/headless_app.py` 提供无 web 站点的启动入口(CLI 自启动专用,详见 §2);审批三类端点(tool/plan/question)已从 chat_bp 拆为独立蓝图 `server/chat/approval.py::approval_bp`(6 条 URL 一字不变),full 与 headless 双形态共用。 ## 13) 工具动态加载(tool_loading,2026-09 新增) diff --git a/cli/src/gateway.ts b/cli/src/gateway.ts index cb25a9b9..2b007078 100644 --- a/cli/src/gateway.ts +++ b/cli/src/gateway.ts @@ -8,11 +8,36 @@ import { readFile, mkdir } from 'node:fs/promises'; import { existsSync, openSync, mkdirSync } from 'node:fs'; import { basename, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { spawn } from 'node:child_process'; +import { spawn, execFileSync } from 'node:child_process'; const DEFAULT_PORT = 8091; const TOKEN_PATH = `${homedir()}/.astrion/astrion/host/data/host_api_token`; +/** 探测能跑后端的 python 解释器:依次试候选,能 import 服务端关键依赖(yaml/flask)才用; + * 全部失败时退回第一个存在的候选(让启动日志留下真实报错,而不是 CLI 侧静默)。 */ +function pickPython(repoRoot: string): string { + const candidates = [ + resolve(repoRoot, '.venv/bin/python'), + '/opt/homebrew/bin/python3.12', + '/opt/homebrew/bin/python3.11', + '/usr/local/bin/python3.12', + '/usr/local/bin/python3.11', + 'python3', + ]; + let firstExisting = ''; + for (const bin of candidates) { + if (bin.startsWith('/') && !existsSync(bin)) continue; + if (!firstExisting) firstExisting = bin; + try { + execFileSync(bin, ['-c', 'import yaml, flask'], { stdio: 'ignore' }); + return bin; + } catch { + // 缺依赖,试下一个 + } + } + return firstExisting || 'python3'; +} + export interface WorkspaceItem { workspace_id: string; label: string; @@ -104,22 +129,23 @@ export class GatewayClient { } } - /** CLI 自启动后端(detached + unref,对齐旧 Ink 版参数;显式 --port 避免默认端口歧义) - * python 优先用项目 .venv(系统 python3 缺依赖会启动即崩);输出落日志文件便于诊断启动失败。 */ + /** CLI 自启动后端(detached + unref;走 headless 入口,只挂 Gateway/任务/审批等运行时蓝图, + * 不拉起 web 站点路由面;显式 --port 避免默认端口歧义) + * python 解释器做依赖探测(import yaml/flask 通过才用)——本机 .venv 缺 yaml 会启动即崩; + * 输出落日志文件便于诊断启动失败。 */ private spawnServer(cwd: string): void { // cli/src → 项目根;fileURLToPath 正确解码中文路径(new URL().pathname 会留下 // 百分号编码,导致 existsSync 永远 false、spawn cwd 无效——中文路径必用此函数) const repoRoot = resolve(fileURLToPath(new URL('../..', import.meta.url))); const port = Number(process.env.ASTRION_API_PORT || process.env.WEB_SERVER_PORT || DEFAULT_PORT); - const venvPython = resolve(repoRoot, '.venv/bin/python'); - const pythonBin = existsSync(venvPython) ? venvPython : 'python3'; + const pythonBin = pickPython(repoRoot); const logDir = `${homedir()}/.astrion/astrion/host/logs`; try { mkdirSync(logDir, { recursive: true }); const logFd = openSync(resolve(logDir, 'cli_spawned_server.log'), 'a'); const child = spawn( pythonBin, - ['-m', 'server.app', '--path', cwd, '--port', String(port), '--thinking-mode'], + ['-m', 'server.headless_app', '--path', cwd, '--port', String(port), '--thinking-mode'], { cwd: repoRoot, env: { diff --git a/server/app_legacy.py b/server/app_legacy.py index d7798120..71e62e39 100644 --- a/server/app_legacy.py +++ b/server/app_legacy.py @@ -31,6 +31,7 @@ from server.files import files_bp from server.admin import admin_bp from server.conversation import conversation_bp from server.chat import chat_bp +from server.chat.approval import approval_bp from server.usage import usage_bp from server.status import status_bp from server.tasks.web import get_tasks_blueprint @@ -294,6 +295,7 @@ app.register_blueprint(files_bp) app.register_blueprint(admin_bp) app.register_blueprint(conversation_bp) app.register_blueprint(chat_bp) +app.register_blueprint(approval_bp) app.register_blueprint(usage_bp) app.register_blueprint(status_bp) app.register_blueprint(get_tasks_blueprint()) diff --git a/server/chat/approval.py b/server/chat/approval.py index 0ef6e017..1b5df060 100644 --- a/server/chat/approval.py +++ b/server/chat/approval.py @@ -1,5 +1,4 @@ from __future__ import annotations -from server.chat import chat_bp import json, time from datetime import datetime from typing import Dict, Any, Optional @@ -9,6 +8,11 @@ import zipfile import os from flask import Blueprint, jsonify, request, session, send_file + +# 审批路由独立蓝图:审批(工具/计划/提问)属 Runtime 人机交互契约, +# 不属 web chat 域;headless 形态只挂本蓝图而不挂 chat_bp。 +# 注意:URL 路径与原 chat_bp 时期完全一致,前端与客户端零改动。 +approval_bp = Blueprint("approval", __name__) from werkzeug.utils import secure_filename from werkzeug.exceptions import RequestEntityTooLarge import secrets @@ -45,7 +49,7 @@ from server.monitor import get_cached_monitor_snapshot from modules.i18n import tr UPLOAD_FOLDER_NAME = ".astrion/user_upload" -@chat_bp.route('/api/user-questions/pending', methods=['GET']) +@approval_bp.route('/api/user-questions/pending', methods=['GET']) @api_login_or_host_token_required @with_terminal def list_pending_user_questions(terminal: WebTerminal, workspace: UserWorkspace, username: str): @@ -60,7 +64,7 @@ def list_pending_user_questions(terminal: WebTerminal, workspace: UserWorkspace, "conversation_id": requested_conv_id, }) -@chat_bp.route('/api/user-questions//answer', methods=['POST']) +@approval_bp.route('/api/user-questions//answer', methods=['POST']) @api_login_or_host_token_required @with_terminal @rate_limited("user_question_answer", 120, 60, scope="user") @@ -90,7 +94,7 @@ def answer_user_question(terminal: WebTerminal, workspace: UserWorkspace, userna "item": item, }) -@chat_bp.route('/api/plan-approvals/pending', methods=['GET']) +@approval_bp.route('/api/plan-approvals/pending', methods=['GET']) @api_login_or_host_token_required @with_terminal def list_pending_plan_approvals(terminal: WebTerminal, workspace: UserWorkspace, username: str): @@ -105,7 +109,7 @@ def list_pending_plan_approvals(terminal: WebTerminal, workspace: UserWorkspace, "conversation_id": requested_conv_id, }) -@chat_bp.route('/api/plan-approvals//answer', methods=['POST']) +@approval_bp.route('/api/plan-approvals//answer', methods=['POST']) @api_login_or_host_token_required @with_terminal @rate_limited("plan_approval_answer", 120, 60, scope="user") @@ -134,7 +138,7 @@ def answer_plan_approval(terminal: WebTerminal, workspace: UserWorkspace, userna "item": item, }) -@chat_bp.route('/api/tool-approvals/pending', methods=['GET']) +@approval_bp.route('/api/tool-approvals/pending', methods=['GET']) @api_login_or_host_token_required @with_terminal def list_pending_tool_approvals(terminal: WebTerminal, workspace: UserWorkspace, username: str): @@ -149,7 +153,7 @@ def list_pending_tool_approvals(terminal: WebTerminal, workspace: UserWorkspace, "conversation_id": requested_conv_id, }) -@chat_bp.route('/api/tool-approvals//decision', methods=['POST']) +@approval_bp.route('/api/tool-approvals//decision', methods=['POST']) @api_login_or_host_token_required @with_terminal @rate_limited("tool_approval_decision", 60, 60, scope="user") diff --git a/server/context/__init__.py b/server/context/__init__.py index 04d4e64d..14115672 100644 --- a/server/context/__init__.py +++ b/server/context/__init__.py @@ -25,6 +25,7 @@ _SYMBOL_MODULE = { "apply_conversation_overrides": "server.context.conversation", # resources "get_user_resources": "server.context.resources", + "make_terminal_callback": "server.context.resources", "_make_terminal_key": "server.context.resources", "_touch_terminal_activity": "server.context.resources", "_set_terminal_workspace_label": "server.context.resources", @@ -42,6 +43,7 @@ __all__ = [ "NoWorkspaceError", "RuntimeIdentity", "get_user_resources", + "make_terminal_callback", "with_terminal", "get_terminal_for_sid", "get_gui_manager", diff --git a/server/context/resources.py b/server/context/resources.py index 151f5ad8..2cd7c774 100644 --- a/server/context/resources.py +++ b/server/context/resources.py @@ -59,6 +59,14 @@ from server.context.personalization import _apply_workspace_personalization_pref from server.context.usage import get_or_create_usage_tracker +def make_terminal_callback(username: str): + """Socket.IO 移除后的兼容空操作:实时推送已全量走任务事件流/REST 轮询, + 回调恒为 None(WebTerminal 与 emit_workflow_progress 均判空安全)。 + 保留本符号仅为维持历史 import 路径不炸,新代码不应依赖它产生用户可见效果。 + """ + return None + + def _make_terminal_key( username: str, diff --git a/server/headless_app.py b/server/headless_app.py new file mode 100644 index 00000000..5cf0d5d0 --- /dev/null +++ b/server/headless_app.py @@ -0,0 +1,199 @@ +"""Astrion headless 后端入口(Gateway + Runtime,无 Web 站点职责)。 + +与 `server.app`(full 形态)是**同一后端进程的两种启动方式**:同一 RuntimeService +单例、同一数据目录、同一端口(默认 8091)。区别仅在启动时挂载的路由面—— +headless 只挂 CLI / 桌面 / API 客户端依赖的 API 蓝图: + + gateway_bp : /api/runtime/sessions(CLI 主通道:创建会话 / 拉历史) + tasks : run.* 任务域(发消息 / 事件轮询 / 取消) + status_bp : /api/health 探活、/api/status、host_workspace 工作区管理 + approval_bp : 工具 / 计划 / 提问三组审批(Runtime 人机交互契约) + usage_bp : /api/usage 配额查询 + +被砍的「web 站点职责」(静态页面、web 登录、旧版通道等)在本形态下访问返回 404; +GET / 返回形态告知页(避免浏览器打开时误判为服务故障)。 + +注意:本入口通过 import server.app_legacy 复用 initialize_system / start_background_jobs, +因此 import 链与 full 形态一致(app_legacy 模块级创建的 app 对象不会被 serve)。 +headless 精简的是**路由面**(攻击面收敛 + CLI 依赖契约显式化),不是 import 链。 + +启动:python -m server.headless_app [--path PATH] [--port 8091] [--thinking-mode] [--debug] +""" +from __future__ import annotations + +import argparse +import os +import secrets +from datetime import timedelta +from pathlib import Path + +from flask import Flask + +from config import ( + DEFAULT_PROJECT_PATH, + MAX_UPLOAD_SIZE, + OUTPUT_FORMATS, + WEB_SERVER_HOST, + WEB_SERVER_PORT, +) + +DEFAULT_PORT = WEB_SERVER_PORT + +# 复用 full 形态的初始化与后台任务(模块级 app 对象仅被创建,不会被 serve) +from server.app_legacy import initialize_system, start_background_jobs # noqa: F401 +from server.gateway_api import gateway_bp +from server.tasks.web import get_tasks_blueprint +from server.status import status_bp +from server.chat.approval import approval_bp +from server.usage import usage_bp +from server.security import attach_security_hooks + +HEADLESS_BLUEPRINTS = ( + ("gateway", gateway_bp), + ("tasks", get_tasks_blueprint()), + ("status", status_bp), + ("approval", approval_bp), + ("usage", usage_bp), +) + +_HEADLESS_LANDING_HTML = """ + + + + +Astrion · API 后端实例 + + + +
+
+ Astrion + HEADLESS +
+

这是 Astrion 的 API 后端实例,运行正常。

+

+ 本实例以 headless 形态启动:只提供 Gateway / Runtime API + (供 CLI、桌面端、Android 等客户端使用),不包含 Web 界面。 +

+

如需 Web 界面,请改用 full 形态启动:

+ python -m server.app +

Astrion headless backend · same runtime, API-only surface

+
+ + +""" + + +def create_headless_app() -> Flask: + """创建 headless 形态的 Flask app(最小 API 路由面)。""" + app = Flask(__name__) + app.config['MAX_CONTENT_LENGTH'] = MAX_UPLOAD_SIZE + # Flask session 仍被 Bearer 通道的身份注入使用(请求内 session 对象), + # 必须配置 SECRET_KEY;解析规则与 full 形态一致。 + _secret_key = os.environ.get("WEB_SECRET_KEY") or os.environ.get("SECRET_KEY") + if not _secret_key: + _secret_key = secrets.token_hex(32) + print(f"{OUTPUT_FORMATS['warning']} WEB_SECRET_KEY 未设置,已生成临时密钥(重启后所有会话将失效)。") + app.config['SECRET_KEY'] = _secret_key + app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(hours=12) + app.config['SESSION_COOKIE_SAMESITE'] = os.environ.get("WEB_COOKIE_SAMESITE", "Strict") + app.config['SESSION_COOKIE_HTTPONLY'] = True + _cookie_secure_env = (os.environ.get("WEB_COOKIE_SECURE") or "").strip().lower() + app.config['SESSION_COOKIE_SECURE'] = _cookie_secure_env in {"1", "true", "yes"} + + attach_security_hooks(app) + + for _name, bp in HEADLESS_BLUEPRINTS: + app.register_blueprint(bp) + + @app.route('/') + def headless_landing(): + return _HEADLESS_LANDING_HTML + + return app + + +def run_headless(path: str, thinking_mode: bool = False, port: int = DEFAULT_PORT, debug: bool = False): + """初始化并以 headless 形态运行(与 full 形态共享初始化与后台任务)。""" + app = create_headless_app() + # 与 full 形态一致:同机多实例时 Cookie 按端口隔离,避免互相覆盖 + app.config['SESSION_COOKIE_NAME'] = os.environ.get( + "WEB_SESSION_COOKIE_NAME", f"agents_session_{port}" + ) + initialize_system(path, thinking_mode) + start_background_jobs() + app.run( + host=WEB_SERVER_HOST, + port=port, + debug=debug, + use_reloader=debug, + threaded=True, + ) + + +def parse_arguments(): + parser = argparse.ArgumentParser(description="Astrion headless backend (Gateway + Runtime, API-only)") + parser.add_argument( + "--path", + default=str(Path(DEFAULT_PROJECT_PATH).resolve()), + help="默认工作区路径(仅作兜底,工作区可在客户端中管理)", + ) + parser.add_argument("--port", type=int, default=DEFAULT_PORT, help="监听端口") + parser.add_argument("--thinking-mode", action="store_true", help="启用思考模式") + parser.add_argument("--debug", action="store_true", help="调试模式") + return parser.parse_args() + + +def main(): + args = parse_arguments() + print(f"{OUTPUT_FORMATS['info']} 以 headless 形态启动(仅 API 路由面,无 Web 界面)...") + run_headless(path=args.path, thinking_mode=args.thinking_mode, port=args.port, debug=args.debug) + + +if __name__ == "__main__": + main()