Skip to content

Commit a674e4b

Browse files
committed
Fix code interpreter workdir path resolution
Anchor relative container paths to the configured work directory while leaving absolute paths unchanged. Reuse the same resolution logic in download_chat_files and add regression tests for relative and absolute write_file paths plus configured workdir downloads.
1 parent 89d4ed4 commit a674e4b

3 files changed

Lines changed: 173 additions & 9 deletions

File tree

src/BE/tests/Chats.BE.UnitTest/CodeInterpreter/CodeInterpreterWriteFileTests.cs

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ private static ServiceProvider CreateServiceProvider(string dbName)
2323
return services.BuildServiceProvider();
2424
}
2525

26-
private static CodeInterpreterExecutor CreateExecutor(ServiceProvider sp, FakeDockerService docker)
26+
private static CodeInterpreterExecutor CreateExecutor(ServiceProvider sp, FakeDockerService docker, CodePodConfig? codePodConfig = null)
2727
{
2828
IHttpContextAccessor accessor = new HttpContextAccessor { HttpContext = new DefaultHttpContext() };
2929
HostUrlService host = new(accessor);
@@ -33,7 +33,7 @@ private static CodeInterpreterExecutor CreateExecutor(ServiceProvider sp, FakeDo
3333
docker,
3434
fsf,
3535
sp.GetRequiredService<IServiceScopeFactory>(),
36-
Options.Create(new CodePodConfig()),
36+
Options.Create(codePodConfig ?? new CodePodConfig()),
3737
Options.Create(new CodeInterpreterOptions()),
3838
NullLogger<CodeInterpreterExecutor>.Instance);
3939
}
@@ -210,4 +210,74 @@ public async Task WriteFile_MultiLineWithCRLF_CountsCorrectly()
210210
Assert.True(result.IsSuccess);
211211
Assert.Contains("Wrote 4 lines", result.Value);
212212
}
213+
214+
[Fact]
215+
public async Task WriteFile_RelativePath_ShouldAnchorToConfiguredWorkDir()
216+
{
217+
// Arrange
218+
ServiceProvider sp = CreateServiceProvider(nameof(WriteFile_RelativePath_ShouldAnchorToConfiguredWorkDir));
219+
FakeDockerService docker = new();
220+
CodeInterpreterExecutor executor = CreateExecutor(sp, docker, new CodePodConfig { WorkDir = "/workspace" });
221+
222+
const string sessionLabel = "s1";
223+
const string containerId = "container-123";
224+
ChatDockerSession session = await SeedSessionAsync(sp, ownerTurnId: 1, sessionLabel, containerId);
225+
226+
CodeInterpreterExecutor.TurnContext ctx = new()
227+
{
228+
MessageTurns = [new ChatTurn { Id = 1, ChatDockerSessions = [session] }],
229+
MessageSteps = [],
230+
CurrentAssistantTurn = new ChatTurn { Id = 1 },
231+
ClientInfoId = 1
232+
};
233+
234+
// Act
235+
Result<string> result = await executor.WriteFile(
236+
ctx,
237+
sessionLabel,
238+
"test.py",
239+
"print('hello')",
240+
CancellationToken.None
241+
);
242+
243+
// Assert
244+
Assert.True(result.IsSuccess);
245+
Assert.Equal("/workspace/test.py", docker.LastUploadedPath);
246+
Assert.Contains("/workspace/test.py", result.Value);
247+
}
248+
249+
[Fact]
250+
public async Task WriteFile_AbsolutePath_ShouldUsePathAsIs()
251+
{
252+
// Arrange
253+
ServiceProvider sp = CreateServiceProvider(nameof(WriteFile_AbsolutePath_ShouldUsePathAsIs));
254+
FakeDockerService docker = new();
255+
CodeInterpreterExecutor executor = CreateExecutor(sp, docker, new CodePodConfig { WorkDir = "/workspace" });
256+
257+
const string sessionLabel = "s1";
258+
const string containerId = "container-123";
259+
ChatDockerSession session = await SeedSessionAsync(sp, ownerTurnId: 1, sessionLabel, containerId);
260+
261+
CodeInterpreterExecutor.TurnContext ctx = new()
262+
{
263+
MessageTurns = [new ChatTurn { Id = 1, ChatDockerSessions = [session] }],
264+
MessageSteps = [],
265+
CurrentAssistantTurn = new ChatTurn { Id = 1 },
266+
ClientInfoId = 1
267+
};
268+
269+
// Act
270+
Result<string> result = await executor.WriteFile(
271+
ctx,
272+
sessionLabel,
273+
"/tmp/test.py",
274+
"print('hello')",
275+
CancellationToken.None
276+
);
277+
278+
// Assert
279+
Assert.True(result.IsSuccess);
280+
Assert.Equal("/tmp/test.py", docker.LastUploadedPath);
281+
Assert.Contains("/tmp/test.py", result.Value);
282+
}
213283
}

src/BE/tests/Chats.BE.UnitTest/CodeInterpreter/DownloadChatFilesToolTests.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -65,15 +65,15 @@ private static ServiceProvider CreateServiceProvider(string dbName)
6565
return services.BuildServiceProvider();
6666
}
6767

68-
private static CodeInterpreterExecutor CreateExecutor(ServiceProvider sp, FakeDockerService docker, IReadOnlyDictionary<string, byte[]> blobs)
68+
private static CodeInterpreterExecutor CreateExecutor(ServiceProvider sp, FakeDockerService docker, IReadOnlyDictionary<string, byte[]> blobs, CodePodConfig? codePodConfig = null)
6969
{
7070
IFileServiceFactory fsf = new FakeFileServiceFactory(blobs);
7171

7272
return new CodeInterpreterExecutor(
7373
docker,
7474
fsf,
7575
sp.GetRequiredService<IServiceScopeFactory>(),
76-
Options.Create(new CodePodConfig()),
76+
Options.Create(codePodConfig ?? new CodePodConfig()),
7777
Options.Create(new CodeInterpreterOptions()),
7878
NullLogger<CodeInterpreterExecutor>.Instance);
7979
}
@@ -196,7 +196,7 @@ public async Task DownloadChatFiles_ShouldOnlyListAndUploadMatchedFiles()
196196
}
197197

198198
FakeDockerService docker = new();
199-
CodeInterpreterExecutor exec = CreateExecutor(sp, docker, blobs);
199+
CodeInterpreterExecutor exec = CreateExecutor(sp, docker, blobs, new CodePodConfig { WorkDir = "/workspace" });
200200
CodeInterpreterExecutor.TurnContext ctx = CreateCtx(session, [step]);
201201

202202
Result<string> done = await exec.DownloadChatFiles(ctx, session.Label, ["maze_game.zip"], CancellationToken.None);
@@ -207,7 +207,7 @@ public async Task DownloadChatFiles_ShouldOnlyListAndUploadMatchedFiles()
207207

208208
Assert.Single(docker.Uploads);
209209
Assert.Equal("container-1", docker.Uploads[0].ContainerId);
210-
Assert.EndsWith("maze_game.zip", docker.Uploads[0].Path, StringComparison.OrdinalIgnoreCase);
210+
Assert.Equal("/workspace/maze_game.zip", docker.Uploads[0].Path);
211211
Assert.Equal(zipBytes, docker.Uploads[0].Content);
212212
}
213213
}

src/BE/web/Services/CodeInterpreter/CodeInterpreterExecutor.cs

Lines changed: 97 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,99 @@ private static string ToShellPrefixCsv(string[] shellPrefix)
287287
return string.Join(',', shellPrefix);
288288
}
289289

290+
private string ResolveContainerPath(string path)
291+
{
292+
if (string.IsNullOrWhiteSpace(path))
293+
{
294+
throw new InvalidOperationException("path is required");
295+
}
296+
297+
string trimmedPath = path.Trim();
298+
if (IsAbsoluteContainerPath(trimmedPath))
299+
{
300+
return NormalizeAbsoluteContainerPath(trimmedPath);
301+
}
302+
303+
return CombineContainerPath(_codePodConfig.WorkDir, trimmedPath);
304+
}
305+
306+
private bool IsAbsoluteContainerPath(string path)
307+
{
308+
string normalized = path.Replace('\\', '/');
309+
if (normalized.StartsWith('/'))
310+
{
311+
return true;
312+
}
313+
314+
return _codePodConfig.IsWindowsContainer && IsWindowsDriveAbsolutePath(normalized);
315+
}
316+
317+
private static string NormalizeAbsoluteContainerPath(string path)
318+
{
319+
string normalized = path.Replace('\\', '/').Trim();
320+
if (normalized.StartsWith('/'))
321+
{
322+
return NormalizeRootedContainerPath(normalized);
323+
}
324+
325+
if (!IsWindowsDriveAbsolutePath(normalized))
326+
{
327+
return normalized;
328+
}
329+
330+
string prefix = normalized[..2];
331+
string suffix = normalized[2..];
332+
while (suffix.Contains("//", StringComparison.Ordinal))
333+
{
334+
suffix = suffix.Replace("//", "/", StringComparison.Ordinal);
335+
}
336+
337+
return prefix + suffix;
338+
}
339+
340+
private static bool IsWindowsDriveAbsolutePath(string path)
341+
=> path.Length >= 3
342+
&& IsAsciiLetter(path[0])
343+
&& path[1] == ':'
344+
&& path[2] == '/';
345+
346+
private static string CombineContainerPath(string basePathNoTrailing, string relativeNoLeading)
347+
{
348+
string basePath = NormalizeRootedContainerPath(basePathNoTrailing).TrimEnd('/');
349+
string relativePath = relativeNoLeading.Replace('\\', '/').Trim().TrimStart('/');
350+
351+
if (string.IsNullOrEmpty(basePath) || basePath == "/")
352+
{
353+
return "/" + relativePath;
354+
}
355+
356+
return basePath + "/" + relativePath;
357+
}
358+
359+
private static string NormalizeRootedContainerPath(string path)
360+
{
361+
string normalized = path.Replace('\\', '/').Trim();
362+
if (string.IsNullOrEmpty(normalized))
363+
{
364+
return "/";
365+
}
366+
367+
if (!normalized.StartsWith('/'))
368+
{
369+
normalized = "/" + normalized;
370+
}
371+
372+
while (normalized.Length > 1 && normalized.Contains("//", StringComparison.Ordinal))
373+
{
374+
normalized = normalized.Replace("//", "/", StringComparison.Ordinal);
375+
}
376+
377+
return normalized;
378+
}
379+
380+
private static bool IsAsciiLetter(char c)
381+
=> (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z');
382+
290383
public async IAsyncEnumerable<ToolProgressDelta> ExecuteToolCallAsync(
291384
TurnContext ctx,
292385
string toolCallId,
@@ -700,14 +793,15 @@ internal async Task<Result<string>> WriteFile(
700793
TurnContext.SessionState state = ensureResult.Value!;
701794
state.UsedInThisTurn = true;
702795

796+
string resolvedPath = ResolveContainerPath(path);
703797
byte[] bytes = Encoding.UTF8.GetBytes(text);
704-
await _docker.UploadFileAsync(state.DbSession.ContainerId, path, bytes, cancellationToken);
798+
await _docker.UploadFileAsync(state.DbSession.ContainerId, resolvedPath, bytes, cancellationToken);
705799
await TouchSession(state.DbSession.Id, cancellationToken);
706800

707801
await SyncArtifactsAfterToolCall(ctx, state, cancellationToken);
708802

709803
int lineCount = string.IsNullOrEmpty(text) ? 0 : text.Split(["\r\n", "\n"], StringSplitOptions.None).Length;
710-
return Result.Ok($"Wrote {lineCount} lines to {path}");
804+
return Result.Ok($"Wrote {lineCount} lines to {resolvedPath}");
711805
}
712806

713807
[ToolFunction("Download cloud files (from chat history) into /app")]
@@ -744,7 +838,7 @@ internal async Task<Result<string>> DownloadChatFiles(
744838
await s.CopyToAsync(ms, cancellationToken);
745839
byte[] bytes = ms.ToArray();
746840

747-
string targetPath = $"{_codePodConfig.WorkDir}/{file.FileName}";
841+
string targetPath = ResolveContainerPath(file.FileName);
748842
await _docker.UploadFileAsync(state.DbSession.ContainerId, targetPath, bytes, cancellationToken);
749843

750844
downloaded.Add(file);

0 commit comments

Comments
 (0)