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:
1 parent
6e1c7cc28e
commit
0cc0bd1a1a
25 files changed
+948
-278
No files matched your search
+16
-15
@@ -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();
|
||||
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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".
|
||||
|
||||
@@ -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]");
|
||||
|
||||
Reference in new issue
Block a user