mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 04:11:58 +00:00
6af4e396b9
- 服务端可以托管前端构建产物(AGENTLAB_WEB_DIST):页面路由回退到 index.html,/api 下不存在的路径照旧是 JSON 404, 挡住路径穿越;index.html 不缓存,assets/ 带哈希的文件长缓存;没设时行为完全不变 - scripts/prod.sh:不用 Docker 的生产运行。按需构建前端,单进程、不热重载,默认只听本机,--host 开放到局域网并提示没有登录 - Dockerfile(多阶段构建,Linux 镜像):前端在构建机架构上构建,运行阶段用 python:3.12-slim,依赖装进独立的 venv, 以非 root 用户(uid 10001)运行,数据在 /data,带健康检查;.dockerignore 排除 .env、data/、各种库文件,镜像里实测没有密钥和数据库 - scripts/package-docker.sh:构建指定平台的镜像(默认按本机架构,可选 linux/amd64),打成 release/agentlab-<版本>-linux-<架构>.tar,里面有 image.tar、deploy.sh、agentlab.env、README.md、SHA256SUMS - deploy/deploy.sh:目标机上只依赖 Docker,离线可用。install / start / stop / restart / status / logs / upgrade / uninstall; 检查架构和配置,自动导入镜像,替换容器时保留旧容器以便回退,数据永远保留,日志轮转 - 修正 bubblewrap 的可用性判断:以前只看有没有装 bwrap,容器里和 Ubuntu 24.04 默认配置下装了却建不了命名空间, 每次执行代码都失败;现在真起一次试,不能用就退回下一个后端,并写明原因 - README 增加「生产部署」一节,部署细节见 deploy/README.md
222 lines
7.9 KiB
Python
222 lines
7.9 KiB
Python
"""生产模式下服务端托管前端构建产物(AGENTLAB_WEB_DIST)。
|
|
|
|
用临时目录伪造一个 dist,覆盖这几条:
|
|
- 文件存在就返回文件;其余非 /api 的 GET 回退到 index.html(单页应用);
|
|
- /api 下不存在的路径照旧是 JSON 404,不能被回退吞掉——POST 也一样,不能变成 405;
|
|
- WebSocket 不受影响;
|
|
- index.html 不缓存,assets/ 下带哈希的文件长缓存;
|
|
- 没设 AGENTLAB_WEB_DIST 时行为完全不变。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import httpx
|
|
import pytest
|
|
from fastapi import APIRouter, FastAPI, WebSocket
|
|
from fastapi.testclient import TestClient
|
|
from starlette.websockets import WebSocketDisconnect
|
|
|
|
from app.core.config import BACKEND_DIR, PROJECT_DIR, Settings
|
|
|
|
INDEX = "<!doctype html><title>fake-agentlab</title><div id=root></div>"
|
|
JS = "console.log('fake bundle')"
|
|
|
|
|
|
@pytest.fixture
|
|
def dist(tmp_path: Path) -> Path:
|
|
root = tmp_path / "dist"
|
|
(root / "assets").mkdir(parents=True)
|
|
(root / "index.html").write_text(INDEX)
|
|
(root / "favicon.svg").write_text("<svg xmlns='http://www.w3.org/2000/svg'/>")
|
|
(root / "assets" / "index-AbC123.js").write_text(JS)
|
|
(root / "assets" / "index-AbC123.css").write_text("body{}")
|
|
# dist 外面的文件:路径穿越不能读到它
|
|
(tmp_path / "secret.txt").write_text("TOP-SECRET")
|
|
return root
|
|
|
|
|
|
def _app(dist: Path) -> FastAPI:
|
|
from app.web import mount_web
|
|
|
|
app = FastAPI()
|
|
api = APIRouter(prefix="/api")
|
|
|
|
@api.get("/ping")
|
|
async def ping() -> dict[str, str]:
|
|
return {"pong": "1"}
|
|
|
|
@api.websocket("/echo")
|
|
async def echo(ws: WebSocket) -> None:
|
|
await ws.accept()
|
|
await ws.send_text(await ws.receive_text())
|
|
await ws.close()
|
|
|
|
app.include_router(api)
|
|
mount_web(app, dist)
|
|
return app
|
|
|
|
|
|
async def _get(app: FastAPI, path: str, method: str = "GET") -> httpx.Response:
|
|
transport = httpx.ASGITransport(app=app)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://t") as client:
|
|
return await client.request(method, path)
|
|
|
|
|
|
async def test_index_and_spa_fallback(dist: Path) -> None:
|
|
app = _app(dist)
|
|
for path in ("/", "/index.html", "/studio", "/runs/abc/evidence", "/studio/"):
|
|
r = await _get(app, path)
|
|
assert r.status_code == 200, path
|
|
assert r.text == INDEX, path
|
|
assert r.headers["content-type"].startswith("text/html"), path
|
|
assert "no-cache" in r.headers["cache-control"], path
|
|
|
|
|
|
async def test_existing_files_are_served(dist: Path) -> None:
|
|
app = _app(dist)
|
|
r = await _get(app, "/assets/index-AbC123.js")
|
|
assert r.status_code == 200
|
|
assert r.text == JS
|
|
assert "javascript" in r.headers["content-type"]
|
|
assert "immutable" in r.headers["cache-control"]
|
|
assert "max-age=31536000" in r.headers["cache-control"]
|
|
|
|
r = await _get(app, "/assets/index-AbC123.css")
|
|
assert r.status_code == 200 and r.headers["content-type"].startswith("text/css")
|
|
|
|
r = await _get(app, "/favicon.svg")
|
|
assert r.status_code == 200
|
|
assert r.headers["content-type"].startswith("image/svg+xml")
|
|
assert "immutable" not in r.headers.get("cache-control", "")
|
|
|
|
|
|
async def test_missing_asset_is_404_not_index(dist: Path) -> None:
|
|
# 发版后旧页面请求旧哈希的文件:回退成 index.html 会被当脚本解析,还会被长缓存
|
|
r = await _get(_app(dist), "/assets/index-old999.js")
|
|
assert r.status_code == 404
|
|
assert r.text != INDEX
|
|
|
|
|
|
async def test_api_routes_win_and_unknown_api_is_json_404(dist: Path) -> None:
|
|
app = _app(dist)
|
|
r = await _get(app, "/api/ping")
|
|
assert r.status_code == 200 and r.json() == {"pong": "1"}
|
|
|
|
for path in ("/api/nope", "/api", "/api/", "/api/runs/xyz/nope"):
|
|
r = await _get(app, path)
|
|
assert r.status_code == 404, path
|
|
assert r.headers["content-type"].startswith("application/json"), path
|
|
assert r.json() == {"detail": "Not Found"}, path
|
|
|
|
# 路径规则里就排除了 /api:否则「路径对上、方法不对」会让 POST 变成 405
|
|
r = await _get(app, "/api/nope", method="POST")
|
|
assert r.status_code == 404
|
|
assert r.json() == {"detail": "Not Found"}
|
|
|
|
# 只保留 api 这一段,别的前缀照常回退
|
|
r = await _get(app, "/apiary")
|
|
assert r.status_code == 200 and r.text == INDEX
|
|
|
|
|
|
async def test_head_and_non_get(dist: Path) -> None:
|
|
app = _app(dist)
|
|
r = await _get(app, "/studio", method="HEAD")
|
|
assert r.status_code == 200
|
|
r = await _get(app, "/studio", method="POST")
|
|
assert r.status_code == 405
|
|
|
|
|
|
async def test_path_traversal_stays_inside_dist(dist: Path) -> None:
|
|
app = _app(dist)
|
|
for path in ("/%2e%2e/secret.txt", "/..%2fsecret.txt", "/assets/%2e%2e/%2e%2e/secret.txt"):
|
|
r = await _get(app, path)
|
|
assert "TOP-SECRET" not in r.text, path
|
|
|
|
|
|
def test_websocket_unaffected(dist: Path) -> None:
|
|
client = TestClient(_app(dist))
|
|
with client.websocket_connect("/api/echo") as ws:
|
|
ws.send_text("hi")
|
|
assert ws.receive_text() == "hi"
|
|
# 没有这条 WebSocket 路由的地址不会被页面回退接走
|
|
with pytest.raises(WebSocketDisconnect):
|
|
with client.websocket_connect("/studio") as ws:
|
|
ws.receive_text()
|
|
|
|
|
|
def test_missing_index_fails_fast(tmp_path: Path) -> None:
|
|
from app.web import mount_web
|
|
|
|
with pytest.raises(RuntimeError, match="index.html"):
|
|
mount_web(FastAPI(), tmp_path)
|
|
|
|
|
|
def test_setting_parsing(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.delenv("AGENTLAB_WEB_DIST", raising=False)
|
|
assert Settings().web_dist is None
|
|
monkeypatch.setenv("AGENTLAB_WEB_DIST", "")
|
|
assert Settings().web_dist is None
|
|
# 相对路径按仓库根目录算,和从哪个目录启动无关
|
|
monkeypatch.setenv("AGENTLAB_WEB_DIST", "frontend/dist")
|
|
assert Settings().web_dist == PROJECT_DIR / "frontend" / "dist"
|
|
monkeypatch.setenv("AGENTLAB_WEB_DIST", "/srv/web")
|
|
assert Settings().web_dist == Path("/srv/web")
|
|
|
|
|
|
async def test_default_app_unchanged_without_setting() -> None:
|
|
from app.core.config import settings
|
|
from app.main import app
|
|
|
|
assert settings.web_dist is None
|
|
r = await _get(app, "/studio")
|
|
assert r.status_code == 404
|
|
assert r.json() == {"detail": "Not Found"}
|
|
|
|
|
|
_PROBE = r"""
|
|
import asyncio, json, httpx
|
|
from app.main import app
|
|
|
|
async def main():
|
|
t = httpx.ASGITransport(app=app)
|
|
async with httpx.AsyncClient(transport=t, base_url="http://t") as c:
|
|
out = {}
|
|
for m, p in (("GET", "/api/health"), ("GET", "/"), ("GET", "/studio"),
|
|
("GET", "/assets/index-AbC123.js"), ("GET", "/api/nope"), ("POST", "/api/nope")):
|
|
r = await c.request(m, p)
|
|
out[f"{m} {p}"] = [r.status_code, r.text[:200]]
|
|
print(json.dumps(out))
|
|
|
|
asyncio.run(main())
|
|
"""
|
|
|
|
|
|
def test_main_app_mounts_when_setting_present(dist: Path, tmp_path: Path) -> None:
|
|
"""真实的 app.main 在设了 AGENTLAB_WEB_DIST 时托管页面、接口照旧。
|
|
|
|
settings 和 app 都是模块级单例,同一进程里改不了,所以起一个子进程导入。
|
|
"""
|
|
env = {
|
|
**os.environ,
|
|
"AGENTLAB_DATA_DIR": str(tmp_path / "data"),
|
|
"AGENTLAB_WEB_DIST": str(dist),
|
|
"AGENTLAB_SECRET_KEY": "test-only-not-a-real-key",
|
|
}
|
|
proc = subprocess.run(
|
|
[sys.executable, "-c", _PROBE], cwd=BACKEND_DIR, env=env,
|
|
capture_output=True, text=True, timeout=120,
|
|
)
|
|
assert proc.returncode == 0, proc.stderr[-2000:]
|
|
out = json.loads(proc.stdout.strip().splitlines()[-1])
|
|
assert out["GET /api/health"][0] == 200 and '"agentlab"' in out["GET /api/health"][1]
|
|
assert out["GET /"] == [200, INDEX]
|
|
assert out["GET /studio"] == [200, INDEX]
|
|
assert out["GET /assets/index-AbC123.js"] == [200, JS]
|
|
assert out["GET /api/nope"][0] == 404 and json.loads(out["GET /api/nope"][1]) == {"detail": "Not Found"}
|
|
assert out["POST /api/nope"][0] == 404
|