mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-09 21:49:37 +00:00
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:
parent
3a967d4f9a
commit
432c09f6b0
28
README.md
28
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.
|
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
|
For workflows that invoke `backend/langgraph.json` through LangGraph Studio or
|
||||||
a direct LangGraph Server, DeerFlow consumes the authenticated identity
|
a direct LangGraph Server, DeerFlow consumes the authenticated identity
|
||||||
published by that runtime and uses it for custom-agent configuration/SOUL, user
|
published by that runtime and uses it for custom-agent configuration/SOUL, user
|
||||||
|
|||||||
@ -152,10 +152,14 @@ from deerflow.config import get_app_config
|
|||||||
```
|
```
|
||||||
|
|
||||||
Package import hygiene: the `deerflow.agents` and `deerflow.subagents` package
|
Package import hygiene: the `deerflow.agents` and `deerflow.subagents` package
|
||||||
roots expose heavyweight graph/executor entrypoints lazily. Internal modules
|
roots expose heavyweight graph/executor entrypoints lazily. The
|
||||||
that only need lightweight types, config, or registries should import the
|
`deerflow.agents:make_lead_agent` LangGraph Server entrypoint is a concrete thin
|
||||||
concrete submodule instead of adding eager package-root imports that pull in the
|
module-level function because the server resolves graph factories directly from
|
||||||
tool graph or subagent executor during state/schema imports.
|
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
|
## Development Workflow
|
||||||
|
|
||||||
|
|||||||
@ -278,6 +278,27 @@ backend/
|
|||||||
deployments run the Gateway embedded runtime; the file is kept for LangGraph
|
deployments run the Gateway embedded runtime; the file is kept for LangGraph
|
||||||
tooling, Studio, or direct LangGraph Server compatibility.
|
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
|
## Configuration
|
||||||
|
|||||||
@ -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.
|
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**:
|
**Routers**:
|
||||||
|
|
||||||
| Router | Endpoints |
|
| Router | Endpoints |
|
||||||
|
|||||||
@ -25,6 +25,11 @@ from app.gateway.deps import get_local_provider
|
|||||||
|
|
||||||
auth = Auth()
|
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).
|
# Methods that require CSRF validation (state-changing per RFC 7231).
|
||||||
_CSRF_METHODS = frozenset({"POST", "PUT", "DELETE", "PATCH"})
|
_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``.
|
Gateway stores thread ownership as ``metadata.user_id``.
|
||||||
This handler ensures LangGraph Server enforces the same isolation.
|
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 = value.setdefault("metadata", {})
|
||||||
metadata["user_id"] = ctx.user.identity
|
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 filter dict — LangGraph applies it to search/read/delete
|
||||||
return {"user_id": ctx.user.identity}
|
return {"user_id": ctx.user.identity}
|
||||||
|
|||||||
185
backend/app/gateway/langgraph_studio.py
Normal file
185
backend/app/gateway/langgraph_studio.py
Normal 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,
|
||||||
|
)
|
||||||
@ -4,13 +4,16 @@
|
|||||||
"dependencies": [
|
"dependencies": [
|
||||||
"."
|
"."
|
||||||
],
|
],
|
||||||
"env": ".env",
|
"env": "../.env",
|
||||||
"graphs": {
|
"graphs": {
|
||||||
"lead_agent": "deerflow.agents:make_lead_agent"
|
"lead_agent": "deerflow.agents:make_lead_agent"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"path": "./app/gateway/langgraph_auth.py:auth"
|
"path": "./app/gateway/langgraph_auth.py:auth"
|
||||||
},
|
},
|
||||||
|
"http": {
|
||||||
|
"app": "./app/gateway/langgraph_studio.py:langgraph_app"
|
||||||
|
},
|
||||||
"checkpointer": {
|
"checkpointer": {
|
||||||
"path": "./packages/harness/deerflow/runtime/checkpointer/async_provider.py:make_checkpointer"
|
"path": "./packages/harness/deerflow/runtime/checkpointer/async_provider.py:make_checkpointer"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,10 @@
|
|||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from .features import Next, Prev, RuntimeFeatures
|
from .features import Next, Prev, RuntimeFeatures
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from langchain_core.runnables import RunnableConfig
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"create_deerflow_agent",
|
"create_deerflow_agent",
|
||||||
"RuntimeFeatures",
|
"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):
|
def __getattr__(name: str):
|
||||||
if name == "create_deerflow_agent":
|
if name == "create_deerflow_agent":
|
||||||
from .factory import create_deerflow_agent
|
from .factory import create_deerflow_agent
|
||||||
|
|
||||||
globals()[name] = create_deerflow_agent
|
globals()[name] = create_deerflow_agent
|
||||||
return 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"}:
|
if name in {"DeltaThreadState", "SandboxState", "ThreadState"}:
|
||||||
from .thread_state import DeltaThreadState, SandboxState, ThreadState
|
from .thread_state import DeltaThreadState, SandboxState, ThreadState
|
||||||
|
|
||||||
|
|||||||
@ -28,7 +28,9 @@ dependencies = [
|
|||||||
"langgraph>=1.2.9,<1.3",
|
"langgraph>=1.2.9,<1.3",
|
||||||
"langgraph-api>=0.8.1",
|
"langgraph-api>=0.8.1",
|
||||||
"langgraph-cli>=0.4.24",
|
"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",
|
"markdownify>=1.2.2",
|
||||||
"markitdown[all,xlsx]>=0.0.1a2",
|
"markitdown[all,xlsx]>=0.0.1a2",
|
||||||
"packaging>=24.2",
|
"packaging>=24.2",
|
||||||
|
|||||||
@ -177,8 +177,17 @@ class _FakeUser:
|
|||||||
self.display_name = identity
|
self.display_name = identity
|
||||||
|
|
||||||
|
|
||||||
def _make_ctx(user_id):
|
def _make_ctx(user_id, *, resource="threads", action="create", user=None):
|
||||||
return Auth.types.AuthContext(resource="threads", action="create", user=_FakeUser(user_id), permissions=[])
|
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():
|
def test_filter_injects_user_id():
|
||||||
@ -226,6 +235,121 @@ def test_filter_with_empty_metadata():
|
|||||||
assert result == {"user_id": "user-z"}
|
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 ───────────────────────────────────────────────────────
|
# ── Gateway parity ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
67
backend/tests/test_langgraph_graph_entrypoint.py
Normal file
67
backend/tests/test_langgraph_graph_entrypoint.py
Normal 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
|
||||||
200
backend/tests/test_langgraph_studio_lifespan.py
Normal file
200
backend/tests/test_langgraph_studio_lifespan.py
Normal 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")
|
||||||
379
backend/tests/test_langgraph_studio_routes.py
Normal file
379
backend/tests/test_langgraph_studio_routes.py
Normal 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
2
backend/uv.lock
generated
@ -1027,7 +1027,7 @@ requires-dist = [
|
|||||||
{ name = "langgraph-checkpoint-postgres", marker = "extra == 'postgres'", specifier = ">=3.1.1,<3.2" },
|
{ 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-checkpoint-sqlite", specifier = ">=3.1.1,<3.2" },
|
||||||
{ name = "langgraph-cli", specifier = ">=0.4.24" },
|
{ 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 = "langgraph-sdk", specifier = ">=0.1.51" },
|
||||||
{ name = "markdownify", specifier = ">=1.2.2" },
|
{ name = "markdownify", specifier = ">=1.2.2" },
|
||||||
{ name = "markitdown", extras = ["all", "xlsx"], specifier = ">=0.0.1a2" },
|
{ name = "markitdown", extras = ["all", "xlsx"], specifier = ">=0.0.1a2" },
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user