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

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

No files matched your search

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