fix: restore standalone LangGraph Studio compatibility (#4760)

* 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
This commit is contained in:
Mason Zhou 2026-08-15 21:20:34 +08:00 committed by GitHub
parent 3a967d4f9a
commit 432c09f6b0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 1090 additions and 21 deletions

View File

@ -373,6 +373,34 @@ DeerFlow runs the agent runtime inside the Gateway API. Development mode enables
Gateway owns `/api/langgraph/*` and translates those public LangGraph-compatible paths to its native `/api/*` routers behind nginx.
#### LangGraph Studio (Optional)
The default `make dev` topology uses DeerFlow's Gateway-embedded runtime and
does not require LangGraph Studio. To inspect and test the registered lead-agent
graph with the standalone development server, run the command from `backend/`
so the CLI discovers `langgraph.json`:
```bash
cd backend
uv run langgraph dev --allow-blocking
```
The command prints the local API and Studio UI URLs. This in-memory server is
for development and testing only. The flag permits DeerFlow's synchronous
configuration and graph-factory setup during local Studio requests; it must not
be treated as a production-server setting. Local Studio authentication is
handled automatically, so the connection does not require custom headers. Use
DeerFlow's documented production startup modes or a supported LangSmith
deployment for production workloads. Assistant ownership and provenance in this
standalone mode are server-owned: Studio can discover registered graphs and the
assistants it creates, and normal assistant-version selection remains available.
Before the locked local runtime loads its persisted development store, DeerFlow
repairs legacy assistant rows and version history so historical client metadata
cannot restore server privileges or be discarded by the runtime's startup
cleanup. Keep the backend dependencies synchronized with `uv sync`; this
compatibility path requires the declared LangGraph runtime versions and logs a
warning if the persisted-store contract no longer matches its expectations.
For workflows that invoke `backend/langgraph.json` through LangGraph Studio or
a direct LangGraph Server, DeerFlow consumes the authenticated identity
published by that runtime and uses it for custom-agent configuration/SOUL, user

View File

@ -152,10 +152,14 @@ from deerflow.config import get_app_config
```
Package import hygiene: the `deerflow.agents` and `deerflow.subagents` package
roots expose heavyweight graph/executor entrypoints lazily. Internal modules
that only need lightweight types, config, or registries should import the
concrete submodule instead of adding eager package-root imports that pull in the
tool graph or subagent executor during state/schema imports.
roots expose heavyweight graph/executor entrypoints lazily. The
`deerflow.agents:make_lead_agent` LangGraph Server entrypoint is a concrete thin
module-level function because the server resolves graph factories directly from
the module dictionary; the wrapper keeps the lead-agent and skill-cache imports
inside the function so importing the package remains lightweight. Internal
modules that only need lightweight types, config, or registries should import
the concrete submodule instead of adding eager package-root imports that pull in
the tool graph or subagent executor during state/schema imports.
## Development Workflow

View File

@ -278,6 +278,27 @@ backend/
deployments run the Gateway embedded runtime; the file is kept for LangGraph
tooling, Studio, or direct LangGraph Server compatibility.
To start the optional standalone development server and open its Studio URL:
```bash
cd backend
uv run langgraph dev --allow-blocking
```
Run it from `backend/` so the CLI discovers `langgraph.json`. The in-memory
server is intended for development and testing, not production deployment. The
flag permits DeerFlow's synchronous configuration and graph-factory setup
during local Studio requests; it is not a production-server setting. Its local
Studio authentication and registered graph discovery are handled automatically;
no custom connection headers are required. Assistant ownership/provenance is
stamped by the server, and normal assistant-version selection remains available.
Before the locked local runtime loads its persisted development store, DeerFlow
repairs legacy assistant rows and version history so older metadata cannot
reactivate server-only privileges or be discarded by runtime startup cleanup.
Run `uv sync` after dependency changes; this compatibility path requires the
declared LangGraph runtime versions and warns when the persisted-store contract
does not match its expectations.
---
## Configuration

View File

@ -8,6 +8,31 @@ Browser auth sessions are owned by `app.gateway.auth.session_cookie`. Login acce
Localhost persistence deliberately reads the direct request `Host` and ignores `Forwarded` / `X-Forwarded-Host`. Scheme and auth-origin reconstruction still consume forwarding headers. The bundled nginx sets `X-Forwarded-Proto`, but preserves an upstream HTTPS value and does not overwrite every forwarded header, so the outer trusted proxy must replace or strip client-supplied forwarding headers before traffic reaches DeerFlow.
Standalone local LangGraph Studio is recognized only through the upstream
`Auth.types.StudioUser` principal type, never by its reusable identity string.
The type is resolved once at import; an older SDK without it degrades to normal
owner scoping instead of failing requests.
For that principal's assistant reads/searches, `langgraph_auth.add_owner_filter`
selects genuine server-registered assistants plus assistants owned by Studio;
all other resources remain owner-scoped. Assistant create/update handlers make
both `user_id` and `created_by=user` server-owned, because LangGraph gives
`created_by=system` privileged ownership semantics during run creation. The
custom application module in `langgraph_studio.py` is imported before the
locked in-memory runtime lifespan. At that pre-runtime boundary it derives
genuine system assistant IDs from the CLI-provided graph registry, removes
their persisted active/version rows so graph registration recreates them, and
demotes every other legacy `created_by=system` marker in both active assistants
and version history. This must happen before runtime 0.30.0 loads and purges
system-marked rows; a user application lifespan is too late. An empty graph
registry or absent persistence file is a no-op, while persistence parse/write
errors fail startup closed. The harness requires in-memory runtime 0.30.0 or
newer, and a persisted store containing no expected registered assistant row
emits a drift warning so changes to LangGraph's internal persistence contract
are observable. Because current create/update writes and all legacy
versions are sanitized, ordinary owner-scoped assistant version selection
remains enabled. Ordinary authenticated users retain owner-scoped assistant
reads/searches.
**Routers**:
| Router | Endpoints |

View File

@ -25,6 +25,11 @@ from app.gateway.deps import get_local_provider
auth = Auth()
# StudioUser was added after DeerFlow's historical langgraph-sdk floor. Resolve
# it once so older compatible SDK installs keep ordinary owner scoping instead
# of failing every request with an AttributeError.
_STUDIO_USER_TYPE = getattr(Auth.types, "StudioUser", None)
# Methods that require CSRF validation (state-changing per RFC 7231).
_CSRF_METHODS = frozenset({"POST", "PUT", "DELETE", "PATCH"})
@ -109,9 +114,27 @@ async def add_owner_filter(ctx: Auth.types.AuthContext, value: dict):
Gateway stores thread ownership as ``metadata.user_id``.
This handler ensures LangGraph Server enforces the same isolation.
"""
# On create/update: stamp user_id into metadata
# LangGraph represents its trusted local Studio principal with a dedicated
# user type. Do not infer that privilege from its public identity string:
# an ordinary authenticated principal may reuse the same string.
if _STUDIO_USER_TYPE is not None and isinstance(ctx.user, _STUDIO_USER_TYPE) and ctx.resource == "assistants" and ctx.action in {"read", "search"}:
return {
"$or": [
{"created_by": "system"},
{"user_id": ctx.user.identity},
]
}
# Ownership and provenance on external assistant writes are server-owned.
# LangGraph treats ``created_by=system`` as privileged during run creation,
# so accepting that marker from request metadata would cross the auth
# boundary. The standalone pre-runtime persistence repair also scrubs this
# marker from legacy active rows and their version history before normal
# version selection becomes available.
metadata = value.setdefault("metadata", {})
metadata["user_id"] = ctx.user.identity
if ctx.resource == "assistants" and ctx.action in {"create", "update"}:
metadata["created_by"] = "user"
# Return filter dict — LangGraph applies it to search/read/delete
return {"user_id": ctx.user.identity}

View File

@ -0,0 +1,185 @@
"""Pre-runtime persistence repair for standalone LangGraph Studio.
``langgraph dev`` imports this custom application before entering the locked
in-memory runtime lifespan. That ordering is intentional: runtime 0.30.0 loads
and purges persisted ``created_by=system`` assistants before graph registration
and before a user application lifespan can run.
"""
from __future__ import annotations
import json
import logging
import os
from collections.abc import Collection, MutableMapping
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from uuid import UUID, uuid5
from fastapi import FastAPI
logger = logging.getLogger(__name__)
_OPS_PATH = Path(".langgraph_api/.langgraph_ops.pckl")
@dataclass(frozen=True)
class ProvenanceRepair:
"""Summary of one atomic pre-runtime persistence repair."""
removed_registered_assistants: int = 0
removed_registered_versions: int = 0
demoted_assistants: int = 0
demoted_versions: int = 0
@property
def changed(self) -> bool:
return any(
(
self.removed_registered_assistants,
self.removed_registered_versions,
self.demoted_assistants,
self.demoted_versions,
)
)
def configured_system_assistant_ids(
graphs_json: str | None,
*,
namespace: UUID | None = None,
) -> set[str]:
"""Derive registered assistant IDs from the CLI-provided graph registry."""
if not graphs_json:
return set()
graphs = json.loads(graphs_json)
if not isinstance(graphs, dict):
raise ValueError("LANGSERVE_GRAPHS must contain a JSON object")
if not graphs:
return set()
if namespace is None:
from langgraph_api.graph import NAMESPACE_GRAPH
namespace = NAMESPACE_GRAPH
return {str(uuid5(namespace, str(graph_id))) for graph_id in graphs}
def _demote_system_marker(row: MutableMapping[str, Any]) -> tuple[MutableMapping[str, Any], bool]:
metadata = row.get("metadata") or {}
if metadata.get("created_by") != "system":
return row, False
repaired = dict(row)
repaired_metadata = dict(metadata)
repaired_metadata["created_by"] = "user"
repaired["metadata"] = repaired_metadata
return repaired, True
def repair_persisted_assistant_provenance(
store: MutableMapping[str, Any],
*,
registered_system_ids: Collection[str],
) -> ProvenanceRepair:
"""Repair legacy assistant rows before the in-memory runtime loads them.
Configured graph assistant IDs are removed so graph registration recreates
them with server-owned provenance. Every other legacy system marker is
demoted in both the active row and its version history. The replacement
lists are built before either store key is assigned, so a malformed row
cannot leave a partially repaired store.
"""
registered_ids = {str(assistant_id) for assistant_id in registered_system_ids}
if not registered_ids:
return ProvenanceRepair()
assistants: list[MutableMapping[str, Any]] = []
removed_registered_assistants = 0
demoted_assistants = 0
for row in store.get("assistants") or []:
if str(row.get("assistant_id")) in registered_ids:
removed_registered_assistants += 1
continue
repaired, demoted = _demote_system_marker(row)
assistants.append(repaired)
demoted_assistants += int(demoted)
versions: list[MutableMapping[str, Any]] = []
removed_registered_versions = 0
demoted_versions = 0
for row in store.get("assistant_versions") or []:
if str(row.get("assistant_id")) in registered_ids:
removed_registered_versions += 1
continue
repaired, demoted = _demote_system_marker(row)
versions.append(repaired)
demoted_versions += int(demoted)
result = ProvenanceRepair(
removed_registered_assistants=removed_registered_assistants,
removed_registered_versions=removed_registered_versions,
demoted_assistants=demoted_assistants,
demoted_versions=demoted_versions,
)
store["assistants"] = assistants
store["assistant_versions"] = versions
return result
def repair_local_dev_persistence_before_runtime(
*,
persistence_path: Path = _OPS_PATH,
graphs_json: str | None = None,
) -> ProvenanceRepair:
"""Load, repair, and atomically rewrite a locked local-dev store."""
if graphs_json is None:
graphs_json = os.getenv("LANGSERVE_GRAPHS")
registered_ids = configured_system_assistant_ids(graphs_json)
if not registered_ids or not persistence_path.is_file():
return ProvenanceRepair()
from langgraph.checkpoint.memory import PersistentDict
store = PersistentDict(dict, filename=str(persistence_path))
store.load()
result = repair_persisted_assistant_provenance(
store,
registered_system_ids=registered_ids,
)
if not (result.removed_registered_assistants or result.removed_registered_versions):
logger.warning(
"Standalone Studio persistence repair matched no persisted registered assistant rows; verify the LangGraph runtime persistence contract before trusting provenance repair",
)
if result.changed:
store.sync()
return result
def _prepare_locked_local_dev_runtime() -> None:
if os.getenv("LANGSMITH_LANGGRAPH_API_VARIANT") != "local_dev":
return
if "__inmem" not in os.getenv("MIGRATIONS_PATH", ""):
return
result = repair_local_dev_persistence_before_runtime()
if result.changed:
logger.warning(
"Repaired standalone Studio persistence before runtime startup: %d registered assistant(s) and %d registered version(s) reset; %d legacy assistant marker(s) and %d version marker(s) demoted",
result.removed_registered_assistants,
result.removed_registered_versions,
result.demoted_assistants,
result.demoted_versions,
)
_prepare_locked_local_dev_runtime()
langgraph_app = FastAPI(
docs_url=None,
redoc_url=None,
openapi_url=None,
)

View File

@ -4,13 +4,16 @@
"dependencies": [
"."
],
"env": ".env",
"env": "../.env",
"graphs": {
"lead_agent": "deerflow.agents:make_lead_agent"
},
"auth": {
"path": "./app/gateway/langgraph_auth.py:auth"
},
"http": {
"app": "./app/gateway/langgraph_studio.py:langgraph_app"
},
"checkpointer": {
"path": "./packages/harness/deerflow/runtime/checkpointer/async_provider.py:make_checkpointer"
}

View File

@ -1,5 +1,10 @@
from typing import TYPE_CHECKING, Any
from .features import Next, Prev, RuntimeFeatures
if TYPE_CHECKING:
from langchain_core.runnables import RunnableConfig
__all__ = [
"create_deerflow_agent",
"RuntimeFeatures",
@ -12,23 +17,26 @@ __all__ = [
]
def make_lead_agent(config: "RunnableConfig") -> Any:
"""Build the lead graph while keeping package-root imports lightweight.
LangGraph Server resolves configured graph factories directly from a
module's ``__dict__``, so this entrypoint must be a concrete module-level
function rather than a value supplied only through ``__getattr__``.
"""
from .lead_agent import make_lead_agent as factory
from .lead_agent.prompt import prime_enabled_skills_cache
prime_enabled_skills_cache()
return factory(config)
def __getattr__(name: str):
if name == "create_deerflow_agent":
from .factory import create_deerflow_agent
globals()[name] = create_deerflow_agent
return create_deerflow_agent
if name == "make_lead_agent":
from .lead_agent import make_lead_agent
from .lead_agent.prompt import prime_enabled_skills_cache
# LangGraph resolves deerflow.agents:make_lead_agent when registering
# the graph. Prime at that explicit entrypoint instead of at package
# import time so lightweight submodules can be imported without pulling
# in the whole tool/subagent graph.
prime_enabled_skills_cache()
globals()[name] = make_lead_agent
return make_lead_agent
if name in {"DeltaThreadState", "SandboxState", "ThreadState"}:
from .thread_state import DeltaThreadState, SandboxState, ThreadState

View File

@ -28,7 +28,9 @@ dependencies = [
"langgraph>=1.2.9,<1.3",
"langgraph-api>=0.8.1",
"langgraph-cli>=0.4.24",
"langgraph-runtime-inmem>=0.28.0",
# Standalone Studio's pre-runtime persistence repair is validated against
# the 0.30.0 store lifecycle and must not resolve an older implementation.
"langgraph-runtime-inmem>=0.30.0",
"markdownify>=1.2.2",
"markitdown[all,xlsx]>=0.0.1a2",
"packaging>=24.2",

View File

@ -177,8 +177,17 @@ class _FakeUser:
self.display_name = identity
def _make_ctx(user_id):
return Auth.types.AuthContext(resource="threads", action="create", user=_FakeUser(user_id), permissions=[])
def _make_ctx(user_id, *, resource="threads", action="create", user=None):
return Auth.types.AuthContext(resource=resource, action=action, user=user or _FakeUser(user_id), permissions=[])
def _studio_ctx(*, resource="assistants", action="search"):
return _make_ctx(
"langgraph-studio-user",
resource=resource,
action=action,
user=Auth.types.StudioUser("langgraph-studio-user"),
)
def test_filter_injects_user_id():
@ -226,6 +235,121 @@ def test_filter_with_empty_metadata():
assert result == {"user_id": "user-z"}
@pytest.mark.parametrize("action", ["read", "search"])
def test_studio_user_assistant_discovery_includes_system_and_studio_owned_assistants(action):
value = {}
result = asyncio.run(add_owner_filter(_studio_ctx(action=action), value))
assert result == {
"$or": [
{"created_by": "system"},
{"user_id": "langgraph-studio-user"},
]
}
assert value == {}
@pytest.mark.parametrize("action", ["create", "update"])
def test_studio_assistant_writes_stamp_server_owned_provenance(action):
value = {"metadata": {"created_by": "system"}}
result = asyncio.run(add_owner_filter(_studio_ctx(action=action), value))
assert value["metadata"] == {
"created_by": "user",
"user_id": "langgraph-studio-user",
}
assert result == {"user_id": "langgraph-studio-user"}
def test_studio_user_non_assistant_operations_remain_owner_scoped():
value = {}
result = asyncio.run(
add_owner_filter(
_studio_ctx(resource="threads", action="search"),
value,
)
)
assert value["metadata"]["user_id"] == "langgraph-studio-user"
assert result == {"user_id": "langgraph-studio-user"}
def test_identity_string_does_not_impersonate_studio_user():
value = {}
result = asyncio.run(
add_owner_filter(
_make_ctx(
"langgraph-studio-user",
resource="assistants",
action="search",
),
value,
)
)
assert value["metadata"]["user_id"] == "langgraph-studio-user"
assert result == {"user_id": "langgraph-studio-user"}
def test_missing_studio_user_type_degrades_to_owner_scoped_behavior():
value = {}
with patch("app.gateway.langgraph_auth._STUDIO_USER_TYPE", None):
result = asyncio.run(add_owner_filter(_studio_ctx(), value))
assert value["metadata"]["user_id"] == "langgraph-studio-user"
assert result == {"user_id": "langgraph-studio-user"}
def test_regular_user_assistant_search_remains_owner_scoped():
value = {}
result = asyncio.run(
add_owner_filter(
_make_ctx("user-a", resource="assistants", action="search"),
value,
)
)
assert value["metadata"]["user_id"] == "user-a"
assert result == {"user_id": "user-a"}
@pytest.mark.parametrize("action", ["create", "update"])
def test_regular_user_cannot_forge_system_assistant_provenance(action):
value = {"metadata": {"created_by": "system", "label": "forged"}}
result = asyncio.run(
add_owner_filter(
_make_ctx("user-a", resource="assistants", action=action),
value,
)
)
assert value["metadata"] == {
"created_by": "user",
"label": "forged",
"user_id": "user-a",
}
assert result == {"user_id": "user-a"}
@pytest.mark.parametrize(
"ctx,user_id",
[
(_studio_ctx(action="update"), "langgraph-studio-user"),
(_make_ctx("user-a", resource="assistants", action="update"), "user-a"),
],
)
def test_assistant_version_selection_remains_owner_scoped(ctx, user_id):
value = {"assistant_id": uuid4(), "version": 1}
result = asyncio.run(add_owner_filter(ctx, value))
assert value["metadata"] == {
"created_by": "user",
"user_id": user_id,
}
assert result == {"user_id": user_id}
# ── Gateway parity ───────────────────────────────────────────────────────

View File

@ -0,0 +1,67 @@
"""Regression coverage for the standalone LangGraph graph entrypoint."""
from __future__ import annotations
import subprocess
import sys
import textwrap
from pathlib import Path
BACKEND_DIR = Path(__file__).resolve().parents[1]
def test_langgraph_config_loads_the_repository_environment_file():
"""Standalone Studio should reuse the root environment used by DeerFlow."""
import json
config = json.loads((BACKEND_DIR / "langgraph.json").read_text(encoding="utf-8"))
assert (BACKEND_DIR / config["env"]).resolve() == BACKEND_DIR.parent / ".env"
def test_langgraph_graph_factory_is_a_concrete_lazy_module_export():
"""Match LangGraph Server's direct ``module.__dict__`` entrypoint lookup."""
script = textwrap.dedent(
"""
import importlib
import json
import sys
from pathlib import Path
from types import ModuleType
config = json.loads(Path("langgraph.json").read_text(encoding="utf-8"))
module_name, variable_name = config["graphs"]["lead_agent"].split(":", 1)
module = importlib.import_module(module_name)
factory = module.__dict__.get(variable_name)
assert callable(factory), (
f"{module_name}:{variable_name} must be a concrete module export "
"for LangGraph Server"
)
assert "deerflow.agents.lead_agent" not in sys.modules, (
"publishing the graph factory must keep heavyweight agent imports lazy"
)
calls = []
fake_lead_agent = ModuleType("deerflow.agents.lead_agent")
fake_lead_agent.__path__ = []
fake_lead_agent.make_lead_agent = lambda config: calls.append(("factory", config)) or "graph"
fake_prompt = ModuleType("deerflow.agents.lead_agent.prompt")
fake_prompt.prime_enabled_skills_cache = lambda: calls.append(("prime", None))
sys.modules[fake_lead_agent.__name__] = fake_lead_agent
sys.modules[fake_prompt.__name__] = fake_prompt
config_arg = {"configurable": {"thread_id": "test-thread"}}
assert factory(config_arg) == "graph"
assert calls == [("prime", None), ("factory", config_arg)]
"""
)
result = subprocess.run(
[sys.executable, "-c", script],
cwd=BACKEND_DIR,
capture_output=True,
text=True,
)
assert result.returncode == 0, result.stderr

View File

@ -0,0 +1,200 @@
"""Tests for pre-runtime standalone Studio provenance repair."""
from __future__ import annotations
import json
import logging
from pathlib import Path
from uuid import NAMESPACE_DNS, uuid4, uuid5
from app.gateway.langgraph_studio import (
configured_system_assistant_ids,
repair_local_dev_persistence_before_runtime,
repair_persisted_assistant_provenance,
)
def test_configured_system_assistant_ids_are_deterministic():
namespace = uuid4()
graphs = json.dumps(
{
"alpha": "./graph.py:alpha",
"beta": "./graph.py:beta",
}
)
assert configured_system_assistant_ids(graphs, namespace=namespace) == {
str(uuid5(namespace, "alpha")),
str(uuid5(namespace, "beta")),
}
def test_configured_system_assistant_ids_skip_missing_or_empty_registry():
assert configured_system_assistant_ids(None, namespace=NAMESPACE_DNS) == set()
assert configured_system_assistant_ids("{}", namespace=NAMESPACE_DNS) == set()
def test_pre_runtime_repair_preserves_all_legacy_user_rows_and_versions():
system_id = str(uuid4())
forged_ids = [str(uuid4()) for _ in range(4)]
ordinary_id = str(uuid4())
store = {
"assistants": [
{
"assistant_id": system_id,
"metadata": {"created_by": "system"},
},
*[
{
"assistant_id": assistant_id,
"metadata": {
"created_by": "system",
"user_id": "langgraph-studio-user",
},
}
for assistant_id in forged_ids
],
{
"assistant_id": ordinary_id,
"metadata": {"created_by": "user", "user_id": "owner"},
},
],
"assistant_versions": [
{
"assistant_id": system_id,
"version": 1,
"metadata": {"created_by": "system"},
},
*[
{
"assistant_id": assistant_id,
"version": version,
"metadata": {
"created_by": "system",
"user_id": "langgraph-studio-user",
},
}
for assistant_id in forged_ids
for version in (1, 2)
],
{
"assistant_id": ordinary_id,
"version": 1,
"metadata": {"created_by": "user", "user_id": "owner"},
},
],
}
result = repair_persisted_assistant_provenance(
store,
registered_system_ids={system_id},
)
assert result.removed_registered_assistants == 1
assert result.removed_registered_versions == 1
assert result.demoted_assistants == 4
assert result.demoted_versions == 8
assert [str(row["assistant_id"]) for row in store["assistants"]] == [*forged_ids, ordinary_id]
assert len(store["assistant_versions"]) == 9
assert all(row["metadata"]["created_by"] == "user" for row in store["assistants"])
assert all(row["metadata"]["created_by"] == "user" for row in store["assistant_versions"])
def test_pre_runtime_repair_replaces_registered_id_even_with_forged_metadata():
system_id = str(uuid4())
store = {
"assistants": [
{
"assistant_id": system_id,
"metadata": {
"created_by": "user",
"user_id": "attacker",
},
}
],
"assistant_versions": [
{
"assistant_id": system_id,
"version": 1,
"metadata": {
"created_by": "user",
"user_id": "attacker",
},
}
],
}
result = repair_persisted_assistant_provenance(
store,
registered_system_ids={system_id},
)
assert result.removed_registered_assistants == 1
assert result.removed_registered_versions == 1
assert store == {"assistants": [], "assistant_versions": []}
def test_pre_runtime_repair_skips_when_no_system_assistants_are_configured():
marked_id = str(uuid4())
store = {
"assistants": [
{
"assistant_id": marked_id,
"metadata": {"created_by": "system"},
}
],
"assistant_versions": [],
}
original = {
"assistants": [dict(store["assistants"][0])],
"assistant_versions": [],
}
result = repair_persisted_assistant_provenance(
store,
registered_system_ids=set(),
)
assert not result.changed
assert store == original
def test_pre_runtime_repair_warns_when_registered_ids_match_no_persisted_rows(
tmp_path: Path,
caplog,
monkeypatch,
):
from langgraph.checkpoint.memory import PersistentDict
persistence_path = tmp_path / ".langgraph_ops.pckl"
store = PersistentDict(dict, filename=str(persistence_path))
store["assistants"] = [
{
"assistant_id": str(uuid4()),
"metadata": {
"created_by": "system",
"user_id": "langgraph-studio-user",
},
}
]
store["assistant_versions"] = []
store.sync()
monkeypatch.setattr(
"app.gateway.langgraph_studio.configured_system_assistant_ids",
lambda _graphs_json: {"registered-assistant-id"},
)
with caplog.at_level(logging.WARNING, logger="app.gateway.langgraph_studio"):
result = repair_local_dev_persistence_before_runtime(
persistence_path=persistence_path,
graphs_json=json.dumps({"registered_graph": "./graph.py:graph"}),
)
assert result.demoted_assistants == 1
assert "matched no persisted registered assistant rows" in caplog.text
def test_langgraph_config_loads_the_pre_runtime_studio_app():
config = json.loads((Path(__file__).resolve().parents[1] / "langgraph.json").read_text(encoding="utf-8"))
assert config["http"]["app"].endswith("app/gateway/langgraph_studio.py:langgraph_app")

View File

@ -0,0 +1,379 @@
"""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"

2
backend/uv.lock generated
View File

@ -1027,7 +1027,7 @@ requires-dist = [
{ name = "langgraph-checkpoint-postgres", marker = "extra == 'postgres'", specifier = ">=3.1.1,<3.2" },
{ name = "langgraph-checkpoint-sqlite", specifier = ">=3.1.1,<3.2" },
{ name = "langgraph-cli", specifier = ">=0.4.24" },
{ name = "langgraph-runtime-inmem", specifier = ">=0.28.0" },
{ name = "langgraph-runtime-inmem", specifier = ">=0.30.0" },
{ name = "langgraph-sdk", specifier = ">=0.1.51" },
{ name = "markdownify", specifier = ">=1.2.2" },
{ name = "markitdown", extras = ["all", "xlsx"], specifier = ">=0.0.1a2" },