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:
@@ -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();
|
||||
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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]");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {};
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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`(回环不代理)。切换即时生效(对新发起的连接),无需重启。
|
||||
|
||||
### 版本与在线更新
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "深色",
|
||||
|
||||
Generated
+4
-2
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user