From 5f479f1af59ca1e8b3a88b046f8531149e30a7a2 Mon Sep 17 00:00:00 2001 From: John Kennedy <65985482+jkennedyvz@users.noreply.github.com> Date: Tue, 11 Aug 2026 09:39:33 -0700 Subject: [PATCH] fix(cli): preserve hosted Studio URL overrides --- libs/cli/langgraph_cli/cli.py | 90 ++++++++++++++++++++++- libs/cli/tests/unit_tests/cli/test_cli.py | 79 +++++++++++++++++++- 2 files changed, 167 insertions(+), 2 deletions(-) diff --git a/libs/cli/langgraph_cli/cli.py b/libs/cli/langgraph_cli/cli.py index 98189a59c..e787d5ddb 100644 --- a/libs/cli/langgraph_cli/cli.py +++ b/libs/cli/langgraph_cli/cli.py @@ -5,6 +5,7 @@ import pathlib import shutil import sys from collections.abc import Sequence +from urllib.parse import SplitResult, urlencode, urlsplit, urlunsplit import click import click.exceptions @@ -231,6 +232,66 @@ cli.add_command(deploy) # --------------------------------------------------------------------------- +def _validated_http_url(value: str, option_name: str) -> SplitResult: + try: + parsed = urlsplit(value) + hostname = parsed.hostname + _ = parsed.port + except ValueError as exc: + raise click.UsageError( + f"{option_name} must be a valid HTTP(S) URL without credentials." + ) from exc + + if ( + value != value.strip() + or parsed.scheme not in {"http", "https"} + or not parsed.netloc + or not hostname + or parsed.username is not None + or parsed.password is not None + ): + raise click.UsageError( + f"{option_name} must be a valid HTTP(S) URL without credentials." + ) + return parsed + + +def _studio_link( + *, + port: int, + studio_url: str | None, + api_url: str | None, + debugger_base_url: str | None, +) -> str: + if debugger_base_url is not None: + if api_url is not None and api_url != debugger_base_url: + raise click.UsageError( + "--api-url and --debugger-base-url cannot specify different URLs." + ) + click.echo( + "Warning: --debugger-base-url is deprecated; use --api-url instead.", + err=True, + ) + api_url = debugger_base_url + + studio_url = "https://smith.langchain.com" if studio_url is None else studio_url + api_url = f"http://127.0.0.1:{port}" if api_url is None else api_url + studio_parts = _validated_http_url(studio_url, "--studio-url") + _validated_http_url(api_url, "--api-url") + if studio_parts.query or studio_parts.fragment: + raise click.UsageError( + "--studio-url must not include a query string or fragment." + ) + + studio_path = f"{studio_parts.path.rstrip('/')}/studio/" + return urlunsplit( + studio_parts._replace( + path=studio_path, + query=urlencode({"baseUrl": api_url}), + ) + ) + + @OPT_RECREATE @OPT_PULL @OPT_PORT @@ -241,6 +302,24 @@ cli.add_command(deploy) @OPT_POSTGRES_URI @OPT_API_VERSION @OPT_ENGINE_RUNTIME_MODE +@click.option( + "--studio-url", + type=str, + default=None, + help="URL of the LangGraph Studio instance. Defaults to https://smith.langchain.com", +) +@click.option( + "--api-url", + type=str, + default=None, + help="URL that LangGraph Studio uses to access the API. Defaults to http://127.0.0.1:[PORT]", +) +@click.option( + "--debugger-base-url", + type=str, + default=None, + hidden=True, +) @click.option( "--image", type=str, @@ -274,9 +353,18 @@ def up( postgres_uri: str | None, api_version: str | None, engine_runtime_mode: str, + studio_url: str | None, + api_url: str | None, + debugger_base_url: str | None, image: str | None, base_image: str | None, ): + studio_link = _studio_link( + port=port, + studio_url=studio_url, + api_url=api_url, + debugger_base_url=debugger_base_url, + ) click.secho("Starting LangGraph API server...", fg="green") click.secho( """For local dev, requires env var LANGSMITH_API_KEY with access to LangSmith Deployment. @@ -325,7 +413,7 @@ For production use, requires a license key in env var LANGGRAPH_CLOUD_LICENSE_KE f"""Ready! - API: http://localhost:{port} - Docs: http://localhost:{port}/docs -- LangGraph Studio: https://smith.langchain.com/studio/?baseUrl=http://127.0.0.1:{port} +- LangGraph Studio: {studio_link} """ ) sys.stdout.flush() diff --git a/libs/cli/tests/unit_tests/cli/test_cli.py b/libs/cli/tests/unit_tests/cli/test_cli.py index 2e32d9e78..dfa54951c 100644 --- a/libs/cli/tests/unit_tests/cli/test_cli.py +++ b/libs/cli/tests/unit_tests/cli/test_cli.py @@ -8,10 +8,11 @@ from contextlib import contextmanager from pathlib import Path import click +import pytest from click.testing import CliRunner import langgraph_cli.deploy as deploy_module -from langgraph_cli.cli import cli, prepare_args_and_stdin +from langgraph_cli.cli import _studio_link, cli, prepare_args_and_stdin from langgraph_cli.config import Config, _get_pip_cleanup_lines, validate_config from langgraph_cli.docker import DEFAULT_POSTGRES_URI, DockerCapabilities, Version from langgraph_cli.util import clean_empty_lines @@ -261,6 +262,82 @@ def test_version_option() -> None: ) +def test_up_help_shows_hosted_studio_options() -> None: + result = CliRunner().invoke(cli, ["up", "--help"]) + + assert result.exit_code == 0, result.output + assert "--studio-url" in result.output + assert "--api-url" in result.output + assert "--debugger-port" not in result.output + assert "--debugger-base-url" not in result.output + + +def test_studio_link_defaults_to_hosted_studio() -> None: + assert _studio_link( + port=8123, + studio_url=None, + api_url=None, + debugger_base_url=None, + ) == ("https://smith.langchain.com/studio/?baseUrl=http%3A%2F%2F127.0.0.1%3A8123") + + +def test_studio_link_supports_self_hosted_and_remote_urls() -> None: + assert _studio_link( + port=8123, + studio_url="https://langsmith.example.com/prefix/", + api_url="https://api.example.com/graph?tenant=a®ion=eu", + debugger_base_url=None, + ) == ( + "https://langsmith.example.com/prefix/studio/" + "?baseUrl=https%3A%2F%2Fapi.example.com%2Fgraph%3Ftenant%3Da%26region%3Deu" + ) + + +def test_studio_link_supports_deprecated_debugger_base_url(capsys) -> None: + assert _studio_link( + port=8123, + studio_url=None, + api_url=None, + debugger_base_url="https://api.example.com", + ).endswith("?baseUrl=https%3A%2F%2Fapi.example.com") + assert "--debugger-base-url is deprecated; use --api-url" in capsys.readouterr().err + + +@pytest.mark.parametrize( + ("studio_url", "api_url"), + [ + ("javascript:alert(1)", None), + ("https://user:password@example.com", None), + ("https://smith.langchain.com?workspace=test", None), + (None, "file:///tmp/langgraph.sock"), + (None, "https://user:password@example.com"), + ], +) +def test_studio_link_rejects_unsafe_urls( + studio_url: str | None, api_url: str | None +) -> None: + with pytest.raises(click.UsageError): + _studio_link( + port=8123, + studio_url=studio_url, + api_url=api_url, + debugger_base_url=None, + ) + + +def test_studio_link_rejects_conflicting_api_url_aliases() -> None: + with pytest.raises( + click.UsageError, + match="cannot specify different URLs", + ): + _studio_link( + port=8123, + studio_url=None, + api_url="https://api.example.com", + debugger_base_url="https://other.example.com", + ) + + def test_top_level_help_shows_deploy_subcommands() -> None: runner = CliRunner()