Skip to content

Commit e204f5a

Browse files
authored
fix(desktop): agent shells inherit a UTF-8 locale on macOS (#6236)
1 parent 3cde99b commit e204f5a

2 files changed

Lines changed: 130 additions & 0 deletions

File tree

apps/desktop/src/shell/DesktopShellEnvironment.test.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,108 @@ describe("DesktopShellEnvironment", () => {
152152
}),
153153
);
154154

155+
it.effect("hydrates the locale from the login shell on macOS", () =>
156+
Effect.gen(function* () {
157+
const env: NodeJS.ProcessEnv = {
158+
SHELL: "/bin/zsh",
159+
PATH: "/usr/bin",
160+
};
161+
162+
yield* runShellEnvironment({
163+
env,
164+
platform: "darwin",
165+
handler: () =>
166+
envOutput({
167+
PATH: "/opt/homebrew/bin:/usr/bin",
168+
LANG: "de_DE.UTF-8",
169+
}),
170+
});
171+
172+
assert.equal(env.LANG, "de_DE.UTF-8");
173+
}),
174+
);
175+
176+
it.effect("preserves an inherited locale over the login shell on macOS", () =>
177+
Effect.gen(function* () {
178+
const env: NodeJS.ProcessEnv = {
179+
SHELL: "/bin/zsh",
180+
PATH: "/usr/bin",
181+
LANG: "en_US.UTF-8",
182+
};
183+
184+
yield* runShellEnvironment({
185+
env,
186+
platform: "darwin",
187+
handler: () =>
188+
envOutput({
189+
PATH: "/opt/homebrew/bin:/usr/bin",
190+
LANG: "de_DE.UTF-8",
191+
}),
192+
});
193+
194+
assert.equal(env.LANG, "en_US.UTF-8");
195+
}),
196+
);
197+
198+
it.effect("does not mix login-shell locale categories into an inherited locale", () =>
199+
Effect.gen(function* () {
200+
const env: NodeJS.ProcessEnv = {
201+
SHELL: "/bin/zsh",
202+
PATH: "/usr/bin",
203+
LANG: "en_US.UTF-8",
204+
};
205+
206+
yield* runShellEnvironment({
207+
env,
208+
platform: "darwin",
209+
handler: () =>
210+
envOutput({
211+
PATH: "/opt/homebrew/bin:/usr/bin",
212+
LC_ALL: "de_DE.UTF-8",
213+
}),
214+
});
215+
216+
assert.equal(env.LANG, "en_US.UTF-8");
217+
assert.equal(env.LC_ALL, undefined);
218+
}),
219+
);
220+
221+
it.effect("falls back to a UTF-8 LC_CTYPE when no locale is available on macOS", () =>
222+
Effect.gen(function* () {
223+
const env: NodeJS.ProcessEnv = {
224+
SHELL: "/bin/zsh",
225+
PATH: "/usr/bin",
226+
};
227+
228+
yield* runShellEnvironment({
229+
env,
230+
platform: "darwin",
231+
handler: () => envOutput({ PATH: "/opt/homebrew/bin:/usr/bin" }),
232+
});
233+
234+
assert.equal(env.LANG, undefined);
235+
assert.equal(env.LC_ALL, undefined);
236+
assert.equal(env.LC_CTYPE, "en_US.UTF-8");
237+
}),
238+
);
239+
240+
it.effect("does not apply the locale fallback on linux", () =>
241+
Effect.gen(function* () {
242+
const env: NodeJS.ProcessEnv = {
243+
SHELL: "/bin/zsh",
244+
PATH: "/usr/bin",
245+
};
246+
247+
yield* runShellEnvironment({
248+
env,
249+
platform: "linux",
250+
handler: () => envOutput({ PATH: "/home/linuxbrew/.linuxbrew/bin:/usr/bin" }),
251+
});
252+
253+
assert.equal(env.LANG, undefined);
254+
}),
255+
);
256+
155257
it.effect("hydrates PATH and missing SSH_AUTH_SOCK from the login shell on linux", () =>
156258
Effect.gen(function* () {
157259
const env: NodeJS.ProcessEnv = {

apps/desktop/src/shell/DesktopShellEnvironment.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,9 @@ const LOGIN_SHELL_ENV_NAMES = [
7171
"PATH",
7272
"DBUS_SESSION_BUS_ADDRESS",
7373
"DISPLAY",
74+
"LANG",
75+
"LC_ALL",
76+
"LC_CTYPE",
7477
"SSH_AUTH_SOCK",
7578
"HOMEBREW_PREFIX",
7679
"HOMEBREW_CELLAR",
@@ -84,6 +87,8 @@ const LOGIN_SHELL_ENV_NAMES = [
8487
"WAYLAND_DISPLAY",
8588
] as const;
8689
const WINDOWS_PROFILE_ENV_NAMES = ["PATH", "FNM_DIR", "FNM_MULTISHELL_PATH"] as const;
90+
const LOCALE_ENV_NAMES = ["LANG", "LC_ALL", "LC_CTYPE"] as const;
91+
const FALLBACK_LC_CTYPE = "en_US.UTF-8";
8792
const WINDOWS_SHELL_CANDIDATES = ["pwsh.exe", "powershell.exe"] as const;
8893
const LOGIN_SHELL_TIMEOUT = Duration.seconds(5);
8994
const LAUNCHCTL_TIMEOUT = Duration.seconds(2);
@@ -472,6 +477,29 @@ const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosix
472477
}
473478
}
474479

480+
// Locale variables form one precedence group: LC_ALL can override an inherited
481+
// LANG or LC_CTYPE, so only hydrate the group when the process has none of them.
482+
if (
483+
config.platform === "darwin" &&
484+
LOCALE_ENV_NAMES.every((name) => Option.isNone(trimNonEmpty(config.env[name])))
485+
) {
486+
for (const name of LOCALE_ENV_NAMES) {
487+
const value = trimNonEmpty(shellEnvironment[name]);
488+
if (Option.isSome(value)) {
489+
config.env[name] = value.value;
490+
}
491+
}
492+
493+
// GUI launches inherit no locale from launchd, so spawned agents land in the C
494+
// locale and pbcopy decodes their UTF-8 output as MacRoman. Older supported
495+
// macOS releases do not provide C.UTF-8, so set only LC_CTYPE to a UTF-8 locale
496+
// available on those releases. Leaving LANG unset keeps C-stable collation and
497+
// formatting, so output parsing is unaffected.
498+
if (LOCALE_ENV_NAMES.every((name) => Option.isNone(trimNonEmpty(config.env[name])))) {
499+
config.env.LC_CTYPE = FALLBACK_LC_CTYPE;
500+
}
501+
}
502+
475503
if (
476504
config.platform === "linux" &&
477505
Option.isNone(trimNonEmpty(config.env.DBUS_SESSION_BUS_ADDRESS))

0 commit comments

Comments
 (0)