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:
@@ -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
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user