Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 출력으로 만듭니다.
Expand Down
Original file line number Diff line number Diff line change
@@ -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<IJsonlProcessRunner, JsonlProcessRunner>();
services.AddSingleton<IManagedAiProfile, WindowsManagedAiProfile>();
services.AddSingleton<WindowsManagedAiScriptRuntimeManager>();
services.AddSingleton<IManagedAiScriptRuntimeManager>(service =>
service.GetRequiredService<WindowsManagedAiScriptRuntimeManager>());
services.AddSingleton<IManagedAiSkillExecution>(service => new WindowsManagedAiSkillExecution(
service.GetRequiredService<ManagedAiPaths>(),
service.GetRequiredService<WindowsManagedAiScriptRuntimeManager>(),
service.GetRequiredService<ISharedLocations>().ExecutableFilePath));
return services;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
using TableCloth.ViewModels;
using TableCloth.ManagedAi;
using TableCloth.ManagedAi.OpenAi;
using TableCloth.ManagedAi.Windows;

namespace TableCloth.App.DependencyInjection;

Expand All @@ -34,11 +33,7 @@ public static IHostApplicationBuilder UseTableCloth(this IHostApplicationBuilder
builder.Services.AddLogging();

builder.Services.AddManagedOpenAi();
builder.Services.AddSingleton<IJsonlProcessRunner, JsonlProcessRunner>();
builder.Services.AddSingleton<IManagedAiProfile, WindowsManagedAiProfile>();
builder.Services.AddSingleton<WindowsManagedAiScriptRuntimeManager>();
builder.Services.AddSingleton<IManagedAiScriptRuntimeManager>(service => service.GetRequiredService<WindowsManagedAiScriptRuntimeManager>());
builder.Services.AddSingleton<IManagedAiSkillExecution, WindowsManagedAiSkillExecution>();
builder.Services.AddManagedAiWindows();
builder.Services.AddTransient<ITableClothBrowser, TableClothBrowserAdapter>();
builder.Services.AddTransient<IManagedAiCatalogLauncher, ManagedAiCatalogLauncher>();
builder.Services.AddTransient<IManagedAiCatalogChoice, ManagedAiCatalogChoice>();
Expand Down
102 changes: 28 additions & 74 deletions src/TableCloth.ManagedAi.Test/SkillExecutionBridgeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ public async Task IneligibleCalculatorDoesNotInstallRuntimesOrExposeATool(string
Directory.CreateDirectory(run);
IReadOnlyList<AiSkill> 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")));
}
Expand All @@ -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<ManagedAiException>(() =>
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<ManagedAiException>(() =>
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]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="TableCloth.ManagedAi.Test" />
<ProjectReference Include="../TableCloth.ManagedAi.Core/TableCloth.ManagedAi.Core.csproj" />
<ProjectReference Include="../TableCloth.ManagedAi.AppContainer/TableCloth.ManagedAi.AppContainer.csproj" />
</ItemGroup>
Expand Down
22 changes: 6 additions & 16 deletions src/TableCloth.ManagedAi.Windows/WindowsManagedAiSkillExecution.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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);
Expand All @@ -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;
Expand Down Expand Up @@ -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);
}
Loading
Loading