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:
1 parent
6e1c7cc28e
commit
0cc0bd1a1a
25 files changed
+948
-278
No files matched your search
+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 { CompactionSettings } from "./engine/context-engine.js";
|
||||||
import type {
|
import type {
|
||||||
GenerativeModelConfig,
|
GenerativeModelConfig,
|
||||||
|
ProxyEnvPolicy,
|
||||||
SubagentRunner,
|
SubagentRunner,
|
||||||
ThinkingLevelName,
|
ThinkingLevelName,
|
||||||
ToolDefinition,
|
ToolDefinition,
|
||||||
@@ -77,15 +78,15 @@ export interface CreateAgentOptions {
|
|||||||
/** Local data root directory; defaults to `resolveRoot()` (PENGUIN_HOME or ~/.penguin/data). */
|
/** Local data root directory; defaults to `resolveRoot()` (PENGUIN_HOME or ~/.penguin/data). */
|
||||||
root?: string;
|
root?: string;
|
||||||
/**
|
/**
|
||||||
* When it returns true, the proxy variables (HTTP_PROXY / HTTPS_PROXY / ALL_PROXY;
|
* Proxy policy for exec_command subprocess environments of every Session this Agent
|
||||||
* NO_PROXY is kept) are stripped from exec_command subprocess environments of every
|
* creates or resumes — and of its subagents' Sessions, which inherit the getter (see
|
||||||
* Session this Agent creates or resumes — and of its subagents' Sessions, which
|
* {@link ProxyEnvPolicy}: strip the proxy variables, inject an explicit proxy over the
|
||||||
* inherit the getter. The Web server threads its admin-level "use system HTTP proxy"
|
* inherited env, or null = pass through). The Web server threads its admin-level proxy
|
||||||
* switch (off state) through here; the getter is re-read at every command spawn, so a
|
* settings through here; the getter is re-read at every command spawn, so a settings
|
||||||
* toggle needs no restart. Absent = proxy allowed (SDK/CLI standalone use follows the
|
* change needs no restart. Absent = pass through (SDK/CLI standalone use follows the
|
||||||
* user's own shell environment).
|
* user's own shell environment).
|
||||||
*/
|
*/
|
||||||
stripProxyEnv?: () => boolean;
|
proxyEnv?: () => ProxyEnvPolicy | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateSessionOptions {
|
export interface CreateSessionOptions {
|
||||||
@@ -159,15 +160,15 @@ export function metaMaxTokens(budget: number, modelCap: number | undefined): num
|
|||||||
export async function createAgent(opts: CreateAgentOptions = {}): Promise<Agent> {
|
export async function createAgent(opts: CreateAgentOptions = {}): Promise<Agent> {
|
||||||
const state = await loadOrInitAgentState(opts);
|
const state = await loadOrInitAgentState(opts);
|
||||||
const projectConfig = await loadProjectConfig(state.root, state.projectId);
|
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 {
|
export class Agent {
|
||||||
constructor(
|
constructor(
|
||||||
readonly state: AgentState,
|
readonly state: AgentState,
|
||||||
readonly projectConfig: ProjectConfig,
|
readonly projectConfig: ProjectConfig,
|
||||||
/** See {@link CreateAgentOptions.stripProxyEnv}; forwarded into every Session's Environment. */
|
/** See {@link CreateAgentOptions.proxyEnv}; forwarded into every Session's Environment. */
|
||||||
private readonly stripProxyEnv?: () => boolean,
|
private readonly proxyEnv?: () => ProxyEnvPolicy | null,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -598,10 +599,10 @@ export class Agent {
|
|||||||
root,
|
root,
|
||||||
projectId,
|
projectId,
|
||||||
agentId,
|
agentId,
|
||||||
// A child Agent loads its own vault/config, but the proxy-strip getter is
|
// A child Agent loads its own vault/config, but the proxy-env policy
|
||||||
// host policy, not Agent state: the subagent's commands run in the same
|
// getter is host policy, not Agent state: the subagent's commands run in
|
||||||
// serving process, so they follow the same switch as the parent's.
|
// the same serving process, so they follow the same settings as the parent's.
|
||||||
...(parentAgent.stripProxyEnv ? { stripProxyEnv: parentAgent.stripProxyEnv } : {}),
|
...(parentAgent.proxyEnv ? { proxyEnv: parentAgent.proxyEnv } : {}),
|
||||||
})
|
})
|
||||||
: parentAgent;
|
: parentAgent;
|
||||||
// The child Session follows the PARENT Session, never the Project default: with the
|
// The child Session follows the PARENT Session, never the Project default: with the
|
||||||
@@ -744,7 +745,7 @@ export class Agent {
|
|||||||
),
|
),
|
||||||
services: { subagentRunner, ...(visionDescriber ? { visionDescriber } : {}) },
|
services: { subagentRunner, ...(visionDescriber ? { visionDescriber } : {}) },
|
||||||
...(Object.keys(vault).length > 0 ? { vault } : {}),
|
...(Object.keys(vault).length > 0 ? { vault } : {}),
|
||||||
...(this.stripProxyEnv ? { stripProxyEnv: this.stripProxyEnv } : {}),
|
...(this.proxyEnv ? { proxyEnv: this.proxyEnv } : {}),
|
||||||
});
|
});
|
||||||
const tools = await environment.listTools();
|
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
|
// 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.
|
// 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
|
// 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({
|
this.commandSessions = new CommandSessionManager({
|
||||||
...(config.vault !== undefined ? { vault: config.vault } : {}),
|
...(config.vault !== undefined ? { vault: config.vault } : {}),
|
||||||
...(config.stripProxyEnv !== undefined ? { stripProxyEnv: config.stripProxyEnv } : {}),
|
...(config.proxyEnv !== undefined ? { proxyEnv: config.proxyEnv } : {}),
|
||||||
});
|
});
|
||||||
this.subagentSessions = new SubagentSessionManager();
|
this.subagentSessions = new SubagentSessionManager();
|
||||||
const services = {
|
const services = {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
*/
|
*/
|
||||||
import { ManagedSession } from "./session.js";
|
import { ManagedSession } from "./session.js";
|
||||||
import { BackgroundRegistry } from "../background/index.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). */
|
/** Concurrent managed-session cap: evicts once exceeded (exited sessions first, otherwise LRU — killing a background process has bounded cost). */
|
||||||
const MAX_SESSIONS = 64;
|
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
|
* Proxy variables removed IN ADDITION when the host supplies a proxy policy (`proxyEnv`,
|
||||||
* {@link CommandSessionManager}): the Web server's "use system HTTP proxy" switch in the
|
* see {@link CommandSessionManager}): the Web server's proxy settings must keep commands
|
||||||
* off state must keep commands from inheriting the serving process's proxy environment
|
* from just inheriting the serving process's proxy environment.
|
||||||
* (design § "出网与系统代理"). NO_PROXY is deliberately NOT stripped — with no proxy
|
* In `strip` mode NO_PROXY is deliberately NOT removed — with no proxy variables left it
|
||||||
* variables left it is inert, and removing it would change behavior for commands that
|
* is inert, and removing it would change behavior for commands that set their own proxy.
|
||||||
* set their own proxy. Matched case-insensitively like {@link STRIPPED_ENV_KEYS}, which
|
* In `inject` mode the inherited NO_PROXY is replaced too (the policy carries the merged
|
||||||
* also covers the conventional lowercase spellings (http_proxy etc.).
|
* 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"]);
|
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 = {};
|
const env: NodeJS.ProcessEnv = {};
|
||||||
// Matched case-insensitively rather than deleting the upper-case spellings: Windows resolves
|
// 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
|
// 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)) {
|
for (const [key, value] of Object.entries(process.env)) {
|
||||||
const name = key.toUpperCase();
|
const name = key.toUpperCase();
|
||||||
if (STRIPPED_ENV_KEYS.has(name)) continue;
|
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;
|
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;
|
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). */
|
/** 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>;
|
private readonly vault: Record<string, string>;
|
||||||
/**
|
/**
|
||||||
* When it returns true, {@link PROXY_ENV_KEYS} are removed from the child environment
|
* Proxy policy for the child environment (see {@link ProxyEnvPolicy}: strip the proxy
|
||||||
* too. A getter rather than a boolean: the hosting server's "use system HTTP proxy"
|
* variables, inject an explicit proxy over the inherited ones, or null = pass
|
||||||
* switch is toggled at runtime, and re-reading at every spawn makes the toggle reach
|
* through). A getter rather than a snapshot: the hosting server's proxy settings
|
||||||
* Sessions that are already running. Absent = proxy allowed (SDK/CLI standalone use).
|
* 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.vault = opts?.vault ?? {};
|
||||||
this.stripProxyEnv = opts?.stripProxyEnv;
|
this.proxyEnv = opts?.proxyEnv;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Starts a command, returning an **unregistered** session (no process_id yet). */
|
/** 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
|
// 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
|
// 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
|
// 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
|
// the harness's own variables first (see STRIPPED_ENV_KEYS) and has the proxyEnv
|
||||||
// when stripProxyEnv says so); the vault still wins, so a user who genuinely wants
|
// policy applied (strip or inject); the vault still wins — over an injected proxy
|
||||||
// PORT — or a proxy — in commands can set it there.
|
// too — so a user who genuinely wants PORT, or their own proxy, in commands can set
|
||||||
env: { ...hostEnvForChild(this.stripProxyEnv?.() === true), ...this.vault, ...HARDENED_ENV },
|
// it there.
|
||||||
|
env: { ...hostEnvForChild(this.proxyEnv?.() ?? null), ...this.vault, ...HARDENED_ENV },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -293,15 +293,32 @@ export interface EnvironmentConfig {
|
|||||||
*/
|
*/
|
||||||
vault?: Record<string, string>;
|
vault?: Record<string, string>;
|
||||||
/**
|
/**
|
||||||
* When it returns true, the proxy variables (HTTP_PROXY / HTTPS_PROXY / ALL_PROXY, any
|
* Proxy policy for exec_command / input_command subprocess environments (see
|
||||||
* casing; NO_PROXY is kept) are stripped from exec_command / input_command subprocess
|
* {@link ProxyEnvPolicy}). Threaded by the Web server from its admin-level proxy
|
||||||
* environments. Threaded by the Web server from its admin-level "use system HTTP proxy"
|
* settings; re-read at every spawn so a settings change needs no restart. Absent, or a
|
||||||
* switch (off state); re-read at every spawn so a toggle needs no restart. Absent =
|
* getter returning null = the host environment passes through unchanged (the default
|
||||||
* proxy allowed (the default for SDK/CLI standalone use).
|
* 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.
|
* An approved tool-call execution request.
|
||||||
* Docs: /docs/interfaces § "ToolExecutionRequest and EnvironmentConfig".
|
* Docs: /docs/interfaces § "ToolExecutionRequest and EnvironmentConfig".
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import path from "node:path";
|
|||||||
import { Environment, ManagedSession } from "../src/environment/index.js";
|
import { Environment, ManagedSession } from "../src/environment/index.js";
|
||||||
import { toolCall } from "../src/omnimessage/index.js";
|
import { toolCall } from "../src/omnimessage/index.js";
|
||||||
import type { OmniMessage } 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 {
|
function execTool(overrides: Partial<ToolDefinitionConfig> = {}): ToolDefinitionConfig {
|
||||||
return {
|
return {
|
||||||
@@ -313,7 +313,7 @@ describe("harness environment variables never reach a spawned command", () => {
|
|||||||
process.env.HOST = "127.0.0.1";
|
process.env.HOST = "127.0.0.1";
|
||||||
process.env.PENGUIN_CLI_ENTRY = "/opt/penguin/lib/dist/index.js";
|
process.env.PENGUIN_CLI_ENTRY = "/opt/penguin/lib/dist/index.js";
|
||||||
process.env.PENGUIN_WEB_DIST = "/opt/penguin/web";
|
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.
|
// would let an Agent-run command call the server's shutdown endpoint.
|
||||||
process.env.PENGUIN_DESKTOP_TOKEN = "secret-desktop-token";
|
process.env.PENGUIN_DESKTOP_TOKEN = "secret-desktop-token";
|
||||||
process.env.PENGUIN_PORT_FILE = "/tmp/port-file";
|
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", () => {
|
describe("proxyEnv policy governs the proxy variables commands inherit", () => {
|
||||||
// The Web server's "use system HTTP proxy" switch (off state) threads stripProxyEnv
|
// The Web server's proxy settings thread a ProxyEnvPolicy getter through
|
||||||
// through Agent -> Environment -> CommandSessionManager; standalone Environments (no
|
// Agent -> Environment -> CommandSessionManager: strip (switch off), inject (explicit
|
||||||
// getter) keep the historical pass-through.
|
// 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 KEYS = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY"] as const;
|
||||||
const saved: Partial<Record<(typeof KEYS)[number], string | undefined>> = {};
|
const saved: Partial<Record<(typeof KEYS)[number], string | undefined>> = {};
|
||||||
let strip = true;
|
let policy: ProxyEnvPolicy | null = null;
|
||||||
let stripEnv: Environment;
|
let policyEnv: Environment;
|
||||||
|
|
||||||
|
const INJECT: ProxyEnvPolicy = {
|
||||||
|
mode: "inject",
|
||||||
|
url: "http://explicit.example:3128",
|
||||||
|
noProxy: "corp.example,localhost,127.0.0.1,::1",
|
||||||
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
for (const k of KEYS) saved[k] = process.env[k];
|
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.HTTPS_PROXY = "http://proxy.corp.example:8443";
|
||||||
process.env.ALL_PROXY = "socks5://proxy.corp.example:1080";
|
process.env.ALL_PROXY = "socks5://proxy.corp.example:1080";
|
||||||
process.env.NO_PROXY = "localhost,127.0.0.1,::1";
|
process.env.NO_PROXY = "localhost,127.0.0.1,::1";
|
||||||
strip = true;
|
policy = { mode: "strip" };
|
||||||
stripEnv = new Environment({
|
policyEnv = new Environment({
|
||||||
workspaceDir: tmp,
|
workspaceDir: tmp,
|
||||||
toolConfig: sessionConfig(),
|
toolConfig: sessionConfig(),
|
||||||
stripProxyEnv: () => strip,
|
proxyEnv: () => policy,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
stripEnv.dispose();
|
policyEnv.dispose();
|
||||||
for (const k of KEYS) {
|
for (const k of KEYS) {
|
||||||
if (saved[k] === undefined) delete process.env[k];
|
if (saved[k] === undefined) delete process.env[k];
|
||||||
else process.env[k] = saved[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 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}')"`,
|
cmd: `node -e "console.log('${READ}')"`,
|
||||||
});
|
});
|
||||||
expect(res.output).toContain(
|
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";
|
process.env.https_proxy = "http://proxy.corp.example:8443";
|
||||||
try {
|
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 ?? '') + ']')"`,
|
cmd: `node -e "console.log('s=[' + (process.env.https_proxy ?? '') + ']')"`,
|
||||||
});
|
});
|
||||||
expect(res.output).toContain("s=[]");
|
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 () => {
|
it("inject: the explicit proxy overrides the inherited variables, both spellings", async () => {
|
||||||
strip = false;
|
policy = INJECT;
|
||||||
const res = await runTool(stripEnv, "exec_command", {
|
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 ?? '') + ']')"`,
|
cmd: `node -e "console.log('H=[' + (process.env.HTTP_PROXY ?? '') + ']')"`,
|
||||||
});
|
});
|
||||||
expect(res.output).toContain("H=[http://proxy.corp.example:8080]");
|
expect(res.output).toContain("H=[http://proxy.corp.example:8080]");
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
/**
|
/**
|
||||||
* OS-proxy resolution for the embedded server (design § "出网与系统代理"): on the
|
* OS-proxy resolution for the embedded server: on the desktop, "system proxy" means
|
||||||
* desktop, "system proxy" means the real operating-system setting. The shell resolves
|
* the real operating-system setting. The shell resolves
|
||||||
* it through Electron's resolveProxy when forking the server and injects the result
|
* 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
|
* 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
|
* (while the admin app-proxy switch is on) and agent commands (while the agent-proxy
|
||||||
* present in the shell's environment are never overridden (same idiom as
|
* switch is on) then honor it. Variables already present in the shell's environment are
|
||||||
* bundledShellEnv): an explicitly configured environment wins over the OS lookup.
|
* 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
|
* 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.
|
* 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
|
sessionScratchpadDir?: string; // this Session's scratchpad (scratchpad/<sessionId>); enables truncated-output recovery
|
||||||
services?: EnvironmentServices; // runtime services injected into individual tools
|
services?: EnvironmentServices; // runtime services injected into individual tools
|
||||||
vault?: Record<string, string>; // Vault env vars, injected into exec_command / input_command subprocesses
|
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 {
|
interface EnvironmentServices {
|
||||||
subagentRunner?: SubagentRunner; // needed by run_subagent
|
subagentRunner?: SubagentRunner; // needed by run_subagent
|
||||||
visionDescriber?: VisionDescriberService; // needed by describe_image on text-only models
|
visionDescriber?: VisionDescriberService; // needed by describe_image on text-only models
|
||||||
|
|||||||
@@ -127,9 +127,14 @@ interface EnvironmentConfig {
|
|||||||
sessionScratchpadDir?: string; // 本 Session 的 scratchpad(scratchpad/<sessionId>),提供后启用截断输出恢复
|
sessionScratchpadDir?: string; // 本 Session 的 scratchpad(scratchpad/<sessionId>),提供后启用截断输出恢复
|
||||||
services?: EnvironmentServices; // 注入给个别工具的运行时服务
|
services?: EnvironmentServices; // 注入给个别工具的运行时服务
|
||||||
vault?: Record<string, string>; // Vault 环境变量,注入 exec_command / input_command 子进程
|
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 {
|
interface EnvironmentServices {
|
||||||
subagentRunner?: SubagentRunner; // run_subagent 所需
|
subagentRunner?: SubagentRunner; // run_subagent 所需
|
||||||
visionDescriber?: VisionDescriberService; // text-only 模型的 describe_image 所需
|
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 |
|
| 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 |
|
| 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
|
### 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 | 更新设置(字段可省略,省略即保持现值),返回更新后的完整设置 |
|
| 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 {
|
export interface MeResponse {
|
||||||
user: UserInfo;
|
user: UserInfo;
|
||||||
/**
|
/**
|
||||||
* Whether Workspace HTML previews open on a separate origin (see design §
|
* Whether Workspace HTML previews open on a separate origin (the loopback
|
||||||
* "Workspace 文件预览"). False means this deployment has no usable preview origin —
|
* 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
|
* 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,
|
* 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
|
* 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
|
* 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
|
* 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
|
* 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;
|
desktopMode: boolean;
|
||||||
/**
|
/**
|
||||||
@@ -119,17 +120,35 @@ export interface AdminPasswordResetRequest {
|
|||||||
password: string;
|
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 {
|
export interface ServerSettings {
|
||||||
/**
|
/**
|
||||||
* "Use system HTTP proxy" (default on): whether the server process and its child
|
* "Application uses the proxy" (default on): the server's own outbound traffic (LLM
|
||||||
* processes reach the internet through the proxy named by HTTP_PROXY / HTTPS_PROXY
|
* requests, the update check, image fetches). On with `proxyUrl` set = that address
|
||||||
* (both spellings). Off = direct connections, with the proxy variables also stripped
|
* for both http and https; on without an address = the proxy environment variables
|
||||||
* from agent command subprocess environments. Either way the effective NO_PROXY always
|
* HTTP_PROXY / HTTPS_PROXY (both spellings); off = always direct.
|
||||||
* includes localhost/127.0.0.1/::1 (loopback is never proxied). Toggling applies to
|
|
||||||
* newly initiated connections immediately — no restart.
|
|
||||||
*/
|
*/
|
||||||
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 {
|
export interface ServerSettingsResponse {
|
||||||
@@ -138,7 +157,15 @@ export interface ServerSettingsResponse {
|
|||||||
|
|
||||||
/** PUT body: every field optional, omitted fields keep their current value (mirrors prefs). */
|
/** PUT body: every field optional, omitted fields keep their current value (mirrors prefs). */
|
||||||
export interface ServerSettingsUpdateRequest {
|
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). */
|
/** User UI preferences (SQLite ui_prefs, free-form JSON; known keys declared here). */
|
||||||
@@ -700,7 +727,7 @@ export interface MessagesPageInfo {
|
|||||||
before?: string;
|
before?: string;
|
||||||
/**
|
/**
|
||||||
* Outline turns (the Web conversation outline's entry rule) opened BEFORE this
|
* 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.
|
* window never mis-numbers. 0 when the window starts at the beginning.
|
||||||
*/
|
*/
|
||||||
earlierTurns: number;
|
earlierTurns: number;
|
||||||
|
|||||||
+21
-12
@@ -13,7 +13,9 @@ import { Hono } from "hono";
|
|||||||
import type { Context } from "hono";
|
import type { Context } from "hono";
|
||||||
import { bodyLimit } from "hono/body-limit";
|
import { bodyLimit } from "hono/body-limit";
|
||||||
import type { DatabaseSync } from "node:sqlite";
|
import type { DatabaseSync } from "node:sqlite";
|
||||||
|
import type { ProxyEnvPolicy } from "@prismshadow/penguin-core";
|
||||||
import type { ServerConfig } from "./config.js";
|
import type { ServerConfig } from "./config.js";
|
||||||
|
import { mergedNoProxy } from "./net/proxy.js";
|
||||||
import { openDatabase } from "./db/database.js";
|
import { openDatabase } from "./db/database.js";
|
||||||
import { AgentsRepo } from "./db/repos/agents.js";
|
import { AgentsRepo } from "./db/repos/agents.js";
|
||||||
import { AuthSessionsRepo } from "./db/repos/auth-sessions.js";
|
import { AuthSessionsRepo } from "./db/repos/auth-sessions.js";
|
||||||
@@ -95,7 +97,7 @@ export interface AppDeps {
|
|||||||
db: DatabaseSync;
|
db: DatabaseSync;
|
||||||
sessionsRepo: SessionsRepo;
|
sessionsRepo: SessionsRepo;
|
||||||
prefsRepo: UiPrefsRepo;
|
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;
|
serverSettingsRepo: ServerSettingsRepo;
|
||||||
authService: AuthService;
|
authService: AuthService;
|
||||||
adminService: AdminService;
|
adminService: AdminService;
|
||||||
@@ -157,12 +159,20 @@ export function buildAppDeps(config: ServerConfig, overrides: BuildDepsOverrides
|
|||||||
const errorsRepo = new ErrorsRepo(db);
|
const errorsRepo = new ErrorsRepo(db);
|
||||||
const prefsRepo = new UiPrefsRepo(db);
|
const prefsRepo = new UiPrefsRepo(db);
|
||||||
const serverSettingsRepo = new ServerSettingsRepo(db);
|
const serverSettingsRepo = new ServerSettingsRepo(db);
|
||||||
// Proxy-off also strips HTTP(S)_PROXY/ALL_PROXY from agent command subprocess
|
// Command-subprocess proxy policy for core, keyed on the
|
||||||
// environments (design § "出网与系统代理"). A getter, not a snapshot: it is re-read at
|
// "agent environment uses the proxy" switch (the app switch only drives the server's
|
||||||
// every command spawn, so a toggle reaches already-loaded Sessions. Threaded through
|
// own dispatcher, see net/proxy.ts): switch off → strip HTTP(S)_PROXY/ALL_PROXY; on
|
||||||
// BOTH core entry paths — the loader (resume/self-heal) and SessionService (creation,
|
// with an explicit address → inject that address (with the merged loopback NO_PROXY)
|
||||||
// whose runtime the manager adopts for the first Task).
|
// over whatever the environment carries; on without an address → pass the environment
|
||||||
const stripProxyEnv = () => !serverSettingsRepo.getUseSystemProxy();
|
// 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 schedulesRepo = new SchedulesRepo(db);
|
||||||
const goalsRepo = new GoalsRepo(db);
|
const goalsRepo = new GoalsRepo(db);
|
||||||
|
|
||||||
@@ -214,8 +224,7 @@ export function buildAppDeps(config: ServerConfig, overrides: BuildDepsOverrides
|
|||||||
const manager = new SessionManager({
|
const manager = new SessionManager({
|
||||||
sessions: sessionsRepo,
|
sessions: sessionsRepo,
|
||||||
channels,
|
channels,
|
||||||
loader:
|
loader: overrides.loader ?? createCoreSessionLoader(config.root, sessionSources, { proxyEnv }),
|
||||||
overrides.loader ?? createCoreSessionLoader(config.root, sessionSources, { stripProxyEnv }),
|
|
||||||
sources: sessionSources,
|
sources: sessionSources,
|
||||||
recorder,
|
recorder,
|
||||||
errors,
|
errors,
|
||||||
@@ -264,7 +273,7 @@ export function buildAppDeps(config: ServerConfig, overrides: BuildDepsOverrides
|
|||||||
projectConfig: projectConfigService,
|
projectConfig: projectConfigService,
|
||||||
sources: sessionSources,
|
sources: sessionSources,
|
||||||
traceIndex,
|
traceIndex,
|
||||||
stripProxyEnv,
|
proxyEnv,
|
||||||
});
|
});
|
||||||
// Schedule scheduler: active only while the server is running. Only
|
// 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).
|
// 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
|
// 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
|
// /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.
|
// 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
|
// 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
|
// 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).
|
// 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
|
// 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
|
// 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
|
// 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));
|
app.route("/preview", previewRoutes(deps));
|
||||||
|
|
||||||
// Static hosting (production): serves the frontend build output when webDist exists, with SPA fallback to index.html.
|
// 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";
|
import type { DatabaseSync } from "node:sqlite";
|
||||||
|
|
||||||
/** Key of the "use system HTTP proxy" switch (design § "出网与系统代理"); default on. */
|
/** Key of the "application uses the proxy" switch (the server's own outbound dispatcher); default on. */
|
||||||
const USE_SYSTEM_PROXY_KEY = "use_system_proxy";
|
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 {
|
export class ServerSettingsRepo {
|
||||||
constructor(private readonly db: DatabaseSync) {}
|
constructor(private readonly db: DatabaseSync) {}
|
||||||
@@ -26,12 +40,48 @@ export class ServerSettingsRepo {
|
|||||||
.run(key, value);
|
.run(key, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The "use system HTTP proxy" switch; an absent (or unreadable) row reads as the default: on. */
|
/** Shared switch read: this key if present, else the legacy single switch, else the default: on. */
|
||||||
getUseSystemProxy(): boolean {
|
private getProxySwitch(key: string): boolean {
|
||||||
return this.get(USE_SYSTEM_PROXY_KEY) !== "false";
|
const raw = this.get(key);
|
||||||
|
if (raw !== null) return raw !== "false";
|
||||||
|
return this.get(LEGACY_USE_SYSTEM_PROXY_KEY) !== "false";
|
||||||
}
|
}
|
||||||
|
|
||||||
setUseSystemProxy(value: boolean): void {
|
/** The "application uses the proxy" switch (the server's own outbound dispatcher). */
|
||||||
this.set(USE_SYSTEM_PROXY_KEY, JSON.stringify(value));
|
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
|
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)
|
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
|
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
|
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):
|
* Admin server-settings routes (admin only, 403 for non-admins):
|
||||||
* GET|PUT /api/admin/settings — the server-global settings stored in server_settings
|
* GET|PUT /api/admin/settings — the server-global settings stored in server_settings
|
||||||
* (currently the "use system HTTP proxy" switch, design § "出网与系统代理").
|
* (currently the proxy settings: the "application uses the proxy" and "agent
|
||||||
* A PUT applies immediately: the persisted value is written first, then the process
|
* environment uses the proxy" switches and their shared explicit address).
|
||||||
* dispatcher is rebuilt so new outbound connections follow the toggle without a restart.
|
* 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 { Hono } from "hono";
|
||||||
import type { ServerSettingsResponse } from "../../api/types.js";
|
import type { ServerSettingsResponse } from "../../api/types.js";
|
||||||
@@ -11,7 +15,26 @@ import { HttpError } from "../errors.js";
|
|||||||
import type { AppEnv } from "../../auth/middleware.js";
|
import type { AppEnv } from "../../auth/middleware.js";
|
||||||
import { optionalBoolean, readJson } from "../validate.js";
|
import { optionalBoolean, readJson } from "../validate.js";
|
||||||
import type { AppDeps } from "../../app.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> {
|
export function adminSettingsRoutes(deps: AppDeps): Hono<AppEnv> {
|
||||||
const app = new Hono<AppEnv>();
|
const app = new Hono<AppEnv>();
|
||||||
@@ -24,19 +47,32 @@ export function adminSettingsRoutes(deps: AppDeps): Hono<AppEnv> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const settings = (): ServerSettingsResponse => ({
|
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.get("/", (c) => c.json(settings()));
|
||||||
|
|
||||||
app.put("/", async (c) => {
|
app.put("/", async (c) => {
|
||||||
const body = await readJson(c);
|
const body = await readJson(c);
|
||||||
const useSystemProxy = optionalBoolean(body, "useSystemProxy");
|
// Validate every provided field before writing any: a partial PUT with one invalid
|
||||||
if (useSystemProxy !== undefined) {
|
// field must leave the others untouched too.
|
||||||
deps.serverSettingsRepo.setUseSystemProxy(useSystemProxy);
|
const proxyForApp = optionalBoolean(body, "proxyForApp");
|
||||||
// Mirror into the process: rebuilds the global fetch dispatcher (live toggle).
|
const proxyForAgent = optionalBoolean(body, "proxyForAgent");
|
||||||
setUseSystemProxy(useSystemProxy);
|
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());
|
return c.json(settings());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { config as loadDotenv } from "dotenv";
|
|||||||
import { serve } from "@hono/node-server";
|
import { serve } from "@hono/node-server";
|
||||||
import { buildAppDeps, createApp } from "./app.js";
|
import { buildAppDeps, createApp } from "./app.js";
|
||||||
import { resolveServerConfig } from "./config.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 { loopbackHostRoles } from "./services/preview-token.js";
|
||||||
import { acquireServerLock, liveServerLock, releaseServerLock } from "./lock.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
|
// 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
|
// 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
|
// dispatcher, starting from the defaults (app switch on, no explicit address). The
|
||||||
// read once the database is open, so it is applied via setUseSystemProxy right after
|
// persisted values can only be read once the database is open, so they are applied via
|
||||||
// buildAppDeps below — nothing in between makes an outbound request. See net/proxy.ts.
|
// applyProxySettings right after buildAppDeps below — nothing in between makes an
|
||||||
|
// outbound request. See net/proxy.ts.
|
||||||
installGlobalProxyDispatcher();
|
installGlobalProxyDispatcher();
|
||||||
|
|
||||||
/** Exit code for "another server already owns this data root" (see lock.ts). */
|
/** Exit code for "another server already owns this data root" (see lock.ts). */
|
||||||
@@ -47,10 +48,14 @@ if (existingLock) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const deps = buildAppDeps(config);
|
const deps = buildAppDeps(config);
|
||||||
// The database is open now: bring the dispatcher in line with the persisted
|
// The database is open now: bring the dispatcher in line with the persisted proxy
|
||||||
// "use system HTTP proxy" switch (an absent row reads as the default: on) before the
|
// settings (absent rows read as the defaults: app switch on, no explicit address)
|
||||||
// first possible outbound request (update check, LLM calls — all behind HTTP handlers).
|
// before the first possible outbound request (update check, LLM calls — all behind
|
||||||
setUseSystemProxy(deps.serverSettingsRepo.getUseSystemProxy());
|
// HTTP handlers).
|
||||||
|
applyProxySettings({
|
||||||
|
proxyForApp: deps.serverSettingsRepo.getProxyForApp(),
|
||||||
|
proxyUrl: deps.serverSettingsRepo.getProxyUrl(),
|
||||||
|
});
|
||||||
const app = createApp(deps);
|
const app = createApp(deps);
|
||||||
|
|
||||||
// Built-in admin seed (idempotent): creates admin and adopts default_project when the
|
// 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.
|
* Second loopback listener so the preview origin is actually reachable.
|
||||||
*
|
*
|
||||||
* Workspace HTML previews are served from the loopback counterpart of the host the App
|
* 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`
|
* 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
|
* 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.
|
* 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 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
|
* (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
|
* 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
|
* 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
|
* admin-level settings — undici's fetch resolves the global dispatcher per call, so a
|
||||||
* toggle takes effect for newly initiated connections without a restart.
|
* settings change takes effect for newly initiated connections without a restart.
|
||||||
*
|
*
|
||||||
* Dispatcher per setting:
|
* Dispatcher per settings state (keyed on the "application uses the proxy" switch; the
|
||||||
* - on (default): `EnvHttpProxyAgent` — honors HTTP_PROXY / HTTPS_PROXY (both
|
* separate agent-environment switch never touches the dispatcher — it only drives the
|
||||||
* spellings; undici reads the lowercase name first) with a NO_PROXY list merged from
|
* command-subprocess policy in app.ts):
|
||||||
* the environment plus the loopback names;
|
* - app switch off: a plain `Agent` — direct connections, proxy variables ignored;
|
||||||
* - 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
|
* (`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
|
* 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
|
* 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.
|
* to the effective NO_PROXY, regardless of what the environment declares.
|
||||||
*
|
*
|
||||||
* The value persisted in server_settings stays authoritative (the routes read/write the
|
* The values persisted in server_settings stay authoritative (the routes read/write the
|
||||||
* repo); this module only mirrors it into the process-global dispatcher. Stripping the
|
* repo); this module only mirrors them into the process-global dispatcher. The command
|
||||||
* proxy variables from agent command subprocesses when the switch is off lives in core
|
* subprocess side — keyed on the agent-environment switch: strip the proxy variables
|
||||||
* (CommandSessionManager, threaded through the session loader), not here.
|
* 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 { Agent, EnvHttpProxyAgent, fetch as undiciFetch, setGlobalDispatcher } from "undici";
|
||||||
import type { Dispatcher } 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). */
|
/** Loopback names every effective NO_PROXY must contain (see module doc). */
|
||||||
export const LOOPBACK_NO_PROXY = ["localhost", "127.0.0.1", "::1"] as const;
|
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
|
* 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
|
* 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 {
|
export function mergedNoProxy(env: NodeJS.ProcessEnv = process.env): string {
|
||||||
const raw = env.no_proxy ?? env.NO_PROXY ?? "";
|
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):
|
* Normalizes a proxy address to its canonical URL, or null when it is not acceptable.
|
||||||
* on → EnvHttpProxyAgent with the merged NO_PROXY, off → direct-connect Agent.
|
* 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 {
|
export function normalizeProxyUrl(raw: string): string | null {
|
||||||
// The merged list is passed via opts.noProxy, which REPLACES the environment lookup
|
const trimmed = raw.trim();
|
||||||
// inside EnvHttpProxyAgent — merging is this module's job (undici would otherwise use
|
if (trimmed === "") return null;
|
||||||
// env NO_PROXY verbatim, without the loopback exemption).
|
// Bare host[:port] shorthand: no scheme -> http. The scheme test requires "://", so
|
||||||
return useSystemProxy ? new EnvHttpProxyAgent({ noProxy: mergedNoProxy(env) }) : new Agent();
|
// "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). */
|
/** The EnvHttpProxyAgent option subset this module assembles (undici's Options type is not re-exported cleanly). */
|
||||||
let useSystemProxy = true;
|
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). */
|
/** True once the startup entry has installed undici globally (never in tests). */
|
||||||
let installed = false;
|
let installed = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One-time install at the startup entry (index.ts), before anything can fetch: replaces
|
* 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
|
* `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
|
* defaults (on, no explicit address) — the entry applies the persisted values via
|
||||||
* soon as the database is open, before any outbound request exists. Tests never call
|
* {@link applyProxySettings} as soon as the database is open, before any outbound
|
||||||
* this, so they keep the runtime's own fetch (and their fetch stubs).
|
* request exists. Tests never call this, so they keep the runtime's own fetch (and
|
||||||
|
* their fetch stubs).
|
||||||
*/
|
*/
|
||||||
export function installGlobalProxyDispatcher(): void {
|
export function installGlobalProxyDispatcher(): void {
|
||||||
installed = true;
|
installed = true;
|
||||||
setGlobalDispatcher(buildProxyDispatcher(useSystemProxy));
|
setGlobalDispatcher(buildProxyDispatcher(current));
|
||||||
// Cast: undici's fetch is typed against its own RequestInit/Response declarations,
|
// Cast: undici's fetch is typed against its own RequestInit/Response declarations,
|
||||||
// which are structurally compatible with the runtime globals for every caller here.
|
// which are structurally compatible with the runtime globals for every caller here.
|
||||||
globalThis.fetch = undiciFetch as unknown as typeof globalThis.fetch;
|
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
|
* 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
|
* persisted values and by PUT /api/admin/settings on every change. Outside the
|
||||||
* runtime (tests) it only records the state.
|
* installed runtime (tests) it only records the state.
|
||||||
*/
|
*/
|
||||||
export function setUseSystemProxy(value: boolean): void {
|
export function applyProxySettings(settings: ProxySettings): void {
|
||||||
if (value === useSystemProxy) return;
|
if (settings.proxyForApp === current.proxyForApp && settings.proxyUrl === current.proxyUrl) {
|
||||||
useSystemProxy = value;
|
return;
|
||||||
if (installed) setGlobalDispatcher(buildProxyDispatcher(useSystemProxy));
|
}
|
||||||
}
|
current = { proxyForApp: settings.proxyForApp, proxyUrl: settings.proxyUrl };
|
||||||
|
if (installed) setGlobalDispatcher(buildProxyDispatcher(current));
|
||||||
/** The switch state the dispatcher currently reflects. */
|
|
||||||
export function getUseSystemProxy(): boolean {
|
|
||||||
return useSystemProxy;
|
|
||||||
}
|
}
|
||||||
@@ -42,6 +42,7 @@ import type {
|
|||||||
ApproveFn,
|
ApproveFn,
|
||||||
CompactAvailability,
|
CompactAvailability,
|
||||||
OmniMessage,
|
OmniMessage,
|
||||||
|
ProxyEnvPolicy,
|
||||||
SessionMetaPayload,
|
SessionMetaPayload,
|
||||||
SessionTitleResult,
|
SessionTitleResult,
|
||||||
TextPayload,
|
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;
|
* 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
|
* 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.
|
* 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:
|
* `opts.proxyEnv` threads the admin proxy settings into core (a live getter returning the
|
||||||
* true = strip HTTP(S)_PROXY/ALL_PROXY from agent command subprocess environments).
|
* agent-command-subprocess policy: strip the proxy variables, inject the explicit proxy
|
||||||
|
* address, or null = pass the environment through).
|
||||||
*/
|
*/
|
||||||
export function createCoreSessionLoader(
|
export function createCoreSessionLoader(
|
||||||
root: string,
|
root: string,
|
||||||
sources?: SessionSources,
|
sources?: SessionSources,
|
||||||
opts: { stripProxyEnv?: () => boolean } = {},
|
opts: { proxyEnv?: () => ProxyEnvPolicy | null } = {},
|
||||||
): SessionLoader {
|
): SessionLoader {
|
||||||
return {
|
return {
|
||||||
async load(row: SessionRow): Promise<RuntimeSession> {
|
async load(row: SessionRow): Promise<RuntimeSession> {
|
||||||
@@ -135,7 +137,7 @@ export function createCoreSessionLoader(
|
|||||||
root,
|
root,
|
||||||
projectId: row.projectId,
|
projectId: row.projectId,
|
||||||
agentId: row.agentId,
|
agentId: row.agentId,
|
||||||
...(opts.stripProxyEnv ? { stripProxyEnv: opts.stripProxyEnv } : {}),
|
...(opts.proxyEnv ? { proxyEnv: opts.proxyEnv } : {}),
|
||||||
});
|
});
|
||||||
const located = await findLatestTraceFile(
|
const located = await findLatestTraceFile(
|
||||||
tracesDir(root, row.projectId, row.agentId),
|
tracesDir(root, row.projectId, row.agentId),
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
* added to session-manager's active table (state idle).
|
* added to session-manager's active table (state idle).
|
||||||
*/
|
*/
|
||||||
import { createAgent, isSessionMeta } from "@prismshadow/penguin-core";
|
import { createAgent, isSessionMeta } from "@prismshadow/penguin-core";
|
||||||
|
import type { ProxyEnvPolicy } from "@prismshadow/penguin-core";
|
||||||
import type {
|
import type {
|
||||||
ApprovalMode,
|
ApprovalMode,
|
||||||
SessionCategory,
|
SessionCategory,
|
||||||
@@ -52,11 +53,11 @@ export interface SessionServiceDeps {
|
|||||||
/** Trace-file index: discovery / adoption / stats serve from it (mtime-gated reconciler; no per-request walks). */
|
/** Trace-file index: discovery / adoption / stats serve from it (mtime-gated reconciler; no per-request walks). */
|
||||||
traceIndex: TraceIndexService;
|
traceIndex: TraceIndexService;
|
||||||
/**
|
/**
|
||||||
* "Use system HTTP proxy" switch threading (same getter the session loader passes,
|
* Admin proxy-settings threading (same getter the session loader passes, see
|
||||||
* see createCoreSessionLoader): the runtime created here is adopted by the manager
|
* createCoreSessionLoader): the runtime created here is adopted by the manager and
|
||||||
* and runs the Session's first Task, so it needs the strip policy too.
|
* runs the Session's first Task, so it needs the command-subprocess proxy policy too.
|
||||||
*/
|
*/
|
||||||
stripProxyEnv?: () => boolean;
|
proxyEnv?: () => ProxyEnvPolicy | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class SessionService {
|
export class SessionService {
|
||||||
@@ -325,7 +326,7 @@ export class SessionService {
|
|||||||
root: this.deps.root,
|
root: this.deps.root,
|
||||||
projectId: args.projectId,
|
projectId: args.projectId,
|
||||||
agentId: args.agentId,
|
agentId: args.agentId,
|
||||||
...(this.deps.stripProxyEnv ? { stripProxyEnv: this.deps.stripProxyEnv } : {}),
|
...(this.deps.proxyEnv ? { proxyEnv: this.deps.proxyEnv } : {}),
|
||||||
});
|
});
|
||||||
let session;
|
let session;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* Admin server-settings route tests: permission boundary (non-admin 403), the
|
* Admin server-settings route tests: permission boundary (non-admin 403), the proxy
|
||||||
* "use system HTTP proxy" default (absent row reads as on), PUT persistence and
|
* defaults (absent rows read as both switches on, no explicit address), adoption of the
|
||||||
* validation, and merge semantics (an omitted field keeps its current value).
|
* 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 { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
import type { ServerSettingsResponse } from "../src/api/types.js";
|
import type { ServerSettingsResponse } from "../src/api/types.js";
|
||||||
@@ -30,39 +32,135 @@ describe("admin server settings", () => {
|
|||||||
const { cookie } = await provisionUser(t.app, "norm");
|
const { cookie } = await provisionUser(t.app, "norm");
|
||||||
const api = apiClient(t.app, cookie);
|
const api = apiClient(t.app, cookie);
|
||||||
expect((await api.get("/api/admin/settings")).status).toBe(403);
|
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.
|
// 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({
|
expect(t.deps.db.prepare("SELECT COUNT(*) AS n FROM server_settings").get()).toMatchObject({
|
||||||
n: 0,
|
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 () => {
|
it("adopts the legacy use_system_proxy row as the default for BOTH switches", async () => {
|
||||||
const off = await admin.put("/api/admin/settings", { useSystemProxy: false });
|
// The single switch shipped only on unreleased main (#225): a deployment that had
|
||||||
expect(off.status).toBe(200);
|
// toggled it off must keep that choice for both new switches, with no migration —
|
||||||
expect(((await off.json()) as ServerSettingsResponse).settings.useSystemProxy).toBe(false);
|
// the legacy key is read as a fallback, never rewritten.
|
||||||
// Round-trips through the repo (the DB row, not process state, is what GET serves).
|
t.deps.serverSettingsRepo.set("use_system_proxy", "false");
|
||||||
expect((await getSettings()).settings.useSystemProxy).toBe(false);
|
let { settings } = await getSettings();
|
||||||
expect(t.deps.serverSettingsRepo.getUseSystemProxy()).toBe(false);
|
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 });
|
it("PUT persists the switches independently and echoes the full settings", async () => {
|
||||||
expect(on.status).toBe(200);
|
const appOff = await admin.put("/api/admin/settings", { proxyForApp: false });
|
||||||
expect((await getSettings()).settings.useSystemProxy).toBe(true);
|
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 () => {
|
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.
|
// Empty PUT: no-op, still returns the current settings.
|
||||||
const noop = await admin.put("/api/admin/settings", {});
|
const noop = await admin.put("/api/admin/settings", {});
|
||||||
expect(noop.status).toBe(200);
|
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.
|
// 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 admin.put("/api/admin/settings", { proxyForApp: "on" })).status).toBe(400);
|
||||||
expect((await getSettings()).settings.useSystemProxy).toBe(false);
|
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
|
* 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 —
|
* proxy-address normalization, and the dispatcher choice per settings state — plus one
|
||||||
* installGlobalProxyDispatcher (fetch replacement) runs only in the production entry.
|
* 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 { describe, expect, it } from "vitest";
|
||||||
import { Agent, EnvHttpProxyAgent } from "undici";
|
import { Agent, EnvHttpProxyAgent, fetch as undiciFetch } from "undici";
|
||||||
import { buildProxyDispatcher, mergedNoProxy } from "../src/net/proxy.js";
|
import {
|
||||||
|
buildProxyDispatcher,
|
||||||
|
envProxyAgentOptions,
|
||||||
|
mergedNoProxy,
|
||||||
|
normalizeProxyUrl,
|
||||||
|
} from "../src/net/proxy.js";
|
||||||
|
|
||||||
describe("mergedNoProxy", () => {
|
describe("mergedNoProxy", () => {
|
||||||
it("yields exactly the loopback names when the environment has no NO_PROXY", () => {
|
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", () => {
|
describe("buildProxyDispatcher", () => {
|
||||||
it("on → EnvHttpProxyAgent (proxy env honored), off → plain Agent (direct)", async () => {
|
it("app switch off → plain Agent; on → EnvHttpProxyAgent, with or without an explicit address", async () => {
|
||||||
const on = buildProxyDispatcher(true, {});
|
const off = buildProxyDispatcher({ proxyForApp: false, proxyUrl: null }, {});
|
||||||
const off = buildProxyDispatcher(false, {});
|
const onEnv = buildProxyDispatcher({ proxyForApp: true, proxyUrl: null }, {});
|
||||||
|
const onExplicit = buildProxyDispatcher(
|
||||||
|
{ proxyForApp: true, proxyUrl: "http://explicit.example:3128" },
|
||||||
|
{},
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
expect(on).toBeInstanceOf(EnvHttpProxyAgent);
|
|
||||||
expect(off).toBeInstanceOf(Agent);
|
expect(off).toBeInstanceOf(Agent);
|
||||||
expect(off).not.toBeInstanceOf(EnvHttpProxyAgent);
|
expect(off).not.toBeInstanceOf(EnvHttpProxyAgent);
|
||||||
|
expect(onEnv).toBeInstanceOf(EnvHttpProxyAgent);
|
||||||
|
expect(onExplicit).toBeInstanceOf(EnvHttpProxyAgent);
|
||||||
} finally {
|
} finally {
|
||||||
await on.close();
|
|
||||||
await off.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 { clearDraft, sessionDraftKey } from "../../features/chat/draft-cache";
|
||||||
import { CreateProjectDialog, ProjectSettingsDialog } from "./project-dialogs";
|
import { CreateProjectDialog, ProjectSettingsDialog } from "./project-dialogs";
|
||||||
import { ChangePasswordDialog } from "../account/change-password-dialog";
|
import { ChangePasswordDialog } from "../account/change-password-dialog";
|
||||||
|
import { ProxySettingsDialog } from "../account/proxy-settings-dialog";
|
||||||
import { UpdateDialog } from "../account/update-dialog";
|
import { UpdateDialog } from "../account/update-dialog";
|
||||||
import { forceUpdateCheck, updateCheckOutcome, useVersionInfo } from "../../lib/use-version-info";
|
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 § "出网与系统代理"):
|
* Admin-only server-global proxy settings dialog: the menu carries only the opener
|
||||||
* null = not hydrated yet. Fetched lazily the first time an ADMIN opens the dropdown —
|
* row; the controls, their form semantics, and the open-time hydration all live in
|
||||||
* same laziness as the version info above, and non-admins never call the endpoint (the
|
* ProxySettingsDialog.
|
||||||
* row is not rendered for them either). A failed hydration stays null (row disabled)
|
|
||||||
* and is retried on the next open.
|
|
||||||
*/
|
*/
|
||||||
const [useSystemProxy, setUseSystemProxyState] = useState<boolean | null>(null);
|
const [proxySettingsOpen, setProxySettingsOpen] = useState(false);
|
||||||
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 currentProjectId = currentProject?.projectId ?? null;
|
const currentProjectId = currentProject?.projectId ?? null;
|
||||||
const collapseStoreKey = currentProjectId === null ? null : collapsedGroupsKey(currentProjectId);
|
const collapseStoreKey = currentProjectId === null ? null : collapsedGroupsKey(currentProjectId);
|
||||||
const pinStoreKey = currentProjectId === null ? null : pinnedGroupsKey(currentProjectId);
|
const pinStoreKey = currentProjectId === null ? null : pinnedGroupsKey(currentProjectId);
|
||||||
@@ -1034,19 +1012,6 @@ export function Sidebar({
|
|||||||
<SettingRow label={S.settings.showCliSessions}>
|
<SettingRow label={S.settings.showCliSessions}>
|
||||||
<Switch checked={showCliSessions} onChange={setShowCliSessions} />
|
<Switch checked={showCliSessions} onChange={setShowCliSessions} />
|
||||||
</SettingRow>
|
</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>
|
||||||
<div className="mt-1 border-t border-gray-100 pt-1 dark:border-gray-800">
|
<div className="mt-1 border-t border-gray-100 pt-1 dark:border-gray-800">
|
||||||
<button
|
<button
|
||||||
@@ -1059,6 +1024,21 @@ export function Sidebar({
|
|||||||
>
|
>
|
||||||
{S.account.changePassword}
|
{S.account.changePassword}
|
||||||
</button>
|
</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
|
{/* 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
|
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
|
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}
|
open={changePasswordOpen}
|
||||||
onClose={() => setChangePasswordOpen(false)}
|
onClose={() => setChangePasswordOpen(false)}
|
||||||
/>
|
/>
|
||||||
|
<ProxySettingsDialog open={proxySettingsOpen} onClose={() => setProxySettingsOpen(false)} />
|
||||||
<UpdateDialog
|
<UpdateDialog
|
||||||
open={updateDialogOpen}
|
open={updateDialogOpen}
|
||||||
onClose={() => setUpdateDialogOpen(false)}
|
onClose={() => setUpdateDialogOpen(false)}
|
||||||
@@ -1376,18 +1357,9 @@ function SessionRow({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SettingRow({
|
function SettingRow({ label, children }: { label: string; children: ReactNode }) {
|
||||||
label,
|
|
||||||
title,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
/** Optional row tooltip (e.g. the system-proxy row's scope + loopback-exemption hint). */
|
|
||||||
title?: string;
|
|
||||||
children: ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<div {...(title !== undefined ? { title } : {})}>
|
<div>
|
||||||
<p className="mb-1 text-[11px] font-medium text-gray-500 dark:text-gray-400">{label}</p>
|
<p className="mb-1 text-[11px] font-medium text-gray-500 dark:text-gray-400">{label}</p>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -33,11 +33,15 @@ export const en: Strings = {
|
|||||||
language: "Language",
|
language: "Language",
|
||||||
/** Sidebar Session list: also show CLI-created Sessions (default off — the list then never scans the Trace directories). */
|
/** Sidebar Session list: also show CLI-created Sessions (default off — the list then never scans the Trace directories). */
|
||||||
showCliSessions: "Show CLI sessions",
|
showCliSessions: "Show CLI sessions",
|
||||||
/** Admin-only server-global switch (design § "出网与系统代理"): saved immediately on toggle. */
|
/** Admin-only user-menu row opening the proxy options dialog. */
|
||||||
useSystemProxy: "Use system HTTP proxy",
|
proxyMenu: "Proxy options…",
|
||||||
/** Row tooltip: scope (server + its child processes, server-wide) and the loopback exemption. */
|
proxyDialogTitle: "Proxy options",
|
||||||
useSystemProxyHint:
|
/** The dialog's two switches: the server's own outbound traffic / agent command subprocess environments. */
|
||||||
"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.",
|
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",
|
theme: "Theme",
|
||||||
themeLight: "Light",
|
themeLight: "Light",
|
||||||
themeDark: "Dark",
|
themeDark: "Dark",
|
||||||
@@ -1175,6 +1179,8 @@ Scenarios:
|
|||||||
task_in_progress: "This Session already has a task running.",
|
task_in_progress: "This Session already has a task running.",
|
||||||
version_conflict: "The snapshot's version is not newer than the current one.",
|
version_conflict: "The snapshot's version is not newer than the current one.",
|
||||||
invalid_title: "The title is invalid.",
|
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.",
|
invalid_trace: "This file is not a valid Trace file.",
|
||||||
trace_session_exists:
|
trace_session_exists:
|
||||||
"This agent already has a Session with that id; a duplicate Trace cannot be imported.",
|
"This agent already has a Session with that id; a duplicate Trace cannot be imported.",
|
||||||
|
|||||||
@@ -35,11 +35,15 @@ export const zh = {
|
|||||||
language: "语言",
|
language: "语言",
|
||||||
/** Sidebar Session list: also show CLI-created Sessions (default off — the list then never scans the Trace directories). */
|
/** Sidebar Session list: also show CLI-created Sessions (default off — the list then never scans the Trace directories). */
|
||||||
showCliSessions: "显示 CLI 会话",
|
showCliSessions: "显示 CLI 会话",
|
||||||
/** Admin-only server-global switch (design § "出网与系统代理"): saved immediately on toggle. */
|
/** Admin-only user-menu row opening the proxy options dialog. */
|
||||||
useSystemProxy: "使用系统 HTTP 代理",
|
proxyMenu: "代理选项",
|
||||||
/** Row tooltip: scope (server + its child processes, server-wide) and the loopback exemption. */
|
proxyDialogTitle: "代理选项",
|
||||||
useSystemProxyHint:
|
/** The dialog's two switches: the server's own outbound traffic / agent command subprocess environments. */
|
||||||
"服务端及其子进程(更新检查、LLM 请求、Agent 命令)出网时是否使用 HTTP_PROXY / HTTPS_PROXY 环境变量指定的代理,对整个服务端全局生效;回环地址(localhost、127.0.0.1、::1)始终直连。关闭后服务端一律直连,并从 Agent 命令子进程环境中移除代理变量。",
|
proxyForApp: "应用程序使用代理",
|
||||||
|
proxyForAgent: "Agent 环境使用代理",
|
||||||
|
/** The shared explicit proxy address (empty = follow the proxy environment variables). */
|
||||||
|
proxyAddress: "代理地址",
|
||||||
|
proxyAddressPlaceholder: "留空 = 跟随系统代理",
|
||||||
theme: "主题",
|
theme: "主题",
|
||||||
themeLight: "浅色",
|
themeLight: "浅色",
|
||||||
themeDark: "深色",
|
themeDark: "深色",
|
||||||
@@ -63,18 +67,20 @@ export const zh = {
|
|||||||
|
|
||||||
/** Version footer, update reminder, and admin self-update in the sidebar user menu. */
|
/** Version footer, update reminder, and admin self-update in the sidebar user menu. */
|
||||||
update: {
|
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}`,
|
lastUpdated: (date: string) => `最近更新日期 ${date}`,
|
||||||
/** Superscript badge on the version lines when the update check found a newer release (owner-specified wording). */
|
/** Superscript badge on the version lines when the update check found a newer release (owner-specified wording). */
|
||||||
newVersionBadge: "有新版本可用",
|
newVersionBadge: "有新版本可用",
|
||||||
newVersion: (v: string) => `新版本 v${v} 可用`,
|
newVersion: (v: string) => `新版本 v${v} 可用`,
|
||||||
/**
|
/**
|
||||||
* 用户菜单里**唯一**的更新行:未知新版本时显示「检查更新」并执行手动检查;已知新版本后改为
|
* The sidebar user menu's SINGLE update row: it reads checkNow until a newer release
|
||||||
* newVersion() 文案,点击打开更新弹窗(弹窗内含更新说明链接,管理员另有自更新操作)。
|
* 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: "检查更新",
|
checkNow: "检查更新",
|
||||||
checking: "检查中…",
|
checking: "检查中…",
|
||||||
/** 手动检查发现新版本时的成功提示;下方同一行即变为更新入口。 */
|
/** Success toast when the manual check finds a newer release; the row below turns into the update entry. */
|
||||||
foundNew: (v: string) => `发现新版本 v${v},点击下方更新入口即可安装`,
|
foundNew: (v: string) => `发现新版本 v${v},点击下方更新入口即可安装`,
|
||||||
upToDate: "已是最新版本",
|
upToDate: "已是最新版本",
|
||||||
checkFailed: "检查更新失败,请稍后重试",
|
checkFailed: "检查更新失败,请稍后重试",
|
||||||
@@ -88,7 +94,7 @@ export const zh = {
|
|||||||
unsupported: "当前安装方式不支持在线更新",
|
unsupported: "当前安装方式不支持在线更新",
|
||||||
confirmBody:
|
confirmBody:
|
||||||
"将下载最新版本并安装到服务器上的安装目录(数据目录不受影响)。安装完成后需要重启服务才会生效。",
|
"将下载最新版本并安装到服务器上的安装目录(数据目录不受影响)。安装完成后需要重启服务才会生效。",
|
||||||
/** 非管理员看到的说明(可查看更新说明,但不能在此执行更新),替代 confirmBody。 */
|
/** Copy shown to non-admins in place of confirmBody (they can read the release notes but cannot run the update here). */
|
||||||
adminOnly: "只有管理员可以在这里执行更新。",
|
adminOnly: "只有管理员可以在这里执行更新。",
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -178,14 +184,14 @@ export const zh = {
|
|||||||
idHint: "2~64 位:小写字母开头,仅小写字母、数字与下划线;创建后不可修改",
|
idHint: "2~64 位:小写字母开头,仅小写字母、数字与下划线;创建后不可修改",
|
||||||
idPrefixHint: "id 固定以「用户名-」为前缀,后接小写字母、数字或下划线;创建后不可修改",
|
idPrefixHint: "id 固定以「用户名-」为前缀,后接小写字母、数字或下划线;创建后不可修改",
|
||||||
name: "显示名(可选,缺省为 Project id)",
|
name: "显示名(可选,缺省为 Project id)",
|
||||||
/** Project 设置里的显示名字段(此处必填,与新建对话框的「可选」措辞区分)。 */
|
/** Display-name field in Project settings (required here, unlike the create dialog's "optional" wording). */
|
||||||
displayName: "显示名",
|
displayName: "显示名",
|
||||||
settings: "Project 设置",
|
settings: "Project 设置",
|
||||||
settingsTitle: "Project 设置",
|
settingsTitle: "Project 设置",
|
||||||
members: "成员",
|
members: "成员",
|
||||||
addMember: "添加成员",
|
addMember: "添加成员",
|
||||||
removeMember: "移除",
|
removeMember: "移除",
|
||||||
/** 新对话默认值分节(Project 设置):预填每个新建对话的 Agent / 工作目录 / 审批模式 / 思考等级 / 默认模型。 */
|
/** New-conversation defaults section (Project settings): prefills each new conversation's agent / working directory / approval mode / thinking level / default model. */
|
||||||
chatDefaultsTitle: "新对话默认值",
|
chatDefaultsTitle: "新对话默认值",
|
||||||
chatDefaultsHint: "新建对话时预填的默认值:Agent、工作目录、审批模式、思考等级与默认模型。",
|
chatDefaultsHint: "新建对话时预填的默认值:Agent、工作目录、审批模式、思考等级与默认模型。",
|
||||||
chatDefaultsAgent: "Agent",
|
chatDefaultsAgent: "Agent",
|
||||||
@@ -193,7 +199,7 @@ export const zh = {
|
|||||||
chatDefaultsApprovalNotSet: "未设置(默认全部放行)",
|
chatDefaultsApprovalNotSet: "未设置(默认全部放行)",
|
||||||
chatDefaultsThinkingNotSet: "未设置(跟随智能体配置)",
|
chatDefaultsThinkingNotSet: "未设置(跟随智能体配置)",
|
||||||
chatDefaultsWorkspaceHint: "留空表示使用临时工作区",
|
chatDefaultsWorkspaceHint: "留空表示使用临时工作区",
|
||||||
/** 模型默认值与模型页同源(同一个 default_model),此处仅是另一处入口。 */
|
/** The model default shares its source with the Models page (the same default_model); this is just another entry point. */
|
||||||
chatDefaultsModelHint: "与模型页的默认模型同步",
|
chatDefaultsModelHint: "与模型页的默认模型同步",
|
||||||
deleteProject: "删除 Project",
|
deleteProject: "删除 Project",
|
||||||
deleteConfirm: "确认删除该 Project?项目目录将被递归删除,不可恢复。",
|
deleteConfirm: "确认删除该 Project?项目目录将被递归删除,不可恢复。",
|
||||||
@@ -367,7 +373,7 @@ export const zh = {
|
|||||||
displayNameHint: "留空则展示模型 ID",
|
displayNameHint: "留空则展示模型 ID",
|
||||||
providerGroup: "分组",
|
providerGroup: "分组",
|
||||||
contextWindow: "上下文窗口",
|
contextWindow: "上下文窗口",
|
||||||
/** 单位后缀,显示在上下文窗口/最大输出长度输入框内右侧。 */
|
/** Unit suffix shown inside the right edge of the context-window / max-output-length inputs. */
|
||||||
tokenUnit: "Token",
|
tokenUnit: "Token",
|
||||||
contextWindowHint: "留空表示未知",
|
contextWindowHint: "留空表示未知",
|
||||||
maxTokens: "最大输出长度",
|
maxTokens: "最大输出长度",
|
||||||
@@ -600,13 +606,13 @@ export const zh = {
|
|||||||
workspaceAuto: "临时工作区",
|
workspaceAuto: "临时工作区",
|
||||||
workspaceClear: "改用临时工作区",
|
workspaceClear: "改用临时工作区",
|
||||||
workspaceDirInvalid: "目录不存在或无法访问,已回退",
|
workspaceDirInvalid: "目录不存在或无法访问,已回退",
|
||||||
/** 侧栏对话列表的分组切换(默认按工作区)与工作区分组。 */
|
/** Grouping toggle of the sidebar conversation list (workspace grouping is the default) and the workspace groups. */
|
||||||
groupByWorkspace: "按工作区分组",
|
groupByWorkspace: "按工作区分组",
|
||||||
groupByAgent: "按 Agent 分组",
|
groupByAgent: "按 Agent 分组",
|
||||||
tempWorkspaces: "临时工作区",
|
tempWorkspaces: "临时工作区",
|
||||||
newSessionInWorkspace: "在此工作区新建对话",
|
newSessionInWorkspace: "在此工作区新建对话",
|
||||||
draftSubtitle: "最擅长 AI 开发任务的自进化 Agent",
|
draftSubtitle: "最擅长 AI 开发任务的自进化 Agent",
|
||||||
/** 首页示例的折叠分组名(书签式,同时只展开一个)。 */
|
/** Collapsed group names for the home-page examples (bookmark style; only one open at a time). */
|
||||||
exampleFolders: {
|
exampleFolders: {
|
||||||
webapps: "搭建网页应用",
|
webapps: "搭建网页应用",
|
||||||
agents: "搭建和优化智能体",
|
agents: "搭建和优化智能体",
|
||||||
@@ -1149,6 +1155,8 @@ Benchmark:
|
|||||||
task_in_progress: "该 Session 已有任务在运行。",
|
task_in_progress: "该 Session 已有任务在运行。",
|
||||||
version_conflict: "快照版本不高于当前版本。",
|
version_conflict: "快照版本不高于当前版本。",
|
||||||
invalid_title: "标题无效。",
|
invalid_title: "标题无效。",
|
||||||
|
invalid_proxy_url:
|
||||||
|
"代理地址无效:应为 http://主机[:端口]、https://主机[:端口] 或 主机[:端口]。",
|
||||||
invalid_trace: "该文件不是有效的 Trace 文件。",
|
invalid_trace: "该文件不是有效的 Trace 文件。",
|
||||||
trace_session_exists: "该 Agent 已存在同名 Session,无法导入重复的 Trace。",
|
trace_session_exists: "该 Agent 已存在同名 Session,无法导入重复的 Trace。",
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in new issue
Block a user