diff --git a/README.md b/README.md index 82aff9c22..04097727b 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/backend/AGENTS.md b/backend/AGENTS.md index fe5bdf7f9..dc1cfd876 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -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 diff --git a/backend/README.md b/backend/README.md index 2c4571f0e..c824f66ee 100644 --- a/backend/README.md +++ b/backend/README.md @@ -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 diff --git a/backend/app/gateway/AGENTS.md b/backend/app/gateway/AGENTS.md index 84026cb5b..a3f0eb65b 100644 --- a/backend/app/gateway/AGENTS.md +++ b/backend/app/gateway/AGENTS.md @@ -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 | diff --git a/backend/app/gateway/langgraph_auth.py b/backend/app/gateway/langgraph_auth.py index 3ab3d2070..8143d5e3f 100644 --- a/backend/app/gateway/langgraph_auth.py +++ b/backend/app/gateway/langgraph_auth.py @@ -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} diff --git a/backend/app/gateway/langgraph_studio.py b/backend/app/gateway/langgraph_studio.py new file mode 100644 index 000000000..21329a67f --- /dev/null +++ b/backend/app/gateway/langgraph_studio.py @@ -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, +) diff --git a/backend/langgraph.json b/backend/langgraph.json index 8ecba0381..7ed842ce8 100644 --- a/backend/langgraph.json +++ b/backend/langgraph.json @@ -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" } diff --git a/backend/packages/harness/deerflow/agents/__init__.py b/backend/packages/harness/deerflow/agents/__init__.py index 5e75e90f3..73cc7fbc7 100644 --- a/backend/packages/harness/deerflow/agents/__init__.py +++ b/backend/packages/harness/deerflow/agents/__init__.py @@ -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 diff --git a/backend/packages/harness/pyproject.toml b/backend/packages/harness/pyproject.toml index 479fb3ba4..56997177e 100644 --- a/backend/packages/harness/pyproject.toml +++ b/backend/packages/harness/pyproject.toml @@ -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", diff --git a/backend/tests/test_langgraph_auth.py b/backend/tests/test_langgraph_auth.py index 1d2d71e7c..9a0e6af5b 100644 --- a/backend/tests/test_langgraph_auth.py +++ b/backend/tests/test_langgraph_auth.py @@ -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 ─────────────────────────────────────────────────────── diff --git a/backend/tests/test_langgraph_graph_entrypoint.py b/backend/tests/test_langgraph_graph_entrypoint.py new file mode 100644 index 000000000..a4572e1d9 --- /dev/null +++ b/backend/tests/test_langgraph_graph_entrypoint.py @@ -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 diff --git a/backend/tests/test_langgraph_studio_lifespan.py b/backend/tests/test_langgraph_studio_lifespan.py new file mode 100644 index 000000000..8f37f83a1 --- /dev/null +++ b/backend/tests/test_langgraph_studio_lifespan.py @@ -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") diff --git a/backend/tests/test_langgraph_studio_routes.py b/backend/tests/test_langgraph_studio_routes.py new file mode 100644 index 000000000..403b99747 --- /dev/null +++ b/backend/tests/test_langgraph_studio_routes.py @@ -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" diff --git a/backend/uv.lock b/backend/uv.lock index a4a9aa18c..608dc8d1b 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -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" },