diff --git a/packages/core/src/agent.ts b/packages/core/src/agent.ts index cf60604..64d7c8c 100644 --- a/packages/core/src/agent.ts +++ b/packages/core/src/agent.ts @@ -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 { 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(); diff --git a/packages/core/src/environment/environment.ts b/packages/core/src/environment/environment.ts index f1fffeb..8cb4d0b 100644 --- a/packages/core/src/environment/environment.ts +++ b/packages/core/src/environment/environment.ts @@ -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, diff --git a/packages/core/src/environment/tools/command/session-manager.ts b/packages/core/src/environment/tools/command/session-manager.ts index c366414..f74b215 100644 --- a/packages/core/src/environment/tools/command/session-manager.ts +++ b/packages/core/src/environment/tools/command/session-manager.ts @@ -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; + /** + * 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 }) { + constructor(opts?: { vault?: Record; 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 }, }); } diff --git a/packages/core/src/interfaces.ts b/packages/core/src/interfaces.ts index 7d2d250..88d403b 100644 --- a/packages/core/src/interfaces.ts +++ b/packages/core/src/interfaces.ts @@ -292,6 +292,14 @@ export interface EnvironmentConfig { * environment; hardened entries cannot be overridden. */ vault?: Record; + /** + * When it returns true, the proxy variables (HTTP_PROXY / HTTPS_PROXY / ALL_PROXY, any + * casing; NO_PROXY is kept) are stripped from exec_command / input_command subprocess + * environments. Threaded by the Web server from its admin-level "use system HTTP proxy" + * switch (off state); re-read at every spawn so a toggle needs no restart. Absent = + * proxy allowed (the default for SDK/CLI standalone use). + */ + stripProxyEnv?: () => boolean; } /** diff --git a/packages/core/test/exec-session.test.ts b/packages/core/test/exec-session.test.ts index 4596164..3bb69c6 100644 --- a/packages/core/test/exec-session.test.ts +++ b/packages/core/test/exec-session.test.ts @@ -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> = {}; + 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]"); + }); +}); diff --git a/packages/desktop/src/os-proxy.ts b/packages/desktop/src/os-proxy.ts new file mode 100644 index 0000000..4476a33 --- /dev/null +++ b/packages/desktop/src/os-proxy.ts @@ -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> { + const wanted = unsetProxyVars(process.env); + if (wanted.length === 0) return {}; + try { + const { session } = await import("electron"); + const env: Record = {}; + 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 {}; + } +} diff --git a/packages/desktop/src/server-process.ts b/packages/desktop/src/server-process.ts index 0094175..44439b9 100644 --- a/packages/desktop/src/server-process.ts +++ b/packages/desktop/src/server-process.ts @@ -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), diff --git a/packages/desktop/test/os-proxy.test.ts b/packages/desktop/test/os-proxy.test.ts new file mode 100644 index 0000000..d815d8a --- /dev/null +++ b/packages/desktop/test/os-proxy.test.ts @@ -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([]); + }); +}); diff --git a/packages/docs/content/interfaces.en.md b/packages/docs/content/interfaces.en.md index 8646723..d3b6691 100644 --- a/packages/docs/content/interfaces.en.md +++ b/packages/docs/content/interfaces.en.md @@ -127,6 +127,7 @@ interface EnvironmentConfig { sessionScratchpadDir?: string; // this Session's scratchpad (scratchpad/); enables truncated-output recovery services?: EnvironmentServices; // runtime services injected into individual tools vault?: Record; // Vault env vars, injected into exec_command / input_command subprocesses + stripProxyEnv?: () => boolean; // true = strip HTTP(S)_PROXY/ALL_PROXY (NO_PROXY kept) from command subprocesses; re-read per spawn, absent = proxy allowed } interface EnvironmentServices { diff --git a/packages/docs/content/interfaces.zh.md b/packages/docs/content/interfaces.zh.md index 69cd953..0cdd614 100644 --- a/packages/docs/content/interfaces.zh.md +++ b/packages/docs/content/interfaces.zh.md @@ -127,6 +127,7 @@ interface EnvironmentConfig { sessionScratchpadDir?: string; // 本 Session 的 scratchpad(scratchpad/),提供后启用截断输出恢复 services?: EnvironmentServices; // 注入给个别工具的运行时服务 vault?: Record; // Vault 环境变量,注入 exec_command / input_command 子进程 + stripProxyEnv?: () => boolean; // 返回 true 时从命令子进程剥除 HTTP(S)_PROXY/ALL_PROXY(保留 NO_PROXY);每次 spawn 重读,缺省即允许代理 } interface EnvironmentServices { diff --git a/packages/docs/content/server-api.en.md b/packages/docs/content/server-api.en.md index 850842f..70573a1 100644 --- a/packages/docs/content/server-api.en.md +++ b/packages/docs/content/server-api.en.md @@ -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 | diff --git a/packages/docs/content/server-api.zh.md b/packages/docs/content/server-api.zh.md index 3cd94e1..f96b7aa 100644 --- a/packages/docs/content/server-api.zh.md +++ b/packages/docs/content/server-api.zh.md @@ -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`(回环不代理)。切换即时生效(对新发起的连接),无需重启。 + ### 版本与在线更新 | 方法 | 路径 | 说明 | diff --git a/packages/server/package.json b/packages/server/package.json index 3caf1d3..63c9fe9 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -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": { diff --git a/packages/server/src/api/types.ts b/packages/server/src/api/types.ts index fc28d64..d7b4551 100644 --- a/packages/server/src/api/types.ts +++ b/packages/server/src/api/types.ts @@ -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"; diff --git a/packages/server/src/app.ts b/packages/server/src/app.ts index 54de097..bcbffef 100644 --- a/packages/server/src/app.ts +++ b/packages/server/src/app.ts @@ -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 { 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()); diff --git a/packages/server/src/db/repos/server-settings.ts b/packages/server/src/db/repos/server-settings.ts new file mode 100644 index 0000000..4f13f91 --- /dev/null +++ b/packages/server/src/db/repos/server-settings.ts @@ -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)); + } +} diff --git a/packages/server/src/db/schema.ts b/packages/server/src/db/schema.ts index a1f5412..a1a0de7 100644 --- a/packages/server/src/db/schema.ts +++ b/packages/server/src/db/schema.ts @@ -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, diff --git a/packages/server/src/http/routes/admin-settings.ts b/packages/server/src/http/routes/admin-settings.ts new file mode 100644 index 0000000..4e6f602 --- /dev/null +++ b/packages/server/src/http/routes/admin-settings.ts @@ -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 { + const app = new Hono(); + + 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; +} diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index db877d6..28abc63 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -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 diff --git a/packages/server/src/net/proxy.ts b/packages/server/src/net/proxy.ts new file mode 100644 index 0000000..689a7ac --- /dev/null +++ b/packages/server/src/net/proxy.ts @@ -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; +} diff --git a/packages/server/src/runtime/session-manager.ts b/packages/server/src/runtime/session-manager.ts index 4ff9361..f3352ba 100644 --- a/packages/server/src/runtime/session-manager.ts +++ b/packages/server/src/runtime/session-manager.ts @@ -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 { 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), diff --git a/packages/server/src/services/session-service.ts b/packages/server/src/services/session-service.ts index a3b029b..a0baff9 100644 --- a/packages/server/src/services/session-service.ts +++ b/packages/server/src/services/session-service.ts @@ -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 { diff --git a/packages/server/test/admin-settings.test.ts b/packages/server/test/admin-settings.test.ts new file mode 100644 index 0000000..a26da88 --- /dev/null +++ b/packages/server/test/admin-settings.test.ts @@ -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; + + beforeEach(async () => { + t = await createTestApp(); + admin = apiClient(t.app, (await loginAdmin(t.app)).cookie); + }); + afterEach(async () => { + await t.cleanup(); + }); + + const getSettings = async (api: ReturnType = 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); + }); +}); diff --git a/packages/server/test/proxy.test.ts b/packages/server/test/proxy.test.ts new file mode 100644 index 0000000..5396099 --- /dev/null +++ b/packages/server/test/proxy.test.ts @@ -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(); + } + }); +}); diff --git a/packages/web/src/api/endpoints.ts b/packages/web/src/api/endpoints.ts index 19fdb27..ee75f6d 100644 --- a/packages/web/src/api/endpoints.ts +++ b/packages/web/src/api/endpoints.ts @@ -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(`/api/admin/users/${encodeURIComponent(userId)}`, { method: "DELETE" }); +/** Server-global settings (admin only): currently the "use system HTTP proxy" switch. */ +export const adminGetSettings = () => apiFetch("/api/admin/settings"); + +/** Omitted fields keep their current value; applies immediately (no restart). */ +export const adminPutSettings = (body: ServerSettingsUpdateRequest) => + apiFetch("/api/admin/settings", { method: "PUT", body }); + // Project & members -------------------------------------------------------------- export const listProjects = () => apiFetch("/api/projects"); diff --git a/packages/web/src/components/layout/sidebar.tsx b/packages/web/src/components/layout/sidebar.tsx index e91dec1..557e2cc 100644 --- a/packages/web/src/components/layout/sidebar.tsx +++ b/packages/web/src/components/layout/sidebar.tsx @@ -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(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({ + {/* 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 && ( + + + + )}