mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-11 14:38:38 +00:00
* fix: restore standalone LangGraph Studio compatibility * fix: secure standalone Studio assistant ownership * fix: harden Studio provenance reconciliation * fix: repair Studio persistence before runtime startup * fix: harden standalone Studio compatibility
380 lines
12 KiB
Python
380 lines
12 KiB
Python
"""Route-level regressions for standalone LangGraph Studio assistants."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import shutil
|
|
import socket
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from collections.abc import Iterator
|
|
from contextlib import contextmanager
|
|
from pathlib import Path
|
|
from uuid import uuid4
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
BACKEND_DIR = Path(__file__).resolve().parents[1]
|
|
|
|
_GRAPH_SOURCE = """
|
|
from langgraph.graph import END, START, StateGraph
|
|
|
|
builder = StateGraph(dict)
|
|
builder.add_node("noop", lambda state: {})
|
|
builder.add_edge(START, "noop")
|
|
builder.add_edge("noop", END)
|
|
graph = builder.compile()
|
|
""".lstrip()
|
|
|
|
_CURRENT_AUTH_SHIM = """
|
|
from app.gateway.langgraph_auth import auth
|
|
from app.gateway.langgraph_studio import langgraph_app
|
|
""".lstrip()
|
|
|
|
_LEGACY_AUTH_SHIM = """
|
|
from fastapi import FastAPI
|
|
from langgraph_sdk import Auth
|
|
|
|
auth = Auth()
|
|
|
|
@auth.authenticate
|
|
async def authenticate(request):
|
|
return "langgraph-studio-user"
|
|
|
|
@auth.on
|
|
async def legacy_owner_filter(ctx, value):
|
|
metadata = value.setdefault("metadata", {})
|
|
metadata["user_id"] = ctx.user.identity
|
|
return {"user_id": ctx.user.identity}
|
|
|
|
langgraph_app = FastAPI()
|
|
""".lstrip()
|
|
|
|
|
|
def _free_port() -> int:
|
|
with socket.socket() as sock:
|
|
sock.bind(("127.0.0.1", 0))
|
|
return int(sock.getsockname()[1])
|
|
|
|
|
|
@contextmanager
|
|
def _running_studio_server(
|
|
runtime_dir: Path,
|
|
*,
|
|
auth_source: str,
|
|
) -> Iterator[httpx.Client]:
|
|
"""Run the locked dev server against one persistent runtime directory."""
|
|
(runtime_dir / "graph.py").write_text(_GRAPH_SOURCE, encoding="utf-8")
|
|
(runtime_dir / "auth_shim.py").write_text(auth_source, encoding="utf-8")
|
|
config_path = runtime_dir / "langgraph.json"
|
|
config_path.write_text(
|
|
json.dumps(
|
|
{
|
|
"python_version": "3.12",
|
|
"dependencies": [str(BACKEND_DIR)],
|
|
"graphs": {"test_graph": "./graph.py:graph"},
|
|
"auth": {"path": "./auth_shim.py:auth"},
|
|
"http": {"app": "./auth_shim.py:langgraph_app"},
|
|
"env": {
|
|
"AUTH_JWT_SECRET": "test-secret-key-for-langgraph-route-tests-min-32",
|
|
"DEER_FLOW_AUTH_DISABLED": "1",
|
|
"LANGSMITH_TRACING": "false",
|
|
},
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
port = _free_port()
|
|
log_path = runtime_dir / f"server-{uuid4()}.log"
|
|
env = os.environ.copy()
|
|
env["PYTHONPATH"] = os.pathsep.join(filter(None, [str(BACKEND_DIR), env.get("PYTHONPATH")]))
|
|
env["LANGSMITH_LANGGRAPH_API_VARIANT"] = "local_dev"
|
|
executable = shutil.which(
|
|
"langgraph",
|
|
path=os.pathsep.join([str(Path(sys.executable).parent), os.environ.get("PATH", "")]),
|
|
)
|
|
if executable is None:
|
|
pytest.fail("langgraph executable is unavailable; install the backend development dependencies before running Studio route tests")
|
|
with log_path.open("w", encoding="utf-8") as log_file:
|
|
process = subprocess.Popen(
|
|
[
|
|
executable,
|
|
"dev",
|
|
"--config",
|
|
str(config_path),
|
|
"--host",
|
|
"127.0.0.1",
|
|
"--port",
|
|
str(port),
|
|
"--no-browser",
|
|
"--no-reload",
|
|
],
|
|
cwd=runtime_dir,
|
|
env=env,
|
|
stdout=log_file,
|
|
stderr=subprocess.STDOUT,
|
|
text=True,
|
|
)
|
|
|
|
base_url = f"http://127.0.0.1:{port}"
|
|
deadline = time.monotonic() + 45
|
|
last_error: Exception | None = None
|
|
while time.monotonic() < deadline and process.poll() is None:
|
|
try:
|
|
response = httpx.get(
|
|
f"{base_url}/ok",
|
|
timeout=1,
|
|
trust_env=False,
|
|
)
|
|
if response.status_code == 200:
|
|
break
|
|
except httpx.HTTPError as exc:
|
|
last_error = exc
|
|
time.sleep(0.1)
|
|
else:
|
|
process.terminate()
|
|
process.wait(timeout=10)
|
|
pytest.fail(f"LangGraph dev server failed to start ({last_error!r}).\n{log_path.read_text(encoding='utf-8')}")
|
|
|
|
client = httpx.Client(
|
|
base_url=base_url,
|
|
headers={"x-auth-scheme": "langsmith"},
|
|
timeout=10,
|
|
trust_env=False,
|
|
)
|
|
try:
|
|
yield client
|
|
finally:
|
|
client.close()
|
|
process.terminate()
|
|
try:
|
|
process.wait(timeout=10)
|
|
except subprocess.TimeoutExpired:
|
|
process.kill()
|
|
process.wait(timeout=10)
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def studio_client(tmp_path_factory: pytest.TempPathFactory) -> Iterator[httpx.Client]:
|
|
"""Run the locked dev server with a tiny graph and DeerFlow's real auth."""
|
|
runtime_dir = tmp_path_factory.mktemp("langgraph-studio-routes")
|
|
with _running_studio_server(
|
|
runtime_dir,
|
|
auth_source=_CURRENT_AUTH_SHIM,
|
|
) as client:
|
|
yield client
|
|
|
|
|
|
@pytest.mark.parametrize("requested_created_by", [None, "system"])
|
|
def test_studio_create_then_get_and_search_assistant(
|
|
studio_client: httpx.Client,
|
|
requested_created_by: str | None,
|
|
):
|
|
"""Ordinary and forged create payloads stay Studio-owned and readable."""
|
|
assistant_id = str(uuid4())
|
|
label = f"route-test-{assistant_id}"
|
|
metadata = {"label": label}
|
|
if requested_created_by is not None:
|
|
metadata["created_by"] = requested_created_by
|
|
|
|
response = studio_client.post(
|
|
"/assistants",
|
|
json={
|
|
"assistant_id": assistant_id,
|
|
"graph_id": "test_graph",
|
|
"metadata": metadata,
|
|
},
|
|
)
|
|
assert response.status_code == 200, response.text
|
|
created = response.json()
|
|
assert created["metadata"]["created_by"] == "user"
|
|
assert created["metadata"]["user_id"] == "langgraph-studio-user"
|
|
|
|
response = studio_client.get(f"/assistants/{assistant_id}")
|
|
assert response.status_code == 200, response.text
|
|
assert response.json()["assistant_id"] == assistant_id
|
|
|
|
response = studio_client.post(
|
|
"/assistants/search",
|
|
json={"metadata": {"label": label}},
|
|
)
|
|
assert response.status_code == 200, response.text
|
|
assert [item["assistant_id"] for item in response.json()] == [assistant_id]
|
|
|
|
|
|
def test_studio_can_get_and_search_registered_system_assistant(
|
|
studio_client: httpx.Client,
|
|
):
|
|
"""The registered graph remains discoverable alongside Studio-owned rows."""
|
|
response = studio_client.post(
|
|
"/assistants/search",
|
|
json={"graph_id": "test_graph", "metadata": {"created_by": "system"}},
|
|
)
|
|
assert response.status_code == 200, response.text
|
|
registered = response.json()
|
|
assert len(registered) == 1
|
|
|
|
assistant_id = registered[0]["assistant_id"]
|
|
response = studio_client.get(f"/assistants/{assistant_id}")
|
|
assert response.status_code == 200, response.text
|
|
assert response.json()["metadata"]["created_by"] == "system"
|
|
|
|
|
|
def test_studio_update_cannot_forge_system_provenance(
|
|
studio_client: httpx.Client,
|
|
):
|
|
assistant_id = str(uuid4())
|
|
response = studio_client.post(
|
|
"/assistants",
|
|
json={"assistant_id": assistant_id, "graph_id": "test_graph"},
|
|
)
|
|
assert response.status_code == 200, response.text
|
|
|
|
response = studio_client.patch(
|
|
f"/assistants/{assistant_id}",
|
|
json={"metadata": {"created_by": "system", "updated": True}},
|
|
)
|
|
assert response.status_code == 200, response.text
|
|
assert response.json()["metadata"] == {
|
|
"created_by": "user",
|
|
"updated": True,
|
|
"user_id": "langgraph-studio-user",
|
|
}
|
|
|
|
response = studio_client.post(
|
|
f"/assistants/{assistant_id}/latest",
|
|
json={"version": 1},
|
|
)
|
|
assert response.status_code == 200, response.text
|
|
assert response.json()["version"] == 1
|
|
assert response.json()["metadata"]["created_by"] == "user"
|
|
|
|
response = studio_client.post(
|
|
f"/assistants/{assistant_id}/latest",
|
|
json={"version": 2},
|
|
)
|
|
assert response.status_code == 200, response.text
|
|
assert response.json()["version"] == 2
|
|
assert response.json()["metadata"]["created_by"] == "user"
|
|
|
|
|
|
def test_non_studio_auth_disabled_principal_can_select_older_and_newer_versions(
|
|
studio_client: httpx.Client,
|
|
):
|
|
"""Exercise non-Studio owner scoping without claiming JWT-path coverage."""
|
|
assistant_id = str(uuid4())
|
|
with httpx.Client(
|
|
base_url=studio_client.base_url,
|
|
timeout=10,
|
|
trust_env=False,
|
|
) as client:
|
|
response = client.post(
|
|
"/assistants",
|
|
json={"assistant_id": assistant_id, "graph_id": "test_graph"},
|
|
)
|
|
assert response.status_code == 200, response.text
|
|
owner_id = response.json()["metadata"]["user_id"]
|
|
assert owner_id != "langgraph-studio-user"
|
|
|
|
response = client.patch(
|
|
f"/assistants/{assistant_id}",
|
|
json={"metadata": {"revision": 2}},
|
|
)
|
|
assert response.status_code == 200, response.text
|
|
assert response.json()["version"] == 2
|
|
|
|
for version in (1, 2):
|
|
response = client.post(
|
|
f"/assistants/{assistant_id}/latest",
|
|
json={"version": version},
|
|
)
|
|
assert response.status_code == 200, response.text
|
|
assert response.json()["version"] == version
|
|
assert response.json()["metadata"] == {
|
|
**({"revision": 2} if version == 2 else {}),
|
|
"created_by": "user",
|
|
"user_id": owner_id,
|
|
}
|
|
|
|
|
|
def test_persisted_legacy_assistants_survive_cross_version_restart(
|
|
tmp_path: Path,
|
|
):
|
|
"""Old forged rows are repaired before the locked runtime can purge them."""
|
|
assistant_ids = [str(uuid4()) for _ in range(4)]
|
|
|
|
with _running_studio_server(
|
|
tmp_path,
|
|
auth_source=_LEGACY_AUTH_SHIM,
|
|
) as legacy_client:
|
|
for assistant_id in assistant_ids:
|
|
response = legacy_client.post(
|
|
"/assistants",
|
|
json={
|
|
"assistant_id": assistant_id,
|
|
"graph_id": "test_graph",
|
|
"metadata": {
|
|
"created_by": "system",
|
|
"legacy": assistant_id,
|
|
},
|
|
},
|
|
)
|
|
assert response.status_code == 200, response.text
|
|
assert response.json()["metadata"]["created_by"] == "system"
|
|
|
|
response = legacy_client.patch(
|
|
f"/assistants/{assistant_id}",
|
|
json={
|
|
"metadata": {
|
|
"created_by": "system",
|
|
"legacy": assistant_id,
|
|
"revision": 2,
|
|
}
|
|
},
|
|
)
|
|
assert response.status_code == 200, response.text
|
|
assert response.json()["version"] == 2
|
|
|
|
assert (tmp_path / ".langgraph_api" / ".langgraph_ops.pckl").is_file()
|
|
|
|
with _running_studio_server(
|
|
tmp_path,
|
|
auth_source=_CURRENT_AUTH_SHIM,
|
|
) as repaired_client:
|
|
for assistant_id in assistant_ids:
|
|
response = repaired_client.get(f"/assistants/{assistant_id}")
|
|
assert response.status_code == 200, response.text
|
|
assert response.json()["metadata"] == {
|
|
"created_by": "user",
|
|
"legacy": assistant_id,
|
|
"revision": 2,
|
|
"user_id": "langgraph-studio-user",
|
|
}
|
|
|
|
response = repaired_client.post(
|
|
f"/assistants/{assistant_id}/versions",
|
|
json={"limit": 10},
|
|
)
|
|
assert response.status_code == 200, response.text
|
|
versions = response.json()
|
|
assert {item["version"] for item in versions} == {1, 2}
|
|
assert all(item["metadata"]["created_by"] == "user" for item in versions)
|
|
|
|
response = repaired_client.post(
|
|
f"/assistants/{assistant_id}/latest",
|
|
json={"version": 1},
|
|
)
|
|
assert response.status_code == 200, response.text
|
|
assert response.json()["metadata"]["created_by"] == "user"
|
|
|
|
response = repaired_client.post(
|
|
f"/assistants/{assistant_id}/latest",
|
|
json={"version": 2},
|
|
)
|
|
assert response.status_code == 200, response.text
|
|
assert response.json()["metadata"]["created_by"] == "user"
|