- session.* 传输暴露:新增 gateway_api 蓝图(list/create/history),RuntimeService 补 create_session - host Bearer 通道:gateway_auth 双通道认证(host 模式+回环限定,token 存 DATA_DIR/host_api_token),tasks/approval 路由接入 - api_v1 会话路由转调公共入口消双轨,会话索引/列表补齐 run_mode/model_key/custom_prompt_name/personalization_name - flask 包依赖拆解:_flask_bridge 延迟桥接 + context 子包 PEP 562 懒加载,import server.tasks/runtime 不再拉起 flask - 审批链路:mark_expired 终态回写(超时/软停止/取消三路径)+ 终态 TTL 惰性清理(3600s) - 测试 patch 点随迁;全量 75 测试失败恰为 4 项存量,独立启动验收 4/4 全绿 Co-authored-by: Astrion powered by Kimi-K3 <astrion-agent@users.noreply.github.com>
143 lines
4.9 KiB
Python
143 lines
4.9 KiB
Python
"""Gateway 本机客户端通道认证(host Bearer token,2026-09-08 新增)。
|
||
|
||
定位:协议 docs/runtime_protocol.md §6「认证与授权按通道裁剪」的 host 通道
|
||
实现——让 CLI/GUI 等本机客户端无需 Web 会话流程(Cookie/CSRF/host-login)
|
||
即可接入 Gateway 公共协议端点。
|
||
|
||
安全模型与 /host-login 完全对齐(host 模式定位为本机单人使用):
|
||
- 仅 TERMINAL_SANDBOX_MODE=host(且非 LINUX_SAFETY)生效;
|
||
- 仅允许回环地址(127.0.0.1/::1/localhost)直连调用;
|
||
- token 为本机生成的随机串,存于运行态数据目录
|
||
(<DATA_DIR>/host_api_token,权限 0600),本机客户端读取后经
|
||
`Authorization: Bearer <token>` 携带。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import functools
|
||
import hmac
|
||
import os
|
||
import secrets
|
||
from pathlib import Path
|
||
from typing import Optional
|
||
|
||
from flask import current_app, jsonify, request, session
|
||
|
||
from config import DATA_DIR, TERMINAL_SANDBOX_MODE
|
||
from config.terminal import LINUX_SAFETY
|
||
from modules.host_workspace_manager import resolve_host_workspace
|
||
from modules.i18n import tr
|
||
from server.auth_helpers import is_logged_in
|
||
|
||
_LOOPBACK_ADDRS = {"127.0.0.1", "::1", "localhost"}
|
||
_TOKEN_FILENAME = "host_api_token"
|
||
|
||
|
||
def _host_channel_enabled() -> bool:
|
||
return (TERMINAL_SANDBOX_MODE or "").lower() == "host" and not LINUX_SAFETY
|
||
|
||
|
||
def host_api_token_path() -> Path:
|
||
return Path(DATA_DIR).expanduser() / _TOKEN_FILENAME
|
||
|
||
|
||
def get_or_create_host_api_token() -> Optional[str]:
|
||
"""读取(不存在则生成)host 通道 token;非 host 模式返回 None。
|
||
|
||
生成使用临时文件 + os.replace 原子替换 + 0600 权限。本地单进程
|
||
场景下并发首次生成概率极低;万一竞争产生两个 token,后写覆盖先写,
|
||
持旧 token 的一方 401 后重读文件即可自愈。
|
||
"""
|
||
if not _host_channel_enabled():
|
||
return None
|
||
path = host_api_token_path()
|
||
try:
|
||
existing = path.read_text(encoding="utf-8").strip()
|
||
if existing:
|
||
return existing
|
||
except OSError:
|
||
pass
|
||
token = secrets.token_urlsafe(32)
|
||
try:
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
tmp = path.with_name(path.name + ".tmp")
|
||
tmp.write_text(token + "\n", encoding="utf-8")
|
||
os.chmod(tmp, 0o600)
|
||
os.replace(tmp, path)
|
||
except OSError:
|
||
return None
|
||
return token
|
||
|
||
|
||
def _extract_bearer_token() -> str:
|
||
auth_header = request.headers.get("Authorization") or ""
|
||
if not auth_header.lower().startswith("bearer "):
|
||
return ""
|
||
return auth_header.split(" ", 1)[1].strip()
|
||
|
||
|
||
def _verify_host_bearer(token: str) -> bool:
|
||
"""校验 host Bearer token:host 模式 + 回环地址 + token 匹配。"""
|
||
if not token or not _host_channel_enabled():
|
||
return False
|
||
remote_addr = (request.remote_addr or "").strip()
|
||
if remote_addr not in _LOOPBACK_ADDRS:
|
||
return False
|
||
expected = get_or_create_host_api_token()
|
||
if not expected:
|
||
return False
|
||
return hmac.compare_digest(token, expected)
|
||
|
||
|
||
def _inject_host_identity() -> None:
|
||
"""注入与 /host-login 等价的 host 会话身份(复用现有上下文装配逻辑)。
|
||
|
||
session 字段与 server/auth.py 的 host_login 保持一致(含 login_nonce),
|
||
保证路由内及下游 is_logged_in()/principal 构造等既有逻辑无差异。
|
||
"""
|
||
from server.auth import _issue_login_nonce
|
||
|
||
_, host_workspace = resolve_host_workspace()
|
||
session["logged_in"] = True
|
||
session["username"] = "host"
|
||
session["role"] = "admin"
|
||
session["host_mode"] = True
|
||
if host_workspace:
|
||
workspace_id = host_workspace.get("workspace_id") or "default"
|
||
session["host_workspace_id"] = workspace_id
|
||
session["workspace_id"] = workspace_id
|
||
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
|
||
_issue_login_nonce("host")
|
||
|
||
|
||
def api_login_or_host_token_required(view_func):
|
||
"""双通道认证装饰器:host Bearer token 或 Web session 登录。
|
||
|
||
- 携带 Bearer token 且匹配 host 通道 → 注入 host 身份并放行
|
||
- 否则回退 Web session 检查(is_logged_in)
|
||
Bearer 请求天然跳过 CSRF(server/security.py 已放行 Authorization 头)。
|
||
"""
|
||
|
||
@functools.wraps(view_func)
|
||
def wrapped(*args, **kwargs):
|
||
token = _extract_bearer_token()
|
||
if token and _verify_host_bearer(token):
|
||
_inject_host_identity()
|
||
return view_func(*args, **kwargs)
|
||
if not is_logged_in():
|
||
return jsonify({"success": False, "error": tr("auth.session_expired")}), 401
|
||
return view_func(*args, **kwargs)
|
||
|
||
return wrapped
|
||
|
||
|
||
__all__ = [
|
||
"api_login_or_host_token_required",
|
||
"get_or_create_host_api_token",
|
||
"host_api_token_path",
|
||
]
|