authorgravatar for linus.boehm@finto.deLinus Boehm <linus.boehm@finto.de> 2026-08-15 14:03:28+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-15 12:03:28+00:00
loge204f5a5d34c753399531e772fbb11420caeea09
tree03161095d89413bb74b559dced06c53087c51385
parent3cde99b259249a02e50079f326a00b5ba38e7235
signature Signed by PGP key B5690EEEBB952194

fix(desktop): agent shells inherit a UTF-8 locale on macOS (#6236)


2 files changed, 130 insertions(+), 0 deletions(-)

apps/desktop/src/shell/DesktopShellEnvironment.test.ts+102
...@@ -152,6 +152,108 @@ describe("DesktopShellEnvironment", () => {...@@ -152,6 +152,108 @@ describe("DesktopShellEnvironment", () => {
152 }),152 }),
153 );153 );
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
155 it.effect("hydrates PATH and missing SSH_AUTH_SOCK from the login shell on linux", () =>257 it.effect("hydrates PATH and missing SSH_AUTH_SOCK from the login shell on linux", () =>
156 Effect.gen(function* () {258 Effect.gen(function* () {
157 const env: NodeJS.ProcessEnv = {259 const env: NodeJS.ProcessEnv = {
apps/desktop/src/shell/DesktopShellEnvironment.ts+28
...@@ -71,6 +71,9 @@ const LOGIN_SHELL_ENV_NAMES = [...@@ -71,6 +71,9 @@ const LOGIN_SHELL_ENV_NAMES = [
71 "PATH",71 "PATH",
72 "DBUS_SESSION_BUS_ADDRESS",72 "DBUS_SESSION_BUS_ADDRESS",
73 "DISPLAY",73 "DISPLAY",
74 "LANG",
75 "LC_ALL",
76 "LC_CTYPE",
74 "SSH_AUTH_SOCK",77 "SSH_AUTH_SOCK",
75 "HOMEBREW_PREFIX",78 "HOMEBREW_PREFIX",
76 "HOMEBREW_CELLAR",79 "HOMEBREW_CELLAR",
...@@ -84,6 +87,8 @@ const LOGIN_SHELL_ENV_NAMES = [...@@ -84,6 +87,8 @@ const LOGIN_SHELL_ENV_NAMES = [
84 "WAYLAND_DISPLAY",87 "WAYLAND_DISPLAY",
85] as const;88] as const;
86const WINDOWS_PROFILE_ENV_NAMES = ["PATH", "FNM_DIR", "FNM_MULTISHELL_PATH"] as const;89const WINDOWS_PROFILE_ENV_NAMES = ["PATH", "FNM_DIR", "FNM_MULTISHELL_PATH"] as const;
90const LOCALE_ENV_NAMES = ["LANG", "LC_ALL", "LC_CTYPE"] as const;
91const FALLBACK_LC_CTYPE = "en_US.UTF-8";
87const WINDOWS_SHELL_CANDIDATES = ["pwsh.exe", "powershell.exe"] as const;92const WINDOWS_SHELL_CANDIDATES = ["pwsh.exe", "powershell.exe"] as const;
88const LOGIN_SHELL_TIMEOUT = Duration.seconds(5);93const LOGIN_SHELL_TIMEOUT = Duration.seconds(5);
89const LAUNCHCTL_TIMEOUT = Duration.seconds(2);94const LAUNCHCTL_TIMEOUT = Duration.seconds(2);
...@@ -472,6 +477,29 @@ const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosix...@@ -472,6 +477,29 @@ const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosix
472 }477 }
473 }478 }
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
475 if (503 if (
476 config.platform === "linux" &&504 config.platform === "linux" &&
477 Option.isNone(trimNonEmpty(config.env.DBUS_SESSION_BUS_ADDRESS))505 Option.isNone(trimNonEmpty(config.env.DBUS_SESSION_BUS_ADDRESS))