feat(server,web,core): explicit proxy address setting (no env vars needed) (#233)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Yaowei ZhengandClaude Fable 5 authored and GitHub committed 2026-08-07 18:53:55 +08:00
1 parent 6e1c7cc28e
commit 0cc0bd1a1a
25 files changed
+948 -278

No files matched your search

+16 -15
View File
@@ -57,6 +57,7 @@ import { INPUT_SUBAGENT_NAME } from "./environment/tools/input-subagent.js";
import type { CompactionSettings } from "./engine/context-engine.js";
import type {
GenerativeModelConfig,
ProxyEnvPolicy,
SubagentRunner,
ThinkingLevelName,
ToolDefinition,
@@ -77,15 +78,15 @@ export interface CreateAgentOptions {
/** 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
* Proxy policy for exec_command subprocess environments of every Session this Agent
* creates or resumes — and of its subagents' Sessions, which inherit the getter (see
* {@link ProxyEnvPolicy}: strip the proxy variables, inject an explicit proxy over the
* inherited env, or null = pass through). The Web server threads its admin-level proxy
* settings through here; the getter is re-read at every command spawn, so a settings
* change needs no restart. Absent = pass through (SDK/CLI standalone use follows the
* user's own shell environment).
*/
stripProxyEnv?: () => boolean;
proxyEnv?: () => ProxyEnvPolicy | null;
}
export interface CreateSessionOptions {
@@ -159,15 +160,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, opts.stripProxyEnv);
return new Agent(state, projectConfig, opts.proxyEnv);
}
export class Agent {
constructor(
readonly state: AgentState,
readonly projectConfig: ProjectConfig,
/** See {@link CreateAgentOptions.stripProxyEnv}; forwarded into every Session's Environment. */
private readonly stripProxyEnv?: () => boolean,
/** See {@link CreateAgentOptions.proxyEnv}; forwarded into every Session's Environment. */
private readonly proxyEnv?: () => ProxyEnvPolicy | null,
) {}
/**
@@ -598,10 +599,10 @@ export class Agent {
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 } : {}),
// A child Agent loads its own vault/config, but the proxy-env policy
// getter is host policy, not Agent state: the subagent's commands run in
// the same serving process, so they follow the same settings as the parent's.
...(parentAgent.proxyEnv ? { proxyEnv: parentAgent.proxyEnv } : {}),
})
: parentAgent;
// The child Session follows the PARENT Session, never the Project default: with the
@@ -744,7 +745,7 @@ export class Agent {
),
services: { subagentRunner, ...(visionDescriber ? { visionDescriber } : {}) },
...(Object.keys(vault).length > 0 ? { vault } : {}),
...(this.stripProxyEnv ? { stripProxyEnv: this.stripProxyEnv } : {}),
...(this.proxyEnv ? { proxyEnv: this.proxyEnv } : {}),
});
const tools = await environment.listTools();
+2 -2
View File
@@ -110,10 +110,10 @@ 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 (which also strips the proxy variables while stripProxyEnv says so).
// registry at spawn time (which also applies the proxyEnv policy — strip or inject).
this.commandSessions = new CommandSessionManager({
...(config.vault !== undefined ? { vault: config.vault } : {}),
...(config.stripProxyEnv !== undefined ? { stripProxyEnv: config.stripProxyEnv } : {}),
...(config.proxyEnv !== undefined ? { proxyEnv: config.proxyEnv } : {}),
});
this.subagentSessions = new SubagentSessionManager();
const services = {
@@ -11,6 +11,7 @@
*/
import { ManagedSession } from "./session.js";
import { BackgroundRegistry } from "../background/index.js";
import type { ProxyEnvPolicy } from "../../../interfaces.js";
/** Concurrent managed-session cap: evicts once exceeded (exited sessions first, otherwise LRU — killing a background process has bounded cost). */
const MAX_SESSIONS = 64;
@@ -86,18 +87,26 @@ const STRIPPED_ENV_KEYS = new Set([
]);
/**
* 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.).
* Proxy variables removed IN ADDITION when the host supplies a proxy policy (`proxyEnv`,
* see {@link CommandSessionManager}): the Web server's proxy settings must keep commands
* from just inheriting the serving process's proxy environment.
* In `strip` mode NO_PROXY is deliberately NOT removed — with no proxy variables left it
* is inert, and removing it would change behavior for commands that set their own proxy.
* In `inject` mode the inherited NO_PROXY is replaced too (the policy carries the merged
* list), and ALL_PROXY stays removed rather than replaced: the explicit app-level proxy
* outranks ambient env wholesale. 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 {
/**
* The host environment minus {@link STRIPPED_ENV_KEYS}, with the proxy policy applied:
* `strip` removes {@link PROXY_ENV_KEYS}; `inject` additionally replaces NO_PROXY and
* sets the explicit proxy variables (both spellings — programs disagree on which they
* read); null passes the proxy variables through untouched.
*/
function hostEnvForChild(policy: ProxyEnvPolicy | null): 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
@@ -106,9 +115,18 @@ function hostEnvForChild(stripProxy: boolean): NodeJS.ProcessEnv {
for (const [key, value] of Object.entries(process.env)) {
const name = key.toUpperCase();
if (STRIPPED_ENV_KEYS.has(name)) continue;
if (stripProxy && PROXY_ENV_KEYS.has(name)) continue;
if (policy !== null && PROXY_ENV_KEYS.has(name)) continue;
if (policy?.mode === "inject" && name === "NO_PROXY") continue;
env[key] = value;
}
if (policy?.mode === "inject") {
env.HTTP_PROXY = policy.url;
env.http_proxy = policy.url;
env.HTTPS_PROXY = policy.url;
env.https_proxy = policy.url;
env.NO_PROXY = policy.noProxy;
env.no_proxy = policy.noProxy;
}
return env;
}
@@ -121,16 +139,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).
* Proxy policy for the child environment (see {@link ProxyEnvPolicy}: strip the proxy
* variables, inject an explicit proxy over the inherited ones, or null = pass
* through). A getter rather than a snapshot: the hosting server's proxy settings
* change at runtime, and re-reading at every spawn makes a change reach Sessions that
* are already running. Absent = pass through (SDK/CLI standalone use).
*/
private readonly stripProxyEnv: (() => boolean) | undefined;
private readonly proxyEnv: (() => ProxyEnvPolicy | null) | undefined;
constructor(opts?: { vault?: Record<string, string>; stripProxyEnv?: () => boolean }) {
constructor(opts?: { vault?: Record<string, string>; proxyEnv?: () => ProxyEnvPolicy | null }) {
this.vault = opts?.vault ?? {};
this.stripProxyEnv = opts?.stripProxyEnv;
this.proxyEnv = opts?.proxyEnv;
}
/** Starts a command, returning an **unregistered** session (no process_id yet). */
@@ -144,10 +163,11 @@ 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; 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 },
// the harness's own variables first (see STRIPPED_ENV_KEYS) and has the proxyEnv
// policy applied (strip or inject); the vault still wins — over an injected proxy
// too — so a user who genuinely wants PORT, or their own proxy, in commands can set
// it there.
env: { ...hostEnvForChild(this.proxyEnv?.() ?? null), ...this.vault, ...HARDENED_ENV },
});
}
+23 -6
View File
@@ -293,15 +293,32 @@ export interface EnvironmentConfig {
*/
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).
* Proxy policy for exec_command / input_command subprocess environments (see
* {@link ProxyEnvPolicy}). Threaded by the Web server from its admin-level proxy
* settings; re-read at every spawn so a settings change needs no restart. Absent, or a
* getter returning null = the host environment passes through unchanged (the default
* for SDK/CLI standalone use).
*/
stripProxyEnv?: () => boolean;
proxyEnv?: () => ProxyEnvPolicy | null;
}
/**
* Proxy policy applied to command subprocess environments (see
* {@link EnvironmentConfig.proxyEnv}):
* - `{ mode: "strip" }` — the proxy variables (HTTP_PROXY / HTTPS_PROXY / ALL_PROXY, any
* casing) are removed; NO_PROXY is kept (inert without them, and commands that set
* their own proxy still honor it). The hosting server's proxy switch in the off state.
* - `{ mode: "inject", url, noProxy }` — the explicit proxy wins over ambient env:
* HTTP_PROXY / HTTPS_PROXY (plus their lowercase twins) are set to `url` and
* NO_PROXY / no_proxy to `noProxy`, overriding inherited values; an inherited
* ALL_PROXY (any casing) is removed for the same reason. The caller supplies `noProxy`
* pre-merged (the hosting server includes the loopback names).
* - `null` (or no getter at all) — pass through unchanged.
* The Agent vault still overrides whichever of these the policy produced: a per-Agent
* explicit variable outranks the host-level policy.
*/
export type ProxyEnvPolicy = { mode: "strip" } | { mode: "inject"; url: string; noProxy: string };
/**
* An approved tool-call execution request.
* Docs: /docs/interfaces § "ToolExecutionRequest and EnvironmentConfig".
+71 -19
View File
@@ -8,7 +8,7 @@ import path from "node:path";
import { Environment, ManagedSession } from "../src/environment/index.js";
import { toolCall } from "../src/omnimessage/index.js";
import type { OmniMessage } from "../src/omnimessage/index.js";
import type { ToolConfig, ToolDefinitionConfig } from "../src/interfaces.js";
import type { ProxyEnvPolicy, ToolConfig, ToolDefinitionConfig } from "../src/interfaces.js";
function execTool(overrides: Partial<ToolDefinitionConfig> = {}): ToolDefinitionConfig {
return {
@@ -313,7 +313,7 @@ describe("harness environment variables never reach a spawned command", () => {
process.env.HOST = "127.0.0.1";
process.env.PENGUIN_CLI_ENTRY = "/opt/penguin/lib/dist/index.js";
process.env.PENGUIN_WEB_DIST = "/opt/penguin/web";
// The desktop shell's process credentials (see design § "桌面端原型"): a leaked token
// The desktop shell's process credentials: a leaked token
// would let an Agent-run command call the server's shutdown endpoint.
process.env.PENGUIN_DESKTOP_TOKEN = "secret-desktop-token";
process.env.PENGUIN_PORT_FILE = "/tmp/port-file";
@@ -407,14 +407,21 @@ 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.
describe("proxyEnv policy governs the proxy variables commands inherit", () => {
// The Web server's proxy settings thread a ProxyEnvPolicy getter through
// Agent -> Environment -> CommandSessionManager: strip (switch off), inject (explicit
// address), or null (passthrough); 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;
let policy: ProxyEnvPolicy | null = null;
let policyEnv: Environment;
const INJECT: ProxyEnvPolicy = {
mode: "inject",
url: "http://explicit.example:3128",
noProxy: "corp.example,localhost,127.0.0.1,::1",
};
beforeEach(() => {
for (const k of KEYS) saved[k] = process.env[k];
@@ -422,24 +429,24 @@ describe("proxy variables are stripped from commands when the host opts out", ()
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({
policy = { mode: "strip" };
policyEnv = new Environment({
workspaceDir: tmp,
toolConfig: sessionConfig(),
stripProxyEnv: () => strip,
proxyEnv: () => policy,
});
});
afterEach(() => {
stripEnv.dispose();
policyEnv.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 () => {
it("strip: 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", {
const res = await runTool(policyEnv, "exec_command", {
cmd: `node -e "console.log('${READ}')"`,
});
expect(res.output).toContain(
@@ -447,10 +454,10 @@ describe("proxy variables are stripped from commands when the host opts out", ()
);
});
it("a lowercase spelling is stripped too (the conventional POSIX form)", async () => {
it("strip: 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", {
const res = await runTool(policyEnv, "exec_command", {
cmd: `node -e "console.log('s=[' + (process.env.https_proxy ?? '') + ']')"`,
});
expect(res.output).toContain("s=[]");
@@ -459,9 +466,54 @@ describe("proxy variables are stripped from commands when the host opts out", ()
}
});
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", {
it("inject: the explicit proxy overrides the inherited variables, both spellings", async () => {
policy = INJECT;
const READ =
"H=[' + (process.env.HTTP_PROXY ?? '') + '] h=[' + (process.env.http_proxy ?? '') + '] " +
"S=[' + (process.env.HTTPS_PROXY ?? '') + '] s=[' + (process.env.https_proxy ?? '') + ']";
const res = await runTool(policyEnv, "exec_command", {
cmd: `node -e "console.log('${READ}')"`,
});
expect(res.output).toContain(
"H=[http://explicit.example:3128] h=[http://explicit.example:3128] " +
"S=[http://explicit.example:3128] s=[http://explicit.example:3128]",
);
});
it("inject: NO_PROXY is replaced with the policy's merged list and ALL_PROXY is removed", async () => {
policy = INJECT;
const READ =
"N=[' + (process.env.NO_PROXY ?? '') + '] n=[' + (process.env.no_proxy ?? '') + '] " +
"A=[' + (process.env.ALL_PROXY ?? '') + ']";
const res = await runTool(policyEnv, "exec_command", {
cmd: `node -e "console.log('${READ}')"`,
});
expect(res.output).toContain(
"N=[corp.example,localhost,127.0.0.1,::1] n=[corp.example,localhost,127.0.0.1,::1] A=[]",
);
});
it("inject: the vault still wins — a per-Agent proxy outranks the injected one", async () => {
policy = INJECT;
const vaultEnv = new Environment({
workspaceDir: tmp,
toolConfig: sessionConfig(),
vault: { HTTP_PROXY: "http://vault.example:9999" },
proxyEnv: () => policy,
});
try {
const res = await runTool(vaultEnv, "exec_command", {
cmd: `node -e "console.log('H=[' + (process.env.HTTP_PROXY ?? '') + ']')"`,
});
expect(res.output).toContain("H=[http://vault.example:9999]");
} finally {
vaultEnv.dispose();
}
});
it("the getter is re-read at every spawn, so a live settings change needs no new Environment", async () => {
policy = null;
const res = await runTool(policyEnv, "exec_command", {
cmd: `node -e "console.log('H=[' + (process.env.HTTP_PROXY ?? '') + ']')"`,
});
expect(res.output).toContain("H=[http://proxy.corp.example:8080]");