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 Zheng
2026-08-06 20:21:26 +08:00
committed by GitHub
parent e8499befd4
commit 91f3292ab5
29 changed files with 710 additions and 19 deletions
+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;
}
/**