fix(subprocess): consumers with one mixed env map split DSH_* onto the managed channel

Codex review of the acp-only fix found lsp-local carries the identical
defect: its server config's unrestricted env merges into the connection's
ordinary spawn channel, so a configured DSH_* fact crashed the spawn with
the reserved-namespace rejection. The partition now lives on the seam as
splitEnvChannels() beside the scrub it complements; the ACP run and the
LSP connection both use it, and each proves child delivery end-to-end
(MOCK_ECHO_ENV / LSP_FAKE_ECHO_ENV fixture knobs). Seam + consumer README
rows updated (en+zh, re-recorded). bash-local is already two-channel;
mcp/pty/sdk bypass the seam and only share the scrub.
This commit is contained in:
Tianyi Cui
2026-07-27 01:21:32 +08:00
parent fced51d4eb
commit 43d81b67ce
12 changed files with 62 additions and 25 deletions
+2 -2
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: 462cb12ce96dbbb645c9a19126911d32d4ddd722
README.zh.md: f5537a416c49106b128188efe4adb2d65304320a
README.md: 3c70977b6ea783d5aa1766709d2a507e08b8ceca
README.zh.md: d24100c5f850172845a24acadcbd2d0207b64859
+1 -1
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). |
| `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. |
| `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. |
+1 -1
View File
@@ -23,7 +23,7 @@ Namespace 插件(`name`/`inject`/`Config`/`apply`,无默认导出)
|---|---|---|
| `command` | (必填) | 要 spawn 的可执行文件:绝对路径,或在加载时从子进程 PATH 解析。不使用 shell 启动。 |
| `args` | `[]` | 传给可执行文件的参数。 |
| `env` | `{}` | 合并到已清理 credential 的环境之上的额外 env(匹配 `KEY`/`SECRET`/`TOKEN` 的变量不会转发)。 |
| `env` | `{}` | 合并到已清理 credential 的环境之上的额外 env(匹配 `KEY`/`SECRET`/`TOKEN` 的变量不会转发);`DSH_*` 条目走 subprocess seam 的受管通道。 |
| `extensionToLanguage` | (必填) | 小写、以点开头的扩展名 → LSP language id(例如 `{ '.ts': 'typescript' }`)。 |
| `initializationOptions` | `null` | 转发给服务器的静态 `initialize` 选项。 |
| `configuration` | `null` | 每个 `workspace/configuration` 配置项的静态答案。 |
+4 -1
View File
@@ -11,6 +11,7 @@
*/
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'
@@ -98,7 +99,9 @@ export class LspConnection {
stderr: { maxBytes: spec.maxStderrBytes },
},
graceMs: spec.pipeDrainGraceMs,
env: spec.env,
// spec.env mixes the scrubbed base with explicit config entries; a
// configured DSH_* fact takes the managed channel the seam reserves.
...splitEnvChannels(spec.env),
})
/* v8 ignore start -- 'pipe' dispositions expose both streams by the seam contract; defensive. */
if (this.handle.stdin === undefined || this.handle.stdout === undefined) {
@@ -51,6 +51,15 @@ 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.
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' })
})
it('rejects a request when the server replies with an error', async () => {
const conn = connect({ LSP_FAKE_ERROR: '1' })
await conn.request('initialize', { capabilities: {} })
@@ -58,7 +58,13 @@ function resultFor(method: string): unknown {
case 'textDocument/definition': return envJson('LSP_FAKE_DEF', null)
case 'textDocument/references': return envJson('LSP_FAKE_REFS', null)
case 'textDocument/implementation': return envJson('LSP_FAKE_IMPL', null)
case 'textDocument/hover': return envJson('LSP_FAKE_HOVER', null)
case 'textDocument/hover': {
// LSP_FAKE_ECHO_ENV names a variable whose VALUE becomes the hover
// contents — a test can assert exactly what env reached this process.
const echoName = process.env.LSP_FAKE_ECHO_ENV
if (echoName !== undefined) return { contents: process.env[echoName] ?? `<${echoName} unset>` }
return envJson('LSP_FAKE_HOVER', null)
}
default: return null
}
}