diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index f2ea6a55..47a62f09 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -87,6 +87,14 @@ TableClothCli.exe catalog services --query 은행 --limit 20 [AI Preview 구현 및 검증 보고서](docs/poc/managed-ai-runtime.md)는 현재 기능, 테스트 증거와 남은 실기기 검증을 설명합니다. [원본 PoC 설계](docs/poc/managed-ai-runtime-design.ko.md)는 구현 전 가설과 승인 조건을 기록한 자료이므로 원래 명칭을 유지합니다. +## DI를 통한 서비스 구성과 환경 경로 주입 + +서비스 구성은 [앱의 DI 등록부](src/TableCloth.App/DependencyInjection/UseTableClothExtensions.cs)에서 관리합니다. 서비스는 생성자로 필수 의존성을 받고 등록부가 구현체와 수명을 선택합니다. 소비 서비스가 누락된 의존성 대신 다른 서비스를 직접 생성하거나 환경별 경로를 다시 추론하는 방식은 사용하지 않습니다. 순수 계산 함수와 값 객체까지 DI에 등록하지는 않습니다. + +Windows AI 서비스는 [전용 등록 함수](src/TableCloth.App/DependencyInjection/ManagedAiWindowsServiceCollectionExtensions.cs)로 묶습니다. 등록부는 `ISharedLocations.ExecutableFilePath`를 스킬 실행 서비스에 전달하고 같은 런타임 관리자 인스턴스를 구체 형식과 인터페이스에 연결합니다. 실행 파일 선택은 [SharedLocations](src/TableCloth.App/Components/Implementations/SharedLocations.cs)가 담당하므로 Windows 서비스 라이브러리가 앱 프로젝트를 참조하지 않습니다. + +주입받은 경로도 실행 서비스에서 검증합니다. 절대 경로, 제품 실행 파일명, 파일 존재 여부와 reparse point 검사를 유지하며 스크립트 해시 검증과 AppContainer 정책도 그대로 적용합니다. [DI 구성 테스트](src/TableCloth.Test/ManagedAiDependencyInjectionTests.cs)는 임시 경로와 대체 `ISharedLocations`를 사용하여 잘못된 경로의 차단, 대체 경로 탐색 방지와 singleton 수명을 검사합니다. + ## 빌드와 Native AOT 게시 개발 빌드는 AnyCPU framework-dependent 출력으로 만듭니다. diff --git a/src/TableCloth.App/DependencyInjection/ManagedAiWindowsServiceCollectionExtensions.cs b/src/TableCloth.App/DependencyInjection/ManagedAiWindowsServiceCollectionExtensions.cs new file mode 100644 index 00000000..289a18b5 --- /dev/null +++ b/src/TableCloth.App/DependencyInjection/ManagedAiWindowsServiceCollectionExtensions.cs @@ -0,0 +1,23 @@ +using Microsoft.Extensions.DependencyInjection; +using TableCloth.Components; +using TableCloth.ManagedAi; +using TableCloth.ManagedAi.Windows; + +namespace TableCloth.App.DependencyInjection; + +internal static class ManagedAiWindowsServiceCollectionExtensions +{ + internal static IServiceCollection AddManagedAiWindows(this IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(service => + service.GetRequiredService()); + services.AddSingleton(service => new WindowsManagedAiSkillExecution( + service.GetRequiredService(), + service.GetRequiredService(), + service.GetRequiredService().ExecutableFilePath)); + return services; + } +} diff --git a/src/TableCloth.App/DependencyInjection/UseTableClothExtensions.cs b/src/TableCloth.App/DependencyInjection/UseTableClothExtensions.cs index 95a5a72b..93fbcee2 100644 --- a/src/TableCloth.App/DependencyInjection/UseTableClothExtensions.cs +++ b/src/TableCloth.App/DependencyInjection/UseTableClothExtensions.cs @@ -11,7 +11,6 @@ using TableCloth.ViewModels; using TableCloth.ManagedAi; using TableCloth.ManagedAi.OpenAi; -using TableCloth.ManagedAi.Windows; namespace TableCloth.App.DependencyInjection; @@ -34,11 +33,7 @@ public static IHostApplicationBuilder UseTableCloth(this IHostApplicationBuilder builder.Services.AddLogging(); builder.Services.AddManagedOpenAi(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(service => service.GetRequiredService()); - builder.Services.AddSingleton(); + builder.Services.AddManagedAiWindows(); builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); diff --git a/src/TableCloth.ManagedAi.Test/SkillExecutionBridgeTests.cs b/src/TableCloth.ManagedAi.Test/SkillExecutionBridgeTests.cs index 0c027dba..237b2df9 100644 --- a/src/TableCloth.ManagedAi.Test/SkillExecutionBridgeTests.cs +++ b/src/TableCloth.ManagedAi.Test/SkillExecutionBridgeTests.cs @@ -19,7 +19,9 @@ public async Task IneligibleCalculatorDoesNotInstallRuntimesOrExposeATool(string Directory.CreateDirectory(run); IReadOnlyList skills = state == "missing" ? [] : [Calculator(fixture.Paths) with { Enabled = state != "disabled", IsValid = state != "invalid" }]; - var bridge = await new WindowsManagedAiSkillExecution(fixture.Paths).PrepareAsync(skills, run, CancellationToken.None); + var bridge = await new WindowsManagedAiSkillExecution(fixture.Paths, + new WindowsManagedAiScriptRuntimeManager(fixture.Paths), fixture.Paths.Under("TableCloth.exe")) + .PrepareAsync(skills, run, CancellationToken.None); Assert.IsNull(bridge); Assert.IsFalse(Directory.Exists(fixture.Paths.Under("skill-execution"))); } @@ -39,98 +41,50 @@ public async Task ChangedScriptFailsClosedBeforeAnyRuntimeDownload(string change if (change == "changed") File.AppendAllText(script, "\n# changed"); else File.Delete(script); var error = await Assert.ThrowsExactlyAsync(() => - new WindowsManagedAiSkillExecution(fixture.Paths, hostExecutable: host).PrepareAsync([calculator], run, CancellationToken.None)); + new WindowsManagedAiSkillExecution(fixture.Paths, + new WindowsManagedAiScriptRuntimeManager(fixture.Paths), host) + .PrepareAsync([calculator], run, CancellationToken.None)); Assert.AreEqual(AiFailureCode.BlockedByPolicy, error.Code); Assert.IsFalse(Directory.Exists(fixture.Paths.Under("skill-execution"))); } [TestMethod] - [DataRow("dotnet.exe")] - [DataRow("DOTNET.EXE")] - public void DotnetHostResolvesOnlyTheFixedExecutableInTheApplicationBaseDirectory(string hostName) - { - using var fixture = new InstallFixture(); - var dotnet = fixture.Paths.Under("dotnet-location", hostName); - var applicationBase = fixture.Paths.Under("application with spaces"); - var appHost = Path.Combine(applicationBase, "TableCloth.exe"); - var unrelatedHost = fixture.Paths.Under("unrelated-location", "TableCloth.exe"); - Directory.CreateDirectory(applicationBase); - Directory.CreateDirectory(Path.GetDirectoryName(unrelatedHost)!); - File.WriteAllText(appHost, "application host, never executed"); - File.WriteAllText(unrelatedHost, "unrelated host, never executed"); - - var resolved = WindowsManagedAiSkillExecution.ResolveHostExecutable(dotnet, applicationBase); - - Assert.IsNotNull(resolved); - Assert.AreEqual(appHost, resolved); - Assert.AreNotEqual(unrelatedHost, resolved); - Assert.AreNotEqual(Path.Combine(Path.GetDirectoryName(dotnet)!, "TableCloth.exe"), resolved); - Assert.IsTrue(Path.IsPathFullyQualified(resolved)); - } - - [TestMethod] - public void NativeHostPreservesItsActualExecutableEvenWhenAnotherApplicationBaseContainsAHost() - { - using var fixture = new InstallFixture(); - var nativeHost = fixture.Paths.Under("native-install", "TableCloth.exe"); - var otherApplicationBase = fixture.Paths.Under("different-application"); - Directory.CreateDirectory(otherApplicationBase); - File.WriteAllText(Path.Combine(otherApplicationBase, "TableCloth.exe"), "unrelated host, never executed"); - - var resolved = WindowsManagedAiSkillExecution.ResolveHostExecutable(nativeHost, otherApplicationBase); - - Assert.AreEqual(nativeHost, resolved); - } - - [TestMethod] + [DataRow("missing")] + [DataRow("relative")] [DataRow("unknown.exe")] + [DataRow("dotnet.exe")] [DataRow("python.exe")] - public void UnknownHostDoesNotRedirectToAnAvailableApplicationHost(string hostName) - { - using var fixture = new InstallFixture(); - var unknown = fixture.Paths.Under(hostName); - var applicationBase = fixture.Paths.Under("application"); - Directory.CreateDirectory(applicationBase); - File.WriteAllText(Path.Combine(applicationBase, "TableCloth.exe"), "application host, never executed"); - - var resolved = WindowsManagedAiSkillExecution.ResolveHostExecutable(unknown, applicationBase); - - Assert.AreEqual(unknown, resolved); - } - - [TestMethod] - public async Task MissingExplicitApplicationHostFailsWithoutSearchingForAnotherExecutableOrDownloadingRuntimes() + [DataRow("empty")] + [DataRow("null")] + public async Task InvalidInjectedHostFailsWithoutFallingBackToAnotherExecutableOrDownloadingRuntimes(string state) { using var fixture = new InstallFixture(); - var run = fixture.Paths.Under("runs", "missing-host-test"); + var run = fixture.Paths.Under("runs", "invalid-host-test"); Directory.CreateDirectory(run); var calculator = InstallCalculator(fixture.Paths); - var missingHost = WindowsManagedAiSkillExecution.ResolveHostExecutable( - fixture.Paths.Under("dotnet.exe"), fixture.Paths.Under("missing-application")); - Assert.IsNotNull(missingHost); + var injectedHost = state switch + { + "missing" => fixture.Paths.Under("missing-application", "TableCloth.exe"), + "relative" => "TableCloth.exe", + "empty" => "", + "null" => null!, + _ => fixture.Paths.Under(state) + }; + if (state is "unknown.exe" or "dotnet.exe" or "python.exe") + File.WriteAllText(injectedHost, "unapproved host, never executed"); + // Even an available app host cannot repair an invalid dependency supplied by DI. var otherHost = fixture.Paths.Under("TableCloth.exe"); File.WriteAllText(otherHost, "another host, never executed"); var error = await Assert.ThrowsExactlyAsync(() => - new WindowsManagedAiSkillExecution(fixture.Paths, hostExecutable: missingHost) + new WindowsManagedAiSkillExecution(fixture.Paths, + new WindowsManagedAiScriptRuntimeManager(fixture.Paths), injectedHost) .PrepareAsync([calculator], run, CancellationToken.None)); Assert.AreEqual(AiFailureCode.SkillExecutionHostUnavailable, error.Code); Assert.IsFalse(Directory.Exists(fixture.Paths.Under("skill-execution"))); - } - - [TestMethod] - [DataRow("application")] - [DataRow(".")] - [DataRow("")] - public void DotnetHostRejectsARelativeApplicationBaseInsteadOfUsingTheWorkingDirectory(string applicationBase) - { - using var fixture = new InstallFixture(); - var dotnet = fixture.Paths.Under("dotnet.exe"); - - var resolved = WindowsManagedAiSkillExecution.ResolveHostExecutable(dotnet, applicationBase); - - Assert.IsNull(resolved); + Assert.IsEmpty(Directory.GetFileSystemEntries(run)); + Assert.AreEqual("another host, never executed", File.ReadAllText(otherHost)); } [TestMethod] diff --git a/src/TableCloth.ManagedAi.Windows/TableCloth.ManagedAi.Windows.csproj b/src/TableCloth.ManagedAi.Windows/TableCloth.ManagedAi.Windows.csproj index d558ffe5..0341b9f1 100644 --- a/src/TableCloth.ManagedAi.Windows/TableCloth.ManagedAi.Windows.csproj +++ b/src/TableCloth.ManagedAi.Windows/TableCloth.ManagedAi.Windows.csproj @@ -7,7 +7,6 @@ true - diff --git a/src/TableCloth.ManagedAi.Windows/WindowsManagedAiSkillExecution.cs b/src/TableCloth.ManagedAi.Windows/WindowsManagedAiSkillExecution.cs index 17981644..ec458163 100644 --- a/src/TableCloth.ManagedAi.Windows/WindowsManagedAiSkillExecution.cs +++ b/src/TableCloth.ManagedAi.Windows/WindowsManagedAiSkillExecution.cs @@ -6,7 +6,7 @@ namespace TableCloth.ManagedAi.Windows; // Only app-reviewed, hash-pinned registrations can expose scripts. A catalog entry cannot register an executable. public sealed class WindowsManagedAiSkillExecution(ManagedAiPaths paths, - WindowsManagedAiScriptRuntimeManager? runtimeManager = null, string? hostExecutable = null) : IManagedAiSkillExecution + WindowsManagedAiScriptRuntimeManager runtimeManager, string hostExecutable) : IManagedAiSkillExecution { public const string CalculatorSha256 = "279b627d4350b8895717a6ac3597a6e87cab7a9882004d639251ebbee3e7c947"; public const string CalculatorId = "tablecloth-calculators"; @@ -24,7 +24,7 @@ public sealed class WindowsManagedAiSkillExecution(ManagedAiPaths paths, if (!Path.GetFullPath(installed.Directory).Equals(expectedSkill, StringComparison.OrdinalIgnoreCase)) throw Blocked(); ManagedAiPaths.RejectReparsePoints(runDirectory); var root = paths.Under("skill-execution"); - var host = hostExecutable ?? ResolveHostExecutable(Environment.ProcessPath, AppContext.BaseDirectory); + var host = hostExecutable; if (host is null || !Path.IsPathFullyQualified(host) || !Path.GetFileName(host).Equals("TableCloth.exe", StringComparison.OrdinalIgnoreCase) || !File.Exists(host)) throw new ManagedAiException(AiFailureCode.SkillExecutionHostUnavailable); @@ -35,12 +35,11 @@ public sealed class WindowsManagedAiSkillExecution(ManagedAiPaths paths, await using var locked = new FileStream(source, FileMode.Open, FileAccess.Read, FileShare.Read); var scriptHash = Convert.ToHexStringLower(await SHA256.HashDataAsync(locked, cancellationToken)); if (scriptHash != CalculatorSha256) throw Blocked(); - var manager = runtimeManager ?? new WindowsManagedAiScriptRuntimeManager(paths); - var status = await manager.EnsureInstalledAsync(cancellationToken); + var status = await runtimeManager.EnsureInstalledAsync(cancellationToken); if (!status.Python.Installed || !status.Node.Installed) throw new ManagedAiException(AiFailureCode.SkillIsolationFailed); - var runtimeFiles = await manager.GetVerifiedFilesAsync(cancellationToken); - var python = manager.PythonExecutable; - var node = manager.NodeExecutable; + var runtimeFiles = await runtimeManager.GetVerifiedFilesAsync(cancellationToken); + var python = runtimeManager.PythonExecutable; + var node = runtimeManager.NodeExecutable; var pythonHash = runtimeFiles.Single(x => x.Path.Equals(python, StringComparison.OrdinalIgnoreCase)).Sha256; var nodeHash = runtimeFiles.Single(x => x.Path.Equals(node, StringComparison.OrdinalIgnoreCase)).Sha256; locked.Position = 0; @@ -94,14 +93,5 @@ public sealed class WindowsManagedAiSkillExecution(ManagedAiPaths paths, } } - internal static string? ResolveHostExecutable(string? processPath, string applicationDirectory) - { - // Framework-dependent launches run as dotnet.exe. Only the fixed apphost beside - // the product payload can serve MCP; never search PATH or launch dotnet/shell tools. - if (processPath is not null && Path.GetFileName(processPath).Equals("dotnet.exe", StringComparison.OrdinalIgnoreCase)) - return Path.IsPathFullyQualified(applicationDirectory) ? Path.Combine(applicationDirectory, "TableCloth.exe") : null; - return processPath; - } - private static ManagedAiException Blocked() => new(AiFailureCode.BlockedByPolicy); } diff --git a/src/TableCloth.Test/ManagedAiDependencyInjectionTests.cs b/src/TableCloth.Test/ManagedAiDependencyInjectionTests.cs new file mode 100644 index 00000000..2fa64207 --- /dev/null +++ b/src/TableCloth.Test/ManagedAiDependencyInjectionTests.cs @@ -0,0 +1,156 @@ +using Microsoft.Extensions.DependencyInjection; +using TableCloth.App.DependencyInjection; +using TableCloth.Components; +using TableCloth.ManagedAi; +using TableCloth.ManagedAi.Windows; + +namespace TableCloth.Test; + +[TestClass] +public sealed class ManagedAiDependencyInjectionTests +{ + [TestMethod] + public void WindowsCompositionSharesRuntimeManagerAcrossItsConcreteAndPublicContracts() + { + using var fixture = new Fixture(); + var locations = new Locations(fixture.Paths.Under("TableCloth.exe")); + using var services = Compose(fixture.Paths, locations); + + var concrete = services.GetRequiredService(); + var contract = services.GetRequiredService(); + Assert.AreSame(concrete, contract); + Assert.AreSame(concrete, services.GetRequiredService()); + Assert.AreSame(contract, services.GetRequiredService()); + var execution = services.GetRequiredService(); + Assert.AreSame(execution, services.GetRequiredService()); + Assert.AreSame(services.GetRequiredService(), services.GetRequiredService()); + Assert.AreSame(services.GetRequiredService(), services.GetRequiredService()); + Assert.AreEqual(1, locations.ExecutableReads); + Assert.IsFalse(Directory.Exists(fixture.Paths.Under("skill-execution"))); + } + + [TestMethod] + [DataRow("missing")] + [DataRow("relative")] + [DataRow("other-name")] + [DataRow("empty")] + public async Task InjectedUnavailableHostFailsBeforeDownloadingRuntimesWithoutUsingApplicationBaseHost(string scenario) + { + using var fixture = new Fixture(); + // The application reference copies its real apphost into the test output. It must not override DI. + Assert.IsTrue(File.Exists(Path.Combine(AppContext.BaseDirectory, "TableCloth.exe")), + "The fallback candidate must exist for this composition test."); + var host = scenario switch + { + "missing" => fixture.Paths.Under("missing", "TableCloth.exe"), + "relative" => "TableCloth.exe", + "other-name" => fixture.Paths.Under("unrelated.exe"), + "empty" => "", + _ => throw new AssertFailedException("Unknown fixture scenario") + }; + if (scenario == "other-name") File.WriteAllText(host, "Fixture executable, never executed."); + var locations = new Locations(host); + using var services = Compose(fixture.Paths, locations); + var execution = services.GetRequiredService(); + + var error = await Assert.ThrowsExactlyAsync(() => + execution.PrepareAsync([fixture.Calculator], fixture.RunDirectory, CancellationToken.None)); + + Assert.AreEqual(AiFailureCode.SkillExecutionHostUnavailable, error.Code); + Assert.AreEqual(1, locations.ExecutableReads); + Assert.IsFalse(Directory.Exists(fixture.Paths.Under("skill-execution"))); + } + + [TestMethod] + public async Task SingletonExecutionCapturesInjectedHostOnceAndReachesScriptValidationWithoutRuntimeDownload() + { + using var fixture = new Fixture(); + var host = fixture.Paths.Under("injected-host", "TableCloth.exe"); + Directory.CreateDirectory(Path.GetDirectoryName(host)!); + File.WriteAllText(host, "Fixture apphost, never executed."); + var script = Path.Combine(fixture.Calculator.Directory, "scripts", "calculate.py"); + Directory.CreateDirectory(Path.GetDirectoryName(script)!); + File.WriteAllText(script, "# Deliberately unreviewed fixture script."); + var locations = new Locations(host); + using var services = Compose(fixture.Paths, locations); + var execution = services.GetRequiredService(); + + var first = await Assert.ThrowsExactlyAsync(() => + execution.PrepareAsync([fixture.Calculator], fixture.RunDirectory, CancellationToken.None)); + Assert.AreEqual(AiFailureCode.BlockedByPolicy, first.Code); + + locations.Host = "relative/TableCloth.exe"; + var again = services.GetRequiredService(); + var second = await Assert.ThrowsExactlyAsync(() => + again.PrepareAsync([fixture.Calculator], fixture.RunDirectory, CancellationToken.None)); + + Assert.AreSame(execution, again); + Assert.AreEqual(AiFailureCode.BlockedByPolicy, second.Code); + Assert.AreEqual(1, locations.ExecutableReads); + Assert.IsFalse(Directory.Exists(fixture.Paths.Under("skill-execution"))); + } + + [TestMethod] + public void WindowsCompositionRequiresSharedLocationsInsteadOfInferringTheCurrentProcessHost() + { + using var fixture = new Fixture(); + var collection = new ServiceCollection(); + collection.AddSingleton(fixture.Paths); + collection.AddManagedAiWindows(); + using var services = collection.BuildServiceProvider(); + + Assert.ThrowsExactly(() => services.GetRequiredService()); + Assert.IsFalse(Directory.Exists(fixture.Paths.Under("skill-execution"))); + } + + private static ServiceProvider Compose(ManagedAiPaths paths, ISharedLocations locations) + { + var services = new ServiceCollection(); + services.AddSingleton(paths); + services.AddSingleton(locations); + services.AddManagedAiWindows(); + return services.BuildServiceProvider(); + } + + private sealed class Fixture : IDisposable + { + public Fixture() + { + Paths = new(Path.Combine(Path.GetTempPath(), "tcloth-di-" + Guid.NewGuid().ToString("N"))); + RunDirectory = Paths.Under("runs", "fixture"); + Directory.CreateDirectory(RunDirectory); + Calculator = new("tablecloth-calculators", "Calculator fixture", "Never executed", + Paths.Under("profiles", "openai-codex", "codex-home", "skills", "tablecloth-calculators"), true); + } + + public ManagedAiPaths Paths { get; } + public string RunDirectory { get; } + public AiSkill Calculator { get; } + public void Dispose() + { + if (Directory.Exists(Paths.Root)) Directory.Delete(Paths.Root, recursive: true); + } + } + + private sealed class Locations(string host) : ISharedLocations + { + public string Host = host; + public int ExecutableReads; + public string ExecutableFilePath { get { ExecutableReads++; return Host; } } + public string AppDataDirectoryPath => throw new NotSupportedException(); + public string ApplicationLogPath => throw new NotSupportedException(); + public string ExecutableDirectoryPath => throw new NotSupportedException(); + public string ImagesZipFilePath => throw new NotSupportedException(); + public string PreferencesFilePath => throw new NotSupportedException(); + public string CatalogCacheFilePath => throw new NotSupportedException(); + public string DefaultDataDirectoryPath => throw new NotSupportedException(); + public string GetImageDirectoryPath() => throw new NotSupportedException(); + public string GetImageFilePath(string serviceId) => throw new NotSupportedException(); + public string GetIconFilePath(string serviceId) => throw new NotSupportedException(); + public string GetTempPath() => throw new NotSupportedException(); + public string GetHelpFilePath() => throw new NotSupportedException(); + public string GetSandboxAppStagingPath(string sandboxStagingDirectory) => throw new NotSupportedException(); + public string GetEffectiveDataDirectoryPath(string? configuredPath) => throw new NotSupportedException(); + public bool IsMountableDataDirectory(string? path) => throw new NotSupportedException(); + } +}