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

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Yaowei Zheng
2026-08-06 20:21:26 +08:00
committed by GitHub
parent e8499befd4
commit 91f3292ab5
29 changed files with 710 additions and 19 deletions
+22
View File
@@ -119,6 +119,28 @@ export interface AdminPasswordResetRequest {
password: string;
}
/** Admin-level server-global settings (SQLite server_settings; design § "出网与系统代理"). */
export interface ServerSettings {
/**
* "Use system HTTP proxy" (default on): whether the server process and its child
* processes reach the internet through the proxy named by HTTP_PROXY / HTTPS_PROXY
* (both spellings). Off = direct connections, with the proxy variables also stripped
* from agent command subprocess environments. Either way the effective NO_PROXY always
* includes localhost/127.0.0.1/::1 (loopback is never proxied). Toggling applies to
* newly initiated connections immediately — no restart.
*/
useSystemProxy: boolean;
}
export interface ServerSettingsResponse {
settings: ServerSettings;
}
/** PUT body: every field optional, omitted fields keep their current value (mirrors prefs). */
export interface ServerSettingsUpdateRequest {
useSystemProxy?: boolean;
}
/** User UI preferences (SQLite ui_prefs, free-form JSON; known keys declared here). */
export interface UiPrefs {
theme?: "light" | "dark";
+16 -1
View File
@@ -22,6 +22,7 @@ import { MembersRepo } from "./db/repos/members.js";
import { ProjectsRepo } from "./db/repos/projects.js";
import { GoalsRepo } from "./db/repos/goals.js";
import { SchedulesRepo } from "./db/repos/schedules.js";
import { ServerSettingsRepo } from "./db/repos/server-settings.js";
import { SessionsRepo } from "./db/repos/sessions.js";
import { TraceIndexRepo } from "./db/repos/trace-index.js";
import { UiPrefsRepo } from "./db/repos/ui-prefs.js";
@@ -33,6 +34,7 @@ import type { AppEnv } from "./auth/middleware.js";
import { AuthService } from "./auth/service.js";
import { handleError, HttpError, errorBody } from "./http/errors.js";
import { adminUsersRoutes } from "./http/routes/admin.js";
import { adminSettingsRoutes } from "./http/routes/admin-settings.js";
import { authRoutes } from "./http/routes/auth.js";
import { meRoutes } from "./http/routes/me.js";
import { eventsRoutes, userChannelKey } from "./http/routes/events.js";
@@ -93,6 +95,8 @@ export interface AppDeps {
db: DatabaseSync;
sessionsRepo: SessionsRepo;
prefsRepo: UiPrefsRepo;
/** Admin-level server-global settings (currently the "use system HTTP proxy" switch). */
serverSettingsRepo: ServerSettingsRepo;
authService: AuthService;
adminService: AdminService;
projectService: ProjectService;
@@ -152,6 +156,13 @@ export function buildAppDeps(config: ServerConfig, overrides: BuildDepsOverrides
const usageRepo = new UsageRepo(db);
const errorsRepo = new ErrorsRepo(db);
const prefsRepo = new UiPrefsRepo(db);
const serverSettingsRepo = new ServerSettingsRepo(db);
// Proxy-off also strips HTTP(S)_PROXY/ALL_PROXY from agent command subprocess
// environments (design § "出网与系统代理"). A getter, not a snapshot: it is re-read at
// every command spawn, so a toggle reaches already-loaded Sessions. Threaded through
// BOTH core entry paths — the loader (resume/self-heal) and SessionService (creation,
// whose runtime the manager adopts for the first Task).
const stripProxyEnv = () => !serverSettingsRepo.getUseSystemProxy();
const schedulesRepo = new SchedulesRepo(db);
const goalsRepo = new GoalsRepo(db);
@@ -203,7 +214,8 @@ export function buildAppDeps(config: ServerConfig, overrides: BuildDepsOverrides
const manager = new SessionManager({
sessions: sessionsRepo,
channels,
loader: overrides.loader ?? createCoreSessionLoader(config.root, sessionSources),
loader:
overrides.loader ?? createCoreSessionLoader(config.root, sessionSources, { stripProxyEnv }),
sources: sessionSources,
recorder,
errors,
@@ -252,6 +264,7 @@ export function buildAppDeps(config: ServerConfig, overrides: BuildDepsOverrides
projectConfig: projectConfigService,
sources: sessionSources,
traceIndex,
stripProxyEnv,
});
// Schedule scheduler: active only while the server is running. Only
// assembled here; start() is called in index.ts (tests drive it via tickOnce, no real timer).
@@ -275,6 +288,7 @@ export function buildAppDeps(config: ServerConfig, overrides: BuildDepsOverrides
db,
sessionsRepo,
prefsRepo,
serverSettingsRepo,
authService,
adminService,
projectService,
@@ -390,6 +404,7 @@ export function createApp(deps: AppDeps): Hono<AppEnv> {
app.route("/api/me", meRoutes(deps));
app.route("/api/version", versionRoutes(deps));
app.route("/api/admin/users", adminUsersRoutes(deps));
app.route("/api/admin/settings", adminSettingsRoutes(deps));
app.route("/api/events", eventsRoutes(deps));
// Skill library listing: readable once logged in, not nested under a Project prefix.
app.route("/api/skills", skillLibraryRoutes());
@@ -0,0 +1,37 @@
/**
* server_settings table repo (admin-level server-global settings): key-value storage
* with JSON-encoded values. An absent row means the setting's built-in default, so
* settings added after a web.db was formed need no migration.
*/
import type { DatabaseSync } from "node:sqlite";
/** Key of the "use system HTTP proxy" switch (design § "出网与系统代理"); default on. */
const USE_SYSTEM_PROXY_KEY = "use_system_proxy";
export class ServerSettingsRepo {
constructor(private readonly db: DatabaseSync) {}
/** Returns the raw JSON-encoded value; null if never set. */
get(key: string): string | null {
const r = this.db.prepare("SELECT value FROM server_settings WHERE key = ?").get(key);
return r ? (r.value as string) : null;
}
set(key: string, value: string): void {
this.db
.prepare(
`INSERT INTO server_settings (key, value) VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
)
.run(key, value);
}
/** The "use system HTTP proxy" switch; an absent (or unreadable) row reads as the default: on. */
getUseSystemProxy(): boolean {
return this.get(USE_SYSTEM_PROXY_KEY) !== "false";
}
setUseSystemProxy(value: boolean): void {
this.set(USE_SYSTEM_PROXY_KEY, JSON.stringify(value));
}
}
+4
View File
@@ -121,6 +121,10 @@ CREATE TABLE IF NOT EXISTS ui_prefs (
user_id TEXT PRIMARY KEY REFERENCES users(user_id) ON DELETE CASCADE,
prefs_json TEXT NOT NULL -- {theme?, lastProjectId?, ...} free-form JSON
);
CREATE TABLE IF NOT EXISTS server_settings ( -- admin-level server-global settings (GET/PUT /api/admin/settings)
key TEXT PRIMARY KEY, -- setting name, e.g. 'use_system_proxy'
value TEXT NOT NULL -- JSON-encoded value; an absent row means the setting's built-in default
);
CREATE TABLE IF NOT EXISTS trace_files ( -- DERIVED CACHE of the on-disk Trace tree (services/trace-index.ts): the directories stay the single source of truth, every row is rebuildable from disk, and a row is never authority for absence — consumers reconcile + retry on a miss, so a stale index costs one extra scan, never a false 404
project_id TEXT NOT NULL,
agent_id TEXT NOT NULL,
@@ -0,0 +1,44 @@
/**
* Admin server-settings routes (admin only, 403 for non-admins):
* GET|PUT /api/admin/settings — the server-global settings stored in server_settings
* (currently the "use system HTTP proxy" switch, design § "出网与系统代理").
* A PUT applies immediately: the persisted value is written first, then the process
* dispatcher is rebuilt so new outbound connections follow the toggle without a restart.
*/
import { Hono } from "hono";
import type { ServerSettingsResponse } from "../../api/types.js";
import { HttpError } from "../errors.js";
import type { AppEnv } from "../../auth/middleware.js";
import { optionalBoolean, readJson } from "../validate.js";
import type { AppDeps } from "../../app.js";
import { setUseSystemProxy } from "../../net/proxy.js";
export function adminSettingsRoutes(deps: AppDeps): Hono<AppEnv> {
const app = new Hono<AppEnv>();
app.use("*", async (c, next) => {
if (!c.var.user.isAdmin) {
throw new HttpError(403, "admin_required", "Only an admin can perform this operation.");
}
await next();
});
const settings = (): ServerSettingsResponse => ({
settings: { useSystemProxy: deps.serverSettingsRepo.getUseSystemProxy() },
});
app.get("/", (c) => c.json(settings()));
app.put("/", async (c) => {
const body = await readJson(c);
const useSystemProxy = optionalBoolean(body, "useSystemProxy");
if (useSystemProxy !== undefined) {
deps.serverSettingsRepo.setUseSystemProxy(useSystemProxy);
// Mirror into the process: rebuilds the global fetch dispatcher (live toggle).
setUseSystemProxy(useSystemProxy);
}
return c.json(settings());
});
return app;
}
+12
View File
@@ -15,11 +15,19 @@ import { config as loadDotenv } from "dotenv";
import { serve } from "@hono/node-server";
import { buildAppDeps, createApp } from "./app.js";
import { resolveServerConfig } from "./config.js";
import { installGlobalProxyDispatcher, setUseSystemProxy } from "./net/proxy.js";
import { loopbackHostRoles } from "./services/preview-token.js";
import { acquireServerLock, liveServerLock, releaseServerLock } from "./lock.js";
loadDotenv({ quiet: true });
// Outbound proxy support, installed at the earliest point (right after dotenv, which may
// itself define HTTP_PROXY): replaces globalThis.fetch with undici's and sets the global
// dispatcher, starting from the switch default (on). The persisted value can only be
// read once the database is open, so it is applied via setUseSystemProxy right after
// buildAppDeps below — nothing in between makes an outbound request. See net/proxy.ts.
installGlobalProxyDispatcher();
/** Exit code for "another server already owns this data root" (see lock.ts). */
const EXIT_ALREADY_RUNNING = 3;
@@ -39,6 +47,10 @@ if (existingLock) {
}
const deps = buildAppDeps(config);
// The database is open now: bring the dispatcher in line with the persisted
// "use system HTTP proxy" switch (an absent row reads as the default: on) before the
// first possible outbound request (update check, LLM calls — all behind HTTP handlers).
setUseSystemProxy(deps.serverSettingsRepo.getUseSystemProxy());
const app = createApp(deps);
// Built-in admin seed (idempotent): creates admin and adopts default_project when the
+96
View File
@@ -0,0 +1,96 @@
/**
* Outbound networking and the "use system HTTP proxy" switch (design § "出网与系统代理").
*
* Node 24's built-in fetch does not read the proxy environment variables
* (NODE_USE_ENV_PROXY is ineffective on 24.18), so the server routes ALL of its own
* outbound traffic through undici: the startup entry replaces `globalThis.fetch` with
* undici's fetch once, and this module keeps undici's global dispatcher in line with the
* admin-level setting — undici's fetch resolves the global dispatcher per call, so a
* toggle takes effect for newly initiated connections without a restart.
*
* Dispatcher per setting:
* - on (default): `EnvHttpProxyAgent` — honors HTTP_PROXY / HTTPS_PROXY (both
* spellings; undici reads the lowercase name first) with a NO_PROXY list merged from
* the environment plus the loopback names;
* - off: a plain `Agent` — direct connections, proxy variables ignored.
*
* The loopback merge is load-bearing in either state: the CLI's readiness probe
* (`penguin web` imports the server in-process and polls its own root path), SSE, and
* Workspace previews all ride the loopback names, so a proxy that intercepts loopback
* would take the whole App down. `localhost,127.0.0.1,::1` is therefore ALWAYS appended
* to the effective NO_PROXY, regardless of what the environment declares.
*
* The value persisted in server_settings stays authoritative (the routes read/write the
* repo); this module only mirrors it into the process-global dispatcher. Stripping the
* proxy variables from agent command subprocesses when the switch is off lives in core
* (CommandSessionManager, threaded through the session loader), not here.
*/
import { Agent, EnvHttpProxyAgent, fetch as undiciFetch, setGlobalDispatcher } from "undici";
import type { Dispatcher } from "undici";
/** Loopback names every effective NO_PROXY must contain (see module doc). */
export const LOOPBACK_NO_PROXY = ["localhost", "127.0.0.1", "::1"] as const;
/**
* The effective NO_PROXY: the environment's entries (lowercase spelling first, matching
* undici's own precedence) with the loopback names appended when missing. Exported as a
* pure helper for tests; `env` defaults to the real process environment.
*/
export function mergedNoProxy(env: NodeJS.ProcessEnv = process.env): string {
const raw = env.no_proxy ?? env.NO_PROXY ?? "";
// Same separators undici's parser accepts (comma or whitespace).
const entries = raw.split(/[,\s]/).filter((e) => e !== "");
const present = new Set(entries.map((e) => e.toLowerCase()));
for (const name of LOOPBACK_NO_PROXY) {
if (!present.has(name)) entries.push(name);
}
return entries.join(",");
}
/**
* Builds the global dispatcher for a switch state (pure choice, exported for tests):
* on → EnvHttpProxyAgent with the merged NO_PROXY, off → direct-connect Agent.
*/
export function buildProxyDispatcher(useSystemProxy: boolean, env?: NodeJS.ProcessEnv): Dispatcher {
// The merged list is passed via opts.noProxy, which REPLACES the environment lookup
// inside EnvHttpProxyAgent — merging is this module's job (undici would otherwise use
// env NO_PROXY verbatim, without the loopback exemption).
return useSystemProxy ? new EnvHttpProxyAgent({ noProxy: mergedNoProxy(env) }) : new Agent();
}
/** Current switch state mirrored for the dispatcher (the DB row is the persisted truth). */
let useSystemProxy = true;
/** True once the startup entry has installed undici globally (never in tests). */
let installed = false;
/**
* One-time install at the startup entry (index.ts), before anything can fetch: replaces
* `globalThis.fetch` with undici's and sets the initial dispatcher. Starts from the
* default (on) — the entry applies the persisted value via {@link setUseSystemProxy} as
* soon as the database is open, before any outbound request exists. Tests never call
* this, so they keep the runtime's own fetch (and their fetch stubs).
*/
export function installGlobalProxyDispatcher(): void {
installed = true;
setGlobalDispatcher(buildProxyDispatcher(useSystemProxy));
// Cast: undici's fetch is typed against its own RequestInit/Response declarations,
// which are structurally compatible with the runtime globals for every caller here.
globalThis.fetch = undiciFetch as unknown as typeof globalThis.fetch;
}
/**
* Applies a switch state to the process: rebuilds the global dispatcher so new
* connections follow it immediately (no restart). Called by the startup entry with the
* persisted value and by PUT /api/admin/settings on every toggle. Outside the installed
* runtime (tests) it only records the state.
*/
export function setUseSystemProxy(value: boolean): void {
if (value === useSystemProxy) return;
useSystemProxy = value;
if (installed) setGlobalDispatcher(buildProxyDispatcher(useSystemProxy));
}
/** The switch state the dispatcher currently reflects. */
export function getUseSystemProxy(): boolean {
return useSystemProxy;
}
@@ -121,14 +121,21 @@ export interface SessionLoader {
* lets the no-Trace self-heal rebuild re-record a known origin into the fresh session_meta;
* with no registry entry (e.g. the process restarted and no Trace was ever written) the
* rebuilt Session is unsourced — session_meta is the single source of truth, and none survived.
* `opts.stripProxyEnv` threads the "use system HTTP proxy" switch into core (a live getter:
* true = strip HTTP(S)_PROXY/ALL_PROXY from agent command subprocess environments).
*/
export function createCoreSessionLoader(root: string, sources?: SessionSources): SessionLoader {
export function createCoreSessionLoader(
root: string,
sources?: SessionSources,
opts: { stripProxyEnv?: () => boolean } = {},
): SessionLoader {
return {
async load(row: SessionRow): Promise<RuntimeSession> {
const agent = await createAgent({
root,
projectId: row.projectId,
agentId: row.agentId,
...(opts.stripProxyEnv ? { stripProxyEnv: opts.stripProxyEnv } : {}),
});
const located = await findLatestTraceFile(
tracesDir(root, row.projectId, row.agentId),
@@ -51,6 +51,12 @@ export interface SessionServiceDeps {
sources: SessionSources;
/** Trace-file index: discovery / adoption / stats serve from it (mtime-gated reconciler; no per-request walks). */
traceIndex: TraceIndexService;
/**
* "Use system HTTP proxy" switch threading (same getter the session loader passes,
* see createCoreSessionLoader): the runtime created here is adopted by the manager
* and runs the Session's first Task, so it needs the strip policy too.
*/
stripProxyEnv?: () => boolean;
}
export class SessionService {
@@ -319,6 +325,7 @@ export class SessionService {
root: this.deps.root,
projectId: args.projectId,
agentId: args.agentId,
...(this.deps.stripProxyEnv ? { stripProxyEnv: this.deps.stripProxyEnv } : {}),
});
let session;
try {