diff --git a/src/TableCloth.App/Components/Implementations/SandboxBuilder.cs b/src/TableCloth.App/Components/Implementations/SandboxBuilder.cs
index 72885919..629d77a9 100644
--- a/src/TableCloth.App/Components/Implementations/SandboxBuilder.cs
+++ b/src/TableCloth.App/Components/Implementations/SandboxBuilder.cs
@@ -257,10 +257,27 @@ private static SandboxConfiguration BootstrapSandboxConfiguration(
}
}
- sandboxConfig.LogonCommand.Add(Path.Combine(SandboxMountPaths.AppDirectory, "StartupScript.cmd"));
+ // 이슈 #304: LogonCommand 로 .cmd 경로를 **직접** 지정하지 않고 cmd.exe 를 거친다.
+ //
+ // .cmd 는 PE 이미지가 아니라서 CreateProcess 로는 띄울 수 없고, 셸(ShellExecute)이나
+ // 명령 인터프리터를 경유해야 한다. 즉 .cmd 를 그대로 넘기면 실행 성공 여부가 게스트의
+ // .cmd 파일 연결과 샌드박스 클라이언트의 실행 방식에 의존하게 되는데, 이 경로가 깨지면
+ // **아무 오류도 없이 그냥 실행되지 않는다**(제보 환경에서 관측된 증상).
+ //
+ // 인터프리터를 명시하면 샌드박스가 띄워야 할 대상이 System32 의 PE 바이너리로 고정되어
+ // 연결·셸 의존이 사라진다. 경로도 %SystemRoot% 같은 확장에 기대지 않고 절대 경로로 박는다
+ // (LogonCommand 문자열이 어느 시점에 환경 변수 확장을 거치는지 보장되지 않기 때문).
+ var startupScriptInSandbox = Path.Combine(SandboxMountPaths.AppDirectory, "StartupScript.cmd");
+ sandboxConfig.LogonCommand.Add($@"{GuestCommandInterpreterPath} /c ""{startupScriptInSandbox}""");
return sandboxConfig;
}
+ ///
+ /// 샌드박스 게스트의 명령 인터프리터 절대 경로. 게스트는 항상 표준 Windows 설치이므로
+ /// C:\Windows\System32\cmd.exe 로 고정된다(호스트 경로가 아니다).
+ ///
+ private const string GuestCommandInterpreterPath = @"C:\Windows\System32\cmd.exe";
+
///
/// 호스트가 가진 인증서 쌍을 staging의 App\certs로 떨어뜨리고, NPKI 경로 조립에 필요한
/// 식별자(O / SubjectNameForNpkiApp / IsPersonalCert)를 에 채워 넣는다.
@@ -397,6 +414,13 @@ setx DOTNET_ROOT ""{SandboxMountPaths.SandboxDesktop}\{HostDotnetLeafName}"" >nu
// reg.exe + citool.exe는 System32의 EV 서명 바이너리라 SAC가 신뢰하므로 batch 단계에서
// 호출하는 것이 안전.
//
+ // 이슈 #304: 단, citool.exe는 작업을 마친 뒤 "계속하려면 Enter 키를 누르세요."로 표준 입력을
+ // 기다리는 빌드가 있다(제보 환경의 게스트 이미지 10.0.26100.8875에서 확인). LogonCommand 로
+ // 실행되는 이 batch 는 stdout/stderr 가 nul 로 묶여 있어 그 프롬프트가 화면에 보이지도 않은 채
+ // 영원히 멈추고, 바로 다음 줄의 Spork 실행에 도달하지 못한다 = "아무 메시지 없이 아무 일도
+ // 안 일어남". 그래서 stdin 을 nul 로 리다이렉트해 프롬프트가 즉시 EOF 를 받도록 한다.
+ // (batch 에서 블로킹될 수 있는 유일한 줄이므로 이 한 줄이 곧 hang 방어다.)
+ //
// GPU 가속 OFF(기본)일 때 vGPU Disable 만으로는 부족할 수 있어 Edge 정책도 함께 적용해
// 다층 차단한다. Edge는 vGPU가 없어도 WARP/소프트웨어 GPU 경로를 시도하며, 이 경로가
// 일부 환경에서 흰 화면이나 렌더링 지연을 유발할 수 있기 때문에 정책 키로 명시적으로
@@ -444,12 +468,51 @@ reg add ""HKLM\SOFTWARE\Policies\Google\Chrome\LocalNetworkAccessAllowedForUrls"
"
: string.Empty;
+ // 이슈 #304: 부팅 브레드크럼.
+ //
+ // 이 batch 가 어디까지 갔는지 남기지 않으면, Spork 가 뜨지 않았을 때 사용자에게도 우리에게도
+ // 단서가 0 이다(콘솔은 즉시 닫히고, Spork 가 뜨기 전이라 Serilog/Sentry 도 아무것도 못 남긴다).
+ // 그래서 각 단계 직후 한 줄씩 기록한다.
+ //
+ // 기록 위치: Data 마운트(항상 RW)가 최우선. staging 의 App 폴더는 메인 창을 닫을 때
+ // SandboxCleanupManager 가 통째로 지우므로 세션이 끝나면 회수할 수 없다. Data 는 호스트의
+ // 실제 폴더(기본 문서\TableCloth\Data)라 샌드박스 종료 후에도 그대로 남는다.
+ // 마운트가 없는 예외 상황에서만 App 폴더로 폴백한다.
+ //
+ // 리다이렉션을 **명령 앞**에 두는 것은 의도적이다. `echo rc=%errorlevel%>>파일` 처럼 숫자로
+ // 끝나면 cmd 가 `0>>` 를 fd 0 리다이렉션으로 파싱해 로그가 조용히 깨진다.
+ const string BootLogVar = "%TCBOOTLOG%";
+ var bootLogHeader = $@"set TCBOOTLOG=%~dp0tablecloth-boot.log
+if exist ""{SandboxMountPaths.DataDirectory}\"" set TCBOOTLOG={SandboxMountPaths.DataDirectory}\tablecloth-boot.log
+>""{BootLogVar}"" echo [00] startup script begin %DATE% %TIME%
+";
+
+ // 본 batch 는 UTF-8(Encoding.Default)로 기록되는데 cmd 는 이를 OEM 코드페이지로 읽는다.
+ // 따라서 **반드시 ASCII 만** 사용한다. 한글을 넣으면 바이트 정렬이 깨져 스크립트가 통째로
+ // 무동작이 될 수 있다(이슈 #304 진단 중 실측).
+ var launchFailureNotice = $@"if not ""%TCSPORKRC%""==""0"" (
+ echo.
+ echo [TableCloth] Spork could not start ^(exit code %TCSPORKRC%^).
+ echo [TableCloth] Diagnostic log: {BootLogVar}
+ echo [TableCloth] Please attach that file to https://github.com/yourtablecloth/TableCloth/issues
+ echo.
+ pause
+)
+";
+
return $@"@echo off
pushd ""%~dp0""
-{dotnetRootScript}reg add ""HKLM\SYSTEM\CurrentControlSet\Control\CI\Policy"" /v VerifiedAndReputablePolicyState /t REG_DWORD /d 0 /f >nul 2>&1
-{disableEdgeGpuScript}{darkWallpaperScript}{localNetworkAccessScript}""%SystemRoot%\System32\citool.exe"" --refresh >nul 2>&1
-{idleGuardScript}""{tableClothExeInSandbox}"" spork {idList} {string.Join(" ", switches)}
-popd
+{bootLogHeader}{dotnetRootScript}reg add ""HKLM\SYSTEM\CurrentControlSet\Control\CI\Policy"" /v VerifiedAndReputablePolicyState /t REG_DWORD /d 0 /f >nul 2>&1
+>>""{BootLogVar}"" echo [01] sac policy rc=%errorlevel%
+{disableEdgeGpuScript}{darkWallpaperScript}{localNetworkAccessScript}>>""{BootLogVar}"" echo [02] browser policies applied rc=%errorlevel%
+>>""{BootLogVar}"" echo [03] citool refresh begin %TIME%
+""%SystemRoot%\System32\citool.exe"" --refresh nul 2>&1
+>>""{BootLogVar}"" echo [04] citool refresh end rc=%errorlevel% %TIME%
+{idleGuardScript}>>""{BootLogVar}"" echo [05] launching spork %TIME%
+""{tableClothExeInSandbox}"" spork {idList} {string.Join(" ", switches)}
+set TCSPORKRC=%errorlevel%
+>>""{BootLogVar}"" echo [06] spork exited rc=%TCSPORKRC% %TIME%
+{launchFailureNotice}popd
@echo on
";
}
diff --git a/src/TableCloth.Test/SandboxStartupScriptTests.cs b/src/TableCloth.Test/SandboxStartupScriptTests.cs
new file mode 100644
index 00000000..3afba84a
--- /dev/null
+++ b/src/TableCloth.Test/SandboxStartupScriptTests.cs
@@ -0,0 +1,148 @@
+using System.Reflection;
+using TableCloth.Components.Implementations;
+using TableCloth.Models.Configuration;
+using TableCloth.Models.WindowsSandbox;
+
+namespace TableCloth.Test
+{
+ ///
+ /// 샌드박스 LogonCommand 로 실행되는 StartupScript.cmd 생성 결과에 대한 회귀 방지 테스트.
+ /// 이 스크립트가 조용히 깨지면 Spork 가 아예 뜨지 않고 사용자에게 아무 메시지도 남지 않으므로
+ /// (이슈 #304), 실측으로 확인된 함정들을 여기서 고정한다.
+ ///
+ [TestClass]
+ public sealed class SandboxStartupScriptTests
+ {
+ ///
+ /// GenerateSandboxStartupScript 는 private 인스턴스 메서드지만 주입된 의존성을
+ /// 전혀 사용하지 않으므로, 생성자 인수는 null 로 두고 리플렉션으로 호출한다.
+ ///
+ private static string GenerateScript(TableClothConfiguration configuration)
+ {
+ var builder = new SandboxBuilder(null!, null!, null!, null!);
+ var method = typeof(SandboxBuilder).GetMethod(
+ "GenerateSandboxStartupScript",
+ BindingFlags.Instance | BindingFlags.NonPublic);
+
+ Assert.IsNotNull(method, "GenerateSandboxStartupScript 를 찾지 못했습니다. 이름이 바뀌었는지 확인하세요.");
+ return (string)method.Invoke(builder, new object[] { configuration })!;
+ }
+
+ private static TableClothConfiguration DefaultConfiguration() => new();
+
+ ///
+ /// 스크립트는 UTF-8(Encoding.Default)로 기록되지만 cmd 는 이를 OEM 코드페이지로 읽는다.
+ /// 비 ASCII 문자가 섞이면 바이트 정렬이 깨져 스크립트가 통째로 무동작이 될 수 있다
+ /// (이슈 #304 진단 중 진단 하니스에서 실측한 현상).
+ ///
+ [TestMethod]
+ public void StartupScript_ShouldBePureAscii()
+ {
+ var script = GenerateScript(DefaultConfiguration());
+
+ var offending = script.Where(c => c > 0x7F).Distinct().ToArray();
+
+ Assert.IsEmpty(offending,
+ $"StartupScript 에 비 ASCII 문자가 있습니다: {string.Join(", ", offending.Select(c => $"U+{(int)c:X4}"))}");
+ }
+
+ ///
+ /// citool.exe 는 작업을 마친 뒤 표준 입력을 기다리는 환경이 있다. stdin 을 nul 로 묶지 않으면
+ /// batch 가 그 자리에서 멈추고 바로 다음 줄의 Spork 실행에 도달하지 못한다(이슈 #304).
+ ///
+ [TestMethod]
+ public void StartupScript_CitoolRefresh_ShouldRedirectStdinFromNul()
+ {
+ var script = GenerateScript(DefaultConfiguration());
+
+ StringAssert.Contains(script, "--refresh
+ /// echo rc=%errorlevel%>>file 처럼 숫자 바로 뒤에 리다이렉션이 붙으면 cmd 가
+ /// 0>> 를 fd 0 리다이렉션으로 파싱해 로그가 조용히 깨진다. 그래서 브레드크럼은
+ /// 리다이렉션을 명령 앞에 둔다.
+ ///
+ [TestMethod]
+ public void StartupScript_ShouldNotHaveDigitAdjacentRedirection()
+ {
+ var script = GenerateScript(DefaultConfiguration());
+
+ foreach (var token in new[] { "%errorlevel%>>", "%errorlevel%>", "%TCSPORKRC%>>" })
+ {
+ Assert.IsFalse(script.Contains(token, StringComparison.Ordinal),
+ $"'{token}' 형태는 cmd 가 fd 리다이렉션으로 오인합니다. 리다이렉션을 명령 앞으로 옮기세요.");
+ }
+ }
+
+ ///
+ /// 부팅 브레드크럼이 없으면 Spork 가 뜨기 전에 실패했을 때 아무 단서도 남지 않는다.
+ /// 기록 위치는 세션 종료 후에도 남는 Data 마운트가 우선이어야 한다.
+ ///
+ [TestMethod]
+ public void StartupScript_ShouldWriteBootBreadcrumbsToPersistentMount()
+ {
+ var script = GenerateScript(DefaultConfiguration());
+
+ StringAssert.Contains(script, "set TCBOOTLOG=");
+ StringAssert.Contains(script, @"Desktop\Data\tablecloth-boot.log",
+ "브레드크럼은 세션 종료 후 회수 가능한 Data 마운트에 남아야 합니다.");
+
+ foreach (var stage in new[] { "[00]", "[01]", "[02]", "[03]", "[04]", "[05]", "[06]" })
+ {
+ StringAssert.Contains(script, stage, $"브레드크럼 단계 {stage} 가 없습니다.");
+ }
+ }
+
+ ///
+ /// LogonCommand 는 .cmd 경로를 직접 지정하지 않는다. .cmd 는 PE 이미지가 아니라
+ /// 실행에 셸/파일 연결이 개입하는데, 그 경로가 깨지면 아무 오류 없이 그냥 실행되지 않는다
+ /// (이슈 #304). System32 의 cmd.exe 를 절대 경로로 명시해 의존을 없앤다.
+ ///
+ [TestMethod]
+ public void LogonCommand_ShouldInvokeScriptThroughAbsoluteCmdExePath()
+ {
+ // AssetsDirectoryPath 가 실재해야 BootstrapSandboxConfiguration 이 LogonCommand 를 채운다.
+ var configuration = new TableClothConfiguration
+ {
+ AssetsDirectoryPath = Path.GetTempPath(),
+ };
+
+ var method = typeof(SandboxBuilder).GetMethod(
+ "BootstrapSandboxConfiguration",
+ BindingFlags.Static | BindingFlags.NonPublic);
+
+ Assert.IsNotNull(method, "BootstrapSandboxConfiguration 을 찾지 못했습니다.");
+
+ var sandboxConfiguration = (SandboxConfiguration)method.Invoke(null, new object[] { configuration })!;
+
+ Assert.HasCount(1, sandboxConfiguration.LogonCommand);
+
+ var command = sandboxConfiguration.LogonCommand[0];
+ StringAssert.StartsWith(command, @"C:\Windows\System32\cmd.exe /c ",
+ "LogonCommand 는 게스트 System32 의 cmd.exe 를 절대 경로로 먼저 지정해야 합니다.");
+ StringAssert.Contains(command, @"""C:\Users\WDAGUtilityAccount\Desktop\App\StartupScript.cmd""",
+ "스크립트 경로는 따옴표로 감싸 전달해야 합니다.");
+ }
+
+ ///
+ /// SAC 해제와 정책 적용은 반드시 Spork 실행 *전에* 끝나야 하고, Spork 실행은 스크립트의
+ /// 마지막 동작이어야 한다. 순서가 뒤집히면 이슈 #256 회귀가 된다.
+ ///
+ [TestMethod]
+ public void StartupScript_ShouldLaunchSporkAfterPolicyStages()
+ {
+ var script = GenerateScript(DefaultConfiguration());
+
+ var sacIndex = script.IndexOf("VerifiedAndReputablePolicyState", StringComparison.Ordinal);
+ var citoolIndex = script.IndexOf("citool.exe", StringComparison.Ordinal);
+ var sporkIndex = script.IndexOf(" spork ", StringComparison.Ordinal);
+
+ Assert.IsTrue(sacIndex >= 0 && citoolIndex >= 0 && sporkIndex >= 0,
+ "SAC 정책 / citool / spork 실행 중 하나가 스크립트에 없습니다.");
+ Assert.IsLessThan(citoolIndex, sacIndex, "SAC 레지스트리 설정이 citool refresh 보다 뒤에 있습니다.");
+ Assert.IsLessThan(sporkIndex, citoolIndex, "citool refresh 가 Spork 실행보다 뒤에 있습니다.");
+ }
+ }
+}
diff --git a/src/TableCloth/Help/manual.ko.html b/src/TableCloth/Help/manual.ko.html
index c0fc615d..cb41cd58 100644
--- a/src/TableCloth/Help/manual.ko.html
+++ b/src/TableCloth/Help/manual.ko.html
@@ -177,6 +177,17 @@
7. 자주 묻는 질문
옵션 → 인증서 탭에서 ‘공동인증서 폴더 공유’가 켜져 있는지, 내 PC에 인증서 폴더가 실제로 있는지 확인하세요.
화면이 하얗게 나오거나 느립니다.
옵션 → 호환성 탭의 GPU 가속을 끈 상태(기본값)로 사용해 보세요. 일부 그래픽 환경에서 발생할 수 있습니다.
+ 샌드박스는 열리는데 식탁보 창이 뜨지 않고 아무 메시지도 없습니다.
+
+ 샌드박스 바탕 화면에 App·Data 폴더는 보이는데 그 뒤로 아무 일도 일어나지 않는 경우입니다.
+ 샌드박스 안에서 바탕 화면 → App → StartupScript.cmd를 직접 실행했을 때 정상적으로 진행된다면,
+ Windows Sandbox가 시작 시 실행하기로 한 명령을 무시하고 있는 상태입니다. 이때는 식탁보가 만든 파일이 아니라
+ Windows Sandbox 쪽 문제이므로, Windows 기능에서 ‘Windows Sandbox’를 끄고 재부팅한 뒤 다시 켜서
+ 샌드박스 이미지를 초기 상태로 되돌려 보세요. 서드파티 백신을 쓰신다면 잠시 중지하고 시도해 보시는 것도 도움이 됩니다.
+
+ 제보해 주실 때는 데이터 폴더에 남는 tablecloth-boot.log 파일을 함께 첨부해 주시면 큰 도움이 됩니다.
+ 샌드박스 시작 과정이 어디까지 진행됐는지 기록되어 있습니다.
+