From b055138b4a3d0d9e3984a3bbc3d2c35766e76ff2 Mon Sep 17 00:00:00 2001 From: lacvet Date: Wed, 15 Apr 2026 15:42:14 +0900 Subject: [PATCH] =?UTF-8?q?=EC=BD=94=EB=93=9C=20=ED=83=AD=20=EC=9B=8C?= =?UTF-8?q?=ED=81=AC=EC=8A=A4=ED=8E=98=EC=9D=B4=EC=8A=A4=20=EA=B6=8C?= =?UTF-8?q?=ED=95=9C=20=EC=A0=95=ED=95=A9=EC=84=B1=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?=EB=B0=8F=20=ED=9A=8C=EA=B7=80=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- README.md | 7 + docs/DEVELOPMENT.md | 6 + .../Services/AgentLoopE2ETests.cs | 145 ++++++++++++++++++ .../Services/Agent/AgentLoopService.cs | 11 +- src/AxCopilot/Views/ChatWindow.xaml.cs | 9 +- 5 files changed, 175 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 5636a75..ef3a9b9 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,13 @@ Windows 전용 시맨틱 런처 & 워크스페이스 매니저 개발 참고: Claw Code 동등성 작업 추적 문서 `docs/claw-code-parity-plan.md` +- 업데이트: 2026-04-15 15:40 (KST) +- Code 탭의 실제 도구 실행 워크스페이스가 설정값의 `CodeWorkFolder`/`WorkFolder`로 고정되고, 현재 대화가 가진 `WorkFolder`와 어긋날 수 있던 문제를 수정했습니다. [AgentLoopService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentLoopService.cs)는 새 `RuntimeWorkFolderOverride`를 통해 실행 턴마다 대화 기준 워크스페이스를 우선 사용하고, [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)는 `RunAgentLoopAsync(...)`에서 현재 conversation의 `WorkFolder`를 루프에 직접 주입합니다. +- 같은 기준을 권한 팝업에도 적용했습니다. 사내 모드에서 [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)의 권한 안내 문구는 이제 `_currentConversation`의 우연한 상태가 아니라, 실제 실행 중인 탭 루프가 들고 있는 워크스페이스 override를 우선 기준으로 삼습니다. 그 결과 `지정한 워크스페이스 하위 경로는 무승인`, `그 외 경로만 승인` 규칙이 Code 탭에서도 Cowork와 같은 기준으로 맞춰집니다. +- 회귀 테스트도 추가했습니다. [AgentLoopE2ETests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/AgentLoopE2ETests.cs)에 `RunAsync_CodeRuntimeWorkspaceOverride_PrefersConversationWorkspaceOverSettingsFolder`, `RunAsync_InternalMode_BypassPermissions_AllowsWorkspaceWriteWithoutPrompt`, `RunAsync_InternalMode_BypassPermissions_RequestsApprovalForPathOutsideWorkspace`를 추가해, stale settings 경로보다 대화 워크스페이스가 우선 적용되는지와 사내 모드 승인 규칙을 함께 고정했습니다. +- 검증: `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" -p:OutputPath=bin\\verify_workspace_permission_fix_tests\\ -p:IntermediateOutputPath=obj\\verify_workspace_permission_fix_tests\\` 통과 6 + - 업데이트: 2026-04-15 15:45 (KST) - Cowork의 PPT 생성 경로를 더 일반적인 고품질 흐름으로 보강했습니다. [ChatWindow.SystemPromptBuilder.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.SystemPromptBuilder.cs)와 [AgentLoopExplorationPolicy.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentLoopExplorationPolicy.cs)는 이제 프레젠테이션/슬라이드 덱 요청에서 `document_plan`을 무조건 먼저 타지 않고, 명시적으로 계획을 요구한 경우가 아니면 `pptx_create`를 우선하도록 안내합니다. - [DeckPlanningService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/DeckPlanningService.cs)에 generic `RefineForQuality(...)` 루프를 추가했습니다. executive summary, comparison, roadmap, chart, KPI dashboard 같은 특화 슬라이드에서 headline, takeaway, verdict, owner/timeline, KPI trend/note를 자동 보강하고, 필요하면 appendix/evidence 슬라이드까지 추가합니다. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index a5dc64e..17898b2 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -1417,3 +1417,9 @@ UI ?붿옄???€洹쒕え 由ы뙥?좊쭅 ???꾪뿕 ?묒뾽 ??湲곕줉???덉쟾 ### 검증 - `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_live_card_restore\\ -p:IntermediateOutputPath=obj\\verify_live_card_restore\\` 경고 0 / 오류 0 - `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "ChatWindowSlashPolicyTests" -p:OutputPath=bin\\verify_live_card_restore_tests\\ -p:IntermediateOutputPath=obj\\verify_live_card_restore_tests\\` 통과 49 +업데이트: 2026-04-15 15:40 (KST) +- Code 탭 워크스페이스/권한 정합성 수정. `src/AxCopilot/Services/Agent/AgentLoopService.cs`에 `RuntimeWorkFolderOverride`를 추가해, loop가 settings의 `CodeWorkFolder`보다 현재 대화의 `WorkFolder`를 우선 사용하도록 바꿨습니다. 이제 UI가 보여주는 작업 폴더와 실제 도구 실행/권한 검사 기준이 같은 경로를 바라봅니다. +- `src/AxCopilot/Views/ChatWindow.xaml.cs`의 `RunAgentLoopAsync(...)`는 현재 conversation의 `WorkFolder`를 해당 탭 loop에 직접 주입하고, 권한 팝업 안내 문구도 `_currentConversation` 대신 실행 중인 loop의 runtime workspace override를 우선 참조합니다. 사내 모드에서는 지정 워크스페이스 하위는 무승인, 외부 경로만 승인 대상이라는 규칙이 Code 탭에도 일관되게 적용됩니다. +- `src/AxCopilot.Tests/Services/AgentLoopE2ETests.cs`에 세 가지 회귀를 추가했습니다. 대화 워크스페이스가 stale settings 폴더보다 우선 적용되는지, 사내 모드 + BypassPermissions에서 워크스페이스 내부 쓰기가 승인 없이 허용되는지, 외부 경로 쓰기는 반드시 승인 콜백을 타는지를 각각 검증합니다. +- 검증: `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" -p:OutputPath=bin\\verify_workspace_permission_fix_tests\\ -p:IntermediateOutputPath=obj\\verify_workspace_permission_fix_tests\\` 통과 6 diff --git a/src/AxCopilot.Tests/Services/AgentLoopE2ETests.cs b/src/AxCopilot.Tests/Services/AgentLoopE2ETests.cs index fbff93b..f29cd89 100644 --- a/src/AxCopilot.Tests/Services/AgentLoopE2ETests.cs +++ b/src/AxCopilot.Tests/Services/AgentLoopE2ETests.cs @@ -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 = "override" }, "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(); + 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(); diff --git a/src/AxCopilot/Services/Agent/AgentLoopService.cs b/src/AxCopilot/Services/Agent/AgentLoopService.cs index 8de6c4d..9eea246 100644 --- a/src/AxCopilot/Services/Agent/AgentLoopService.cs +++ b/src/AxCopilot/Services/Agent/AgentLoopService.cs @@ -105,6 +105,13 @@ public partial class AgentLoopService /// 현재 활성 탭 (파일명 타임스탬프 등 탭별 동작 제어용). public string ActiveTab { get; set; } = "Chat"; + /// + /// 현재 실행 턴에 강제로 사용할 워크스페이스 루트. + /// UI/대화 메타데이터의 WorkFolder를 settings 기반 기본 경로보다 우선 적용해 + /// 권한 검사와 실제 도구 실행이 같은 폴더를 바라보도록 맞춥니다. + /// + public string? RuntimeWorkFolderOverride { get; set; } + /// 현재 대화 ID (감사 로그 기록용). 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 { diff --git a/src/AxCopilot/Views/ChatWindow.xaml.cs b/src/AxCopilot/Views/ChatWindow.xaml.cs index 8cdbdee..a9b3a3f 100644 --- a/src/AxCopilot/Views/ChatWindow.xaml.cs +++ b/src/AxCopilot/Views/ChatWindow.xaml.cs @@ -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; }