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 Zheng
2026-08-07 18:53:55 +08:00
committed by GitHub
parent 6e1c7cc28e
commit 0cc0bd1a1a
25 changed files with 948 additions and 278 deletions
+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]");
+9 -5
View File
@@ -1,11 +1,15 @@
/**
* OS-proxy resolution for the embedded server (design § "出网与系统代理"): on the
* desktop, "system proxy" means the real operating-system setting. The shell resolves
* OS-proxy resolution for the embedded server: on the desktop, "system proxy" means
* the real operating-system setting. The shell resolves
* it through Electron's resolveProxy when forking the server and injects the result
* into the child environment as HTTP_PROXY / HTTPS_PROXY — the server's dispatcher
* (and, while the admin switch is on, agent commands) then honor it. Variables already
* present in the shell's environment are never overridden (same idiom as
* bundledShellEnv): an explicitly configured environment wins over the OS lookup.
* (while the admin app-proxy switch is on) and agent commands (while the agent-proxy
* switch is on) then honor it. Variables already present in the shell's environment are
* never overridden (same idiom as bundledShellEnv): an explicitly configured
* environment wins over the OS lookup. An admin-configured explicit proxy address
* (server_settings `proxyUrl`) in turn wins over BOTH at the server's dispatcher and in
* agent command env injection — what this module injects is only the "follow the system
* proxy" default the server falls back to.
*
* Only the entry point touches Electron, behind a dynamic import; the parsing and the
* override check stay pure so they unit-test without an Electron runtime.
+6 -1
View File
@@ -127,9 +127,14 @@ interface EnvironmentConfig {
sessionScratchpadDir?: string; // this Session's scratchpad (scratchpad/<sessionId>); enables truncated-output recovery
services?: EnvironmentServices; // runtime services injected into individual tools
vault?: Record<string, string>; // Vault env vars, injected into exec_command / input_command subprocesses
stripProxyEnv?: () => boolean; // true = strip HTTP(S)_PROXY/ALL_PROXY (NO_PROXY kept) from command subprocesses; re-read per spawn, absent = proxy allowed
proxyEnv?: () => ProxyEnvPolicy | null; // command-subprocess proxy policy; re-read per spawn, absent or null = pass through
}
// "strip" removes HTTP(S)_PROXY/ALL_PROXY (NO_PROXY kept); "inject" forces the explicit
// proxy over the inherited env: HTTP(S)_PROXY (+ lowercase twins) = url, NO_PROXY = noProxy
// (supplied pre-merged by the caller), inherited ALL_PROXY removed. Vault entries still win.
type ProxyEnvPolicy = { mode: "strip" } | { mode: "inject"; url: string; noProxy: string };
interface EnvironmentServices {
subagentRunner?: SubagentRunner; // needed by run_subagent
visionDescriber?: VisionDescriberService; // needed by describe_image on text-only models
+6 -1
View File
@@ -127,9 +127,14 @@ interface EnvironmentConfig {
sessionScratchpadDir?: string; // 本 Session 的 scratchpad(scratchpad/<sessionId>),提供后启用截断输出恢复
services?: EnvironmentServices; // 注入给个别工具的运行时服务
vault?: Record<string, string>; // Vault 环境变量,注入 exec_command / input_command 子进程
stripProxyEnv?: () => boolean; // 返回 true 时从命令子进程剥除 HTTP(S)_PROXY/ALL_PROXY(保留 NO_PROXY);每次 spawn 重读,缺省即允许代理
proxyEnv?: () => ProxyEnvPolicy | null; // 命令子进程代理策略;每次 spawn 重读,缺省或 null 即原样透传
}
// "strip" 剥除 HTTP(S)_PROXY/ALL_PROXY(保留 NO_PROXY);"inject" 以显式代理覆盖继承环境:
// HTTP(S)_PROXY(含小写拼写)= url、NO_PROXY = noProxy(由调用方预先合并),继承的 ALL_PROXY
// 一并移除。Vault 条目仍然优先。
type ProxyEnvPolicy = { mode: "strip" } | { mode: "inject"; url: string; noProxy: string };
interface EnvironmentServices {
subagentRunner?: SubagentRunner; // run_subagent 所需
visionDescriber?: VisionDescriberService; // text-only 模型的 describe_image 所需
+8 -2
View File
@@ -73,10 +73,16 @@ In desktop mode (the server spawned by the desktop app) the whole surface answer
| Method | Path | Description |
| --- | --- | --- |
| GET | /api/admin/settings | Server-global settings: `{settings: {useSystemProxy}}` |
| GET | /api/admin/settings | Server-global settings: `{settings: {proxyForApp, proxyForAgent, proxyUrl}}` |
| PUT | /api/admin/settings | Update settings (fields optional; omitted fields keep their current value), returns the full updated settings |
`useSystemProxy` is the "use system HTTP proxy" switch (default on): while on, the server and its child processes honor HTTP_PROXY / HTTPS_PROXY / NO_PROXY (both spellings) for outbound traffic; while off, the server always connects directly and the proxy variables are stripped from agent command subprocess environments (NO_PROXY is kept). In either state the effective NO_PROXY always includes `localhost,127.0.0.1,::1` (loopback is never proxied). Toggling takes effect for newly initiated connections immediately — no restart.
The proxy settings are two independent switches sharing one optional explicit address; changes take effect for newly initiated connections/spawns immediately — no restart:
- `proxyForApp` ("application uses the proxy", default on) governs the server's own outbound traffic (LLM requests, the update check, image fetches): on with `proxyUrl` set → that address for both http and https, **taking precedence over the proxy environment variables** — no environment variable needs to be configured; on without an address → the environment variables HTTP_PROXY / HTTPS_PROXY / NO_PROXY (both spellings); off → always direct.
- `proxyForAgent` ("agent environment uses the proxy", default on) governs agent command subprocess environments: on with `proxyUrl` set → `HTTP_PROXY` / `HTTPS_PROXY` (plus lowercase twins) are injected as that address together with the merged NO_PROXY, overriding inherited values; on without an address → the host environment passes through unchanged; off → the proxy variables are stripped (NO_PROXY is kept).
- `proxyUrl` (default null = follow the environment variables) is the shared explicit address. Validation on PUT: the value is trimmed; empty or null clears the address; accepted forms are `http://host[:port]`, `https://host[:port]`, and bare `host[:port]` (normalized to `http://host[:port]` — only normalized values are stored, and the response echoes the stored form); anything else is `400` with code `invalid_proxy_url`, and the rejected PUT writes nothing.
In every on-state the effective NO_PROXY always includes `localhost,127.0.0.1,::1` (loopback is never proxied).
### Version and Self-Update
+8 -2
View File
@@ -73,10 +73,16 @@ curl -c cookies.txt -H "Content-Type: application/json" \
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| GET | /api/admin/settings | 服务端全局设置:`{settings: {useSystemProxy}}` |
| GET | /api/admin/settings | 服务端全局设置:`{settings: {proxyForApp, proxyForAgent, proxyUrl}}` |
| PUT | /api/admin/settings | 更新设置(字段可省略,省略即保持现值),返回更新后的完整设置 |
`useSystemProxy` 即「使用系统 HTTP 代理」开关(默认开):开时服务端及其子进程出网遵循 HTTP_PROXY / HTTPS_PROXY / NO_PROXY(大小写并存);关时服务端一律直连,并从 Agent 命令子进程环境中剥除代理变量(NO_PROXY 保留)。任一状态下生效的 NO_PROXY 恒包含 `localhost,127.0.0.1,::1`(回环不代理)。切换即时生效(对新发起的连接),无需重启。
代理设置为两个独立开关共享一个可选的显式地址;修改即时生效(对新发起的连接与新派生的子进程),无需重启:
- `proxyForApp`(「应用程序使用代理」,默认开)治理服务端自身出网(LLM 请求、更新检查、图片抓取):开且填写了 `proxyUrl` → http 与 https 流量都走该地址,**优先于代理环境变量**——无需配置任何环境变量;开但未填地址 → 遵循 HTTP_PROXY / HTTPS_PROXY / NO_PROXY 环境变量(大小写并存);关 → 一律直连。
- `proxyForAgent`(「Agent 环境使用代理」,默认开)治理 Agent 命令子进程环境:开且填写了 `proxyUrl` → 注入 `HTTP_PROXY` / `HTTPS_PROXY`(含小写拼写)为该地址并附合并后的 NO_PROXY,覆盖继承值;开但未填地址 → 宿主环境原样透传;关 → 剥除代理变量(NO_PROXY 保留)。
- `proxyUrl`(默认 null = 跟随环境变量)即两者共享的显式地址。PUT 校验:先 trim;空串或 null 即清除地址;接受 `http://主机[:端口]`、`https://主机[:端口]` 与裸 `主机[:端口]`(规范化为 `http://主机[:端口]`——只存储规范化后的值,响应回显存储形态);其余一律 `400`,错误码 `invalid_proxy_url`,且被拒绝的 PUT 不写入任何字段。
任一开启状态下生效的 NO_PROXY 恒包含 `localhost,127.0.0.1,::1`(回环不代理)。
### 版本与在线更新
+40 -13
View File
@@ -64,8 +64,9 @@ export interface AuthResponse {
export interface MeResponse {
user: UserInfo;
/**
* Whether Workspace HTML previews open on a separate origin (see design §
* "Workspace 文件预览"). False means this deployment has no usable preview origin —
* Whether Workspace HTML previews open on a separate origin (the loopback
* counterpart of the App host, or PENGUIN_PREVIEW_ORIGIN when set). False means this
* deployment has no usable preview origin —
* the App is reached on something other than a loopback name and
* PENGUIN_PREVIEW_ORIGIN is unset — so previews fall back to the same-origin sandbox,
* where `localStorage`, cookies and third-party embeds do not work. Computed per
@@ -76,7 +77,7 @@ export interface MeResponse {
* Whether this server runs in desktop mode (spawned by the desktop shell with
* PENGUIN_DESKTOP_TOKEN). The web app then hides the logout entry, the
* initial-password banner and the self-update entry, and omits the old-password
* field when changing the password. See design § "桌面端原型".
* field when changing the password.
*/
desktopMode: boolean;
/**
@@ -119,17 +120,35 @@ export interface AdminPasswordResetRequest {
password: string;
}
/** Admin-level server-global settings (SQLite server_settings; design § "出网与系统代理"). */
/**
* Admin-level server-global settings (SQLite server_settings):
* two independent proxy switches sharing one optional explicit address. In every
* on-state the effective NO_PROXY always includes localhost/127.0.0.1/::1 (loopback is
* never proxied), and changes apply to newly initiated connections/spawns immediately —
* no restart.
*/
export interface ServerSettings {
/**
* "Use system HTTP proxy" (default on): whether the server process and its child
* processes reach the internet through the proxy named by HTTP_PROXY / HTTPS_PROXY
* (both spellings). Off = direct connections, with the proxy variables also stripped
* from agent command subprocess environments. Either way the effective NO_PROXY always
* includes localhost/127.0.0.1/::1 (loopback is never proxied). Toggling applies to
* newly initiated connections immediately — no restart.
* "Application uses the proxy" (default on): the server's own outbound traffic (LLM
* requests, the update check, image fetches). On with `proxyUrl` set = that address
* for both http and https; on without an address = the proxy environment variables
* HTTP_PROXY / HTTPS_PROXY (both spellings); off = always direct.
*/
useSystemProxy: boolean;
proxyForApp: boolean;
/**
* "Agent environment uses the proxy" (default on): agent command subprocess
* environments. On with `proxyUrl` set = HTTP_PROXY / HTTPS_PROXY (plus lowercase
* twins) injected as that address with the merged NO_PROXY, overriding inherited
* values; on without an address = the host environment passes through unchanged;
* off = the proxy variables are stripped (NO_PROXY kept).
*/
proxyForAgent: boolean;
/**
* The shared explicit proxy address (canonical `http(s)://host[:port]`), or null =
* follow the proxy environment variables. When set it takes precedence over
* HTTP_PROXY / HTTPS_PROXY wherever the owning switch is on.
*/
proxyUrl: string | null;
}
export interface ServerSettingsResponse {
@@ -138,7 +157,15 @@ export interface ServerSettingsResponse {
/** PUT body: every field optional, omitted fields keep their current value (mirrors prefs). */
export interface ServerSettingsUpdateRequest {
useSystemProxy?: boolean;
proxyForApp?: boolean;
proxyForAgent?: boolean;
/**
* New proxy address. Accepted forms: `http://host[:port]`, `https://host[:port]`, or
* bare `host[:port]` (normalized to `http://…` — only normalized values are stored,
* and the response echoes the stored form). Empty/whitespace-only or null clears the
* address (follow the environment variables); anything else is 400 `invalid_proxy_url`.
*/
proxyUrl?: string | null;
}
/** User UI preferences (SQLite ui_prefs, free-form JSON; known keys declared here). */
@@ -700,7 +727,7 @@ export interface MessagesPageInfo {
before?: string;
/**
* Outline turns (the Web conversation outline's entry rule) opened BEFORE this
* window: the client offsets its global `第 N 轮` numbering by this, so a partial
* window: the client offsets its global "round N" numbering by this, so a partial
* window never mis-numbers. 0 when the window starts at the beginning.
*/
earlierTurns: number;
+21 -12
View File
@@ -13,7 +13,9 @@ import { Hono } from "hono";
import type { Context } from "hono";
import { bodyLimit } from "hono/body-limit";
import type { DatabaseSync } from "node:sqlite";
import type { ProxyEnvPolicy } from "@prismshadow/penguin-core";
import type { ServerConfig } from "./config.js";
import { mergedNoProxy } from "./net/proxy.js";
import { openDatabase } from "./db/database.js";
import { AgentsRepo } from "./db/repos/agents.js";
import { AuthSessionsRepo } from "./db/repos/auth-sessions.js";
@@ -95,7 +97,7 @@ export interface AppDeps {
db: DatabaseSync;
sessionsRepo: SessionsRepo;
prefsRepo: UiPrefsRepo;
/** Admin-level server-global settings (currently the "use system HTTP proxy" switch). */
/** Admin-level server-global settings (currently the proxy switches and address). */
serverSettingsRepo: ServerSettingsRepo;
authService: AuthService;
adminService: AdminService;
@@ -157,12 +159,20 @@ export function buildAppDeps(config: ServerConfig, overrides: BuildDepsOverrides
const errorsRepo = new ErrorsRepo(db);
const prefsRepo = new UiPrefsRepo(db);
const serverSettingsRepo = new ServerSettingsRepo(db);
// Proxy-off also strips HTTP(S)_PROXY/ALL_PROXY from agent command subprocess
// environments (design § "出网与系统代理"). A getter, not a snapshot: it is re-read at
// every command spawn, so a toggle reaches already-loaded Sessions. Threaded through
// BOTH core entry paths — the loader (resume/self-heal) and SessionService (creation,
// whose runtime the manager adopts for the first Task).
const stripProxyEnv = () => !serverSettingsRepo.getUseSystemProxy();
// Command-subprocess proxy policy for core, keyed on the
// "agent environment uses the proxy" switch (the app switch only drives the server's
// own dispatcher, see net/proxy.ts): switch off → strip HTTP(S)_PROXY/ALL_PROXY; on
// with an explicit address → inject that address (with the merged loopback NO_PROXY)
// over whatever the environment carries; on without an address → pass the environment
// through. A getter, not a snapshot: it is re-read at every command spawn, so a
// settings change reaches already-loaded Sessions. Threaded through BOTH core entry
// paths — the loader (resume/self-heal) and SessionService (creation, whose runtime
// the manager adopts for the first Task).
const proxyEnv = (): ProxyEnvPolicy | null => {
if (!serverSettingsRepo.getProxyForAgent()) return { mode: "strip" };
const url = serverSettingsRepo.getProxyUrl();
return url === null ? null : { mode: "inject", url, noProxy: mergedNoProxy() };
};
const schedulesRepo = new SchedulesRepo(db);
const goalsRepo = new GoalsRepo(db);
@@ -214,8 +224,7 @@ export function buildAppDeps(config: ServerConfig, overrides: BuildDepsOverrides
const manager = new SessionManager({
sessions: sessionsRepo,
channels,
loader:
overrides.loader ?? createCoreSessionLoader(config.root, sessionSources, { stripProxyEnv }),
loader: overrides.loader ?? createCoreSessionLoader(config.root, sessionSources, { proxyEnv }),
sources: sessionSources,
recorder,
errors,
@@ -264,7 +273,7 @@ export function buildAppDeps(config: ServerConfig, overrides: BuildDepsOverrides
projectConfig: projectConfigService,
sources: sessionSources,
traceIndex,
stripProxyEnv,
proxyEnv,
});
// Schedule scheduler: active only while the server is running. Only
// assembled here; start() is called in index.ts (tests drive it via tickOnce, no real timer).
@@ -350,7 +359,7 @@ export function createApp(deps: AppDeps): Hono<AppEnv> {
// cookie had ever been set on that host — act as the user. So the preview host serves ONLY
// /preview/*: /api answers 401 (it never sets or honors a cookie there, closing both the
// login and the stale-cookie paths), and everything else 302s to the canonical App host.
// See design § "Workspace 文件预览". Off when PENGUIN_PREVIEW_ORIGIN is set: previews then
// Off when PENGUIN_PREVIEW_ORIGIN is set: previews then
// use that origin rather than the loopback counterpart, so 127.0.0.1 is an ordinary App
// access point and must not be locked down — deployments enforce the equivalent at the
// reverse proxy (route only /preview/* to the App on the preview origin).
@@ -428,7 +437,7 @@ export function createApp(deps: AppDeps): Hono<AppEnv> {
// Workspace HTML preview on the separate preview origin: deliberately outside /api and
// outside the auth middleware — that origin never receives the session cookie, so the
// signed token in the path is the only credential. Mounted before static hosting so the
// SPA fallback cannot swallow it. See design § "Workspace 文件预览".
// SPA fallback cannot swallow it.
app.route("/preview", previewRoutes(deps));
// Static hosting (production): serves the frontend build output when webDist exists, with SPA fallback to index.html.
@@ -5,8 +5,22 @@
*/
import type { DatabaseSync } from "node:sqlite";
/** Key of the "use system HTTP proxy" switch (design § "出网与系统代理"); default on. */
const USE_SYSTEM_PROXY_KEY = "use_system_proxy";
/** Key of the "application uses the proxy" switch (the server's own outbound dispatcher); default on. */
const PROXY_FOR_APP_KEY = "proxy_for_app";
/** Key of the "agent environment uses the proxy" switch (command subprocess env policy); default on. */
const PROXY_FOR_AGENT_KEY = "proxy_for_agent";
/**
* Legacy single-switch key from the unreleased #225 iteration (never in a release): read
* as the fallback default for BOTH new switches while their own keys are absent, so a
* main-branch deployment that had toggled it keeps its choice. Read-only adoption — the
* legacy key is never written back, and either new key, once set, wins for its switch.
*/
const LEGACY_USE_SYSTEM_PROXY_KEY = "use_system_proxy";
/** Key of the explicit proxy address; absent/null = follow the proxy environment variables. */
const PROXY_URL_KEY = "proxy_url";
export class ServerSettingsRepo {
constructor(private readonly db: DatabaseSync) {}
@@ -26,12 +40,48 @@ export class ServerSettingsRepo {
.run(key, value);
}
/** The "use system HTTP proxy" switch; an absent (or unreadable) row reads as the default: on. */
getUseSystemProxy(): boolean {
return this.get(USE_SYSTEM_PROXY_KEY) !== "false";
/** Shared switch read: this key if present, else the legacy single switch, else the default: on. */
private getProxySwitch(key: string): boolean {
const raw = this.get(key);
if (raw !== null) return raw !== "false";
return this.get(LEGACY_USE_SYSTEM_PROXY_KEY) !== "false";
}
setUseSystemProxy(value: boolean): void {
this.set(USE_SYSTEM_PROXY_KEY, JSON.stringify(value));
/** The "application uses the proxy" switch (the server's own outbound dispatcher). */
getProxyForApp(): boolean {
return this.getProxySwitch(PROXY_FOR_APP_KEY);
}
setProxyForApp(value: boolean): void {
this.set(PROXY_FOR_APP_KEY, JSON.stringify(value));
}
/** The "agent environment uses the proxy" switch (command subprocess env policy). */
getProxyForAgent(): boolean {
return this.getProxySwitch(PROXY_FOR_AGENT_KEY);
}
setProxyForAgent(value: boolean): void {
this.set(PROXY_FOR_AGENT_KEY, JSON.stringify(value));
}
/**
* The explicit proxy address (normalized at write time by the settings route); null =
* follow the proxy environment variables. An absent or unreadable row reads as null
* (the safe default: behave as before the setting existed).
*/
getProxyUrl(): string | null {
const raw = this.get(PROXY_URL_KEY);
if (raw === null) return null;
try {
const value: unknown = JSON.parse(raw);
return typeof value === "string" && value !== "" ? value : null;
} catch {
return null;
}
}
setProxyUrl(value: string | null): void {
this.set(PROXY_URL_KEY, JSON.stringify(value));
}
}
+1 -1
View File
@@ -122,7 +122,7 @@ CREATE TABLE IF NOT EXISTS ui_prefs (
prefs_json TEXT NOT NULL -- {theme?, lastProjectId?, ...} free-form JSON
);
CREATE TABLE IF NOT EXISTS server_settings ( -- admin-level server-global settings (GET/PUT /api/admin/settings)
key TEXT PRIMARY KEY, -- setting name, e.g. 'use_system_proxy'
key TEXT PRIMARY KEY, -- setting name, e.g. 'proxy_for_app'
value TEXT NOT NULL -- JSON-encoded value; an absent row means the setting's built-in default
);
CREATE TABLE IF NOT EXISTS trace_files ( -- DERIVED CACHE of the on-disk Trace tree (services/trace-index.ts): the directories stay the single source of truth, every row is rebuildable from disk, and a row is never authority for absence — consumers reconcile + retry on a miss, so a stale index costs one extra scan, never a false 404
@@ -1,9 +1,13 @@
/**
* Admin server-settings routes (admin only, 403 for non-admins):
* GET|PUT /api/admin/settings — the server-global settings stored in server_settings
* (currently the "use system HTTP proxy" switch, design § "出网与系统代理").
* A PUT applies immediately: the persisted value is written first, then the process
* dispatcher is rebuilt so new outbound connections follow the toggle without a restart.
* (currently the proxy settings: the "application uses the proxy" and "agent
* environment uses the proxy" switches and their shared explicit address).
* A PUT applies immediately: everything is validated first (a rejected request writes
* nothing), then the persisted values are written, then the process dispatcher is
* rebuilt so new outbound connections follow the change without a restart (the agent
* switch needs no push — the command-subprocess policy getter re-reads the repo at
* every spawn).
*/
import { Hono } from "hono";
import type { ServerSettingsResponse } from "../../api/types.js";
@@ -11,7 +15,26 @@ import { HttpError } from "../errors.js";
import type { AppEnv } from "../../auth/middleware.js";
import { optionalBoolean, readJson } from "../validate.js";
import type { AppDeps } from "../../app.js";
import { setUseSystemProxy } from "../../net/proxy.js";
import { applyProxySettings, normalizeProxyUrl } from "../../net/proxy.js";
/**
* proxyUrl update value -> stored value: null and empty/whitespace-only clear the
* address; anything else must normalize (see normalizeProxyUrl) or the whole PUT is
* rejected with `invalid_proxy_url` — un-normalized values are never stored.
*/
function parseProxyUrl(value: unknown): string | null {
if (value === null) return null;
if (typeof value === "string") {
if (value.trim() === "") return null;
const normalized = normalizeProxyUrl(value);
if (normalized !== null) return normalized;
}
throw new HttpError(
400,
"invalid_proxy_url",
"proxyUrl must be http://host[:port], https://host[:port], or host[:port] (empty or null clears it).",
);
}
export function adminSettingsRoutes(deps: AppDeps): Hono<AppEnv> {
const app = new Hono<AppEnv>();
@@ -24,19 +47,32 @@ export function adminSettingsRoutes(deps: AppDeps): Hono<AppEnv> {
});
const settings = (): ServerSettingsResponse => ({
settings: { useSystemProxy: deps.serverSettingsRepo.getUseSystemProxy() },
settings: {
proxyForApp: deps.serverSettingsRepo.getProxyForApp(),
proxyForAgent: deps.serverSettingsRepo.getProxyForAgent(),
proxyUrl: deps.serverSettingsRepo.getProxyUrl(),
},
});
app.get("/", (c) => c.json(settings()));
app.put("/", async (c) => {
const body = await readJson(c);
const useSystemProxy = optionalBoolean(body, "useSystemProxy");
if (useSystemProxy !== undefined) {
deps.serverSettingsRepo.setUseSystemProxy(useSystemProxy);
// Mirror into the process: rebuilds the global fetch dispatcher (live toggle).
setUseSystemProxy(useSystemProxy);
}
// Validate every provided field before writing any: a partial PUT with one invalid
// field must leave the others untouched too.
const proxyForApp = optionalBoolean(body, "proxyForApp");
const proxyForAgent = optionalBoolean(body, "proxyForAgent");
const proxyUrlProvided = body.proxyUrl !== undefined;
const proxyUrl = proxyUrlProvided ? parseProxyUrl(body.proxyUrl) : null;
if (proxyForApp !== undefined) deps.serverSettingsRepo.setProxyForApp(proxyForApp);
if (proxyForAgent !== undefined) deps.serverSettingsRepo.setProxyForAgent(proxyForAgent);
if (proxyUrlProvided) deps.serverSettingsRepo.setProxyUrl(proxyUrl);
// Mirror the app switch + address into the process: rebuilds the global fetch
// dispatcher (live change, no restart; a no-op when nothing effectively changed).
applyProxySettings({
proxyForApp: deps.serverSettingsRepo.getProxyForApp(),
proxyUrl: deps.serverSettingsRepo.getProxyUrl(),
});
return c.json(settings());
});
+14 -9
View File
@@ -15,7 +15,7 @@ import { config as loadDotenv } from "dotenv";
import { serve } from "@hono/node-server";
import { buildAppDeps, createApp } from "./app.js";
import { resolveServerConfig } from "./config.js";
import { installGlobalProxyDispatcher, setUseSystemProxy } from "./net/proxy.js";
import { applyProxySettings, installGlobalProxyDispatcher } from "./net/proxy.js";
import { loopbackHostRoles } from "./services/preview-token.js";
import { acquireServerLock, liveServerLock, releaseServerLock } from "./lock.js";
@@ -23,9 +23,10 @@ loadDotenv({ quiet: true });
// Outbound proxy support, installed at the earliest point (right after dotenv, which may
// itself define HTTP_PROXY): replaces globalThis.fetch with undici's and sets the global
// dispatcher, starting from the switch default (on). The persisted value can only be
// read once the database is open, so it is applied via setUseSystemProxy right after
// buildAppDeps below — nothing in between makes an outbound request. See net/proxy.ts.
// dispatcher, starting from the defaults (app switch on, no explicit address). The
// persisted values can only be read once the database is open, so they are applied via
// applyProxySettings right after buildAppDeps below — nothing in between makes an
// outbound request. See net/proxy.ts.
installGlobalProxyDispatcher();
/** Exit code for "another server already owns this data root" (see lock.ts). */
@@ -47,10 +48,14 @@ if (existingLock) {
}
const deps = buildAppDeps(config);
// The database is open now: bring the dispatcher in line with the persisted
// "use system HTTP proxy" switch (an absent row reads as the default: on) before the
// first possible outbound request (update check, LLM calls — all behind HTTP handlers).
setUseSystemProxy(deps.serverSettingsRepo.getUseSystemProxy());
// The database is open now: bring the dispatcher in line with the persisted proxy
// settings (absent rows read as the defaults: app switch on, no explicit address)
// before the first possible outbound request (update check, LLM calls — all behind
// HTTP handlers).
applyProxySettings({
proxyForApp: deps.serverSettingsRepo.getProxyForApp(),
proxyUrl: deps.serverSettingsRepo.getProxyUrl(),
});
const app = createApp(deps);
// Built-in admin seed (idempotent): creates admin and adopts default_project when the
@@ -84,7 +89,7 @@ const appHost = loopbackHostRoles(config.host)?.app ?? config.host;
* Second loopback listener so the preview origin is actually reachable.
*
* Workspace HTML previews are served from the loopback counterpart of the host the App
* is used on (`127.0.0.1` <-> `localhost`, see design § "Workspace 文件预览"). On most
* is used on (`127.0.0.1` <-> `localhost`). On most
* systems `localhost` resolves to `::1` first, so a server bound only to `127.0.0.1`
* would leave every preview URL refusing connections. Binding `::1` as well closes that
* gap. Failure is non-fatal — the App keeps working, previews just fall back.
+109 -39
View File
@@ -1,29 +1,39 @@
/**
* Outbound networking and the "use system HTTP proxy" switch (design § "出网与系统代理").
* Outbound networking and the admin proxy settings.
*
* Node 24's built-in fetch does not read the proxy environment variables
* (NODE_USE_ENV_PROXY is ineffective on 24.18), so the server routes ALL of its own
* outbound traffic through undici: the startup entry replaces `globalThis.fetch` with
* undici's fetch once, and this module keeps undici's global dispatcher in line with the
* admin-level setting — undici's fetch resolves the global dispatcher per call, so a
* toggle takes effect for newly initiated connections without a restart.
* admin-level settings — undici's fetch resolves the global dispatcher per call, so a
* settings change takes effect for newly initiated connections without a restart.
*
* Dispatcher per setting:
* - on (default): `EnvHttpProxyAgent` — honors HTTP_PROXY / HTTPS_PROXY (both
* spellings; undici reads the lowercase name first) with a NO_PROXY list merged from
* the environment plus the loopback names;
* - off: a plain `Agent` — direct connections, proxy variables ignored.
* Dispatcher per settings state (keyed on the "application uses the proxy" switch; the
* separate agent-environment switch never touches the dispatcher — it only drives the
* command-subprocess policy in app.ts):
* - app switch off: a plain `Agent` — direct connections, proxy variables ignored;
* - on, no explicit address (default): `EnvHttpProxyAgent` — honors HTTP_PROXY /
* HTTPS_PROXY (both spellings; undici reads the lowercase name first) with a
* NO_PROXY list merged from the environment plus the loopback names;
* - on with an explicit address (`proxyUrl`): `EnvHttpProxyAgent` again, but with its
* `httpProxy` / `httpsProxy` options pinned to that URL — undici consults those
* BEFORE the environment (verified against undici 7.29's constructor:
* `httpProxy ?? process.env.http_proxy ?? process.env.HTTP_PROXY`), so the explicit
* address governs both http and https traffic regardless of ambient env, while the
* merged NO_PROXY keeps working through the same `noProxy` option.
*
* The loopback merge is load-bearing in either state: the CLI's readiness probe
* The loopback merge is load-bearing in every state: the CLI's readiness probe
* (`penguin web` imports the server in-process and polls its own root path), SSE, and
* Workspace previews all ride the loopback names, so a proxy that intercepts loopback
* would take the whole App down. `localhost,127.0.0.1,::1` is therefore ALWAYS appended
* to the effective NO_PROXY, regardless of what the environment declares.
*
* The value persisted in server_settings stays authoritative (the routes read/write the
* repo); this module only mirrors it into the process-global dispatcher. Stripping the
* proxy variables from agent command subprocesses when the switch is off lives in core
* (CommandSessionManager, threaded through the session loader), not here.
* The values persisted in server_settings stay authoritative (the routes read/write the
* repo); this module only mirrors them into the process-global dispatcher. The command
* subprocess side — keyed on the agent-environment switch: strip the proxy variables
* when it is off, inject the explicit address when set, pass through otherwise — lives
* in core (CommandSessionManager, threaded through the session loader as a
* ProxyEnvPolicy getter), not here.
*/
import { Agent, EnvHttpProxyAgent, fetch as undiciFetch, setGlobalDispatcher } from "undici";
import type { Dispatcher } from "undici";
@@ -31,10 +41,19 @@ import type { Dispatcher } from "undici";
/** Loopback names every effective NO_PROXY must contain (see module doc). */
export const LOOPBACK_NO_PROXY = ["localhost", "127.0.0.1", "::1"] as const;
/** The slice of the admin proxy settings the dispatcher mirrors (persisted in server_settings). */
export interface ProxySettings {
/** The "application uses the proxy" switch (default on). */
proxyForApp: boolean;
/** Explicit proxy URL (normalized, see {@link normalizeProxyUrl}); null = follow the proxy environment variables. */
proxyUrl: string | null;
}
/**
* The effective NO_PROXY: the environment's entries (lowercase spelling first, matching
* undici's own precedence) with the loopback names appended when missing. Exported as a
* pure helper for tests; `env` defaults to the real process environment.
* pure helper for tests and for app.ts's command-subprocess policy getter; `env`
* defaults to the real process environment.
*/
export function mergedNoProxy(env: NodeJS.ProcessEnv = process.env): string {
const raw = env.no_proxy ?? env.NO_PROXY ?? "";
@@ -48,49 +67,100 @@ export function mergedNoProxy(env: NodeJS.ProcessEnv = process.env): string {
}
/**
* Builds the global dispatcher for a switch state (pure choice, exported for tests):
* on → EnvHttpProxyAgent with the merged NO_PROXY, off → direct-connect Agent.
* Normalizes a proxy address to its canonical URL, or null when it is not acceptable.
* Accepted forms: `http://host[:port]`, `https://host[:port]`, and the bare
* `host[:port]` / `host` shorthand (normalized to `http://…`). Anything else — other
* schemes (the dispatcher speaks only HTTP(S) proxies; a socks:// URL would break every
* server fetch, see os-proxy.ts for the same rule), credentials, paths, queries — is
* rejected rather than stored: server_settings only ever holds normalized values.
*/
export function buildProxyDispatcher(useSystemProxy: boolean, env?: NodeJS.ProcessEnv): Dispatcher {
// The merged list is passed via opts.noProxy, which REPLACES the environment lookup
// inside EnvHttpProxyAgent — merging is this module's job (undici would otherwise use
// env NO_PROXY verbatim, without the loopback exemption).
return useSystemProxy ? new EnvHttpProxyAgent({ noProxy: mergedNoProxy(env) }) : new Agent();
export function normalizeProxyUrl(raw: string): string | null {
const trimmed = raw.trim();
if (trimmed === "") return null;
// Bare host[:port] shorthand: no scheme -> http. The scheme test requires "://", so
// "host:8080" (a port, not a scheme) takes the shorthand path.
const candidate = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(trimmed) ? trimmed : `http://${trimmed}`;
let url: URL;
try {
url = new URL(candidate);
} catch {
return null;
}
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
if (url.hostname === "" || url.username !== "" || url.password !== "") return null;
// A proxy address is host-only; the parser's bare "/" (its normal form for an absent
// path) is tolerated, an actual path/query/fragment means this was not a proxy address.
if ((url.pathname !== "/" && url.pathname !== "") || url.search !== "" || url.hash !== "") {
return null;
}
// origin is the canonical form: lowercased scheme/host, default ports dropped.
return url.origin;
}
/** Current switch state mirrored for the dispatcher (the DB row is the persisted truth). */
let useSystemProxy = true;
/** The EnvHttpProxyAgent option subset this module assembles (undici's Options type is not re-exported cleanly). */
export interface EnvProxyAgentOptions {
httpProxy?: string;
httpsProxy?: string;
noProxy: string;
}
/**
* The EnvHttpProxyAgent options for the switch-on states (pure choice, exported for
* tests): always the merged NO_PROXY — passed via opts.noProxy, which REPLACES the
* environment lookup inside EnvHttpProxyAgent, so merging is this module's job (undici
* would otherwise use env NO_PROXY verbatim, without the loopback exemption) — plus,
* with an explicit address, the httpProxy/httpsProxy overrides that pin both traffic
* kinds to it.
*/
export function envProxyAgentOptions(
proxyUrl: string | null,
env?: NodeJS.ProcessEnv,
): EnvProxyAgentOptions {
const noProxy = mergedNoProxy(env);
return proxyUrl === null ? { noProxy } : { httpProxy: proxyUrl, httpsProxy: proxyUrl, noProxy };
}
/**
* Builds the global dispatcher for a settings state (pure choice, exported for tests):
* app switch off → direct-connect Agent, on → EnvHttpProxyAgent (env-driven, or pinned
* to the explicit address; see {@link envProxyAgentOptions}).
*/
export function buildProxyDispatcher(settings: ProxySettings, env?: NodeJS.ProcessEnv): Dispatcher {
if (!settings.proxyForApp) return new Agent();
return new EnvHttpProxyAgent(envProxyAgentOptions(settings.proxyUrl, env));
}
/** Current settings mirrored for the dispatcher (the DB rows are the persisted truth). */
let current: ProxySettings = { proxyForApp: true, proxyUrl: null };
/** True once the startup entry has installed undici globally (never in tests). */
let installed = false;
/**
* One-time install at the startup entry (index.ts), before anything can fetch: replaces
* `globalThis.fetch` with undici's and sets the initial dispatcher. Starts from the
* default (on) — the entry applies the persisted value via {@link setUseSystemProxy} as
* soon as the database is open, before any outbound request exists. Tests never call
* this, so they keep the runtime's own fetch (and their fetch stubs).
* defaults (on, no explicit address) — the entry applies the persisted values via
* {@link applyProxySettings} as soon as the database is open, before any outbound
* request exists. Tests never call this, so they keep the runtime's own fetch (and
* their fetch stubs).
*/
export function installGlobalProxyDispatcher(): void {
installed = true;
setGlobalDispatcher(buildProxyDispatcher(useSystemProxy));
setGlobalDispatcher(buildProxyDispatcher(current));
// Cast: undici's fetch is typed against its own RequestInit/Response declarations,
// which are structurally compatible with the runtime globals for every caller here.
globalThis.fetch = undiciFetch as unknown as typeof globalThis.fetch;
}
/**
* Applies a switch state to the process: rebuilds the global dispatcher so new
* Applies a settings state to the process: rebuilds the global dispatcher so new
* connections follow it immediately (no restart). Called by the startup entry with the
* persisted value and by PUT /api/admin/settings on every toggle. Outside the installed
* runtime (tests) it only records the state.
* persisted values and by PUT /api/admin/settings on every change. Outside the
* installed runtime (tests) it only records the state.
*/
export function setUseSystemProxy(value: boolean): void {
if (value === useSystemProxy) return;
useSystemProxy = value;
if (installed) setGlobalDispatcher(buildProxyDispatcher(useSystemProxy));
}
/** The switch state the dispatcher currently reflects. */
export function getUseSystemProxy(): boolean {
return useSystemProxy;
export function applyProxySettings(settings: ProxySettings): void {
if (settings.proxyForApp === current.proxyForApp && settings.proxyUrl === current.proxyUrl) {
return;
}
current = { proxyForApp: settings.proxyForApp, proxyUrl: settings.proxyUrl };
if (installed) setGlobalDispatcher(buildProxyDispatcher(current));
}
@@ -42,6 +42,7 @@ import type {
ApproveFn,
CompactAvailability,
OmniMessage,
ProxyEnvPolicy,
SessionMetaPayload,
SessionTitleResult,
TextPayload,
@@ -121,13 +122,14 @@ export interface SessionLoader {
* lets the no-Trace self-heal rebuild re-record a known origin into the fresh session_meta;
* with no registry entry (e.g. the process restarted and no Trace was ever written) the
* rebuilt Session is unsourced — session_meta is the single source of truth, and none survived.
* `opts.stripProxyEnv` threads the "use system HTTP proxy" switch into core (a live getter:
* true = strip HTTP(S)_PROXY/ALL_PROXY from agent command subprocess environments).
* `opts.proxyEnv` threads the admin proxy settings into core (a live getter returning the
* agent-command-subprocess policy: strip the proxy variables, inject the explicit proxy
* address, or null = pass the environment through).
*/
export function createCoreSessionLoader(
root: string,
sources?: SessionSources,
opts: { stripProxyEnv?: () => boolean } = {},
opts: { proxyEnv?: () => ProxyEnvPolicy | null } = {},
): SessionLoader {
return {
async load(row: SessionRow): Promise<RuntimeSession> {
@@ -135,7 +137,7 @@ export function createCoreSessionLoader(
root,
projectId: row.projectId,
agentId: row.agentId,
...(opts.stripProxyEnv ? { stripProxyEnv: opts.stripProxyEnv } : {}),
...(opts.proxyEnv ? { proxyEnv: opts.proxyEnv } : {}),
});
const located = await findLatestTraceFile(
tracesDir(root, row.projectId, row.agentId),
@@ -15,6 +15,7 @@
* added to session-manager's active table (state idle).
*/
import { createAgent, isSessionMeta } from "@prismshadow/penguin-core";
import type { ProxyEnvPolicy } from "@prismshadow/penguin-core";
import type {
ApprovalMode,
SessionCategory,
@@ -52,11 +53,11 @@ export interface SessionServiceDeps {
/** Trace-file index: discovery / adoption / stats serve from it (mtime-gated reconciler; no per-request walks). */
traceIndex: TraceIndexService;
/**
* "Use system HTTP proxy" switch threading (same getter the session loader passes,
* see createCoreSessionLoader): the runtime created here is adopted by the manager
* and runs the Session's first Task, so it needs the strip policy too.
* Admin proxy-settings threading (same getter the session loader passes, see
* createCoreSessionLoader): the runtime created here is adopted by the manager and
* runs the Session's first Task, so it needs the command-subprocess proxy policy too.
*/
stripProxyEnv?: () => boolean;
proxyEnv?: () => ProxyEnvPolicy | null;
}
export class SessionService {
@@ -325,7 +326,7 @@ export class SessionService {
root: this.deps.root,
projectId: args.projectId,
agentId: args.agentId,
...(this.deps.stripProxyEnv ? { stripProxyEnv: this.deps.stripProxyEnv } : {}),
...(this.deps.proxyEnv ? { proxyEnv: this.deps.proxyEnv } : {}),
});
let session;
try {
+119 -21
View File
@@ -1,7 +1,9 @@
/**
* Admin server-settings route tests: permission boundary (non-admin 403), the
* "use system HTTP proxy" default (absent row reads as on), PUT persistence and
* validation, and merge semantics (an omitted field keeps its current value).
* Admin server-settings route tests: permission boundary (non-admin 403), the proxy
* defaults (absent rows read as both switches on, no explicit address), adoption of the
* legacy single-switch key, PUT persistence and validation (proxy-address normalization
* and rejection), and merge semantics (an omitted field keeps its current value; a
* rejected PUT writes nothing).
*/
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { ServerSettingsResponse } from "../src/api/types.js";
@@ -30,39 +32,135 @@ describe("admin server settings", () => {
const { cookie } = await provisionUser(t.app, "norm");
const api = apiClient(t.app, cookie);
expect((await api.get("/api/admin/settings")).status).toBe(403);
expect((await api.put("/api/admin/settings", { useSystemProxy: false })).status).toBe(403);
expect((await api.put("/api/admin/settings", { proxyForApp: false })).status).toBe(403);
// The failed PUT changed nothing.
expect((await getSettings()).settings.useSystemProxy).toBe(true);
expect((await getSettings()).settings.proxyForApp).toBe(true);
});
it("useSystemProxy defaults to on while no row exists", async () => {
it("defaults while no rows exist: both switches on, no explicit address", async () => {
expect(t.deps.db.prepare("SELECT COUNT(*) AS n FROM server_settings").get()).toMatchObject({
n: 0,
});
expect((await getSettings()).settings.useSystemProxy).toBe(true);
const { settings } = await getSettings();
expect(settings.proxyForApp).toBe(true);
expect(settings.proxyForAgent).toBe(true);
expect(settings.proxyUrl).toBeNull();
});
it("PUT persists the toggle and echoes the full settings", async () => {
const off = await admin.put("/api/admin/settings", { useSystemProxy: false });
expect(off.status).toBe(200);
expect(((await off.json()) as ServerSettingsResponse).settings.useSystemProxy).toBe(false);
// Round-trips through the repo (the DB row, not process state, is what GET serves).
expect((await getSettings()).settings.useSystemProxy).toBe(false);
expect(t.deps.serverSettingsRepo.getUseSystemProxy()).toBe(false);
it("adopts the legacy use_system_proxy row as the default for BOTH switches", async () => {
// The single switch shipped only on unreleased main (#225): a deployment that had
// toggled it off must keep that choice for both new switches, with no migration —
// the legacy key is read as a fallback, never rewritten.
t.deps.serverSettingsRepo.set("use_system_proxy", "false");
let { settings } = await getSettings();
expect(settings.proxyForApp).toBe(false);
expect(settings.proxyForAgent).toBe(false);
// Setting one NEW switch writes only its own key: the other still follows legacy.
await admin.put("/api/admin/settings", { proxyForApp: true });
({ settings } = await getSettings());
expect(settings.proxyForApp).toBe(true);
expect(settings.proxyForAgent).toBe(false);
// The legacy row itself was never touched.
expect(t.deps.serverSettingsRepo.get("use_system_proxy")).toBe("false");
});
const on = await admin.put("/api/admin/settings", { useSystemProxy: true });
expect(on.status).toBe(200);
expect((await getSettings()).settings.useSystemProxy).toBe(true);
it("PUT persists the switches independently and echoes the full settings", async () => {
const appOff = await admin.put("/api/admin/settings", { proxyForApp: false });
expect(appOff.status).toBe(200);
const afterAppOff = ((await appOff.json()) as ServerSettingsResponse).settings;
expect(afterAppOff.proxyForApp).toBe(false);
expect(afterAppOff.proxyForAgent).toBe(true);
// Round-trips through the repo (the DB rows, not process state, are what GET serves).
expect(t.deps.serverSettingsRepo.getProxyForApp()).toBe(false);
expect(t.deps.serverSettingsRepo.getProxyForAgent()).toBe(true);
const agentOff = await admin.put("/api/admin/settings", { proxyForAgent: false });
expect(agentOff.status).toBe(200);
const { settings } = await getSettings();
expect(settings.proxyForApp).toBe(false);
expect(settings.proxyForAgent).toBe(false);
});
it("an omitted field keeps its current value; a non-boolean is 400", async () => {
await admin.put("/api/admin/settings", { useSystemProxy: false });
await admin.put("/api/admin/settings", { proxyForApp: false });
// Empty PUT: no-op, still returns the current settings.
const noop = await admin.put("/api/admin/settings", {});
expect(noop.status).toBe(200);
expect(((await noop.json()) as ServerSettingsResponse).settings.useSystemProxy).toBe(false);
expect(((await noop.json()) as ServerSettingsResponse).settings.proxyForApp).toBe(false);
// Type check: only booleans are accepted, and a rejected write changes nothing.
expect((await admin.put("/api/admin/settings", { useSystemProxy: "on" })).status).toBe(400);
expect((await getSettings()).settings.useSystemProxy).toBe(false);
expect((await admin.put("/api/admin/settings", { proxyForApp: "on" })).status).toBe(400);
expect((await admin.put("/api/admin/settings", { proxyForAgent: 1 })).status).toBe(400);
const { settings } = await getSettings();
expect(settings.proxyForApp).toBe(false);
expect(settings.proxyForAgent).toBe(true);
});
const putProxyUrl = async (proxyUrl: unknown) => admin.put("/api/admin/settings", { proxyUrl });
it("PUT normalizes the proxy address and stores/echoes only the normalized form", async () => {
// Bare host[:port] shorthand → http://.
const bare = await putProxyUrl("proxy.corp.example:8080");
expect(bare.status).toBe(200);
expect(((await bare.json()) as ServerSettingsResponse).settings.proxyUrl).toBe(
"http://proxy.corp.example:8080",
);
expect((await getSettings()).settings.proxyUrl).toBe("http://proxy.corp.example:8080");
expect(t.deps.serverSettingsRepo.getProxyUrl()).toBe("http://proxy.corp.example:8080");
// https passes through; surrounding whitespace is trimmed.
const https = await putProxyUrl(" https://proxy.corp.example:3128 ");
expect(https.status).toBe(200);
expect(((await https.json()) as ServerSettingsResponse).settings.proxyUrl).toBe(
"https://proxy.corp.example:3128",
);
});
it("PUT rejects a bad proxy address with invalid_proxy_url and stores nothing", async () => {
await putProxyUrl("http://proxy.corp.example:8080");
for (const bad of ["socks5://proxy.corp.example:1080", "not a proxy", 42]) {
const res = await putProxyUrl(bad);
expect(res.status).toBe(400);
const body = (await res.json()) as { error: { code: string } };
expect(body.error.code).toBe("invalid_proxy_url");
}
expect((await getSettings()).settings.proxyUrl).toBe("http://proxy.corp.example:8080");
});
it("a rejected combined PUT leaves the other fields untouched too", async () => {
// Validation happens before any write: the valid switch halves of a PUT whose
// proxyUrl is garbage must not land either — the PUT is atomic.
const res = await admin.put("/api/admin/settings", {
proxyForApp: false,
proxyForAgent: false,
proxyUrl: "not a proxy",
});
expect(res.status).toBe(400);
const { settings } = await getSettings();
expect(settings.proxyForApp).toBe(true);
expect(settings.proxyForAgent).toBe(true);
expect(settings.proxyUrl).toBeNull();
});
it("empty string and null both clear the address back to follow-the-environment", async () => {
for (const clear of ["", " ", null]) {
await putProxyUrl("http://proxy.corp.example:8080");
const res = await putProxyUrl(clear);
expect(res.status).toBe(200);
expect(((await res.json()) as ServerSettingsResponse).settings.proxyUrl).toBeNull();
expect(t.deps.serverSettingsRepo.getProxyUrl()).toBeNull();
}
});
it("partial PUTs keep the other fields: address-only keeps the switches and vice versa", async () => {
await admin.put("/api/admin/settings", { proxyForApp: false, proxyForAgent: false });
await putProxyUrl("proxy.corp.example:8080");
let { settings } = await getSettings();
expect(settings.proxyForApp).toBe(false);
expect(settings.proxyForAgent).toBe(false);
expect(settings.proxyUrl).toBe("http://proxy.corp.example:8080");
await admin.put("/api/admin/settings", { proxyForApp: true, proxyForAgent: true });
({ settings } = await getSettings());
expect(settings.proxyForApp).toBe(true);
expect(settings.proxyForAgent).toBe(true);
expect(settings.proxyUrl).toBe("http://proxy.corp.example:8080");
});
});
+149 -10
View File
@@ -1,11 +1,20 @@
/**
* Unit tests for the outbound-proxy module's pure parts: the NO_PROXY loopback merge
* and the dispatcher choice per switch state. No real network and no global install —
* installGlobalProxyDispatcher (fetch replacement) runs only in the production entry.
* Unit tests for the outbound-proxy module's pure parts — the NO_PROXY loopback merge,
* proxy-address normalization, and the dispatcher choice per settings state — plus one
* behavioral check that an explicit address actually routes traffic through that proxy
* while loopback stays direct. No global install — installGlobalProxyDispatcher (fetch
* replacement) runs only in the production entry.
*/
import http from "node:http";
import type { AddressInfo } from "node:net";
import { describe, expect, it } from "vitest";
import { Agent, EnvHttpProxyAgent } from "undici";
import { buildProxyDispatcher, mergedNoProxy } from "../src/net/proxy.js";
import { Agent, EnvHttpProxyAgent, fetch as undiciFetch } from "undici";
import {
buildProxyDispatcher,
envProxyAgentOptions,
mergedNoProxy,
normalizeProxyUrl,
} from "../src/net/proxy.js";
describe("mergedNoProxy", () => {
it("yields exactly the loopback names when the environment has no NO_PROXY", () => {
@@ -35,17 +44,147 @@ describe("mergedNoProxy", () => {
});
});
describe("normalizeProxyUrl", () => {
it("passes canonical http/https addresses through", () => {
expect(normalizeProxyUrl("http://proxy.corp.example:8080")).toBe(
"http://proxy.corp.example:8080",
);
expect(normalizeProxyUrl("https://proxy.corp.example:3128")).toBe(
"https://proxy.corp.example:3128",
);
expect(normalizeProxyUrl("http://proxy.corp.example")).toBe("http://proxy.corp.example");
});
it("normalizes the bare host[:port] shorthand to http://", () => {
expect(normalizeProxyUrl("proxy.corp.example:8080")).toBe("http://proxy.corp.example:8080");
expect(normalizeProxyUrl("proxy.corp.example")).toBe("http://proxy.corp.example");
expect(normalizeProxyUrl("127.0.0.1:7890")).toBe("http://127.0.0.1:7890");
});
it("canonicalizes case, surrounding whitespace, trailing slash, and default ports", () => {
expect(normalizeProxyUrl(" http://proxy.corp.example:8080 ")).toBe(
"http://proxy.corp.example:8080",
);
expect(normalizeProxyUrl("HTTP://PROXY.CORP.EXAMPLE:8080")).toBe(
"http://proxy.corp.example:8080",
);
expect(normalizeProxyUrl("http://proxy.corp.example:8080/")).toBe(
"http://proxy.corp.example:8080",
);
// URL's canonical form drops a scheme-default port; the connection is identical.
expect(normalizeProxyUrl("http://proxy.corp.example:80")).toBe("http://proxy.corp.example");
});
it("keeps IPv6 literals bracketed", () => {
expect(normalizeProxyUrl("http://[::1]:8080")).toBe("http://[::1]:8080");
});
it("rejects everything that is not an http(s) host[:port]", () => {
// Other schemes: the dispatcher speaks only HTTP(S) proxies.
expect(normalizeProxyUrl("socks5://proxy.corp.example:1080")).toBeNull();
expect(normalizeProxyUrl("ftp://proxy.corp.example")).toBeNull();
// Credentials, paths, queries, fragments: a proxy address is host-only.
expect(normalizeProxyUrl("http://user:pass@proxy.corp.example:8080")).toBeNull();
expect(normalizeProxyUrl("http://proxy.corp.example:8080/path")).toBeNull();
expect(normalizeProxyUrl("http://proxy.corp.example:8080?x=1")).toBeNull();
expect(normalizeProxyUrl("http://proxy.corp.example:8080#frag")).toBeNull();
// Unparseable shapes.
expect(normalizeProxyUrl("http://")).toBeNull();
expect(normalizeProxyUrl("not a proxy")).toBeNull();
expect(normalizeProxyUrl("proxy.corp.example:port")).toBeNull();
expect(normalizeProxyUrl("")).toBeNull();
expect(normalizeProxyUrl(" ")).toBeNull();
});
});
describe("envProxyAgentOptions", () => {
it("without an explicit address: only the merged NO_PROXY (env drives the proxies)", () => {
expect(envProxyAgentOptions(null, { NO_PROXY: "corp.example" })).toEqual({
noProxy: "corp.example,localhost,127.0.0.1,::1",
});
});
it("with an explicit address: both httpProxy and httpsProxy pinned to it, NO_PROXY still merged", () => {
// httpProxy/httpsProxy take precedence over the environment inside undici's
// EnvHttpProxyAgent (its constructor reads `httpProxy ?? env...`), so the ambient
// variables in `env` must not leak into the options this module assembles.
expect(
envProxyAgentOptions("http://explicit.example:3128", {
HTTP_PROXY: "http://ambient.example:8080",
NO_PROXY: "corp.example",
}),
).toEqual({
httpProxy: "http://explicit.example:3128",
httpsProxy: "http://explicit.example:3128",
noProxy: "corp.example,localhost,127.0.0.1,::1",
});
});
});
describe("buildProxyDispatcher", () => {
it("on → EnvHttpProxyAgent (proxy env honored), off → plain Agent (direct)", async () => {
const on = buildProxyDispatcher(true, {});
const off = buildProxyDispatcher(false, {});
it("app switch off → plain Agent; on → EnvHttpProxyAgent, with or without an explicit address", async () => {
const off = buildProxyDispatcher({ proxyForApp: false, proxyUrl: null }, {});
const onEnv = buildProxyDispatcher({ proxyForApp: true, proxyUrl: null }, {});
const onExplicit = buildProxyDispatcher(
{ proxyForApp: true, proxyUrl: "http://explicit.example:3128" },
{},
);
try {
expect(on).toBeInstanceOf(EnvHttpProxyAgent);
expect(off).toBeInstanceOf(Agent);
expect(off).not.toBeInstanceOf(EnvHttpProxyAgent);
expect(onEnv).toBeInstanceOf(EnvHttpProxyAgent);
expect(onExplicit).toBeInstanceOf(EnvHttpProxyAgent);
} finally {
await on.close();
await off.close();
await onEnv.close();
await onExplicit.close();
}
});
it("explicit address: traffic tunnels through that proxy, loopback bypasses it", async () => {
// The fake proxy answers CONNECT and then plays the tunneled origin itself, so a
// request to an unresolvable name can only succeed by traveling through the proxy.
const connects: string[] = [];
const proxy = http.createServer((_req, res) => res.end());
proxy.on("connect", (req, socket) => {
connects.push(req.url ?? "");
socket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
let buffered = "";
socket.on("data", (chunk: Buffer) => {
buffered += chunk.toString("utf8");
if (!buffered.includes("\r\n\r\n")) return; // wait for the full tunneled request head
socket.end(
"HTTP/1.1 200 OK\r\ncontent-type: text/plain\r\ncontent-length: 9\r\nconnection: close\r\n\r\nvia-proxy",
);
});
});
const direct = http.createServer((_req, res) => res.end("direct"));
const listen = (server: http.Server) =>
new Promise<number>((resolve) => {
// No hostname: dual-stack, so the localhost fetch below works whether the
// resolver picks ::1 or 127.0.0.1 first.
server.listen(0, () => resolve((server.address() as AddressInfo).port));
});
const proxyPort = await listen(proxy);
const directPort = await listen(direct);
// Empty env: no proxy variables — reaching the fake proxy proves the explicit URL
// is honored on its own, and the loopback exemption proves the merge alone (not an
// ambient NO_PROXY) produced it.
const dispatcher = buildProxyDispatcher(
{ proxyForApp: true, proxyUrl: `http://127.0.0.1:${proxyPort}` },
{},
);
try {
const viaProxy = await undiciFetch("http://proxied.invalid/x", { dispatcher });
expect(await viaProxy.text()).toBe("via-proxy");
expect(connects).toEqual(["proxied.invalid:80"]);
const directRes = await undiciFetch(`http://localhost:${directPort}/`, { dispatcher });
expect(await directRes.text()).toBe("direct");
expect(connects).toHaveLength(1); // the loopback request never touched the proxy
} finally {
await dispatcher.close();
proxy.close();
direct.close();
}
});
});
@@ -0,0 +1,131 @@
/**
* Admin proxy options dialog (server-global), opened from
* the sidebar user menu. A form, not a live surface: two switches — "Application uses
* the proxy" (the server's own outbound dispatcher) and "Agent environment uses the
* proxy" (command subprocess environments) — share one proxy address, and nothing is
* written until Save, which applies everything atomically via a single PUT. The server
* validates and normalizes the address (bare "host:port" is stored as
* "http://host:port"); a rejected address renders inline under the input (models-dialog
* convention) and the atomic PUT writes nothing. Save with no modifications sends no
* request and toasts "no changes" (the app's form convention); success
* toasts and closes. Settings hydrate fresh on every open; the controls and Save stay
* disabled until they arrive, and Cancel/Esc discards edits.
*/
import { useEffect, useState } from "react";
import type { ServerSettings } from "@prismshadow/penguin-server/api";
import * as api from "../../api/endpoints";
import { ApiError } from "../../api/client";
import { S } from "../../lib/strings";
import { apiErrorText } from "../../lib/api-error";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
import { Modal } from "../ui/modal";
import { Switch } from "../ui/switch";
import { toastError, toastInfo, toastSuccess } from "../ui/toast";
export function ProxySettingsDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
/** Stored settings as hydrated on open (null until then) — the no-change baseline. */
const [settings, setSettings] = useState<ServerSettings | null>(null);
// Form drafts; the pre-hydration values mirror the server defaults (on, on, empty).
const [proxyForApp, setProxyForApp] = useState(true);
const [proxyForAgent, setProxyForAgent] = useState(true);
const [proxyUrl, setProxyUrl] = useState("");
/** Inline error under the address input (the server's invalid_proxy_url rejection). */
const [addressError, setAddressError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
useEffect(() => {
if (!open) return;
let cancelled = false;
setSettings(null);
setAddressError(null);
setBusy(false);
void api
.adminGetSettings()
.then((res) => {
if (cancelled) return;
setSettings(res.settings);
setProxyForApp(res.settings.proxyForApp);
setProxyForAgent(res.settings.proxyForAgent);
setProxyUrl(res.settings.proxyUrl ?? "");
})
.catch((e: unknown) => {
// Controls stay disabled; closing and reopening retries the fetch.
if (!cancelled) toastError(apiErrorText(e));
});
return () => {
cancelled = true;
};
}, [open]);
const save = async () => {
if (settings === null || busy) return;
const unchanged =
proxyForApp === settings.proxyForApp &&
proxyForAgent === settings.proxyForAgent &&
proxyUrl.trim() === (settings.proxyUrl ?? "");
if (unchanged) {
toastInfo(S.common.noChangesToSave);
return;
}
setBusy(true);
setAddressError(null);
try {
await api.adminPutSettings({ proxyForApp, proxyForAgent, proxyUrl });
toastSuccess(S.common.saved);
onClose();
} catch (e) {
// The address is the only validated field: its rejection renders inline;
// anything else (auth, network) is a generic failure toast.
if (e instanceof ApiError && e.code === "invalid_proxy_url") {
setAddressError(apiErrorText(e));
} else {
toastError(apiErrorText(e));
}
} finally {
setBusy(false);
}
};
const hydrated = settings !== null;
return (
<Modal
open={open}
title={S.settings.proxyDialogTitle}
onClose={onClose}
footer={
<>
<Button onClick={onClose} disabled={busy}>
{S.common.cancel}
</Button>
<Button variant="primary" disabled={!hydrated || busy} onClick={() => void save()}>
{S.common.save}
</Button>
</>
}
>
<div className="space-y-4">
<div className="flex items-center justify-between gap-3">
<span className="text-sm font-medium">{S.settings.proxyForApp}</span>
<Switch checked={proxyForApp} onChange={setProxyForApp} disabled={!hydrated} />
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-sm font-medium">{S.settings.proxyForAgent}</span>
<Switch checked={proxyForAgent} onChange={setProxyForAgent} disabled={!hydrated} />
</div>
<Input
label={S.settings.proxyAddress}
size="sm"
value={proxyUrl}
placeholder={S.settings.proxyAddressPlaceholder}
disabled={!hydrated}
{...(addressError !== null ? { error: addressError } : {})}
onChange={(e) => {
setProxyUrl(e.target.value);
if (addressError !== null) setAddressError(null);
}}
/>
</div>
</Modal>
);
}
+23 -51
View File
@@ -75,6 +75,7 @@ import { DRAFT_SESSION_ID } from "../../features/chat/chat-page";
import { clearDraft, sessionDraftKey } from "../../features/chat/draft-cache";
import { CreateProjectDialog, ProjectSettingsDialog } from "./project-dialogs";
import { ChangePasswordDialog } from "../account/change-password-dialog";
import { ProxySettingsDialog } from "../account/proxy-settings-dialog";
import { UpdateDialog } from "../account/update-dialog";
import { forceUpdateCheck, updateCheckOutcome, useVersionInfo } from "../../lib/use-version-info";
@@ -235,34 +236,11 @@ export function Sidebar({
}
};
/**
* Admin-only "use system HTTP proxy" switch (server-global, design § "出网与系统代理"):
* null = not hydrated yet. Fetched lazily the first time an ADMIN opens the dropdown —
* same laziness as the version info above, and non-admins never call the endpoint (the
* row is not rendered for them either). A failed hydration stays null (row disabled)
* and is retried on the next open.
* Admin-only server-global proxy settings dialog: the menu carries only the opener
* row; the controls, their form semantics, and the open-time hydration all live in
* ProxySettingsDialog.
*/
const [useSystemProxy, setUseSystemProxyState] = useState<boolean | null>(null);
useEffect(() => {
if (!userOpen || user?.isAdmin !== true || useSystemProxy !== null) return;
let cancelled = false;
void api
.adminGetSettings()
.then((res) => {
if (!cancelled) setUseSystemProxyState(res.settings.useSystemProxy);
})
.catch(() => undefined);
return () => {
cancelled = true;
};
}, [userOpen, user?.isAdmin, useSystemProxy]);
/** Saved immediately on toggle (the user-menu quick-control convention): optimistic flip, reverted with a toast on failure. */
const setUseSystemProxy = (value: boolean) => {
setUseSystemProxyState(value);
void api.adminPutSettings({ useSystemProxy: value }).catch((e: unknown) => {
setUseSystemProxyState(!value);
toastError(apiErrorText(e));
});
};
const [proxySettingsOpen, setProxySettingsOpen] = useState(false);
const currentProjectId = currentProject?.projectId ?? null;
const collapseStoreKey = currentProjectId === null ? null : collapsedGroupsKey(currentProjectId);
const pinStoreKey = currentProjectId === null ? null : pinnedGroupsKey(currentProjectId);
@@ -1034,19 +1012,6 @@ export function Sidebar({
<SettingRow label={S.settings.showCliSessions}>
<Switch checked={showCliSessions} onChange={setShowCliSessions} />
</SettingRow>
{/* Admin-only, server-global (all users), saved immediately on toggle; the
tooltip spells out the scope and the loopback exemption. Disabled (showing
the default: on) until the stored value has hydrated, so a click can never
write a value the admin was not looking at. */}
{user?.isAdmin && (
<SettingRow label={S.settings.useSystemProxy} title={S.settings.useSystemProxyHint}>
<Switch
checked={useSystemProxy ?? true}
onChange={setUseSystemProxy}
disabled={useSystemProxy === null}
/>
</SettingRow>
)}
</div>
<div className="mt-1 border-t border-gray-100 pt-1 dark:border-gray-800">
<button
@@ -1059,6 +1024,21 @@ export function Sidebar({
>
{S.account.changePassword}
</button>
{/* Admin-only, server-global proxy settings: one menu row opening the
dialog (same idiom as Change password above) — the switch, address
input and their live-save semantics live in ProxySettingsDialog. */}
{user?.isAdmin && (
<button
type="button"
className={menuItemClass}
onClick={() => {
setUserOpen(false);
setProxySettingsOpen(true);
}}
>
{S.settings.proxyMenu}
</button>
)}
{/* THE update row — one button, two jobs, directly below Change password (owner
layout: the menu used to stack a release-notes link, an admin "Update now" row
and this check row on top of each other). It reads "Check for updates" and runs
@@ -1156,6 +1136,7 @@ export function Sidebar({
open={changePasswordOpen}
onClose={() => setChangePasswordOpen(false)}
/>
<ProxySettingsDialog open={proxySettingsOpen} onClose={() => setProxySettingsOpen(false)} />
<UpdateDialog
open={updateDialogOpen}
onClose={() => setUpdateDialogOpen(false)}
@@ -1376,18 +1357,9 @@ function SessionRow({
);
}
function SettingRow({
label,
title,
children,
}: {
label: string;
/** Optional row tooltip (e.g. the system-proxy row's scope + loopback-exemption hint). */
title?: string;
children: ReactNode;
}) {
function SettingRow({ label, children }: { label: string; children: ReactNode }) {
return (
<div {...(title !== undefined ? { title } : {})}>
<div>
<p className="mb-1 text-[11px] font-medium text-gray-500 dark:text-gray-400">{label}</p>
{children}
</div>
+11 -5
View File
@@ -33,11 +33,15 @@ export const en: Strings = {
language: "Language",
/** Sidebar Session list: also show CLI-created Sessions (default off — the list then never scans the Trace directories). */
showCliSessions: "Show CLI sessions",
/** Admin-only server-global switch (design § "出网与系统代理"): saved immediately on toggle. */
useSystemProxy: "Use system HTTP proxy",
/** Row tooltip: scope (server + its child processes, server-wide) and the loopback exemption. */
useSystemProxyHint:
"Whether the server and its child processes (update checks, LLM requests, agent commands) reach the internet through the proxy named by HTTP_PROXY / HTTPS_PROXY. Applies server-wide; loopback addresses (localhost, 127.0.0.1, ::1) always connect directly. When off, the server connects directly and the proxy variables are removed from agent command subprocess environments.",
/** Admin-only user-menu row opening the proxy options dialog. */
proxyMenu: "Proxy options…",
proxyDialogTitle: "Proxy options",
/** The dialog's two switches: the server's own outbound traffic / agent command subprocess environments. */
proxyForApp: "Application uses the proxy",
proxyForAgent: "Agent environment uses the proxy",
/** The shared explicit proxy address (empty = follow the proxy environment variables). */
proxyAddress: "Proxy address",
proxyAddressPlaceholder: "Empty = follow system proxy",
theme: "Theme",
themeLight: "Light",
themeDark: "Dark",
@@ -1175,6 +1179,8 @@ Scenarios:
task_in_progress: "This Session already has a task running.",
version_conflict: "The snapshot's version is not newer than the current one.",
invalid_title: "The title is invalid.",
invalid_proxy_url:
"Invalid proxy address — use http://host[:port], https://host[:port], or host[:port].",
invalid_trace: "This file is not a valid Trace file.",
trace_session_exists:
"This agent already has a Session with that id; a duplicate Trace cannot be imported.",
+24 -16
View File
@@ -35,11 +35,15 @@ export const zh = {
language: "语言",
/** Sidebar Session list: also show CLI-created Sessions (default off — the list then never scans the Trace directories). */
showCliSessions: "显示 CLI 会话",
/** Admin-only server-global switch (design § "出网与系统代理"): saved immediately on toggle. */
useSystemProxy: "使用系统 HTTP 代理",
/** Row tooltip: scope (server + its child processes, server-wide) and the loopback exemption. */
useSystemProxyHint:
"服务端及其子进程(更新检查、LLM 请求、Agent 命令)出网时是否使用 HTTP_PROXY / HTTPS_PROXY 环境变量指定的代理,对整个服务端全局生效;回环地址(localhost、127.0.0.1、::1)始终直连。关闭后服务端一律直连,并从 Agent 命令子进程环境中移除代理变量。",
/** Admin-only user-menu row opening the proxy options dialog. */
proxyMenu: "代理选项",
proxyDialogTitle: "代理选项",
/** The dialog's two switches: the server's own outbound traffic / agent command subprocess environments. */
proxyForApp: "应用程序使用代理",
proxyForAgent: "Agent 环境使用代理",
/** The shared explicit proxy address (empty = follow the proxy environment variables). */
proxyAddress: "代理地址",
proxyAddressPlaceholder: "留空 = 跟随系统代理",
theme: "主题",
themeLight: "浅色",
themeDark: "深色",
@@ -63,18 +67,20 @@ export const zh = {
/** Version footer, update reminder, and admin self-update in the sidebar user menu. */
update: {
/** Version-line date label (owner-specified wording); `date` is formatMonthDay output, e.g. 「最近更新日期 7 月 26 日」. */
/** Version-line date label (owner-specified wording); `date` is formatMonthDay output. */
lastUpdated: (date: string) => `最近更新日期 ${date}`,
/** Superscript badge on the version lines when the update check found a newer release (owner-specified wording). */
newVersionBadge: "有新版本可用",
newVersion: (v: string) => `新版本 v${v} 可用`,
/**
* 用户菜单里**唯一**的更新行:未知新版本时显示「检查更新」并执行手动检查;已知新版本后改为
* newVersion() 文案,点击打开更新弹窗(弹窗内含更新说明链接,管理员另有自更新操作)。
* The sidebar user menu's SINGLE update row: it reads checkNow until a newer release
* is known and runs the manual check; once one is known it reads newVersion() and
* opens the update dialog instead (which carries the release-notes link and, for
* admins, the self-update action).
*/
checkNow: "检查更新",
checking: "检查中…",
/** 手动检查发现新版本时的成功提示;下方同一行即变为更新入口。 */
/** Success toast when the manual check finds a newer release; the row below turns into the update entry. */
foundNew: (v: string) => `发现新版本 v${v},点击下方更新入口即可安装`,
upToDate: "已是最新版本",
checkFailed: "检查更新失败,请稍后重试",
@@ -88,7 +94,7 @@ export const zh = {
unsupported: "当前安装方式不支持在线更新",
confirmBody:
"将下载最新版本并安装到服务器上的安装目录(数据目录不受影响)。安装完成后需要重启服务才会生效。",
/** 非管理员看到的说明(可查看更新说明,但不能在此执行更新),替代 confirmBody。 */
/** Copy shown to non-admins in place of confirmBody (they can read the release notes but cannot run the update here). */
adminOnly: "只有管理员可以在这里执行更新。",
},
@@ -178,14 +184,14 @@ export const zh = {
idHint: "2~64 位:小写字母开头,仅小写字母、数字与下划线;创建后不可修改",
idPrefixHint: "id 固定以「用户名-」为前缀,后接小写字母、数字或下划线;创建后不可修改",
name: "显示名(可选,缺省为 Project id)",
/** Project 设置里的显示名字段(此处必填,与新建对话框的「可选」措辞区分)。 */
/** Display-name field in Project settings (required here, unlike the create dialog's "optional" wording). */
displayName: "显示名",
settings: "Project 设置",
settingsTitle: "Project 设置",
members: "成员",
addMember: "添加成员",
removeMember: "移除",
/** 新对话默认值分节(Project 设置):预填每个新建对话的 Agent / 工作目录 / 审批模式 / 思考等级 / 默认模型。 */
/** New-conversation defaults section (Project settings): prefills each new conversation's agent / working directory / approval mode / thinking level / default model. */
chatDefaultsTitle: "新对话默认值",
chatDefaultsHint: "新建对话时预填的默认值:Agent、工作目录、审批模式、思考等级与默认模型。",
chatDefaultsAgent: "Agent",
@@ -193,7 +199,7 @@ export const zh = {
chatDefaultsApprovalNotSet: "未设置(默认全部放行)",
chatDefaultsThinkingNotSet: "未设置(跟随智能体配置)",
chatDefaultsWorkspaceHint: "留空表示使用临时工作区",
/** 模型默认值与模型页同源(同一个 default_model),此处仅是另一处入口。 */
/** The model default shares its source with the Models page (the same default_model); this is just another entry point. */
chatDefaultsModelHint: "与模型页的默认模型同步",
deleteProject: "删除 Project",
deleteConfirm: "确认删除该 Project?项目目录将被递归删除,不可恢复。",
@@ -367,7 +373,7 @@ export const zh = {
displayNameHint: "留空则展示模型 ID",
providerGroup: "分组",
contextWindow: "上下文窗口",
/** 单位后缀,显示在上下文窗口/最大输出长度输入框内右侧。 */
/** Unit suffix shown inside the right edge of the context-window / max-output-length inputs. */
tokenUnit: "Token",
contextWindowHint: "留空表示未知",
maxTokens: "最大输出长度",
@@ -600,13 +606,13 @@ export const zh = {
workspaceAuto: "临时工作区",
workspaceClear: "改用临时工作区",
workspaceDirInvalid: "目录不存在或无法访问,已回退",
/** 侧栏对话列表的分组切换(默认按工作区)与工作区分组。 */
/** Grouping toggle of the sidebar conversation list (workspace grouping is the default) and the workspace groups. */
groupByWorkspace: "按工作区分组",
groupByAgent: "按 Agent 分组",
tempWorkspaces: "临时工作区",
newSessionInWorkspace: "在此工作区新建对话",
draftSubtitle: "最擅长 AI 开发任务的自进化 Agent",
/** 首页示例的折叠分组名(书签式,同时只展开一个)。 */
/** Collapsed group names for the home-page examples (bookmark style; only one open at a time). */
exampleFolders: {
webapps: "搭建网页应用",
agents: "搭建和优化智能体",
@@ -1149,6 +1155,8 @@ Benchmark:
task_in_progress: "该 Session 已有任务在运行。",
version_conflict: "快照版本不高于当前版本。",
invalid_title: "标题无效。",
invalid_proxy_url:
"代理地址无效:应为 http://主机[:端口]、https://主机[:端口] 或 主机[:端口]。",
invalid_trace: "该文件不是有效的 Trace 文件。",
trace_session_exists: "该 Agent 已存在同名 Session,无法导入重复的 Trace。",
},