Skip to content

Commit cc6fd76

Browse files
committed
Add cross-platform unauthenticated Codex CLI smoke check
1 parent f133fa1 commit cc6fd76

File tree

3 files changed

+115
-7
lines changed

3 files changed

+115
-7
lines changed

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ If no new rule is detected -> do not update the file.
123123
- For CLI process interaction tests, use the real installed `codex` CLI (no `FakeCodexProcessRunner` test doubles).
124124
- Treat `codex` CLI as a test prerequisite: ensure local/CI test setup installs `codex` before running CLI interaction tests; do not replace this with fakes.
125125
- CI must validate SDK on all Codex-supported desktop/server platforms (macOS, Linux, Windows): run build + tests and include a non-auth smoke check that `codex` is discoverable and invokable.
126+
- Cross-platform non-auth smoke must run `codex` from local installation in CI and verify unauthenticated behavior explicitly (for example `codex login status` in isolated profile returns "Not logged in"), proving binary discovery + process launch on each platform.
126127
- Real Codex integration tests must rely on existing local Codex CLI login/session only; do not read or require `OPENAI_API_KEY` in test setup.
127128
- Do not use nullable `TryGetSettings()` + early `return` skip patterns in real integration tests; resolve required settings directly and fail fast with actionable errors when missing.
128129
- Do not bypass integration tests on Windows with unconditional early returns; keep tests cross-platform for supported Codex CLI environments.

CodexSharpSDK.Tests/Integration/CodexCliSmokeTests.cs

Lines changed: 113 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,32 @@ namespace ManagedCode.CodexSharpSDK.Tests.Integration;
55

66
public class CodexCliSmokeTests
77
{
8+
private const string SolutionFileName = "ManagedCode.CodexSharpSDK.slnx";
9+
private const string TestsDirectoryName = "tests";
10+
private const string SandboxDirectoryName = ".sandbox";
11+
private const string SandboxPrefix = "CodexCliSmokeTests-";
812
private const string PathEnvironmentVariable = "PATH";
13+
private const string HomeEnvironmentVariable = "HOME";
14+
private const string UserProfileEnvironmentVariable = "USERPROFILE";
15+
private const string XdgConfigHomeEnvironmentVariable = "XDG_CONFIG_HOME";
16+
private const string AppDataEnvironmentVariable = "APPDATA";
17+
private const string LocalAppDataEnvironmentVariable = "LOCALAPPDATA";
18+
private const string OpenAiApiKeyEnvironmentVariable = "OPENAI_API_KEY";
19+
private const string OpenAiBaseUrlEnvironmentVariable = "OPENAI_BASE_URL";
20+
private const string CodexApiKeyEnvironmentVariable = "CODEX_API_KEY";
21+
private const string CodexHomeDirectoryName = ".codex";
22+
private const string AppDataDirectoryName = "AppData";
23+
private const string RoamingDirectoryName = "Roaming";
24+
private const string LocalDirectoryName = "Local";
25+
private const string ConfigDirectoryName = ".config";
926
private const string VersionFlag = "--version";
1027
private const string ExecCommand = "exec";
28+
private const string LoginCommand = "login";
29+
private const string StatusCommand = "status";
1130
private const string HelpFlag = "--help";
1231
private const string VersionToken = "codex-cli";
1332
private const string ExecHelpToken = "Run Codex non-interactively";
33+
private const string NotLoggedInToken = "Not logged in";
1434

1535
[Test]
1636
public async Task CodexCli_Smoke_FindExecutablePath_ResolvesExistingBinary()
@@ -24,7 +44,7 @@ public async Task CodexCli_Smoke_VersionCommand_ReturnsCodexCliVersion()
2444
{
2545
var executablePath = ResolveExecutablePath();
2646

27-
var result = await RunCodexAsync(executablePath, VersionFlag);
47+
var result = await RunCodexAsync(executablePath, null, VersionFlag);
2848
await Assert.That(result.ExitCode).IsEqualTo(0);
2949

3050
var output = string.Concat(result.StandardOutput, result.StandardError);
@@ -36,13 +56,34 @@ public async Task CodexCli_Smoke_ExecHelpCommand_ReturnsSuccess()
3656
{
3757
var executablePath = ResolveExecutablePath();
3858

39-
var result = await RunCodexAsync(executablePath, ExecCommand, HelpFlag);
59+
var result = await RunCodexAsync(executablePath, null, ExecCommand, HelpFlag);
4060
await Assert.That(result.ExitCode).IsEqualTo(0);
4161

4262
var output = string.Concat(result.StandardOutput, result.StandardError);
4363
await Assert.That(output.Contains(ExecHelpToken, StringComparison.Ordinal)).IsTrue();
4464
}
4565

66+
[Test]
67+
public async Task CodexCli_Smoke_LoginStatusWithoutAuth_ReportsNotLoggedIn()
68+
{
69+
var executablePath = ResolveExecutablePath();
70+
var sandboxDirectory = CreateSandboxDirectory();
71+
72+
try
73+
{
74+
var environmentOverrides = CreateUnauthenticatedEnvironmentOverrides(sandboxDirectory);
75+
var result = await RunCodexAsync(executablePath, environmentOverrides, LoginCommand, StatusCommand);
76+
await Assert.That(result.ExitCode).IsNotEqualTo(0);
77+
78+
var output = string.Concat(result.StandardOutput, result.StandardError);
79+
await Assert.That(output.Contains(NotLoggedInToken, StringComparison.OrdinalIgnoreCase)).IsTrue();
80+
}
81+
finally
82+
{
83+
Directory.Delete(sandboxDirectory, recursive: true);
84+
}
85+
}
86+
4687
private static string ResolveExecutablePath()
4788
{
4889
var resolvedPath = CodexCliLocator.FindCodexPath(null);
@@ -67,10 +108,63 @@ private static string ResolveExecutablePath()
67108
throw new InvalidOperationException("Failed to resolve Codex CLI path.");
68109
}
69110

111+
private static string CreateSandboxDirectory()
112+
{
113+
var repositoryRoot = ResolveRepositoryRootPath();
114+
var sandboxDirectory = Path.Combine(
115+
repositoryRoot,
116+
TestsDirectoryName,
117+
SandboxDirectoryName,
118+
$"{SandboxPrefix}{Guid.NewGuid():N}");
119+
Directory.CreateDirectory(sandboxDirectory);
120+
return sandboxDirectory;
121+
}
122+
123+
private static string ResolveRepositoryRootPath()
124+
{
125+
var current = new DirectoryInfo(AppContext.BaseDirectory);
126+
while (current is not null)
127+
{
128+
if (File.Exists(Path.Combine(current.FullName, SolutionFileName)))
129+
{
130+
return current.FullName;
131+
}
132+
133+
current = current.Parent;
134+
}
135+
136+
throw new InvalidOperationException("Could not locate repository root from test execution directory.");
137+
}
138+
139+
private static Dictionary<string, string> CreateUnauthenticatedEnvironmentOverrides(string sandboxDirectory)
140+
{
141+
var codexHome = Path.Combine(sandboxDirectory, CodexHomeDirectoryName);
142+
var configHome = Path.Combine(sandboxDirectory, ConfigDirectoryName);
143+
var appData = Path.Combine(sandboxDirectory, AppDataDirectoryName, RoamingDirectoryName);
144+
var localAppData = Path.Combine(sandboxDirectory, AppDataDirectoryName, LocalDirectoryName);
145+
146+
Directory.CreateDirectory(codexHome);
147+
Directory.CreateDirectory(configHome);
148+
Directory.CreateDirectory(appData);
149+
Directory.CreateDirectory(localAppData);
150+
151+
return new Dictionary<string, string>(StringComparer.Ordinal)
152+
{
153+
[HomeEnvironmentVariable] = sandboxDirectory,
154+
[UserProfileEnvironmentVariable] = sandboxDirectory,
155+
[XdgConfigHomeEnvironmentVariable] = configHome,
156+
[AppDataEnvironmentVariable] = appData,
157+
[LocalAppDataEnvironmentVariable] = localAppData,
158+
[OpenAiApiKeyEnvironmentVariable] = string.Empty,
159+
[OpenAiBaseUrlEnvironmentVariable] = string.Empty,
160+
[CodexApiKeyEnvironmentVariable] = string.Empty,
161+
};
162+
}
163+
70164
private static async Task<CodexProcessResult> RunCodexAsync(
71165
string executablePath,
72-
string firstArgument,
73-
string? secondArgument = null)
166+
IReadOnlyDictionary<string, string>? environmentOverrides,
167+
params string[] arguments)
74168
{
75169
var startInfo = new ProcessStartInfo(executablePath)
76170
{
@@ -80,10 +174,22 @@ private static async Task<CodexProcessResult> RunCodexAsync(
80174
CreateNoWindow = true,
81175
};
82176

83-
startInfo.ArgumentList.Add(firstArgument);
84-
if (!string.IsNullOrWhiteSpace(secondArgument))
177+
foreach (var argument in arguments)
85178
{
86-
startInfo.ArgumentList.Add(secondArgument);
179+
if (string.IsNullOrWhiteSpace(argument))
180+
{
181+
continue;
182+
}
183+
184+
startInfo.ArgumentList.Add(argument);
185+
}
186+
187+
if (environmentOverrides is not null)
188+
{
189+
foreach (var (key, value) in environmentOverrides)
190+
{
191+
startInfo.Environment[key] = value;
192+
}
87193
}
88194

89195
using var process = new Process { StartInfo = startInfo };

docs/Testing/strategy.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ Verify `ManagedCode.CodexSharpSDK` behavior against real Codex CLI contracts, wi
1515
- Use the real installed `codex` CLI for process interaction tests; do not use `FakeCodexProcessRunner` doubles.
1616
- Treat `codex` as a prerequisite for real integration runs and install it in CI/local setup before running those tests.
1717
- CI validates Codex CLI smoke behavior on Linux/macOS/Windows without requiring login: CLI must be discoverable and invokable.
18+
- Cross-platform CI smoke also validates unauthenticated behavior in an isolated profile (`codex login status` must report `Not logged in`), proving binary discovery + process launch without relying on local credentials.
1819
- Real integration runs must use existing Codex CLI login/session; test harness does not use API key environment variables.
1920
- Real integration model selection must be explicit: set `CODEX_TEST_MODEL` or define `model` in `~/.codex/config.toml` (no hardcoded fallback model).
2021
- Cover error paths and cancellation paths.

0 commit comments

Comments
 (0)