From 526c571b103751e3d8775af42fb67fcd625e1715 Mon Sep 17 00:00:00 2001 From: X-iZhang Date: Thu, 11 Jun 2026 00:23:55 +0100 Subject: [PATCH] feat(tunnel): add Cloudflare tunnel support for `EvoSci deploy` and update documentation --- EvoScientist/deploy/server.py | 35 ++++++ EvoScientist/langgraph_dev/manager.py | 63 +++++++++++ README.md | 2 +- README.zh-CN.md | 2 +- tests/test_cli_deploy.py | 31 +++++- tests/test_langgraph_dev_deploy_mode.py | 27 +++++ tests/test_langgraph_manager.py | 140 ++++++++++++++++++++++++ 7 files changed, 297 insertions(+), 3 deletions(-) diff --git a/EvoScientist/deploy/server.py b/EvoScientist/deploy/server.py index 07a246a..2af9a0b 100644 --- a/EvoScientist/deploy/server.py +++ b/EvoScientist/deploy/server.py @@ -46,6 +46,12 @@ def deploy( "--port", help="Port for langgraph dev (default: config.langgraph_dev_port = 6174)", ), + tunnel: bool = typer.Option( + False, + "--tunnel", + help="Expose the server over a public Cloudflare tunnel (no auth — " + "anyone with the URL can drive the agent; trusted use only)", + ), debug: bool = typer.Option( False, "--debug", @@ -64,6 +70,7 @@ def deploy( RUNTIME, _is_port_occupied, is_langgraph_dev_running, + read_tunnel_url, start_langgraph_dev, stop_langgraph_dev, ) @@ -155,6 +162,13 @@ def deploy( f"[bold red]{DANGEROUS_BANNER_MESSAGE}[/bold red]" ) + if tunnel: + console.print( + "[bold white on red] ⚠ PUBLIC TUNNEL [/bold white on red] " + "[bold red]Public URL, no auth — share only with people you " + "trust.[/bold red]" + ) + # 6. ccproxy lifecycle (only if any provider uses OAuth) _ccproxy_proc = None if config.anthropic_auth_mode == "oauth" or config.openai_auth_mode == "oauth": @@ -186,6 +200,7 @@ def deploy( file_persistence=file_persistence, jobs_per_worker=jobs_per_worker, deploy_mode=True, + tunnel=tunnel, ) atexit.register(stop_langgraph_dev, proc) except Exception as exc: @@ -196,13 +211,33 @@ def deploy( # here, the subprocess is up. console.print("[green]✓[/green] langgraph dev ready") + # 8b. Cloudflare tunnel URL — the local server is healthy, but cloudflared + # establishes the public tunnel a few seconds later and prints the random + # URL into the log. Poll for it so we can surface it in the ready banner. + public_url: str | None = None + if tunnel: + with console.status( + "[dim]Waiting for Cloudflare tunnel URL...[/dim]", spinner="dots" + ): + public_url = read_tunnel_url() + if public_url: + console.print("[green]✓[/green] tunnel up") + else: + console.print( + "[yellow]⚠ Tunnel URL not detected within the wait window. " + f"Check the log ({_shorten(str(RUNTIME.log_file))}) for a " + "trycloudflare.com URL.[/yellow]" + ) + # 9. Ready banner log_hint = _shorten(str(RUNTIME.log_file)) + public_line = f"[bold]Public URL:[/bold] {public_url}\n" if public_url else "" console.print( Panel( Text.from_markup( f"[bold]Endpoint:[/bold] " f"http://localhost:{effective_port}\n" + f"{public_line}" f"[bold]Assistant ID:[/bold] EvoScientist\n" f"[bold]Connect via:[/bold] any LangChain SDK / " f"LangGraph-compatible UI\n" diff --git a/EvoScientist/langgraph_dev/manager.py b/EvoScientist/langgraph_dev/manager.py index 1de2731..fb0552c 100644 --- a/EvoScientist/langgraph_dev/manager.py +++ b/EvoScientist/langgraph_dev/manager.py @@ -15,6 +15,7 @@ import atexit import json import logging import os +import re import shutil import subprocess import threading @@ -253,6 +254,16 @@ _PROCESS: subprocess.Popen | None = None # sub-agents' cwd / EVOSCIENTIST_WORKSPACE_DIR env match the new workspace. _PROCESS_WORKSPACE: Path | None = None +# Byte offset into ``RUNTIME.log_file`` captured the instant before the current +# subprocess was spawned. ``read_tunnel_url`` scans only bytes written after +# this point so a stale ``trycloudflare.com`` URL from a previous (appended, +# not-yet-rotated) session can never be misreported as the live tunnel. +_LOG_OFFSET_AT_START: int = 0 + +# Cloudflare quick-tunnel public URL, as printed by cloudflared into the +# langgraph dev log. Mirrors langgraph_api/tunneling/cloudflare.py. +_TUNNEL_URL_RE = re.compile(r"https://[A-Za-z0-9.-]+\.trycloudflare\.com") + # Whether async sub-agents are usable in this process. # # - CLI / serve parent process: starts False; flipped True after @@ -525,6 +536,7 @@ def start_langgraph_dev( file_persistence: bool = True, jobs_per_worker: int = 10, deploy_mode: bool = False, + tunnel: bool = False, ) -> subprocess.Popen: """Start langgraph dev as a background subprocess. @@ -538,6 +550,14 @@ def start_langgraph_dev( ``.langgraph_api/`` cache so async-task / Store / scheduler state survives subprocess restarts. Set False to suppress periodic flushes (workspace stays cleaner; state is in-memory only). + jobs_per_worker: Concurrent runs per worker (``--n-jobs-per-worker``). + deploy_mode: When True, the subprocess loads full MCP + async + sub-agents (``EVOSCIENTIST_DEPLOY_MODE=full``); otherwise stripped. + tunnel: When True, pass ``--tunnel`` so langgraph dev exposes the + server over a public Cloudflare quick-tunnel. The random + ``*.trycloudflare.com`` URL is written to the log; read it back + with :func:`read_tunnel_url`. SECURITY: the tunnel has no auth and + the deployed agent can run shell — only enable for trusted use. Returns: The Popen handle for the langgraph dev process. @@ -626,6 +646,14 @@ def start_langgraph_dev( # one fd — a problem on heavy ``/resume`` cycling that could eventually # exhaust the process's open-file limit. log_handle = open(RUNTIME.log_file, "ab") # closed in finally below + # Remember where this session's output begins so ``read_tunnel_url`` only + # scans lines this subprocess writes — never a stale URL left in the + # appended-to log by a previous tunnel session. + global _LOG_OFFSET_AT_START + try: + _LOG_OFFSET_AT_START = RUNTIME.log_file.stat().st_size + except OSError: + _LOG_OFFSET_AT_START = 0 # Propagate workspace to the subprocess so deployed sub-agents resolve # paths.WORKSPACE_ROOT to the same dir as the CLI's main agent. cwd alone @@ -685,6 +713,7 @@ def start_langgraph_dev( str(jobs_per_worker), "--no-browser", "--no-reload", + *(["--tunnel"] if tunnel else []), ], cwd=str(workspace_dir), stdout=log_handle, @@ -741,6 +770,40 @@ def start_langgraph_dev( ) +def read_tunnel_url(timeout: float = 35.0, poll_interval: float = 0.5) -> str | None: + """Poll the langgraph dev log for the Cloudflare quick-tunnel public URL. + + Started with ``tunnel=True``, langgraph dev shells out to cloudflared, + which prints a random ``https://.trycloudflare.com`` URL once the + tunnel is established — typically a few seconds after the local server is + already healthy. We scan only the bytes written since this subprocess + started (``_LOG_OFFSET_AT_START``) so a stale URL from an earlier session + in the same appended-to log is never returned. + + Args: + timeout: Max seconds to wait for the URL to appear. cloudflared may + also need to download its binary on first use, so the default is + generous (langgraph_api itself waits up to 30s internally). + poll_interval: Seconds between log re-reads. + + Returns: + The public tunnel URL, or ``None`` if it never appeared in time. + """ + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + try: + with open(RUNTIME.log_file, "rb") as fh: + fh.seek(_LOG_OFFSET_AT_START) + chunk = fh.read().decode("utf-8", errors="replace") + except OSError: + chunk = "" + match = _TUNNEL_URL_RE.search(chunk) + if match: + return match.group(0) + time.sleep(poll_interval) + return None + + def stop_langgraph_dev(proc: subprocess.Popen | None = None) -> None: """Gracefully stop a langgraph dev process. diff --git a/README.md b/README.md index 680e1a9..8e2d69a 100644 --- a/README.md +++ b/README.md @@ -149,7 +149,7 @@ Moving beyond traditional human-in-the-loop systems, EvoScientist adopts a human
📦 Release Highlights — version changelog -- **[10 Jun 2026]** **[v0.1.5](https://github.com/EvoScientist/EvoScientist/releases/tag/v0.1.5)** — Dangerous mode (real-filesystem access with safety checks), LangGraph streaming v3 pipeline, opt-in Anthropic prompt caching via OpenRouter, claude-fable-5, free-scrolling TUI, Windows CI support. +- **[11 Jun 2026]** **[v0.1.5](https://github.com/EvoScientist/EvoScientist/releases/tag/v0.1.5)** — Dangerous mode (real-filesystem access with safety checks), LangGraph streaming v3 pipeline, opt-in Anthropic prompt caching via OpenRouter, claude-fable-5, free-scrolling TUI, Windows CI support, public Cloudflare tunnel for `EvoSci deploy` (`--tunnel`). - **[07 Jun 2026]** **[v0.1.4](https://github.com/EvoScientist/EvoScientist/releases/tag/v0.1.4)** — Auxiliary model for background tasks & tool selection, observation-memory lifecycle, Qwen3.7-Max/Plus (DashScope), UI-backend selection, plus an OpenRouter multi-turn reasoning fix. - **[03 Jun 2026]** **[v0.1.3](https://github.com/EvoScientist/EvoScientist/releases/tag/v0.1.3)** — Multimodal handling (image + PDF/doc flatten/hoisting, text-only model fallback), runtime-context middleware, memory middleware → profile files with stream timeline narration, textual CJK-input fix. - **[02 Jun 2026]** **[v0.1.2](https://github.com/EvoScientist/EvoScientist/releases/tag/v0.1.2)** — Browser WebUI mode, `EvoSci deploy` standalone LangGraph server, default model → claude-sonnet-4-6, MiniMax M3, plus sandbox-timeout and async-notifier channel-routing fixes. diff --git a/README.zh-CN.md b/README.zh-CN.md index ba31bf4..aad216b 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -158,7 +158,7 @@ EvoScientist 超越了传统的人在回路(Human-in-the-Loop)模式,采
📦 版本更新摘要(changelog) -- **[2026 年 6 月 10 日]** **[v0.1.5](https://github.com/EvoScientist/EvoScientist/releases/tag/v0.1.5)** — Dangerous 模式(带安全检查的真实文件系统访问)、LangGraph streaming v3 流水线、OpenRouter Anthropic prompt caching(可选启用)、claude-fable-5、TUI 自由滚动,以及 Windows CI 支持。 +- **[2026 年 6 月 11 日]** **[v0.1.5](https://github.com/EvoScientist/EvoScientist/releases/tag/v0.1.5)** — Dangerous 模式(带安全检查的真实文件系统访问)、LangGraph streaming v3 流水线、OpenRouter Anthropic prompt caching(可选启用)、claude-fable-5、TUI 自由滚动、Windows CI 支持,以及 `EvoSci deploy` 公网 Cloudflare 隧道(`--tunnel`)。 - **[2026 年 6 月 7 日]** **[v0.1.4](https://github.com/EvoScientist/EvoScientist/releases/tag/v0.1.4)** — 辅助模型(后台任务与工具选择)、observation 记忆生命周期、Qwen3.7-Max/Plus(DashScope)、UI 后端选择,以及 OpenRouter 多轮推理修复。 - **[2026 年 6 月 3 日]** **[v0.1.3](https://github.com/EvoScientist/EvoScientist/releases/tag/v0.1.3)** — 多模态处理(图片 + PDF/文档 flatten/hoisting、纯文本模型回退)、runtime-context 中间件、memory 中间件迁移至 profile 文件 + stream 时间线叙述、textual 中文输入修复。 - **[2026 年 6 月 2 日]** **[v0.1.2](https://github.com/EvoScientist/EvoScientist/releases/tag/v0.1.2)** — 浏览器 WebUI 模式(beta)、`EvoSci deploy` 独立 LangGraph 服务器、默认模型 → claude-sonnet-4-6、MiniMax M3,以及 sandbox 超时与 async-notifier 渠道路由修复。 diff --git a/tests/test_cli_deploy.py b/tests/test_cli_deploy.py index c0fbfb4..27578ff 100644 --- a/tests/test_cli_deploy.py +++ b/tests/test_cli_deploy.py @@ -74,6 +74,8 @@ def _run_deploy_once( cwd: str | None = None, port_occupied: bool = False, langgraph_dev_running: bool = True, # health-check passes after start + tunnel: bool = False, + tunnel_url: str | None = None, ): """Run ``deploy()`` end-to-end with all external dependencies mocked. Returns a ``captured`` dict with observation points.""" @@ -124,6 +126,7 @@ def _run_deploy_once( file_persistence=True, jobs_per_worker=10, deploy_mode=False, + tunnel=False, ): captured["langgraph_dev_started"] = True captured["workspace_passed"] = str(workspace_dir) if workspace_dir else None @@ -131,6 +134,7 @@ def _run_deploy_once( captured["deploy_mode_passed"] = deploy_mode captured["jobs_per_worker_passed"] = jobs_per_worker captured["file_persistence_passed"] = file_persistence + captured["tunnel_passed"] = tunnel return SimpleNamespace(pid=99999) def _fake_stop_langgraph_dev(_proc=None): @@ -138,6 +142,10 @@ def _run_deploy_once( monkeypatch.setattr(lgm, "start_langgraph_dev", _fake_start_langgraph_dev) monkeypatch.setattr(lgm, "stop_langgraph_dev", _fake_stop_langgraph_dev) + # Never poll a real log for the tunnel URL in tests. + monkeypatch.setattr( + lgm, "read_tunnel_url", lambda *a, **k: tunnel_url, raising=False + ) # ccproxy mocks from EvoScientist import ccproxy_manager as ccp @@ -196,7 +204,7 @@ def _run_deploy_once( if cwd is not None: monkeypatch.setattr(os, "getcwd", lambda: cwd) - deploy_server.deploy(workdir=workdir, port=port, debug=debug) + deploy_server.deploy(workdir=workdir, port=port, debug=debug, tunnel=tunnel) return captured @@ -234,6 +242,27 @@ def test_deploy_starts_langgraph_dev_with_deploy_mode_true(monkeypatch, tmp_path ) +def test_deploy_tunnel_default_off(monkeypatch, tmp_path): + """Without ``--tunnel``, start_langgraph_dev is called with tunnel=False.""" + config = _make_config(default_workdir=str(tmp_path)) + captured = _run_deploy_once(monkeypatch, config) + + assert captured["tunnel_passed"] is False + + +def test_deploy_tunnel_flag_passed_through(monkeypatch, tmp_path): + """``--tunnel`` propagates to start_langgraph_dev(tunnel=True).""" + config = _make_config(default_workdir=str(tmp_path)) + captured = _run_deploy_once( + monkeypatch, + config, + tunnel=True, + tunnel_url="https://demo-xyz.trycloudflare.com", + ) + + assert captured["tunnel_passed"] is True + + def test_deploy_workdir_cli_arg_beats_config(monkeypatch, tmp_path): cli_ws = tmp_path / "cli_ws" cfg_ws = tmp_path / "cfg_ws" diff --git a/tests/test_langgraph_dev_deploy_mode.py b/tests/test_langgraph_dev_deploy_mode.py index b34c574..f94a74a 100644 --- a/tests/test_langgraph_dev_deploy_mode.py +++ b/tests/test_langgraph_dev_deploy_mode.py @@ -267,3 +267,30 @@ def test_async_subagents_available_init_false_without_env(monkeypatch): reloaded = importlib.reload(mgr) assert reloaded._ASYNC_SUBAGENTS_AVAILABLE is False + + +def test_tunnel_true_appends_flag(monkeypatch, tmp_path, runtime_paths): + """``tunnel=True`` must add ``--tunnel`` to the langgraph dev argv.""" + captured = _patch_start_prereqs(monkeypatch, tmp_path, runtime_paths) + + with pytest.raises(_PopenAbort): + manager.start_langgraph_dev( + workspace_dir=tmp_path, + port=16190, + tunnel=True, + ) + + assert "--tunnel" in captured["args"] + + +def test_tunnel_false_default_omits_flag(monkeypatch, tmp_path, runtime_paths): + """``tunnel`` defaults to False — no ``--tunnel`` in the argv.""" + captured = _patch_start_prereqs(monkeypatch, tmp_path, runtime_paths) + + with pytest.raises(_PopenAbort): + manager.start_langgraph_dev( + workspace_dir=tmp_path, + port=16191, + ) + + assert "--tunnel" not in captured["args"] diff --git a/tests/test_langgraph_manager.py b/tests/test_langgraph_manager.py index 26a4797..2eef08f 100644 --- a/tests/test_langgraph_manager.py +++ b/tests/test_langgraph_manager.py @@ -24,10 +24,12 @@ def reset_module_state(): manager._PROCESS = None manager._PROCESS_WORKSPACE = None manager._ASYNC_SUBAGENTS_AVAILABLE = False + manager._LOG_OFFSET_AT_START = 0 yield manager._PROCESS = None manager._PROCESS_WORKSPACE = None manager._ASYNC_SUBAGENTS_AVAILABLE = False + manager._LOG_OFFSET_AT_START = 0 # ============================================================================= @@ -468,3 +470,141 @@ class TestStartLanggraphDevRotatesLog: # under ``tmp_path``). The ``pid_dir`` we redirected to must # exist, proving the function reached past the mkdir prelude. assert pid_dir.is_dir() + + +class TestStartLanggraphDevCapturesLogOffset: + """``start_langgraph_dev`` must capture ``_LOG_OFFSET_AT_START`` at the + right moment — after ``_rotate_log_if_needed`` + ``open('ab')`` but + before ``subprocess.Popen`` — so ``read_tunnel_url`` scans only this + session's bytes. The existing ``TestReadTunnelUrl`` tests monkeypatch + the offset directly (consumer side); these guard the producer side, so + a regression moving the capture line would actually be caught. + """ + + def _patch_prereqs(self, tmp_path, monkeypatch, log): + """Mock everything up to (but not including) Popen, redirecting all + runtime paths under ``tmp_path``.""" + pid_dir = tmp_path / "pids" + monkeypatch.setattr( + manager, + "RUNTIME", + dataclasses.replace( + manager.LanggraphRuntimePaths.for_directory(pid_dir), + log_file=log, + ), + ) + monkeypatch.setattr(manager, "_can_bind_port", lambda port: True) + fake_config = tmp_path / "langgraph.json" + fake_config.write_text("{}") + monkeypatch.setattr(manager, "_langgraph_exe", lambda: "/fake/langgraph") + monkeypatch.setattr(manager, "_packaged_langgraph_config", lambda: fake_config) + + def test_offset_equals_existing_log_size(self, tmp_path, monkeypatch): + """No rotation → offset is the pre-existing (appended-to) log size, + so a stale URL above that offset is never re-read.""" + log = tmp_path / "langgraph_dev.log" + log.write_bytes(b"x" * 512) + # Keep the log well under the rotation threshold so it is NOT rotated. + monkeypatch.setattr(manager, "_LOG_ROTATION_BYTES", 10**9) + self._patch_prereqs(tmp_path, monkeypatch, log) + + captured: dict = {} + + def _fake_popen(args, **kwargs): + # Read the global at the instant Popen is invoked — this is + # strictly after the capture line in start_langgraph_dev. + captured["offset"] = manager._LOG_OFFSET_AT_START + raise FileNotFoundError("stop before real spawn") + + monkeypatch.setattr( + "EvoScientist.langgraph_dev.manager.subprocess.Popen", _fake_popen + ) + try: + manager.start_langgraph_dev(workspace_dir=tmp_path) + except FileNotFoundError: + pass + assert captured["offset"] == 512 + + def test_offset_zero_after_forced_rotation(self, tmp_path, monkeypatch): + """Forced rotation moves the old log away; the fresh ``open('ab')`` + starts empty → offset 0 (scan the whole new file).""" + log = tmp_path / "langgraph_dev.log" + log.write_bytes(b"x" * 4096) + monkeypatch.setattr(manager, "_LOG_ROTATION_BYTES", 1024) + self._patch_prereqs(tmp_path, monkeypatch, log) + + captured: dict = {} + + def _fake_popen(args, **kwargs): + captured["offset"] = manager._LOG_OFFSET_AT_START + raise FileNotFoundError("stop before real spawn") + + monkeypatch.setattr( + "EvoScientist.langgraph_dev.manager.subprocess.Popen", _fake_popen + ) + try: + manager.start_langgraph_dev(workspace_dir=tmp_path) + except FileNotFoundError: + pass + assert (tmp_path / "langgraph_dev.log.1").exists() # rotation happened + assert captured["offset"] == 0 + + +# ============================================================================= +# read_tunnel_url +# ============================================================================= + + +class TestReadTunnelUrl: + """``read_tunnel_url`` scrapes the Cloudflare tunnel URL from the log, + scanning only bytes written after the current subprocess started.""" + + def test_returns_url_when_present(self, tmp_path, runtime_paths, monkeypatch): + log = tmp_path / "langgraph_dev.log" + log.write_text( + "INFO server up\n" + "[cloudflared] Your quick Tunnel has been created! Visit it at:\n" + "[cloudflared] https://happy-tiger-demo.trycloudflare.com\n" + ) + monkeypatch.setattr( + manager, "RUNTIME", dataclasses.replace(runtime_paths, log_file=log) + ) + monkeypatch.setattr(manager, "_LOG_OFFSET_AT_START", 0) + + assert ( + manager.read_tunnel_url(timeout=1.0) + == "https://happy-tiger-demo.trycloudflare.com" + ) + + def test_returns_none_on_timeout(self, tmp_path, runtime_paths, monkeypatch): + log = tmp_path / "langgraph_dev.log" + log.write_text("INFO server up — but no tunnel line ever printed\n") + monkeypatch.setattr( + manager, "RUNTIME", dataclasses.replace(runtime_paths, log_file=log) + ) + monkeypatch.setattr(manager, "_LOG_OFFSET_AT_START", 0) + + assert manager.read_tunnel_url(timeout=0.2, poll_interval=0.05) is None + + def test_ignores_stale_url_before_offset( + self, tmp_path, runtime_paths, monkeypatch + ): + """A URL from a previous session (before the offset) must be skipped; + only this session's bytes count.""" + stale = "[cloudflared] https://old-stale-url.trycloudflare.com\n" + log = tmp_path / "langgraph_dev.log" + log.write_text(stale) + monkeypatch.setattr( + manager, "RUNTIME", dataclasses.replace(runtime_paths, log_file=log) + ) + # Offset points past the stale line — nothing fresh yet → None. + monkeypatch.setattr(manager, "_LOG_OFFSET_AT_START", len(stale.encode())) + assert manager.read_tunnel_url(timeout=0.2, poll_interval=0.05) is None + + # Now this session appends its own fresh URL → returned. + with open(log, "a") as fh: + fh.write("[cloudflared] https://fresh-new-url.trycloudflare.com\n") + assert ( + manager.read_tunnel_url(timeout=1.0) + == "https://fresh-new-url.trycloudflare.com" + )