코드 탭 워크스페이스 권한 정합성 수정 및 회귀 테스트 추가

- AgentLoopService에 RuntimeWorkFolderOverride를 추가해 Code/Cowork 실행이 settings 기본 경로보다 현재 대화 WorkFolder를 우선 사용하도록 정리

- ChatWindow에서 RunAgentLoopAsync 실행 시 conversation.WorkFolder를 루프에 직접 주입하고 사내 모드 권한 안내도 같은 런타임 워크스페이스 기준으로 맞춤

- AgentLoopE2ETests에 워크스페이스 override 우선 적용, 사내 모드 내부 경로 무승인, 외부 경로 승인 강제 회귀를 추가

- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_workspace_permission_fix\\ -p:IntermediateOutputPath=obj\\verify_workspace_permission_fix\\ / 경고 0 오류 0

- 검증: dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter FullyQualifiedName~RunAsync_CodeRuntimeWorkspaceOverride_PrefersConversationWorkspaceOverSettingsFolder|FullyQualifiedName~RunAsync_InternalMode_BypassPermissions_AllowsWorkspaceWriteWithoutPrompt|FullyQualifiedName~RunAsync_InternalMode_BypassPermissions_RequestsApprovalForPathOutsideWorkspace|FullyQualifiedName~RunAsync_EmptyWorkspace_BlocksExternalFallbackAndRecoversToFileWrite|FullyQualifiedName~RunAsync_EmptyWorkspace_DisallowsSkillManagerAndRecoversToFileWrite|FullyQualifiedName~RunAsync_TextEmbeddedToolCall_RecoversAndExecutesFileWrite / 통과 6
This commit is contained in:
2026-04-15 15:42:14 +09:00
parent 675bdc9595
commit b055138b4a
5 changed files with 175 additions and 3 deletions

View File

@@ -637,6 +637,151 @@ public class AgentLoopE2ETests
}
}
[Fact]
public async Task RunAsync_CodeRuntimeWorkspaceOverride_PrefersConversationWorkspaceOverSettingsFolder()
{
var settingsDir = Path.Combine(Path.GetTempPath(), "axcopilot-settings-workspace-" + Guid.NewGuid().ToString("N"));
var conversationDir = Path.Combine(Path.GetTempPath(), "axcopilot-conversation-workspace-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(settingsDir);
Directory.CreateDirectory(conversationDir);
try
{
using var server = new FakeOllamaServer(
[
BuildToolCallResponse("file_write", new { path = "index.html", content = "<html><body>override</body></html>" }, "write to conversation workspace"),
BuildTextResponse("?꾨즺"),
]);
var settings = BuildLoopSettings(server.Endpoint);
settings.Settings.Llm.WorkFolder = settingsDir;
settings.Settings.Llm.CodeWorkFolder = settingsDir;
using var llm = new LlmService(settings);
using var tools = ToolRegistry.CreateDefault();
var loop = new AgentLoopService(llm, tools, settings)
{
ActiveTab = "Code",
RuntimeWorkFolderOverride = conversationDir,
};
var result = await loop.RunAsync(
[
new ChatMessage { Role = "user", Content = "index.html ?뚯씪 留뚮뱾?댁쨾" }
]);
result.Should().Contain("?꾨즺");
File.Exists(Path.Combine(conversationDir, "index.html")).Should().BeTrue();
File.Exists(Path.Combine(settingsDir, "index.html")).Should().BeFalse();
}
finally
{
try { if (Directory.Exists(settingsDir)) Directory.Delete(settingsDir, true); } catch { }
try { if (Directory.Exists(conversationDir)) Directory.Delete(conversationDir, true); } catch { }
}
}
[Fact]
public async Task RunAsync_InternalMode_BypassPermissions_AllowsWorkspaceWriteWithoutPrompt()
{
var workspaceDir = Path.Combine(Path.GetTempPath(), "axcopilot-internal-workspace-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(workspaceDir);
try
{
using var server = new FakeOllamaServer(
[
BuildToolCallResponse("file_write", new { path = "inside.txt", content = "ok" }, "write inside workspace"),
BuildTextResponse("?꾨즺"),
]);
var settings = BuildLoopSettings(server.Endpoint);
settings.Settings.OperationMode = OperationModePolicy.InternalMode;
settings.Settings.Llm.WorkFolder = workspaceDir;
settings.Settings.Llm.CodeWorkFolder = workspaceDir;
using var llm = new LlmService(settings);
using var tools = ToolRegistry.CreateDefault();
var loop = new AgentLoopService(llm, tools, settings)
{
ActiveTab = "Code",
RuntimeWorkFolderOverride = workspaceDir,
};
var promptCount = 0;
loop.AskPermissionCallback = (_, _) =>
{
promptCount++;
return Task.FromResult(false);
};
var result = await loop.RunAsync(
[
new ChatMessage { Role = "user", Content = "inside.txt ?뚯씪 留뚮뱾?댁쨾" }
]);
result.Should().Contain("?꾨즺");
File.Exists(Path.Combine(workspaceDir, "inside.txt")).Should().BeTrue();
promptCount.Should().Be(0);
}
finally
{
try { if (Directory.Exists(workspaceDir)) Directory.Delete(workspaceDir, true); } catch { }
}
}
[Fact]
public async Task RunAsync_InternalMode_BypassPermissions_RequestsApprovalForPathOutsideWorkspace()
{
var workspaceDir = Path.Combine(Path.GetTempPath(), "axcopilot-internal-base-" + Guid.NewGuid().ToString("N"));
var externalDir = Path.Combine(Path.GetTempPath(), "axcopilot-internal-external-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(workspaceDir);
Directory.CreateDirectory(externalDir);
try
{
var externalTarget = Path.Combine(externalDir, "outside.txt");
using var server = new FakeOllamaServer(
[
BuildToolCallResponse("file_write", new { path = externalTarget, content = "blocked" }, "write outside workspace"),
BuildTextResponse("?꾨즺"),
]);
var settings = BuildLoopSettings(server.Endpoint);
settings.Settings.OperationMode = OperationModePolicy.InternalMode;
settings.Settings.Llm.WorkFolder = workspaceDir;
settings.Settings.Llm.CodeWorkFolder = workspaceDir;
using var llm = new LlmService(settings);
using var tools = ToolRegistry.CreateDefault();
var loop = new AgentLoopService(llm, tools, settings)
{
ActiveTab = "Code",
RuntimeWorkFolderOverride = workspaceDir,
};
var promptCount = 0;
var events = new List<AgentEvent>();
loop.EventOccurred += evt => events.Add(evt);
loop.AskPermissionCallback = (_, _) =>
{
promptCount++;
return Task.FromResult(false);
};
var result = await loop.RunAsync(
[
new ChatMessage { Role = "user", Content = "외부 경로에 파일을 써줘" }
]);
result.Should().Contain("?꾨즺");
File.Exists(externalTarget).Should().BeFalse();
promptCount.Should().Be(1);
events.Should().Contain(e =>
(e.Type == AgentEventType.PermissionDenied || e.Type == AgentEventType.Error) &&
e.ToolName == "file_write");
}
finally
{
try { if (Directory.Exists(workspaceDir)) Directory.Delete(workspaceDir, true); } catch { }
try { if (Directory.Exists(externalDir)) Directory.Delete(externalDir, true); } catch { }
}
}
private static SettingsService BuildLoopSettings(string endpoint)
{
var settings = new SettingsService();

View File

@@ -105,6 +105,13 @@ public partial class AgentLoopService
/// <summary>현재 활성 탭 (파일명 타임스탬프 등 탭별 동작 제어용).</summary>
public string ActiveTab { get; set; } = "Chat";
/// <summary>
/// 현재 실행 턴에 강제로 사용할 워크스페이스 루트.
/// UI/대화 메타데이터의 WorkFolder를 settings 기반 기본 경로보다 우선 적용해
/// 권한 검사와 실제 도구 실행이 같은 폴더를 바라보도록 맞춥니다.
/// </summary>
public string? RuntimeWorkFolderOverride { get; set; }
/// <summary>현재 대화 ID (감사 로그 기록용).</summary>
private string _conversationId = "";
@@ -4463,7 +4470,9 @@ public partial class AgentLoopService
private AgentContext BuildContext()
{
var llm = _settings.Settings.Llm;
var baseWorkFolder = ResolveTabWorkFolder(llm, ActiveTab);
var baseWorkFolder = !string.IsNullOrWhiteSpace(RuntimeWorkFolderOverride)
? RuntimeWorkFolderOverride
: ResolveTabWorkFolder(llm, ActiveTab);
var runtimeWorkFolder = ResolveRuntimeWorkFolder(baseWorkFolder);
return new AgentContext
{

View File

@@ -5819,7 +5819,8 @@ public partial class ChatWindow : Window
await appDispatcher.InvokeAsync(() =>
{
AgentLoopService.PermissionPromptPreview? preview = null;
if (_agentLoops.TryGetValue(tab, out var tabLoop) &&
_agentLoops.TryGetValue(tab, out var tabLoop);
if (tabLoop != null &&
tabLoop.TryGetPendingPermissionPreview(toolName, filePath, out var pendingPreview))
preview = pendingPreview;
@@ -5828,7 +5829,9 @@ public partial class ChatWindow : Window
if (Services.OperationModePolicy.IsInternal(_settings.Settings)
&& !string.IsNullOrWhiteSpace(filePath))
{
var wsFolder = _currentConversation?.WorkFolder ?? "";
var wsFolder = tabLoop?.RuntimeWorkFolderOverride;
if (string.IsNullOrWhiteSpace(wsFolder))
wsFolder = _currentConversation?.WorkFolder ?? "";
if (!string.IsNullOrEmpty(wsFolder) && IsPathOutsideFolder(filePath, wsFolder))
notice = "사내 모드에서는 지정된 경로 외의 접근 시 무조건 승인이 필요합니다.";
}
@@ -5871,6 +5874,7 @@ public partial class ChatWindow : Window
try
{
loop.ActiveTab = runTab;
loop.RuntimeWorkFolderOverride = conversation.WorkFolder;
// 에이전트 루프를 백그라운드 스레드에서 실행 — UI 스레드 블록 방지
// RunAsync 내부의 _llm.SendAsync가 SynchronizationContext로 UI 스레드에
// 복귀할 때 WPF 메시지 펌프를 독점하여 창 드래그/최소화가 안 되는 문제 해결
@@ -5918,6 +5922,7 @@ public partial class ChatWindow : Window
}
finally
{
loop.RuntimeWorkFolderOverride = null;
loop.EventOccurred -= agentEventHandler;
loop.UserDecisionCallback = null;
}