Files
AgentLab/backend/app/core/config.py
T

367 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import ipaddress
import re
from functools import lru_cache
from pathlib import Path
from typing import Annotated, Any
from pydantic import field_validator
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
BACKEND_DIR = Path(__file__).resolve().parents[2]
PROJECT_DIR = BACKEND_DIR.parent
#: 嵌入白名单里一项的主机部分:域名(可以 *. 开头)或方括号里的 IPv6,可以带端口;不许路径、查询、用户名
_ORIGIN_HOST = re.compile(r"(?:\*\.)?(?:[A-Za-z0-9-]+\.)*[A-Za-z0-9-]+(?::\d{1,5})?|\[[0-9A-Fa-f:.]+\](?::\d{1,5})?")
# session_id 拼进路径前必须过这一关。允许的字符刻意收得很窄:字母数字加
# 连字符下划线——点号也不放行,免得出现 ".." 这种能往上走的名字。
_SESSION_MAX = 64
def sanitize_session(session_id: str | None) -> str:
"""把任意字符串收敛成一个能安全当目录名的 token。
以前这段逻辑在四个沙箱后端里各抄了一遍,而文件工具那条路径漏抄了,
结果 sandbox_session 传 ".." 就能读到工作区外的文件(连加密主密钥都
读得到)。所以统一收到这里,只此一份。
"""
safe = "".join(c for c in (session_id or "") if c.isalnum() or c in "-_")
return safe[:_SESSION_MAX] or "default"
#: 数据目录里的标记文件(Settings.ensure_dirs 建,deploy/deploy.sh 认)
DATA_DIR_MARKER = ".agentlab-data"
class Settings(BaseSettings):
"""全局配置。所有项都可以用 AGENTLAB_ 前缀的环境变量覆盖。"""
model_config = SettingsConfigDict(
env_prefix="AGENTLAB_",
env_file=(PROJECT_DIR / ".env", BACKEND_DIR / ".env"),
extra="ignore",
)
# --- 服务 ---
host: str = "127.0.0.1"
port: int = 8000
cors_origins: list[str] = ["http://localhost:5273", "http://127.0.0.1:5273"]
# --- 存储 ---
data_dir: Path = PROJECT_DIR / "data"
# 应用自己的库(工作流、运行、事件、审批……)。不设:数据目录里的 SQLite(agentlab.db,断点在 checkpoints.db),
# 单机单进程。设成 postgresql+asyncpg://用户:密码@主机:端口/库名(postgresql:// 也认)时整个应用库用 PostgreSQL,
# LangGraph 的断点也放进同一个库;checkpoint_url 另给就放那里。数据目录照旧要有:工件、上传表格、沙箱等仍是文件
database_url: str = ""
checkpoint_url: str = ""
# 直连地址(可选,写法同 database_url):普通查询走 database_url(可以是 PgBouncer),依赖会话状态的连接走这里——进程间通知的
# LISTEN 那一条、跨进程锁(会话级咨询锁)、断点库(预备语句、建表时的会话锁)。不设就全用 database_url
database_direct_url: str = ""
# database_url 是 PgBouncer 的事务模式(AGENTLAB_DB_PGBOUNCER=1):应用库不用客户端连接池(交给 PgBouncer)、关掉 asyncpg 和
# SQLAlchemy 的预备语句缓存、预备语句用不会撞名的名字。这时必须另给 database_direct_url(事务模式下 LISTEN、会话级咨询锁不成立),
# 启动时检查
db_pgbouncer: bool = False
# PostgreSQL 的连接池(SQLite 不用这几项)。pool_size=0 表示不用连接池、每次新建连接(前面有 PgBouncer 时用,
# 测试里也这么用:每个测试一个事件循环,asyncpg 的连接不能跨循环)
db_pool_size: int = 10
db_max_overflow: int = 20
db_pool_timeout: int = 30
db_pool_recycle: int = 1800
# --- 多进程(app/cluster,docs/design/orchestration-round §5 W2-b)---
# 几个工作进程(scripts/prod.sh、Docker 的入口按它给 uvicorn --workers)。只有应用库是 PostgreSQL 时才能大于 1:
# SQLite 只支持单进程,启动时拒绝(app/cluster/guard.py)
workers: int = 1
# 每个工作进程在库里登记一行:隔 worker_heartbeat_s 秒续一次租约,租约长 worker_lease_s 秒。进程没了(强杀、断电)
# 租约到期后,别的进程接手它认领的运行、问数据、后台任务(各按各的规矩)。租约要比心跳长几倍:库一时慢了不算进程没了
worker_lease_s: float = 30.0
worker_heartbeat_s: float = 5.0
# --- 前端构建产物(生产部署用)---
# 设了就由服务端一并托管页面(见 app/web.py),页面和接口同源、只起一个进程。
# 不设(开发模式)什么都不变:页面由 Vite 开发服务器提供。相对路径按仓库根目录算
web_dist: Path | None = None
# --- 密钥加密 ---
# 用于加密落库的 provider api key。留空则自动生成并存到 data_dir/.secret_key。
secret_key: str = ""
# --- 身份(app/auth/)---
# 登录方式 AGENTLAB_AUTH_MODE(off / local / oidc)由 app/auth 每次直接读环境变量:测试和部署脚本临时改它
# 要立刻生效,不能被这个单例在导入时定死。这里只放会话和对外地址
auth_session_idle_hours: int = 12
auth_session_max_days: int = 7
# 会话 cookie 带不带 Secure。不设时按请求本身是不是 https 定:本机 http 开发照常能登录
auth_cookie_secure: bool | None = None
# 对外的访问地址(反向代理后面时填,如 https://agentlab.example.com)。全站只认一个(core/public_url.py):
# 设了它,设置里的「对外访问地址」就不生效、不能改;通知和 Webhook 的链接、OIDC 的回调、来源核对都按它拼。
# 不设时用设置里的那一项,再没有就按请求的 Host 拼(通知里不附链接)
public_url: str = ""
# --- 安全响应头(app/web_security.py)---
# 允许哪些站点用 iframe 嵌整站(同站的门户,如 https://portal.example.com),逗号分隔。本站自己总是允许的。
# 不设时只有本站能嵌(frame-ancestors 'self',另加 X-Frame-Options: SAMEORIGIN)
frame_ancestors: Annotated[list[str], NoDecode] = []
# 分享页(/s)允许被哪些站点嵌,逗号分隔,写法同上。分享页只认这一项,不认上面那一项
embed_origins: Annotated[list[str], NoDecode] = []
# --- 沙箱 ---
sandbox_backend: str = "auto" # auto | microvm | seatbelt | bubblewrap | local | off
sandbox_timeout: int = 30
sandbox_memory_mb: int = 512
sandbox_cpus: float = 1.0
sandbox_network: bool = False
sandbox_max_output: int = 20_000
# --- microVM(硬件级隔离,独立内核)---
# 跑代码用的 OCI 镜像。首次使用会拉取并缓存到 ~/.microsandbox。
microvm_image: str = "python:3.12-slim"
# microVM 是长驻的:同一 session 的多次执行复用同一台,省掉重复冷启动。
# 空闲超过这个秒数就回收,避免闲置 VM 一直占着内存。
microvm_idle_seconds: int = 300
# 单次启动(含首次拉镜像)的等待上限
microvm_boot_timeout: int = 300
# --- 执行引擎护栏 ---
# 单次执行(两次人工介入之间的那一段)的墙钟上限,runner 里强制执行
max_run_seconds: int = 600
# 单次模型调用的超时。流式调用时是"两段输出之间最长沉默多久",非流式是整次
# 请求。不设的话走 SDK 默认的 10 分钟、外加重试——一个挂住的服务商能把一个
# 节点拖上半小时
model_timeout_seconds: int = 300
max_graph_steps: int = 200
# 节点上配多少步都过不去这个硬顶。默认值仍是节点上的 12(nodeDefs.ts),
# 这里只决定"想调大的人最多能调到哪"——实测 87% 的 agent 节点 ≤10 步就收口,
# 绷紧的是那 10%,不该让所有人陪着多花钱
max_agent_steps: int = 100
#: 上传文件的大小上限(MB)。原来写死 10,而且是读完整个文件才检查——
#: 传一个 1GB 的文件,内存在报 413 之前就吃掉了
max_upload_mb: int = 50
max_concurrent_runs: int = 20
# --- 定时任务 ---
# 后台调度循环开不开(AGENTLAB_SCHEDULER=0 / false / off 关掉)。拷一份数据目录另起的服务
# (检查脚本的 --stacks、沙箱)必须关:库里的定时任务会照常到点发起运行、发出 webhook,
# 拷贝里的任务和原库的是同一批,等于同一件事做两遍
scheduler: bool = True
# 调度循环一拍的间隔(秒):定时认领、事件派发、流程的时限和核实都按它查一次。缺省 15;验收脚本(多进程端到端)调小
scheduler_tick_s: float = 15.0
# --- 工具 ---
http_tool_timeout: int = 20
http_tool_max_bytes: int = 2_000_000
# 逗号分隔的域名白名单;为空表示不限制(仅拦截内网地址)
http_tool_allowlist: Annotated[list[str], NoDecode] = []
# 本机代理 fake-ip 模式给的假地址段,逗号分隔(Clash / mihomo 默认 198.18.0.0/15)。
# 为空表示不认:解析到这些地址一律按内网拦截。配了之后,落在段里的解析结果改向
# 下面的公共 DNS 核实真实地址——不能直接放行,内网域名在 fake-ip 下也是这个段
http_tool_fake_ip_ranges: Annotated[list[str], NoDecode] = []
# 核实用的 DNS-over-HTTPS(JSON 接口),按顺序试。写 IP 形式免得解析 DoH 自己的域名。
# 境外的排前面:境内公共 DNS 对被封锁的域名返回污染结果(维基百科的 AAAA 给 2001::1),
# 会被当成内网误拦;开 fake-ip 的机器本来就有代理,连得上境外的 DoH
http_tool_doh_urls: Annotated[list[str], NoDecode] = [
"https://1.1.1.1/dns-query",
"https://223.5.5.5/resolve",
]
@field_validator("http_tool_allowlist", "http_tool_fake_ip_ranges", "http_tool_doh_urls", "frame_ancestors",
"embed_origins", mode="before")
@classmethod
def _comma_list(cls, value: object) -> object:
# 环境变量里写逗号分隔;也兼容 JSON 数组的写法
if isinstance(value, str):
text = value.strip()
if text.startswith("["):
import json
return json.loads(text)
return [part.strip() for part in text.split(",") if part.strip()]
return value
@field_validator("public_url")
@classmethod
def _valid_public_url(cls, value: str) -> str:
# 写错了启动时就报出来:以前拼出 agentlab.example.com/api/auth/oidc/callback 这样的回调,到登录时才发现
from app.core.public_url import ENV_NAME, normalize, url_problem
if not value.strip():
return ""
if problem := url_problem(value):
what = "不能带 ? 或 # 之后的部分" if problem == "suffix" else "需要是 http:// 或 https:// 开头的完整地址"
raise ValueError(f"{ENV_NAME}={value!r} {what},例如 https://agentlab.example.com")
return normalize(value) or ""
@field_validator("frame_ancestors", "embed_origins")
@classmethod
def _valid_origins(cls, value: list[str], info: Any) -> list[str]:
# 只收「协议://主机[:端口]」,主机可以写 *.example.com。路径、裸 *、没写协议的写错了启动时就报出来,别到嵌入
# 失败(或者整站被谁都能嵌)才发现。'self' 本来就有,写了也不重复
name = f"AGENTLAB_{(info.field_name or '').upper()}"
out: list[str] = []
for raw in value:
item = raw.strip().rstrip("/")
if item == "'self'":
continue
scheme, _, host = item.partition("://")
if scheme.lower() not in ("http", "https") or not _ORIGIN_HOST.fullmatch(host):
raise ValueError(f"{name} 里的「{raw}」需要写成「协议://主机」(可以带端口),例如 https://portal.example.com")
if (origin := f"{scheme.lower()}://{host.lower()}") not in out:
out.append(origin)
return out
@field_validator("auth_cookie_secure", mode="before")
@classmethod
def _blank_is_auto(cls, value: object) -> object:
# AGENTLAB_AUTH_COOKIE_SECURE= 写成空值等于没设(按请求定)
if isinstance(value, str) and not value.strip():
return None
return value
@field_validator("scheduler", mode="before")
@classmethod
def _blank_is_default(cls, value: object) -> object:
# AGENTLAB_SCHEDULER= 写成空值等于没设(默认开)。其余写法照 pydantic 的布尔解析:
# 0 / false / off / no 关,1 / true / on / yes 开,认不出的值启动时就报错,不猜
if isinstance(value, str) and not value.strip():
return True
return value
@field_validator("database_url", "checkpoint_url", "database_direct_url", mode="before")
@classmethod
def _valid_postgres_url(cls, value: object, info: Any) -> object:
# 写错了启动时就报出来,别等到第一次查库。只认 PostgreSQL:SQLite 就是不设
if value is None or (isinstance(value, str) and not value.strip()):
return ""
if not isinstance(value, str):
return value
from app.db.urls import normalize_database_url, normalize_checkpoint_url
name = f"AGENTLAB_{(info.field_name or '').upper()}"
normalize = normalize_checkpoint_url if info.field_name == "checkpoint_url" else normalize_database_url
try:
return normalize(value.strip())
except ValueError as e:
raise ValueError(f"{name}:{e}") from None
@field_validator("workers")
@classmethod
def _valid_workers(cls, value: int) -> int:
if value < 1:
raise ValueError("AGENTLAB_WORKERS 至少是 1")
return value
@field_validator("worker_heartbeat_s", "worker_lease_s")
@classmethod
def _positive_seconds(cls, value: float, info: Any) -> float:
if value <= 0:
raise ValueError(f"AGENTLAB_{(info.field_name or '').upper()} 要大于 0")
return value
@field_validator("web_dist", mode="before")
@classmethod
def _blank_is_unset(cls, value: object) -> object:
# AGENTLAB_WEB_DIST= 写成空值等于没设,别变成 Path("") 也就是当前目录
if isinstance(value, str) and not value.strip():
return None
return value
@field_validator("web_dist")
@classmethod
def _relative_to_project(cls, value: Path | None) -> Path | None:
if value is None or value.is_absolute():
return value
return PROJECT_DIR / value
@field_validator("http_tool_fake_ip_ranges")
@classmethod
def _valid_ranges(cls, value: list[str]) -> list[str]:
for cidr in value:
ipaddress.ip_network(cidr, strict=False) # 写错就在启动时报出来,别到运行时才静默失效
return value
@property
def db_path(self) -> Path:
return self.data_dir / "agentlab.db"
@property
def db_url(self) -> str:
"""应用库的 SQLAlchemy 地址:设了 database_url 用它(已规范成 postgresql+asyncpg://),否则数据目录里的 SQLite。"""
return self.database_url or f"sqlite+aiosqlite:///{self.db_path}"
@property
def db_dialect(self) -> str:
""""sqlite" 或 "postgresql"。"""
return "postgresql" if self.database_url else "sqlite"
@property
def checkpoint_path(self) -> Path:
"""LangGraph checkpointer 用独立的库文件,避免和业务表抢写锁。只在 SQLite 时用。"""
return self.data_dir / "checkpoints.db"
@property
def session_db_url(self) -> str:
"""依赖会话状态的连接(LISTEN、会话级咨询锁、断点库)用的应用库地址:设了直连地址用它,否则同 database_url。"""
return self.database_direct_url or self.database_url
@property
def checkpoint_conninfo(self) -> str | None:
"""PostgreSQL 断点库的 libpq 连接串(psycopg 用):checkpoint_url 优先,否则由直连地址(没设就是 database_url)换算。
SQLite 时 None。断点库用预备语句、建表时取会话级的锁,所以缺省走直连。"""
if self.checkpoint_url:
return self.checkpoint_url
if self.database_url:
from app.db.urls import libpq_conninfo
return libpq_conninfo(self.session_db_url)
return None
@property
def workspace_dir(self) -> Path:
"""沙箱与文件工具的可写根目录,一切文件访问都被限制在这里面。"""
return self.data_dir / "workspace"
def session_dir(self, session_id: str | None, *, sub: str = "") -> Path:
"""会话工作目录。session_id 一律先净化再拼路径。
这个函数存在的理由:session_id 有些调用点是外部可控的(工具 API 的
请求体就能指定),拿它直接拼路径就等于把工作区根目录交给调用方摆布,
后面再怎么做越界检查都是在被挪走的根下面做的,形同虚设。
"""
safe = sanitize_session(session_id)
root = (self.workspace_dir / (sub or "") / safe).resolve()
base = self.workspace_dir.resolve()
# 净化之后理应不可能跑出去,这里再断言一次:这条路径值得多一道保险
if root != base and base not in root.parents:
raise ValueError(f"非法的 session 目录:{session_id!r}")
return root
@property
def uploads_dir(self) -> Path:
return self.data_dir / "uploads"
def ensure_dirs(self) -> None:
for d in (self.data_dir, self.workspace_dir, self.uploads_dir):
d.mkdir(parents=True, exist_ok=True)
# 认得出「这是 AgentLab 的数据目录」的标记:应用库放在 PostgreSQL 时这里没有 agentlab.db,设了 AGENTLAB_SECRET_KEY
# 时也没有 .secret_key。deploy.sh 改属主前按它认,免得 DATA_DIR 写错把别人的目录 chown 了。只建一次,不覆盖
marker = self.data_dir / DATA_DIR_MARKER
if not marker.exists():
from app.core import atomic
try:
atomic.create_text(marker, "AgentLab 的数据目录(deploy.sh 按这个文件认,别删)\n")
except OSError:
pass
@lru_cache
def get_settings() -> Settings:
s = Settings()
s.ensure_dirs()
return s
settings = get_settings()