From 7e0009ff92c54b2fa9b1fe39ff6b5d5cbacd265b Mon Sep 17 00:00:00 2001 From: "ruohan.chen" Date: Wed, 23 Sep 2026 17:09:28 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(env):=20proxy=5Fenv=5Fextra=20?= =?UTF-8?q?=E2=80=94=20proxyctl=20env=20=E9=A2=9D=E5=A4=96=E5=AF=BC?= =?UTF-8?q?=E5=87=BA=20agent=20=E4=B8=93=E7=94=A8=E4=BB=A3=E7=90=86?= =?UTF-8?q?=E5=8F=98=E9=87=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit omp 的 anthropic-messages transport 只读 PI_PROXY_ / PI_PROXY, 不读 HTTPS_PROXY,缺了就直连(受限地区 403 Request not allowed)。 新增 proxy_env_extra 配置:列出的变量名由 `proxyctl env` 导出为引擎的 HTTP 代理地址(http://127.0.0.1:),`env --unset` 一并清除。 默认为空,现有输出不变;输出会被 eval,非法变量名跳过并在 stderr 提示, 与内置代理变量同名的项不重复导出。 替代 #3:不再维护契约文件、生命周期刷新、shell rc 注入与 doctor 规则。 --- CHANGELOG.md | 10 +++++++++ README.md | 2 ++ config.yaml.example | 9 +++++++- man/proxyctl.1 | 4 +++- src/proxyctl/cli.py | 41 +++++++++++++++++++++++++++++++--- src/proxyctl/explain.py | 8 +++++-- tests/unit/test_cli_helpers.py | 24 ++++++++++++++++++++ 7 files changed, 91 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index da7d1ec..4a819ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ ## [Unreleased] +### Added + +- **新增 `proxy_env_extra` 配置:`proxyctl env` 可以额外导出指定的代理变量。** + 列出的变量名会被 `proxyctl env` 导出为引擎的 HTTP 代理地址 + (`http://127.0.0.1:`),`proxyctl env --unset` 一并清除。 + 用于不读 `HTTP(S)_PROXY`、只认自家变量的工具——例如 omp 的 Anthropic 模型请求 + 只读 `PI_PROXY_ANTHROPIC` / `PI_PROXY`,缺了就直连,受限地区返回 + `403 Request not allowed`;配置 `proxy_env_extra: [PI_PROXY_ANTHROPIC]` 即可。 + 默认为空,`proxyctl env` 的现有输出不变;非法变量名会被跳过并在 stderr 提示。 + ## [0.5.13] — 2026-07-17 ### Fixed diff --git a/README.md b/README.md index b6a74c0..3c702de 100644 --- a/README.md +++ b/README.md @@ -250,6 +250,8 @@ proxy_port: 7890 # 自 0.1.4 起可配 no_proxy_extra: # 自 0.1.5 起追加 NO_PROXY - "*.internal.example.com" - 10.0.0.0/8 +proxy_env_extra: # 自 0.5.14 起:额外导出为代理地址的变量名 + - PI_PROXY_ANTHROPIC # omp 的 Anthropic 请求只读它,不读 HTTPS_PROXY ``` 完整字段见 [config.yaml.example](config.yaml.example)。 diff --git a/config.yaml.example b/config.yaml.example index 6151d76..86e7c1e 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -61,7 +61,7 @@ extra_daemons: {} # port: 7891 # ───────────────────────────────────────────────────────────── -# 代理端口与 NO_PROXY +# 代理端口与代理环境变量(NO_PROXY / 额外变量) # ───────────────────────────────────────────────────────────── # 引擎对外暴露的 HTTP/SOCKS mixed-port(应与 mihomo/sing-box 配置一致) # 默认 7890;同机多实例(如 Docker 已占 7890)时改这里 @@ -75,6 +75,13 @@ extra_daemons: {} # - 100.64.0.0/10 # Tailscale CGNAT # - .ts.net # Tailscale MagicDNS +# 额外导出为代理地址(http://127.0.0.1:)的环境变量名: +# `proxyctl env` 一并 export,`proxyctl env --unset` 一并 unset。 +# 给不读 HTTP(S)_PROXY、只认自家变量的工具用——例如 omp 的 Anthropic 模型请求 +# 只读 PI_PROXY_ANTHROPIC / PI_PROXY,缺了就直连(受限地区返回 403)。 +# proxy_env_extra: +# - PI_PROXY_ANTHROPIC + # ───────────────────────────────────────────────────────────── # 其他配置 # ───────────────────────────────────────────────────────────── diff --git a/man/proxyctl.1 b/man/proxyctl.1 index 9adb5da..022ad09 100644 --- a/man/proxyctl.1 +++ b/man/proxyctl.1 @@ -186,6 +186,8 @@ rulePayload / host / IP 聚合目的地,输出 app_contexts / candidate_contex .TP .B proxyctl env [--unset] 输出 / 清除 HTTP_PROXY / HTTPS_PROXY / NO_PROXY 环境变量(可 \fIeval\fR)。 +配置 \fBproxy_env_extra\fR 列出的变量名(如 omp 的 \fBPI_PROXY_ANTHROPIC\fR) +一并导出为同一 HTTP 代理地址、一并清除。 .TP .B proxyctl log [--tail \fIN\fR] [--no-follow] [--json] 查看后端日志。默认 \fItail -f\fR;\fB--json\fR 为 NDJSON(首行 meta header + 数据行)。 @@ -271,7 +273,7 @@ DEPENDENCY_MISSING — 依赖二进制 / 脚本缺失 .SH FILES .TP .I ~/.config/proxyctl/config.yaml -proxyctl 自身配置(API token / 端口 / 企业 DNS / extra_daemons / no_proxy_extra / plugins_disabled)。 +proxyctl 自身配置(API token / 端口 / 企业 DNS / extra_daemons / no_proxy_extra / proxy_env_extra / plugins_disabled)。 .TP .I ~/.config/mihomo/config.yaml mihomo 引擎配置(rules / proxies / proxy-providers / dns / tun)。 diff --git a/src/proxyctl/cli.py b/src/proxyctl/cli.py index 1eb1847..8246c3d 100755 --- a/src/proxyctl/cli.py +++ b/src/proxyctl/cli.py @@ -55,6 +55,9 @@ # 个人附加的 NO_PROXY 项(追加到默认 localhost/私网集合之后) # 例: ["corp.example.com", "intranet.local"] 或 "corp.example.com,intranet.local" "no_proxy_extra": [], + # 额外导出为代理地址的环境变量名:`proxyctl env` 一并 export,`env --unset` 一并 unset。 + # 给不读 HTTP(S)_PROXY、只认自家变量的工具,例: ["PI_PROXY_ANTHROPIC"](omp) + "proxy_env_extra": [], } SCRIPTS_DIR = os.path.dirname(os.path.realpath(__file__)) @@ -1352,6 +1355,34 @@ def _normalize_no_proxy_extra(extra) -> list[str]: return out +_ENV_PROXY_VARS = ("http_proxy", "https_proxy", "all_proxy", + "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", + "no_proxy", "NO_PROXY") +_ENV_VAR_NAME_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*") + + +def _normalize_proxy_env_extra(extra) -> list[str]: + """proxy_env_extra → 可安全 eval 的变量名列表(去重、保序)。 + + `proxyctl env` 的输出会被 eval:非法变量名一律跳过并在 stderr 提示; + 与内置代理变量同名的项跳过(内置那组已导出,不能被改写成 HTTP 地址)。 + """ + if not extra: + return [] + items = extra.split(",") if isinstance(extra, str) else extra + out: list[str] = [] + for item in items: + name = str(item).strip() + if not name or name in out or name in _ENV_PROXY_VARS: + continue + if not _ENV_VAR_NAME_RE.fullmatch(name): + print(f"{YELLOW}警告:proxy_env_extra 跳过非法变量名 {name!r}{NC}", + file=sys.stderr) + continue + out.append(name) + return out + + def cmd_env(config: dict, unset: bool = False): """输出设置/清除代理环境变量的 shell 语句。 @@ -1359,14 +1390,16 @@ def cmd_env(config: dict, unset: bool = False): eval $(proxyctl env) # 设置代理 eval $(proxyctl env --unset) # 清除代理 + config.proxy_env_extra 列出的变量名一并 export 为 HTTP 代理地址、一并 unset, + 给不读 HTTP(S)_PROXY、只认自家变量的工具用(如 omp 的 PI_PROXY_ANTHROPIC)。 + Args: config: 全局配置字典 unset: True 则输出 unset 语句 """ + extra_vars = _normalize_proxy_env_extra(config.get("proxy_env_extra")) if unset: - for var in ("http_proxy", "https_proxy", "all_proxy", - "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", - "no_proxy", "NO_PROXY"): + for var in _ENV_PROXY_VARS + tuple(extra_vars): print(f"unset {var};") return @@ -1387,6 +1420,8 @@ def cmd_env(config: dict, unset: bool = False): print(f"export {var}={proxy_socks};") for var in ("no_proxy", "NO_PROXY"): print(f"export {var}={no_proxy};") + for var in extra_vars: + print(f"export {var}={proxy_http};") # ── 命令:log ───────────────────────────────────────────────────────────────── diff --git a/src/proxyctl/explain.py b/src/proxyctl/explain.py index 2e0079f..6753453 100644 --- a/src/proxyctl/explain.py +++ b/src/proxyctl/explain.py @@ -215,11 +215,15 @@ def _t_env(backend, config) -> TopicCard: "summary": ( "代理环境变量(HTTP_PROXY / HTTPS_PROXY / NO_PROXY 等)。" f"`proxyctl env` 输出 export 行,可 eval 进当前 shell(端口 {port})。" + "只认自家变量、不读 HTTP(S)_PROXY 的工具(如 omp 的 " + "PI_PROXY_ANTHROPIC)把变量名加进 proxy_env_extra,随 env 一并导出。" ), - "file": _io_proxyctl_config_path() + " [no_proxy_extra: 字段]", + "file": _io_proxyctl_config_path() + " [no_proxy_extra / proxy_env_extra 字段]", "edit": ( " # no_proxy_extra: 追加内网域名 / IPv4 CIDR / 企业 host\n" - " # 裸 IPv6 CIDR 会被跳过,避免 Python/httpx 误解析 NO_PROXY" + " # 裸 IPv6 CIDR 会被跳过,避免 Python/httpx 误解析 NO_PROXY\n" + " # proxy_env_extra: 额外导出为代理地址的变量名,例 [PI_PROXY_ANTHROPIC]\n" + " # env --unset 一并清除;非法变量名会被跳过" ), "verify": "eval \"$(proxyctl env)\" && env | grep -i proxy", "next_commands": ["env", "env --unset"], diff --git a/tests/unit/test_cli_helpers.py b/tests/unit/test_cli_helpers.py index 5ee35a1..9c4d737 100644 --- a/tests/unit/test_cli_helpers.py +++ b/tests/unit/test_cli_helpers.py @@ -80,6 +80,30 @@ def test_normalize_no_proxy_extra_filters_ipv6_cidr_from_csv(): ) == [".crhan.com", ".ts.net", "100.64.0.0/10"] +def test_cmd_env_exports_and_unsets_proxy_env_extra(capsys): + """proxy_env_extra 的变量随 env 导出为 HTTP 代理地址,随 env --unset 清除。""" + config = {"proxy_port": 7891, "proxy_env_extra": ["PI_PROXY_ANTHROPIC"]} + + cli.cmd_env(config) + exported = capsys.readouterr().out.splitlines() + assert "export PI_PROXY_ANTHROPIC=http://127.0.0.1:7891;" in exported + + cli.cmd_env(config, unset=True) + assert "unset PI_PROXY_ANTHROPIC;" in capsys.readouterr().out.splitlines() + + +def test_normalize_proxy_env_extra_keeps_eval_output_safe(capsys): + """env 输出会被 eval:非法名字不能进 stdout,内置代理变量不能被改写。""" + names = cli._normalize_proxy_env_extra( + "PI_PROXY_ANTHROPIC, PI_PROXY;id, $(id), 9LIVES, ALL_PROXY, PI_PROXY_ANTHROPIC" + ) + + captured = capsys.readouterr() + assert names == ["PI_PROXY_ANTHROPIC"] + assert captured.out == "" + assert "PI_PROXY;id" in captured.err + + # ──────────────────────────────────────────────────────────────────────────── # wait_port # ──────────────────────────────────────────────────────────────────────────── From 3d10b037b90f5bef2f18bea006397ef05214d340 Mon Sep 17 00:00:00 2001 From: "ruohan.chen" Date: Wed, 23 Sep 2026 17:50:37 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(env):=20proxy=5Fenv=5Fextra=20=E8=B7=B3?= =?UTF-8?q?=E8=BF=87=20YAML=20=E8=A7=A3=E6=9E=90=E5=87=BA=E7=9A=84?= =?UTF-8?q?=E9=9D=9E=E5=AD=97=E7=AC=A6=E4=B8=B2=E9=A1=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit YAML 会把 yes/no/on/off/true/false/null 解析成 bool/None,str() 之后 变成 True/None 能通过变量名校验,于是被导出成 True=… / None=…。 现在列表里的非字符串项跳过并在 stderr 提示加引号;整个值既不是列表 也不是字符串时忽略并提示,不再在迭代时抛 TypeError。 --- CHANGELOG.md | 4 +++- src/proxyctl/cli.py | 22 ++++++++++++++++++---- tests/unit/test_cli_helpers.py | 17 +++++++++++++++++ 3 files changed, 38 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a819ed..c3a0601 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,9 @@ 用于不读 `HTTP(S)_PROXY`、只认自家变量的工具——例如 omp 的 Anthropic 模型请求 只读 `PI_PROXY_ANTHROPIC` / `PI_PROXY`,缺了就直连,受限地区返回 `403 Request not allowed`;配置 `proxy_env_extra: [PI_PROXY_ANTHROPIC]` 即可。 - 默认为空,`proxyctl env` 的现有输出不变;非法变量名会被跳过并在 stderr 提示。 + 默认为空,`proxyctl env` 的现有输出不变。非法变量名、以及 YAML 解析成布尔 / + 空值的项(未加引号的 `yes` / `no` / `on` / `off` / `null` 等)会被跳过并在 + stderr 提示。 ## [0.5.13] — 2026-07-17 diff --git a/src/proxyctl/cli.py b/src/proxyctl/cli.py index 8246c3d..38bb5df 100755 --- a/src/proxyctl/cli.py +++ b/src/proxyctl/cli.py @@ -1366,18 +1366,32 @@ def _normalize_proxy_env_extra(extra) -> list[str]: `proxyctl env` 的输出会被 eval:非法变量名一律跳过并在 stderr 提示; 与内置代理变量同名的项跳过(内置那组已导出,不能被改写成 HTTP 地址)。 + YAML 会把 yes / no / on / off / true / false / null 解析成 bool / None, + 非字符串项同样跳过并提示,不能被 str() 成 True / None 再导出。 """ + def warn(msg: str) -> None: + print(f"{YELLOW}警告:proxy_env_extra {msg}{NC}", file=sys.stderr) + if not extra: return [] - items = extra.split(",") if isinstance(extra, str) else extra + if isinstance(extra, str): + items = extra.split(",") + elif isinstance(extra, (list, tuple)): + items = extra + else: + warn(f"应为列表或逗号分隔字符串,已忽略:{extra!r}") + return [] out: list[str] = [] for item in items: - name = str(item).strip() + if not isinstance(item, str): + warn(f"跳过非字符串项 {item!r}(YAML 会把 yes/no/on/off/true/false/null " + "解析成布尔或空值,这类变量名请加引号)") + continue + name = item.strip() if not name or name in out or name in _ENV_PROXY_VARS: continue if not _ENV_VAR_NAME_RE.fullmatch(name): - print(f"{YELLOW}警告:proxy_env_extra 跳过非法变量名 {name!r}{NC}", - file=sys.stderr) + warn(f"跳过非法变量名 {name!r}") continue out.append(name) return out diff --git a/tests/unit/test_cli_helpers.py b/tests/unit/test_cli_helpers.py index 9c4d737..f074708 100644 --- a/tests/unit/test_cli_helpers.py +++ b/tests/unit/test_cli_helpers.py @@ -11,6 +11,7 @@ from types import SimpleNamespace import pytest +import yaml from proxyctl import cli @@ -104,6 +105,22 @@ def test_normalize_proxy_env_extra_keeps_eval_output_safe(capsys): assert "PI_PROXY;id" in captured.err +def test_cmd_env_skips_yaml_scalars_that_are_not_names(capsys): + """YAML 把 yes / null 解析成 True / None:不能被导出成 True=… / None=…。""" + config = yaml.safe_load( + "proxy_port: 7890\n" + "proxy_env_extra: [PI_PROXY_ANTHROPIC, yes, null]\n" + ) + + cli.cmd_env(config) + + names = [line.split("=", 1)[0] for line in capsys.readouterr().out.splitlines()] + assert "export PI_PROXY_ANTHROPIC" in names + assert "export True" not in names + assert "export None" not in names + assert cli._normalize_proxy_env_extra(True) == [] + + # ──────────────────────────────────────────────────────────────────────────── # wait_port # ────────────────────────────────────────────────────────────────────────────