1+ using System . Collections ;
12using System . Diagnostics ;
23using ManagedCode . CodexSharpSDK . Client ;
34using ManagedCode . CodexSharpSDK . Configuration ;
5+ using ManagedCode . CodexSharpSDK . Execution ;
46using ManagedCode . CodexSharpSDK . Internal ;
7+ using Microsoft . Extensions . Logging ;
58
69namespace ManagedCode . CodexSharpSDK . Tests . Shared ;
710
811internal static class RealCodexTestSupport
912{
1013 private const string ModelEnvVar = "CODEX_TEST_MODEL" ;
14+ private const string SolutionFileName = "ManagedCode.CodexSharpSDK.slnx" ;
15+ private const string TestsDirectoryName = "tests" ;
16+ private const string SandboxDirectoryName = ".sandbox" ;
17+ private const string SandboxPrefix = "RealCodexTestSupport-" ;
18+ private const string CodexHomeEnvironmentVariable = "CODEX_HOME" ;
19+ private const string HomeEnvironmentVariable = "HOME" ;
20+ private const string UserProfileEnvironmentVariable = "USERPROFILE" ;
21+ private const string XdgConfigHomeEnvironmentVariable = "XDG_CONFIG_HOME" ;
22+ private const string AppDataEnvironmentVariable = "APPDATA" ;
23+ private const string LocalAppDataEnvironmentVariable = "LOCALAPPDATA" ;
24+ private const string OpenAiApiKeyEnvironmentVariable = "OPENAI_API_KEY" ;
25+ private const string OpenAiBaseUrlEnvironmentVariable = "OPENAI_BASE_URL" ;
26+ private const string CodexApiKeyEnvironmentVariable = "CODEX_API_KEY" ;
27+ private const string CodexHomeDirectoryName = ".codex" ;
28+ private const string ConfigDirectoryName = ".config" ;
29+ private const string AppDataDirectoryName = "AppData" ;
30+ private const string RoamingDirectoryName = "Roaming" ;
31+ private const string LocalDirectoryName = "Local" ;
32+ private const string ConfigFileName = "config.toml" ;
33+ private const string SessionsDirectoryName = "sessions" ;
34+ private const string AuthFileName = "auth.json" ;
35+ private const string KosAuthFileName = "kos-auth.json" ;
36+ private const string SandboxCleanupFailureDataKey = "SandboxCleanupFailure" ;
1137
1238 public static RealCodexTestSettings GetRequiredSettings ( )
1339 {
@@ -17,20 +43,46 @@ public static RealCodexTestSettings GetRequiredSettings()
1743 "Real Codex tests require the codex CLI. Install it first and ensure it is available in PATH." ) ;
1844 }
1945
20- return new RealCodexTestSettings ( ResolveModel ( ) ) ;
46+ var sandboxDirectory = CreateSandboxDirectory ( ) ;
47+ try
48+ {
49+ var model = ResolveModel ( ) ;
50+ var environmentOverrides = CreateAuthenticatedEnvironmentOverrides ( sandboxDirectory , model ) ;
51+ return new RealCodexTestSettings (
52+ model ,
53+ sandboxDirectory ,
54+ environmentOverrides [ CodexHomeEnvironmentVariable ] ,
55+ environmentOverrides ) ;
56+ }
57+ catch ( Exception exception )
58+ {
59+ AttachCleanupFailure ( exception , sandboxDirectory ) ;
60+ throw ;
61+ }
2162 }
2263
23- public static CodexClient CreateClient ( CodexOptions ? options = null )
64+ public static CodexClient CreateClient ( RealCodexTestSettings settings , CodexOptions ? options = null )
2465 {
25- return new CodexClient ( options ?? new CodexOptions ( ) ) ;
66+ ArgumentNullException . ThrowIfNull ( settings ) ;
67+ return new CodexClient ( CreateCodexOptions ( settings , options ) ) ;
2668 }
2769
28- public static async Task < string ? > FindPersistedRolloutPathAsync ( string threadId , TimeSpan timeout )
70+ public static CodexExec CreateExec ( RealCodexTestSettings settings , ILogger ? logger = null )
2971 {
72+ ArgumentNullException . ThrowIfNull ( settings ) ;
73+ return new CodexExec ( environmentOverride : settings . EnvironmentOverrides , logger : logger ) ;
74+ }
75+
76+ public static async Task < string ? > FindPersistedRolloutPathAsync (
77+ RealCodexTestSettings settings ,
78+ string threadId ,
79+ TimeSpan timeout )
80+ {
81+ ArgumentNullException . ThrowIfNull ( settings ) ;
3082 ArgumentException . ThrowIfNullOrWhiteSpace ( threadId ) ;
3183
32- var sessionsPath = GetCodexSessionsPath ( ) ;
33- if ( sessionsPath is null || ! Directory . Exists ( sessionsPath ) )
84+ var sessionsPath = Path . Combine ( settings . CodexHomeDirectory , SessionsDirectoryName ) ;
85+ if ( ! Directory . Exists ( sessionsPath ) )
3486 {
3587 return null ;
3688 }
@@ -127,24 +179,24 @@ private static string ResolveModel()
127179
128180 private static string ? GetCodexConfigPath ( )
129181 {
130- var homeDirectory = Environment . GetFolderPath ( Environment . SpecialFolder . UserProfile ) ;
131- if ( string . IsNullOrWhiteSpace ( homeDirectory ) )
182+ var codexHomeDirectory = GetSourceCodexHomePath ( ) ;
183+ if ( string . IsNullOrWhiteSpace ( codexHomeDirectory ) )
132184 {
133185 return null ;
134186 }
135187
136- return Path . Combine ( homeDirectory , ".codex" , "config.toml" ) ;
188+ return Path . Combine ( codexHomeDirectory , ConfigFileName ) ;
137189 }
138190
139- private static string ? GetCodexSessionsPath ( )
191+ private static string ? GetSourceCodexHomePath ( )
140192 {
141193 var homeDirectory = Environment . GetFolderPath ( Environment . SpecialFolder . UserProfile ) ;
142194 if ( string . IsNullOrWhiteSpace ( homeDirectory ) )
143195 {
144196 return null ;
145197 }
146198
147- return Path . Combine ( homeDirectory , ".codex" , "sessions" ) ;
199+ return Path . Combine ( homeDirectory , CodexHomeDirectoryName ) ;
148200 }
149201
150202 private static bool IsCodexAvailable ( )
@@ -160,6 +212,177 @@ private static bool IsCodexAvailable()
160212 OperatingSystem . IsWindows ( ) ,
161213 out _ ) ;
162214 }
215+
216+ private static CodexOptions CreateCodexOptions ( RealCodexTestSettings settings , CodexOptions ? options )
217+ {
218+ var environmentOverrides = new Dictionary < string , string > ( settings . EnvironmentOverrides , StringComparer . Ordinal ) ;
219+ if ( options ? . EnvironmentVariables is not null )
220+ {
221+ foreach ( var ( key , value ) in options . EnvironmentVariables )
222+ {
223+ environmentOverrides [ key ] = value ;
224+ }
225+ }
226+
227+ return new CodexOptions
228+ {
229+ CodexExecutablePath = options ? . CodexExecutablePath ,
230+ BaseUrl = options ? . BaseUrl ,
231+ ApiKey = options ? . ApiKey ,
232+ Config = options ? . Config ,
233+ EnvironmentVariables = environmentOverrides ,
234+ Logger = options ? . Logger ,
235+ } ;
236+ }
237+
238+ private static Dictionary < string , string > CreateAuthenticatedEnvironmentOverrides ( string sandboxDirectory , string model )
239+ {
240+ var codexHome = Path . Combine ( sandboxDirectory , CodexHomeDirectoryName ) ;
241+ var configHome = Path . Combine ( sandboxDirectory , ConfigDirectoryName ) ;
242+ var appData = Path . Combine ( sandboxDirectory , AppDataDirectoryName , RoamingDirectoryName ) ;
243+ var localAppData = Path . Combine ( sandboxDirectory , AppDataDirectoryName , LocalDirectoryName ) ;
244+
245+ Directory . CreateDirectory ( codexHome ) ;
246+ Directory . CreateDirectory ( configHome ) ;
247+ Directory . CreateDirectory ( appData ) ;
248+ Directory . CreateDirectory ( localAppData ) ;
249+
250+ WriteIsolatedCodexConfig ( codexHome , model ) ;
251+ CopyAuthenticationArtifacts ( codexHome ) ;
252+
253+ var environmentOverrides = Environment . GetEnvironmentVariables ( )
254+ . Cast < DictionaryEntry > ( )
255+ . Where ( entry => entry . Key is string && entry . Value is not null )
256+ . ToDictionary (
257+ entry => entry . Key . ToString ( ) ?? string . Empty ,
258+ entry => entry . Value ? . ToString ( ) ?? string . Empty ,
259+ StringComparer . Ordinal ) ;
260+
261+ environmentOverrides [ CodexHomeEnvironmentVariable ] = codexHome ;
262+ environmentOverrides [ HomeEnvironmentVariable ] = sandboxDirectory ;
263+ environmentOverrides [ UserProfileEnvironmentVariable ] = sandboxDirectory ;
264+ environmentOverrides [ XdgConfigHomeEnvironmentVariable ] = configHome ;
265+ environmentOverrides [ AppDataEnvironmentVariable ] = appData ;
266+ environmentOverrides [ LocalAppDataEnvironmentVariable ] = localAppData ;
267+ environmentOverrides [ OpenAiApiKeyEnvironmentVariable ] = string . Empty ;
268+ environmentOverrides [ OpenAiBaseUrlEnvironmentVariable ] = string . Empty ;
269+ environmentOverrides [ CodexApiKeyEnvironmentVariable ] = string . Empty ;
270+
271+ return environmentOverrides ;
272+ }
273+
274+ private static void WriteIsolatedCodexConfig ( string codexHomeDirectory , string model )
275+ {
276+ var escapedModel = model
277+ . Replace ( "\\ " , "\\ \\ " , StringComparison . Ordinal )
278+ . Replace ( "\" " , "\\ \" " , StringComparison . Ordinal ) ;
279+ var configPath = Path . Combine ( codexHomeDirectory , ConfigFileName ) ;
280+ File . WriteAllText ( configPath , $ "model = \" { escapedModel } \" { Environment . NewLine } ") ;
281+ }
282+
283+ private static void CopyAuthenticationArtifacts ( string codexHomeDirectory )
284+ {
285+ var sourceCodexHome = GetSourceCodexHomePath ( ) ;
286+ if ( string . IsNullOrWhiteSpace ( sourceCodexHome ) || ! Directory . Exists ( sourceCodexHome ) )
287+ {
288+ throw new InvalidOperationException (
289+ "Real Codex tests require an existing local Codex login. Run 'codex login' first." ) ;
290+ }
291+
292+ var copiedFiles = 0 ;
293+ copiedFiles += CopyAuthenticationArtifactIfExists ( sourceCodexHome , codexHomeDirectory , AuthFileName ) ;
294+ copiedFiles += CopyAuthenticationArtifactIfExists ( sourceCodexHome , codexHomeDirectory , KosAuthFileName ) ;
295+
296+ if ( copiedFiles == 0 )
297+ {
298+ throw new InvalidOperationException (
299+ "Real Codex tests require an existing local Codex login. Run 'codex login' first." ) ;
300+ }
301+ }
302+
303+ private static int CopyAuthenticationArtifactIfExists (
304+ string sourceCodexHome ,
305+ string destinationCodexHome ,
306+ string fileName )
307+ {
308+ var sourcePath = Path . Combine ( sourceCodexHome , fileName ) ;
309+ if ( ! File . Exists ( sourcePath ) )
310+ {
311+ return 0 ;
312+ }
313+
314+ var destinationPath = Path . Combine ( destinationCodexHome , fileName ) ;
315+ File . Copy ( sourcePath , destinationPath , overwrite : true ) ;
316+ return 1 ;
317+ }
318+
319+ private static string CreateSandboxDirectory ( )
320+ {
321+ var repositoryRoot = ResolveRepositoryRootPath ( ) ;
322+ var sandboxDirectory = Path . Combine (
323+ repositoryRoot ,
324+ TestsDirectoryName ,
325+ SandboxDirectoryName ,
326+ $ "{ SandboxPrefix } { Guid . NewGuid ( ) : N} ") ;
327+ Directory . CreateDirectory ( sandboxDirectory ) ;
328+ return sandboxDirectory ;
329+ }
330+
331+ private static string ResolveRepositoryRootPath ( )
332+ {
333+ var current = new DirectoryInfo ( AppContext . BaseDirectory ) ;
334+ while ( current is not null )
335+ {
336+ if ( File . Exists ( Path . Combine ( current . FullName , SolutionFileName ) ) )
337+ {
338+ return current . FullName ;
339+ }
340+
341+ current = current . Parent ;
342+ }
343+
344+ throw new InvalidOperationException ( "Could not locate repository root from test execution directory." ) ;
345+ }
346+
347+ private static void AttachCleanupFailure ( Exception originalException , string sandboxDirectory )
348+ {
349+ try
350+ {
351+ if ( Directory . Exists ( sandboxDirectory ) )
352+ {
353+ Directory . Delete ( sandboxDirectory , recursive : true ) ;
354+ }
355+ }
356+ catch ( IOException cleanupException )
357+ {
358+ originalException . Data [ SandboxCleanupFailureDataKey ] = cleanupException ;
359+ }
360+ catch ( UnauthorizedAccessException cleanupException )
361+ {
362+ originalException . Data [ SandboxCleanupFailureDataKey ] = cleanupException ;
363+ }
364+ }
163365}
164366
165- internal sealed record RealCodexTestSettings ( string Model ) ;
367+ internal sealed class RealCodexTestSettings (
368+ string model ,
369+ string sandboxDirectory ,
370+ string codexHomeDirectory ,
371+ IReadOnlyDictionary < string , string > environmentOverrides ) : IDisposable
372+ {
373+ public string Model { get ; } = model ;
374+
375+ public string SandboxDirectory { get ; } = sandboxDirectory ;
376+
377+ public string CodexHomeDirectory { get ; } = codexHomeDirectory ;
378+
379+ public IReadOnlyDictionary < string , string > EnvironmentOverrides { get ; } = environmentOverrides ;
380+
381+ public void Dispose ( )
382+ {
383+ if ( Directory . Exists ( SandboxDirectory ) )
384+ {
385+ Directory . Delete ( SandboxDirectory , recursive : true ) ;
386+ }
387+ }
388+ }
0 commit comments