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:
@@ -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();
|
||||
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user