Skip to content

Commit dcfb861

Browse files
committed
fix: bound Claude CLI metadata probes
1 parent c51c0a8 commit dcfb861

8 files changed

Lines changed: 627 additions & 80 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.ClaudeCodeSharpSDK.Internal;
3+
4+
namespace ManagedCode.ClaudeCodeSharpSDK.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+
}

‎ClaudeCodeSharpSDK.Tests/Unit/ClaudeCliMetadataReaderTests.cs‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,18 @@
11
using System.Diagnostics;
2+
using ManagedCode.ClaudeCodeSharpSDK.Client;
3+
using ManagedCode.ClaudeCodeSharpSDK.Configuration;
24
using ManagedCode.ClaudeCodeSharpSDK.Internal;
35
using ManagedCode.ClaudeCodeSharpSDK.Models;
46

57
namespace ManagedCode.ClaudeCodeSharpSDK.Tests.Unit;
68

79
public class ClaudeCliMetadataReaderTests
810
{
11+
private const string MetadataSandboxPrefix = "ClaudeCliMetadataReaderTests-";
12+
private const string PathEnvironmentVariable = "PATH";
13+
private const string ClaudeConfigDirectoryEnvironmentVariable = "CLAUDE_CONFIG_DIR";
14+
private const string SettingsFileName = "settings.json";
15+
private const string ClaudeSettingsFixture = "{ \"model\": \"" + ClaudeModels.Sonnet + "\" }";
916
private const string CommandFlagUnix = "-c";
1017
private const string CommandFlagWindows = "/c";
1118
private const string ConcurrentOutputCommandUnix =
@@ -113,6 +120,37 @@ public async Task ReadStandardStreamsAndWaitForExit_CapturesLargeStandardOutputA
113120
await Assert.That(standardError).Contains(LastStandardErrorLine);
114121
}
115122

123+
[Test]
124+
public async Task ClaudeClient_GetCliMetadata_UsesConfiguredSettingsAndEnvironmentAllowlist()
125+
{
126+
var configDirectory = Path.Combine(Environment.CurrentDirectory, "tests", ".sandbox",
127+
$"{MetadataSandboxPrefix}{Guid.NewGuid():N}");
128+
Directory.CreateDirectory(configDirectory);
129+
try
130+
{
131+
File.WriteAllText(Path.Combine(configDirectory, SettingsFileName), ClaudeSettingsFixture);
132+
using var client = new ClaudeClient(new ClaudeOptions
133+
{
134+
ClaudeExecutablePath = ClaudeCliLocator.FindClaudePath(null),
135+
EnvironmentVariables = new Dictionary<string, string>(StringComparer.Ordinal)
136+
{
137+
[PathEnvironmentVariable] = Environment.GetEnvironmentVariable(PathEnvironmentVariable) ?? string.Empty,
138+
[ClaudeConfigDirectoryEnvironmentVariable] = configDirectory,
139+
},
140+
InheritEnvironmentVariables = false,
141+
});
142+
143+
var metadata = client.GetCliMetadata();
144+
145+
await Assert.That(metadata.DefaultModel).IsEqualTo(ClaudeModels.Sonnet);
146+
await Assert.That(string.IsNullOrWhiteSpace(metadata.InstalledVersion)).IsFalse();
147+
}
148+
finally
149+
{
150+
Directory.Delete(configDirectory, recursive: true);
151+
}
152+
}
153+
116154
private static ProcessStartInfo CreateConcurrentOutputProcessStartInfo()
117155
{
118156
var startInfo = new ProcessStartInfo(

‎ClaudeCodeSharpSDK/Client/ClaudeClient.cs‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,13 +65,19 @@ public ClaudeThread ResumeThread(string id, ThreadOptions? options = null)
6565
public ClaudeCliMetadata GetCliMetadata()
6666
{
6767
var executablePath = ClaudeCliLocator.FindClaudePath(_options.ClaudeExecutablePath);
68-
return ClaudeCliMetadataReader.Read(executablePath);
68+
var exec = CreateExec();
69+
return ClaudeCliMetadataReader.Read(executablePath, exec.BuildEnvironment(_options.BaseUrl, _options.ApiKey),
70+
_options.InheritEnvironmentVariables, _options.CliMetadataProbeTimeout,
71+
_options.CliMetadataMaximumOutputCharacters);
6972
}
7073

7174
public ClaudeCliUpdateStatus GetCliUpdateStatus()
7275
{
7376
var executablePath = ClaudeCliLocator.FindClaudePath(_options.ClaudeExecutablePath);
74-
return ClaudeCliMetadataReader.ReadUpdateStatus(executablePath);
77+
var exec = CreateExec();
78+
return ClaudeCliMetadataReader.ReadUpdateStatus(executablePath,
79+
exec.BuildEnvironment(_options.BaseUrl, _options.ApiKey), _options.InheritEnvironmentVariables,
80+
_options.CliMetadataProbeTimeout, _options.CliMetadataMaximumOutputCharacters);
7581
}
7682

7783
public void Dispose() => _connectionState.Dispose();

‎ClaudeCodeSharpSDK/Configuration/ClaudeOptions.cs‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ public sealed record ClaudeOptions
77
{
88
public static readonly TimeSpan DefaultProcessTerminationTimeout = TimeSpan.FromSeconds(5);
99

10+
public static readonly TimeSpan DefaultCliMetadataProbeTimeout = TimeSpan.FromSeconds(10);
11+
12+
public const int DefaultCliMetadataMaximumOutputCharacters = 65536;
13+
1014
public string? ClaudeExecutablePath { get; init; }
1115

1216
public string? BaseUrl { get; init; }
@@ -21,5 +25,9 @@ public sealed record ClaudeOptions
2125

2226
public TimeSpan ProcessTerminationTimeout { get; init; } = DefaultProcessTerminationTimeout;
2327

28+
public TimeSpan CliMetadataProbeTimeout { get; init; } = DefaultCliMetadataProbeTimeout;
29+
30+
public int CliMetadataMaximumOutputCharacters { get; init; } = DefaultCliMetadataMaximumOutputCharacters;
31+
2432
public ILogger? Logger { get; init; }
2533
}

0 commit comments

Comments
 (0)