Skip to content

Commit a210924

Browse files
committed
feat(serve): add buildEnvStatusFromProcess helper (#4175 Wave 3 PR 13)
Pure helper that constructs the `/workspace/env` payload from `process.*` state. No I/O, no ACP roundtrip, no globals beyond `process.env`. The route itself lands in the next commit. - `ServeEnvKind` discriminant: `runtime | platform | sandbox | proxy | env_var` - `ServeEnvCell extends ServeStatusCell` with `name` + optional `present` / `value`. Cells with `kind: 'env_var'` are presence-only — `value` is ALWAYS omitted to keep secret env vars off the wire even by accident. - `ServeWorkspaceEnvStatus` envelope: `{ v, workspaceCwd, initialized: true, acpChannelLive, cells, errors? }`. `initialized` is structurally `true` because env answers from the daemon process directly; `acpChannelLive` reports whether a child is up but does not change the payload shape. Whitelist policy: - Auth/secret keys (presence-only): OPENAI/ANTHROPIC/GEMINI/GOOGLE/DASHSCOPE/ OPENROUTER `_API_KEY`, `QWEN_SERVER_TOKEN`. - Non-secret keys (also presence-only for shape uniformity): base URLs, locale, TZ, NODE_EXTRA_CA_CERTS, QWEN_CLI_ENTRY. - Proxy vars (`HTTP_PROXY`/`HTTPS_PROXY`/`NO_PROXY`/`ALL_PROXY` + lowercase variants): credentials stripped via `redactProxyCredentials`, then `URL().host` so the wire only carries `host:port`. NO_PROXY is a host list rather than a URL so we pass the redacted form verbatim. SDK mirrors: `DaemonEnvKind`, `DaemonEnvCell`, `DaemonWorkspaceEnvStatus`. Tests: 9 unit tests covering the proxy-credential redaction, lowercase env fallback, NO_PROXY pass-through, presence-only `env_var` invariant (`'value' in cell === false`), whitelist enforcement, runtime tag detection, and envelope shape.
1 parent c89173d commit a210924

7 files changed

Lines changed: 524 additions & 0 deletions

File tree

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Qwen Team
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
8+
import {
9+
ENV_NONSECRET_VARS,
10+
ENV_PROXY_VARS,
11+
ENV_SECRET_VARS,
12+
buildEnvStatusFromProcess,
13+
readProxyVar,
14+
} from './envSnapshot.js';
15+
16+
const TRACKED_ENV = [
17+
...ENV_SECRET_VARS,
18+
...ENV_NONSECRET_VARS,
19+
...ENV_PROXY_VARS,
20+
...ENV_PROXY_VARS.map((n) => n.toLowerCase()),
21+
'SANDBOX',
22+
'SEATBELT_PROFILE',
23+
];
24+
25+
let prevEnv: Record<string, string | undefined>;
26+
27+
beforeEach(() => {
28+
prevEnv = {};
29+
for (const k of TRACKED_ENV) {
30+
prevEnv[k] = process.env[k];
31+
delete process.env[k];
32+
}
33+
});
34+
35+
afterEach(() => {
36+
for (const k of TRACKED_ENV) {
37+
if (prevEnv[k] === undefined) {
38+
delete process.env[k];
39+
} else {
40+
process.env[k] = prevEnv[k];
41+
}
42+
}
43+
});
44+
45+
describe('buildEnvStatusFromProcess', () => {
46+
it('emits a runtime cell whose value matches the actual runtime version', () => {
47+
const status = buildEnvStatusFromProcess('/ws', false);
48+
const runtime = status.cells.find((c) => c.kind === 'runtime');
49+
expect(runtime).toBeDefined();
50+
expect(['node', 'bun', 'unknown']).toContain(runtime!.name);
51+
// `detectRuntime` keys on `process.versions['bun']`, so on Bun the
52+
// cell carries Bun's version, not Node's compat shim version.
53+
const expected =
54+
runtime!.name === 'bun'
55+
? (process.versions['bun'] ?? process.versions.node)
56+
: process.versions.node;
57+
expect(runtime!.value).toBe(expected);
58+
expect(runtime!.status).toBe('ok');
59+
});
60+
61+
it('reports Bun version (not Node compat shim) when running under Bun', () => {
62+
// `process.versions.bun` is undefined under Node; setting it makes
63+
// `detectRuntime()` (which keys on `process.versions['bun']`) return
64+
// `'bun'`, exercising the Bun branch of the runtime-version selector
65+
// without needing a real Bun process.
66+
const versions = process.versions as Record<string, string | undefined>;
67+
const prev = versions['bun'];
68+
versions['bun'] = '1.2.42';
69+
try {
70+
const status = buildEnvStatusFromProcess('/ws', false);
71+
const runtime = status.cells.find((c) => c.kind === 'runtime');
72+
expect(runtime!.name).toBe('bun');
73+
expect(runtime!.value).toBe('1.2.42');
74+
expect(runtime!.value).not.toBe(process.versions.node);
75+
} finally {
76+
if (prev === undefined) delete versions['bun'];
77+
else versions['bun'] = prev;
78+
}
79+
});
80+
81+
it('emits platform and arch on the platform cell', () => {
82+
const status = buildEnvStatusFromProcess('/ws', true);
83+
const platform = status.cells.find((c) => c.kind === 'platform');
84+
expect(platform!.name).toBe(process.platform);
85+
expect(platform!.value).toBe(process.arch);
86+
});
87+
88+
it('marks SANDBOX disabled when unset and ok with the profile name when set', () => {
89+
let status = buildEnvStatusFromProcess('/ws', false);
90+
let cell = status.cells.find(
91+
(c) => c.kind === 'sandbox' && c.name === 'SANDBOX',
92+
);
93+
expect(cell!.status).toBe('disabled');
94+
expect(cell!.present).toBe(false);
95+
expect('value' in cell!).toBe(false);
96+
97+
process.env['SANDBOX'] = 'docker';
98+
status = buildEnvStatusFromProcess('/ws', false);
99+
cell = status.cells.find(
100+
(c) => c.kind === 'sandbox' && c.name === 'SANDBOX',
101+
);
102+
expect(cell!.status).toBe('ok');
103+
expect(cell!.present).toBe(true);
104+
expect(cell!.value).toBe('docker');
105+
});
106+
107+
it('redacts user:pass from proxy URLs and surfaces only the host:port', () => {
108+
process.env['HTTPS_PROXY'] = 'http://alice:secret@proxy.internal:1080';
109+
const status = buildEnvStatusFromProcess('/ws', false);
110+
const cell = status.cells.find(
111+
(c) => c.kind === 'proxy' && c.name === 'HTTPS_PROXY',
112+
);
113+
expect(cell!.present).toBe(true);
114+
expect(cell!.value).toBe('proxy.internal:1080');
115+
expect(cell!.value).not.toContain('alice');
116+
expect(cell!.value).not.toContain('secret');
117+
});
118+
119+
it('reduces authority-only proxy values (no scheme) to host:port without leaking userinfo', () => {
120+
process.env['HTTPS_PROXY'] = 'alice:secret@proxy.internal:1080';
121+
const status = buildEnvStatusFromProcess('/ws', false);
122+
const cell = status.cells.find(
123+
(c) => c.kind === 'proxy' && c.name === 'HTTPS_PROXY',
124+
);
125+
expect(cell!.value).toBe('proxy.internal:1080');
126+
expect(cell!.value).not.toContain('alice');
127+
expect(cell!.value).not.toContain('secret');
128+
expect(cell!.value).not.toContain('@');
129+
expect(cell!.value).not.toContain('<redacted>');
130+
});
131+
132+
it('falls back to a scrubbed authority for unparseable proxy values rather than the raw input', () => {
133+
process.env['HTTP_PROXY'] = 'garbage://[not a valid url]:::abc';
134+
const status = buildEnvStatusFromProcess('/ws', false);
135+
const cell = status.cells.find(
136+
(c) => c.kind === 'proxy' && c.name === 'HTTP_PROXY',
137+
);
138+
expect(cell!.present).toBe(true);
139+
// Whatever the value is, it must NOT contain credential-shaped userinfo
140+
// and must NOT be the original raw string verbatim.
141+
expect(cell!.value).not.toMatch(/[^@/?#]*:[^@/?#]+@/);
142+
});
143+
144+
it('reads lowercase proxy env vars when uppercase is unset', () => {
145+
process.env['http_proxy'] = 'http://proxy.local:3128';
146+
const status = buildEnvStatusFromProcess('/ws', false);
147+
const cell = status.cells.find(
148+
(c) => c.kind === 'proxy' && c.name === 'HTTP_PROXY',
149+
);
150+
expect(cell!.present).toBe(true);
151+
expect(cell!.value).toBe('proxy.local:3128');
152+
});
153+
154+
it('readProxyVar uses ?? not || so an explicit empty string disables fallthrough', () => {
155+
// Docker/K8s entrypoints commonly set `HTTPS_PROXY=""` to override an
156+
// inherited proxy. With `||` the empty string would be treated as
157+
// falsy and `readProxyVar` would fall through to the lowercase
158+
// variant; with `??` it preserves the empty string.
159+
//
160+
// Tested via `readProxyVar` directly (not `buildEnvStatusFromProcess`)
161+
// because Windows' `process.env` is case-INSENSITIVE — setting
162+
// `HTTPS_PROXY=""` then `https_proxy=...` ends up writing the same
163+
// key twice, so we couldn't distinguish `||` from `??` through the
164+
// process-env path on Windows. Passing a plain JS object here keeps
165+
// the keys distinct on every platform.
166+
const explicitlyDisabled = readProxyVar(
167+
{ HTTPS_PROXY: '', https_proxy: 'http://proxy.parent:3128' },
168+
'HTTPS_PROXY',
169+
);
170+
expect(explicitlyDisabled).toBe('');
171+
172+
// Sanity check — when the uppercase variant is absent (not just empty),
173+
// the lowercase fallback IS taken.
174+
const lowercaseFallback = readProxyVar(
175+
{ https_proxy: 'http://proxy.parent:3128' },
176+
'HTTPS_PROXY',
177+
);
178+
expect(lowercaseFallback).toBe('http://proxy.parent:3128');
179+
});
180+
181+
it('passes NO_PROXY through redaction without URL parsing', () => {
182+
process.env['NO_PROXY'] = 'localhost,127.0.0.1,internal.local';
183+
const status = buildEnvStatusFromProcess('/ws', false);
184+
const cell = status.cells.find(
185+
(c) => c.kind === 'proxy' && c.name === 'NO_PROXY',
186+
);
187+
expect(cell!.present).toBe(true);
188+
expect(cell!.value).toBe('localhost,127.0.0.1,internal.local');
189+
});
190+
191+
it('emits env_var cells presence-only — never includes a value field', () => {
192+
process.env['OPENAI_API_KEY'] = 'sk-do-not-leak-1234567890';
193+
process.env['ANTHROPIC_BASE_URL'] = 'https://api.anthropic.com';
194+
const status = buildEnvStatusFromProcess('/ws', false);
195+
for (const cell of status.cells) {
196+
if (cell.kind !== 'env_var') continue;
197+
expect('value' in cell).toBe(false);
198+
}
199+
const apiKey = status.cells.find(
200+
(c) => c.kind === 'env_var' && c.name === 'OPENAI_API_KEY',
201+
);
202+
expect(apiKey!.present).toBe(true);
203+
expect(apiKey!.status).toBe('ok');
204+
const baseUrl = status.cells.find(
205+
(c) => c.kind === 'env_var' && c.name === 'ANTHROPIC_BASE_URL',
206+
);
207+
expect(baseUrl!.present).toBe(true);
208+
});
209+
210+
it('does not enumerate non-whitelisted secrets even when set', () => {
211+
process.env['SOME_OTHER_SECRET_KEY'] = 'leak-me';
212+
const status = buildEnvStatusFromProcess('/ws', false);
213+
expect(
214+
status.cells.some(
215+
(c) => c.name === 'SOME_OTHER_SECRET_KEY' || c.value === 'leak-me',
216+
),
217+
).toBe(false);
218+
});
219+
220+
it('preserves workspaceCwd / acpChannelLive / initialized=true on the envelope', () => {
221+
const live = buildEnvStatusFromProcess('/abs/ws', true);
222+
expect(live.workspaceCwd).toBe('/abs/ws');
223+
expect(live.acpChannelLive).toBe(true);
224+
expect(live.initialized).toBe(true);
225+
expect(live.v).toBe(1);
226+
227+
const idle = buildEnvStatusFromProcess('/abs/ws', false);
228+
expect(idle.acpChannelLive).toBe(false);
229+
expect(idle.initialized).toBe(true);
230+
});
231+
});

0 commit comments

Comments
 (0)