Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 0284fa4

Browse files
committed
frontend_gradio's "Open Output Dir" now opens the macOS Finder. I had to add a "open_dir_server" that runs outside docker.
1 parent 96271f3 commit 0284fa4

7 files changed

Lines changed: 263 additions & 13 deletions

File tree

docker-compose.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ services:
5151
PLANEXE_HOST_RUN_DIR: ${PLANEXE_HOST_RUN_DIR:-${PWD}/run}
5252
PLANEXE_RUN_DIR: /app/run
5353
WORKER_PLAN_URL: ${WORKER_PLAN_URL:-http://worker_plan:8000}
54+
PLANEXE_OPEN_DIR_SERVER_URL: ${PLANEXE_OPEN_DIR_SERVER_URL:-}
5455
ports:
5556
- "7860:7860"
5657
volumes:

extra/docker.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,34 @@ Environment notes
2929
-----------------
3030
- The worker exports logs to stdout when `WORKER_RELAY_PROCESS_OUTPUT=true` (set in `docker-compose.yml`).
3131
- Shared volumes: `./run` is mounted into both services; `.env` and `llm_config.json` are mounted read-only. Ensure they exist on the host before starting.***
32+
33+
One-shot env setup (avoid manual exports)
34+
----------------------------------------
35+
Run once per terminal session to emit exports for `PLANEXE_OPEN_DIR_SERVER_URL` and related values:
36+
```bash
37+
eval "$(python setup_env.py)"
38+
```
39+
This keeps `.env` reserved for secrets while still seeding the shell for Docker commands.
40+
41+
Host opener (Open Output Dir)
42+
-----------------------------
43+
Because Docker containers cannot launch host apps, the `Open Output Dir` button needs a host-side service:
44+
45+
1) Start host opener **before** Docker (on the host):
46+
```bash
47+
eval "$(python setup_env.py)"
48+
cd open_dir_server
49+
python -m venv venv
50+
source venv/bin/activate # Windows: venv\Scripts\activate
51+
pip install -r requirements.txt
52+
python app.py
53+
```
54+
2) Configure the frontend container to call it:
55+
- Set `PLANEXE_OPEN_DIR_SERVER_URL` so the container can reach the host opener (containers cannot guess this URL):
56+
- macOS/Windows (Docker Desktop): `http://host.docker.internal:5100`
57+
- Linux: often `http://172.17.0.1:5100` or add `host.docker.internal` in `/etc/hosts` pointing to the docker bridge IP.
58+
- If you override host/port for the opener, reflect that in the URL.
59+
- Provide the variable via your shell env, `.env`, or docker compose environment for `frontend_gradio`.
60+
- macOS (zsh default shell): `export PLANEXE_OPEN_DIR_SERVER_URL=http://host.docker.internal:5100` then `docker compose up`
61+
- macOS example (`.env`): add `PLANEXE_OPEN_DIR_SERVER_URL=http://host.docker.internal:5100` then `docker compose up`
62+

frontend_gradio/app.py

Lines changed: 43 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ class Config:
6262
WORKER_PLAN_TIMEOUT_SECONDS = float(os.environ.get("WORKER_PLAN_TIMEOUT", "30"))
6363
GRADIO_SERVER_NAME = os.environ.get("GRADIO_SERVER_NAME", "0.0.0.0")
6464
GRADIO_SERVER_PORT = int(os.environ.get("PORT", os.environ.get("GRADIO_SERVER_PORT", "7860")))
65+
OPEN_DIR_SERVER_URL = os.environ.get("PLANEXE_OPEN_DIR_SERVER_URL")
6566

6667
# Load prompt catalog and examples.
6768
prompt_catalog = PromptCatalog()
@@ -524,27 +525,56 @@ def stop_planner(session_state: SessionState):
524525
def open_output_dir(session_state: SessionState):
525526
"""
526527
Presents a host-visible path (and clickable link) to the latest output directory.
527-
Note: containers cannot launch host-native file explorers; users must open manually.
528+
If a host opener service is configured, it requests the host to open the path; otherwise it shows manual instructions.
528529
"""
529530

530531
container_run_dir = session_state.latest_run_dir_container
531532
display_run_dir = session_state.latest_run_dir_display or container_run_dir
532533

533-
if not container_run_dir or not os.path.exists(container_run_dir):
534+
if not container_run_dir:
535+
return "No plan has been submitted, cannot open dir.", session_state
536+
537+
if not os.path.exists(container_run_dir):
534538
return "No output directory available.", session_state
535539

536540
open_path = display_run_dir or container_run_dir
537-
try:
538-
file_uri = Path(open_path).resolve().as_uri()
539-
except Exception:
540-
file_uri = None
541-
542-
# Browsers often block file:// links; provide explicit instructions.
543-
mac_hint = f'Run on host: `open "{open_path}"`' if sys.platform == "darwin" else ""
544-
link_part = f"[{open_path}]({file_uri})" if file_uri else open_path
545-
parts = [f"Open manually: {link_part}"]
546-
if mac_hint:
547-
parts.append(mac_hint)
541+
parts = []
542+
opener_succeeded = False
543+
544+
# Attempt to ask the host opener service (running outside Docker) to open the path.
545+
if OPEN_DIR_SERVER_URL:
546+
try:
547+
response = httpx.post(f"{OPEN_DIR_SERVER_URL.rstrip('/')}/open", json={"path": open_path})
548+
response.raise_for_status()
549+
data = response.json()
550+
msg = data.get("message", "Requested host to open directory.")
551+
parts.append(msg)
552+
opener_succeeded = True
553+
except httpx.HTTPStatusError as exc:
554+
status_code = exc.response.status_code if exc.response else "unknown"
555+
error_detail = None
556+
try:
557+
error_payload = exc.response.json()
558+
error_detail = error_payload.get("message")
559+
except Exception:
560+
pass
561+
detail_msg = f" ({error_detail})" if error_detail else ""
562+
parts.append(f"Host opener error (status {status_code}){detail_msg}.")
563+
except Exception as exc:
564+
parts.append(f"Failed to contact host opener: {exc}")
565+
else:
566+
parts.append("Host opener service not configured (set PLANEXE_OPEN_DIR_SERVER_URL).")
567+
568+
# Include manual instructions only when the opener is not available or failed.
569+
if not opener_succeeded:
570+
try:
571+
file_uri = Path(open_path).resolve().as_uri()
572+
except Exception:
573+
file_uri = None
574+
575+
link_part = f"[{open_path}]({file_uri})" if file_uri else open_path
576+
parts.append(f"Open manually: {link_part}")
577+
548578
return "\n\n".join(parts), session_state
549579

550580

open_dir_server/README.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# Host Open Dir Server
2+
3+
## Why this exists
4+
- Docker containers cannot launch host applications (e.g., macOS Finder) because they are isolated from the host OS.
5+
- The Gradio frontend runs in a container and cannot run `open`, `xdg-open`, or `start` on the host.
6+
- This small FastAPI service runs **on the host** and receives a path from the frontend, then asks the host OS to open that path.
7+
8+
## Prerequisites
9+
- Python 3.10+ on the host (outside Docker).
10+
11+
## Setup (virtual environment)
12+
```bash
13+
cd open_dir_server
14+
python -m venv venv
15+
source venv/bin/activate # Windows: venv\Scripts\activate
16+
pip install -r requirements.txt
17+
```
18+
19+
## Configuration
20+
Environment variables (`PLANEXE_` prefixed):
21+
- `PLANEXE_OPEN_DIR_SERVER_HOST` (default `127.0.0.1`)
22+
- `PLANEXE_OPEN_DIR_SERVER_PORT` (default `5100`)
23+
- `PLANEXE_HOST_RUN_DIR`: required; only allow opening paths under this directory.
24+
25+
Frontend configuration:
26+
- Set `PLANEXE_OPEN_DIR_SERVER_URL` so the container can reach the host service (e.g., `http://host.docker.internal:5100`).
27+
28+
## Start the server
29+
From `open_dir_server`:
30+
```bash
31+
source venv/bin/activate # if not already
32+
python app.py
33+
```
34+
The service will listen on `PLANEXE_OPEN_DIR_SERVER_HOST:PLANEXE_OPEN_DIR_SERVER_PORT`.
35+
36+
## Stop the server
37+
- Press `Ctrl+C` in the terminal where it is running.

open_dir_server/app.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
"""
2+
Small FastAPI service to open a given path in the host OS (e.g., Finder on macOS).
3+
Run this outside Docker so it can launch the native file explorer.
4+
"""
5+
import logging
6+
import os
7+
import subprocess
8+
import sys
9+
import re
10+
from pathlib import Path
11+
from fastapi import FastAPI, HTTPException
12+
from pydantic import BaseModel
13+
14+
logging.basicConfig(
15+
level=logging.INFO,
16+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
17+
handlers=[logging.StreamHandler()],
18+
)
19+
logger = logging.getLogger(__name__)
20+
21+
app = FastAPI(title="PlanExe Host Opener", version="0.1.0")
22+
23+
RUN_BASE_ENV = os.environ.get("PLANEXE_HOST_RUN_DIR")
24+
if not RUN_BASE_ENV:
25+
raise RuntimeError("PLANEXE_HOST_RUN_DIR must be set to the host run directory.")
26+
ALLOWED_BASE = Path(RUN_BASE_ENV).expanduser().resolve()
27+
28+
HOST = os.environ.get("PLANEXE_OPEN_DIR_SERVER_HOST", "127.0.0.1")
29+
PORT = int(os.environ.get("PLANEXE_OPEN_DIR_SERVER_PORT", "5100"))
30+
31+
32+
class OpenPathRequest(BaseModel):
33+
path: str
34+
35+
36+
class OpenPathResponse(BaseModel):
37+
status: str
38+
message: str
39+
40+
41+
def _is_allowed(target: Path) -> bool:
42+
try:
43+
target.resolve().relative_to(ALLOWED_BASE)
44+
return True
45+
except ValueError:
46+
return False
47+
48+
49+
def _command_for_platform(target: Path) -> list[str]:
50+
if sys.platform == "darwin":
51+
return ["open", str(target)]
52+
if os.name == "nt":
53+
return ["cmd", "/c", "start", "", str(target)]
54+
if sys.platform.startswith("linux"):
55+
return ["xdg-open", str(target)]
56+
raise ValueError(f"Unsupported platform: {sys.platform}")
57+
58+
59+
@app.post("/open", response_model=OpenPathResponse)
60+
def open_path(request: OpenPathRequest):
61+
raw_path = request.path
62+
# Only allow PlanExe-style run directory names to avoid arbitrary path access.
63+
if not re.fullmatch(r"^PlanExe_\d+_\d+$", Path(raw_path).name):
64+
raise HTTPException(status_code=400, detail="Invalid path format.")
65+
target = Path(raw_path).expanduser().resolve()
66+
67+
if not _is_allowed(target):
68+
raise HTTPException(status_code=403, detail="Path is outside allowed base.")
69+
70+
if not target.exists():
71+
raise HTTPException(status_code=404, detail=f"Path does not exist: {target}")
72+
73+
try:
74+
cmd = _command_for_platform(target)
75+
except ValueError as exc:
76+
raise HTTPException(status_code=400, detail=str(exc)) from exc
77+
78+
try:
79+
subprocess.run(cmd, check=True)
80+
except Exception as exc:
81+
logger.warning("Failed to open %s: %s", target, exc)
82+
raise HTTPException(status_code=500, detail=f"Failed to open path: {exc}") from exc
83+
84+
return OpenPathResponse(status="ok", message=f"Requested OS to open {target}.")
85+
86+
87+
@app.get("/healthz")
88+
def health() -> dict:
89+
return {
90+
"status": "ok",
91+
"allowed_base": str(ALLOWED_BASE) if ALLOWED_BASE else None,
92+
"platform": sys.platform,
93+
"host": HOST,
94+
"port": PORT,
95+
}
96+
97+
98+
if __name__ == "__main__":
99+
import uvicorn
100+
101+
uvicorn.run("app:app", host=HOST, port=PORT, reload=False)

open_dir_server/requirements.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
fastapi==0.124.0
2+
uvicorn==0.38.0

setup_env.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Emit shell exports for host-specific environment needed by the PlanExe containers.
4+
Run once per terminal session, e.g.:
5+
eval "$(python setup_env.py)"
6+
This avoids manually exporting PLANEXE_OPEN_DIR_SERVER_URL every time.
7+
"""
8+
from __future__ import annotations
9+
10+
import os
11+
import platform
12+
from pathlib import Path
13+
14+
15+
def detect_host_os() -> str:
16+
system = platform.system().lower()
17+
if system.startswith("darwin"):
18+
return "darwin"
19+
if system.startswith("windows"):
20+
return "windows"
21+
return "linux"
22+
23+
24+
def default_open_dir_server_url(host_os: str) -> str:
25+
if host_os in {"darwin", "windows"}:
26+
return "http://host.docker.internal:5100"
27+
return "http://172.17.0.1:5100"
28+
29+
30+
def main() -> None:
31+
repo_root = Path(__file__).resolve().parent
32+
run_dir = (repo_root / "run").resolve()
33+
34+
host_os = detect_host_os()
35+
open_dir_server_url = os.environ.get("PLANEXE_OPEN_DIR_SERVER_URL", "").strip() or default_open_dir_server_url(host_os)
36+
host_run_dir = os.environ.get("PLANEXE_HOST_RUN_DIR", "").strip() or str(run_dir)
37+
38+
exports = {
39+
"PLANEXE_OPEN_DIR_SERVER_URL": open_dir_server_url,
40+
"PLANEXE_HOST_RUN_DIR": host_run_dir,
41+
}
42+
43+
for key, value in exports.items():
44+
print(f'export {key}="{value}"')
45+
46+
47+
if __name__ == "__main__":
48+
main()

0 commit comments

Comments
 (0)