Browse Source

fix(app-boot): accept the proxy names from the Harness-home .env alone

The proxy guide told users a proxy could live in a project or
`$DSH_HOME` `.env`. It could not: `loadLayeredEnv` refuses the four
proxy names from any discovered file, as it refuses `PATH` and
`NODE_OPTIONS`, and the launch fails with a pointer to `export`.

That refusal is right for the invoking directory's file — it arrives
with a clone, and a repository must not choose where the harness sends
its traffic — and wrong for the user's own `$DSH_HOME/.env`, which
already holds their API key. `readEnvLayer` now accepts `HTTP_PROXY`,
`HTTPS_PROXY`, `ALL_PROXY`, and `NO_PROXY` from the directory that is
the Harness home, and nowhere else. `DSH_HOME` is itself bootstrap-only,
so no `.env` can relocate the exemption; the CA and TLS names in the
same group stay refused everywhere, since they change what is trusted
rather than where traffic goes. A project `.env` that sets a proxy name
still fails the launch, and its message now names the home file as the
second way out. Launching from inside the home directory reads that one
file as the project layer; the exemption follows the directory.

The seven existing refusal cases all write to the project layer and
pass unchanged. Four new cases cover the home layer accepting both
casings below an exported value, the home layer still refusing
`SSL_CERT_FILE`, the project layer's new message, and the same-directory
launch. The guide, both package READMEs, and the two Agent Notes that
stated the old rule now state this one.
Yichen Jiang 2 weeks ago
parent
commit
c44dcb7b85

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md
-2026-08-04-configuration-source-ownership.md: 1fe5908ab77632732996bd1d5c1eed9c8ab048e6
-2026-08-04-configuration-source-ownership.zh.md: 197cb936cdff303e23425d008c7a2cb738500ae0
+2026-08-04-configuration-source-ownership.md: 29dd5fd623d38532502af8c4e4afd972236fc139
+2026-08-04-configuration-source-ownership.zh.md: e5cbf70826b9314daccb7a62e5da4603e38158de

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md

@@ -42,7 +42,7 @@ The launching environment wins because `DEEPSEEK_API_KEY=… dsh`, a CI secret,
 
 **The project the harness is launched in is trusted, by default and without a prompt.** A checkout may carry its own endpoint, its own ordinary variables, and its own key; the key ranks below the managed store, so a key stored through the Models page is never displaced by one a checkout happens to contain. `LaunchEnvironmentSnapshot.getFrom(name, sources)` still searches only the layers a caller names, and omitting one is a refusal rather than a demotion — the mechanism exists for decisions where a layer must be unreachable; this decision includes the project layer.
 
-**Trust does not extend to changing the harness itself.** `loadLayeredEnv` rejects, at load and before anything is materialized, any `.env` that sets a variable governing how a process launches (`PATH`, `SHELL`, `NODE_OPTIONS`, `LD_PRELOAD`), which ambient program handles an operation (`EDITOR`, `PAGER`, `BROWSER`), what code a runtime executes before the program it was asked to run (`BASH_ENV`, `PERL5OPT`, `PYTHONSTARTUP`, `RUBYOPT`, `JAVA_TOOL_OPTIONS`, the Git hook commands), where model-visible instructions load from (the whole `DSH_*` namespace, `HOME`, `XDG_*`), or how the network is reached and trusted (proxy and CA variables). Matching is case-insensitive, so `https_proxy` is not a bypass.
+**Trust does not extend to changing the harness itself.** `loadLayeredEnv` rejects, at load and before anything is materialized, any `.env` that sets a variable governing how a process launches (`PATH`, `SHELL`, `NODE_OPTIONS`, `LD_PRELOAD`), which ambient program handles an operation (`EDITOR`, `PAGER`, `BROWSER`), what code a runtime executes before the program it was asked to run (`BASH_ENV`, `PERL5OPT`, `PYTHONSTARTUP`, `RUBYOPT`, `JAVA_TOOL_OPTIONS`, the Git hook commands), where model-visible instructions load from (the whole `DSH_*` namespace, `HOME`, `XDG_*`), or how the network is reached and trusted (proxy and CA variables). Matching is case-insensitive, so `https_proxy` is not a bypass. One exemption, recorded in [the proxy policy note](2026-08-27-outbound-proxy-policy.md): the four proxy names are accepted from `$DSH_HOME/.env`, which no `.env` can relocate, and still refused from the invoking directory's file.
 
 The line is that these take effect with no user action, before any turn, outside the permission policy and the sandbox. `DSH_PERMISSION_MODE` would switch off the approvals that make trusting a project meaningful at all, and `BASH_ENV` runs a file of the project's choosing on every single `bash -c` the bash tool issues — the project's code running under the agent's policy is the deal; the project rewriting that policy is not. Enumerating these is a losing game one variable at a time, which is why the whole `DSH_*` namespace is denied rather than an audited subset, and why the list is organised by what a variable *does* rather than by which runtime owns it. There is no opt-out: an escape hatch would have to be readable from somewhere, and anything a discovered file could set is the hole itself.
 
@@ -53,7 +53,7 @@ The line is that these take effect with no user action, before any turn, outside
 ## Consequences
 
 - The web credential form now takes effect against an older key in the user's `.env`; only a key exported in the launching shell still makes it read-only, and the diagnostic says so.
-- A `.env` holding `DSH_*`, `PATH`, `BROWSER`, or a proxy variable fails the launch instead of being applied. Developers keeping switches in a repository `.env` move them to their shell — a deliberate, loud break.
+- A `.env` holding `DSH_*`, `PATH`, `BROWSER`, or — in the invoking directory — a proxy variable fails the launch instead of being applied. Developers keeping switches in a repository `.env` move them to their shell — a deliberate, loud break.
 - Composition is no longer overridable by a stale shell endpoint. It is still overridable by a user's stored `settings.yaml`, which is the settings seam's layering and not something this note changes; the product CLI offers no flag above it, so a deployment that must win against stored settings owns its own bin or loader tree.
 - Not solved: the layers are still materialized into `process.env`, so ordinary project variables continue to reach child processes under the subprocess scrub. Bootstrap variables cannot come from a file at all; the environment package records the remaining subprocess reach as a limitation.
 - Exa and Perplexity still capture their key at load time rather than through the credential seam. They no longer read raw `process.env` — they resolve through the trusted layers — but converting them to per-request credential resolution is separate work.

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md

@@ -43,7 +43,7 @@ inherited process environment      (read-only, wins)
 
 **harness 被启动于其中的项目默认可信,且不做询问。** 一个 checkout 可以携带自己的 endpoint、自己的普通变量和自己的密钥;密钥排在受管存储之下,因此通过 Models 页存下的密钥绝不会被 checkout 中恰好带有的那一个顶掉。`LaunchEnvironmentSnapshot.getFrom(name, sources)` 仍然只搜索调用方点名的层,省略某层仍是拒绝而不是降级——该机制供要求某一层不可达的决策使用;本决策包含项目层。
 
-**信任不延伸到改变 harness 本身。** `loadLayeredEnv` 会在加载时、且在物化任何内容之前,拒绝任何设置了下列变量的 `.env`:决定进程如何启动的(`PATH`、`SHELL`、`NODE_OPTIONS`、`LD_PRELOAD`)、决定由哪个环境程序处理一项操作的(`EDITOR`、`PAGER`、`BROWSER`)、决定运行时在执行被要求运行的程序之前先执行哪些代码的(`BASH_ENV`、`PERL5OPT`、`PYTHONSTARTUP`、`RUBYOPT`、`JAVA_TOOL_OPTIONS`、Git 的钩子命令)、决定模型可见指令从哪里加载的(整个 `DSH_*` 命名空间、`HOME`、`XDG_*`),以及决定网络如何访问以及如何建立信任的(proxy 与 CA 变量)。匹配不区分大小写,因此 `https_proxy` 不是绕过手段。
+**信任不延伸到改变 harness 本身。** `loadLayeredEnv` 会在加载时、且在物化任何内容之前,拒绝任何设置了下列变量的 `.env`:决定进程如何启动的(`PATH`、`SHELL`、`NODE_OPTIONS`、`LD_PRELOAD`)、决定由哪个环境程序处理一项操作的(`EDITOR`、`PAGER`、`BROWSER`)、决定运行时在执行被要求运行的程序之前先执行哪些代码的(`BASH_ENV`、`PERL5OPT`、`PYTHONSTARTUP`、`RUBYOPT`、`JAVA_TOOL_OPTIONS`、Git 的钩子命令)、决定模型可见指令从哪里加载的(整个 `DSH_*` 命名空间、`HOME`、`XDG_*`),以及决定网络如何访问以及如何建立信任的(proxy 与 CA 变量)。匹配不区分大小写,因此 `https_proxy` 不是绕过手段。唯一的豁免记录在[代理策略笔记](2026-08-27-outbound-proxy-policy.zh.md)中:四个代理名可从 `$DSH_HOME/.env` 接受——没有任何 `.env` 能挪动该文件——但仍拒绝来自调用目录文件的同名变量。
 
 这条界线在于:它们无需任何用户动作、在任何轮次开始之前、且在权限策略与沙箱之外就生效。`DSH_PERMISSION_MODE` 会关掉让「信任项目」根本成立的那道审批,而 `BASH_ENV` 会在 bash 工具每次发出 `bash -c` 时执行项目指定的文件——项目的代码在 agent(智能体)的策略下运行是约定,项目改写那份策略不是。一个变量一个变量地枚举是必输的游戏,所以整个 `DSH_*` 命名空间被拒绝而不是只拒绝一份经审查的子集,也所以这份清单是按变量*做什么*而不是按哪个运行时拥有它来组织的。不设逃生门:逃生门本身总得从某处读取,而任何被发现的文件能设置的东西,就是那个漏洞本身。
 
@@ -54,7 +54,7 @@ inherited process environment      (read-only, wins)
 ## Consequences
 
 - Web 凭据表单现在能压过用户 `.env` 里更旧的密钥;只有在启动 shell 里 export 的密钥才会让它变成只读,诊断信息也会这么说。
-- 含 `DSH_*`、`PATH`、`BROWSER` 或 proxy 变量的 `.env` 会导致启动失败而不是被应用。把开关放在仓库 `.env` 里的开发者需要改放到 shell——这是一次刻意且响亮的破坏。
+- 含 `DSH_*`、`PATH`、`BROWSER` 或(在调用目录中)proxy 变量的 `.env` 会导致启动失败而不是被应用。把开关放在仓库 `.env` 里的开发者需要改放到 shell——这是一次刻意且响亮的破坏。
 - composition 不再会被陈旧的 shell endpoint 覆盖。但它仍然会被用户已存的 `settings.yaml` 覆盖,这是 settings seam 的分层方式,本 Note 不改变它;产品 CLI 没有高于它的标志,因此需要压过已存 settings 的部署方要自带 bin 或 loader 配置树。
 - 未解决的:各层仍然会被物化进 `process.env`,因此普通项目变量继续按子进程清洗规则抵达子进程。bootstrap 变量完全不能来自文件;环境包将其余变量仍可抵达子进程这一点记录为一项限制。
 - Exa 与 Perplexity 仍在加载时捕获密钥,而不是经凭据 seam。它们不再读裸 `process.env`——改为经受信层解析——但把它们改造成按请求解析凭据是另一件事。

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.md
-2026-08-27-outbound-proxy-policy.md: 0de49f2c3302cfdc611d757828390eb8ef8efe84
-2026-08-27-outbound-proxy-policy.zh.md: 59175834f5257dd3065debffdda97d5fc9645c04
+2026-08-27-outbound-proxy-policy.md: ff81530764006419afdfd6ce9e75ce9eebe62f94
+2026-08-27-outbound-proxy-policy.zh.md: f289013ec7606c4d2c5cc4487b429000598b403b

+ 4 - 4
.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.md

@@ -10,13 +10,13 @@ Node's built-in `fetch` ignores `HTTP_PROXY` and `HTTPS_PROXY`. Every other tool
 
 The repository had briefly had an answer and lost it without noticing. PR #971 set `NODE_USE_ENV_PROXY=1` in `bin/dsh`; eleven days later `bbb1b1cc38 cleanup: remove managed source installer` deleted that launcher wholesale, taking the flag with it. What survived was one sentence in `apps/cli/reference/README.md` telling the reader to set a variable that nothing consumed any more.
 
-That sentence could not have worked anyway, for three measured reasons. `NODE_USE_ENV_PROXY` samples the environment at process start, while `loadLayeredEnv()` merges the `.env` layers afterwards, so a proxy declared in a project or `$DSH_HOME` `.env` is invisible to it. It reaches Node 24.0+ and, on the 22 line, only 22.21+ — while `engines` admits `^22.19.0`, where the variable does not exist and setting it warns about nothing. And it does not reach `web-fetch-http` at all: that provider passes its own `dispatcher` to `fetch`, and an explicit dispatcher overrides the global one whatever the flag says.
+That sentence could not have worked anyway, for three measured reasons. `NODE_USE_ENV_PROXY` samples the environment at process start, while `loadLayeredEnv()` merges the `.env` layers afterwards, so a proxy declared in `$DSH_HOME/.env` is invisible to it. It reaches Node 24.0+ and, on the 22 line, only 22.21+ — while `engines` admits `^22.19.0`, where the variable does not exist and setting it warns about nothing. And it does not reach `web-fetch-http` at all: that provider passes its own `dispatcher` to `fetch`, and an explicit dispatcher overrides the global one whatever the flag says.
 
 ## Decision
 
 **One policy, resolved once from the launch environment, installed as the global dispatcher.** `packages/util/http-proxy` resolves a `ProxyPolicy` and installs it in `runProfile` immediately after the environment snapshot is provided and before any entry mounts. Node's `fetch` resolves undici's global dispatcher, so every plain `fetch()` and every SDK that reaches `globalThis.fetch` is covered without touching its code — nine call sites at the time of writing, and every future one for free. `loadLayeredEnv` has exactly one caller and `apps/web` ships no bin, so this single site covers every profile including `sdk-minimal`, which does not layer over `base`.
 
-Resolution reads the launcher's snapshot rather than `process.env`, which is what makes a proxy in a `.env` layer work — the capability the environment-variable approach cannot have.
+Resolution reads the launcher's snapshot rather than `process.env`, which is what makes a proxy in `$DSH_HOME/.env` work — the capability the environment-variable approach cannot have. Only that file: `loadLayeredEnv` refuses a proxy name in the project `.env` exactly as it refuses `PATH` or `NODE_OPTIONS` there, because that file arrives with a clone and must not choose the harness's route. The home file is exempt for the four proxy names alone, and `DSH_HOME` is itself bootstrap-only, so no `.env` can point the exemption at a directory a repository controls.
 
 **A library in `util/`, not a plugin.** Transport policy has one answer per process: nothing to swap, and no scope narrower than the process to give one. The package exports functions and mounts nothing — `boot`, `web`, `subprocess`, and `workflow` all consume it, and `util/` is the group every other group may depend on.
 
@@ -58,7 +58,7 @@ Weighed against that, telemetry is the one outbound channel whose loss costs the
 
 ## Alternatives considered
 
-**Document `NODE_USE_ENV_PROXY=1` and stop.** Rejected on three measurements, above: invisible to `.env` layers, absent on the lowest supported Node, and bypassed by `web-fetch-http` regardless. It is also what the repository already claimed to do.
+**Document `NODE_USE_ENV_PROXY=1` and stop.** Rejected on three measurements, above: invisible to `$DSH_HOME/.env`, absent on the lowest supported Node, and bypassed by `web-fetch-http` regardless. It is also what the repository already claimed to do.
 
 **Thread a policy value to every call site.** DeepSeek-Reasonix does this across 98 sites, buying a per-provider opt-out. Rejected: that opt-out exists for a need this harness does not have, and nine sites changed by hand means the tenth is forgotten — Pi's changelog records OAuth and Bedrock as two separate after-the-fact fixes of exactly that kind. The isolation argument for it is real, and is answered instead by handling worker threads explicitly and by proving disposal restores the previous dispatcher.
 
@@ -74,7 +74,7 @@ Weighed against that, telemetry is the one outbound channel whose loss costs the
 
 ## Consequences
 
-A user who exports `HTTPS_PROXY`, or writes it into a `.env` layer, is proxied everywhere the harness makes a request, with no flag and no configuration. The launcher installs it exactly once, before the first plugin mounts.
+A user who exports `HTTPS_PROXY`, or writes it into `$DSH_HOME/.env`, is proxied everywhere the harness makes a request, with no flag and no configuration. The launcher installs it exactly once, before the first plugin mounts.
 
 Because the operating system's settings are not read, the user-facing documentation is now load-bearing rather than supplementary: a user who only toggled "system proxy" in a proxy application gets nothing and no diagnostic. `docs/user/guide/network-proxy.md` therefore states which variables to export and why a browser is proxied when a terminal is not — the three-mechanism confusion is the single most common report, and it is not specific to this harness.
 

+ 4 - 4
.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.zh.md

@@ -10,13 +10,13 @@ Node 内置的 `fetch` 会忽略 `HTTP_PROXY` 与 `HTTPS_PROXY`。开发者运
 
 仓库曾短暂拥有过答案,又在无人察觉时弄丢了。PR #971 在 `bin/dsh` 里设置了 `NODE_USE_ENV_PROXY=1`;十一天后 `bbb1b1cc38 cleanup: remove managed source installer` 整体删除了那个启动器,把该标志一并带走。留下的只有 `apps/cli/reference/README.md` 里的一句话,让读者去设置一个已经无人消费的变量。
 
-即便照做,那句话也不可能生效,原因有三条且都经过实测。`NODE_USE_ENV_PROXY` 在进程启动时对环境取快照,而 `loadLayeredEnv()` 是在之后才合并 `.env` 层,因此写在项目或 `$DSH_HOME` `.env` 中的代理对它不可见。它只覆盖 Node 24.0+,在 22 线上只覆盖 22.21+——而 `engines` 允许 `^22.19.0`,那里根本没有这个变量,设置了也不会有任何警告。它也完全触及不到 `web-fetch-http`:该提供方向 `fetch` 传入自己的 `dispatcher`,而显式 dispatcher 无论标志如何都会覆盖全局的那个。
+即便照做,那句话也不可能生效,原因有三条且都经过实测。`NODE_USE_ENV_PROXY` 在进程启动时对环境取快照,而 `loadLayeredEnv()` 是在之后才合并 `.env` 层,因此写在 `$DSH_HOME/.env` 中的代理对它不可见。它只覆盖 Node 24.0+,在 22 线上只覆盖 22.21+——而 `engines` 允许 `^22.19.0`,那里根本没有这个变量,设置了也不会有任何警告。它也完全触及不到 `web-fetch-http`:该提供方向 `fetch` 传入自己的 `dispatcher`,而显式 dispatcher 无论标志如何都会覆盖全局的那个。
 
 ## Decision
 
 **一份策略,从启动环境解析一次,装为全局 dispatcher。** `packages/util/http-proxy` 解析出 `ProxyPolicy`,并在 `runProfile` 中于环境快照提供之后、任何 entry 挂载之前完成安装。Node 的 `fetch` 解析的正是 undici 的全局 dispatcher,因此每一处普通 `fetch()` 以及每一个最终落到 `globalThis.fetch` 的 SDK 都无需改动即被覆盖——撰写时是九个调用点,未来新增的也自动覆盖。`loadLayeredEnv` 只有一个调用方,且 `apps/web` 不提供 bin,因此这一处即覆盖全部 profile,包括不叠加 `base` 的 `sdk-minimal`。
 
-解析读取的是启动器的快照而非 `process.env`,这正是让 `.env` 中的代理生效的原因——也是环境变量方案不可能具备的能力。
+解析读取的是启动器的快照而非 `process.env`,这正是让 `$DSH_HOME/.env` 中的代理生效的原因——也是环境变量方案不可能具备的能力。仅限该文件:`loadLayeredEnv` 拒绝项目 `.env` 里的代理名,正如它在那里拒绝 `PATH` 或 `NODE_OPTIONS`,因为那个文件随 clone 一起到来,不得替 Harness 选择路由。home 文件仅对这四个代理名豁免,而 `DSH_HOME` 本身是 bootstrap-only,因此没有任何 `.env` 能把这份豁免指向仓库控制的目录。
 
 **放在 `util/` 的库,而非插件。** 传输策略每个进程只有一个答案:没有可替换的实现,也没有比进程更窄的作用域可赋予。因此本包只导出函数、不挂载任何东西——`boot`、`web`、`subprocess` 与 `workflow` 都消费它,而 `util/` 正是其他所有组都可以依赖的那一组。
 
@@ -58,7 +58,7 @@ URL 层策略未受影响:仅 `http(s)`、禁止内嵌凭据、长度上限与
 
 ## Alternatives considered
 
-**只写文档,让用户设 `NODE_USE_ENV_PROXY=1`。** 基于上文三条实测被否决:对 `.env` 不可见、在最低支持的 Node 上不存在、且无论如何被 `web-fetch-http` 绕过。而这恰恰是仓库此前声称的做法。
+**只写文档,让用户设 `NODE_USE_ENV_PROXY=1`。** 基于上文三条实测被否决:对 `$DSH_HOME/.env` 不可见、在最低支持的 Node 上不存在、且无论如何被 `web-fetch-http` 绕过。而这恰恰是仓库此前声称的做法。
 
 **把策略值传递到每一个调用点。** DeepSeek-Reasonix 在 98 处这样做,换来每提供方的 opt-out。被否决:该能力服务于本 Harness 并不具备的需求,而手工改九处意味着第十处会被遗忘——Pi 的变更日志正记录了 OAuth 与 Bedrock 两次事后补漏。它关于隔离性的论点确实成立,本方案改为显式处理 worker 线程、并以「dispose 后还原前一个 dispatcher」的断言来回应。
 
@@ -74,7 +74,7 @@ URL 层策略未受影响:仅 `http(s)`、禁止内嵌凭据、长度上限与
 
 ## Consequences
 
-导出了 `HTTPS_PROXY`、或把它写进 `.env` 的用户,在 Harness 发起请求的每一处都会走代理,无需任何标志与配置。启动器在第一个插件挂载之前恰好安装一次。
+导出了 `HTTPS_PROXY`、或把它写进 `$DSH_HOME/.env` 的用户,在 Harness 发起请求的每一处都会走代理,无需任何标志与配置。启动器在第一个插件挂载之前恰好安装一次。
 
 由于不读取操作系统设置,面向用户的文档从补充材料变成了承重件:仅在代理软件里拨了「系统代理」开关的用户什么也得不到,且没有诊断。因此 `docs/user/guide/network-proxy.md` 说明了要导出哪些变量,以及为什么浏览器走代理而终端不走——这个「三套机制」的困惑是最常见的报障,且并非本 Harness 特有。
 

+ 2 - 2
docs/user/guide/network-proxy.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/user/guide/network-proxy.md
-network-proxy.md: 896e4b5416910cf34bcbe5297382f71cf3d3e2bb
-network-proxy.zh.md: 8283a5836223b843f51ffd0cc972b803703e1a33
+network-proxy.md: da887195d3c76257f01b824f64cf41372b3717ed
+network-proxy.zh.md: 97d637fc3efcbc23ceacb5ab95e9191bdadec5a7

+ 1 - 1
docs/user/guide/network-proxy.md

@@ -11,7 +11,7 @@ export HTTPS_PROXY=http://127.0.0.1:7890
 export HTTP_PROXY=http://127.0.0.1:7890
 ```
 
-Put both lines in your shell profile so every `dsh` invocation inherits them. DSH also reads a `.env` file in the launch directory and in `$DSH_HOME`, so a proxy that should apply to one project can live there instead; a real environment variable always wins over a file.
+Put both lines in your shell profile so every `dsh` invocation inherits them, or in `$DSH_HOME/.env` (`~/.dsh/.env` by default) next to your API key; an exported variable always wins over that file. A project's own `.env` cannot set them: it arrives with `git clone`, and DSH refuses to start rather than let a repository decide where your traffic goes.
 
 A proxy that needs credentials takes them in the URL: `http://user:password@proxy.example:8080`. DSH never prints the URL back: a diagnostic names the variable it rejected, so neither the username nor the password appears anywhere.
 

+ 1 - 1
docs/user/guide/network-proxy.zh.md

@@ -11,7 +11,7 @@ export HTTPS_PROXY=http://127.0.0.1:7890
 export HTTP_PROXY=http://127.0.0.1:7890
 ```
 
-把这两行写进 shell 配置,这样每次调用 `dsh` 都会继承它们。DSH 还会读取启动目录与 `$DSH_HOME` 下的 `.env` 文件,因此只对某个项目生效的代理可以写在那里;真实环境变量始终优先于文件
+把这两行写进 shell 配置,这样每次调用 `dsh` 都会继承它们;也可以写进 `$DSH_HOME/.env`(默认 `~/.dsh/.env`),和 API key 放在一起;导出的环境变量始终优先于该文件。项目自己的 `.env` 不能设置它们:它随 `git clone` 一起到来,DSH 宁可拒绝启动,也不让一个仓库决定你的流量去向
 
 需要凭据的代理把凭据写在 URL 里:`http://user:password@proxy.example:8080`。DSH 绝不会回显这个 URL:诊断只点名被拒绝的变量,因此用户名和密码都不会出现在任何地方。
 

+ 2 - 2
packages/boot/app-boot/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/boot/app-boot/README.md
-README.md: a9ed535c2662237229e0702dcf41eae4f93af5df
-README.zh.md: 4f7688f7db5060bb1bf00ca5d091ca4ad16466ea
+README.md: ddbcc900fcbb38e83b35f517f9d38cf0e04927f3
+README.zh.md: 184e1a086d09bf6807fd8fb2d17f7157ba8deaa6

+ 1 - 1
packages/boot/app-boot/README.md

@@ -49,7 +49,7 @@ A profile is how one dsh installation ships different app surfaces: `web`, `head
 
 Your machine-local preferences also live in the Harness home:
 
-- **`.env`** — your ordinary environment layers: the invoking directory's file outranks the Harness-home file, and both sit below the inherited environment. Variables that decide how the process starts (`PATH`, proxies, `DSH_*`, `XDG_*` and similar) are rejected from files: export them instead. For a non-product bin that just wants one directory's `.env`, a missing file is fine and an unloadable one prints one labelled warning line.
+- **`.env`** — your ordinary environment layers: the invoking directory's file outranks the Harness-home file, and both sit below the inherited environment. Variables that decide how the process starts (`PATH`, `DSH_*`, `XDG_*` and similar) are rejected from files: export them instead. The four proxy names (`HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, `NO_PROXY`) are accepted from the Harness-home file only, never from the invoking directory's, which arrives with a clone. For a non-product bin that just wants one directory's `.env`, a missing file is fine and an unloadable one prints one labelled warning line.
 - **`cordis.patch.yml`** — your tweak layer, applied after every bundle layer (per-profile first, then the home-level file, which therefore outranks it): replace one entry's whole config (restating the fields you keep), insert new entries, or interpolate `!!js` expressions at boot. A patch naming an entry that does not exist prints a stderr warning; an empty or comments-only file fails boot — disable the layer with `[]` instead.
 
 Profiles with `patchReload: live` watch both user patch files: a valid edit recomposes without restart, while a rejected edit leaves the last good app running. A `startup` profile installs neither those watchers nor the launcher's watch-only HMR fallback.

+ 1 - 1
packages/boot/app-boot/README.zh.md

@@ -49,7 +49,7 @@ profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`head
 
 你的机器本地偏好同样位于 harness home 中:
 
-- **`.env`**——你的普通环境层:调用目录的文件优先于 harness home 的文件,两者都低于继承环境。决定进程如何启动的变量(`PATH`、代理、`DSH_*`、`XDG_*` 等)会被文件拒绝:请改为导出。对于只想加载某个目录 `.env` 的非产品 bin,文件缺失不影响启动,文件无法加载时输出一行带标签的警告。
+- **`.env`**——你的普通环境层:调用目录的文件优先于 harness home 的文件,两者都低于继承环境。决定进程如何启动的变量(`PATH`、`DSH_*`、`XDG_*` 等)会被文件拒绝:请改为导出。四个代理名(`HTTP_PROXY`、`HTTPS_PROXY`、`ALL_PROXY`、`NO_PROXY`)只从 harness home 的文件接受,绝不从调用目录的文件接受——后者随 clone 一起到来。对于只想加载某个目录 `.env` 的非产品 bin,文件缺失不影响启动,文件无法加载时输出一行带标签的警告。
 - **`cordis.patch.yml`**——你的 tweak 层,应用在所有组合包层之后(先应用逐 profile 的文件,再应用 home 级文件,因此后者优先级更高):替换某个条目的整个 config(重述你要保留的字段)、插入新条目,或在启动时插值 `!!js` 表达式。patch 指定的条目不存在时输出 stderr 警告;空文件或仅含注释的文件会导致启动失败——如需禁用该层,请改用 `[]`。
 
 带 `patchReload: live` 的 profile 会监视两份用户 patch 文件:有效编辑无需重启即可重新组合,被拒绝的编辑则让最后一个可用应用继续运行。`startup` profile 既不安装这些监视器,也不安装 launcher 的仅监视 HMR 回退。

+ 25 - 7
packages/boot/app-boot/src/index.ts

@@ -119,9 +119,19 @@ const BOOTSTRAP_NAMES = new Set([
 /** Name prefixes no discovered file may set. */
 const BOOTSTRAP_PREFIXES = ['DSH_', 'XDG_', 'DYLD_', 'BASH_FUNC_']
 
+/**
+ * The bootstrap names the Harness-home `.env` alone may set. A proxy chooses the route every
+ * request takes, so the invoking directory's file — which arrives with a clone — keeps refusing
+ * them; the home file is the user's own, and `DSH_HOME` is itself bootstrap-only, so no `.env` can
+ * relocate this exemption. The CA and TLS names in the same group stay refused everywhere: they
+ * change what is trusted, not where traffic goes.
+ */
+const HOME_LAYER_PROXY_NAMES = new Set(['HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'NO_PROXY'])
+
 /**
  * Whether a variable may come only from the inherited process environment
- * because it changes process, runtime, VCS, or network bootstrap.
+ * because it changes process, runtime, VCS, or network bootstrap. The Harness-home
+ * file is additionally allowed {@link HOME_LAYER_PROXY_NAMES}.
  * @param name - the variable name.
  * @returns true when only the inherited environment may supply it.
  */
@@ -136,13 +146,15 @@ function isBootstrapOnly(name: string): boolean {
  * @param binName - the diagnostic prefix on the thrown error.
  * @param dir - the directory whose `.env` to read.
  * @param warn - sink for the one-line unreadable-file diagnostic.
+ * @param home - the resolved Harness home; when `dir` is it, {@link HOME_LAYER_PROXY_NAMES} are accepted.
  * @returns the parsed entries, or `undefined` when the file is absent or unreadable.
- * @throws when the file declares a name {@link isBootstrapOnly} rejects.
+ * @throws when the file declares a name {@link isBootstrapOnly} rejects and this layer may not set.
  */
 function readEnvLayer(
-  binName: string, dir: string, warn: (line: string) => void,
+  binName: string, dir: string, warn: (line: string) => void, home: string,
 ): { path: string; values: Record<string, string> } | undefined {
   const path = resolve(dir, '.env')
+  const isHome = resolve(dir) === home
   let content: string
   try {
     content = readFileSync(path, 'utf8')
@@ -157,10 +169,16 @@ function readEnvLayer(
   const values = parseEnv(content) as Record<string, string>
   for (const name of Object.keys(values)) {
     if (!isBootstrapOnly(name)) continue
+    const proxyName = HOME_LAYER_PROXY_NAMES.has(name.toUpperCase())
+    if (isHome && proxyName) continue
+    // A proxy name has a second way out that the other bootstrap names do not, so its message says so.
+    const remedy = proxyName
+      ? `export ${name}, or put it in ${resolve(home, '.env')}, which does not travel with a repository`
+      : `export ${name} instead of putting it in a .env file`
     throw new Error(
       `${binName}: ${path} sets "${name}", which only the launching environment may set`
       + ' (it decides how this process starts, where its code and instructions load from, or how it'
-      + ` reaches the network); export ${name} instead of putting it in a .env file`,
+      + ` reaches the network); ${remedy}`,
     )
   }
   return { path, values }
@@ -175,7 +193,7 @@ function readEnvLayer(
  * @param cwd - the invoking directory whose `.env` is the project layer.
  * @param warn - sink for the one-line misconfiguration diagnostics.
  * @returns this run's frozen environment snapshot.
- * @throws when either file declares a bootstrap-only variable.
+ * @throws when either file declares a bootstrap-only variable, except {@link HOME_LAYER_PROXY_NAMES} in the Harness-home file.
  */
 export function loadLayeredEnv(
   binName: string, cwd: string = process.cwd(),
@@ -184,8 +202,8 @@ export function loadLayeredEnv(
   const home = resolveDshHome()
   const inherited = { ...process.env } as Record<string, string>
   // Parse both layers first: a rejection must not leave one file applied.
-  const project = readEnvLayer(binName, cwd, warn)
-  const user = home === resolve(cwd) ? undefined : readEnvLayer(binName, home, warn)
+  const project = readEnvLayer(binName, cwd, warn, home)
+  const user = home === resolve(cwd) ? undefined : readEnvLayer(binName, home, warn, home)
   // Apply the checked values without replacing a higher-ranked name.
   for (const layer of [project, user]) {
     if (layer === undefined) continue

+ 74 - 0
packages/boot/app-boot/tests/app-boot.spec.ts

@@ -149,6 +149,80 @@ describe('loadLayeredEnv', () => {
     }
   })
 
+  const PROXY = ['HTTP_PROXY', 'http_proxy', 'HTTPS_PROXY', 'https_proxy', 'NO_PROXY', 'no_proxy'] as const
+  function clearProxy(): void {
+    for (const name of PROXY) Reflect.deleteProperty(process.env, name)
+  }
+
+  it('accepts the proxy names from the Harness-home .env, below an exported one', () => {
+    const home = tmp()
+    const project = tmp()
+    // Both casings, because a shell profile writes either and the rejection matches both.
+    writeFileSync(join(home, '.env'), 'HTTP_PROXY=http://from-home:8080\nhttps_proxy=http://from-home-lower:8080\nNO_PROXY=example.com\n')
+    clear(); clearProxy()
+    vi.stubEnv('DSH_HOME', home)
+    vi.stubEnv('HTTPS_PROXY', 'http://exported:8080')
+    try {
+      const snapshot = loadLayeredEnv(NAME, project, vi.fn())
+      expect(snapshot.get('HTTP_PROXY')).toEqual({ value: 'http://from-home:8080', source: 'user-env', path: join(home, '.env') })
+      expect(snapshot.get('https_proxy')).toEqual({ value: 'http://from-home-lower:8080', source: 'user-env', path: join(home, '.env') })
+      expect(snapshot.get('NO_PROXY')?.value).toBe('example.com')
+      // The launching shell still outranks the file.
+      expect(snapshot.get('HTTPS_PROXY')).toEqual({ value: 'http://exported:8080', source: 'process' })
+      expect(process.env.HTTP_PROXY).toBe('http://from-home:8080')
+    } finally {
+      clear(); clearProxy()
+      vi.unstubAllEnvs()
+    }
+  })
+
+  it('still refuses every other bootstrap name in the Harness-home .env', () => {
+    const home = tmp()
+    const project = tmp()
+    // A CA path sits in the same network group as the proxy names and changes what is trusted,
+    // not where traffic goes; the exemption must not widen to it.
+    writeFileSync(join(home, '.env'), 'SSL_CERT_FILE=/tmp/ca.pem\n')
+    clear()
+    vi.stubEnv('DSH_HOME', home)
+    try {
+      expect(() => loadLayeredEnv(NAME, project, vi.fn())).toThrow(/only the launching environment may set/)
+    } finally {
+      clear()
+      vi.unstubAllEnvs()
+    }
+  })
+
+  it('names the Harness-home file as the way out when a project .env sets a proxy', () => {
+    const home = tmp()
+    const project = tmp()
+    writeFileSync(join(project, '.env'), 'HTTP_PROXY=http://attacker.example\n')
+    clear(); clearProxy()
+    vi.stubEnv('DSH_HOME', home)
+    try {
+      expect(() => loadLayeredEnv(NAME, project, vi.fn()))
+        .toThrow(`export HTTP_PROXY, or put it in ${join(home, '.env')}, which does not travel with a repository`)
+      expect(process.env.HTTP_PROXY).toBeUndefined()
+    } finally {
+      clear(); clearProxy()
+      vi.unstubAllEnvs()
+    }
+  })
+
+  it('treats the invoking directory as the Harness home when they are the same directory', () => {
+    const home = tmp()
+    writeFileSync(join(home, '.env'), 'HTTP_PROXY=http://from-home:8080\n')
+    clear(); clearProxy()
+    vi.stubEnv('DSH_HOME', home)
+    try {
+      // Launched from inside the home itself, its one file is read as the project layer; the
+      // exemption follows the directory, not the layer name.
+      expect(loadLayeredEnv(NAME, home, vi.fn()).get('HTTP_PROXY')?.value).toBe('http://from-home:8080')
+    } finally {
+      clear(); clearProxy()
+      vi.unstubAllEnvs()
+    }
+  })
+
   it('reports each file value with its absolute path', () => {
     const home = tmp()
     const project = tmp()

+ 2 - 2
packages/util/http-proxy/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/util/http-proxy/README.md
-README.md: f0dab162eba192ee786e2eed9f19e5739f2d9ad1
-README.zh.md: acdd8200a3f304d9aa63127ba97652d1eadef0f4
+README.md: 1a2098a7ba6e9a43df54e06079dd2c37899ed3a6
+README.zh.md: 6589fce3c7a618eb17f3507437ab45550a760a8a

+ 1 - 1
packages/util/http-proxy/README.md

@@ -49,7 +49,7 @@ That gate cannot see inside an SDK, so every outbound call site in the repositor
 
 ### What the policy reads
 
-`http_proxy`, `https_proxy`, `no_proxy`, and `all_proxy`, lowercase first and uppercase as the fallback, with a blank value treated as unset. `ALL_PROXY` backs both schemes, and HTTPS falls back to the HTTP proxy last — neither Node nor undici derives the first of these on its own. Values come from the launcher's snapshot, so a proxy declared in a project or `$DSH_HOME` `.env` layer works too; real environment variables still outrank both.
+`http_proxy`, `https_proxy`, `no_proxy`, and `all_proxy`, lowercase first and uppercase as the fallback, with a blank value treated as unset. `ALL_PROXY` backs both schemes, and HTTPS falls back to the HTTP proxy last — neither Node nor undici derives the first of these on its own. Values come from the launcher's snapshot: an exported variable first, then `$DSH_HOME/.env`. A project's own `.env` cannot carry these names — that file arrives with a clone, and the launcher refuses to start rather than let a repository choose where the harness sends its traffic.
 
 Loopback is always bypassed — `localhost`, the whole `127.0.0.0/8` range, `::1`, `0.0.0.0`, and the IPv4-mapped spellings of those. The harness's own Web UI, Connection transport, and every local test server would otherwise route through the proxy and loop. The published bypass list names only the four literal entries an environment reader can match; `proxyForUrl` recognises the range itself, because a list entry cannot express one.
 

+ 1 - 1
packages/util/http-proxy/README.zh.md

@@ -49,7 +49,7 @@ Node 内置的 `fetch` 会忽略 `HTTP_PROXY` 与 `HTTPS_PROXY`,因此在代
 
 ### 策略读取哪些值
 
-`http_proxy`、`https_proxy`、`no_proxy` 与 `all_proxy`,小写优先、大写兜底,空值视为未设置。`ALL_PROXY` 为两种协议兜底,HTTPS 最后回退到 HTTP 代理——其中第一条 Node 与 undici 都不会自行推导。取值来自启动器的快照,因此写在项目或 `$DSH_HOME` 的 `.env` 层中的代理同样生效;真实环境变量仍然高于两者
+`http_proxy`、`https_proxy`、`no_proxy` 与 `all_proxy`,小写优先、大写兜底,空值视为未设置。`ALL_PROXY` 为两种协议兜底,HTTPS 最后回退到 HTTP 代理——其中第一条 Node 与 undici 都不会自行推导。取值来自启动器的快照:先看导出的环境变量,再看 `$DSH_HOME/.env`。项目自己的 `.env` 不能携带这些名字——那个文件随 clone 一起到来,启动器宁可拒绝启动,也不让一个仓库决定 Harness 把流量发往何处
 
 loopback 始终被绕过——`localhost`、整个 `127.0.0.0/8` 段、`::1`、`0.0.0.0`,以及它们的 IPv4 映射写法。否则 Harness 自己的 Web UI、Connection 传输以及每一个本地测试服务器都会经由代理并形成回环。发布出去的绕过列表只包含读取环境的消费者能匹配的四个字面量条目;`proxyForUrl` 自行识别整个网段,因为列表条目无法表达一个范围。