Skip to content

feat(env): 托管 agent CLI 的 PI_PROXY_* 变量(write/install + status/doctor) - #3

Closed
crhan wants to merge 1 commit into
mainfrom
feat/agent-env-proxy-vars
Closed

crhan wants to merge 1 commit into
mainfrom
feat/agent-env-proxy-vars

Conversation

@crhan

@crhan crhan commented Sep 23, 2026

Copy link
Copy Markdown
Owner

背景

通用 HTTP(S)_PROXY 覆盖不到所有 agent transport。以 omp(pi 系)为例:

  • provider 为 anthropic + api 为 anthropic-messages 时走 cowork-fetch(自建 node:https + 固定 https.Agent),不读 env proxy,只有 fetch init 里带显式 proxy 时才让位给 globalThis.fetch;
  • 而那个 proxy 只来自 PI_PROXY_<PROVIDER> / PI_PROXY(transport-fetch.ts 里的 hMe()),没有 HTTPS_PROXY 回退;
  • 于是出现"usage 探针(普通 fetch,走 env proxy)一直活着,模型请求却全红"的错觉:模型请求直连 Anthropic,受限地区返回
    403 {"error":{"type":"forbidden","message":"Request not allowed"}},cf-ray 落在与直连一致的边缘节点。

实测签名(本机):

路径 结果
直连 POST /v1/messages 403 · cf-ray …-LAX
经 127.0.0.1:7890 401(鉴权错误,即已过地区检查)· cf-ray …-SIN

proxyctl 是 7890(引擎)与兜底出口(如 7891 claude-proxy)的持有者,因此由它把这组变量按生命周期托管,而不是让用户手工 export。

改动

新增 proxyctl.agent_env — 契约文件 ~/.config/proxyctl/agent-env.sh(首行为机读元信息,下面是可 source 的 export 行):

  • 出口按存活解析:引擎端口在听 → 指向引擎;否则指向 agent_env.fallback_daemon 声明的 port;两个都没在听 → 删除文件(避免 agent 一直打到死口);
  • 内容不变则跳过重写(generated= 时间戳不参与比对),start 不会每次弄脏文件;
  • provider 名 → 变量名规则与 pi 系一致:anthropic → PI_PROXY_ANTHROPIC。

生命周期自动刷新:start / stop / restart / fix / daemon(start|stop|restart) / engine 之后各调一次 agent_env.sync(),失败只 warning 不中断主流程。

CLI(proxyctl env):

用法 行为
proxyctl env 通用变量 + PI_PROXY_* 的 export 行;无存活出口时改输出 unset,避免 eval 后残留死口
--unset 同一组变量一起清(含 PI_PROXY_*)
--write 立即按当前出口刷新契约文件
--install --write + 往 shell rc(默认 ~/.zprofile)注入 marker 块,幂等
--uninstall 摘掉 marker 块 + 删契约文件
--json data.vars + data.agent_env

--write/--install/--uninstall 均支持 --dry-run(新增 _plan_env)。

诊断:

  • status 新增 AGENT ENV 段;status --json 新增 data.agent_env(路径 / 出口与来源 / reachable / in_sync / shell_rc_installed);
  • doctor 新增 3 条建议,均带 fix_command 且 auto_fixable=true:
    • agent_env.dead_endpoint(warn)文件指向已停的出口
    • agent_env.missing(advisory)有存活出口但没生成文件
    • agent_env.not_sourced(advisory)文件在但 shell rc 没 source 它

配置 / 文档:config.yaml.example 新增 agent_env: {enabled, providers, fallback_daemon, shell_rc};man(env 条目 + ENVIRONMENT + FILES);CHANGELOG [Unreleased];bash / zsh / fish 补全;supported_features 新增 agent_env_contract、status_agent_env。

兼容性

  • proxyctl env 无参输出追加 PI_PROXY_* 行(无存活出口时是 unset),原有通用变量行不变;
  • cmd_env 保留 unset= 关键字(集成 smoke 依赖的调用形状),新增的 mode= / shell_rc= 为 keyword-only;
  • env 的 side_effects 仍为 [],写行为走 conditional_side_effects;
  • 新增 agent_env 配置段有默认值,老配置不用改;
  • 刻意不接管通用 HTTP(S)_PROXY —— 那属于用户 shell 的静态约定,引擎停时不应该被 proxyctl 悄悄摘掉。
  • 版本号未动:0.5.13 仍未发布([Unreleased] 继续累积,遵循 AGENTS 的 bump 约定)。

验证

  • uv run pytest -q → 855 passed(新增 tests/unit/test_agent_env.py 37 条:出口解析、幂等重写、无出口删除、rc 注入/摘除、status 字段、3 条规则、各 CLI 模式与 dry-run;另补 completion 与 suggestion-id 白名单断言)。
  • 真机 A/B(同机同模型,agent 侧日志):
15:12:34  provider proxy resolved provider=anthropic source=none
          cowork transport rejected  url=api.anthropic.com/v1/messages status=403  cf-ray=…-LAX
15:13:29  provider proxy resolved provider=anthropic source=PI_PROXY_ANTHROPIC proxy=127.0.0.1:7890
          cowork transport bypassed  url=api.anthropic.com/v1/messages reason=proxy-configured
          → 请求成功;同时刻 mihomo 日志记到 api.anthropic.com:443 → proxy[新加坡5]
  • proxyctl env --install 后新开 login shell 里 PI_PROXY_ANTHROPIC 已生效;status 显示 ✓ PI_PROXY_ANTHROPIC=… (engine);伪造 dead endpoint → doctor 报 agent_env.dead_endpoint;PROXYCTL_SHELL_RC=/tmp/nonexistent → 报 agent_env.not_sourced。

备注

  • 变量是进程启动时读取的:正在跑的 agent 进程需要重启才会拿到新值(文档/man 里已写明)。
  • 测试用 PROXYCTL_AGENT_ENV_PATH / PROXYCTL_SHELL_RC 覆盖路径,不碰真实用户文件。

通用 HTTP(S)_PROXY 覆盖不到所有 agent transport:omp 的 anthropic-messages
transport(cowork-fetch)只认 PI_PROXY_<PROVIDER>,没有这个变量就直连,
在受限地区拿到 403 Request not allowed;而 usage 探针等普通 fetch 走 env
proxy 正常,表现为"探针活着、模型请求全红"。proxyctl 是 7890 / 兜底 7891
的持有者,因此由它把这组变量按生命周期托管起来。

- 新增 proxyctl.agent_env:出口按存活解析(引擎端口 → agent_env.fallback_daemon
  的 port → 都没有),落成可 source 的契约文件
  ~/.config/proxyctl/agent-env.sh;无存活出口时删除文件,避免 agent 打到死口
- start / stop / restart / fix / daemon / engine 后自动刷新;env --write /
  --install / --uninstall 显式管理,三种写模式支持 --dry-run(_plan_env)
- env 无参输出追加 PI_PROXY_* export 行(无存活出口时输出 unset),
  新增 --json;--unset 同时清 PI_PROXY_*
- status 新增 AGENT ENV 段与 envelope.data.agent_env
- doctor 新增 agent_env.dead_endpoint(warn) / agent_env.missing /
  agent_env.not_sourced,均带 fix_command 且 auto_fixable=true
- config.yaml.example 新增 agent_env: 段;man / CHANGELOG / bash+zsh+fish 补全同步
- 刻意不接管通用 HTTP(S)_PROXY:那属于用户 shell 的静态约定,引擎停时不应被
  proxyctl 悄悄摘掉

Tests: uv run pytest -q → 855 passed(新增 tests/unit/test_agent_env.py 37 条)
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-09-23T07:35:58.236030Z 1daf010 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1daf010e6e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/proxyctl/cli.py
Comment on lines +1527 to +1530
if mode == "unset":
for var in list(_env_generic_vars(config)) + agent_env.var_names(cfg):
print(f"unset {var};")
return

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Emit an envelope for JSON unset requests

When --json is active—especially via PROXYCTL_AGENT=1—the unset branch still prints shell statements and returns before the JSON envelope path. Thus proxyctl env --unset --json produces non-JSON stdout even though env advertises JSON support, breaking machine consumers that parse every invocation as an envelope. Emit structured data for this mode, reserving shell statements for non-JSON output.

AGENTS.md reference: AGENTS.md:L55-L60

Useful? React with 👍 / 👎.

Comment thread src/proxyctl/agent_env.py
Comment on lines +202 to +205
tmp = f"{path}.tmp"
with open(tmp, "w", encoding="utf-8") as f:
f.write(text)
os.replace(tmp, path)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve restrictive permissions when rewriting shell rc files

When the configured shell rc already has restrictive permissions, such as mode 0600 because it contains credentials, _write_atomic creates a new file using the process umask and replaces the original; with a typical 022 umask, env --install or --uninstall silently changes it to 0644, exposing its contents to other local users. Preserve the destination mode when replacing an existing file.

Useful? React with 👍 / 👎.

Comment thread src/proxyctl/cli.py
Comment on lines +3013 to +3014
cmd_env(ctx["config"], unset=(mode == "unset"), mode=mode,
shell_rc=shell_rc)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Run env write modes under a write lock

For --write, --install, and --uninstall, the handler performs the dry-run check and then invokes cmd_env directly rather than through _exec_with_lock. Concurrent invocations can therefore race on the fixed .tmp path or overwrite each other's read-modify-write edits to the shell rc, instead of returning the documented LOCKED exit code. Route these modes through an appropriate write lock.

AGENTS.md reference: AGENTS.md:L241-L242

Useful? React with 👍 / 👎.

Comment thread src/proxyctl/agent_env.py
"stale": bool(present and port and not reachable),
"expected_endpoint": expected_endpoint,
"expected_source": expected_source,
"in_sync": bool(present and endpoint == expected_endpoint),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Compare the full contract when reporting in_sync

If a user changes agent_env.providers while the selected endpoint remains the same, the existing contract can still contain only the old PI_PROXY_* variables, yet this expression reports in_sync: true. The status panel and doctor therefore provide no indication that newly configured providers are absent; compare the loaded providers/variables (and relevant metadata) with the desired rendered contract rather than only the endpoint.

Useful? React with 👍 / 👎.

@crhan

crhan commented Sep 23, 2026

Copy link
Copy Markdown
Owner Author

由 #4 替代,关闭。

不合并的原因:

  • 按存活改写 / 删除契约文件只在 shell 启动时生效,对运行中的 agent 做不到 failover;两个出口都不在时删文件,agent 会退回直连(fail-open)。
  • 默认对所有用户开启,还新增了会改 ~/.zprofile 的 auto_fixable 建议。
  • 实现问题:env --unset --json 输出不是 JSON;改写 rc 时丢失 0600 权限、把软链接替换成普通文件、标记不配对时吞掉后续行;写模式没加锁;in_sync 不比对 providers;explain env 的 file 指向生成文件;PROXYCTL_AGENT=1 proxyctl env 改成输出 JSON,eval 会坏。

#4 只保留核心:proxy_env_extra 配置 + proxyctl env 静态导出 / 清除。

@crhan crhan closed this Sep 23, 2026
crhan added a commit that referenced this pull request Sep 23, 2026
新增 proxy_env_extra 配置:列出的变量名由 `proxyctl env` 导出为引擎的 HTTP 代理地址,`proxyctl env --unset` 一并清除;给不读 HTTP(S)_PROXY、只认自家变量的工具用(如 omp 的 PI_PROXY_ANTHROPIC)。默认为空;非法变量名和 YAML 解析出的非字符串项会跳过并在 stderr 提示。

替代 #3。
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant