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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user