feat(server,web,desktop): admin "use system HTTP proxy" switch (#225)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Yaowei Zheng
2026-08-06 20:21:26 +08:00
committed by GitHub
parent e8499befd4
commit 91f3292ab5
29 changed files with 710 additions and 19 deletions
+23 -2
View File
@@ -76,6 +76,16 @@ export interface CreateAgentOptions {
projectId?: string;
/** Local data root directory; defaults to `resolveRoot()` (PENGUIN_HOME or ~/.penguin/data). */
root?: string;
/**
* When it returns true, the proxy variables (HTTP_PROXY / HTTPS_PROXY / ALL_PROXY;
* NO_PROXY is kept) are stripped from exec_command subprocess environments of every
* Session this Agent creates or resumes — and of its subagents' Sessions, which
* inherit the getter. The Web server threads its admin-level "use system HTTP proxy"
* switch (off state) through here; the getter is re-read at every command spawn, so a
* toggle needs no restart. Absent = proxy allowed (SDK/CLI standalone use follows the
* user's own shell environment).
*/
stripProxyEnv?: () => boolean;
}
export interface CreateSessionOptions {
@@ -149,13 +159,15 @@ export function metaMaxTokens(budget: number, modelCap: number | undefined): num
export async function createAgent(opts: CreateAgentOptions = {}): Promise<Agent> {
const state = await loadOrInitAgentState(opts);
const projectConfig = await loadProjectConfig(state.root, state.projectId);
return new Agent(state, projectConfig);
return new Agent(state, projectConfig, opts.stripProxyEnv);
}
export class Agent {
constructor(
readonly state: AgentState,
readonly projectConfig: ProjectConfig,
/** See {@link CreateAgentOptions.stripProxyEnv}; forwarded into every Session's Environment. */
private readonly stripProxyEnv?: () => boolean,
) {}
/**
@@ -582,7 +594,15 @@ export class Agent {
}
const childAgent =
agentId !== undefined && agentId !== parentAgentId
? await createAgent({ root, projectId, agentId })
? await createAgent({
root,
projectId,
agentId,
// A child Agent loads its own vault/config, but the proxy-strip getter is
// host policy, not Agent state: the subagent's commands run in the same
// serving process, so they follow the same switch as the parent's.
...(parentAgent.stripProxyEnv ? { stripProxyEnv: parentAgent.stripProxyEnv } : {}),
})
: parentAgent;
// The child Session follows the PARENT Session, never the Project default: with the
// model pair fully omitted it reuses the parent's resolved (provider, model_id) —
@@ -724,6 +744,7 @@ export class Agent {
),
services: { subagentRunner, ...(visionDescriber ? { visionDescriber } : {}) },
...(Object.keys(vault).length > 0 ? { vault } : {}),
...(this.stripProxyEnv ? { stripProxyEnv: this.stripProxyEnv } : {}),
});
const tools = await environment.listTools();
+5 -4
View File
@@ -110,10 +110,11 @@ export class Environment implements EnvironmentInterface {
// The background session registry is created alongside Environment (one per Session) and
// injected into whichever tools need it; all sessions are finalized together on dispose.
// The vault environment variables are injected into child processes by the command session
// registry at spawn time.
this.commandSessions = new CommandSessionManager(
config.vault !== undefined ? { vault: config.vault } : {},
);
// registry at spawn time (which also strips the proxy variables while stripProxyEnv says so).
this.commandSessions = new CommandSessionManager({
...(config.vault !== undefined ? { vault: config.vault } : {}),
...(config.stripProxyEnv !== undefined ? { stripProxyEnv: config.stripProxyEnv } : {}),
});
this.subagentSessions = new SubagentSessionManager();
const services = {
...config.services,
@@ -85,15 +85,29 @@ const STRIPPED_ENV_KEYS = new Set([
"PENGUIN_SEED_ADMIN_PASSWORD",
]);
/** The host environment minus {@link STRIPPED_ENV_KEYS}. */
function hostEnvForChild(): NodeJS.ProcessEnv {
/**
* Proxy variables removed IN ADDITION when the host asks for it (`stripProxyEnv`, see
* {@link CommandSessionManager}): the Web server's "use system HTTP proxy" switch in the
* off state must keep commands from inheriting the serving process's proxy environment
* (design § "出网与系统代理"). NO_PROXY is deliberately NOT stripped — with no proxy
* variables left it is inert, and removing it would change behavior for commands that
* set their own proxy. Matched case-insensitively like {@link STRIPPED_ENV_KEYS}, which
* also covers the conventional lowercase spellings (http_proxy etc.).
*/
const PROXY_ENV_KEYS = new Set(["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY"]);
/** The host environment minus {@link STRIPPED_ENV_KEYS} (and {@link PROXY_ENV_KEYS} when asked). */
function hostEnvForChild(stripProxy: boolean): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = {};
// Matched case-insensitively rather than deleting the upper-case spellings: Windows resolves
// environment names without regard to case but stores whatever casing was written, so a
// `set Port=3000` before `penguin web` would survive a `delete env.PORT` and still reach the
// child as PORT. On POSIX the two are distinct names and only the exact one exists.
for (const [key, value] of Object.entries(process.env)) {
if (!STRIPPED_ENV_KEYS.has(key.toUpperCase())) env[key] = value;
const name = key.toUpperCase();
if (STRIPPED_ENV_KEYS.has(name)) continue;
if (stripProxy && PROXY_ENV_KEYS.has(name)) continue;
env[key] = value;
}
return env;
}
@@ -106,9 +120,17 @@ export class CommandSessionManager {
/** Agent vault environment variables: injected into the child process on every spawn (values never enter the model context, only the environment). */
private readonly vault: Record<string, string>;
/**
* When it returns true, {@link PROXY_ENV_KEYS} are removed from the child environment
* too. A getter rather than a boolean: the hosting server's "use system HTTP proxy"
* switch is toggled at runtime, and re-reading at every spawn makes the toggle reach
* Sessions that are already running. Absent = proxy allowed (SDK/CLI standalone use).
*/
private readonly stripProxyEnv: (() => boolean) | undefined;
constructor(opts?: { vault?: Record<string, string> }) {
constructor(opts?: { vault?: Record<string, string>; stripProxyEnv?: () => boolean }) {
this.vault = opts?.vault ?? {};
this.stripProxyEnv = opts?.stripProxyEnv;
}
/** Starts a command, returning an **unregistered** session (no process_id yet). */
@@ -122,9 +144,10 @@ export class CommandSessionManager {
// Spread order is priority: vault overrides host variables of the same name, but must
// come before HARDENED_ENV — the hardening entries (GIT_EDITOR/PAGER etc. that prevent
// interactive hangs) must never be overridable by vault. The host side is stripped of
// the harness's own variables first (see STRIPPED_ENV_KEYS); the vault still wins, so a
// user who genuinely wants PORT in commands can set it there.
env: { ...hostEnvForChild(), ...this.vault, ...HARDENED_ENV },
// the harness's own variables first (see STRIPPED_ENV_KEYS; plus the proxy variables
// when stripProxyEnv says so); the vault still wins, so a user who genuinely wants
// PORT — or a proxy — in commands can set it there.
env: { ...hostEnvForChild(this.stripProxyEnv?.() === true), ...this.vault, ...HARDENED_ENV },
});
}
+8
View File
@@ -292,6 +292,14 @@ export interface EnvironmentConfig {
* environment; hardened entries cannot be overridden.
*/
vault?: Record<string, string>;
/**
* When it returns true, the proxy variables (HTTP_PROXY / HTTPS_PROXY / ALL_PROXY, any
* casing; NO_PROXY is kept) are stripped from exec_command / input_command subprocess
* environments. Threaded by the Web server from its admin-level "use system HTTP proxy"
* switch (off state); re-read at every spawn so a toggle needs no restart. Absent =
* proxy allowed (the default for SDK/CLI standalone use).
*/
stripProxyEnv?: () => boolean;
}
/**
+68
View File
@@ -406,3 +406,71 @@ describe("harness environment variables never reach a spawned command", () => {
}
});
});
describe("proxy variables are stripped from commands when the host opts out", () => {
// The Web server's "use system HTTP proxy" switch (off state) threads stripProxyEnv
// through Agent -> Environment -> CommandSessionManager; standalone Environments (no
// getter) keep the historical pass-through.
const KEYS = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY"] as const;
const saved: Partial<Record<(typeof KEYS)[number], string | undefined>> = {};
let strip = true;
let stripEnv: Environment;
beforeEach(() => {
for (const k of KEYS) saved[k] = process.env[k];
process.env.HTTP_PROXY = "http://proxy.corp.example:8080";
process.env.HTTPS_PROXY = "http://proxy.corp.example:8443";
process.env.ALL_PROXY = "socks5://proxy.corp.example:1080";
process.env.NO_PROXY = "localhost,127.0.0.1,::1";
strip = true;
stripEnv = new Environment({
workspaceDir: tmp,
toolConfig: sessionConfig(),
stripProxyEnv: () => strip,
});
});
afterEach(() => {
stripEnv.dispose();
for (const k of KEYS) {
if (saved[k] === undefined) delete process.env[k];
else process.env[k] = saved[k];
}
});
it("HTTP(S)_PROXY/ALL_PROXY are removed, NO_PROXY stays (inert without them)", async () => {
const READ = KEYS.map((k) => `${k}=[' + (process.env.${k} ?? '') + ']`).join(" ");
const res = await runTool(stripEnv, "exec_command", {
cmd: `node -e "console.log('${READ}')"`,
});
expect(res.output).toContain(
"HTTP_PROXY=[] HTTPS_PROXY=[] ALL_PROXY=[] NO_PROXY=[localhost,127.0.0.1,::1]",
);
});
it("a lowercase spelling is stripped too (the conventional POSIX form)", async () => {
process.env.https_proxy = "http://proxy.corp.example:8443";
try {
const res = await runTool(stripEnv, "exec_command", {
cmd: `node -e "console.log('s=[' + (process.env.https_proxy ?? '') + ']')"`,
});
expect(res.output).toContain("s=[]");
} finally {
delete process.env.https_proxy;
}
});
it("the getter is re-read at every spawn, so a live toggle needs no new Environment", async () => {
strip = false;
const res = await runTool(stripEnv, "exec_command", {
cmd: `node -e "console.log('H=[' + (process.env.HTTP_PROXY ?? '') + ']')"`,
});
expect(res.output).toContain("H=[http://proxy.corp.example:8080]");
});
it("without the getter (SDK/CLI standalone), proxy variables pass through", async () => {
const res = await runTool(env, "exec_command", {
cmd: `node -e "console.log('H=[' + (process.env.HTTP_PROXY ?? '') + ']')"`,
});
expect(res.output).toContain("H=[http://proxy.corp.example:8080]");
});
});
+63
View File
@@ -0,0 +1,63 @@
/**
* OS-proxy resolution for the embedded server (design § "出网与系统代理"): on the
* desktop, "system proxy" means the real operating-system setting. The shell resolves
* it through Electron's resolveProxy when forking the server and injects the result
* into the child environment as HTTP_PROXY / HTTPS_PROXY — the server's dispatcher
* (and, while the admin switch is on, agent commands) then honor it. Variables already
* present in the shell's environment are never overridden (same idiom as
* bundledShellEnv): an explicitly configured environment wins over the OS lookup.
*
* 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.
*/
/** The variables injected, with the probe URL whose scheme selects the OS proxy rule. */
const PROXY_VARS = [
{ name: "HTTP_PROXY", lower: "http_proxy", probe: "http://example.com/" },
{ name: "HTTPS_PROXY", lower: "https_proxy", probe: "https://example.com/" },
] as const;
type ProxyVar = (typeof PROXY_VARS)[number];
/** The proxy variables `env` does not already define (either spelling — those are kept as-is). */
export function unsetProxyVars(env: NodeJS.ProcessEnv): readonly ProxyVar[] {
return PROXY_VARS.filter((v) => env[v.name] === undefined && env[v.lower] === undefined);
}
/**
* Maps one PAC-style resolveProxy result to a proxy URL, or null for "connect
* directly". The result is a `;`-separated fallback list ("PROXY host:port; DIRECT");
* the first usable entry wins: `PROXY` → http://, `HTTPS` (a proxy reached over TLS) →
* https://. `DIRECT` and the SOCKS flavors yield nothing — undici's dispatcher speaks
* only HTTP(S) proxies, and injecting a socks:// URL would break every server fetch.
*/
export function proxyUrlFromPacResult(result: string): string | null {
for (const entry of result.split(";")) {
const m = /^\s*(PROXY|HTTPS)\s+(\S+)\s*$/i.exec(entry);
if (!m) continue;
const scheme = m[1]!.toUpperCase() === "HTTPS" ? "https" : "http";
return `${scheme}://${m[2]!}`;
}
return null;
}
/**
* The proxy environment to add to the server child: the OS resolution of every proxy
* variable the shell's own environment leaves unset. Best-effort — any resolution
* failure injects nothing (the server then simply sees no proxy configuration).
*/
export async function osProxyEnv(): Promise<Record<string, string>> {
const wanted = unsetProxyVars(process.env);
if (wanted.length === 0) return {};
try {
const { session } = await import("electron");
const env: Record<string, string> = {};
for (const v of wanted) {
const url = proxyUrlFromPacResult(await session.defaultSession.resolveProxy(v.probe));
if (url !== null) env[v.name] = url;
}
return env;
} catch {
return {};
}
}
+5
View File
@@ -11,6 +11,7 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
import { app, utilityProcess } from "electron";
import type { UtilityProcess } from "electron";
import { osProxyEnv } from "./os-proxy.js";
import { choosePort, readPreferredPort, rememberPreferredPort } from "./port-memory.js";
import { appOriginFor, parsePortFile } from "./util.js";
@@ -125,6 +126,10 @@ export async function startEmbeddedServer(opts: {
env: {
...process.env,
...bundledShellEnv(),
// OS proxy settings resolved at fork time (Electron resolveProxy) — only for the
// proxy variables the environment leaves unset, never overriding existing values.
// The server's "use system HTTP proxy" switch then governs whether they are used.
...(await osProxyEnv()),
PENGUIN_HOME: opts.dataRoot,
HOST: "127.0.0.1",
PORT: String(requestedPort),
+46
View File
@@ -0,0 +1,46 @@
/**
* Pure-part tests for the OS-proxy injection (no Electron runtime): PAC-result parsing
* and the "never override an existing variable" selection.
*/
import { describe, expect, it } from "vitest";
import { proxyUrlFromPacResult, unsetProxyVars } from "../src/os-proxy.js";
describe("proxyUrlFromPacResult", () => {
it("maps PROXY to an http:// URL and HTTPS to an https:// URL", () => {
expect(proxyUrlFromPacResult("PROXY 127.0.0.1:8888")).toBe("http://127.0.0.1:8888");
expect(proxyUrlFromPacResult("HTTPS proxy.corp.example:443")).toBe(
"https://proxy.corp.example:443",
);
expect(proxyUrlFromPacResult("proxy proxy.corp.example:3128")).toBe(
"http://proxy.corp.example:3128",
);
});
it("takes the first usable entry of a fallback list", () => {
expect(proxyUrlFromPacResult("PROXY 10.0.0.1:8080; DIRECT")).toBe("http://10.0.0.1:8080");
expect(proxyUrlFromPacResult("DIRECT; PROXY 10.0.0.1:8080")).toBe("http://10.0.0.1:8080");
});
it("yields nothing for DIRECT and for SOCKS entries (undici cannot speak SOCKS)", () => {
expect(proxyUrlFromPacResult("DIRECT")).toBeNull();
expect(proxyUrlFromPacResult("SOCKS5 127.0.0.1:1080")).toBeNull();
expect(proxyUrlFromPacResult("SOCKS 127.0.0.1:1080; DIRECT")).toBeNull();
expect(proxyUrlFromPacResult("")).toBeNull();
});
});
describe("unsetProxyVars", () => {
it("selects both variables on a clean environment", () => {
expect(unsetProxyVars({}).map((v) => v.name)).toEqual(["HTTP_PROXY", "HTTPS_PROXY"]);
});
it("skips a variable already present in either spelling (never overridden)", () => {
expect(unsetProxyVars({ HTTP_PROXY: "http://a:1" }).map((v) => v.name)).toEqual([
"HTTPS_PROXY",
]);
expect(unsetProxyVars({ https_proxy: "http://a:1" }).map((v) => v.name)).toEqual([
"HTTP_PROXY",
]);
expect(unsetProxyVars({ HTTP_PROXY: "http://a:1", https_proxy: "http://a:1" })).toEqual([]);
});
});
+1
View File
@@ -127,6 +127,7 @@ interface EnvironmentConfig {
sessionScratchpadDir?: string; // this Session's scratchpad (scratchpad/<sessionId>); enables truncated-output recovery
services?: EnvironmentServices; // runtime services injected into individual tools
vault?: Record<string, string>; // Vault env vars, injected into exec_command / input_command subprocesses
stripProxyEnv?: () => boolean; // true = strip HTTP(S)_PROXY/ALL_PROXY (NO_PROXY kept) from command subprocesses; re-read per spawn, absent = proxy allowed
}
interface EnvironmentServices {
+1
View File
@@ -127,6 +127,7 @@ interface EnvironmentConfig {
sessionScratchpadDir?: string; // 本 Session 的 scratchpad(scratchpad/<sessionId>),提供后启用截断输出恢复
services?: EnvironmentServices; // 注入给个别工具的运行时服务
vault?: Record<string, string>; // Vault 环境变量,注入 exec_command / input_command 子进程
stripProxyEnv?: () => boolean; // 返回 true 时从命令子进程剥除 HTTP(S)_PROXY/ALL_PROXY(保留 NO_PROXY);每次 spawn 重读,缺省即允许代理
}
interface EnvironmentServices {
+9
View File
@@ -67,6 +67,15 @@ curl -c cookies.txt -H "Content-Type: application/json" \
| POST | /api/admin/users/:userId/password | Reset a password (invalidates all of that user's login sessions) |
| DELETE | /api/admin/users/:userId | Delete a user |
### Server Settings (admin only)
| Method | Path | Description |
| --- | --- | --- |
| GET | /api/admin/settings | Server-global settings: `{settings: {useSystemProxy}}` |
| 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.
### Version and Self-Update
| Method | Path | Description |
+9
View File
@@ -67,6 +67,15 @@ curl -c cookies.txt -H "Content-Type: application/json" \
| POST | /api/admin/users/:userId/password | 重置密码(该用户全部登录会话失效) |
| DELETE | /api/admin/users/:userId | 删除用户 |
### 服务端设置(仅管理员)
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| GET | /api/admin/settings | 服务端全局设置:`{settings: {useSystemProxy}}` |
| PUT | /api/admin/settings | 更新设置(字段可省略,省略即保持现值),返回更新后的完整设置 |
`useSystemProxy` 即「使用系统 HTTP 代理」开关(默认开):开时服务端及其子进程出网遵循 HTTP_PROXY / HTTPS_PROXY / NO_PROXY(大小写并存);关时服务端一律直连,并从 Agent 命令子进程环境中剥除代理变量(NO_PROXY 保留)。任一状态下生效的 NO_PROXY 恒包含 `localhost,127.0.0.1,::1`(回环不代理)。切换即时生效(对新发起的连接),无需重启。
### 版本与在线更新
| 方法 | 路径 | 说明 |
+1
View File
@@ -44,6 +44,7 @@
"hono": "^4.12.34",
"smol-toml": "^1.3.0",
"tar": "^7.5.21",
"undici": "^7.29.0",
"yaml": "^2.5.0"
},
"devDependencies": {
+22
View File
@@ -119,6 +119,28 @@ export interface AdminPasswordResetRequest {
password: string;
}
/** Admin-level server-global settings (SQLite server_settings; design § "出网与系统代理"). */
export interface ServerSettings {
/**
* "Use system HTTP proxy" (default on): whether the server process and its child
* processes reach the internet through the proxy named by HTTP_PROXY / HTTPS_PROXY
* (both spellings). Off = direct connections, with the proxy variables also stripped
* from agent command subprocess environments. Either way the effective NO_PROXY always
* includes localhost/127.0.0.1/::1 (loopback is never proxied). Toggling applies to
* newly initiated connections immediately — no restart.
*/
useSystemProxy: boolean;
}
export interface ServerSettingsResponse {
settings: ServerSettings;
}
/** PUT body: every field optional, omitted fields keep their current value (mirrors prefs). */
export interface ServerSettingsUpdateRequest {
useSystemProxy?: boolean;
}
/** User UI preferences (SQLite ui_prefs, free-form JSON; known keys declared here). */
export interface UiPrefs {
theme?: "light" | "dark";
+16 -1
View File
@@ -22,6 +22,7 @@ import { MembersRepo } from "./db/repos/members.js";
import { ProjectsRepo } from "./db/repos/projects.js";
import { GoalsRepo } from "./db/repos/goals.js";
import { SchedulesRepo } from "./db/repos/schedules.js";
import { ServerSettingsRepo } from "./db/repos/server-settings.js";
import { SessionsRepo } from "./db/repos/sessions.js";
import { TraceIndexRepo } from "./db/repos/trace-index.js";
import { UiPrefsRepo } from "./db/repos/ui-prefs.js";
@@ -33,6 +34,7 @@ import type { AppEnv } from "./auth/middleware.js";
import { AuthService } from "./auth/service.js";
import { handleError, HttpError, errorBody } from "./http/errors.js";
import { adminUsersRoutes } from "./http/routes/admin.js";
import { adminSettingsRoutes } from "./http/routes/admin-settings.js";
import { authRoutes } from "./http/routes/auth.js";
import { meRoutes } from "./http/routes/me.js";
import { eventsRoutes, userChannelKey } from "./http/routes/events.js";
@@ -93,6 +95,8 @@ export interface AppDeps {
db: DatabaseSync;
sessionsRepo: SessionsRepo;
prefsRepo: UiPrefsRepo;
/** Admin-level server-global settings (currently the "use system HTTP proxy" switch). */
serverSettingsRepo: ServerSettingsRepo;
authService: AuthService;
adminService: AdminService;
projectService: ProjectService;
@@ -152,6 +156,13 @@ export function buildAppDeps(config: ServerConfig, overrides: BuildDepsOverrides
const usageRepo = new UsageRepo(db);
const errorsRepo = new ErrorsRepo(db);
const prefsRepo = new UiPrefsRepo(db);
const serverSettingsRepo = new ServerSettingsRepo(db);
// Proxy-off also strips HTTP(S)_PROXY/ALL_PROXY from agent command subprocess
// environments (design § "出网与系统代理"). A getter, not a snapshot: it is re-read at
// every command spawn, so a toggle reaches already-loaded Sessions. Threaded through
// BOTH core entry paths — the loader (resume/self-heal) and SessionService (creation,
// whose runtime the manager adopts for the first Task).
const stripProxyEnv = () => !serverSettingsRepo.getUseSystemProxy();
const schedulesRepo = new SchedulesRepo(db);
const goalsRepo = new GoalsRepo(db);
@@ -203,7 +214,8 @@ export function buildAppDeps(config: ServerConfig, overrides: BuildDepsOverrides
const manager = new SessionManager({
sessions: sessionsRepo,
channels,
loader: overrides.loader ?? createCoreSessionLoader(config.root, sessionSources),
loader:
overrides.loader ?? createCoreSessionLoader(config.root, sessionSources, { stripProxyEnv }),
sources: sessionSources,
recorder,
errors,
@@ -252,6 +264,7 @@ export function buildAppDeps(config: ServerConfig, overrides: BuildDepsOverrides
projectConfig: projectConfigService,
sources: sessionSources,
traceIndex,
stripProxyEnv,
});
// 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).
@@ -275,6 +288,7 @@ export function buildAppDeps(config: ServerConfig, overrides: BuildDepsOverrides
db,
sessionsRepo,
prefsRepo,
serverSettingsRepo,
authService,
adminService,
projectService,
@@ -390,6 +404,7 @@ export function createApp(deps: AppDeps): Hono<AppEnv> {
app.route("/api/me", meRoutes(deps));
app.route("/api/version", versionRoutes(deps));
app.route("/api/admin/users", adminUsersRoutes(deps));
app.route("/api/admin/settings", adminSettingsRoutes(deps));
app.route("/api/events", eventsRoutes(deps));
// Skill library listing: readable once logged in, not nested under a Project prefix.
app.route("/api/skills", skillLibraryRoutes());
@@ -0,0 +1,37 @@
/**
* server_settings table repo (admin-level server-global settings): key-value storage
* with JSON-encoded values. An absent row means the setting's built-in default, so
* settings added after a web.db was formed need no migration.
*/
import type { DatabaseSync } from "node:sqlite";
/** Key of the "use system HTTP proxy" switch (design § "出网与系统代理"); default on. */
const USE_SYSTEM_PROXY_KEY = "use_system_proxy";
export class ServerSettingsRepo {
constructor(private readonly db: DatabaseSync) {}
/** Returns the raw JSON-encoded value; null if never set. */
get(key: string): string | null {
const r = this.db.prepare("SELECT value FROM server_settings WHERE key = ?").get(key);
return r ? (r.value as string) : null;
}
set(key: string, value: string): void {
this.db
.prepare(
`INSERT INTO server_settings (key, value) VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
)
.run(key, value);
}
/** The "use system HTTP proxy" switch; an absent (or unreadable) row reads as the default: on. */
getUseSystemProxy(): boolean {
return this.get(USE_SYSTEM_PROXY_KEY) !== "false";
}
setUseSystemProxy(value: boolean): void {
this.set(USE_SYSTEM_PROXY_KEY, JSON.stringify(value));
}
}
+4
View File
@@ -121,6 +121,10 @@ CREATE TABLE IF NOT EXISTS ui_prefs (
user_id TEXT PRIMARY KEY REFERENCES users(user_id) ON DELETE CASCADE,
prefs_json TEXT NOT NULL -- {theme?, lastProjectId?, ...} free-form JSON
);
CREATE TABLE IF NOT EXISTS server_settings ( -- admin-level server-global settings (GET/PUT /api/admin/settings)
key TEXT PRIMARY KEY, -- setting name, e.g. 'use_system_proxy'
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
project_id TEXT NOT NULL,
agent_id TEXT NOT NULL,
@@ -0,0 +1,44 @@
/**
* Admin server-settings routes (admin only, 403 for non-admins):
* GET|PUT /api/admin/settings — the server-global settings stored in server_settings
* (currently the "use system HTTP proxy" switch, design § "出网与系统代理").
* A PUT applies immediately: the persisted value is written first, then the process
* dispatcher is rebuilt so new outbound connections follow the toggle without a restart.
*/
import { Hono } from "hono";
import type { ServerSettingsResponse } from "../../api/types.js";
import { HttpError } from "../errors.js";
import type { AppEnv } from "../../auth/middleware.js";
import { optionalBoolean, readJson } from "../validate.js";
import type { AppDeps } from "../../app.js";
import { setUseSystemProxy } from "../../net/proxy.js";
export function adminSettingsRoutes(deps: AppDeps): Hono<AppEnv> {
const app = new Hono<AppEnv>();
app.use("*", async (c, next) => {
if (!c.var.user.isAdmin) {
throw new HttpError(403, "admin_required", "Only an admin can perform this operation.");
}
await next();
});
const settings = (): ServerSettingsResponse => ({
settings: { useSystemProxy: deps.serverSettingsRepo.getUseSystemProxy() },
});
app.get("/", (c) => c.json(settings()));
app.put("/", async (c) => {
const body = await readJson(c);
const useSystemProxy = optionalBoolean(body, "useSystemProxy");
if (useSystemProxy !== undefined) {
deps.serverSettingsRepo.setUseSystemProxy(useSystemProxy);
// Mirror into the process: rebuilds the global fetch dispatcher (live toggle).
setUseSystemProxy(useSystemProxy);
}
return c.json(settings());
});
return app;
}
+12
View File
@@ -15,11 +15,19 @@ import { config as loadDotenv } from "dotenv";
import { serve } from "@hono/node-server";
import { buildAppDeps, createApp } from "./app.js";
import { resolveServerConfig } from "./config.js";
import { installGlobalProxyDispatcher, setUseSystemProxy } from "./net/proxy.js";
import { loopbackHostRoles } from "./services/preview-token.js";
import { acquireServerLock, liveServerLock, releaseServerLock } from "./lock.js";
loadDotenv({ quiet: true });
// Outbound proxy support, installed at the earliest point (right after dotenv, which may
// itself define HTTP_PROXY): replaces globalThis.fetch with undici's and sets the global
// dispatcher, starting from the switch default (on). The persisted value can only be
// read once the database is open, so it is applied via setUseSystemProxy right after
// buildAppDeps below — nothing in between makes an outbound request. See net/proxy.ts.
installGlobalProxyDispatcher();
/** Exit code for "another server already owns this data root" (see lock.ts). */
const EXIT_ALREADY_RUNNING = 3;
@@ -39,6 +47,10 @@ if (existingLock) {
}
const deps = buildAppDeps(config);
// The database is open now: bring the dispatcher in line with the persisted
// "use system HTTP proxy" switch (an absent row reads as the default: on) before the
// first possible outbound request (update check, LLM calls — all behind HTTP handlers).
setUseSystemProxy(deps.serverSettingsRepo.getUseSystemProxy());
const app = createApp(deps);
// Built-in admin seed (idempotent): creates admin and adopts default_project when the
+96
View File
@@ -0,0 +1,96 @@
/**
* Outbound networking and the "use system HTTP proxy" switch (design § "出网与系统代理").
*
* Node 24's built-in fetch does not read the proxy environment variables
* (NODE_USE_ENV_PROXY is ineffective on 24.18), so the server routes ALL of its own
* outbound traffic through undici: the startup entry replaces `globalThis.fetch` with
* undici's fetch once, and this module keeps undici's global dispatcher in line with the
* admin-level setting — undici's fetch resolves the global dispatcher per call, so a
* toggle takes effect for newly initiated connections without a restart.
*
* Dispatcher per setting:
* - on (default): `EnvHttpProxyAgent` — honors HTTP_PROXY / HTTPS_PROXY (both
* spellings; undici reads the lowercase name first) with a NO_PROXY list merged from
* the environment plus the loopback names;
* - off: a plain `Agent` — direct connections, proxy variables ignored.
*
* The loopback merge is load-bearing in either state: the CLI's readiness probe
* (`penguin web` imports the server in-process and polls its own root path), SSE, and
* Workspace previews all ride the loopback names, so a proxy that intercepts loopback
* would take the whole App down. `localhost,127.0.0.1,::1` is therefore ALWAYS appended
* to the effective NO_PROXY, regardless of what the environment declares.
*
* The value persisted in server_settings stays authoritative (the routes read/write the
* repo); this module only mirrors it into the process-global dispatcher. Stripping the
* proxy variables from agent command subprocesses when the switch is off lives in core
* (CommandSessionManager, threaded through the session loader), not here.
*/
import { Agent, EnvHttpProxyAgent, fetch as undiciFetch, setGlobalDispatcher } from "undici";
import type { Dispatcher } from "undici";
/** Loopback names every effective NO_PROXY must contain (see module doc). */
export const LOOPBACK_NO_PROXY = ["localhost", "127.0.0.1", "::1"] as const;
/**
* The effective NO_PROXY: the environment's entries (lowercase spelling first, matching
* undici's own precedence) with the loopback names appended when missing. Exported as a
* pure helper for tests; `env` defaults to the real process environment.
*/
export function mergedNoProxy(env: NodeJS.ProcessEnv = process.env): string {
const raw = env.no_proxy ?? env.NO_PROXY ?? "";
// Same separators undici's parser accepts (comma or whitespace).
const entries = raw.split(/[,\s]/).filter((e) => e !== "");
const present = new Set(entries.map((e) => e.toLowerCase()));
for (const name of LOOPBACK_NO_PROXY) {
if (!present.has(name)) entries.push(name);
}
return entries.join(",");
}
/**
* Builds the global dispatcher for a switch state (pure choice, exported for tests):
* on → EnvHttpProxyAgent with the merged NO_PROXY, off → direct-connect Agent.
*/
export function buildProxyDispatcher(useSystemProxy: boolean, env?: NodeJS.ProcessEnv): Dispatcher {
// The merged list is passed via opts.noProxy, which REPLACES the environment lookup
// inside EnvHttpProxyAgent — merging is this module's job (undici would otherwise use
// env NO_PROXY verbatim, without the loopback exemption).
return useSystemProxy ? new EnvHttpProxyAgent({ noProxy: mergedNoProxy(env) }) : new Agent();
}
/** Current switch state mirrored for the dispatcher (the DB row is the persisted truth). */
let useSystemProxy = true;
/** True once the startup entry has installed undici globally (never in tests). */
let installed = false;
/**
* One-time install at the startup entry (index.ts), before anything can fetch: replaces
* `globalThis.fetch` with undici's and sets the initial dispatcher. Starts from the
* default (on) — the entry applies the persisted value via {@link setUseSystemProxy} as
* soon as the database is open, before any outbound request exists. Tests never call
* this, so they keep the runtime's own fetch (and their fetch stubs).
*/
export function installGlobalProxyDispatcher(): void {
installed = true;
setGlobalDispatcher(buildProxyDispatcher(useSystemProxy));
// Cast: undici's fetch is typed against its own RequestInit/Response declarations,
// which are structurally compatible with the runtime globals for every caller here.
globalThis.fetch = undiciFetch as unknown as typeof globalThis.fetch;
}
/**
* Applies a switch state to the process: rebuilds the global dispatcher so new
* connections follow it immediately (no restart). Called by the startup entry with the
* persisted value and by PUT /api/admin/settings on every toggle. Outside the installed
* runtime (tests) it only records the state.
*/
export function setUseSystemProxy(value: boolean): void {
if (value === useSystemProxy) return;
useSystemProxy = value;
if (installed) setGlobalDispatcher(buildProxyDispatcher(useSystemProxy));
}
/** The switch state the dispatcher currently reflects. */
export function getUseSystemProxy(): boolean {
return useSystemProxy;
}
@@ -121,14 +121,21 @@ export interface SessionLoader {
* lets the no-Trace self-heal rebuild re-record a known origin into the fresh session_meta;
* with no registry entry (e.g. the process restarted and no Trace was ever written) the
* rebuilt Session is unsourced — session_meta is the single source of truth, and none survived.
* `opts.stripProxyEnv` threads the "use system HTTP proxy" switch into core (a live getter:
* true = strip HTTP(S)_PROXY/ALL_PROXY from agent command subprocess environments).
*/
export function createCoreSessionLoader(root: string, sources?: SessionSources): SessionLoader {
export function createCoreSessionLoader(
root: string,
sources?: SessionSources,
opts: { stripProxyEnv?: () => boolean } = {},
): SessionLoader {
return {
async load(row: SessionRow): Promise<RuntimeSession> {
const agent = await createAgent({
root,
projectId: row.projectId,
agentId: row.agentId,
...(opts.stripProxyEnv ? { stripProxyEnv: opts.stripProxyEnv } : {}),
});
const located = await findLatestTraceFile(
tracesDir(root, row.projectId, row.agentId),
@@ -51,6 +51,12 @@ export interface SessionServiceDeps {
sources: SessionSources;
/** Trace-file index: discovery / adoption / stats serve from it (mtime-gated reconciler; no per-request walks). */
traceIndex: TraceIndexService;
/**
* "Use system HTTP proxy" switch threading (same getter the session loader passes,
* see createCoreSessionLoader): the runtime created here is adopted by the manager
* and runs the Session's first Task, so it needs the strip policy too.
*/
stripProxyEnv?: () => boolean;
}
export class SessionService {
@@ -319,6 +325,7 @@ export class SessionService {
root: this.deps.root,
projectId: args.projectId,
agentId: args.agentId,
...(this.deps.stripProxyEnv ? { stripProxyEnv: this.deps.stripProxyEnv } : {}),
});
let session;
try {
@@ -0,0 +1,68 @@
/**
* Admin server-settings route tests: permission boundary (non-admin 403), the
* "use system HTTP proxy" default (absent row reads as on), PUT persistence and
* validation, and merge semantics (an omitted field keeps its current value).
*/
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { ServerSettingsResponse } from "../src/api/types.js";
import { apiClient, createTestApp, loginAdmin, provisionUser } from "./helpers.js";
import type { TestApp } from "./helpers.js";
describe("admin server settings", () => {
let t: TestApp;
let admin: ReturnType<typeof apiClient>;
beforeEach(async () => {
t = await createTestApp();
admin = apiClient(t.app, (await loginAdmin(t.app)).cookie);
});
afterEach(async () => {
await t.cleanup();
});
const getSettings = async (api: ReturnType<typeof apiClient> = admin) => {
const res = await api.get("/api/admin/settings");
expect(res.status).toBe(200);
return (await res.json()) as ServerSettingsResponse;
};
it("non-admin access is always 403", async () => {
const { cookie } = await provisionUser(t.app, "norm");
const api = apiClient(t.app, cookie);
expect((await api.get("/api/admin/settings")).status).toBe(403);
expect((await api.put("/api/admin/settings", { useSystemProxy: false })).status).toBe(403);
// The failed PUT changed nothing.
expect((await getSettings()).settings.useSystemProxy).toBe(true);
});
it("useSystemProxy defaults to on while no row exists", async () => {
expect(t.deps.db.prepare("SELECT COUNT(*) AS n FROM server_settings").get()).toMatchObject({
n: 0,
});
expect((await getSettings()).settings.useSystemProxy).toBe(true);
});
it("PUT persists the toggle and echoes the full settings", async () => {
const off = await admin.put("/api/admin/settings", { useSystemProxy: false });
expect(off.status).toBe(200);
expect(((await off.json()) as ServerSettingsResponse).settings.useSystemProxy).toBe(false);
// Round-trips through the repo (the DB row, not process state, is what GET serves).
expect((await getSettings()).settings.useSystemProxy).toBe(false);
expect(t.deps.serverSettingsRepo.getUseSystemProxy()).toBe(false);
const on = await admin.put("/api/admin/settings", { useSystemProxy: true });
expect(on.status).toBe(200);
expect((await getSettings()).settings.useSystemProxy).toBe(true);
});
it("an omitted field keeps its current value; a non-boolean is 400", async () => {
await admin.put("/api/admin/settings", { useSystemProxy: false });
// Empty PUT: no-op, still returns the current settings.
const noop = await admin.put("/api/admin/settings", {});
expect(noop.status).toBe(200);
expect(((await noop.json()) as ServerSettingsResponse).settings.useSystemProxy).toBe(false);
// Type check: only booleans are accepted, and a rejected write changes nothing.
expect((await admin.put("/api/admin/settings", { useSystemProxy: "on" })).status).toBe(400);
expect((await getSettings()).settings.useSystemProxy).toBe(false);
});
});
+51
View File
@@ -0,0 +1,51 @@
/**
* Unit tests for the outbound-proxy module's pure parts: the NO_PROXY loopback merge
* and the dispatcher choice per switch state. No real network and no global install —
* installGlobalProxyDispatcher (fetch replacement) runs only in the production entry.
*/
import { describe, expect, it } from "vitest";
import { Agent, EnvHttpProxyAgent } from "undici";
import { buildProxyDispatcher, mergedNoProxy } from "../src/net/proxy.js";
describe("mergedNoProxy", () => {
it("yields exactly the loopback names when the environment has no NO_PROXY", () => {
expect(mergedNoProxy({})).toBe("localhost,127.0.0.1,::1");
});
it("appends the loopback names after the environment's entries", () => {
expect(mergedNoProxy({ NO_PROXY: "example.com,.corp.internal" })).toBe(
"example.com,.corp.internal,localhost,127.0.0.1,::1",
);
});
it("prefers the lowercase spelling, like undici itself", () => {
expect(mergedNoProxy({ no_proxy: "a.example", NO_PROXY: "b.example" })).toBe(
"a.example,localhost,127.0.0.1,::1",
);
});
it("does not duplicate loopback names already present (case-insensitively)", () => {
expect(mergedNoProxy({ NO_PROXY: "LOCALHOST,::1" })).toBe("LOCALHOST,::1,127.0.0.1");
});
it("survives messy separators (comma/whitespace mix, empty segments)", () => {
expect(mergedNoProxy({ NO_PROXY: " example.com ,, other.example " })).toBe(
"example.com,other.example,localhost,127.0.0.1,::1",
);
});
});
describe("buildProxyDispatcher", () => {
it("on → EnvHttpProxyAgent (proxy env honored), off → plain Agent (direct)", async () => {
const on = buildProxyDispatcher(true, {});
const off = buildProxyDispatcher(false, {});
try {
expect(on).toBeInstanceOf(EnvHttpProxyAgent);
expect(off).toBeInstanceOf(Agent);
expect(off).not.toBeInstanceOf(EnvHttpProxyAgent);
} finally {
await on.close();
await off.close();
}
});
});
+9
View File
@@ -50,6 +50,8 @@ import type {
ScheduleItem,
SchedulesResponse,
ScheduleUpsertRequest,
ServerSettingsResponse,
ServerSettingsUpdateRequest,
SessionCategory,
SessionCreateRequest,
SessionCreateResponse,
@@ -114,6 +116,13 @@ export const adminResetPassword = (userId: string, body: AdminPasswordResetReque
export const adminDeleteUser = (userId: string) =>
apiFetch<void>(`/api/admin/users/${encodeURIComponent(userId)}`, { method: "DELETE" });
/** Server-global settings (admin only): currently the "use system HTTP proxy" switch. */
export const adminGetSettings = () => apiFetch<ServerSettingsResponse>("/api/admin/settings");
/** Omitted fields keep their current value; applies immediately (no restart). */
export const adminPutSettings = (body: ServerSettingsUpdateRequest) =>
apiFetch<ServerSettingsResponse>("/api/admin/settings", { method: "PUT", body });
// Project & members --------------------------------------------------------------
export const listProjects = () => apiFetch<ProjectsResponse>("/api/projects");
+53 -2
View File
@@ -234,6 +234,35 @@ export function Sidebar({
setUpdateChecking(false);
}
};
/**
* Admin-only "use system HTTP proxy" switch (server-global, design § "出网与系统代理"):
* null = not hydrated yet. Fetched lazily the first time an ADMIN opens the dropdown —
* same laziness as the version info above, and non-admins never call the endpoint (the
* row is not rendered for them either). A failed hydration stays null (row disabled)
* and is retried on the next open.
*/
const [useSystemProxy, setUseSystemProxyState] = useState<boolean | null>(null);
useEffect(() => {
if (!userOpen || user?.isAdmin !== true || useSystemProxy !== null) return;
let cancelled = false;
void api
.adminGetSettings()
.then((res) => {
if (!cancelled) setUseSystemProxyState(res.settings.useSystemProxy);
})
.catch(() => undefined);
return () => {
cancelled = true;
};
}, [userOpen, user?.isAdmin, useSystemProxy]);
/** Saved immediately on toggle (the user-menu quick-control convention): optimistic flip, reverted with a toast on failure. */
const setUseSystemProxy = (value: boolean) => {
setUseSystemProxyState(value);
void api.adminPutSettings({ useSystemProxy: value }).catch((e: unknown) => {
setUseSystemProxyState(!value);
toastError(apiErrorText(e));
});
};
const currentProjectId = currentProject?.projectId ?? null;
const collapseStoreKey = currentProjectId === null ? null : collapsedGroupsKey(currentProjectId);
const pinStoreKey = currentProjectId === null ? null : pinnedGroupsKey(currentProjectId);
@@ -1005,6 +1034,19 @@ export function Sidebar({
<SettingRow label={S.settings.showCliSessions}>
<Switch checked={showCliSessions} onChange={setShowCliSessions} />
</SettingRow>
{/* Admin-only, server-global (all users), saved immediately on toggle; the
tooltip spells out the scope and the loopback exemption. Disabled (showing
the default: on) until the stored value has hydrated, so a click can never
write a value the admin was not looking at. */}
{user?.isAdmin && (
<SettingRow label={S.settings.useSystemProxy} title={S.settings.useSystemProxyHint}>
<Switch
checked={useSystemProxy ?? true}
onChange={setUseSystemProxy}
disabled={useSystemProxy === null}
/>
</SettingRow>
)}
</div>
<div className="mt-1 border-t border-gray-100 pt-1 dark:border-gray-800">
<button
@@ -1332,9 +1374,18 @@ function SessionRow({
);
}
function SettingRow({ label, children }: { label: string; children: ReactNode }) {
function SettingRow({
label,
title,
children,
}: {
label: string;
/** Optional row tooltip (e.g. the system-proxy row's scope + loopback-exemption hint). */
title?: string;
children: ReactNode;
}) {
return (
<div>
<div {...(title !== undefined ? { title } : {})}>
<p className="mb-1 text-[11px] font-medium text-gray-500 dark:text-gray-400">{label}</p>
{children}
</div>
+5
View File
@@ -33,6 +33,11 @@ export const en: Strings = {
language: "Language",
/** Sidebar Session list: also show CLI-created Sessions (default off — the list then never scans the Trace directories). */
showCliSessions: "Show CLI sessions",
/** Admin-only server-global switch (design § "出网与系统代理"): saved immediately on toggle. */
useSystemProxy: "Use system HTTP proxy",
/** Row tooltip: scope (server + its child processes, server-wide) and the loopback exemption. */
useSystemProxyHint:
"Whether the server and its child processes (update checks, LLM requests, agent commands) reach the internet through the proxy named by HTTP_PROXY / HTTPS_PROXY. Applies server-wide; loopback addresses (localhost, 127.0.0.1, ::1) always connect directly. When off, the server connects directly and the proxy variables are removed from agent command subprocess environments.",
theme: "Theme",
themeLight: "Light",
themeDark: "Dark",
+5
View File
@@ -35,6 +35,11 @@ export const zh = {
language: "语言",
/** Sidebar Session list: also show CLI-created Sessions (default off — the list then never scans the Trace directories). */
showCliSessions: "显示 CLI 会话",
/** Admin-only server-global switch (design § "出网与系统代理"): saved immediately on toggle. */
useSystemProxy: "使用系统 HTTP 代理",
/** Row tooltip: scope (server + its child processes, server-wide) and the loopback exemption. */
useSystemProxyHint:
"服务端及其子进程(更新检查、LLM 请求、Agent 命令)出网时是否使用 HTTP_PROXY / HTTPS_PROXY 环境变量指定的代理,对整个服务端全局生效;回环地址(localhost、127.0.0.1、::1)始终直连。关闭后服务端一律直连,并从 Agent 命令子进程环境中移除代理变量。",
theme: "主题",
themeLight: "浅色",
themeDark: "深色",
+4 -2
View File
@@ -264,6 +264,9 @@ importers:
tar:
specifier: ^7.5.21
version: 7.5.22
undici:
specifier: ^7.29.0
version: 7.29.0
yaml:
specifier: ^2.5.0
version: 2.9.0
@@ -7279,8 +7282,7 @@ snapshots:
undici@6.28.0: {}
undici@7.29.0:
optional: true
undici@7.29.0: {}
unified@11.0.5:
dependencies: