Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,4 @@ frontier_pid_study.yaml
workspace/
.optpilot-ui/
.venv/
/_deploy/
2 changes: 2 additions & 0 deletions studio/src/optpilot_studio/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
)
Expand Down
104 changes: 86 additions & 18 deletions studio/src/optpilot_studio/ui/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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"
Expand All @@ -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,
Expand Down Expand Up @@ -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 [])),
Expand Down Expand Up @@ -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"),
Expand All @@ -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"),
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand All @@ -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()
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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(
Expand All @@ -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,
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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,
)
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion studio/src/optpilot_studio/ui/static/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading