Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
256 lines
7.4 KiB
JavaScript
256 lines
7.4 KiB
JavaScript
/**
|
|
* HTTP network proxy bridge — applies app-level proxy for Chromium sessions
|
|
* (electron.net.fetch used by Google/OneDrive/GitHub OAuth) and Node HTTP stacks
|
|
* (AI providerHandlers, WebDAV, S3, electron-updater).
|
|
*
|
|
* Not related to SSH ProxyJump / ProxyCommand vault profiles.
|
|
*/
|
|
|
|
"use strict";
|
|
|
|
const DEFAULT_SETTINGS = Object.freeze({
|
|
mode: "system",
|
|
url: "",
|
|
bypass: "<local>",
|
|
});
|
|
|
|
const PROXY_ENV_KEYS = [
|
|
"HTTP_PROXY",
|
|
"HTTPS_PROXY",
|
|
"NO_PROXY",
|
|
"http_proxy",
|
|
"https_proxy",
|
|
"no_proxy",
|
|
];
|
|
|
|
const VALID_MODES = new Set(["system", "direct", "custom"]);
|
|
|
|
function asTrimmedString(value) {
|
|
return typeof value === "string" ? value.trim() : "";
|
|
}
|
|
|
|
/**
|
|
* Strip userinfo from proxy URLs without rewriting the rest of the string.
|
|
* Electron proxyRules does not support credentials. Avoid `new URL()` so
|
|
* incomplete drafts like `http://127.0.0.1:` keep the trailing colon.
|
|
*/
|
|
function sanitizeProxyUrl(proxyUrl) {
|
|
const trimmed = asTrimmedString(proxyUrl);
|
|
if (!trimmed) return "";
|
|
// Strip userinfo for both scheme URLs and scheme-less drafts.
|
|
return trimmed.replace(/^([a-z][a-z0-9+.-]*:\/\/)?([^/?#]*@)/i, "$1");
|
|
}
|
|
|
|
function normalizeProxySettingsPayload(raw) {
|
|
if (!raw || typeof raw !== "object") {
|
|
return { ...DEFAULT_SETTINGS };
|
|
}
|
|
|
|
const modeRaw = asTrimmedString(raw.mode);
|
|
const mode = VALID_MODES.has(modeRaw) ? modeRaw : "system";
|
|
const url = sanitizeProxyUrl(raw.url);
|
|
const bypass = asTrimmedString(raw.bypass) || DEFAULT_SETTINGS.bypass;
|
|
|
|
if (mode === "system") {
|
|
return { mode: "system", url: "", bypass: DEFAULT_SETTINGS.bypass };
|
|
}
|
|
if (mode === "direct") {
|
|
return { mode: "direct", url: "", bypass: DEFAULT_SETTINGS.bypass };
|
|
}
|
|
// Keep custom+empty as a draft so the settings UI can show URL fields.
|
|
return { mode: "custom", url, bypass };
|
|
}
|
|
|
|
/** Effective mode for Electron/env apply: empty custom acts like system. */
|
|
function effectiveApplySettings(settings) {
|
|
if (settings.mode === "custom" && !settings.url) {
|
|
return { mode: "system", url: "", bypass: DEFAULT_SETTINGS.bypass };
|
|
}
|
|
return settings;
|
|
}
|
|
|
|
function buildElectronProxyConfigFromPayload(raw) {
|
|
const settings = effectiveApplySettings(normalizeProxySettingsPayload(raw));
|
|
if (settings.mode === "direct") return { mode: "direct" };
|
|
if (settings.mode === "custom") {
|
|
return {
|
|
mode: "fixed_servers",
|
|
proxyRules: settings.url,
|
|
proxyBypassRules: settings.bypass || DEFAULT_SETTINGS.bypass,
|
|
};
|
|
}
|
|
return { mode: "system" };
|
|
}
|
|
|
|
/**
|
|
* Strip userinfo from a proxy URL before writing to process.env so local
|
|
* terminals / spawned tools do not inherit proxy credentials.
|
|
* (normalizeProxySettingsPayload already strips credentials; this is defense in depth.)
|
|
*/
|
|
function sanitizeProxyUrlForEnv(proxyUrl) {
|
|
return sanitizeProxyUrl(proxyUrl);
|
|
}
|
|
|
|
/** Snapshot of env values before this feature last wrote them (WeakMap by env object). */
|
|
const ownedEnvSnapshots = new WeakMap();
|
|
let applyGeneration = 0;
|
|
let applyChain = Promise.resolve();
|
|
|
|
function captureEnvSnapshot(env) {
|
|
const snapshot = {};
|
|
for (const key of PROXY_ENV_KEYS) {
|
|
snapshot[key] = Object.prototype.hasOwnProperty.call(env, key) ? env[key] : undefined;
|
|
}
|
|
return snapshot;
|
|
}
|
|
|
|
function restoreEnvSnapshot(env, snapshot) {
|
|
if (!snapshot) return;
|
|
for (const key of PROXY_ENV_KEYS) {
|
|
if (snapshot[key] === undefined) {
|
|
delete env[key];
|
|
} else {
|
|
env[key] = snapshot[key];
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Apply or clear Node proxy env vars on `env` (defaults to process.env).
|
|
*
|
|
* - custom (with URL): snapshot prior values once, then write redacted proxy URL/bypass
|
|
* - direct: snapshot prior values once (if needed), then clear proxy env
|
|
* - system / empty custom: restore the snapshot captured before this feature mutated env
|
|
*
|
|
* Credentials in the proxy URL are stripped before writing to env so child
|
|
* processes (local terminals) do not inherit user:pass.
|
|
*/
|
|
function applyNodeProxyEnv(raw, env = process.env) {
|
|
const settings = effectiveApplySettings(normalizeProxySettingsPayload(raw));
|
|
|
|
if (settings.mode === "system") {
|
|
const snapshot = ownedEnvSnapshots.get(env);
|
|
if (snapshot) {
|
|
restoreEnvSnapshot(env, snapshot);
|
|
ownedEnvSnapshots.delete(env);
|
|
}
|
|
return { applied: false, settings };
|
|
}
|
|
|
|
if (!ownedEnvSnapshots.has(env)) {
|
|
ownedEnvSnapshots.set(env, captureEnvSnapshot(env));
|
|
}
|
|
|
|
if (settings.mode === "direct") {
|
|
for (const key of PROXY_ENV_KEYS) {
|
|
delete env[key];
|
|
}
|
|
return { applied: true, settings };
|
|
}
|
|
|
|
const safeUrl = sanitizeProxyUrlForEnv(settings.url);
|
|
env.HTTP_PROXY = safeUrl;
|
|
env.HTTPS_PROXY = safeUrl;
|
|
env.NO_PROXY = settings.bypass || "";
|
|
env.http_proxy = safeUrl;
|
|
env.https_proxy = safeUrl;
|
|
env.no_proxy = settings.bypass || "";
|
|
return { applied: true, settings };
|
|
}
|
|
|
|
function resetProxyEnvOwnershipForTests() {
|
|
// WeakMap cannot be cleared; tests use fresh env objects.
|
|
applyGeneration = 0;
|
|
applyChain = Promise.resolve();
|
|
}
|
|
|
|
/**
|
|
* Clone `baseEnv` for local terminal / PTY children, restoring the proxy env
|
|
* values that existed before this feature mutated process.env.
|
|
*
|
|
* App-level Direct/Custom modes intentionally rewrite process.env for Node
|
|
* HTTP clients (AI, updater). Terminals should keep the user's launch-time
|
|
* HTTP(S)_PROXY / NO_PROXY instead of inheriting those app-only overrides.
|
|
*/
|
|
function buildTerminalProcessEnv(baseEnv = process.env) {
|
|
const env = { ...baseEnv };
|
|
const snapshot = ownedEnvSnapshots.get(baseEnv) || ownedEnvSnapshots.get(process.env);
|
|
if (snapshot) {
|
|
restoreEnvSnapshot(env, snapshot);
|
|
}
|
|
return env;
|
|
}
|
|
|
|
let currentSettings = { ...DEFAULT_SETTINGS };
|
|
|
|
function getCurrentProxySettings() {
|
|
return { ...currentSettings };
|
|
}
|
|
|
|
async function applyHttpNetworkProxy(raw, deps = {}) {
|
|
const settings = normalizeProxySettingsPayload(raw);
|
|
const electronConfig = buildElectronProxyConfigFromPayload(settings);
|
|
const generation = ++applyGeneration;
|
|
const env = deps.env || process.env;
|
|
|
|
// Serialize setProxy so an older slow call cannot overwrite a newer one.
|
|
const previous = applyChain;
|
|
let release;
|
|
applyChain = new Promise((resolve) => {
|
|
release = resolve;
|
|
});
|
|
|
|
try {
|
|
await previous.catch(() => {});
|
|
if (generation !== applyGeneration) {
|
|
return { success: false, settings, electronConfig, superseded: true };
|
|
}
|
|
|
|
const session =
|
|
deps.session ||
|
|
deps.electronModule?.session?.defaultSession ||
|
|
null;
|
|
|
|
if (session?.setProxy) {
|
|
await session.setProxy(electronConfig);
|
|
}
|
|
|
|
if (generation !== applyGeneration) {
|
|
return { success: false, settings, electronConfig, superseded: true };
|
|
}
|
|
|
|
currentSettings = settings;
|
|
applyNodeProxyEnv(settings, env);
|
|
return { success: true, settings, electronConfig };
|
|
} finally {
|
|
release();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {Electron.IpcMain} ipcMain
|
|
* @param {import('electron')=} electronModule
|
|
*/
|
|
function registerHandlers(ipcMain, electronModule) {
|
|
ipcMain.handle("netcatty:networkProxy:set", async (_event, payload) => {
|
|
return applyHttpNetworkProxy(payload, { electronModule });
|
|
});
|
|
|
|
ipcMain.handle("netcatty:networkProxy:get", async () => {
|
|
return { settings: getCurrentProxySettings() };
|
|
});
|
|
}
|
|
|
|
module.exports = {
|
|
DEFAULT_SETTINGS,
|
|
normalizeProxySettingsPayload,
|
|
buildElectronProxyConfigFromPayload,
|
|
applyNodeProxyEnv,
|
|
applyHttpNetworkProxy,
|
|
buildTerminalProcessEnv,
|
|
getCurrentProxySettings,
|
|
resetProxyEnvOwnershipForTests,
|
|
sanitizeProxyUrlForEnv,
|
|
registerHandlers,
|
|
};
|