Skip to content

Commit 35f0921

Browse files
committed
fix: bound Codex CLI metadata probes
1 parent 0b44f81 commit 35f0921

11 files changed

Lines changed: 781 additions & 86 deletions

File tree

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
using System.Diagnostics;
2+
using ManagedCode.CodexSharpSDK.Internal;
3+
4+
namespace ManagedCode.CodexSharpSDK.Tests.Unit;
5+
6+
public class BoundedCliProcessProbeTests
7+
{
8+
private const string ShellExecutableUnix = "/bin/sh";
9+
private const string ShellExecutableWindows = "cmd.exe";
10+
private const string ShellArgumentUnix = "-c";
11+
private const string ShellArgumentWindows = "/c";
12+
private const string SentinelVariable = "SDK_METADATA_PROBE_SENTINEL";
13+
private const string SentinelValue = "explicit-environment-value";
14+
private const string OverflowMessage = "CLI metadata process exceeded the configured output limit.";
15+
private const string TimeoutMessage = "CLI metadata process did not exit within the configured time limit.";
16+
private const string OutputTimeoutMessage = "CLI metadata process output did not close within the configured time limit.";
17+
private const string LinuxFixtureSkipReason = "The detached inherited-pipe fixture requires Linux setsid.";
18+
private const string DetachedChildCommandPrefix = "/usr/bin/setsid /bin/sleep 30 >&2 & echo $! > '";
19+
private const string DetachedChildCommandSuffix = "'; exit 0";
20+
private const string FixtureDirectoryPrefix = "BoundedCliProcessProbe-";
21+
private const string ChildProcessIdFileName = "child.pid";
22+
private const string StandardOutputPressureMarker = "stdout-pressure-1999";
23+
private const string StandardErrorPressureMarker = "stderr-pressure-1999";
24+
private const string StandardInputEofCommandUnix = "cat >/dev/null";
25+
private const string StandardInputEofCommandWindows = "more >NUL";
26+
private const int DetachedPipeProbeCount = 3;
27+
private const string SentinelCommandUnix = "printf '%s' \"$SDK_METADATA_PROBE_SENTINEL\"";
28+
private const string SentinelCommandWindows = "echo %SDK_METADATA_PROBE_SENTINEL%";
29+
private const string PressureCommandUnix =
30+
"i=0; while [ $i -lt 2000 ]; do printf 'stdout-pressure-%s\\n' \"$i\"; printf 'stderr-pressure-%s\\n' \"$i\" >&2; i=$((i+1)); done";
31+
private const string PressureCommandWindows =
32+
"for /L %i in (0,1,1999) do @(echo stdout-pressure-%i&echo stderr-pressure-%i 1>&2)";
33+
private const string OverflowCommandUnix = "printf '%01000d' 0";
34+
private const string OverflowCommandWindows = "for /L %i in (1,1,200) do @<nul set /p =0123456789";
35+
private const string LongRunningCommandUnix = "sleep 20";
36+
private const string LongRunningCommandWindows = "ping -n 21 127.0.0.1 >NUL";
37+
private static readonly TimeSpan ProbeTimeout = TimeSpan.FromMilliseconds(400);
38+
private static readonly TimeSpan ProcessTimeout = TimeSpan.FromSeconds(10);
39+
40+
[Test]
41+
public async Task Run_PreservesExplicitEnvironmentAndCanExcludeParentEnvironment()
42+
{
43+
var environment = new Dictionary<string, string>(StringComparer.Ordinal)
44+
{
45+
[SentinelVariable] = SentinelValue,
46+
};
47+
48+
var result = Run(SentinelCommandUnix, SentinelCommandWindows, environment, inheritEnvironmentVariables: false);
49+
50+
await Assert.That(result.StandardOutput).Contains(SentinelValue);
51+
}
52+
53+
[Test]
54+
public async Task Run_InheritsParentEnvironmentWhenRequested()
55+
{
56+
var previous = Environment.GetEnvironmentVariable(SentinelVariable);
57+
Environment.SetEnvironmentVariable(SentinelVariable, SentinelValue);
58+
try
59+
{
60+
var result = Run(SentinelCommandUnix, SentinelCommandWindows, environment: null, inheritEnvironmentVariables: true);
61+
62+
await Assert.That(result.StandardOutput).Contains(SentinelValue);
63+
}
64+
finally
65+
{
66+
Environment.SetEnvironmentVariable(SentinelVariable, previous);
67+
}
68+
}
69+
70+
[Test]
71+
public async Task Run_DrainsStandardOutputAndErrorConcurrentlyUnderPressure()
72+
{
73+
var result = Run(PressureCommandUnix, PressureCommandWindows, environment: null,
74+
inheritEnvironmentVariables: true, maximumOutputCharacters: 100000);
75+
76+
await Assert.That(result.ExitCode).IsEqualTo(0);
77+
await Assert.That(result.StandardOutput).Contains(StandardOutputPressureMarker);
78+
await Assert.That(result.StandardError).Contains(StandardErrorPressureMarker);
79+
}
80+
81+
[Test]
82+
public async Task Run_ClosesStandardInputImmediatelyAfterStart()
83+
{
84+
var result = Run(StandardInputEofCommandUnix, StandardInputEofCommandWindows,
85+
environment: null, inheritEnvironmentVariables: true, timeout: ProbeTimeout);
86+
87+
await Assert.That(result.ExitCode).IsEqualTo(0);
88+
}
89+
90+
[Test]
91+
public async Task Run_FailsExplicitlyWhenOutputExceedsConfiguredLimit()
92+
{
93+
var exception = await Assert.That(() => Run(OverflowCommandUnix, OverflowCommandWindows,
94+
environment: null, inheritEnvironmentVariables: true, maximumOutputCharacters: 64)).ThrowsException();
95+
96+
await Assert.That(exception).IsTypeOf<InvalidOperationException>();
97+
await Assert.That(exception!.Message).IsEqualTo(OverflowMessage);
98+
}
99+
100+
[Test]
101+
public async Task Run_StopsProcessWhenProbeTimesOut()
102+
{
103+
var stopwatch = Stopwatch.StartNew();
104+
var exception = await Assert.That(() => Run(LongRunningCommandUnix, LongRunningCommandWindows,
105+
environment: null, inheritEnvironmentVariables: true, timeout: ProbeTimeout)).ThrowsException();
106+
stopwatch.Stop();
107+
108+
await Assert.That(exception).IsTypeOf<TimeoutException>();
109+
await Assert.That(exception!.Message).IsEqualTo(TimeoutMessage);
110+
await Assert.That(stopwatch.Elapsed).IsLessThan(ProcessTimeout);
111+
}
112+
113+
[Test]
114+
public async Task Run_FailsWithinBoundWhenExitedRootHasDescendantHoldingPipes()
115+
{
116+
if (!OperatingSystem.IsLinux())
117+
{
118+
Skip.Test(LinuxFixtureSkipReason);
119+
return;
120+
}
121+
122+
var sandbox = Path.Combine(Environment.CurrentDirectory, "tests", ".sandbox",
123+
$"{FixtureDirectoryPrefix}{Guid.NewGuid():N}");
124+
Directory.CreateDirectory(sandbox);
125+
var childProcessIdPath = Path.Combine(sandbox, ChildProcessIdFileName);
126+
var detachedCommand = string.Concat(DetachedChildCommandPrefix, childProcessIdPath,
127+
DetachedChildCommandSuffix);
128+
try
129+
{
130+
for (var attempt = 0; attempt < DetachedPipeProbeCount; attempt++)
131+
{
132+
var activeReaders = 0;
133+
var stopwatch = Stopwatch.StartNew();
134+
var exception = await Assert.That(() => BoundedCliProcessProbe.Run(
135+
ShellExecutableUnix,
136+
[ShellArgumentUnix, detachedCommand],
137+
environment: null,
138+
inheritEnvironmentVariables: true,
139+
timeout: ProbeTimeout,
140+
maximumOutputCharacters: 64,
141+
readerCountChanged: delta => Interlocked.Add(ref activeReaders, delta))).ThrowsException();
142+
stopwatch.Stop();
143+
144+
await Assert.That(exception).IsTypeOf<TimeoutException>();
145+
await Assert.That(exception!.Message).IsEqualTo(OutputTimeoutMessage);
146+
await Assert.That(stopwatch.Elapsed).IsLessThan(ProcessTimeout);
147+
await Assert.That(activeReaders).IsEqualTo(0);
148+
}
149+
}
150+
finally
151+
{
152+
if (File.Exists(childProcessIdPath))
153+
{
154+
var processIdText = File.ReadAllText(childProcessIdPath);
155+
if (int.TryParse(processIdText, out var processId))
156+
{
157+
using var child = Process.GetProcessById(processId);
158+
if (!child.HasExited)
159+
{
160+
child.Kill(entireProcessTree: true);
161+
}
162+
163+
child.WaitForExit(1000);
164+
}
165+
}
166+
167+
Directory.Delete(sandbox, recursive: true);
168+
}
169+
}
170+
171+
private static CliProcessProbeResult Run(
172+
string unixCommand,
173+
string windowsCommand,
174+
IReadOnlyDictionary<string, string>? environment,
175+
bool inheritEnvironmentVariables,
176+
TimeSpan? timeout = null,
177+
int maximumOutputCharacters = 200000)
178+
{
179+
var executablePath = OperatingSystem.IsWindows()
180+
? Path.Combine(Environment.SystemDirectory, ShellExecutableWindows)
181+
: ShellExecutableUnix;
182+
var arguments = new[]
183+
{
184+
OperatingSystem.IsWindows() ? ShellArgumentWindows : ShellArgumentUnix,
185+
OperatingSystem.IsWindows() ? windowsCommand : unixCommand,
186+
};
187+
return BoundedCliProcessProbe.Run(executablePath, arguments, environment, inheritEnvironmentVariables,
188+
timeout ?? ProcessTimeout, maximumOutputCharacters);
189+
}
190+
}

‎CodexSharpSDK.Tests/Unit/CodexCliMetadataReaderTests.cs‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ namespace ManagedCode.CodexSharpSDK.Tests.Unit;
55

66
public class CodexCliMetadataReaderTests
77
{
8+
private const string NpmUpdateCommand = "npm install --global @openai/codex@latest";
9+
private const string UserInstallExecutablePath = "/usr/local/bin/codex";
810
[Test]
911
public async Task ParseInstalledVersion_ReturnsVersionTokenForCodexCliOutput()
1012
{
@@ -91,7 +93,19 @@ public async Task ResolveUpdateCommand_ReturnsNpmCommand_ByDefault()
9193

9294
var command = CodexCliMetadataReader.ResolveUpdateCommand(executablePath, npmUserAgent: "npm/10.0.0");
9395

94-
await Assert.That(command).IsEqualTo("npm install --global @openai/codex@latest");
96+
await Assert.That(command).IsEqualTo(NpmUpdateCommand);
97+
}
98+
99+
[Test]
100+
public async Task ResolveUpdateCommand_DoesNotConsultParentEnvironmentWhenDisabled()
101+
{
102+
var command = CodexCliMetadataReader.ResolveUpdateCommand(
103+
UserInstallExecutablePath,
104+
npmUserAgent: null,
105+
bunInstallRoot: null,
106+
useProcessEnvironmentFallback: false);
107+
108+
await Assert.That(command).IsEqualTo(NpmUpdateCommand);
95109
}
96110

97111
[Test]

‎CodexSharpSDK.Tests/Unit/CodexClientTests.cs‎

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,33 @@
11
using ManagedCode.CodexSharpSDK.Client;
22
using ManagedCode.CodexSharpSDK.Configuration;
33
using ManagedCode.CodexSharpSDK.Execution;
4+
using ManagedCode.CodexSharpSDK.Internal;
45
using ManagedCode.CodexSharpSDK.Models;
56
using ManagedCode.CodexSharpSDK.Tests.Shared;
67

78
namespace ManagedCode.CodexSharpSDK.Tests.Unit;
89

910
public class CodexClientTests
1011
{
12+
private const string NpmFixtureSkipReason = "The npm.cmd invocation fixture is Windows-specific.";
13+
private const string NpmFixtureDirectoryPrefix = "CodexNpmProbe-";
14+
private const string NpmFixtureScriptFileName = "npm.cmd";
15+
private const string NpmFixtureArgumentsFileName = "npm-arguments.txt";
16+
private const string NpmFixtureVersionOutput = "99.0.0";
17+
private const string NpmFixtureExpectedArguments = "view @openai/codex version --silent";
18+
private const string NpmFixtureScriptContent = "@echo off\r\necho %*>> \"%SDK_NPM_ARGS_FILE%\"\r\necho " + NpmFixtureVersionOutput + "\r\n";
19+
private const string NpmArgumentsEnvironmentVariable = "SDK_NPM_ARGS_FILE";
20+
private const string SystemRootEnvironmentVariable = "SystemRoot";
21+
private const string CodexHomeEnvironmentVariable = "CODEX_HOME";
22+
private const string HomeEnvironmentVariable = "HOME";
23+
private const string PathEnvironmentVariable = "PATH";
24+
private const string DotCodexDirectoryName = ".codex";
25+
private const string CodexConfigFileName = "config.toml";
26+
private const string CodexModelsCacheFileName = "models_cache.json";
27+
private const string CodexConfigFixture = "model = \"" + CodexModels.Gpt53Codex + "\"";
28+
private const string CodexModelsCacheFixture =
29+
"{ \"models\": [ { \"slug\": \"" + CodexModels.Gpt53Codex +
30+
"\", \"display_name\": \"" + CodexModels.Gpt53Codex + "\", \"visibility\": \"list\", \"supported_in_api\": true } ] }";
1131
[Test]
1232
public async Task StartAsync_CanBeCalledConcurrently()
1333
{
@@ -276,6 +296,57 @@ public async Task CodexCli_Smoke_GetCliMetadata_ReturnsInstalledVersion()
276296
await Assert.That(metadata.InstalledVersion.Contains('.')).IsTrue();
277297
}
278298

299+
[Test]
300+
public async Task CodexCli_GetCliMetadata_UsesConfiguredCodexHomeWithEnvironmentAllowlist()
301+
{
302+
var codexHome = CreateMetadataSandbox();
303+
try
304+
{
305+
File.WriteAllText(Path.Combine(codexHome, CodexConfigFileName), CodexConfigFixture);
306+
File.WriteAllText(Path.Combine(codexHome, CodexModelsCacheFileName), CodexModelsCacheFixture);
307+
using var client = CreateMetadataClient(new Dictionary<string, string>(StringComparer.Ordinal)
308+
{
309+
[PathEnvironmentVariable] = Environment.GetEnvironmentVariable(PathEnvironmentVariable) ?? string.Empty,
310+
[CodexHomeEnvironmentVariable] = codexHome,
311+
});
312+
313+
var metadata = client.GetCliMetadata();
314+
315+
await Assert.That(metadata.DefaultModel).IsEqualTo(CodexModels.Gpt53Codex);
316+
await Assert.That(metadata.Models).Count().IsEqualTo(1);
317+
await Assert.That(metadata.Models[0].Slug).IsEqualTo(CodexModels.Gpt53Codex);
318+
}
319+
finally
320+
{
321+
Directory.Delete(codexHome, recursive: true);
322+
}
323+
}
324+
325+
[Test]
326+
public async Task CodexCli_GetCliMetadata_UsesExplicitHomeFallbackWhenInheritanceIsDisabled()
327+
{
328+
var home = CreateMetadataSandbox();
329+
var codexHome = Path.Combine(home, DotCodexDirectoryName);
330+
Directory.CreateDirectory(codexHome);
331+
try
332+
{
333+
File.WriteAllText(Path.Combine(codexHome, CodexConfigFileName), CodexConfigFixture);
334+
using var client = CreateMetadataClient(new Dictionary<string, string>(StringComparer.Ordinal)
335+
{
336+
[PathEnvironmentVariable] = Environment.GetEnvironmentVariable(PathEnvironmentVariable) ?? string.Empty,
337+
[HomeEnvironmentVariable] = home,
338+
});
339+
340+
var metadata = client.GetCliMetadata();
341+
342+
await Assert.That(metadata.DefaultModel).IsEqualTo(CodexModels.Gpt53Codex);
343+
}
344+
finally
345+
{
346+
Directory.Delete(home, recursive: true);
347+
}
348+
}
349+
279350
[Test]
280351
public async Task CodexCli_Smoke_GetCliUpdateStatus_ReturnsInstalledVersion()
281352
{
@@ -287,6 +358,64 @@ public async Task CodexCli_Smoke_GetCliUpdateStatus_ReturnsInstalledVersion()
287358
await Assert.That(status.InstalledVersion.Contains('.')).IsTrue();
288359
}
289360

361+
[Test]
362+
public async Task CodexCli_UpdateStatus_InvokesScopedNpmPackageThroughWindowsCommandProcessor()
363+
{
364+
if (!OperatingSystem.IsWindows())
365+
{
366+
Skip.Test(NpmFixtureSkipReason);
367+
return;
368+
}
369+
370+
var sandbox = Path.Combine(Environment.CurrentDirectory, "tests", ".sandbox",
371+
$"{NpmFixtureDirectoryPrefix}{Guid.NewGuid():N}");
372+
Directory.CreateDirectory(sandbox);
373+
var npmScriptPath = Path.Combine(sandbox, NpmFixtureScriptFileName);
374+
var argumentsPath = Path.Combine(sandbox, NpmFixtureArgumentsFileName);
375+
try
376+
{
377+
File.WriteAllText(npmScriptPath, NpmFixtureScriptContent);
378+
using var client = new CodexClient(new CodexOptions
379+
{
380+
CodexExecutablePath = CodexCliLocator.FindCodexPath(null),
381+
InheritEnvironmentVariables = false,
382+
EnvironmentVariables = new Dictionary<string, string>(StringComparer.Ordinal)
383+
{
384+
[PathEnvironmentVariable] = string.Concat(sandbox, Path.PathSeparator,
385+
Environment.GetEnvironmentVariable(PathEnvironmentVariable)),
386+
[SystemRootEnvironmentVariable] = Environment.GetEnvironmentVariable(SystemRootEnvironmentVariable) ?? string.Empty,
387+
[NpmArgumentsEnvironmentVariable] = argumentsPath,
388+
},
389+
});
390+
391+
var status = client.GetCliUpdateStatus();
392+
393+
await Assert.That(status.LatestVersion).IsEqualTo(NpmFixtureVersionOutput);
394+
await Assert.That(status.IsUpdateAvailable).IsTrue();
395+
await Assert.That(File.ReadAllText(argumentsPath).Trim()).IsEqualTo(NpmFixtureExpectedArguments);
396+
}
397+
finally
398+
{
399+
Directory.Delete(sandbox, recursive: true);
400+
}
401+
}
402+
403+
private static CodexClient CreateMetadataClient(IReadOnlyDictionary<string, string> environment) =>
404+
new(new CodexOptions
405+
{
406+
CodexExecutablePath = CodexCliLocator.FindCodexPath(null),
407+
EnvironmentVariables = environment,
408+
InheritEnvironmentVariables = false,
409+
});
410+
411+
private static string CreateMetadataSandbox()
412+
{
413+
var sandbox = Path.Combine(Environment.CurrentDirectory, "tests", ".sandbox",
414+
$"CodexClientMetadata-{Guid.NewGuid():N}");
415+
Directory.CreateDirectory(sandbox);
416+
return sandbox;
417+
}
418+
290419
[Test]
291420
[Property("RequiresCodexAuth", "true")]
292421
public async Task ResumeThread_WithThreadOptions_RunsWithRealCodexCli()

0 commit comments

Comments
 (0)