feat(server,web,desktop): admin "use system HTTP proxy" switch (#225)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Yaowei ZhengandClaude Fable 5 authored and GitHub committed 2026-08-06 20:21:26 +08:00
1 parent e8499befd4
commit 91f3292ab5
29 files changed
+710 -19

No files matched your search

+23 -2
View File
@@ -76,6 +76,16 @@ export interface CreateAgentOptions {
projectId?: string;
/** Local data root directory; defaults to `resolveRoot()` (PENGUIN_HOME or ~/.penguin/data). */
root?: string;
/**
* When it returns true, the proxy variables (HTTP_PROXY / HTTPS_PROXY / ALL_PROXY;
* NO_PROXY is kept) are stripped from exec_command subprocess environments of every
* Session this Agent creates or resumes — and of its subagents' Sessions, which
* inherit the getter. The Web server threads its admin-level "use system HTTP proxy"
* switch (off state) through here; the getter is re-read at every command spawn, so a
* toggle needs no restart. Absent = proxy allowed (SDK/CLI standalone use follows the
* user's own shell environment).
*/
stripProxyEnv?: () => boolean;
}
export interface CreateSessionOptions {
@@ -149,13 +159,15 @@ export function metaMaxTokens(budget: number, modelCap: number | undefined): num
export async function createAgent(opts: CreateAgentOptions = {}): Promise<Agent> {
const state = await loadOrInitAgentState(opts);
const projectConfig = await loadProjectConfig(state.root, state.projectId);
return new Agent(state, projectConfig);
return new Agent(state, projectConfig, opts.stripProxyEnv);
}
export class Agent {
constructor(
readonly state: AgentState,
readonly projectConfig: ProjectConfig,
/** See {@link CreateAgentOptions.stripProxyEnv}; forwarded into every Session's Environment. */
private readonly stripProxyEnv?: () => boolean,
) {}
/**
@@ -582,7 +594,15 @@ export class Agent {
}
const childAgent =
agentId !== undefined && agentId !== parentAgentId
? await createAgent({ root, projectId, agentId })
? await createAgent({
root,
projectId,
agentId,
// A child Agent loads its own vault/config, but the proxy-strip getter is
// host policy, not Agent state: the subagent's commands run in the same
// serving process, so they follow the same switch as the parent's.
...(parentAgent.stripProxyEnv ? { stripProxyEnv: parentAgent.stripProxyEnv } : {}),
})
: parentAgent;
// The child Session follows the PARENT Session, never the Project default: with the
// model pair fully omitted it reuses the parent's resolved (provider, model_id) —
@@ -724,6 +744,7 @@ export class Agent {
),
services: { subagentRunner, ...(visionDescriber ? { visionDescriber } : {}) },
...(Object.keys(vault).length > 0 ? { vault } : {}),
...(this.stripProxyEnv ? { stripProxyEnv: this.stripProxyEnv } : {}),
});
const tools = await environment.listTools();
+5 -4
View File
@@ -110,10 +110,11 @@ export class Environment implements EnvironmentInterface {
// The background session registry is created alongside Environment (one per Session) and
// injected into whichever tools need it; all sessions are finalized together on dispose.
// The vault environment variables are injected into child processes by the command session
// registry at spawn time.
this.commandSessions = new CommandSessionManager(
config.vault !== undefined ? { vault: config.vault } : {},
);
// registry at spawn time (which also strips the proxy variables while stripProxyEnv says so).
this.commandSessions = new CommandSessionManager({
...(config.vault !== undefined ? { vault: config.vault } : {}),
...(config.stripProxyEnv !== undefined ? { stripProxyEnv: config.stripProxyEnv } : {}),
});
this.subagentSessions = new SubagentSessionManager();
const services = {
...config.services,
@@ -85,15 +85,29 @@ const STRIPPED_ENV_KEYS = new Set([
"PENGUIN_SEED_ADMIN_PASSWORD",
]);
/** The host environment minus {@link STRIPPED_ENV_KEYS}. */
function hostEnvForChild(): NodeJS.ProcessEnv {
/**
* Proxy variables removed IN ADDITION when the host asks for it (`stripProxyEnv`, see
* {@link CommandSessionManager}): the Web server's "use system HTTP proxy" switch in the
* off state must keep commands from inheriting the serving process's proxy environment
* (design § "出网与系统代理"). NO_PROXY is deliberately NOT stripped — with no proxy
* variables left it is inert, and removing it would change behavior for commands that
* set their own proxy. Matched case-insensitively like {@link STRIPPED_ENV_KEYS}, which
* also covers the conventional lowercase spellings (http_proxy etc.).
*/
const PROXY_ENV_KEYS = new Set(["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY"]);
/** The host environment minus {@link STRIPPED_ENV_KEYS} (and {@link PROXY_ENV_KEYS} when asked). */
function hostEnvForChild(stripProxy: boolean): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = {};
// Matched case-insensitively rather than deleting the upper-case spellings: Windows resolves
// environment names without regard to case but stores whatever casing was written, so a
// `set Port=3000` before `penguin web` would survive a `delete env.PORT` and still reach the
// child as PORT. On POSIX the two are distinct names and only the exact one exists.
for (const [key, value] of Object.entries(process.env)) {
if (!STRIPPED_ENV_KEYS.has(key.toUpperCase())) env[key] = value;
const name = key.toUpperCase();
if (STRIPPED_ENV_KEYS.has(name)) continue;
if (stripProxy && PROXY_ENV_KEYS.has(name)) continue;
env[key] = value;
}
return env;
}
@@ -106,9 +120,17 @@ export class CommandSessionManager {
/** Agent vault environment variables: injected into the child process on every spawn (values never enter the model context, only the environment). */
private readonly vault: Record<string, string>;
/**
* When it returns true, {@link PROXY_ENV_KEYS} are removed from the child environment
* too. A getter rather than a boolean: the hosting server's "use system HTTP proxy"
* switch is toggled at runtime, and re-reading at every spawn makes the toggle reach
* Sessions that are already running. Absent = proxy allowed (SDK/CLI standalone use).
*/
private readonly stripProxyEnv: (() => boolean) | undefined;
constructor(opts?: { vault?: Record<string, string> }) {
constructor(opts?: { vault?: Record<string, string>; stripProxyEnv?: () => boolean }) {
this.vault = opts?.vault ?? {};
this.stripProxyEnv = opts?.stripProxyEnv;
}
/** Starts a command, returning an **unregistered** session (no process_id yet). */
@@ -122,9 +144,10 @@ export class CommandSessionManager {
// Spread order is priority: vault overrides host variables of the same name, but must
// come before HARDENED_ENV — the hardening entries (GIT_EDITOR/PAGER etc. that prevent
// interactive hangs) must never be overridable by vault. The host side is stripped of
// the harness's own variables first (see STRIPPED_ENV_KEYS); the vault still wins, so a
// user who genuinely wants PORT in commands can set it there.
env: { ...hostEnvForChild(), ...this.vault, ...HARDENED_ENV },
// the harness's own variables first (see STRIPPED_ENV_KEYS; plus the proxy variables
// when stripProxyEnv says so); the vault still wins, so a user who genuinely wants
// PORT — or a proxy — in commands can set it there.
env: { ...hostEnvForChild(this.stripProxyEnv?.() === true), ...this.vault, ...HARDENED_ENV },
});
}
+8
View File
@@ -292,6 +292,14 @@ export interface EnvironmentConfig {
* environment; hardened entries cannot be overridden.
*/
vault?: Record<string, string>;
/**
* When it returns true, the proxy variables (HTTP_PROXY / HTTPS_PROXY / ALL_PROXY, any
* casing; NO_PROXY is kept) are stripped from exec_command / input_command subprocess
* environments. Threaded by the Web server from its admin-level "use system HTTP proxy"
* switch (off state); re-read at every spawn so a toggle needs no restart. Absent =
* proxy allowed (the default for SDK/CLI standalone use).
*/
stripProxyEnv?: () => boolean;
}
/**
+68
View File
@@ -406,3 +406,71 @@ describe("harness environment variables never reach a spawned command", () => {
}
});
});
describe("proxy variables are stripped from commands when the host opts out", () => {
// The Web server's "use system HTTP proxy" switch (off state) threads stripProxyEnv
// through Agent -> Environment -> CommandSessionManager; standalone Environments (no
// getter) keep the historical pass-through.
const KEYS = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY"] as const;
const saved: Partial<Record<(typeof KEYS)[number], string | undefined>> = {};
let strip = true;
let stripEnv: Environment;
beforeEach(() => {
for (const k of KEYS) saved[k] = process.env[k];
process.env.HTTP_PROXY = "http://proxy.corp.example:8080";
process.env.HTTPS_PROXY = "http://proxy.corp.example:8443";
process.env.ALL_PROXY = "socks5://proxy.corp.example:1080";
process.env.NO_PROXY = "localhost,127.0.0.1,::1";
strip = true;
stripEnv = new Environment({
workspaceDir: tmp,
toolConfig: sessionConfig(),
stripProxyEnv: () => strip,
});
});
afterEach(() => {
stripEnv.dispose();
for (const k of KEYS) {
if (saved[k] === undefined) delete process.env[k];
else process.env[k] = saved[k];
}
});
it("HTTP(S)_PROXY/ALL_PROXY are removed, NO_PROXY stays (inert without them)", async () => {
const READ = KEYS.map((k) => `${k}=[' + (process.env.${k} ?? '') + ']`).join(" ");
const res = await runTool(stripEnv, "exec_command", {
cmd: `node -e "console.log('${READ}')"`,
});
expect(res.output).toContain(
"HTTP_PROXY=[] HTTPS_PROXY=[] ALL_PROXY=[] NO_PROXY=[localhost,127.0.0.1,::1]",
);
});
it("a lowercase spelling is stripped too (the conventional POSIX form)", async () => {
process.env.https_proxy = "http://proxy.corp.example:8443";
try {
const res = await runTool(stripEnv, "exec_command", {
cmd: `node -e "console.log('s=[' + (process.env.https_proxy ?? '') + ']')"`,
});
expect(res.output).toContain("s=[]");
} finally {
delete process.env.https_proxy;
}
});
it("the getter is re-read at every spawn, so a live toggle needs no new Environment", async () => {
strip = false;
const res = await runTool(stripEnv, "exec_command", {
cmd: `node -e "console.log('H=[' + (process.env.HTTP_PROXY ?? '') + ']')"`,
});
expect(res.output).toContain("H=[http://proxy.corp.example:8080]");
});
it("without the getter (SDK/CLI standalone), proxy variables pass through", async () => {
const res = await runTool(env, "exec_command", {
cmd: `node -e "console.log('H=[' + (process.env.HTTP_PROXY ?? '') + ']')"`,
});
expect(res.output).toContain("H=[http://proxy.corp.example:8080]");
});
});