@@ -2,12 +2,14 @@ import os from 'os';
22import fs from 'fs' ;
33import path from 'path' ;
44import crypto from 'crypto' ;
5+ import { StringDecoder } from 'string_decoder' ;
56import type { IPty } from 'node-pty' ;
67import { log } from '../lib/logger' ;
78import { PROVIDERS , type ProviderDefinition } from '@shared/providers/registry' ;
89import { parsePtyId } from '@shared/ptyId' ;
910import { providerStatusCache } from './providerStatusCache' ;
1011import { errorTracking } from '../errorTracking' ;
12+ import { LOCALE_ENV_VARS , DEFAULT_UTF8_LOCALE , isUtf8Locale } from '../utils/locale' ;
1113
1214/**
1315 * Suppress EPIPE/EIO errors on a PTY's underlying socket.
@@ -103,6 +105,64 @@ type PtyRecord = {
103105const ptys = new Map < string , PtyRecord > ( ) ;
104106const MIN_PTY_COLS = 2 ;
105107const MIN_PTY_ROWS = 1 ;
108+ export function getLocaleEnv ( sourceEnv : NodeJS . ProcessEnv = process . env ) : Record < string , string > {
109+ if ( process . platform === 'win32' ) {
110+ const localeEnv : Record < string , string > = { } ;
111+ for ( const key of LOCALE_ENV_VARS ) {
112+ const value = sourceEnv [ key ] ;
113+ if ( value && isUtf8Locale ( value ) ) {
114+ localeEnv [ key ] = value ;
115+ }
116+ }
117+ return localeEnv ;
118+ }
119+
120+ // On non-Windows, preserve explicit UTF-8 locale choices and only fall back
121+ // to a minimal UTF-8 locale when no effective UTF-8 locale is available.
122+ const localeEnv : Record < string , string > = { } ;
123+ const lang = sourceEnv . LANG ;
124+ const lcAll = sourceEnv . LC_ALL ;
125+ const lcCtype = sourceEnv . LC_CTYPE ;
126+
127+ if ( lcAll && isUtf8Locale ( lcAll ) ) {
128+ localeEnv . LC_ALL = lcAll ;
129+ }
130+ if ( lang && isUtf8Locale ( lang ) ) {
131+ localeEnv . LANG = lang ;
132+ }
133+ if ( lcCtype && isUtf8Locale ( lcCtype ) ) {
134+ localeEnv . LC_CTYPE = lcCtype ;
135+ }
136+
137+ if ( localeEnv . LC_ALL || localeEnv . LANG || localeEnv . LC_CTYPE ) {
138+ return localeEnv ;
139+ }
140+
141+ localeEnv . LANG = DEFAULT_UTF8_LOCALE ;
142+ localeEnv . LC_CTYPE = DEFAULT_UTF8_LOCALE ;
143+ return localeEnv ;
144+ }
145+
146+ export function mergeEnvWithNormalizedLocale (
147+ ...envs : Array < NodeJS . ProcessEnv | undefined >
148+ ) : Record < string , string > {
149+ const mergedEnv : NodeJS . ProcessEnv = { } ;
150+
151+ for ( const env of envs ) {
152+ if ( ! env ) continue ;
153+ Object . assign ( mergedEnv , env ) ;
154+ }
155+
156+ const localeEnv = getLocaleEnv ( mergedEnv ) ;
157+ for ( const key of LOCALE_ENV_VARS ) {
158+ delete mergedEnv [ key ] ;
159+ }
160+
161+ return {
162+ ...mergedEnv ,
163+ ...localeEnv ,
164+ } as Record < string , string > ;
165+ }
106166
107167function applyAgentEventHookEnv ( env : Record < string , string > , ptyId : string ) : void {
108168 const hookPort = agentEventService . getPort ( ) ;
@@ -1036,7 +1096,7 @@ export function startSshPty(options: {
10361096 HOME : process . env . HOME || os . homedir ( ) ,
10371097 USER : process . env . USER || os . userInfo ( ) . username ,
10381098 PATH : process . env . PATH || process . env . Path || '' ,
1039- ...( process . env . LANG && { LANG : process . env . LANG } ) ,
1099+ ...getLocaleEnv ( ) ,
10401100 ...( process . env . TMPDIR && { TMPDIR : process . env . TMPDIR } ) ,
10411101 ...getDisplayEnv ( ) ,
10421102 ...( process . env . SSH_AUTH_SOCK && { SSH_AUTH_SOCK : process . env . SSH_AUTH_SOCK } ) ,
@@ -1201,7 +1261,7 @@ export function startDirectPty(options: {
12011261 USER : process . env . USER || os . userInfo ( ) . username ,
12021262 // Include PATH so CLI can find its dependencies
12031263 PATH : process . env . PATH || process . env . Path || '' ,
1204- ...( process . env . LANG && { LANG : process . env . LANG } ) ,
1264+ ...getLocaleEnv ( ) ,
12051265 ...( process . env . TMPDIR && { TMPDIR : process . env . TMPDIR } ) ,
12061266 ...getDisplayEnv ( ) ,
12071267 ...( process . env . SSH_AUTH_SOCK && { SSH_AUTH_SOCK : process . env . SSH_AUTH_SOCK } ) ,
@@ -1323,21 +1383,20 @@ export async function startPty(options: {
13231383 // tools create clean user environments.
13241384 //
13251385 // See: https://github.com/generalaction/emdash/issues/485
1326- const useEnv : Record < string , string > = {
1386+ const useEnv = mergeEnvWithNormalizedLocale ( {
13271387 TERM : 'xterm-256color' ,
13281388 COLORTERM : 'truecolor' ,
13291389 TERM_PROGRAM : 'emdash' ,
13301390 HOME : process . env . HOME || os . homedir ( ) ,
13311391 USER : process . env . USER || os . userInfo ( ) . username ,
13321392 SHELL : process . env . SHELL || defaultShell ,
13331393 ...( process . platform === 'win32' ? getWindowsEssentialEnv ( ) : { } ) ,
1334- ...( process . env . LANG && { LANG : process . env . LANG } ) ,
13351394 ...( process . env . TMPDIR && { TMPDIR : process . env . TMPDIR } ) ,
13361395 ...( process . env . DISPLAY && { DISPLAY : process . env . DISPLAY } ) ,
13371396 ...getDisplayEnv ( ) ,
13381397 ...( process . env . SSH_AUTH_SOCK && { SSH_AUTH_SOCK : process . env . SSH_AUTH_SOCK } ) ,
13391398 ...( env || { } ) ,
1340- } ;
1399+ } ) ;
13411400
13421401 applyAgentEventHookEnv ( useEnv , id ) ;
13431402
@@ -1651,6 +1710,82 @@ export interface LifecyclePtyHandle {
16511710 kill : ( signal ?: string ) => void ;
16521711}
16531712
1713+ export function createUtf8StreamForwarder ( emitData : ( data : string ) => void ) : {
1714+ pushStdout : ( buf : Buffer ) => void ;
1715+ pushStderr : ( buf : Buffer ) => void ;
1716+ flush : ( ) => void ;
1717+ } {
1718+ const stdoutDecoder = new StringDecoder ( 'utf8' ) ;
1719+ const stderrDecoder = new StringDecoder ( 'utf8' ) ;
1720+ let flushed = false ;
1721+
1722+ const emitIfPresent = ( data : string ) => {
1723+ if ( ! data ) return ;
1724+ emitData ( data ) ;
1725+ } ;
1726+
1727+ return {
1728+ pushStdout : ( buf : Buffer ) => {
1729+ emitIfPresent ( stdoutDecoder . write ( buf ) ) ;
1730+ } ,
1731+ pushStderr : ( buf : Buffer ) => {
1732+ emitIfPresent ( stderrDecoder . write ( buf ) ) ;
1733+ } ,
1734+ flush : ( ) => {
1735+ if ( flushed ) return ;
1736+ flushed = true ;
1737+ emitIfPresent ( stdoutDecoder . end ( ) ) ;
1738+ emitIfPresent ( stderrDecoder . end ( ) ) ;
1739+ } ,
1740+ } ;
1741+ }
1742+
1743+ type LifecycleSpawnFallbackChild = {
1744+ stdout ?: { on : ( event : 'data' , listener : ( buf : Buffer ) => void ) => void } | null ;
1745+ stderr ?: { on : ( event : 'data' , listener : ( buf : Buffer ) => void ) => void } | null ;
1746+ on : ( event : 'error' | 'exit' | 'close' , listener : ( ...args : any [ ] ) => void ) => void ;
1747+ } ;
1748+
1749+ export function attachLifecycleSpawnFallbackHandlers (
1750+ child : LifecycleSpawnFallbackChild ,
1751+ callbacks : {
1752+ onData : ( data : string ) => void ;
1753+ onExit : ( exitCode : number | null , signal : string | null ) => void ;
1754+ onError : ( error : Error ) => void ;
1755+ }
1756+ ) : void {
1757+ const { onData, onExit, onError } = callbacks ;
1758+ let didExit = false ;
1759+ let exitCode : number | null = null ;
1760+ let exitSignal : string | null = null ;
1761+ const forwarder = createUtf8StreamForwarder ( onData ) ;
1762+
1763+ child . stdout ?. on ( 'data' , ( buf : Buffer ) => {
1764+ forwarder . pushStdout ( buf ) ;
1765+ } ) ;
1766+ child . stderr ?. on ( 'data' , ( buf : Buffer ) => {
1767+ forwarder . pushStderr ( buf ) ;
1768+ } ) ;
1769+
1770+ child . on ( 'error' , ( error : Error ) => {
1771+ forwarder . flush ( ) ;
1772+ onError ( error ) ;
1773+ } ) ;
1774+
1775+ child . on ( 'exit' , ( code : number | null , signal : string | null ) => {
1776+ didExit = true ;
1777+ exitCode = code ;
1778+ exitSignal = signal ?? null ;
1779+ } ) ;
1780+
1781+ child . on ( 'close' , ( ) => {
1782+ // Flush only after stdio closes so buffered UTF-8 bytes can complete.
1783+ forwarder . flush ( ) ;
1784+ if ( ! didExit ) return ;
1785+ onExit ( exitCode , exitSignal ) ;
1786+ } ) ;
1787+ }
1788+
16541789function startLifecycleSpawnFallback ( options : {
16551790 id : string ;
16561791 command : string ;
@@ -1664,26 +1799,22 @@ function startLifecycleSpawnFallback(options: {
16641799 cwd : cwd || os . homedir ( ) ,
16651800 shell : true ,
16661801 detached : true ,
1667- env : { ... process . env , ... ( env || { } ) } ,
1802+ env : mergeEnvWithNormalizedLocale ( process . env , env ) ,
16681803 } ) ;
16691804
16701805 const dataCallbacks : Array < ( data : string ) => void > = [ ] ;
16711806 const exitCallbacks : Array < ( exitCode : number | null , signal : string | null ) => void > = [ ] ;
16721807 const errorCallbacks : Array < ( error : Error ) => void > = [ ] ;
1673-
1674- const onData = ( buf : Buffer ) => {
1675- const str = buf . toString ( ) ;
1676- for ( const cb of dataCallbacks ) cb ( str ) ;
1677- } ;
1678- child . stdout ?. on ( 'data' , onData ) ;
1679- child . stderr ?. on ( 'data' , onData ) ;
1680-
1681- child . on ( 'error' , ( error : Error ) => {
1682- for ( const cb of errorCallbacks ) cb ( error ) ;
1683- } ) ;
1684-
1685- child . on ( 'exit' , ( code , signal ) => {
1686- for ( const cb of exitCallbacks ) cb ( code , signal ?? null ) ;
1808+ attachLifecycleSpawnFallbackHandlers ( child , {
1809+ onData : ( data ) => {
1810+ for ( const cb of dataCallbacks ) cb ( data ) ;
1811+ } ,
1812+ onExit : ( code , signal ) => {
1813+ for ( const cb of exitCallbacks ) cb ( code , signal ) ;
1814+ } ,
1815+ onError : ( error ) => {
1816+ for ( const cb of errorCallbacks ) cb ( error ) ;
1817+ } ,
16871818 } ) ;
16881819
16891820 return {
@@ -1731,21 +1862,20 @@ export function startLifecyclePty(options: {
17311862 const { id, command, cwd, env } = options ;
17321863 const defaultShell = getDefaultShell ( ) ;
17331864
1734- const useEnv : Record < string , string > = {
1865+ const useEnv = mergeEnvWithNormalizedLocale ( {
17351866 TERM : 'xterm-256color' ,
17361867 COLORTERM : 'truecolor' ,
17371868 TERM_PROGRAM : 'emdash' ,
17381869 HOME : process . env . HOME || os . homedir ( ) ,
17391870 USER : process . env . USER || os . userInfo ( ) . username ,
17401871 SHELL : process . env . SHELL || defaultShell ,
17411872 ...( process . platform === 'win32' ? getWindowsEssentialEnv ( ) : { } ) ,
1742- ...( process . env . LANG && { LANG : process . env . LANG } ) ,
17431873 ...( process . env . TMPDIR && { TMPDIR : process . env . TMPDIR } ) ,
17441874 ...( process . env . DISPLAY && { DISPLAY : process . env . DISPLAY } ) ,
17451875 ...getDisplayEnv ( ) ,
17461876 ...( process . env . SSH_AUTH_SOCK && { SSH_AUTH_SOCK : process . env . SSH_AUTH_SOCK } ) ,
17471877 ...( env || { } ) ,
1748- } ;
1878+ } ) ;
17491879
17501880 const proc = pty . spawn ( defaultShell , [ '-ilc' , command ] , {
17511881 name : 'xterm-256color' ,
0 commit comments