Skip to content
Merged
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
21 changes: 17 additions & 4 deletions desktop/resources/server-bootstrap/install-local-server.sh
Original file line number Diff line number Diff line change
Expand Up @@ -235,10 +235,23 @@ uv_run pip install --python "$VenvPython" \
progress 78 "正在检查可选的 Node.js 文档能力…"
NodeExecutable=""
if [[ "${HUGAGENT_SKIP_OPTIONAL_NODE:-0}" != "1" ]]; then
for Candidate in \
"$(command -v node 2>/dev/null || true)" \
"/opt/homebrew/bin/node" \
"/usr/local/bin/node"; do
# The desktop app launches this script with the minimal GUI PATH, so `command -v`
# misses most user installs. Probe the common per-user install locations too
# (plain binary drops, Homebrew both arches, nvm/fnm/mise/volta version trees).
NodeCandidates=(
"$(command -v node 2>/dev/null || true)"
"$HOME/.local/bin/node"
"/opt/homebrew/bin/node"
"/usr/local/bin/node"
"$HOME/.volta/bin/node"
)
for VersionedNode in \
"$HOME/.nvm/versions/node"/*/bin/node \
"$HOME/.local/share/fnm/node-versions"/*/installation/bin/node \
"$HOME/.local/share/mise/installs/node"/*/bin/node; do
[[ -x "$VersionedNode" ]] && NodeCandidates+=("$VersionedNode")
done
for Candidate in "${NodeCandidates[@]}"; do
if [[ -n "$Candidate" && -x "$Candidate" ]] \
&& [[ "$("$Candidate" -p 'Number(process.versions.node.split(".")[0]) >= 20 ? "ok" : "old"' 2>/dev/null)" == "ok" ]]; then
NodeExecutable="$Candidate"
Expand Down
8 changes: 8 additions & 0 deletions desktop/src-tauri/src/local_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1032,6 +1032,14 @@ mod tests {
assert!(!config.contains("\"../generated/server-ce\": \"server-ce\""));
}
assert!(!linux_config.contains("server-ce"));
// The macOS installer runs with the minimal GUI PATH, so optional Node
// detection must probe common per-user install locations instead of
// relying on `command -v` alone (else site building silently degrades).
let macos_installer =
include_str!("../../resources/server-bootstrap/install-local-server.sh");
assert!(macos_installer.contains(".local/bin/node"));
assert!(macos_installer.contains(".nvm/versions/node"));
assert!(macos_installer.contains(".volta/bin/node"));
assert!(windows_installer.contains("System.IO.Compression.ZipFile"));
assert!(windows_installer.contains("Join-Path $InstallRoot \"runtime\""));
assert!(windows_installer.contains("Join-Path $RuntimeRoot \"node\""));
Expand Down
21 changes: 21 additions & 0 deletions src/backend/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,26 @@ def data_dir() -> Path:
return Path(os.getenv("HUGAGENT_HOME", str(Path.home() / ".hugagent"))).expanduser()


def ensure_loopback_proxy_bypass() -> None:
"""Keep loopback traffic off any HTTP(S)/SOCKS proxy from the environment.

The local profile is a mesh of 127.0.0.1 services (backend ↔ MCP sidecars ↔
script_runner ↔ internal publish/batch callbacks) all speaking httpx, which
honours ``http_proxy``/``https_proxy``/``all_proxy``. A system proxy (e.g.
Clash on macOS) usually rejects loopback targets with 502, which kills
startup readiness and site publishing. Merge loopback hosts into NO_PROXY
instead of deleting the proxy vars — model/API calls may legitimately need
the proxy to reach external endpoints.
"""
loopback_hosts = ("127.0.0.1", "localhost", "::1")
current = os.environ.get("NO_PROXY") or os.environ.get("no_proxy") or ""
entries = [entry.strip() for entry in current.split(",") if entry.strip()]
entries.extend(host for host in loopback_hosts if host not in entries)
merged = ",".join(entries)
os.environ["NO_PROXY"] = merged
os.environ["no_proxy"] = merged


def _resolve_frontend_dist() -> Optional[str]:
"""Locate the built frontend for StaticFiles hosting."""
env = os.getenv("FRONTEND_DIST_DIR", "").strip()
Expand All @@ -77,6 +97,7 @@ def _resolve_frontend_dist() -> Optional[str]:

def apply_local_env(port: int) -> dict:
"""Populate the local-profile env (idempotent; real env wins) + data dirs."""
ensure_loopback_proxy_bypass()
dd = data_dir()
for sub in ("", "storage", "workspace", "logs", "node", "node/browsers", "fonts"):
(dd / sub).mkdir(parents=True, exist_ok=True)
Expand Down
12 changes: 10 additions & 2 deletions src/backend/services/script_runner_service/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -480,15 +480,23 @@ async def put_file(req: PutFileRequest):
return {"ok": True, "size": len(content)}


# /get_file serves both sandbox_get_artifact (small outputs) and the internal
# site-publish flow, whose tar pack is allowed up to 40MB (internal_sites
# MAX_PACK_BYTES) — a fetch cap below that makes larger site publishes fail
# after a successful in-sandbox tar. Keep a generous ceiling here; callers
# enforce their own tighter budgets.
MAX_FETCH_FILE_SIZE = 64 * 1024 * 1024


@app.post("/get_file", response_model=GetFileResponse)
async def get_file(req: GetFileRequest):
"""Read a file from the sandbox and return it base64-encoded. Used by sandbox_get_artifact to register outputs as artifacts."""
p = _validate_workspace_path(req.path)
if not p.is_file():
raise HTTPException(404, f"文件不存在: {req.path}")
data = p.read_bytes()
if len(data) > MAX_FILE_SIZE:
raise HTTPException(413, f"文件过大: {len(data)} > {MAX_FILE_SIZE}")
if len(data) > MAX_FETCH_FILE_SIZE:
raise HTTPException(413, f"文件过大: {len(data)} > {MAX_FETCH_FILE_SIZE}")
return GetFileResponse(
content_b64=base64.b64encode(data).decode("ascii"),
size=len(data),
Expand Down
32 changes: 32 additions & 0 deletions src/backend/tests/test_local_profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -468,6 +468,38 @@ def test_kb_milvus_lite_dense_only(tmp_path, monkeypatch):
assert len(hits) == 1 and hits[0]["content"] == "机器学习"


# ── Loopback proxy bypass (desktop/local internal calls must not hit env proxies) ─


def test_local_env_merges_loopback_hosts_into_no_proxy(monkeypatch):
"""A system proxy (http_proxy/all_proxy) must never intercept the local
profile's 127.0.0.1 service mesh — httpx honours those vars and a desktop
proxy (e.g. Clash) answers loopback targets with 502, breaking sidecar
readiness and site publishing. Existing NO_PROXY entries must survive."""
import cli

monkeypatch.setenv("http_proxy", "http://127.0.0.1:7897")
monkeypatch.setenv("NO_PROXY", "internal.corp")
monkeypatch.delenv("no_proxy", raising=False)

cli.ensure_loopback_proxy_bypass()

import os

for var in ("NO_PROXY", "no_proxy"):
entries = os.environ[var].split(",")
assert "internal.corp" in entries
assert "127.0.0.1" in entries
assert "localhost" in entries
assert "::1" in entries
# The proxy itself stays configured — external model/API calls may need it.
assert os.environ["http_proxy"] == "http://127.0.0.1:7897"

# Idempotent: a second call must not duplicate entries.
cli.ensure_loopback_proxy_bypass()
assert os.environ["NO_PROXY"].split(",").count("127.0.0.1") == 1


# ── /workspace alias (site-building + skills work when WORKSPACE != /workspace) ─


Expand Down
Loading