Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 43 additions & 5 deletions apps/desktop/src/backend/DesktopBackendConfiguration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,46 @@ const withPackagedWslHarness = <A, E, R>(
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer));

describe("DesktopBackendConfiguration", () => {
it.effect("resolvePrimary opts the backend into HTTP(S) environment proxies", () =>
Effect.gen(function* () {
const names = [
"HTTP_PROXY",
"http_proxy",
"HTTPS_PROXY",
"https_proxy",
"ALL_PROXY",
"all_proxy",
"NODE_USE_ENV_PROXY",
] as const;
const previous = names.map((name) => [name, process.env[name]] as const);
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
for (const [name, value] of previous) restoreEnv(name, value);
}),
);
for (const name of names) delete process.env[name];

yield* withHarness(
Effect.gen(function* () {
const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration;
const usesEnvProxy = configuration.resolvePrimary.pipe(
Effect.map((config) => config.args.includes("--use-env-proxy")),
);

// Node's environment proxy support does not read ALL_PROXY.
process.env.ALL_PROXY = "http://proxy.example.test:8080";
assert.isFalse(yield* usesEnvProxy);

process.env.HTTPS_PROXY = "http://proxy.example.test:8080";
assert.isTrue(yield* usesEnvProxy);

process.env.NODE_USE_ENV_PROXY = "0";
assert.isFalse(yield* usesEnvProxy);
}),
);
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);

it.effect("resolvePrimary produces a stable scoped bootstrap token", () =>
withHarness(
Effect.gen(function* () {
Expand All @@ -247,11 +287,9 @@ describe("DesktopBackendConfiguration", () => {
const second = yield* configuration.resolvePrimary;

assert.equal(first.executablePath, process.execPath);
assert.deepEqual(first.args.slice(0, 3), [
"--require",
environment.compileCachePath,
environment.backendEntryPath,
]);
assert.deepEqual(first.args.slice(0, 2), ["--require", environment.compileCachePath]);
assert.include(first.args, environment.backendEntryPath);
assert.deepEqual(first.args.slice(-2), ["--bootstrap-fd", "3"]);
assert.equal(first.entryPath, environment.backendEntryPath);
assert.equal(first.cwd, environment.backendCwd);
assert.equal(first.captureOutput, true);
Expand Down
10 changes: 10 additions & 0 deletions apps/desktop/src/backend/DesktopBackendConfiguration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,12 @@ const DESKTOP_BACKEND_ENV_NAMES = [
"T3CODE_TAILSCALE_SERVE",
"T3CODE_TAILSCALE_SERVE_PORT",
] as const;
// The variables Node's own `--use-env-proxy` reads; it ignores ALL_PROXY.
const NODE_ENV_PROXY_NAMES = ["HTTP_PROXY", "http_proxy", "HTTPS_PROXY", "https_proxy"] as const;

const useEnvironmentProxy = () =>
process.env.NODE_USE_ENV_PROXY !== "0" &&
NODE_ENV_PROXY_NAMES.some((name) => Boolean(process.env[name]?.trim()));

// Env vars that the WSL backend needs but Windows process.env won't forward
// across the wsl.exe boundary without WSLENV.
Expand Down Expand Up @@ -542,6 +548,10 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv
// the backend starts.
args: [
...(environment.isPackaged ? ["--require", environment.compileCachePath] : []),
// Node's fetch ignores HTTP(S)_PROXY without this opt-in. A flag rather
// than NODE_USE_ENV_PROXY so, like the compile cache, it stays out of
// the provider and terminal processes the backend starts.
...(useEnvironmentProxy() ? ["--use-env-proxy"] : []),
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Resolved
environment.backendEntryPath,
"--bootstrap-fd",
"3",
Expand Down
79 changes: 79 additions & 0 deletions apps/desktop/src/shell/DesktopShellEnvironment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,85 @@ describe("DesktopShellEnvironment", () => {
}),
);

it.effect("hydrates proxy settings from the login shell", () =>
Effect.gen(function* () {
const env: NodeJS.ProcessEnv = { SHELL: "/bin/zsh", PATH: "/usr/bin" };

yield* runShellEnvironment({
env,
platform: "darwin",
handler: () =>
envOutput({
PATH: "/opt/homebrew/bin:/usr/bin",
https_proxy: "http://127.0.0.1:2080",
no_proxy: "localhost,127.0.0.1",
}),
});

assert.equal(env.https_proxy, "http://127.0.0.1:2080");
assert.equal(env.no_proxy, "localhost,127.0.0.1");

// Node reads the lowercase alias first, so a blank inherited alias must
// not survive next to the uppercase settings the shell exports.
const blankAlias: NodeJS.ProcessEnv = {
SHELL: "/bin/zsh",
PATH: "/usr/bin",
https_proxy: "",
no_proxy: " ",
};
yield* runShellEnvironment({
env: blankAlias,
platform: "darwin",
handler: () =>
envOutput({
PATH: "/opt/homebrew/bin:/usr/bin",
HTTPS_PROXY: "http://127.0.0.1:2080",
NO_PROXY: "localhost,127.0.0.1",
}),
});
assert.equal(blankAlias.HTTPS_PROXY, "http://127.0.0.1:2080");
assert.equal(blankAlias.NO_PROXY, "localhost,127.0.0.1");
assert.isUndefined(blankAlias.https_proxy);
assert.isUndefined(blankAlias.no_proxy);
}),
);

it.effect("keeps inherited proxy settings ahead of the login shell", () =>
Effect.gen(function* () {
const handler = () =>
envOutput({
PATH: "/opt/homebrew/bin:/usr/bin",
https_proxy: "http://127.0.0.1:2080",
no_proxy: "shell.example.test",
});

const inheritedEndpoint: NodeJS.ProcessEnv = {
SHELL: "/bin/zsh",
PATH: "/usr/bin",
HTTPS_PROXY: "http://inherited:8080",
https_proxy: "",
NO_PROXY: "inherited.example.test",
no_proxy: " ",
};
yield* runShellEnvironment({ env: inheritedEndpoint, platform: "darwin", handler });
assert.equal(inheritedEndpoint.HTTPS_PROXY, "http://inherited:8080");
assert.equal(inheritedEndpoint.NO_PROXY, "inherited.example.test");
assert.isUndefined(inheritedEndpoint.https_proxy);
assert.isUndefined(inheritedEndpoint.no_proxy);

// A bypass list alone is not a proxy, so the shell's endpoint still applies.
const inheritedBypass: NodeJS.ProcessEnv = {
SHELL: "/bin/zsh",
PATH: "/usr/bin",
NO_PROXY: "localhost,127.0.0.1",
};
yield* runShellEnvironment({ env: inheritedBypass, platform: "darwin", handler });
assert.equal(inheritedBypass.https_proxy, "http://127.0.0.1:2080");
assert.equal(inheritedBypass.NO_PROXY, "localhost,127.0.0.1");
assert.isUndefined(inheritedBypass.no_proxy);
}),
);

it.effect("hydrates the locale from the login shell on macOS", () =>
Effect.gen(function* () {
const env: NodeJS.ProcessEnv = {
Expand Down
61 changes: 48 additions & 13 deletions apps/desktop/src/shell/DesktopShellEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,15 @@ export class DesktopShellEnvironment extends Context.Service<
}
>()("@t3tools/desktop/shell/DesktopShellEnvironment") {}

const PROXY_ENDPOINT_ENV_NAMES = [
"HTTPS_PROXY",
"https_proxy",
"HTTP_PROXY",
"http_proxy",
"ALL_PROXY",
"all_proxy",
] as const;
const PROXY_BYPASS_ENV_NAMES = ["NO_PROXY", "no_proxy"] as const;
const LOGIN_SHELL_ENV_NAMES = [
"PATH",
"DBUS_SESSION_BUS_ADDRESS",
Expand All @@ -92,6 +101,8 @@ const LOGIN_SHELL_ENV_NAMES = [
"NODE_EXTRA_CA_CERTS",
"T3CODE_TELEMETRY_ENABLED",
"__MISE_DIFF",
...PROXY_ENDPOINT_ENV_NAMES,
...PROXY_BYPASS_ENV_NAMES,
] as const;
const WINDOWS_PROFILE_ENV_NAMES = ["PATH", "FNM_DIR", "FNM_MULTISHELL_PATH"] as const;
const LOCALE_ENV_NAMES = ["LANG", "LC_ALL", "LC_CTYPE"] as const;
Expand All @@ -107,6 +118,23 @@ const trimNonEmpty = (value: string | null | undefined): Option.Option<string> =
Option.filter((entry) => entry.length > 0),
);

const hasNone = (env: NodeJS.ProcessEnv, names: ReadonlyArray<string>) =>
names.every((name) => Option.isNone(trimNonEmpty(env[name])));

// Variables that only make sense together are hydrated as a group: the login
// shell's values apply only when the process inherited none of them.
const hydrateEnvGroup = (
env: NodeJS.ProcessEnv,
shellEnvironment: EnvironmentPatch,
names: ReadonlyArray<string>,
) => {
if (!hasNone(env, names)) return;
for (const name of names) {
const value = trimNonEmpty(shellEnvironment[name]);
if (Option.isSome(value)) env[name] = value.value;
}
};

const pathDelimiter = (platform: NodeJS.Platform) => (platform === "win32" ? ";" : ":");

const readEnvPath = (env: NodeJS.ProcessEnv): Option.Option<string> =>
Expand Down Expand Up @@ -472,25 +500,32 @@ const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosix
}
}

// Locale variables form one precedence group: LC_ALL can override an inherited
// LANG or LC_CTYPE, so only hydrate the group when the process has none of them.
if (
config.platform === "darwin" &&
LOCALE_ENV_NAMES.every((name) => Option.isNone(trimNonEmpty(config.env[name])))
) {
for (const name of LOCALE_ENV_NAMES) {
const value = trimNonEmpty(shellEnvironment[name]);
if (Option.isSome(value)) {
config.env[name] = value.value;
}
}
// Node's own proxy support reads the lowercase alias before the uppercase
// name and treats a blank value as set, so an inherited `https_proxy=""`
// would hide a populated HTTPS_PROXY. Blank means unset here, so drop it.
for (const name of [...PROXY_ENDPOINT_ENV_NAMES, ...PROXY_BYPASS_ENV_NAMES]) {
if (Option.isNone(trimNonEmpty(config.env[name]))) delete config.env[name];
}

// Provider CLIs reach their APIs through the shell's proxy, which a Dock
// launch never sees. An inherited endpoint keeps its own bypass list rather
// than mixing in the shell's, but an inherited bypass list alone is not a
// proxy configuration and still takes the shell's endpoints.
if (hasNone(config.env, PROXY_ENDPOINT_ENV_NAMES)) {
hydrateEnvGroup(config.env, shellEnvironment, PROXY_ENDPOINT_ENV_NAMES);
hydrateEnvGroup(config.env, shellEnvironment, PROXY_BYPASS_ENV_NAMES);
}

// LC_ALL can override an inherited LANG or LC_CTYPE, so locale is one group.
if (config.platform === "darwin") {
hydrateEnvGroup(config.env, shellEnvironment, LOCALE_ENV_NAMES);

// GUI launches inherit no locale from launchd, so spawned agents land in the C
// locale and pbcopy decodes their UTF-8 output as MacRoman. Older supported
// macOS releases do not provide C.UTF-8, so set only LC_CTYPE to a UTF-8 locale
// available on those releases. Leaving LANG unset keeps C-stable collation and
// formatting, so output parsing is unaffected.
if (LOCALE_ENV_NAMES.every((name) => Option.isNone(trimNonEmpty(config.env[name])))) {
if (hasNone(config.env, LOCALE_ENV_NAMES)) {
config.env.LC_CTYPE = FALLBACK_LC_CTYPE;
}
}
Expand Down
4 changes: 2 additions & 2 deletions apps/server/src/binCli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { browserCommand } from "./cli/browser.ts";
import { connectCommand } from "./cli/connect.ts";
import { pairCommand } from "./cli/pair.ts";
import { hasCloudPublicConfig } from "./cloud/publicConfig.ts";
import { extendConnectionAttemptTimeout, keepOutboundFetchOnHttp1 } from "./outboundHttp.ts";
import { configureOutboundFetch, extendConnectionAttemptTimeout } from "./outboundHttp.ts";
import { sharedServerCommandFlags } from "./cli/config.ts";
import { projectCommand } from "./cli/project.ts";
import { runDefaultServerCommand, serveCommand, startCommand } from "./cli/server.ts";
Expand Down Expand Up @@ -96,7 +96,7 @@ export const cli = makeCli();

export function runCli() {
extendConnectionAttemptTimeout();
keepOutboundFetchOnHttp1();
configureOutboundFetch();
Command.run(cli, { version: packageJson.version }).pipe(
Effect.scoped,
Effect.provide(layerCliRuntime),
Expand Down
Loading
Loading