feat(server,web,core): explicit proxy address setting (no env vars needed) (#233)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Yaowei Zheng
2026-08-07 18:53:55 +08:00
committed by GitHub
parent 6e1c7cc28e
commit 0cc0bd1a1a
25 changed files with 948 additions and 278 deletions
+40 -13
View File
@@ -64,8 +64,9 @@ export interface AuthResponse {
export interface MeResponse {
user: UserInfo;
/**
* Whether Workspace HTML previews open on a separate origin (see design §
* "Workspace 文件预览"). False means this deployment has no usable preview origin —
* Whether Workspace HTML previews open on a separate origin (the loopback
* counterpart of the App host, or PENGUIN_PREVIEW_ORIGIN when set). False means this
* deployment has no usable preview origin —
* the App is reached on something other than a loopback name and
* PENGUIN_PREVIEW_ORIGIN is unset — so previews fall back to the same-origin sandbox,
* where `localStorage`, cookies and third-party embeds do not work. Computed per
@@ -76,7 +77,7 @@ export interface MeResponse {
* Whether this server runs in desktop mode (spawned by the desktop shell with
* PENGUIN_DESKTOP_TOKEN). The web app then hides the logout entry, the
* initial-password banner and the self-update entry, and omits the old-password
* field when changing the password. See design § "桌面端原型".
* field when changing the password.
*/
desktopMode: boolean;
/**
@@ -119,17 +120,35 @@ export interface AdminPasswordResetRequest {
password: string;
}
/** Admin-level server-global settings (SQLite server_settings; design § "出网与系统代理"). */
/**
* Admin-level server-global settings (SQLite server_settings):
* two independent proxy switches sharing one optional explicit address. In every
* on-state the effective NO_PROXY always includes localhost/127.0.0.1/::1 (loopback is
* never proxied), and changes apply to newly initiated connections/spawns immediately —
* no restart.
*/
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.
* "Application uses the proxy" (default on): the server's own outbound traffic (LLM
* requests, the update check, image fetches). On with `proxyUrl` set = that address
* for both http and https; on without an address = the proxy environment variables
* HTTP_PROXY / HTTPS_PROXY (both spellings); off = always direct.
*/
useSystemProxy: boolean;
proxyForApp: boolean;
/**
* "Agent environment uses the proxy" (default on): agent command subprocess
* environments. On with `proxyUrl` set = HTTP_PROXY / HTTPS_PROXY (plus lowercase
* twins) injected as that address with the merged NO_PROXY, overriding inherited
* values; on without an address = the host environment passes through unchanged;
* off = the proxy variables are stripped (NO_PROXY kept).
*/
proxyForAgent: boolean;
/**
* The shared explicit proxy address (canonical `http(s)://host[:port]`), or null =
* follow the proxy environment variables. When set it takes precedence over
* HTTP_PROXY / HTTPS_PROXY wherever the owning switch is on.
*/
proxyUrl: string | null;
}
export interface ServerSettingsResponse {
@@ -138,7 +157,15 @@ export interface ServerSettingsResponse {
/** PUT body: every field optional, omitted fields keep their current value (mirrors prefs). */
export interface ServerSettingsUpdateRequest {
useSystemProxy?: boolean;
proxyForApp?: boolean;
proxyForAgent?: boolean;
/**
* New proxy address. Accepted forms: `http://host[:port]`, `https://host[:port]`, or
* bare `host[:port]` (normalized to `http://…` — only normalized values are stored,
* and the response echoes the stored form). Empty/whitespace-only or null clears the
* address (follow the environment variables); anything else is 400 `invalid_proxy_url`.
*/
proxyUrl?: string | null;
}
/** User UI preferences (SQLite ui_prefs, free-form JSON; known keys declared here). */
@@ -700,7 +727,7 @@ export interface MessagesPageInfo {
before?: string;
/**
* Outline turns (the Web conversation outline's entry rule) opened BEFORE this
* window: the client offsets its global `第 N 轮` numbering by this, so a partial
* window: the client offsets its global "round N" numbering by this, so a partial
* window never mis-numbers. 0 when the window starts at the beginning.
*/
earlierTurns: number;
+21 -12
View File
@@ -13,7 +13,9 @@ import { Hono } from "hono";
import type { Context } from "hono";
import { bodyLimit } from "hono/body-limit";
import type { DatabaseSync } from "node:sqlite";
import type { ProxyEnvPolicy } from "@prismshadow/penguin-core";
import type { ServerConfig } from "./config.js";
import { mergedNoProxy } from "./net/proxy.js";
import { openDatabase } from "./db/database.js";
import { AgentsRepo } from "./db/repos/agents.js";
import { AuthSessionsRepo } from "./db/repos/auth-sessions.js";
@@ -95,7 +97,7 @@ export interface AppDeps {
db: DatabaseSync;
sessionsRepo: SessionsRepo;
prefsRepo: UiPrefsRepo;
/** Admin-level server-global settings (currently the "use system HTTP proxy" switch). */
/** Admin-level server-global settings (currently the proxy switches and address). */
serverSettingsRepo: ServerSettingsRepo;
authService: AuthService;
adminService: AdminService;
@@ -157,12 +159,20 @@ export function buildAppDeps(config: ServerConfig, overrides: BuildDepsOverrides
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();
// Command-subprocess proxy policy for core, keyed on the
// "agent environment uses the proxy" switch (the app switch only drives the server's
// own dispatcher, see net/proxy.ts): switch off → strip HTTP(S)_PROXY/ALL_PROXY; on
// with an explicit address → inject that address (with the merged loopback NO_PROXY)
// over whatever the environment carries; on without an address → pass the environment
// through. A getter, not a snapshot: it is re-read at every command spawn, so a
// settings change 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 proxyEnv = (): ProxyEnvPolicy | null => {
if (!serverSettingsRepo.getProxyForAgent()) return { mode: "strip" };
const url = serverSettingsRepo.getProxyUrl();
return url === null ? null : { mode: "inject", url, noProxy: mergedNoProxy() };
};
const schedulesRepo = new SchedulesRepo(db);
const goalsRepo = new GoalsRepo(db);
@@ -214,8 +224,7 @@ export function buildAppDeps(config: ServerConfig, overrides: BuildDepsOverrides
const manager = new SessionManager({
sessions: sessionsRepo,
channels,
loader:
overrides.loader ?? createCoreSessionLoader(config.root, sessionSources, { stripProxyEnv }),
loader: overrides.loader ?? createCoreSessionLoader(config.root, sessionSources, { proxyEnv }),
sources: sessionSources,
recorder,
errors,
@@ -264,7 +273,7 @@ export function buildAppDeps(config: ServerConfig, overrides: BuildDepsOverrides
projectConfig: projectConfigService,
sources: sessionSources,
traceIndex,
stripProxyEnv,
proxyEnv,
});
// 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).
@@ -350,7 +359,7 @@ export function createApp(deps: AppDeps): Hono<AppEnv> {
// cookie had ever been set on that host — act as the user. So the preview host serves ONLY
// /preview/*: /api answers 401 (it never sets or honors a cookie there, closing both the
// login and the stale-cookie paths), and everything else 302s to the canonical App host.
// See design § "Workspace 文件预览". Off when PENGUIN_PREVIEW_ORIGIN is set: previews then
// Off when PENGUIN_PREVIEW_ORIGIN is set: previews then
// use that origin rather than the loopback counterpart, so 127.0.0.1 is an ordinary App
// access point and must not be locked down — deployments enforce the equivalent at the
// reverse proxy (route only /preview/* to the App on the preview origin).
@@ -428,7 +437,7 @@ export function createApp(deps: AppDeps): Hono<AppEnv> {
// Workspace HTML preview on the separate preview origin: deliberately outside /api and
// outside the auth middleware — that origin never receives the session cookie, so the
// signed token in the path is the only credential. Mounted before static hosting so the
// SPA fallback cannot swallow it. See design § "Workspace 文件预览".
// SPA fallback cannot swallow it.
app.route("/preview", previewRoutes(deps));
// Static hosting (production): serves the frontend build output when webDist exists, with SPA fallback to index.html.
@@ -5,8 +5,22 @@
*/
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";
/** Key of the "application uses the proxy" switch (the server's own outbound dispatcher); default on. */
const PROXY_FOR_APP_KEY = "proxy_for_app";
/** Key of the "agent environment uses the proxy" switch (command subprocess env policy); default on. */
const PROXY_FOR_AGENT_KEY = "proxy_for_agent";
/**
* Legacy single-switch key from the unreleased #225 iteration (never in a release): read
* as the fallback default for BOTH new switches while their own keys are absent, so a
* main-branch deployment that had toggled it keeps its choice. Read-only adoption — the
* legacy key is never written back, and either new key, once set, wins for its switch.
*/
const LEGACY_USE_SYSTEM_PROXY_KEY = "use_system_proxy";
/** Key of the explicit proxy address; absent/null = follow the proxy environment variables. */
const PROXY_URL_KEY = "proxy_url";
export class ServerSettingsRepo {
constructor(private readonly db: DatabaseSync) {}
@@ -26,12 +40,48 @@ export class ServerSettingsRepo {
.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";
/** Shared switch read: this key if present, else the legacy single switch, else the default: on. */
private getProxySwitch(key: string): boolean {
const raw = this.get(key);
if (raw !== null) return raw !== "false";
return this.get(LEGACY_USE_SYSTEM_PROXY_KEY) !== "false";
}
setUseSystemProxy(value: boolean): void {
this.set(USE_SYSTEM_PROXY_KEY, JSON.stringify(value));
/** The "application uses the proxy" switch (the server's own outbound dispatcher). */
getProxyForApp(): boolean {
return this.getProxySwitch(PROXY_FOR_APP_KEY);
}
setProxyForApp(value: boolean): void {
this.set(PROXY_FOR_APP_KEY, JSON.stringify(value));
}
/** The "agent environment uses the proxy" switch (command subprocess env policy). */
getProxyForAgent(): boolean {
return this.getProxySwitch(PROXY_FOR_AGENT_KEY);
}
setProxyForAgent(value: boolean): void {
this.set(PROXY_FOR_AGENT_KEY, JSON.stringify(value));
}
/**
* The explicit proxy address (normalized at write time by the settings route); null =
* follow the proxy environment variables. An absent or unreadable row reads as null
* (the safe default: behave as before the setting existed).
*/
getProxyUrl(): string | null {
const raw = this.get(PROXY_URL_KEY);
if (raw === null) return null;
try {
const value: unknown = JSON.parse(raw);
return typeof value === "string" && value !== "" ? value : null;
} catch {
return null;
}
}
setProxyUrl(value: string | null): void {
this.set(PROXY_URL_KEY, JSON.stringify(value));
}
}
+1 -1
View File
@@ -122,7 +122,7 @@ CREATE TABLE IF NOT EXISTS ui_prefs (
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'
key TEXT PRIMARY KEY, -- setting name, e.g. 'proxy_for_app'
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
@@ -1,9 +1,13 @@
/**
* 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.
* (currently the proxy settings: the "application uses the proxy" and "agent
* environment uses the proxy" switches and their shared explicit address).
* A PUT applies immediately: everything is validated first (a rejected request writes
* nothing), then the persisted values are written, then the process dispatcher is
* rebuilt so new outbound connections follow the change without a restart (the agent
* switch needs no push — the command-subprocess policy getter re-reads the repo at
* every spawn).
*/
import { Hono } from "hono";
import type { ServerSettingsResponse } from "../../api/types.js";
@@ -11,7 +15,26 @@ 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";
import { applyProxySettings, normalizeProxyUrl } from "../../net/proxy.js";
/**
* proxyUrl update value -> stored value: null and empty/whitespace-only clear the
* address; anything else must normalize (see normalizeProxyUrl) or the whole PUT is
* rejected with `invalid_proxy_url` — un-normalized values are never stored.
*/
function parseProxyUrl(value: unknown): string | null {
if (value === null) return null;
if (typeof value === "string") {
if (value.trim() === "") return null;
const normalized = normalizeProxyUrl(value);
if (normalized !== null) return normalized;
}
throw new HttpError(
400,
"invalid_proxy_url",
"proxyUrl must be http://host[:port], https://host[:port], or host[:port] (empty or null clears it).",
);
}
export function adminSettingsRoutes(deps: AppDeps): Hono<AppEnv> {
const app = new Hono<AppEnv>();
@@ -24,19 +47,32 @@ export function adminSettingsRoutes(deps: AppDeps): Hono<AppEnv> {
});
const settings = (): ServerSettingsResponse => ({
settings: { useSystemProxy: deps.serverSettingsRepo.getUseSystemProxy() },
settings: {
proxyForApp: deps.serverSettingsRepo.getProxyForApp(),
proxyForAgent: deps.serverSettingsRepo.getProxyForAgent(),
proxyUrl: deps.serverSettingsRepo.getProxyUrl(),
},
});
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);
}
// Validate every provided field before writing any: a partial PUT with one invalid
// field must leave the others untouched too.
const proxyForApp = optionalBoolean(body, "proxyForApp");
const proxyForAgent = optionalBoolean(body, "proxyForAgent");
const proxyUrlProvided = body.proxyUrl !== undefined;
const proxyUrl = proxyUrlProvided ? parseProxyUrl(body.proxyUrl) : null;
if (proxyForApp !== undefined) deps.serverSettingsRepo.setProxyForApp(proxyForApp);
if (proxyForAgent !== undefined) deps.serverSettingsRepo.setProxyForAgent(proxyForAgent);
if (proxyUrlProvided) deps.serverSettingsRepo.setProxyUrl(proxyUrl);
// Mirror the app switch + address into the process: rebuilds the global fetch
// dispatcher (live change, no restart; a no-op when nothing effectively changed).
applyProxySettings({
proxyForApp: deps.serverSettingsRepo.getProxyForApp(),
proxyUrl: deps.serverSettingsRepo.getProxyUrl(),
});
return c.json(settings());
});
+14 -9
View File
@@ -15,7 +15,7 @@ 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 { applyProxySettings, installGlobalProxyDispatcher } from "./net/proxy.js";
import { loopbackHostRoles } from "./services/preview-token.js";
import { acquireServerLock, liveServerLock, releaseServerLock } from "./lock.js";
@@ -23,9 +23,10 @@ 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.
// dispatcher, starting from the defaults (app switch on, no explicit address). The
// persisted values can only be read once the database is open, so they are applied via
// applyProxySettings 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). */
@@ -47,10 +48,14 @@ 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());
// The database is open now: bring the dispatcher in line with the persisted proxy
// settings (absent rows read as the defaults: app switch on, no explicit address)
// before the first possible outbound request (update check, LLM calls — all behind
// HTTP handlers).
applyProxySettings({
proxyForApp: deps.serverSettingsRepo.getProxyForApp(),
proxyUrl: deps.serverSettingsRepo.getProxyUrl(),
});
const app = createApp(deps);
// Built-in admin seed (idempotent): creates admin and adopts default_project when the
@@ -84,7 +89,7 @@ const appHost = loopbackHostRoles(config.host)?.app ?? config.host;
* Second loopback listener so the preview origin is actually reachable.
*
* Workspace HTML previews are served from the loopback counterpart of the host the App
* is used on (`127.0.0.1` <-> `localhost`, see design § "Workspace 文件预览"). On most
* is used on (`127.0.0.1` <-> `localhost`). On most
* systems `localhost` resolves to `::1` first, so a server bound only to `127.0.0.1`
* would leave every preview URL refusing connections. Binding `::1` as well closes that
* gap. Failure is non-fatal — the App keeps working, previews just fall back.
+109 -39
View File
@@ -1,29 +1,39 @@
/**
* Outbound networking and the "use system HTTP proxy" switch (design § "出网与系统代理").
* Outbound networking and the admin proxy settings.
*
* 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.
* admin-level settings — undici's fetch resolves the global dispatcher per call, so a
* settings change 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.
* Dispatcher per settings state (keyed on the "application uses the proxy" switch; the
* separate agent-environment switch never touches the dispatcher — it only drives the
* command-subprocess policy in app.ts):
* - app switch off: a plain `Agent` — direct connections, proxy variables ignored;
* - on, no explicit address (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;
* - on with an explicit address (`proxyUrl`): `EnvHttpProxyAgent` again, but with its
* `httpProxy` / `httpsProxy` options pinned to that URL — undici consults those
* BEFORE the environment (verified against undici 7.29's constructor:
* `httpProxy ?? process.env.http_proxy ?? process.env.HTTP_PROXY`), so the explicit
* address governs both http and https traffic regardless of ambient env, while the
* merged NO_PROXY keeps working through the same `noProxy` option.
*
* The loopback merge is load-bearing in either state: the CLI's readiness probe
* The loopback merge is load-bearing in every 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.
* The values persisted in server_settings stay authoritative (the routes read/write the
* repo); this module only mirrors them into the process-global dispatcher. The command
* subprocess side — keyed on the agent-environment switch: strip the proxy variables
* when it is off, inject the explicit address when set, pass through otherwise — lives
* in core (CommandSessionManager, threaded through the session loader as a
* ProxyEnvPolicy getter), not here.
*/
import { Agent, EnvHttpProxyAgent, fetch as undiciFetch, setGlobalDispatcher } from "undici";
import type { Dispatcher } from "undici";
@@ -31,10 +41,19 @@ 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 slice of the admin proxy settings the dispatcher mirrors (persisted in server_settings). */
export interface ProxySettings {
/** The "application uses the proxy" switch (default on). */
proxyForApp: boolean;
/** Explicit proxy URL (normalized, see {@link normalizeProxyUrl}); null = follow the proxy environment variables. */
proxyUrl: string | null;
}
/**
* 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.
* pure helper for tests and for app.ts's command-subprocess policy getter; `env`
* defaults to the real process environment.
*/
export function mergedNoProxy(env: NodeJS.ProcessEnv = process.env): string {
const raw = env.no_proxy ?? env.NO_PROXY ?? "";
@@ -48,49 +67,100 @@ export function mergedNoProxy(env: NodeJS.ProcessEnv = process.env): string {
}
/**
* Builds the global dispatcher for a switch state (pure choice, exported for tests):
* on → EnvHttpProxyAgent with the merged NO_PROXY, off → direct-connect Agent.
* Normalizes a proxy address to its canonical URL, or null when it is not acceptable.
* Accepted forms: `http://host[:port]`, `https://host[:port]`, and the bare
* `host[:port]` / `host` shorthand (normalized to `http://…`). Anything else — other
* schemes (the dispatcher speaks only HTTP(S) proxies; a socks:// URL would break every
* server fetch, see os-proxy.ts for the same rule), credentials, paths, queries — is
* rejected rather than stored: server_settings only ever holds normalized values.
*/
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();
export function normalizeProxyUrl(raw: string): string | null {
const trimmed = raw.trim();
if (trimmed === "") return null;
// Bare host[:port] shorthand: no scheme -> http. The scheme test requires "://", so
// "host:8080" (a port, not a scheme) takes the shorthand path.
const candidate = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(trimmed) ? trimmed : `http://${trimmed}`;
let url: URL;
try {
url = new URL(candidate);
} catch {
return null;
}
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
if (url.hostname === "" || url.username !== "" || url.password !== "") return null;
// A proxy address is host-only; the parser's bare "/" (its normal form for an absent
// path) is tolerated, an actual path/query/fragment means this was not a proxy address.
if ((url.pathname !== "/" && url.pathname !== "") || url.search !== "" || url.hash !== "") {
return null;
}
// origin is the canonical form: lowercased scheme/host, default ports dropped.
return url.origin;
}
/** Current switch state mirrored for the dispatcher (the DB row is the persisted truth). */
let useSystemProxy = true;
/** The EnvHttpProxyAgent option subset this module assembles (undici's Options type is not re-exported cleanly). */
export interface EnvProxyAgentOptions {
httpProxy?: string;
httpsProxy?: string;
noProxy: string;
}
/**
* The EnvHttpProxyAgent options for the switch-on states (pure choice, exported for
* tests): always the merged NO_PROXY — passed via opts.noProxy, which REPLACES the
* environment lookup inside EnvHttpProxyAgent, so merging is this module's job (undici
* would otherwise use env NO_PROXY verbatim, without the loopback exemption) — plus,
* with an explicit address, the httpProxy/httpsProxy overrides that pin both traffic
* kinds to it.
*/
export function envProxyAgentOptions(
proxyUrl: string | null,
env?: NodeJS.ProcessEnv,
): EnvProxyAgentOptions {
const noProxy = mergedNoProxy(env);
return proxyUrl === null ? { noProxy } : { httpProxy: proxyUrl, httpsProxy: proxyUrl, noProxy };
}
/**
* Builds the global dispatcher for a settings state (pure choice, exported for tests):
* app switch off → direct-connect Agent, on → EnvHttpProxyAgent (env-driven, or pinned
* to the explicit address; see {@link envProxyAgentOptions}).
*/
export function buildProxyDispatcher(settings: ProxySettings, env?: NodeJS.ProcessEnv): Dispatcher {
if (!settings.proxyForApp) return new Agent();
return new EnvHttpProxyAgent(envProxyAgentOptions(settings.proxyUrl, env));
}
/** Current settings mirrored for the dispatcher (the DB rows are the persisted truth). */
let current: ProxySettings = { proxyForApp: true, proxyUrl: null };
/** 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).
* defaults (on, no explicit address) — the entry applies the persisted values via
* {@link applyProxySettings} 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));
setGlobalDispatcher(buildProxyDispatcher(current));
// 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
* Applies a settings 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.
* persisted values and by PUT /api/admin/settings on every change. 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;
export function applyProxySettings(settings: ProxySettings): void {
if (settings.proxyForApp === current.proxyForApp && settings.proxyUrl === current.proxyUrl) {
return;
}
current = { proxyForApp: settings.proxyForApp, proxyUrl: settings.proxyUrl };
if (installed) setGlobalDispatcher(buildProxyDispatcher(current));
}
@@ -42,6 +42,7 @@ import type {
ApproveFn,
CompactAvailability,
OmniMessage,
ProxyEnvPolicy,
SessionMetaPayload,
SessionTitleResult,
TextPayload,
@@ -121,13 +122,14 @@ 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).
* `opts.proxyEnv` threads the admin proxy settings into core (a live getter returning the
* agent-command-subprocess policy: strip the proxy variables, inject the explicit proxy
* address, or null = pass the environment through).
*/
export function createCoreSessionLoader(
root: string,
sources?: SessionSources,
opts: { stripProxyEnv?: () => boolean } = {},
opts: { proxyEnv?: () => ProxyEnvPolicy | null } = {},
): SessionLoader {
return {
async load(row: SessionRow): Promise<RuntimeSession> {
@@ -135,7 +137,7 @@ export function createCoreSessionLoader(
root,
projectId: row.projectId,
agentId: row.agentId,
...(opts.stripProxyEnv ? { stripProxyEnv: opts.stripProxyEnv } : {}),
...(opts.proxyEnv ? { proxyEnv: opts.proxyEnv } : {}),
});
const located = await findLatestTraceFile(
tracesDir(root, row.projectId, row.agentId),
@@ -15,6 +15,7 @@
* added to session-manager's active table (state idle).
*/
import { createAgent, isSessionMeta } from "@prismshadow/penguin-core";
import type { ProxyEnvPolicy } from "@prismshadow/penguin-core";
import type {
ApprovalMode,
SessionCategory,
@@ -52,11 +53,11 @@ export interface SessionServiceDeps {
/** 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.
* Admin proxy-settings 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 command-subprocess proxy policy too.
*/
stripProxyEnv?: () => boolean;
proxyEnv?: () => ProxyEnvPolicy | null;
}
export class SessionService {
@@ -325,7 +326,7 @@ export class SessionService {
root: this.deps.root,
projectId: args.projectId,
agentId: args.agentId,
...(this.deps.stripProxyEnv ? { stripProxyEnv: this.deps.stripProxyEnv } : {}),
...(this.deps.proxyEnv ? { proxyEnv: this.deps.proxyEnv } : {}),
});
let session;
try {