subprocess: one explicit env channel on the spawn spec

Drop SubprocessSpawnSpec.dshEnv and splitEnvChannels(); childEnv() is now
scrubbed-base + explicit entries with no namespace validation. The invariant
dropped is the reserved-namespace check on explicit entries (DSH_* rejected
from env, non-DSH_* rejected from dshEnv). Explicit-entry trust already
covers it: an explicit credential-shaped entry has always merged after the
scrub as a deliberate caller opt-in, and an explicit DSH_* entry is the same
deliberate act — the staleness invariant lives entirely in scrubbedParentEnv
dropping AMBIENT credential-shaped and DSH_* names, which stays. The
validation's only observed effect was rejecting legitimate explicit entries:
both recent CI breakages (DSH_GATE_CONCURRENCY exported into every job
crashing lsp specs, DSH_PERMISSION_MODE in acp config.env crashing the
child spawn) were this check firing on values a caller meant to pass, each
fixed by routing around the bureaucracy the seam itself imposed.

The bash seam keeps its own request/spec dshEnv field: that is bash-owned
trusted-plugin vocabulary (the ctx.bashEnv collected overlay) whose merge-last
position guarantees a caller env entry cannot displace a managed fact;
bash-local now flattens ENV_OVERRIDES -> spec.env -> spec.dshEnv into the
seam's one env map. subagent-acp and lsp-local pass their single config env
map straight through. DshEnvironment/DshEnvironmentKey/DSH_ENV_PREFIX stay on
the subprocess seam as the namespace vocabulary (bash re-exports them;
scrubbedParentEnv filters on the prefix).

Tests: the two channel-rejection specs and the splitEnvChannels partition
spec are deleted; one spawn spec now proves an explicit DSH_* env entry
reaches the child while an ambient one is scrubbed; the acp/lsp forwarding
specs keep their MOCK_ECHO_ENV / LSP_FAKE_ECHO_ENV assertions with the split
comments rewritten to merge-after-scrub. Docs (en+zh, re-recorded) and the
owning Agent Notes updated; cordis api/services catalogs regenerated.
This commit is contained in:
Tianyi Cui
2026-07-27 04:14:51 +08:00
parent cb1864795e
commit 5717726835
43 changed files with 134 additions and 201 deletions

View File

@@ -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
README.md: 3c70977b6ea783d5aa1766709d2a507e08b8ceca
README.zh.md: d24100c5f850172845a24acadcbd2d0207b64859
README.md: 85254eea2bb74df277be6fd5de1529b5da4ea178
README.zh.md: 290601d455ba2012d0c1f5c97503ae87304881cb

View File

@@ -23,7 +23,7 @@ The `servers` record key is the stable provider id reserved on `ctx.lsp`; each v
|---|---|---|
| `command` | (required) | Executable to spawn — absolute, or resolved on the child PATH at load. Launch uses no shell. |
| `args` | `[]` | Arguments passed to the executable. |
| `env` | `{}` | Extra env merged on top of the credential-scrubbed ambient env (vars matching `KEY`/`SECRET`/`TOKEN` are not forwarded); `DSH_*` entries ride the subprocess seam's managed channel. |
| `env` | `{}` | Extra env merged on top of the credential-scrubbed ambient env (vars matching `KEY`/`SECRET`/`TOKEN` are not forwarded); an explicit `DSH_*` entry merges after the seam's scrub of ambient ones. |
| `extensionToLanguage` | (required) | Lowercase leading-dot extension → LSP language id (e.g. `{ '.ts': 'typescript' }`). |
| `initializationOptions` | `null` | Static `initialize` options forwarded to the server. |
| `configuration` | `null` | Static answer to every `workspace/configuration` item. |

View File

@@ -23,7 +23,7 @@ Namespace 插件(`name`/`inject`/`Config`/`apply`,无默认导出)
|---|---|---|
| `command` | (必填) | 要 spawn 的可执行文件:绝对路径,或在加载时从子进程 PATH 解析。不使用 shell 启动。 |
| `args` | `[]` | 传给可执行文件的参数。 |
| `env` | `{}` | 合并到已清理 credential 的环境之上的额外 env(匹配 `KEY`/`SECRET`/`TOKEN` 的变量不会转发);`DSH_*` 条目走 subprocess seam 的受管通道。 |
| `env` | `{}` | 合并到已清理 credential 的环境之上的额外 env(匹配 `KEY`/`SECRET`/`TOKEN` 的变量不会转发);显式 `DSH_*` 条目在 seam 清除环境中同名值之后合并。 |
| `extensionToLanguage` | (必填) | 小写、以点开头的扩展名 → LSP language id(例如 `{ '.ts': 'typescript' }`)。 |
| `initializationOptions` | `null` | 转发给服务器的静态 `initialize` 选项。 |
| `configuration` | `null` | 每个 `workspace/configuration` 配置项的静态答案。 |

View File

@@ -11,7 +11,6 @@
*/
import type { Writable } from 'node:stream'
import { splitEnvChannels } from '@deepseek-ai/dsh-subprocess'
import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
import { encodeMessage, MessageDecoder } from './framing.ts'
@@ -99,9 +98,10 @@ export class LspConnection {
stderr: { maxBytes: spec.maxStderrBytes },
},
graceMs: spec.pipeDrainGraceMs,
// spec.env mixes the scrubbed base with explicit config entries; a
// configured DSH_* fact takes the managed channel the seam reserves.
...splitEnvChannels(spec.env),
// spec.env mixes the scrubbed base with explicit config entries; the
// seam merges the whole map after its own ambient scrub, so a
// configured DSH_* fact reaches the child.
env: spec.env,
})
/* v8 ignore start -- 'pipe' dispositions expose both streams by the seam contract; defensive. */
if (this.handle.stdin === undefined || this.handle.stdout === undefined) {

View File

@@ -51,10 +51,10 @@ describe('LspConnection', () => {
expect(conn.pid).toBeGreaterThan(0)
})
it('routes explicit DSH_* env entries onto the managed channel', async () => {
// A configured DSH_* fact must reach the child: the ordinary channel
// rejects the reserved namespace, so the connection's spawn must split it
// onto dshEnv. The fixture echoes the named variable back as hover text.
it('forwards explicit DSH_* env entries to the child', async () => {
// A configured DSH_* fact must reach the child: the seam scrubs only the
// ambient namespace, and the explicit entry merges after that scrub. The
// fixture echoes the named variable back as hover text.
const conn = connect({ LSP_FAKE_ECHO_ENV: 'DSH_LSP_TEST_FACT', DSH_LSP_TEST_FACT: 'managed' })
await conn.request('initialize', { capabilities: {} })
expect(await conn.request('textDocument/hover', {})).toEqual({ contents: 'managed' })