diff --git a/.gitignore b/.gitignore index 033c733..3d325ec 100755 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,4 @@ frontier_pid_study.yaml workspace/ .optpilot-ui/ .venv/ +/_deploy/ diff --git a/studio/src/optpilot_studio/cli.py b/studio/src/optpilot_studio/cli.py index 477196a..55b55a4 100644 --- a/studio/src/optpilot_studio/cli.py +++ b/studio/src/optpilot_studio/cli.py @@ -15,6 +15,7 @@ def _run_ui_command(args) -> int: run_ui( host=args.host, port=args.port, + public_host=args.public_host, catalog_roots=args.catalog, run_roots=args.runs, code_server_bin=args.code_server_bin, @@ -25,6 +26,7 @@ def _run_ui_command(args) -> int: workspace_runtime_executable=args.workspace_runtime_bin, workspace_runtime_image=args.workspace_runtime_image, workspace_runtime_network=args.workspace_runtime_network, + workspace_runtime_host=args.workspace_runtime_host, workspace_runtime_port_start=args.workspace_runtime_port_start, open_browser=args.open_browser, ) diff --git a/studio/src/optpilot_studio/ui/server.py b/studio/src/optpilot_studio/ui/server.py index fae2d8e..37c4d26 100755 --- a/studio/src/optpilot_studio/ui/server.py +++ b/studio/src/optpilot_studio/ui/server.py @@ -196,6 +196,7 @@ def to_dict(self) -> JsonDict: class CodeServerOptions: executable: Optional[str] = None host: str = "127.0.0.1" + public_host: str = "127.0.0.1" port: int = 8766 auth: str = "none" password: Optional[str] = None @@ -212,7 +213,7 @@ class CodeServerState: @property def url(self) -> str: - return f"http://{self.options.host}:{self.options.port}/" + return f"http://{self.options.public_host}:{self.options.port}/" @property def running(self) -> bool: @@ -252,6 +253,7 @@ class WorkspaceRuntimeOptions: build_image: bool = True dockerfile: Optional[str] = None host: str = "127.0.0.1" + public_host: str = "127.0.0.1" port_start: int = 18766 container_port: int = 8766 auth: str = "none" @@ -277,6 +279,7 @@ def from_env(cls) -> "WorkspaceRuntimeOptions": build_image=_ui_env_flag("OPTPILOT_WORKSPACE_RUNTIME_BUILD", image == DEFAULT_WORKSPACE_RUNTIME_IMAGE), dockerfile=os.environ.get("OPTPILOT_WORKSPACE_RUNTIME_DOCKERFILE") or None, host=os.environ.get("OPTPILOT_WORKSPACE_RUNTIME_HOST") or cls.host, + public_host=os.environ.get("OPTPILOT_WORKSPACE_RUNTIME_PUBLIC_HOST") or cls.public_host, port_start=_int_env("OPTPILOT_WORKSPACE_RUNTIME_PORT_START", cls.port_start), container_port=_int_env("OPTPILOT_WORKSPACE_RUNTIME_CONTAINER_PORT", cls.container_port), auth=os.environ.get("OPTPILOT_WORKSPACE_CODE_SERVER_AUTH") or cls.auth, @@ -414,7 +417,8 @@ def status(self, workspace: JsonDict) -> JsonDict: image_matches = (not current_image) or current_image == self.options.image host_port = int(record.get("host_port") or 0) or None code_url = self._code_server_base_url(host_port) if host_port else "" - code_reachable = bool(code_url and _code_server_reachable(code_url)) + probe_url = self._code_server_probe_url(host_port) if host_port else "" + code_reachable = bool(probe_url and _code_server_reachable(probe_url)) mount_mode = self._mount_mode(workspace) active_references = { "attached_sessions": len(list(workspace.get("attached_sessions", []) or [])), @@ -456,7 +460,7 @@ def status(self, workspace: JsonDict) -> JsonDict: "mount_mode": mount_mode, "workspace_root": str(root), "runtime_dir": str(self._workspace_runtime_dir(workspace_id)), - "host": self.options.host, + "host": self.options.public_host, "port": host_port, "url": code_url, "started_at": record.get("started_at"), @@ -481,7 +485,7 @@ def global_status(self, *, active_workspace: Optional[JsonDict] = None, port: Op "port_conflict": port_conflict, "pid": None, "url": url, - "host": self.options.host, + "host": self.options.public_host, "port": host_port, "auth": self.options.auth, "started_at": active_status.get("started_at"), @@ -602,7 +606,8 @@ def start_code_server(self, workspace: JsonDict) -> JsonDict: stdout_path = log_dir / "code-server.stdout.log" stderr_path = log_dir / "code-server.stderr.log" url = self._code_server_base_url(host_port) - if _code_server_reachable(url): + probe_url = self._code_server_probe_url(host_port) + if _code_server_reachable(probe_url): record.update( { "container_name": container_name, @@ -655,7 +660,7 @@ def start_code_server(self, workspace: JsonDict) -> JsonDict: if completed.returncode != 0: raise RuntimeError(f"Code Server failed to start in workspace container: {completed.stderr.strip() or completed.stdout.strip()}") for _ in range(20): - if _code_server_reachable(url): + if _code_server_reachable(probe_url): break time.sleep(0.25) record.update( @@ -985,7 +990,7 @@ def _workspace_has_active_reference(self, workspace: JsonDict, record: JsonDict, if workspace.get("attached_sessions"): return True host_port = int(record.get("host_port") or 0) - if host_port and _code_server_reachable(self._code_server_base_url(host_port)): + if host_port and _code_server_reachable(self._code_server_probe_url(host_port)): return True return False @@ -1027,6 +1032,9 @@ def _mount_mode(self, workspace: JsonDict) -> str: return "ro" if mode in {"read-only", "analysis"} or source_type in {"catalog", "run"} else "rw" def _code_server_base_url(self, port: Optional[int]) -> str: + return f"http://{self.options.public_host}:{int(port or self.options.port_start)}/" + + def _code_server_probe_url(self, port: Optional[int]) -> str: return f"http://{self.options.host}:{int(port or self.options.port_start)}/" def _workspace_runtime_dir(self, workspace_id: str) -> Path: @@ -1271,21 +1279,25 @@ def workspace_preview_open(self, folder: Optional[Path], port: int, *, extra_por def _workspace_preview_proxy(self, workspace_id: str, port: int, target_base_url: str, *, allowed_ports: List[int]) -> WorkspacePreviewProxy: key = f"{workspace_id}:{int(port)}" existing = self.preview_proxies.get(key) - if existing and existing.running and existing.target_base_url == target_base_url and existing.allowed_ports == allowed_ports: - return existing + if existing and existing.running and existing.target_base_url == target_base_url: + merged_allowed_ports = sorted({int(item) for item in existing.allowed_ports + allowed_ports}) + if existing.allowed_ports == merged_allowed_ports: + return existing + allowed_ports = merged_allowed_ports if existing: self._stop_workspace_preview_proxy(key) - host = self.workspace_runtime.options.host or "127.0.0.1" + bind_host = self.workspace_runtime.options.host or "127.0.0.1" + public_host = self.workspace_runtime.options.public_host or bind_host start_port = max(19000, int(self.workspace_runtime.options.port_start) + 1000) reserved = {proxy.port for proxy in self.preview_proxies.values() if proxy.running} - preview_port = _find_available_port(host, start_port, reserved=reserved) + preview_port = _find_available_port(bind_host, start_port, reserved=reserved) token = uuid.uuid4().hex handler = _preview_proxy_handler_factory(target_base_url, token=token, allowed_ports=allowed_ports) - server = ThreadingHTTPServer((host, preview_port), handler) + server = ThreadingHTTPServer((bind_host, preview_port), handler) thread = threading.Thread(target=server.serve_forever, name=f"optpilot-preview-{workspace_id}-{port}", daemon=True) proxy = WorkspacePreviewProxy( key=key, - host=host, + host=public_host, port=server.server_port, target_base_url=target_base_url, token=token, @@ -1312,9 +1324,21 @@ def stop_workspace_preview_proxies(self) -> None: self._stop_workspace_preview_proxy(key) def _active_code_workspace(self) -> Optional[JsonDict]: - if not self.active_code_workspace_id: - return None - return _workspace_by_id(self, self.active_code_workspace_id) + if self.active_code_workspace_id: + current = _workspace_by_id(self, self.active_code_workspace_id) + if current: + runtime_status = self.workspace_runtime.status(current) + if runtime_status.get("code_server_running") or runtime_status.get("container_running"): + return current + active_candidates = [] + for workspace in _list_ui_workspaces(self): + runtime_status = self.workspace_runtime.status(workspace) + if runtime_status.get("code_server_running") or runtime_status.get("container_running"): + active_candidates.append(workspace) + if len(active_candidates) == 1: + self.active_code_workspace_id = str(active_candidates[0].get("id") or "") + return active_candidates[0] + return None def _ensure_code_workspace(self, workspace_root: Path) -> JsonDict: workspace_root = workspace_root.resolve() @@ -1358,6 +1382,7 @@ def run_ui( *, host: str = "127.0.0.1", port: int = 8765, + public_host: Optional[str] = None, catalog_roots: Optional[List[str]] = None, run_roots: Optional[List[str]] = None, code_server_bin: Optional[str] = None, @@ -1368,10 +1393,12 @@ def run_ui( workspace_runtime_executable: Optional[str] = None, workspace_runtime_image: Optional[str] = None, workspace_runtime_network: Optional[str] = None, + workspace_runtime_host: Optional[str] = None, workspace_runtime_port_start: Optional[int] = None, open_browser: bool = False, ) -> None: cwd = Path.cwd().resolve() + public_host = os.environ.get("OPTPILOT_UI_PUBLIC_HOST") or public_host or code_server_host or host runtime_options = WorkspaceRuntimeOptions.from_env() if workspace_runtime_executable: runtime_options.executable = workspace_runtime_executable @@ -1382,7 +1409,8 @@ def run_ui( runtime_options.network = workspace_runtime_network if workspace_runtime_port_start: runtime_options.port_start = workspace_runtime_port_start - runtime_options.host = code_server_host + runtime_options.host = workspace_runtime_host or code_server_host + runtime_options.public_host = public_host runtime_options.auth = code_server_auth runtime_options.password = code_server_password or runtime_options.password state = UiState( @@ -1392,6 +1420,7 @@ def run_ui( code_server=CodeServerOptions( executable=code_server_bin, host=code_server_host, + public_host=public_host, port=code_server_port, auth=code_server_auth, password=code_server_password, @@ -1578,6 +1607,7 @@ def log_message(self, format: str, *args: Any) -> None: def add_ui_arguments(parser: argparse.ArgumentParser) -> argparse.ArgumentParser: parser.add_argument("--host", default="127.0.0.1", help="Host interface to bind") parser.add_argument("--port", type=int, default=8765, help="Port to bind") + parser.add_argument("--public-host", default=None, help="Browser-reachable host or DNS name used when generating external URLs") parser.add_argument( "--catalog", action="append", @@ -1610,6 +1640,11 @@ def add_ui_arguments(parser: argparse.ArgumentParser) -> argparse.ArgumentParser default=None, help="Container network policy for workspace containers. Use bridge/default for embedded Code Server.", ) + parser.add_argument( + "--workspace-runtime-host", + default=None, + help="Host interface for per-workspace Code Server and preview listeners. Defaults to --code-server-host.", + ) parser.add_argument( "--workspace-runtime-port-start", type=int, @@ -1630,6 +1665,7 @@ def main(argv: Optional[List[str]] = None) -> int: run_ui( host=args.host, port=args.port, + public_host=args.public_host, catalog_roots=args.catalog, run_roots=args.runs, code_server_bin=args.code_server_bin, @@ -1640,6 +1676,7 @@ def main(argv: Optional[List[str]] = None) -> int: workspace_runtime_executable=args.workspace_runtime_bin, workspace_runtime_image=args.workspace_runtime_image, workspace_runtime_network=args.workspace_runtime_network, + workspace_runtime_host=args.workspace_runtime_host, workspace_runtime_port_start=args.workspace_runtime_port_start, open_browser=args.open_browser, ) @@ -2965,6 +3002,11 @@ def _update_agent_settings(state: UiState, payload: JsonDict) -> JsonDict: assistant["runtime"] = "openhands" openhands = assistant.setdefault("openhands", {}) incoming = payload.get("openhands") if isinstance(payload.get("openhands"), dict) else payload + previous_base_url = str(openhands.get("base_url") or "").strip().rstrip("/") + previous_session_endpoint = str(openhands.get("session_endpoint") or "").strip() + previous_model = str(openhands.get("model") or "").strip() + previous_enabled = bool(openhands.get("enabled")) + previous_api_key = str(openhands.get("api_key") or "").strip() openhands["enabled"] = bool(incoming.get("enabled")) openhands["base_url"] = str(incoming.get("base_url") or "").strip().rstrip("/") openhands["session_endpoint"] = str(incoming.get("session_endpoint") or "").strip() @@ -2995,6 +3037,29 @@ def _update_agent_settings(state: UiState, payload: JsonDict) -> JsonDict: variables[name] = value environment["variables"] = variables _write_ui_settings(state, settings) + if ( + previous_base_url != openhands["base_url"] + or previous_session_endpoint != openhands["session_endpoint"] + or previous_model != openhands["model"] + or previous_enabled != openhands["enabled"] + or previous_api_key != str(openhands.get("api_key") or "").strip() + or incoming.get("clear_api_key") + ): + sessions = _read_agent_session_index(state) + changed = False + for session in sessions: + if session.get("openhands_conversation_id"): + session.pop("openhands_conversation_id", None) + session.pop("openhands_pending_sync", None) + session.pop("cancelled_openhands_conversation_id", None) + changed = True + if session.get("status") in {"waiting_for_agent", "running"}: + session["status"] = "idle" + session.pop("active_turn_id", None) + session.pop("active_turn_started_at", None) + changed = True + if changed: + _write_agent_session_index(state, sessions) _refresh_agent_adapter(state) return _agent_settings_payload(state) @@ -5131,6 +5196,7 @@ def _cancel_agent_session(state: UiState, session_id: str) -> JsonDict: session["cancelled_turn_id"] = turn_id if conversation_id: session["cancelled_openhands_conversation_id"] = conversation_id + session.pop("openhands_conversation_id", None) session.pop("active_turn_id", None) session.pop("active_turn_started_at", None) session.pop("openhands_pending_sync", None) @@ -6311,7 +6377,9 @@ def report(title: str, detail: str = "", status: str = "running", data: Optional for key, value in (interface.get("env") or {}).items() if str(key) } - env.setdefault("HOST", "0.0.0.0") + # Preview traffic is proxied from code-server inside the same container, so + # loopback is a safer default than 0.0.0.0 for frontend dev servers. + env.setdefault("HOST", "127.0.0.1") env.setdefault("PORT", str(port)) env["OPTPILOT_INTERFACE_PORT"] = str(port) env["OPTPILOT_WORKSPACE_ROOT"] = str(root) diff --git a/studio/src/optpilot_studio/ui/static/app.js b/studio/src/optpilot_studio/ui/static/app.js index 625829a..5b352ef 100644 --- a/studio/src/optpilot_studio/ui/static/app.js +++ b/studio/src/optpilot_studio/ui/static/app.js @@ -3065,7 +3065,7 @@ function renderPreviewWorkbench() { ? `Preparing port ${preview.port || 5173} through the workspace runtime.` : preview.status === "error" ? preview.message || "The preview could not be opened." - : "Start a frontend server in the workspace terminal, make it listen on 0.0.0.0, then enter the port here."; + : "Start a frontend server in the workspace terminal, make it listen on 127.0.0.1, then enter the port here."; } if (els.openWorkspacePreviewButton) { els.openWorkspacePreviewButton.disabled = !hasWorkspace || opening; diff --git a/tests/test_mvp.py b/tests/test_mvp.py index ffd4cea..fcd5202 100755 --- a/tests/test_mvp.py +++ b/tests/test_mvp.py @@ -5160,6 +5160,24 @@ def test_ui_workspace_runtime_defaults_to_packaged_dev_image(self) -> None: self.assertEqual(status["runtime"]["memory_limit"], "4g") self.assertEqual(status["runtime"]["pids_limit"], 1024) + def test_ui_workspace_runtime_separates_bind_and_public_hosts(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + state = UiState( + cwd=Path(tmp_dir), + catalog_roots=[], + run_roots=[], + code_server=CodeServerOptions(host="127.0.0.1", public_host="studio.example.test", port=18766), + workspace_runtime=WorkspaceRuntimeOptions( + host="127.0.0.1", + public_host="studio.example.test", + port_start=18766, + ), + ) + + self.assertEqual(state.code_server.url, "http://studio.example.test:18766/") + self.assertEqual(state.workspace_runtime._code_server_base_url(18767), "http://studio.example.test:18767/") + self.assertEqual(state.workspace_runtime._code_server_probe_url(18767), "http://127.0.0.1:18767/") + def test_ui_code_server_status_rejects_non_code_server_port_conflict(self) -> None: class FakeOptPilotHandler(BaseHTTPRequestHandler): server_version = "OptPilotUI/0.1" @@ -5578,6 +5596,8 @@ def test_cli_parser_accepts_ui_command(self) -> None: "ui", "--port", "9001", + "--public-host", + "studio.example.test", "--catalog", "catalog/example_package", "--workspace-runtime-bin", @@ -5586,6 +5606,8 @@ def test_cli_parser_accepts_ui_command(self) -> None: "custom/workspace:latest", "--workspace-runtime-network", "bridge", + "--workspace-runtime-host", + "127.0.0.2", "--workspace-runtime-port-start", "19000", ] @@ -5593,10 +5615,12 @@ def test_cli_parser_accepts_ui_command(self) -> None: self.assertEqual(args.command, "ui") self.assertEqual(args.port, 9001) + self.assertEqual(args.public_host, "studio.example.test") self.assertEqual(args.catalog, ["catalog/example_package"]) self.assertEqual(args.workspace_runtime_bin, "podman") self.assertEqual(args.workspace_runtime_image, "custom/workspace:latest") self.assertEqual(args.workspace_runtime_network, "bridge") + self.assertEqual(args.workspace_runtime_host, "127.0.0.2") self.assertEqual(args.workspace_runtime_port_start, 19000) def test_cli_ui_forwards_workspace_runtime_options(self) -> None: @@ -5604,12 +5628,16 @@ def test_cli_ui_forwards_workspace_runtime_options(self) -> None: exit_code = cli_main( [ "ui", + "--public-host", + "studio.example.test", "--workspace-runtime-bin", "podman", "--workspace-runtime-image", "custom/workspace:latest", "--workspace-runtime-network", "bridge", + "--workspace-runtime-host", + "127.0.0.2", "--workspace-runtime-port-start", "19000", ] @@ -5618,9 +5646,11 @@ def test_cli_ui_forwards_workspace_runtime_options(self) -> None: self.assertEqual(exit_code, 0) run_ui_mock.assert_called_once() kwargs = run_ui_mock.call_args.kwargs + self.assertEqual(kwargs["public_host"], "studio.example.test") self.assertEqual(kwargs["workspace_runtime_executable"], "podman") self.assertEqual(kwargs["workspace_runtime_image"], "custom/workspace:latest") self.assertEqual(kwargs["workspace_runtime_network"], "bridge") + self.assertEqual(kwargs["workspace_runtime_host"], "127.0.0.2") self.assertEqual(kwargs["workspace_runtime_port_start"], 19000) @staticmethod