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:
+16
-15
@@ -57,6 +57,7 @@ import { INPUT_SUBAGENT_NAME } from "./environment/tools/input-subagent.js";
|
||||
import type { CompactionSettings } from "./engine/context-engine.js";
|
||||
import type {
|
||||
GenerativeModelConfig,
|
||||
ProxyEnvPolicy,
|
||||
SubagentRunner,
|
||||
ThinkingLevelName,
|
||||
ToolDefinition,
|
||||
@@ -77,15 +78,15 @@ export interface CreateAgentOptions {
|
||||
/** Local data root directory; defaults to `resolveRoot()` (PENGUIN_HOME or ~/.penguin/data). */
|
||||
root?: string;
|
||||
/**
|
||||
* When it returns true, the proxy variables (HTTP_PROXY / HTTPS_PROXY / ALL_PROXY;
|
||||
* NO_PROXY is kept) are stripped from exec_command subprocess environments of every
|
||||
* Session this Agent creates or resumes — and of its subagents' Sessions, which
|
||||
* inherit the getter. The Web server threads its admin-level "use system HTTP proxy"
|
||||
* switch (off state) through here; the getter is re-read at every command spawn, so a
|
||||
* toggle needs no restart. Absent = proxy allowed (SDK/CLI standalone use follows the
|
||||
* Proxy policy for exec_command subprocess environments of every Session this Agent
|
||||
* creates or resumes — and of its subagents' Sessions, which inherit the getter (see
|
||||
* {@link ProxyEnvPolicy}: strip the proxy variables, inject an explicit proxy over the
|
||||
* inherited env, or null = pass through). The Web server threads its admin-level proxy
|
||||
* settings through here; the getter is re-read at every command spawn, so a settings
|
||||
* change needs no restart. Absent = pass through (SDK/CLI standalone use follows the
|
||||
* user's own shell environment).
|
||||
*/
|
||||
stripProxyEnv?: () => boolean;
|
||||
proxyEnv?: () => ProxyEnvPolicy | null;
|
||||
}
|
||||
|
||||
export interface CreateSessionOptions {
|
||||
@@ -159,15 +160,15 @@ export function metaMaxTokens(budget: number, modelCap: number | undefined): num
|
||||
export async function createAgent(opts: CreateAgentOptions = {}): Promise<Agent> {
|
||||
const state = await loadOrInitAgentState(opts);
|
||||
const projectConfig = await loadProjectConfig(state.root, state.projectId);
|
||||
return new Agent(state, projectConfig, opts.stripProxyEnv);
|
||||
return new Agent(state, projectConfig, opts.proxyEnv);
|
||||
}
|
||||
|
||||
export class Agent {
|
||||
constructor(
|
||||
readonly state: AgentState,
|
||||
readonly projectConfig: ProjectConfig,
|
||||
/** See {@link CreateAgentOptions.stripProxyEnv}; forwarded into every Session's Environment. */
|
||||
private readonly stripProxyEnv?: () => boolean,
|
||||
/** See {@link CreateAgentOptions.proxyEnv}; forwarded into every Session's Environment. */
|
||||
private readonly proxyEnv?: () => ProxyEnvPolicy | null,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -598,10 +599,10 @@ export class Agent {
|
||||
root,
|
||||
projectId,
|
||||
agentId,
|
||||
// A child Agent loads its own vault/config, but the proxy-strip getter is
|
||||
// host policy, not Agent state: the subagent's commands run in the same
|
||||
// serving process, so they follow the same switch as the parent's.
|
||||
...(parentAgent.stripProxyEnv ? { stripProxyEnv: parentAgent.stripProxyEnv } : {}),
|
||||
// A child Agent loads its own vault/config, but the proxy-env policy
|
||||
// getter is host policy, not Agent state: the subagent's commands run in
|
||||
// the same serving process, so they follow the same settings as the parent's.
|
||||
...(parentAgent.proxyEnv ? { proxyEnv: parentAgent.proxyEnv } : {}),
|
||||
})
|
||||
: parentAgent;
|
||||
// The child Session follows the PARENT Session, never the Project default: with the
|
||||
@@ -744,7 +745,7 @@ export class Agent {
|
||||
),
|
||||
services: { subagentRunner, ...(visionDescriber ? { visionDescriber } : {}) },
|
||||
...(Object.keys(vault).length > 0 ? { vault } : {}),
|
||||
...(this.stripProxyEnv ? { stripProxyEnv: this.stripProxyEnv } : {}),
|
||||
...(this.proxyEnv ? { proxyEnv: this.proxyEnv } : {}),
|
||||
});
|
||||
const tools = await environment.listTools();
|
||||
|
||||
|
||||
@@ -110,10 +110,10 @@ export class Environment implements EnvironmentInterface {
|
||||
// The background session registry is created alongside Environment (one per Session) and
|
||||
// injected into whichever tools need it; all sessions are finalized together on dispose.
|
||||
// The vault environment variables are injected into child processes by the command session
|
||||
// registry at spawn time (which also strips the proxy variables while stripProxyEnv says so).
|
||||
// registry at spawn time (which also applies the proxyEnv policy — strip or inject).
|
||||
this.commandSessions = new CommandSessionManager({
|
||||
...(config.vault !== undefined ? { vault: config.vault } : {}),
|
||||
...(config.stripProxyEnv !== undefined ? { stripProxyEnv: config.stripProxyEnv } : {}),
|
||||
...(config.proxyEnv !== undefined ? { proxyEnv: config.proxyEnv } : {}),
|
||||
});
|
||||
this.subagentSessions = new SubagentSessionManager();
|
||||
const services = {
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
*/
|
||||
import { ManagedSession } from "./session.js";
|
||||
import { BackgroundRegistry } from "../background/index.js";
|
||||
import type { ProxyEnvPolicy } from "../../../interfaces.js";
|
||||
|
||||
/** Concurrent managed-session cap: evicts once exceeded (exited sessions first, otherwise LRU — killing a background process has bounded cost). */
|
||||
const MAX_SESSIONS = 64;
|
||||
@@ -86,18 +87,26 @@ const STRIPPED_ENV_KEYS = new Set([
|
||||
]);
|
||||
|
||||
/**
|
||||
* Proxy variables removed IN ADDITION when the host asks for it (`stripProxyEnv`, see
|
||||
* {@link CommandSessionManager}): the Web server's "use system HTTP proxy" switch in the
|
||||
* off state must keep commands from inheriting the serving process's proxy environment
|
||||
* (design § "出网与系统代理"). NO_PROXY is deliberately NOT stripped — with no proxy
|
||||
* variables left it is inert, and removing it would change behavior for commands that
|
||||
* set their own proxy. Matched case-insensitively like {@link STRIPPED_ENV_KEYS}, which
|
||||
* also covers the conventional lowercase spellings (http_proxy etc.).
|
||||
* Proxy variables removed IN ADDITION when the host supplies a proxy policy (`proxyEnv`,
|
||||
* see {@link CommandSessionManager}): the Web server's proxy settings must keep commands
|
||||
* from just inheriting the serving process's proxy environment.
|
||||
* In `strip` mode NO_PROXY is deliberately NOT removed — with no proxy variables left it
|
||||
* is inert, and removing it would change behavior for commands that set their own proxy.
|
||||
* In `inject` mode the inherited NO_PROXY is replaced too (the policy carries the merged
|
||||
* list), and ALL_PROXY stays removed rather than replaced: the explicit app-level proxy
|
||||
* outranks ambient env wholesale. Matched case-insensitively like
|
||||
* {@link STRIPPED_ENV_KEYS}, which also covers the conventional lowercase spellings
|
||||
* (http_proxy etc.).
|
||||
*/
|
||||
const PROXY_ENV_KEYS = new Set(["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY"]);
|
||||
|
||||
/** The host environment minus {@link STRIPPED_ENV_KEYS} (and {@link PROXY_ENV_KEYS} when asked). */
|
||||
function hostEnvForChild(stripProxy: boolean): NodeJS.ProcessEnv {
|
||||
/**
|
||||
* The host environment minus {@link STRIPPED_ENV_KEYS}, with the proxy policy applied:
|
||||
* `strip` removes {@link PROXY_ENV_KEYS}; `inject` additionally replaces NO_PROXY and
|
||||
* sets the explicit proxy variables (both spellings — programs disagree on which they
|
||||
* read); null passes the proxy variables through untouched.
|
||||
*/
|
||||
function hostEnvForChild(policy: ProxyEnvPolicy | null): NodeJS.ProcessEnv {
|
||||
const env: NodeJS.ProcessEnv = {};
|
||||
// Matched case-insensitively rather than deleting the upper-case spellings: Windows resolves
|
||||
// environment names without regard to case but stores whatever casing was written, so a
|
||||
@@ -106,9 +115,18 @@ function hostEnvForChild(stripProxy: boolean): NodeJS.ProcessEnv {
|
||||
for (const [key, value] of Object.entries(process.env)) {
|
||||
const name = key.toUpperCase();
|
||||
if (STRIPPED_ENV_KEYS.has(name)) continue;
|
||||
if (stripProxy && PROXY_ENV_KEYS.has(name)) continue;
|
||||
if (policy !== null && PROXY_ENV_KEYS.has(name)) continue;
|
||||
if (policy?.mode === "inject" && name === "NO_PROXY") continue;
|
||||
env[key] = value;
|
||||
}
|
||||
if (policy?.mode === "inject") {
|
||||
env.HTTP_PROXY = policy.url;
|
||||
env.http_proxy = policy.url;
|
||||
env.HTTPS_PROXY = policy.url;
|
||||
env.https_proxy = policy.url;
|
||||
env.NO_PROXY = policy.noProxy;
|
||||
env.no_proxy = policy.noProxy;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
@@ -121,16 +139,17 @@ export class CommandSessionManager {
|
||||
/** Agent vault environment variables: injected into the child process on every spawn (values never enter the model context, only the environment). */
|
||||
private readonly vault: Record<string, string>;
|
||||
/**
|
||||
* When it returns true, {@link PROXY_ENV_KEYS} are removed from the child environment
|
||||
* too. A getter rather than a boolean: the hosting server's "use system HTTP proxy"
|
||||
* switch is toggled at runtime, and re-reading at every spawn makes the toggle reach
|
||||
* Sessions that are already running. Absent = proxy allowed (SDK/CLI standalone use).
|
||||
* Proxy policy for the child environment (see {@link ProxyEnvPolicy}: strip the proxy
|
||||
* variables, inject an explicit proxy over the inherited ones, or null = pass
|
||||
* through). A getter rather than a snapshot: the hosting server's proxy settings
|
||||
* change at runtime, and re-reading at every spawn makes a change reach Sessions that
|
||||
* are already running. Absent = pass through (SDK/CLI standalone use).
|
||||
*/
|
||||
private readonly stripProxyEnv: (() => boolean) | undefined;
|
||||
private readonly proxyEnv: (() => ProxyEnvPolicy | null) | undefined;
|
||||
|
||||
constructor(opts?: { vault?: Record<string, string>; stripProxyEnv?: () => boolean }) {
|
||||
constructor(opts?: { vault?: Record<string, string>; proxyEnv?: () => ProxyEnvPolicy | null }) {
|
||||
this.vault = opts?.vault ?? {};
|
||||
this.stripProxyEnv = opts?.stripProxyEnv;
|
||||
this.proxyEnv = opts?.proxyEnv;
|
||||
}
|
||||
|
||||
/** Starts a command, returning an **unregistered** session (no process_id yet). */
|
||||
@@ -144,10 +163,11 @@ export class CommandSessionManager {
|
||||
// Spread order is priority: vault overrides host variables of the same name, but must
|
||||
// come before HARDENED_ENV — the hardening entries (GIT_EDITOR/PAGER etc. that prevent
|
||||
// interactive hangs) must never be overridable by vault. The host side is stripped of
|
||||
// the harness's own variables first (see STRIPPED_ENV_KEYS; plus the proxy variables
|
||||
// when stripProxyEnv says so); the vault still wins, so a user who genuinely wants
|
||||
// PORT — or a proxy — in commands can set it there.
|
||||
env: { ...hostEnvForChild(this.stripProxyEnv?.() === true), ...this.vault, ...HARDENED_ENV },
|
||||
// the harness's own variables first (see STRIPPED_ENV_KEYS) and has the proxyEnv
|
||||
// policy applied (strip or inject); the vault still wins — over an injected proxy
|
||||
// too — so a user who genuinely wants PORT, or their own proxy, in commands can set
|
||||
// it there.
|
||||
env: { ...hostEnvForChild(this.proxyEnv?.() ?? null), ...this.vault, ...HARDENED_ENV },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -293,15 +293,32 @@ export interface EnvironmentConfig {
|
||||
*/
|
||||
vault?: Record<string, string>;
|
||||
/**
|
||||
* When it returns true, the proxy variables (HTTP_PROXY / HTTPS_PROXY / ALL_PROXY, any
|
||||
* casing; NO_PROXY is kept) are stripped from exec_command / input_command subprocess
|
||||
* environments. Threaded by the Web server from its admin-level "use system HTTP proxy"
|
||||
* switch (off state); re-read at every spawn so a toggle needs no restart. Absent =
|
||||
* proxy allowed (the default for SDK/CLI standalone use).
|
||||
* Proxy policy for exec_command / input_command subprocess environments (see
|
||||
* {@link ProxyEnvPolicy}). Threaded by the Web server from its admin-level proxy
|
||||
* settings; re-read at every spawn so a settings change needs no restart. Absent, or a
|
||||
* getter returning null = the host environment passes through unchanged (the default
|
||||
* for SDK/CLI standalone use).
|
||||
*/
|
||||
stripProxyEnv?: () => boolean;
|
||||
proxyEnv?: () => ProxyEnvPolicy | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy policy applied to command subprocess environments (see
|
||||
* {@link EnvironmentConfig.proxyEnv}):
|
||||
* - `{ mode: "strip" }` — the proxy variables (HTTP_PROXY / HTTPS_PROXY / ALL_PROXY, any
|
||||
* casing) are removed; NO_PROXY is kept (inert without them, and commands that set
|
||||
* their own proxy still honor it). The hosting server's proxy switch in the off state.
|
||||
* - `{ mode: "inject", url, noProxy }` — the explicit proxy wins over ambient env:
|
||||
* HTTP_PROXY / HTTPS_PROXY (plus their lowercase twins) are set to `url` and
|
||||
* NO_PROXY / no_proxy to `noProxy`, overriding inherited values; an inherited
|
||||
* ALL_PROXY (any casing) is removed for the same reason. The caller supplies `noProxy`
|
||||
* pre-merged (the hosting server includes the loopback names).
|
||||
* - `null` (or no getter at all) — pass through unchanged.
|
||||
* The Agent vault still overrides whichever of these the policy produced: a per-Agent
|
||||
* explicit variable outranks the host-level policy.
|
||||
*/
|
||||
export type ProxyEnvPolicy = { mode: "strip" } | { mode: "inject"; url: string; noProxy: string };
|
||||
|
||||
/**
|
||||
* An approved tool-call execution request.
|
||||
* Docs: /docs/interfaces § "ToolExecutionRequest and EnvironmentConfig".
|
||||
|
||||
@@ -8,7 +8,7 @@ import path from "node:path";
|
||||
import { Environment, ManagedSession } from "../src/environment/index.js";
|
||||
import { toolCall } from "../src/omnimessage/index.js";
|
||||
import type { OmniMessage } from "../src/omnimessage/index.js";
|
||||
import type { ToolConfig, ToolDefinitionConfig } from "../src/interfaces.js";
|
||||
import type { ProxyEnvPolicy, ToolConfig, ToolDefinitionConfig } from "../src/interfaces.js";
|
||||
|
||||
function execTool(overrides: Partial<ToolDefinitionConfig> = {}): ToolDefinitionConfig {
|
||||
return {
|
||||
@@ -313,7 +313,7 @@ describe("harness environment variables never reach a spawned command", () => {
|
||||
process.env.HOST = "127.0.0.1";
|
||||
process.env.PENGUIN_CLI_ENTRY = "/opt/penguin/lib/dist/index.js";
|
||||
process.env.PENGUIN_WEB_DIST = "/opt/penguin/web";
|
||||
// The desktop shell's process credentials (see design § "桌面端原型"): a leaked token
|
||||
// The desktop shell's process credentials: a leaked token
|
||||
// would let an Agent-run command call the server's shutdown endpoint.
|
||||
process.env.PENGUIN_DESKTOP_TOKEN = "secret-desktop-token";
|
||||
process.env.PENGUIN_PORT_FILE = "/tmp/port-file";
|
||||
@@ -407,14 +407,21 @@ describe("harness environment variables never reach a spawned command", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("proxy variables are stripped from commands when the host opts out", () => {
|
||||
// The Web server's "use system HTTP proxy" switch (off state) threads stripProxyEnv
|
||||
// through Agent -> Environment -> CommandSessionManager; standalone Environments (no
|
||||
// getter) keep the historical pass-through.
|
||||
describe("proxyEnv policy governs the proxy variables commands inherit", () => {
|
||||
// The Web server's proxy settings thread a ProxyEnvPolicy getter through
|
||||
// Agent -> Environment -> CommandSessionManager: strip (switch off), inject (explicit
|
||||
// address), or null (passthrough); standalone Environments (no getter) keep the
|
||||
// historical pass-through.
|
||||
const KEYS = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY"] as const;
|
||||
const saved: Partial<Record<(typeof KEYS)[number], string | undefined>> = {};
|
||||
let strip = true;
|
||||
let stripEnv: Environment;
|
||||
let policy: ProxyEnvPolicy | null = null;
|
||||
let policyEnv: Environment;
|
||||
|
||||
const INJECT: ProxyEnvPolicy = {
|
||||
mode: "inject",
|
||||
url: "http://explicit.example:3128",
|
||||
noProxy: "corp.example,localhost,127.0.0.1,::1",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
for (const k of KEYS) saved[k] = process.env[k];
|
||||
@@ -422,24 +429,24 @@ describe("proxy variables are stripped from commands when the host opts out", ()
|
||||
process.env.HTTPS_PROXY = "http://proxy.corp.example:8443";
|
||||
process.env.ALL_PROXY = "socks5://proxy.corp.example:1080";
|
||||
process.env.NO_PROXY = "localhost,127.0.0.1,::1";
|
||||
strip = true;
|
||||
stripEnv = new Environment({
|
||||
policy = { mode: "strip" };
|
||||
policyEnv = new Environment({
|
||||
workspaceDir: tmp,
|
||||
toolConfig: sessionConfig(),
|
||||
stripProxyEnv: () => strip,
|
||||
proxyEnv: () => policy,
|
||||
});
|
||||
});
|
||||
afterEach(() => {
|
||||
stripEnv.dispose();
|
||||
policyEnv.dispose();
|
||||
for (const k of KEYS) {
|
||||
if (saved[k] === undefined) delete process.env[k];
|
||||
else process.env[k] = saved[k];
|
||||
}
|
||||
});
|
||||
|
||||
it("HTTP(S)_PROXY/ALL_PROXY are removed, NO_PROXY stays (inert without them)", async () => {
|
||||
it("strip: HTTP(S)_PROXY/ALL_PROXY are removed, NO_PROXY stays (inert without them)", async () => {
|
||||
const READ = KEYS.map((k) => `${k}=[' + (process.env.${k} ?? '') + ']`).join(" ");
|
||||
const res = await runTool(stripEnv, "exec_command", {
|
||||
const res = await runTool(policyEnv, "exec_command", {
|
||||
cmd: `node -e "console.log('${READ}')"`,
|
||||
});
|
||||
expect(res.output).toContain(
|
||||
@@ -447,10 +454,10 @@ describe("proxy variables are stripped from commands when the host opts out", ()
|
||||
);
|
||||
});
|
||||
|
||||
it("a lowercase spelling is stripped too (the conventional POSIX form)", async () => {
|
||||
it("strip: a lowercase spelling is stripped too (the conventional POSIX form)", async () => {
|
||||
process.env.https_proxy = "http://proxy.corp.example:8443";
|
||||
try {
|
||||
const res = await runTool(stripEnv, "exec_command", {
|
||||
const res = await runTool(policyEnv, "exec_command", {
|
||||
cmd: `node -e "console.log('s=[' + (process.env.https_proxy ?? '') + ']')"`,
|
||||
});
|
||||
expect(res.output).toContain("s=[]");
|
||||
@@ -459,9 +466,54 @@ describe("proxy variables are stripped from commands when the host opts out", ()
|
||||
}
|
||||
});
|
||||
|
||||
it("the getter is re-read at every spawn, so a live toggle needs no new Environment", async () => {
|
||||
strip = false;
|
||||
const res = await runTool(stripEnv, "exec_command", {
|
||||
it("inject: the explicit proxy overrides the inherited variables, both spellings", async () => {
|
||||
policy = INJECT;
|
||||
const READ =
|
||||
"H=[' + (process.env.HTTP_PROXY ?? '') + '] h=[' + (process.env.http_proxy ?? '') + '] " +
|
||||
"S=[' + (process.env.HTTPS_PROXY ?? '') + '] s=[' + (process.env.https_proxy ?? '') + ']";
|
||||
const res = await runTool(policyEnv, "exec_command", {
|
||||
cmd: `node -e "console.log('${READ}')"`,
|
||||
});
|
||||
expect(res.output).toContain(
|
||||
"H=[http://explicit.example:3128] h=[http://explicit.example:3128] " +
|
||||
"S=[http://explicit.example:3128] s=[http://explicit.example:3128]",
|
||||
);
|
||||
});
|
||||
|
||||
it("inject: NO_PROXY is replaced with the policy's merged list and ALL_PROXY is removed", async () => {
|
||||
policy = INJECT;
|
||||
const READ =
|
||||
"N=[' + (process.env.NO_PROXY ?? '') + '] n=[' + (process.env.no_proxy ?? '') + '] " +
|
||||
"A=[' + (process.env.ALL_PROXY ?? '') + ']";
|
||||
const res = await runTool(policyEnv, "exec_command", {
|
||||
cmd: `node -e "console.log('${READ}')"`,
|
||||
});
|
||||
expect(res.output).toContain(
|
||||
"N=[corp.example,localhost,127.0.0.1,::1] n=[corp.example,localhost,127.0.0.1,::1] A=[]",
|
||||
);
|
||||
});
|
||||
|
||||
it("inject: the vault still wins — a per-Agent proxy outranks the injected one", async () => {
|
||||
policy = INJECT;
|
||||
const vaultEnv = new Environment({
|
||||
workspaceDir: tmp,
|
||||
toolConfig: sessionConfig(),
|
||||
vault: { HTTP_PROXY: "http://vault.example:9999" },
|
||||
proxyEnv: () => policy,
|
||||
});
|
||||
try {
|
||||
const res = await runTool(vaultEnv, "exec_command", {
|
||||
cmd: `node -e "console.log('H=[' + (process.env.HTTP_PROXY ?? '') + ']')"`,
|
||||
});
|
||||
expect(res.output).toContain("H=[http://vault.example:9999]");
|
||||
} finally {
|
||||
vaultEnv.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("the getter is re-read at every spawn, so a live settings change needs no new Environment", async () => {
|
||||
policy = null;
|
||||
const res = await runTool(policyEnv, "exec_command", {
|
||||
cmd: `node -e "console.log('H=[' + (process.env.HTTP_PROXY ?? '') + ']')"`,
|
||||
});
|
||||
expect(res.output).toContain("H=[http://proxy.corp.example:8080]");
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
/**
|
||||
* OS-proxy resolution for the embedded server (design § "出网与系统代理"): on the
|
||||
* desktop, "system proxy" means the real operating-system setting. The shell resolves
|
||||
* OS-proxy resolution for the embedded server: on the desktop, "system proxy" means
|
||||
* the real operating-system setting. The shell resolves
|
||||
* it through Electron's resolveProxy when forking the server and injects the result
|
||||
* into the child environment as HTTP_PROXY / HTTPS_PROXY — the server's dispatcher
|
||||
* (and, while the admin switch is on, agent commands) then honor it. Variables already
|
||||
* present in the shell's environment are never overridden (same idiom as
|
||||
* bundledShellEnv): an explicitly configured environment wins over the OS lookup.
|
||||
* (while the admin app-proxy switch is on) and agent commands (while the agent-proxy
|
||||
* switch is on) then honor it. Variables already present in the shell's environment are
|
||||
* never overridden (same idiom as bundledShellEnv): an explicitly configured
|
||||
* environment wins over the OS lookup. An admin-configured explicit proxy address
|
||||
* (server_settings `proxyUrl`) in turn wins over BOTH at the server's dispatcher and in
|
||||
* agent command env injection — what this module injects is only the "follow the system
|
||||
* proxy" default the server falls back to.
|
||||
*
|
||||
* Only the entry point touches Electron, behind a dynamic import; the parsing and the
|
||||
* override check stay pure so they unit-test without an Electron runtime.
|
||||
|
||||
@@ -127,9 +127,14 @@ interface EnvironmentConfig {
|
||||
sessionScratchpadDir?: string; // this Session's scratchpad (scratchpad/<sessionId>); enables truncated-output recovery
|
||||
services?: EnvironmentServices; // runtime services injected into individual tools
|
||||
vault?: Record<string, string>; // Vault env vars, injected into exec_command / input_command subprocesses
|
||||
stripProxyEnv?: () => boolean; // true = strip HTTP(S)_PROXY/ALL_PROXY (NO_PROXY kept) from command subprocesses; re-read per spawn, absent = proxy allowed
|
||||
proxyEnv?: () => ProxyEnvPolicy | null; // command-subprocess proxy policy; re-read per spawn, absent or null = pass through
|
||||
}
|
||||
|
||||
// "strip" removes HTTP(S)_PROXY/ALL_PROXY (NO_PROXY kept); "inject" forces the explicit
|
||||
// proxy over the inherited env: HTTP(S)_PROXY (+ lowercase twins) = url, NO_PROXY = noProxy
|
||||
// (supplied pre-merged by the caller), inherited ALL_PROXY removed. Vault entries still win.
|
||||
type ProxyEnvPolicy = { mode: "strip" } | { mode: "inject"; url: string; noProxy: string };
|
||||
|
||||
interface EnvironmentServices {
|
||||
subagentRunner?: SubagentRunner; // needed by run_subagent
|
||||
visionDescriber?: VisionDescriberService; // needed by describe_image on text-only models
|
||||
|
||||
@@ -127,9 +127,14 @@ interface EnvironmentConfig {
|
||||
sessionScratchpadDir?: string; // 本 Session 的 scratchpad(scratchpad/<sessionId>),提供后启用截断输出恢复
|
||||
services?: EnvironmentServices; // 注入给个别工具的运行时服务
|
||||
vault?: Record<string, string>; // Vault 环境变量,注入 exec_command / input_command 子进程
|
||||
stripProxyEnv?: () => boolean; // 返回 true 时从命令子进程剥除 HTTP(S)_PROXY/ALL_PROXY(保留 NO_PROXY);每次 spawn 重读,缺省即允许代理
|
||||
proxyEnv?: () => ProxyEnvPolicy | null; // 命令子进程代理策略;每次 spawn 重读,缺省或 null 即原样透传
|
||||
}
|
||||
|
||||
// "strip" 剥除 HTTP(S)_PROXY/ALL_PROXY(保留 NO_PROXY);"inject" 以显式代理覆盖继承环境:
|
||||
// HTTP(S)_PROXY(含小写拼写)= url、NO_PROXY = noProxy(由调用方预先合并),继承的 ALL_PROXY
|
||||
// 一并移除。Vault 条目仍然优先。
|
||||
type ProxyEnvPolicy = { mode: "strip" } | { mode: "inject"; url: string; noProxy: string };
|
||||
|
||||
interface EnvironmentServices {
|
||||
subagentRunner?: SubagentRunner; // run_subagent 所需
|
||||
visionDescriber?: VisionDescriberService; // text-only 模型的 describe_image 所需
|
||||
|
||||
@@ -73,10 +73,16 @@ In desktop mode (the server spawned by the desktop app) the whole surface answer
|
||||
|
||||
| Method | Path | Description |
|
||||
| --- | --- | --- |
|
||||
| GET | /api/admin/settings | Server-global settings: `{settings: {useSystemProxy}}` |
|
||||
| GET | /api/admin/settings | Server-global settings: `{settings: {proxyForApp, proxyForAgent, proxyUrl}}` |
|
||||
| PUT | /api/admin/settings | Update settings (fields optional; omitted fields keep their current value), returns the full updated settings |
|
||||
|
||||
`useSystemProxy` is the "use system HTTP proxy" switch (default on): while on, the server and its child processes honor HTTP_PROXY / HTTPS_PROXY / NO_PROXY (both spellings) for outbound traffic; while off, the server always connects directly and the proxy variables are stripped from agent command subprocess environments (NO_PROXY is kept). In either state the effective NO_PROXY always includes `localhost,127.0.0.1,::1` (loopback is never proxied). Toggling takes effect for newly initiated connections immediately — no restart.
|
||||
The proxy settings are two independent switches sharing one optional explicit address; changes take effect for newly initiated connections/spawns immediately — no restart:
|
||||
|
||||
- `proxyForApp` ("application uses the proxy", default on) governs the server's own outbound traffic (LLM requests, the update check, image fetches): on with `proxyUrl` set → that address for both http and https, **taking precedence over the proxy environment variables** — no environment variable needs to be configured; on without an address → the environment variables HTTP_PROXY / HTTPS_PROXY / NO_PROXY (both spellings); off → always direct.
|
||||
- `proxyForAgent` ("agent environment uses the proxy", default on) governs agent command subprocess environments: on with `proxyUrl` set → `HTTP_PROXY` / `HTTPS_PROXY` (plus lowercase twins) are injected as that address together 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 is kept).
|
||||
- `proxyUrl` (default null = follow the environment variables) is the shared explicit address. Validation on PUT: the value is trimmed; empty or null clears the address; accepted forms are `http://host[:port]`, `https://host[:port]`, and bare `host[:port]` (normalized to `http://host[:port]` — only normalized values are stored, and the response echoes the stored form); anything else is `400` with code `invalid_proxy_url`, and the rejected PUT writes nothing.
|
||||
|
||||
In every on-state the effective NO_PROXY always includes `localhost,127.0.0.1,::1` (loopback is never proxied).
|
||||
|
||||
### Version and Self-Update
|
||||
|
||||
|
||||
@@ -73,10 +73,16 @@ curl -c cookies.txt -H "Content-Type: application/json" \
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| GET | /api/admin/settings | 服务端全局设置:`{settings: {useSystemProxy}}` |
|
||||
| GET | /api/admin/settings | 服务端全局设置:`{settings: {proxyForApp, proxyForAgent, proxyUrl}}` |
|
||||
| PUT | /api/admin/settings | 更新设置(字段可省略,省略即保持现值),返回更新后的完整设置 |
|
||||
|
||||
`useSystemProxy` 即「使用系统 HTTP 代理」开关(默认开):开时服务端及其子进程出网遵循 HTTP_PROXY / HTTPS_PROXY / NO_PROXY(大小写并存);关时服务端一律直连,并从 Agent 命令子进程环境中剥除代理变量(NO_PROXY 保留)。任一状态下生效的 NO_PROXY 恒包含 `localhost,127.0.0.1,::1`(回环不代理)。切换即时生效(对新发起的连接),无需重启。
|
||||
代理设置为两个独立开关共享一个可选的显式地址;修改即时生效(对新发起的连接与新派生的子进程),无需重启:
|
||||
|
||||
- `proxyForApp`(「应用程序使用代理」,默认开)治理服务端自身出网(LLM 请求、更新检查、图片抓取):开且填写了 `proxyUrl` → http 与 https 流量都走该地址,**优先于代理环境变量**——无需配置任何环境变量;开但未填地址 → 遵循 HTTP_PROXY / HTTPS_PROXY / NO_PROXY 环境变量(大小写并存);关 → 一律直连。
|
||||
- `proxyForAgent`(「Agent 环境使用代理」,默认开)治理 Agent 命令子进程环境:开且填写了 `proxyUrl` → 注入 `HTTP_PROXY` / `HTTPS_PROXY`(含小写拼写)为该地址并附合并后的 NO_PROXY,覆盖继承值;开但未填地址 → 宿主环境原样透传;关 → 剥除代理变量(NO_PROXY 保留)。
|
||||
- `proxyUrl`(默认 null = 跟随环境变量)即两者共享的显式地址。PUT 校验:先 trim;空串或 null 即清除地址;接受 `http://主机[:端口]`、`https://主机[:端口]` 与裸 `主机[:端口]`(规范化为 `http://主机[:端口]`——只存储规范化后的值,响应回显存储形态);其余一律 `400`,错误码 `invalid_proxy_url`,且被拒绝的 PUT 不写入任何字段。
|
||||
|
||||
任一开启状态下生效的 NO_PROXY 恒包含 `localhost,127.0.0.1,::1`(回环不代理)。
|
||||
|
||||
### 版本与在线更新
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
/**
|
||||
* Admin server-settings route tests: permission boundary (non-admin 403), the
|
||||
* "use system HTTP proxy" default (absent row reads as on), PUT persistence and
|
||||
* validation, and merge semantics (an omitted field keeps its current value).
|
||||
* Admin server-settings route tests: permission boundary (non-admin 403), the proxy
|
||||
* defaults (absent rows read as both switches on, no explicit address), adoption of the
|
||||
* legacy single-switch key, PUT persistence and validation (proxy-address normalization
|
||||
* and rejection), and merge semantics (an omitted field keeps its current value; a
|
||||
* rejected PUT writes nothing).
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import type { ServerSettingsResponse } from "../src/api/types.js";
|
||||
@@ -30,39 +32,135 @@ describe("admin server settings", () => {
|
||||
const { cookie } = await provisionUser(t.app, "norm");
|
||||
const api = apiClient(t.app, cookie);
|
||||
expect((await api.get("/api/admin/settings")).status).toBe(403);
|
||||
expect((await api.put("/api/admin/settings", { useSystemProxy: false })).status).toBe(403);
|
||||
expect((await api.put("/api/admin/settings", { proxyForApp: false })).status).toBe(403);
|
||||
// The failed PUT changed nothing.
|
||||
expect((await getSettings()).settings.useSystemProxy).toBe(true);
|
||||
expect((await getSettings()).settings.proxyForApp).toBe(true);
|
||||
});
|
||||
|
||||
it("useSystemProxy defaults to on while no row exists", async () => {
|
||||
it("defaults while no rows exist: both switches on, no explicit address", async () => {
|
||||
expect(t.deps.db.prepare("SELECT COUNT(*) AS n FROM server_settings").get()).toMatchObject({
|
||||
n: 0,
|
||||
});
|
||||
expect((await getSettings()).settings.useSystemProxy).toBe(true);
|
||||
const { settings } = await getSettings();
|
||||
expect(settings.proxyForApp).toBe(true);
|
||||
expect(settings.proxyForAgent).toBe(true);
|
||||
expect(settings.proxyUrl).toBeNull();
|
||||
});
|
||||
|
||||
it("PUT persists the toggle and echoes the full settings", async () => {
|
||||
const off = await admin.put("/api/admin/settings", { useSystemProxy: false });
|
||||
expect(off.status).toBe(200);
|
||||
expect(((await off.json()) as ServerSettingsResponse).settings.useSystemProxy).toBe(false);
|
||||
// Round-trips through the repo (the DB row, not process state, is what GET serves).
|
||||
expect((await getSettings()).settings.useSystemProxy).toBe(false);
|
||||
expect(t.deps.serverSettingsRepo.getUseSystemProxy()).toBe(false);
|
||||
it("adopts the legacy use_system_proxy row as the default for BOTH switches", async () => {
|
||||
// The single switch shipped only on unreleased main (#225): a deployment that had
|
||||
// toggled it off must keep that choice for both new switches, with no migration —
|
||||
// the legacy key is read as a fallback, never rewritten.
|
||||
t.deps.serverSettingsRepo.set("use_system_proxy", "false");
|
||||
let { settings } = await getSettings();
|
||||
expect(settings.proxyForApp).toBe(false);
|
||||
expect(settings.proxyForAgent).toBe(false);
|
||||
// Setting one NEW switch writes only its own key: the other still follows legacy.
|
||||
await admin.put("/api/admin/settings", { proxyForApp: true });
|
||||
({ settings } = await getSettings());
|
||||
expect(settings.proxyForApp).toBe(true);
|
||||
expect(settings.proxyForAgent).toBe(false);
|
||||
// The legacy row itself was never touched.
|
||||
expect(t.deps.serverSettingsRepo.get("use_system_proxy")).toBe("false");
|
||||
});
|
||||
|
||||
const on = await admin.put("/api/admin/settings", { useSystemProxy: true });
|
||||
expect(on.status).toBe(200);
|
||||
expect((await getSettings()).settings.useSystemProxy).toBe(true);
|
||||
it("PUT persists the switches independently and echoes the full settings", async () => {
|
||||
const appOff = await admin.put("/api/admin/settings", { proxyForApp: false });
|
||||
expect(appOff.status).toBe(200);
|
||||
const afterAppOff = ((await appOff.json()) as ServerSettingsResponse).settings;
|
||||
expect(afterAppOff.proxyForApp).toBe(false);
|
||||
expect(afterAppOff.proxyForAgent).toBe(true);
|
||||
// Round-trips through the repo (the DB rows, not process state, are what GET serves).
|
||||
expect(t.deps.serverSettingsRepo.getProxyForApp()).toBe(false);
|
||||
expect(t.deps.serverSettingsRepo.getProxyForAgent()).toBe(true);
|
||||
|
||||
const agentOff = await admin.put("/api/admin/settings", { proxyForAgent: false });
|
||||
expect(agentOff.status).toBe(200);
|
||||
const { settings } = await getSettings();
|
||||
expect(settings.proxyForApp).toBe(false);
|
||||
expect(settings.proxyForAgent).toBe(false);
|
||||
});
|
||||
|
||||
it("an omitted field keeps its current value; a non-boolean is 400", async () => {
|
||||
await admin.put("/api/admin/settings", { useSystemProxy: false });
|
||||
await admin.put("/api/admin/settings", { proxyForApp: false });
|
||||
// Empty PUT: no-op, still returns the current settings.
|
||||
const noop = await admin.put("/api/admin/settings", {});
|
||||
expect(noop.status).toBe(200);
|
||||
expect(((await noop.json()) as ServerSettingsResponse).settings.useSystemProxy).toBe(false);
|
||||
expect(((await noop.json()) as ServerSettingsResponse).settings.proxyForApp).toBe(false);
|
||||
// Type check: only booleans are accepted, and a rejected write changes nothing.
|
||||
expect((await admin.put("/api/admin/settings", { useSystemProxy: "on" })).status).toBe(400);
|
||||
expect((await getSettings()).settings.useSystemProxy).toBe(false);
|
||||
expect((await admin.put("/api/admin/settings", { proxyForApp: "on" })).status).toBe(400);
|
||||
expect((await admin.put("/api/admin/settings", { proxyForAgent: 1 })).status).toBe(400);
|
||||
const { settings } = await getSettings();
|
||||
expect(settings.proxyForApp).toBe(false);
|
||||
expect(settings.proxyForAgent).toBe(true);
|
||||
});
|
||||
|
||||
const putProxyUrl = async (proxyUrl: unknown) => admin.put("/api/admin/settings", { proxyUrl });
|
||||
|
||||
it("PUT normalizes the proxy address and stores/echoes only the normalized form", async () => {
|
||||
// Bare host[:port] shorthand → http://.
|
||||
const bare = await putProxyUrl("proxy.corp.example:8080");
|
||||
expect(bare.status).toBe(200);
|
||||
expect(((await bare.json()) as ServerSettingsResponse).settings.proxyUrl).toBe(
|
||||
"http://proxy.corp.example:8080",
|
||||
);
|
||||
expect((await getSettings()).settings.proxyUrl).toBe("http://proxy.corp.example:8080");
|
||||
expect(t.deps.serverSettingsRepo.getProxyUrl()).toBe("http://proxy.corp.example:8080");
|
||||
// https passes through; surrounding whitespace is trimmed.
|
||||
const https = await putProxyUrl(" https://proxy.corp.example:3128 ");
|
||||
expect(https.status).toBe(200);
|
||||
expect(((await https.json()) as ServerSettingsResponse).settings.proxyUrl).toBe(
|
||||
"https://proxy.corp.example:3128",
|
||||
);
|
||||
});
|
||||
|
||||
it("PUT rejects a bad proxy address with invalid_proxy_url and stores nothing", async () => {
|
||||
await putProxyUrl("http://proxy.corp.example:8080");
|
||||
for (const bad of ["socks5://proxy.corp.example:1080", "not a proxy", 42]) {
|
||||
const res = await putProxyUrl(bad);
|
||||
expect(res.status).toBe(400);
|
||||
const body = (await res.json()) as { error: { code: string } };
|
||||
expect(body.error.code).toBe("invalid_proxy_url");
|
||||
}
|
||||
expect((await getSettings()).settings.proxyUrl).toBe("http://proxy.corp.example:8080");
|
||||
});
|
||||
|
||||
it("a rejected combined PUT leaves the other fields untouched too", async () => {
|
||||
// Validation happens before any write: the valid switch halves of a PUT whose
|
||||
// proxyUrl is garbage must not land either — the PUT is atomic.
|
||||
const res = await admin.put("/api/admin/settings", {
|
||||
proxyForApp: false,
|
||||
proxyForAgent: false,
|
||||
proxyUrl: "not a proxy",
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
const { settings } = await getSettings();
|
||||
expect(settings.proxyForApp).toBe(true);
|
||||
expect(settings.proxyForAgent).toBe(true);
|
||||
expect(settings.proxyUrl).toBeNull();
|
||||
});
|
||||
|
||||
it("empty string and null both clear the address back to follow-the-environment", async () => {
|
||||
for (const clear of ["", " ", null]) {
|
||||
await putProxyUrl("http://proxy.corp.example:8080");
|
||||
const res = await putProxyUrl(clear);
|
||||
expect(res.status).toBe(200);
|
||||
expect(((await res.json()) as ServerSettingsResponse).settings.proxyUrl).toBeNull();
|
||||
expect(t.deps.serverSettingsRepo.getProxyUrl()).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("partial PUTs keep the other fields: address-only keeps the switches and vice versa", async () => {
|
||||
await admin.put("/api/admin/settings", { proxyForApp: false, proxyForAgent: false });
|
||||
await putProxyUrl("proxy.corp.example:8080");
|
||||
let { settings } = await getSettings();
|
||||
expect(settings.proxyForApp).toBe(false);
|
||||
expect(settings.proxyForAgent).toBe(false);
|
||||
expect(settings.proxyUrl).toBe("http://proxy.corp.example:8080");
|
||||
await admin.put("/api/admin/settings", { proxyForApp: true, proxyForAgent: true });
|
||||
({ settings } = await getSettings());
|
||||
expect(settings.proxyForApp).toBe(true);
|
||||
expect(settings.proxyForAgent).toBe(true);
|
||||
expect(settings.proxyUrl).toBe("http://proxy.corp.example:8080");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
/**
|
||||
* Unit tests for the outbound-proxy module's pure parts: the NO_PROXY loopback merge
|
||||
* and the dispatcher choice per switch state. No real network and no global install —
|
||||
* installGlobalProxyDispatcher (fetch replacement) runs only in the production entry.
|
||||
* Unit tests for the outbound-proxy module's pure parts — the NO_PROXY loopback merge,
|
||||
* proxy-address normalization, and the dispatcher choice per settings state — plus one
|
||||
* behavioral check that an explicit address actually routes traffic through that proxy
|
||||
* while loopback stays direct. No global install — installGlobalProxyDispatcher (fetch
|
||||
* replacement) runs only in the production entry.
|
||||
*/
|
||||
import http from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Agent, EnvHttpProxyAgent } from "undici";
|
||||
import { buildProxyDispatcher, mergedNoProxy } from "../src/net/proxy.js";
|
||||
import { Agent, EnvHttpProxyAgent, fetch as undiciFetch } from "undici";
|
||||
import {
|
||||
buildProxyDispatcher,
|
||||
envProxyAgentOptions,
|
||||
mergedNoProxy,
|
||||
normalizeProxyUrl,
|
||||
} from "../src/net/proxy.js";
|
||||
|
||||
describe("mergedNoProxy", () => {
|
||||
it("yields exactly the loopback names when the environment has no NO_PROXY", () => {
|
||||
@@ -35,17 +44,147 @@ describe("mergedNoProxy", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeProxyUrl", () => {
|
||||
it("passes canonical http/https addresses through", () => {
|
||||
expect(normalizeProxyUrl("http://proxy.corp.example:8080")).toBe(
|
||||
"http://proxy.corp.example:8080",
|
||||
);
|
||||
expect(normalizeProxyUrl("https://proxy.corp.example:3128")).toBe(
|
||||
"https://proxy.corp.example:3128",
|
||||
);
|
||||
expect(normalizeProxyUrl("http://proxy.corp.example")).toBe("http://proxy.corp.example");
|
||||
});
|
||||
|
||||
it("normalizes the bare host[:port] shorthand to http://", () => {
|
||||
expect(normalizeProxyUrl("proxy.corp.example:8080")).toBe("http://proxy.corp.example:8080");
|
||||
expect(normalizeProxyUrl("proxy.corp.example")).toBe("http://proxy.corp.example");
|
||||
expect(normalizeProxyUrl("127.0.0.1:7890")).toBe("http://127.0.0.1:7890");
|
||||
});
|
||||
|
||||
it("canonicalizes case, surrounding whitespace, trailing slash, and default ports", () => {
|
||||
expect(normalizeProxyUrl(" http://proxy.corp.example:8080 ")).toBe(
|
||||
"http://proxy.corp.example:8080",
|
||||
);
|
||||
expect(normalizeProxyUrl("HTTP://PROXY.CORP.EXAMPLE:8080")).toBe(
|
||||
"http://proxy.corp.example:8080",
|
||||
);
|
||||
expect(normalizeProxyUrl("http://proxy.corp.example:8080/")).toBe(
|
||||
"http://proxy.corp.example:8080",
|
||||
);
|
||||
// URL's canonical form drops a scheme-default port; the connection is identical.
|
||||
expect(normalizeProxyUrl("http://proxy.corp.example:80")).toBe("http://proxy.corp.example");
|
||||
});
|
||||
|
||||
it("keeps IPv6 literals bracketed", () => {
|
||||
expect(normalizeProxyUrl("http://[::1]:8080")).toBe("http://[::1]:8080");
|
||||
});
|
||||
|
||||
it("rejects everything that is not an http(s) host[:port]", () => {
|
||||
// Other schemes: the dispatcher speaks only HTTP(S) proxies.
|
||||
expect(normalizeProxyUrl("socks5://proxy.corp.example:1080")).toBeNull();
|
||||
expect(normalizeProxyUrl("ftp://proxy.corp.example")).toBeNull();
|
||||
// Credentials, paths, queries, fragments: a proxy address is host-only.
|
||||
expect(normalizeProxyUrl("http://user:pass@proxy.corp.example:8080")).toBeNull();
|
||||
expect(normalizeProxyUrl("http://proxy.corp.example:8080/path")).toBeNull();
|
||||
expect(normalizeProxyUrl("http://proxy.corp.example:8080?x=1")).toBeNull();
|
||||
expect(normalizeProxyUrl("http://proxy.corp.example:8080#frag")).toBeNull();
|
||||
// Unparseable shapes.
|
||||
expect(normalizeProxyUrl("http://")).toBeNull();
|
||||
expect(normalizeProxyUrl("not a proxy")).toBeNull();
|
||||
expect(normalizeProxyUrl("proxy.corp.example:port")).toBeNull();
|
||||
expect(normalizeProxyUrl("")).toBeNull();
|
||||
expect(normalizeProxyUrl(" ")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("envProxyAgentOptions", () => {
|
||||
it("without an explicit address: only the merged NO_PROXY (env drives the proxies)", () => {
|
||||
expect(envProxyAgentOptions(null, { NO_PROXY: "corp.example" })).toEqual({
|
||||
noProxy: "corp.example,localhost,127.0.0.1,::1",
|
||||
});
|
||||
});
|
||||
|
||||
it("with an explicit address: both httpProxy and httpsProxy pinned to it, NO_PROXY still merged", () => {
|
||||
// httpProxy/httpsProxy take precedence over the environment inside undici's
|
||||
// EnvHttpProxyAgent (its constructor reads `httpProxy ?? env...`), so the ambient
|
||||
// variables in `env` must not leak into the options this module assembles.
|
||||
expect(
|
||||
envProxyAgentOptions("http://explicit.example:3128", {
|
||||
HTTP_PROXY: "http://ambient.example:8080",
|
||||
NO_PROXY: "corp.example",
|
||||
}),
|
||||
).toEqual({
|
||||
httpProxy: "http://explicit.example:3128",
|
||||
httpsProxy: "http://explicit.example:3128",
|
||||
noProxy: "corp.example,localhost,127.0.0.1,::1",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildProxyDispatcher", () => {
|
||||
it("on → EnvHttpProxyAgent (proxy env honored), off → plain Agent (direct)", async () => {
|
||||
const on = buildProxyDispatcher(true, {});
|
||||
const off = buildProxyDispatcher(false, {});
|
||||
it("app switch off → plain Agent; on → EnvHttpProxyAgent, with or without an explicit address", async () => {
|
||||
const off = buildProxyDispatcher({ proxyForApp: false, proxyUrl: null }, {});
|
||||
const onEnv = buildProxyDispatcher({ proxyForApp: true, proxyUrl: null }, {});
|
||||
const onExplicit = buildProxyDispatcher(
|
||||
{ proxyForApp: true, proxyUrl: "http://explicit.example:3128" },
|
||||
{},
|
||||
);
|
||||
try {
|
||||
expect(on).toBeInstanceOf(EnvHttpProxyAgent);
|
||||
expect(off).toBeInstanceOf(Agent);
|
||||
expect(off).not.toBeInstanceOf(EnvHttpProxyAgent);
|
||||
expect(onEnv).toBeInstanceOf(EnvHttpProxyAgent);
|
||||
expect(onExplicit).toBeInstanceOf(EnvHttpProxyAgent);
|
||||
} finally {
|
||||
await on.close();
|
||||
await off.close();
|
||||
await onEnv.close();
|
||||
await onExplicit.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("explicit address: traffic tunnels through that proxy, loopback bypasses it", async () => {
|
||||
// The fake proxy answers CONNECT and then plays the tunneled origin itself, so a
|
||||
// request to an unresolvable name can only succeed by traveling through the proxy.
|
||||
const connects: string[] = [];
|
||||
const proxy = http.createServer((_req, res) => res.end());
|
||||
proxy.on("connect", (req, socket) => {
|
||||
connects.push(req.url ?? "");
|
||||
socket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
|
||||
let buffered = "";
|
||||
socket.on("data", (chunk: Buffer) => {
|
||||
buffered += chunk.toString("utf8");
|
||||
if (!buffered.includes("\r\n\r\n")) return; // wait for the full tunneled request head
|
||||
socket.end(
|
||||
"HTTP/1.1 200 OK\r\ncontent-type: text/plain\r\ncontent-length: 9\r\nconnection: close\r\n\r\nvia-proxy",
|
||||
);
|
||||
});
|
||||
});
|
||||
const direct = http.createServer((_req, res) => res.end("direct"));
|
||||
const listen = (server: http.Server) =>
|
||||
new Promise<number>((resolve) => {
|
||||
// No hostname: dual-stack, so the localhost fetch below works whether the
|
||||
// resolver picks ::1 or 127.0.0.1 first.
|
||||
server.listen(0, () => resolve((server.address() as AddressInfo).port));
|
||||
});
|
||||
const proxyPort = await listen(proxy);
|
||||
const directPort = await listen(direct);
|
||||
// Empty env: no proxy variables — reaching the fake proxy proves the explicit URL
|
||||
// is honored on its own, and the loopback exemption proves the merge alone (not an
|
||||
// ambient NO_PROXY) produced it.
|
||||
const dispatcher = buildProxyDispatcher(
|
||||
{ proxyForApp: true, proxyUrl: `http://127.0.0.1:${proxyPort}` },
|
||||
{},
|
||||
);
|
||||
try {
|
||||
const viaProxy = await undiciFetch("http://proxied.invalid/x", { dispatcher });
|
||||
expect(await viaProxy.text()).toBe("via-proxy");
|
||||
expect(connects).toEqual(["proxied.invalid:80"]);
|
||||
const directRes = await undiciFetch(`http://localhost:${directPort}/`, { dispatcher });
|
||||
expect(await directRes.text()).toBe("direct");
|
||||
expect(connects).toHaveLength(1); // the loopback request never touched the proxy
|
||||
} finally {
|
||||
await dispatcher.close();
|
||||
proxy.close();
|
||||
direct.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Admin proxy options dialog (server-global), opened from
|
||||
* the sidebar user menu. A form, not a live surface: two switches — "Application uses
|
||||
* the proxy" (the server's own outbound dispatcher) and "Agent environment uses the
|
||||
* proxy" (command subprocess environments) — share one proxy address, and nothing is
|
||||
* written until Save, which applies everything atomically via a single PUT. The server
|
||||
* validates and normalizes the address (bare "host:port" is stored as
|
||||
* "http://host:port"); a rejected address renders inline under the input (models-dialog
|
||||
* convention) and the atomic PUT writes nothing. Save with no modifications sends no
|
||||
* request and toasts "no changes" (the app's form convention); success
|
||||
* toasts and closes. Settings hydrate fresh on every open; the controls and Save stay
|
||||
* disabled until they arrive, and Cancel/Esc discards edits.
|
||||
*/
|
||||
import { useEffect, useState } from "react";
|
||||
import type { ServerSettings } from "@prismshadow/penguin-server/api";
|
||||
import * as api from "../../api/endpoints";
|
||||
import { ApiError } from "../../api/client";
|
||||
import { S } from "../../lib/strings";
|
||||
import { apiErrorText } from "../../lib/api-error";
|
||||
import { Button } from "../ui/button";
|
||||
import { Input } from "../ui/input";
|
||||
import { Modal } from "../ui/modal";
|
||||
import { Switch } from "../ui/switch";
|
||||
import { toastError, toastInfo, toastSuccess } from "../ui/toast";
|
||||
|
||||
export function ProxySettingsDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
/** Stored settings as hydrated on open (null until then) — the no-change baseline. */
|
||||
const [settings, setSettings] = useState<ServerSettings | null>(null);
|
||||
// Form drafts; the pre-hydration values mirror the server defaults (on, on, empty).
|
||||
const [proxyForApp, setProxyForApp] = useState(true);
|
||||
const [proxyForAgent, setProxyForAgent] = useState(true);
|
||||
const [proxyUrl, setProxyUrl] = useState("");
|
||||
/** Inline error under the address input (the server's invalid_proxy_url rejection). */
|
||||
const [addressError, setAddressError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let cancelled = false;
|
||||
setSettings(null);
|
||||
setAddressError(null);
|
||||
setBusy(false);
|
||||
void api
|
||||
.adminGetSettings()
|
||||
.then((res) => {
|
||||
if (cancelled) return;
|
||||
setSettings(res.settings);
|
||||
setProxyForApp(res.settings.proxyForApp);
|
||||
setProxyForAgent(res.settings.proxyForAgent);
|
||||
setProxyUrl(res.settings.proxyUrl ?? "");
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
// Controls stay disabled; closing and reopening retries the fetch.
|
||||
if (!cancelled) toastError(apiErrorText(e));
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const save = async () => {
|
||||
if (settings === null || busy) return;
|
||||
const unchanged =
|
||||
proxyForApp === settings.proxyForApp &&
|
||||
proxyForAgent === settings.proxyForAgent &&
|
||||
proxyUrl.trim() === (settings.proxyUrl ?? "");
|
||||
if (unchanged) {
|
||||
toastInfo(S.common.noChangesToSave);
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setAddressError(null);
|
||||
try {
|
||||
await api.adminPutSettings({ proxyForApp, proxyForAgent, proxyUrl });
|
||||
toastSuccess(S.common.saved);
|
||||
onClose();
|
||||
} catch (e) {
|
||||
// The address is the only validated field: its rejection renders inline;
|
||||
// anything else (auth, network) is a generic failure toast.
|
||||
if (e instanceof ApiError && e.code === "invalid_proxy_url") {
|
||||
setAddressError(apiErrorText(e));
|
||||
} else {
|
||||
toastError(apiErrorText(e));
|
||||
}
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const hydrated = settings !== null;
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title={S.settings.proxyDialogTitle}
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={onClose} disabled={busy}>
|
||||
{S.common.cancel}
|
||||
</Button>
|
||||
<Button variant="primary" disabled={!hydrated || busy} onClick={() => void save()}>
|
||||
{S.common.save}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-sm font-medium">{S.settings.proxyForApp}</span>
|
||||
<Switch checked={proxyForApp} onChange={setProxyForApp} disabled={!hydrated} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-sm font-medium">{S.settings.proxyForAgent}</span>
|
||||
<Switch checked={proxyForAgent} onChange={setProxyForAgent} disabled={!hydrated} />
|
||||
</div>
|
||||
<Input
|
||||
label={S.settings.proxyAddress}
|
||||
size="sm"
|
||||
value={proxyUrl}
|
||||
placeholder={S.settings.proxyAddressPlaceholder}
|
||||
disabled={!hydrated}
|
||||
{...(addressError !== null ? { error: addressError } : {})}
|
||||
onChange={(e) => {
|
||||
setProxyUrl(e.target.value);
|
||||
if (addressError !== null) setAddressError(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -75,6 +75,7 @@ import { DRAFT_SESSION_ID } from "../../features/chat/chat-page";
|
||||
import { clearDraft, sessionDraftKey } from "../../features/chat/draft-cache";
|
||||
import { CreateProjectDialog, ProjectSettingsDialog } from "./project-dialogs";
|
||||
import { ChangePasswordDialog } from "../account/change-password-dialog";
|
||||
import { ProxySettingsDialog } from "../account/proxy-settings-dialog";
|
||||
import { UpdateDialog } from "../account/update-dialog";
|
||||
import { forceUpdateCheck, updateCheckOutcome, useVersionInfo } from "../../lib/use-version-info";
|
||||
|
||||
@@ -235,34 +236,11 @@ export function Sidebar({
|
||||
}
|
||||
};
|
||||
/**
|
||||
* Admin-only "use system HTTP proxy" switch (server-global, design § "出网与系统代理"):
|
||||
* null = not hydrated yet. Fetched lazily the first time an ADMIN opens the dropdown —
|
||||
* same laziness as the version info above, and non-admins never call the endpoint (the
|
||||
* row is not rendered for them either). A failed hydration stays null (row disabled)
|
||||
* and is retried on the next open.
|
||||
* Admin-only server-global proxy settings dialog: the menu carries only the opener
|
||||
* row; the controls, their form semantics, and the open-time hydration all live in
|
||||
* ProxySettingsDialog.
|
||||
*/
|
||||
const [useSystemProxy, setUseSystemProxyState] = useState<boolean | null>(null);
|
||||
useEffect(() => {
|
||||
if (!userOpen || user?.isAdmin !== true || useSystemProxy !== null) return;
|
||||
let cancelled = false;
|
||||
void api
|
||||
.adminGetSettings()
|
||||
.then((res) => {
|
||||
if (!cancelled) setUseSystemProxyState(res.settings.useSystemProxy);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [userOpen, user?.isAdmin, useSystemProxy]);
|
||||
/** Saved immediately on toggle (the user-menu quick-control convention): optimistic flip, reverted with a toast on failure. */
|
||||
const setUseSystemProxy = (value: boolean) => {
|
||||
setUseSystemProxyState(value);
|
||||
void api.adminPutSettings({ useSystemProxy: value }).catch((e: unknown) => {
|
||||
setUseSystemProxyState(!value);
|
||||
toastError(apiErrorText(e));
|
||||
});
|
||||
};
|
||||
const [proxySettingsOpen, setProxySettingsOpen] = useState(false);
|
||||
const currentProjectId = currentProject?.projectId ?? null;
|
||||
const collapseStoreKey = currentProjectId === null ? null : collapsedGroupsKey(currentProjectId);
|
||||
const pinStoreKey = currentProjectId === null ? null : pinnedGroupsKey(currentProjectId);
|
||||
@@ -1034,19 +1012,6 @@ export function Sidebar({
|
||||
<SettingRow label={S.settings.showCliSessions}>
|
||||
<Switch checked={showCliSessions} onChange={setShowCliSessions} />
|
||||
</SettingRow>
|
||||
{/* Admin-only, server-global (all users), saved immediately on toggle; the
|
||||
tooltip spells out the scope and the loopback exemption. Disabled (showing
|
||||
the default: on) until the stored value has hydrated, so a click can never
|
||||
write a value the admin was not looking at. */}
|
||||
{user?.isAdmin && (
|
||||
<SettingRow label={S.settings.useSystemProxy} title={S.settings.useSystemProxyHint}>
|
||||
<Switch
|
||||
checked={useSystemProxy ?? true}
|
||||
onChange={setUseSystemProxy}
|
||||
disabled={useSystemProxy === null}
|
||||
/>
|
||||
</SettingRow>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 border-t border-gray-100 pt-1 dark:border-gray-800">
|
||||
<button
|
||||
@@ -1059,6 +1024,21 @@ export function Sidebar({
|
||||
>
|
||||
{S.account.changePassword}
|
||||
</button>
|
||||
{/* Admin-only, server-global proxy settings: one menu row opening the
|
||||
dialog (same idiom as Change password above) — the switch, address
|
||||
input and their live-save semantics live in ProxySettingsDialog. */}
|
||||
{user?.isAdmin && (
|
||||
<button
|
||||
type="button"
|
||||
className={menuItemClass}
|
||||
onClick={() => {
|
||||
setUserOpen(false);
|
||||
setProxySettingsOpen(true);
|
||||
}}
|
||||
>
|
||||
{S.settings.proxyMenu}
|
||||
</button>
|
||||
)}
|
||||
{/* THE update row — one button, two jobs, directly below Change password (owner
|
||||
layout: the menu used to stack a release-notes link, an admin "Update now" row
|
||||
and this check row on top of each other). It reads "Check for updates" and runs
|
||||
@@ -1156,6 +1136,7 @@ export function Sidebar({
|
||||
open={changePasswordOpen}
|
||||
onClose={() => setChangePasswordOpen(false)}
|
||||
/>
|
||||
<ProxySettingsDialog open={proxySettingsOpen} onClose={() => setProxySettingsOpen(false)} />
|
||||
<UpdateDialog
|
||||
open={updateDialogOpen}
|
||||
onClose={() => setUpdateDialogOpen(false)}
|
||||
@@ -1376,18 +1357,9 @@ function SessionRow({
|
||||
);
|
||||
}
|
||||
|
||||
function SettingRow({
|
||||
label,
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
/** Optional row tooltip (e.g. the system-proxy row's scope + loopback-exemption hint). */
|
||||
title?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
function SettingRow({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<div {...(title !== undefined ? { title } : {})}>
|
||||
<div>
|
||||
<p className="mb-1 text-[11px] font-medium text-gray-500 dark:text-gray-400">{label}</p>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -33,11 +33,15 @@ export const en: Strings = {
|
||||
language: "Language",
|
||||
/** Sidebar Session list: also show CLI-created Sessions (default off — the list then never scans the Trace directories). */
|
||||
showCliSessions: "Show CLI sessions",
|
||||
/** Admin-only server-global switch (design § "出网与系统代理"): saved immediately on toggle. */
|
||||
useSystemProxy: "Use system HTTP proxy",
|
||||
/** Row tooltip: scope (server + its child processes, server-wide) and the loopback exemption. */
|
||||
useSystemProxyHint:
|
||||
"Whether the server and its child processes (update checks, LLM requests, agent commands) reach the internet through the proxy named by HTTP_PROXY / HTTPS_PROXY. Applies server-wide; loopback addresses (localhost, 127.0.0.1, ::1) always connect directly. When off, the server connects directly and the proxy variables are removed from agent command subprocess environments.",
|
||||
/** Admin-only user-menu row opening the proxy options dialog. */
|
||||
proxyMenu: "Proxy options…",
|
||||
proxyDialogTitle: "Proxy options",
|
||||
/** The dialog's two switches: the server's own outbound traffic / agent command subprocess environments. */
|
||||
proxyForApp: "Application uses the proxy",
|
||||
proxyForAgent: "Agent environment uses the proxy",
|
||||
/** The shared explicit proxy address (empty = follow the proxy environment variables). */
|
||||
proxyAddress: "Proxy address",
|
||||
proxyAddressPlaceholder: "Empty = follow system proxy",
|
||||
theme: "Theme",
|
||||
themeLight: "Light",
|
||||
themeDark: "Dark",
|
||||
@@ -1175,6 +1179,8 @@ Scenarios:
|
||||
task_in_progress: "This Session already has a task running.",
|
||||
version_conflict: "The snapshot's version is not newer than the current one.",
|
||||
invalid_title: "The title is invalid.",
|
||||
invalid_proxy_url:
|
||||
"Invalid proxy address — use http://host[:port], https://host[:port], or host[:port].",
|
||||
invalid_trace: "This file is not a valid Trace file.",
|
||||
trace_session_exists:
|
||||
"This agent already has a Session with that id; a duplicate Trace cannot be imported.",
|
||||
|
||||
@@ -35,11 +35,15 @@ export const zh = {
|
||||
language: "语言",
|
||||
/** Sidebar Session list: also show CLI-created Sessions (default off — the list then never scans the Trace directories). */
|
||||
showCliSessions: "显示 CLI 会话",
|
||||
/** Admin-only server-global switch (design § "出网与系统代理"): saved immediately on toggle. */
|
||||
useSystemProxy: "使用系统 HTTP 代理",
|
||||
/** Row tooltip: scope (server + its child processes, server-wide) and the loopback exemption. */
|
||||
useSystemProxyHint:
|
||||
"服务端及其子进程(更新检查、LLM 请求、Agent 命令)出网时是否使用 HTTP_PROXY / HTTPS_PROXY 环境变量指定的代理,对整个服务端全局生效;回环地址(localhost、127.0.0.1、::1)始终直连。关闭后服务端一律直连,并从 Agent 命令子进程环境中移除代理变量。",
|
||||
/** Admin-only user-menu row opening the proxy options dialog. */
|
||||
proxyMenu: "代理选项",
|
||||
proxyDialogTitle: "代理选项",
|
||||
/** The dialog's two switches: the server's own outbound traffic / agent command subprocess environments. */
|
||||
proxyForApp: "应用程序使用代理",
|
||||
proxyForAgent: "Agent 环境使用代理",
|
||||
/** The shared explicit proxy address (empty = follow the proxy environment variables). */
|
||||
proxyAddress: "代理地址",
|
||||
proxyAddressPlaceholder: "留空 = 跟随系统代理",
|
||||
theme: "主题",
|
||||
themeLight: "浅色",
|
||||
themeDark: "深色",
|
||||
@@ -63,18 +67,20 @@ export const zh = {
|
||||
|
||||
/** Version footer, update reminder, and admin self-update in the sidebar user menu. */
|
||||
update: {
|
||||
/** Version-line date label (owner-specified wording); `date` is formatMonthDay output, e.g. 「最近更新日期 7 月 26 日」. */
|
||||
/** Version-line date label (owner-specified wording); `date` is formatMonthDay output. */
|
||||
lastUpdated: (date: string) => `最近更新日期 ${date}`,
|
||||
/** Superscript badge on the version lines when the update check found a newer release (owner-specified wording). */
|
||||
newVersionBadge: "有新版本可用",
|
||||
newVersion: (v: string) => `新版本 v${v} 可用`,
|
||||
/**
|
||||
* 用户菜单里**唯一**的更新行:未知新版本时显示「检查更新」并执行手动检查;已知新版本后改为
|
||||
* newVersion() 文案,点击打开更新弹窗(弹窗内含更新说明链接,管理员另有自更新操作)。
|
||||
* The sidebar user menu's SINGLE update row: it reads checkNow until a newer release
|
||||
* is known and runs the manual check; once one is known it reads newVersion() and
|
||||
* opens the update dialog instead (which carries the release-notes link and, for
|
||||
* admins, the self-update action).
|
||||
*/
|
||||
checkNow: "检查更新",
|
||||
checking: "检查中…",
|
||||
/** 手动检查发现新版本时的成功提示;下方同一行即变为更新入口。 */
|
||||
/** Success toast when the manual check finds a newer release; the row below turns into the update entry. */
|
||||
foundNew: (v: string) => `发现新版本 v${v},点击下方更新入口即可安装`,
|
||||
upToDate: "已是最新版本",
|
||||
checkFailed: "检查更新失败,请稍后重试",
|
||||
@@ -88,7 +94,7 @@ export const zh = {
|
||||
unsupported: "当前安装方式不支持在线更新",
|
||||
confirmBody:
|
||||
"将下载最新版本并安装到服务器上的安装目录(数据目录不受影响)。安装完成后需要重启服务才会生效。",
|
||||
/** 非管理员看到的说明(可查看更新说明,但不能在此执行更新),替代 confirmBody。 */
|
||||
/** Copy shown to non-admins in place of confirmBody (they can read the release notes but cannot run the update here). */
|
||||
adminOnly: "只有管理员可以在这里执行更新。",
|
||||
},
|
||||
|
||||
@@ -178,14 +184,14 @@ export const zh = {
|
||||
idHint: "2~64 位:小写字母开头,仅小写字母、数字与下划线;创建后不可修改",
|
||||
idPrefixHint: "id 固定以「用户名-」为前缀,后接小写字母、数字或下划线;创建后不可修改",
|
||||
name: "显示名(可选,缺省为 Project id)",
|
||||
/** Project 设置里的显示名字段(此处必填,与新建对话框的「可选」措辞区分)。 */
|
||||
/** Display-name field in Project settings (required here, unlike the create dialog's "optional" wording). */
|
||||
displayName: "显示名",
|
||||
settings: "Project 设置",
|
||||
settingsTitle: "Project 设置",
|
||||
members: "成员",
|
||||
addMember: "添加成员",
|
||||
removeMember: "移除",
|
||||
/** 新对话默认值分节(Project 设置):预填每个新建对话的 Agent / 工作目录 / 审批模式 / 思考等级 / 默认模型。 */
|
||||
/** New-conversation defaults section (Project settings): prefills each new conversation's agent / working directory / approval mode / thinking level / default model. */
|
||||
chatDefaultsTitle: "新对话默认值",
|
||||
chatDefaultsHint: "新建对话时预填的默认值:Agent、工作目录、审批模式、思考等级与默认模型。",
|
||||
chatDefaultsAgent: "Agent",
|
||||
@@ -193,7 +199,7 @@ export const zh = {
|
||||
chatDefaultsApprovalNotSet: "未设置(默认全部放行)",
|
||||
chatDefaultsThinkingNotSet: "未设置(跟随智能体配置)",
|
||||
chatDefaultsWorkspaceHint: "留空表示使用临时工作区",
|
||||
/** 模型默认值与模型页同源(同一个 default_model),此处仅是另一处入口。 */
|
||||
/** The model default shares its source with the Models page (the same default_model); this is just another entry point. */
|
||||
chatDefaultsModelHint: "与模型页的默认模型同步",
|
||||
deleteProject: "删除 Project",
|
||||
deleteConfirm: "确认删除该 Project?项目目录将被递归删除,不可恢复。",
|
||||
@@ -367,7 +373,7 @@ export const zh = {
|
||||
displayNameHint: "留空则展示模型 ID",
|
||||
providerGroup: "分组",
|
||||
contextWindow: "上下文窗口",
|
||||
/** 单位后缀,显示在上下文窗口/最大输出长度输入框内右侧。 */
|
||||
/** Unit suffix shown inside the right edge of the context-window / max-output-length inputs. */
|
||||
tokenUnit: "Token",
|
||||
contextWindowHint: "留空表示未知",
|
||||
maxTokens: "最大输出长度",
|
||||
@@ -600,13 +606,13 @@ export const zh = {
|
||||
workspaceAuto: "临时工作区",
|
||||
workspaceClear: "改用临时工作区",
|
||||
workspaceDirInvalid: "目录不存在或无法访问,已回退",
|
||||
/** 侧栏对话列表的分组切换(默认按工作区)与工作区分组。 */
|
||||
/** Grouping toggle of the sidebar conversation list (workspace grouping is the default) and the workspace groups. */
|
||||
groupByWorkspace: "按工作区分组",
|
||||
groupByAgent: "按 Agent 分组",
|
||||
tempWorkspaces: "临时工作区",
|
||||
newSessionInWorkspace: "在此工作区新建对话",
|
||||
draftSubtitle: "最擅长 AI 开发任务的自进化 Agent",
|
||||
/** 首页示例的折叠分组名(书签式,同时只展开一个)。 */
|
||||
/** Collapsed group names for the home-page examples (bookmark style; only one open at a time). */
|
||||
exampleFolders: {
|
||||
webapps: "搭建网页应用",
|
||||
agents: "搭建和优化智能体",
|
||||
@@ -1149,6 +1155,8 @@ Benchmark:
|
||||
task_in_progress: "该 Session 已有任务在运行。",
|
||||
version_conflict: "快照版本不高于当前版本。",
|
||||
invalid_title: "标题无效。",
|
||||
invalid_proxy_url:
|
||||
"代理地址无效:应为 http://主机[:端口]、https://主机[:端口] 或 主机[:端口]。",
|
||||
invalid_trace: "该文件不是有效的 Trace 文件。",
|
||||
trace_session_exists: "该 Agent 已存在同名 Session,无法导入重复的 Trace。",
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user