diff --git a/packages/core/src/agent.ts b/packages/core/src/agent.ts index 64d7c8c..b79bee8 100644 --- a/packages/core/src/agent.ts +++ b/packages/core/src/agent.ts @@ -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 { 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(); diff --git a/packages/core/src/environment/environment.ts b/packages/core/src/environment/environment.ts index 8cb4d0b..bdcb1ed 100644 --- a/packages/core/src/environment/environment.ts +++ b/packages/core/src/environment/environment.ts @@ -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 = { diff --git a/packages/core/src/environment/tools/command/session-manager.ts b/packages/core/src/environment/tools/command/session-manager.ts index f74b215..39a2518 100644 --- a/packages/core/src/environment/tools/command/session-manager.ts +++ b/packages/core/src/environment/tools/command/session-manager.ts @@ -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; /** - * 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; stripProxyEnv?: () => boolean }) { + constructor(opts?: { vault?: Record; 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 }, }); } diff --git a/packages/core/src/interfaces.ts b/packages/core/src/interfaces.ts index 88d403b..998f128 100644 --- a/packages/core/src/interfaces.ts +++ b/packages/core/src/interfaces.ts @@ -293,15 +293,32 @@ export interface EnvironmentConfig { */ vault?: Record; /** - * 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". diff --git a/packages/core/test/exec-session.test.ts b/packages/core/test/exec-session.test.ts index 3bb69c6..894bd75 100644 --- a/packages/core/test/exec-session.test.ts +++ b/packages/core/test/exec-session.test.ts @@ -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 { 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> = {}; - 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]"); diff --git a/packages/desktop/src/os-proxy.ts b/packages/desktop/src/os-proxy.ts index 4476a33..a0734ff 100644 --- a/packages/desktop/src/os-proxy.ts +++ b/packages/desktop/src/os-proxy.ts @@ -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. diff --git a/packages/docs/content/interfaces.en.md b/packages/docs/content/interfaces.en.md index d3b6691..859fbb2 100644 --- a/packages/docs/content/interfaces.en.md +++ b/packages/docs/content/interfaces.en.md @@ -127,9 +127,14 @@ interface EnvironmentConfig { sessionScratchpadDir?: string; // this Session's scratchpad (scratchpad/); enables truncated-output recovery services?: EnvironmentServices; // runtime services injected into individual tools vault?: Record; // 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 diff --git a/packages/docs/content/interfaces.zh.md b/packages/docs/content/interfaces.zh.md index 0cdd614..376c637 100644 --- a/packages/docs/content/interfaces.zh.md +++ b/packages/docs/content/interfaces.zh.md @@ -127,9 +127,14 @@ interface EnvironmentConfig { sessionScratchpadDir?: string; // 本 Session 的 scratchpad(scratchpad/),提供后启用截断输出恢复 services?: EnvironmentServices; // 注入给个别工具的运行时服务 vault?: Record; // 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 所需 diff --git a/packages/docs/content/server-api.en.md b/packages/docs/content/server-api.en.md index a65faac..0ed34ea 100644 --- a/packages/docs/content/server-api.en.md +++ b/packages/docs/content/server-api.en.md @@ -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 diff --git a/packages/docs/content/server-api.zh.md b/packages/docs/content/server-api.zh.md index c9b31eb..a913ab4 100644 --- a/packages/docs/content/server-api.zh.md +++ b/packages/docs/content/server-api.zh.md @@ -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`(回环不代理)。 ### 版本与在线更新 diff --git a/packages/server/src/api/types.ts b/packages/server/src/api/types.ts index d7b4551..e2f4990 100644 --- a/packages/server/src/api/types.ts +++ b/packages/server/src/api/types.ts @@ -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; diff --git a/packages/server/src/app.ts b/packages/server/src/app.ts index bcbffef..97ce256 100644 --- a/packages/server/src/app.ts +++ b/packages/server/src/app.ts @@ -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 { // 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 { // 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. diff --git a/packages/server/src/db/repos/server-settings.ts b/packages/server/src/db/repos/server-settings.ts index 4f13f91..3065315 100644 --- a/packages/server/src/db/repos/server-settings.ts +++ b/packages/server/src/db/repos/server-settings.ts @@ -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)); } } diff --git a/packages/server/src/db/schema.ts b/packages/server/src/db/schema.ts index a1a0de7..d86a5ca 100644 --- a/packages/server/src/db/schema.ts +++ b/packages/server/src/db/schema.ts @@ -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 diff --git a/packages/server/src/http/routes/admin-settings.ts b/packages/server/src/http/routes/admin-settings.ts index 4e6f602..e894109 100644 --- a/packages/server/src/http/routes/admin-settings.ts +++ b/packages/server/src/http/routes/admin-settings.ts @@ -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 { const app = new Hono(); @@ -24,19 +47,32 @@ export function adminSettingsRoutes(deps: AppDeps): Hono { }); 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()); }); diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 28abc63..dfcf52c 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -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. diff --git a/packages/server/src/net/proxy.ts b/packages/server/src/net/proxy.ts index 689a7ac..75fca26 100644 --- a/packages/server/src/net/proxy.ts +++ b/packages/server/src/net/proxy.ts @@ -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)); } diff --git a/packages/server/src/runtime/session-manager.ts b/packages/server/src/runtime/session-manager.ts index f3352ba..06116d8 100644 --- a/packages/server/src/runtime/session-manager.ts +++ b/packages/server/src/runtime/session-manager.ts @@ -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 { @@ -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), diff --git a/packages/server/src/services/session-service.ts b/packages/server/src/services/session-service.ts index a0baff9..46203d4 100644 --- a/packages/server/src/services/session-service.ts +++ b/packages/server/src/services/session-service.ts @@ -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 { diff --git a/packages/server/test/admin-settings.test.ts b/packages/server/test/admin-settings.test.ts index a26da88..9ae3f61 100644 --- a/packages/server/test/admin-settings.test.ts +++ b/packages/server/test/admin-settings.test.ts @@ -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"); }); }); diff --git a/packages/server/test/proxy.test.ts b/packages/server/test/proxy.test.ts index 5396099..b34461e 100644 --- a/packages/server/test/proxy.test.ts +++ b/packages/server/test/proxy.test.ts @@ -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((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(); } }); }); diff --git a/packages/web/src/components/account/proxy-settings-dialog.tsx b/packages/web/src/components/account/proxy-settings-dialog.tsx new file mode 100644 index 0000000..a4ae619 --- /dev/null +++ b/packages/web/src/components/account/proxy-settings-dialog.tsx @@ -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(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(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 ( + + + + + } + > +
+
+ {S.settings.proxyForApp} + +
+
+ {S.settings.proxyForAgent} + +
+ { + setProxyUrl(e.target.value); + if (addressError !== null) setAddressError(null); + }} + /> +
+
+ ); +} diff --git a/packages/web/src/components/layout/sidebar.tsx b/packages/web/src/components/layout/sidebar.tsx index e714f7f..f54755c 100644 --- a/packages/web/src/components/layout/sidebar.tsx +++ b/packages/web/src/components/layout/sidebar.tsx @@ -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(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({ - {/* 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 && ( - - - - )}
+ {/* 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 && ( + + )} {/* 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)} /> + setProxySettingsOpen(false)} /> 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 ( -
+

{label}

{children}
diff --git a/packages/web/src/lib/strings-en.ts b/packages/web/src/lib/strings-en.ts index df2d9a1..20b871b 100644 --- a/packages/web/src/lib/strings-en.ts +++ b/packages/web/src/lib/strings-en.ts @@ -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.", diff --git a/packages/web/src/lib/strings.ts b/packages/web/src/lib/strings.ts index 4e50adf..3007bc0 100644 --- a/packages/web/src/lib/strings.ts +++ b/packages/web/src/lib/strings.ts @@ -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。", },