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
3 changes: 2 additions & 1 deletion PhyAgentOS/cli/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
from PhyAgentOS.config.paths import get_workspace_path
from PhyAgentOS.config.schema import Config
from PhyAgentOS.utils.helpers import sync_workspace_templates
from PhyAgentOS.utils.proxy import normalize_proxy_env

app = typer.Typer(
name="paos",
Expand Down Expand Up @@ -204,7 +205,7 @@ def main(
),
):
"""PhyAgentOS - Personal AI Assistant."""
pass
normalize_proxy_env()


# ============================================================================
Expand Down
57 changes: 57 additions & 0 deletions PhyAgentOS/utils/proxy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
"""Proxy environment fixes applied before any HTTP client is created."""

from __future__ import annotations

import ipaddress
import os
import re

_PROXY_URL_VARS = {"all_proxy", "http_proxy", "https_proxy"}
_NO_PROXY_SEPARATOR = re.compile(r"[,;]")
_BRACKETED_HOST = re.compile(r"\[(?P<address>[^\]]+)\](?::\d+)?(?P<prefix>/\d+)?")


def _httpx_no_proxy_entry(entry: str) -> str | None:
"""Return the form of a NO_PROXY entry httpx can parse, or None to drop it."""
host = entry.strip()
if "://" not in host and not host.isascii():
return None
if match := _BRACKETED_HOST.fullmatch(host):
host = match["address"] + (match["prefix"] or "")
address, slash, prefix = host.partition("/")
try:
if ipaddress.ip_address(address).version != 6:
return entry
except ValueError:
return entry
if slash and prefix != "128":
return None
# Bare IPv6 addresses already work; keep them byte for byte.
return address if match or slash else entry


def normalize_proxy_env() -> None:
"""Rewrite proxy environment values that httpx <= 0.28.1 cannot use.

With a proxy set, any of these makes every client fail at construction:

- ``socks://`` proxy URLs, an alias httpx rejects, become ``socks5://``.
- Bracketed IPv6 NO_PROXY entries (``[::1]``, ``[fd00::1]:8080``) become
the bare address, which httpx turns into a working bypass.
- IPv6 CIDR NO_PROXY entries: ``/128`` is one address and becomes that
address; wider ranges (``fd00::/8``) are dropped. httpx never matched
NO_PROXY entries as CIDR ranges, so those hosts now use the proxy.
- Non-ASCII host entries (``中国.cn``) are dropped.

Variable names match case-insensitively, as in ``urllib``. NO_PROXY
entries may be separated by ``,`` or ``;`` and are rejoined with ``,``
without duplicates. IPv4 ranges, host names, wildcards and URL-form
entries are kept unchanged.
"""
for var, value in list(os.environ.items()):
name = var.lower()
if name in _PROXY_URL_VARS and value.lower().startswith("socks://"):
os.environ[var] = "socks5://" + value[len("socks://"):]
elif name == "no_proxy" and value:
entries = (_httpx_no_proxy_entry(entry) for entry in _NO_PROXY_SEPARATOR.split(value))
os.environ[var] = ",".join(dict.fromkeys(entry for entry in entries if entry is not None))
1 change: 1 addition & 0 deletions docs/en/02-user-manual.md
Original file line number Diff line number Diff line change
Expand Up @@ -499,6 +499,7 @@ does not delete execution records or evolution history.

| Symptom | Check |
|:--------|:------|
| With a proxy set, provider configure/test reports `Endpoint configuration error`, or httpx reports `Invalid port`, `Invalid IDNA hostname` or `Unknown scheme for proxy URL` | httpx 0.28.1 and earlier cannot parse some proxy environment values. At startup, `paos` rewrites them for its own process only, leaving system settings unchanged: `socks://` becomes `socks5://`; bracketed IPv6 `NO_PROXY` entries such as `[::1]` lose their brackets; single-address `/128` entries such as `::1/128` become that address; wider IPv6 ranges such as `fd00::/8` and non-ASCII host names are dropped, so requests to them use the proxy. Variable names match case-insensitively and `NO_PROXY` may also use `;` separators. Scripts that import PhyAgentOS directly must adjust these variables themselves. |
| Tool not found or not ready | Run `forge_tool_context`; confirm ToolSpec, binding, Endpoint, and Runtime profile. |
| Skill will not install | Confirm Skill bundle metadata has size and SHA-256, every Node lock has a SHA-256, and each Registry/index Node resolves to a sized direct download. |
| Skill will not start | Run `paos skill status` and `logs`; verify Dora, dataflow paths, assets, nodes, and Gateway `/tools`. |
Expand Down
1 change: 1 addition & 0 deletions docs/zh/02-user-manual.md
Original file line number Diff line number Diff line change
Expand Up @@ -466,6 +466,7 @@ evidence。Evolution fail-open,反思错误不会改变执行或验证。

| 现象 | 检查项 |
|:-----|:-------|
| 设置了代理后,Provider 配置或测试报 `Endpoint configuration error`,或 httpx 报 `Invalid port`、`Invalid IDNA hostname`、`Unknown scheme for proxy URL` | httpx 0.28.1 及更早版本无法解析部分代理环境变量的写法。`paos` 启动时只在当前进程内改写这些值,系统设置不变:`socks://` 改为 `socks5://`;`NO_PROXY` 中带方括号的 IPv6(如 `[::1]`)去掉方括号;`/128` 单地址网段(如 `::1/128`)改为该地址;更大的 IPv6 网段(如 `fd00::/8`)和非 ASCII 域名被删除,发往这些地址的请求改走代理。变量名不区分大小写,`NO_PROXY` 也接受 `;` 分隔。直接导入 PhyAgentOS 的脚本需自行修改这些环境变量。 |
| Tool 不存在或未就绪 | 运行 `forge_tool_context`,检查 ToolSpec、binding、Endpoint 与 Runtime profile。 |
| Skill 无法安装 | 确认 Skill Bundle 元数据包含 size、SHA-256,每个 Node lock 包含 SHA-256,且 Registry/index Node 能解析为具有明确大小的直接下载。 |
| Skill 无法启动 | 运行 `paos skill status` 与 `logs`,检查 Dora、dataflow、assets、nodes 和 Gateway `/tools`。 |
Expand Down
90 changes: 90 additions & 0 deletions tests/test_proxy_env.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
"""Proxy environment values that httpx cannot parse are normalized at CLI startup."""

from __future__ import annotations

import os

import httpx
import pytest
from typer.testing import CliRunner

from PhyAgentOS.cli.commands import app
from PhyAgentOS.utils.proxy import normalize_proxy_env

_BROKEN_NO_PROXY = "localhost,[::1],::1/128,fd00::/8"
_FIXED_NO_PROXY = "localhost,::1"


@pytest.fixture(autouse=True)
def _clean_proxy_env(monkeypatch):
for var in list(os.environ):
if var.lower().endswith("_proxy"):
monkeypatch.delenv(var)


@pytest.mark.parametrize(
("value", "expected"),
[
# The unbracketed duplicate collapses into the existing ::1.
("localhost,127.0.0.1,::1,[::1]", "localhost,127.0.0.1,::1"),
# httpx has no port-scoped IPv6 bypass, so the port is dropped.
("[fd00::1]:8080,[FD00::2],[fe80::1%eth0]", "fd00::1,FD00::2,fe80::1%eth0"),
# A /128 range is exactly one address and keeps bypassing it.
("localhost,127.0.0.0/8,::1/128,[::2]/128", "localhost,127.0.0.0/8,::1,::2"),
# Wider IPv6 ranges and invalid masks go; IPv4 ranges never crashed httpx.
("10.0.0.0/8,fd7a:115c:a1e0::/48,[fd00::]/8,::1/129", "10.0.0.0/8"),
("localhost;[::1];<local>", "localhost,::1,<local>"),
# Non-ASCII hosts fail IDNA after httpx adds its "*" prefix; punycode works.
("中国.cn,.中国.cn,*.中国.cn,xn--fiqs8s.cn", "xn--fiqs8s.cn"),
],
)
@pytest.mark.parametrize("var", ["NO_PROXY", "no_proxy", "No_Proxy"])
def test_normalizes_no_proxy_entries(monkeypatch, var, value, expected):
monkeypatch.setenv(var, value)
normalize_proxy_env()
assert os.environ[var] == expected


def test_keeps_supported_no_proxy_entries(monkeypatch):
value = "example.com, .corp,*.internal,10.0.0.0/8,[127.0.0.1],fe80::1%eth0,http://[::1]:8080,*"
monkeypatch.setenv("NO_PROXY", value)
normalize_proxy_env()
assert os.environ["NO_PROXY"] == value


@pytest.mark.parametrize("var", ["ALL_PROXY", "all_proxy", "HTTPS_PROXY", "http_proxy", "Https_Proxy"])
@pytest.mark.parametrize("scheme", ["socks", "SOCKS", "Socks"])
def test_rewrites_socks_alias(monkeypatch, var, scheme):
monkeypatch.setenv(var, f"{scheme}://127.0.0.1:7897/")
normalize_proxy_env()
assert os.environ[var] == "socks5://127.0.0.1:7897/"


@pytest.mark.parametrize("url", ["socks5://127.0.0.1:7897", "socks5h://127.0.0.1:7897", "http://127.0.0.1:7897"])
def test_keeps_supported_proxy_urls(monkeypatch, url):
monkeypatch.setenv("ALL_PROXY", url)
normalize_proxy_env()
assert os.environ["ALL_PROXY"] == url


@pytest.mark.parametrize(
"no_proxy",
[_BROKEN_NO_PROXY, "localhost,127.0.0.0/8,::1/128", "localhost;[::1]", "[::1],中国.cn"],
)
def test_httpx_client_builds_and_bypasses_loopback(monkeypatch, no_proxy):
monkeypatch.setenv("HTTPS_PROXY", "http://127.0.0.1:7897")
monkeypatch.setenv("ALL_PROXY", "socks://127.0.0.1:7897")
monkeypatch.setenv("No_Proxy", no_proxy)
normalize_proxy_env()
with httpx.Client() as client:
# Building the client is not enough: loopback must still skip the proxy.
assert client._transport_for_url(httpx.URL("http://[::1]:8000/")) is client._transport


def test_cli_entry_normalizes_before_commands(monkeypatch, tmp_path):
monkeypatch.setenv("NO_PROXY", _BROKEN_NO_PROXY)
result = CliRunner().invoke(
app, ["provider", "list", "--json", "-c", str(tmp_path / "config.json")]
)
assert result.exit_code == 0, result.output
assert os.environ["NO_PROXY"] == _FIXED_NO_PROXY