From fec2fe2dda7b5cb681b5aa76859ef6fe2993dda2 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Sat, 30 May 2026 14:04:22 +0200 Subject: [PATCH] fix(worker): cap run-log read size and harden run-history tests - GetTaskLog reads at most last 256 KB; prepends truncation marker if file exceeds cap - Wrap temp-file cleanup in finally block to prevent leak on assertion failure - Add GetRun_NotFound_Throws, GetTaskLog_RunExistsButNoLogPath_Throws, and GetTaskLog_LargeFile_ReturnsTruncatedTail tests Co-Authored-By: Claude Opus 4.7 --- .../External/RunHistoryMcpTools.cs | 14 +++- .../External/RunHistoryMcpToolsTests.cs | 66 ++++++++++++++++++- 2 files changed, 77 insertions(+), 3 deletions(-) diff --git a/src/ClaudeDo.Worker/External/RunHistoryMcpTools.cs b/src/ClaudeDo.Worker/External/RunHistoryMcpTools.cs index 70b50e0..1081a28 100644 --- a/src/ClaudeDo.Worker/External/RunHistoryMcpTools.cs +++ b/src/ClaudeDo.Worker/External/RunHistoryMcpTools.cs @@ -33,6 +33,8 @@ public sealed class RunHistoryMcpTools return ToDto(run); } + private const int MaxLogBytes = 256 * 1024; + [McpServerTool, Description("Fetch the raw log output of a task's latest run. Throws if no log is available.")] public async Task GetTaskLog(string taskId, CancellationToken cancellationToken) { @@ -40,7 +42,17 @@ public sealed class RunHistoryMcpTools ?? throw new InvalidOperationException($"No runs found for task {taskId}."); if (string.IsNullOrWhiteSpace(run.LogPath) || !File.Exists(run.LogPath)) throw new InvalidOperationException("No log available for the latest run."); - return await File.ReadAllTextAsync(run.LogPath, cancellationToken); + + var totalBytes = new FileInfo(run.LogPath).Length; + if (totalBytes <= MaxLogBytes) + return await File.ReadAllTextAsync(run.LogPath, cancellationToken); + + var buffer = new byte[MaxLogBytes]; + await using var fs = new FileStream(run.LogPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + fs.Seek(totalBytes - MaxLogBytes, SeekOrigin.Begin); + var read = await fs.ReadAsync(buffer, cancellationToken); + var tail = System.Text.Encoding.UTF8.GetString(buffer, 0, read); + return $"[truncated: showing last {MaxLogBytes} of {totalBytes} bytes]\n{tail}"; } private static RunDto ToDto(TaskRunEntity r) => new( diff --git a/tests/ClaudeDo.Worker.Tests/External/RunHistoryMcpToolsTests.cs b/tests/ClaudeDo.Worker.Tests/External/RunHistoryMcpToolsTests.cs index 71649c2..730806c 100644 --- a/tests/ClaudeDo.Worker.Tests/External/RunHistoryMcpToolsTests.cs +++ b/tests/ClaudeDo.Worker.Tests/External/RunHistoryMcpToolsTests.cs @@ -76,9 +76,71 @@ public sealed class RunHistoryMcpToolsTests : IDisposable IsRetry = false, Prompt = "p", LogPath = logPath, }); - var content = await _sut.GetTaskLog(taskId, CancellationToken.None); + string content; + try + { + content = await _sut.GetTaskLog(taskId, CancellationToken.None); + } + finally + { + File.Delete(logPath); + } Assert.Equal("hello log", content); - File.Delete(logPath); + } + + [Fact] + public async Task GetRun_NotFound_Throws() + { + await Assert.ThrowsAsync(() => + _sut.GetRun("missing", CancellationToken.None)); + } + + [Fact] + public async Task GetTaskLog_RunExistsButNoLogPath_Throws() + { + var taskId = Guid.NewGuid().ToString(); + await SeedTaskAsync(taskId); + await _runs.AddAsync(new TaskRunEntity + { + Id = Guid.NewGuid().ToString(), TaskId = taskId, RunNumber = 1, + IsRetry = false, Prompt = "p", LogPath = null, + }); + + await Assert.ThrowsAsync(() => + _sut.GetTaskLog(taskId, CancellationToken.None)); + } + + [Fact] + public async Task GetTaskLog_LargeFile_ReturnsTruncatedTail() + { + var taskId = Guid.NewGuid().ToString(); + await SeedTaskAsync(taskId); + var logPath = Path.Combine(Path.GetTempPath(), $"claudedo_log_{Guid.NewGuid():N}.txt"); + + // Write 300 KB so it exceeds the 256 KB cap + var chunk = new string('A', 1024); + await using (var w = new StreamWriter(logPath, append: false)) + for (var i = 0; i < 300; i++) + await w.WriteAsync(chunk); + + await _runs.AddAsync(new TaskRunEntity + { + Id = Guid.NewGuid().ToString(), TaskId = taskId, RunNumber = 1, + IsRetry = false, Prompt = "p", LogPath = logPath, + }); + + string content; + try + { + content = await _sut.GetTaskLog(taskId, CancellationToken.None); + } + finally + { + File.Delete(logPath); + } + + Assert.StartsWith("[truncated:", content); + Assert.True(content.Length < 300 * 1024); } }