diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e1c12cd0..3c296c15 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -327,6 +327,20 @@ jobs: Write-Host "Created symbols archive: $zip" shell: pwsh + # 이슈 대응용 경량 테스트 빌드 아티팩트. + # 아래 Velopack-* 아티팩트는 설치본 + Portable + Spork + nupkg + 업데이트 메타데이터를 + # 모두 담아 수백 MB에 달한다. 특정 제보자에게 "이 빌드로 한 번만 확인해 주세요"를 부탁할 때 + # 그 크기를 통째로 받게 하는 것은 부담이 크고, 아티팩트는 부분 다운로드도 되지 않는다. + # 식탁보 Portable zip 하나만 따로 올려 두면 필요한 것만 받을 수 있다. + - name: Upload TableCloth portable only (lightweight test build) + if: matrix.configuration == 'Release' + uses: actions/upload-artifact@v4 + with: + name: TableCloth-Portable-${{ matrix.platform }} + path: "releases/TableCloth_*_Release_${{ matrix.platform }}_Portable.zip" + if-no-files-found: error + retention-days: 5 + # Upload the Velopack package - name: Upload build artifacts (Velopack) uses: actions/upload-artifact@v4 diff --git a/docs/ISSUE_304_TRIAGE.md b/docs/ISSUE_304_TRIAGE.md new file mode 100644 index 00000000..ba6aab90 --- /dev/null +++ b/docs/ISSUE_304_TRIAGE.md @@ -0,0 +1,347 @@ +# 샌드박스 진입 무음 실패 트리아지 — Spork가 뜨지 않음 (이슈 #304) + +> 상태: **종료 — 제보 환경 고유 문제로 확인, 이슈 CLOSED(2026-07-27)** · 최종 갱신 2026-07-27 +> 결론은 §0 을, 재발 시 대응은 §0-6 을 볼 것. §3 의 가설 서술은 진단 결과가 오기 전의 기록이다. +> 대상: 식탁보 1.20.7(Retail/WPF), Windows 11 25H2 빌드 26200.8875 +> 선행 이슈: [#256](https://github.com/yourtablecloth/TableCloth/issues/256)(SAC 차단) · +> [#277](https://github.com/yourtablecloth/TableCloth/issues/277)(#256 중복) · +> [#237](https://github.com/yourtablecloth/TableCloth/issues/237)(DNS/hang) + +## 0. 결론 (2026-07-26 2차 — 제보자 진단 결과 반영) + +### 0-1. SAC 가설(§3 H1)은 **기각** + +제보자 환경의 게스트에서: + +| 항목 | 실측값 | 해석 | +| --- | --- | --- | +| `VerifiedAndReputablePolicyState` | `0x2` | **평가(evaluation) 모드 = 차단하지 않음** | +| 활성 CI 정책 | `VerifiedAndReputableDesktopEvaluation(+FlightSupplemental)` | 강제 정책 없음 | +| 미서명 `TableCloth.exe` 수동 실행 | **PID 4320, 20초 후에도 262MB 점유 생존** | 차단 없음 | +| CodeIntegrity 이벤트 | 차단(3077 등) 0건, 전부 정보성 refresh | 차단 없음 | + +이슈 #256과는 **다른 문제**다. 코드 서명 유무는 이번 건과 무관하다. + +### 0-2. 원인 — `citool --refresh`가 표준 입력을 기다린다 + +게스트 이미지 `10.0.26100.8875`에서 `citool.exe --refresh`는 작업을 마친 뒤 +**"계속하려면 Enter 키를 누르세요."로 stdin을 기다린다.** 제보자가 직접 확인: + +```text +C:\...\App>"C:\Windows\System32\citool.exe" --refresh +작업 성공 +계속하려면 Enter 키를 누르세요. <- CiTool.exe 가 살아서 대기 +``` + +Enter를 치자 그 자리에서 Spork가 실행됐다. 즉: + +- `StartupScript.cmd`에서 **블로킹될 수 있는 유일한 줄이 `citool`** 이다(`reg add`는 즉시 반환). +- 그 바로 다음 줄이 `TableCloth.exe spork`이므로 **Spork 실행에 영원히 도달하지 못한다**. +- 원본 스크립트는 이 줄이 `>nul 2>&1`로 묶여 있어 **프롬프트조차 보이지 않는다** = "아무 메시지 없음". + +**수정**(반영됨): stdin을 nul로 리다이렉트해 프롬프트가 즉시 EOF를 받게 한다. + +```bat +"%SystemRoot%\System32\citool.exe" --refresh nul 2>&1 +``` + +### 0-3. 로컬 재현 결과 (2026-07-26 23:15, 메인테이너 PC) + +[tools/issue304/repro-boot-chain.ps1](../tools/issue304/repro-boot-chain.ps1)로 부팅 체인을 +그대로 재현했다. **결론: 제보자 증상은 로컬에서 재현되지 않으며, 아래 추정 몇 개가 뒤집혔다.** + +```text +[00] logon batch started 23:15:20.15 +[01] pushd ok cwd=C:\Users\WDAGUtilityAccount\Desktop\App +[01b] VerifiedAndReputablePolicyState 0x2 <- 게스트 기본값은 평가 모드 +[02]~[05] reg add 4건 모두 errorlevel=0 +[06] BEFORE citool : CI=0x0, Edge LNA=* (기록 성공) +[07] calling citool 23:15:21.15 +[08] citool returned errorlevel=0 at 23:15:21.69 <- 0.54초, 대기 없음 +[08b] citool 무(無)리다이렉트 프로브: 10초 뒤 "No tasks are running" <- 프롬프트 안 뜸 +[09] AFTER citool : CI=0x0, Edge LNA=* (그대로 유지) +[10] launching TableCloth.exe spork 23:15:32.22 <- 체인 정상 완주 +``` + +여기서 확정된 사실: + +| 항목 | 결과 | +| --- | --- | +| LogonCommand가 마운트의 `.cmd`를 직접 실행 | **정상 동작** (로그온 후 6~9초). §3 H2 는 최소한 이 환경에선 성립하지 않는다 | +| 게스트 기본 SAC 상태 | **`0x2`(평가)** — 호스트가 `0x0`이어도 그렇다. 호스트 상태를 상속하지 않는다 | +| `citool --refresh` 대기 | **재현 안 됨.** stdin 리다이렉트 없이 백그라운드로 띄워도 10초 내 종료 | +| citool이 레지스트리를 되돌리는가 | **아니다.** `[06]`/`[09]` 값이 동일하게 유지된다 | + +→ 따라서 §0-2에서 "citool 대기가 원인"이라고 본 것은 **제보자 환경에 국한된 조건부 현상**이다. +` 하니스 자체의 함정: 초기 템플릿에 한글 주석을 넣었더니 UTF-8 로 기록된 바이트를 cmd 가 OEM +> 코드페이지로 읽으며 스크립트가 통째로 무동작이 되어, "LogonCommand 가 실행되지 않는다"는 +> 잘못된 결론을 한 번 냈다. 실제 생성 스크립트는 순수 ASCII 이므로 이 문제가 없다. +> **게스트에서 실행할 진단 스크립트는 반드시 ASCII 로 작성할 것.** + +### 0-4. 반영한 변경 (PR [#305](https://github.com/yourtablecloth/TableCloth/pull/305)) + +로컬 재현이 안 되므로, **다음 재현에서 증거가 남게 하는 것**까지를 이번 범위로 잡았다. + +1. **citool 대기 경로 차단** — `--refresh "` 경유로 바꿔 한 번 더 전달했으나, +제보자가 자신의 PC 문제로 판단하고 이슈를 닫았다. + +### 0-6. 최종 결론 — 제보 환경 고유 문제 (재발 대비 기록) + +**식탁보가 만들어내는 산출물에는 문제가 없다.** 아래가 전부 실측으로 배제됐다. + +| 배제된 후보 | 근거 | +| --- | --- | +| Smart App Control 차단 | 게스트 SAC는 평가 모드(`0x2`), 미서명 exe 정상 생존, CodeIntegrity 차단 이벤트 0건 | +| 배치 파일 인코딩 / BOM / 줄바꿈 | 생성물 실측: 비 ASCII 0, BOM 없음, CRLF 33 / LF 0. 카탈로그 서비스 ID도 전수 ASCII | +| wsb 한 줄 XML · XML 선언 없음 · 마운트 3개 | 실물 직렬화 코드로 만든 wsb 로 로컬 정상 동작 | +| wsb 안의 비 ASCII 경로(UTF-8 no BOM) | 한글 경로 마운트로 로컬 정상 동작 | +| 그룹 정책으로 LogonCommand 차단 | 그런 정책이 존재하지 않음(Windows Sandbox 정책 8종 전수 확인) | +| citool 무한 대기 | ` + + C:\Windows\System32\notepad.exe + + + ``` + +2. 메모장이 뜨면 마운트 폴더의 `.cmd` 실행으로 한 단계 좁힌다(`tools/issue304/` 하니스 참고). +3. 사용자 안내: **Windows 기능에서 Windows Sandbox 끄기 → 재부팅 → 다시 켜기**(게스트 이미지 초기화), + 서드파티 백신 일시 중지. 앱 내 도움말 FAQ 에 같은 내용을 실어 두었다 + (`src/TableCloth/Help/manual.ko.html`). +4. `문서\TableCloth\Data\tablecloth-boot.log` 를 받는다. 마지막 줄이 곧 실패 지점이다. + +| 마지막 줄 | 해석 | +| --- | --- | +| 파일 자체가 없음 | LogonCommand가 실행되지 않았다 (본 이슈와 동일) | +| `[03] citool refresh begin` | citool 대기 — `> "%LOG%"`에서 cmd가 `1>>`을 stdout + 리다이렉션으로 파싱해 가장 중요한 항목이 무효화됐다. `/v "1"`로 수정 완료. + +## 1. 증상 + +- 식탁보로 샌드박스를 띄우면 **샌드박스는 정상적으로 부팅**되고 바탕 화면에 `App` / `Data` / `NPKI` 폴더가 보인다. +- 그런데 Spork가 뜨지 않고, **오류 대화상자도 토스트도 콘솔 창도 전혀 없다.** +- 제보자는 같은 PC의 **Windows 10에서는 정상 동작**했고, Windows 11 인플레이스 업그레이드 후부터 재현. 버전을 올려도 동일. + +바탕 화면의 세 폴더는 wsb `MappedFolders`가 붙었다는 뜻일 뿐, LogonCommand 실행 여부와는 무관하다 +([SandboxMountPaths.cs](../src/TableCloth.Core/Models/WindowsSandbox/SandboxMountPaths.cs) 주석 참조 — +`SandboxFolder`를 쓰지 않으므로 모든 마운트는 `Desktop\`로 노출된다). + +## 2. 부팅 체인과 "무음" 구조 + +```text +wsb LogonCommand + └─ Desktop\App\StartupScript.cmd SandboxBuilder.cs:258, 445-452 + ├─ reg add ...CI\Policy VerifiedAndReputablePolicyState=0 (SAC 끄기 시도) + ├─ reg add ...Edge\HardwareAccelerationModeEnabled (GPU OFF일 때) + ├─ reg add ...Edge\LocalNetworkAccessAllowedForUrls (항상) + ├─ citool.exe --refresh + └─ Desktop\App\TableCloth.exe spork + └─ RunSpork → UseSpork → AppStartup.InitializeAsync → SandboxBootstrap → MainWindow +``` + +| 실패 지점 | 사용자에게 보이는 것 | 근거 | +| --- | --- | --- | +| LogonCommand 미실행 | **없음** | 배치가 안 돌면 콘솔 자체가 안 뜬다 | +| `TableCloth.exe` 프로세스 생성 실패 | **없음** | [SandboxBuilder.cs:449-450](../src/TableCloth.App/Components/Implementations/SandboxBuilder.cs#L449-L450) — exit code를 검사하지 않고 곧바로 `popd`. 콘솔이 즉시 닫혀 `액세스가 거부되었습니다` 도 사라진다 | +| Spork 프로세스 내부 예외 | 대화상자 | `Program.RunSpork`의 `catch` → `MessageBox` | +| 카탈로그 로드 실패 | 대화상자 | `AppStartup.InitializeAsync` 3회 재시도 후 critical 오류 | + +**따라서 "메시지가 하나도 없다"는 것은 제보자 환경의 특이점이 아니라, 프로세스가 뜨기 전에 죽었다는 신호다.** +v1.14 시절(#256)에는 SAC 차단 시 대화상자가 보였으나, v1.20.0(c2ec7d7)에서 실행 지점이 cmd 배치로 옮겨가며 완전 무음이 되었다. + +### 2-1. 로그가 남지 않는 이유 + +- [UseSporkExtensions.cs:62-64](../src/Spork.App/DependencyInjection/UseSporkExtensions.cs#L62-L64) — `AddSerilog()`만 호출하고 + **sink 구성이 리포지토리 어디에도 없다**(`LoggerConfiguration` / `WriteTo` 검색 결과 0건). `AddConsole()`은 WinExe라 무의미. +- Sentry는 프로세스가 떠야 전송되므로 "실행 전 실패"는 아무 데이터도 남기지 못한다. +- 호스트 staging(`%LocalAppData%\TableCloth.Data\Sandbox`)은 메인 창을 닫을 때 + [MainWindowViewModel.cs:62](../src/TableCloth.App/ViewModels/MainWindowViewModel.cs#L62)에서 통째로 삭제된다. + → **로그를 남긴다면 영속 마운트인 `Data` 쪽**(기본값 `문서\TableCloth\Data`)이어야 한다. + +## 3. 가설 + +### H1. Smart App Control이 미서명 `TableCloth.exe`를 커널 단에서 차단 (유력) + +| 근거 | 내용 | +| --- | --- | +| 회귀 시점 | SAC는 Windows 11 전용 기능. "Win10 정상 → Win11 업그레이드 후 실패"와 정확히 일치 | +| 전례 | #256/#277이 **동일 증상**(`SAC에 차단되면서 아무런 반응 없음`) | +| 바이너리 서명 | 서명이 수동 경로(`build.cs --sign`, [sign-release.ps1](../tools/sign-release.ps1))라 CI 산출물은 미서명일 수 있다. 메인테이너 PC의 설치본(1.21.0-preview.1)은 `Get-AuthenticodeSignature` = **NotSigned** 확인. 1.20.7 릴리스 자산의 실제 서명 여부는 별도 확인 필요 | +| 우회책 수명 | `reg + CiTool -r` 우회가 최근 빌드에서 듣지 않는다는 보고 다수(MS Q&A). 2026년 들어 SAC 토글 방식 자체가 개편됨 | +| 무음성 | SAC 차단은 `PsCreateProcess` 커널 단에서 일어나 부모 프로세스는 그대로 진행 | + +하위 시나리오: SAC는 신뢰되지 않은 출처의 `.exe/.bat/.cmd` 자체도 차단한다. `StartupScript.cmd`가 걸리면 +**SAC를 끄는 코드가 SAC 때문에 실행되지 않는** 닭-달걀 구조가 되어 H1과 H2가 같은 증상으로 수렴한다. + +### H2. LogonCommand 자체가 실행되지 않음 / 마운트 레이스 + +- 25H2에서 Windows Sandbox는 인박스 컴포넌트가 아니라 **MSIX 앱**(`MicrosoftWindows.WindowsSandbox`)으로 제공된다. + #237에서 제보자가 추측했던 "24H2에서 샌드박스가 스토어 앱처럼 변했다"가 사실로 확인됨. +- LogonCommand가 마운트 완료보다 먼저 발사되면 `Desktop\App\StartupScript.cmd`를 찾지 못하고 조용히 끝난다. + 스토어 경유로 클라이언트 버전이 사람마다 다를 수 있어 "나는 되는데 저 사람은 안 되는" 양상과도 맞는다. + +### H3. 배제된 후보 + +| 후보 | 배제 근거 | +| --- | --- | +| 게스트 PowerShell 미구성 | 08819eb에서 **부팅 경로의 PowerShell이 제거**됨. 현재 배치는 `reg`/`citool`만 사용. PowerShell은 UI 진입 **이후** 사이트별 설치 단계([PowerShellScriptRunStep.cs](../src/Spork.App/Steps/Implementations/PowerShellScriptRunStep.cs))에서만 쓰인다 | +| DNS/카탈로그 실패(#237류) | 실패 시 오류 대화상자가 뜬다. "메시지 없음"과 모순 | +| `citool --refresh` hang | 배치가 멈춰 있으면 검은 콘솔 창이 바탕 화면에 남아 있어야 한다 | +| 배치 파일 인코딩(mojibake) | 생성되는 스크립트 내용이 전부 ASCII(경로·서비스 ID). `Encoding.Default`(=UTF-8)여도 무해 | + +## 4. 판별 프로토콜 + +### 4-1. 결정적 분기 + +게스트 안에서 아래 한 줄의 결과가 조사 범위를 절반으로 줄인다. 이 레지스트리 값은 `StartupScript.cmd`만 기록한다. + +```cmd +reg query "HKLM\SOFTWARE\Policies\Microsoft\Edge\LocalNetworkAccessAllowedForUrls" /v 1 +``` + +| 결과 | 해석 | 다음 단계 | +| --- | --- | --- | +| 값이 있음 | 배치는 실행됨 → `TableCloth.exe` 실행 단계에서 실패 | H1 계열. SAC 상태와 CodeIntegrity 이벤트 확인 | +| 값이 없음 | LogonCommand가 아예 안 돌았음 | H2 계열. 샌드박스 클라이언트 버전·마운트 타이밍 확인 | + +### 4-2. 게스트 측 나머지 + +```cmd +reg query "HKLM\SYSTEM\CurrentControlSet\Control\CI\Policy" /v VerifiedAndReputablePolicyState +powershell -c "(Get-MpComputerStatus).SmartAppControlState" +cd /d "%USERPROFILE%\Desktop\App" && TableCloth.exe spork +wevtutil qe Microsoft-Windows-CodeIntegrity/Operational /c:30 /rd:true /f:text | findstr /i tablecloth +``` + +### 4-3. 호스트 측 + +```powershell +Get-AppxPackage -Name "*WindowsSandbox*" | Select-Object Name, Version +reg query "HKLM\SYSTEM\CurrentControlSet\Control\CI\Policy" /v VerifiedAndReputablePolicyState +``` + +## 5. 진단 도구 + +### 5-1. 제보자용 — 실제 세션에서 증거 수집 + +[tools/diagnose_sandbox_boot.cmd](../tools/diagnose_sandbox_boot.cmd) — §4의 항목을 한 번에 수집해 +`tablecloth-diag.txt`로 남긴다. + +1. 호스트의 **Data 폴더**(기본값 `문서\TableCloth\Data`)에 스크립트를 넣는다. +2. 식탁보로 평소처럼 샌드박스를 실행한다. +3. Spork가 뜨지 않으면 게스트에서 `%USERPROFILE%\Desktop\Data\diagnose_sandbox_boot.cmd` 실행. +4. Data는 읽기·쓰기 마운트라 결과 파일이 **호스트에서도 그대로 보인다** → 이슈에 첨부. + +출력은 의도적으로 ASCII만 사용한다(게스트 콘솔 코드페이지가 보장되지 않아 한글은 깨질 수 있다). +제보자에게 실제로 게시한 안내 전문은 [tools/issue304/reporter-comment-2026-07-26.md](../tools/issue304/reporter-comment-2026-07-26.md)에 보존해 두었다. + +### 5-2. 메인테이너용 — 맨 샌드박스로 우회책 유효성 측정 + +[tools/issue304/](../tools/issue304/) — 식탁보와 무관한 빈 샌드박스를 띄워 **게스트의 SAC 기본 상태**와 +**`reg + citool` 우회가 이 빌드에서 아직 듣는지**를 직접 측정한다. 5-1이 "제보자 환경에서 무슨 일이 +벌어졌나"라면 이쪽은 "이 빌드에서 우회책이 유효한가"를 본다. + +```powershell +cd tools/issue304 +./run-local-repro.ps1 +``` + +읽는 법과 각 프로브의 의미는 [tools/issue304/README.md](../tools/issue304/README.md) 참조. + +## 6. 확인된 환경 사실 (2026-07-26, 메인테이너 PC) + +| 항목 | 값 | 의미 | +| --- | --- | --- | +| OS | Windows 11 Pro 25H2 **26200.8875** | 제보자와 **완전히 동일한 빌드** → 로컬 재현 시도 가능 | +| Windows Sandbox | MSIX `MicrosoftWindows.WindowsSandbox` **0.8.107.0** | 인박스가 아닌 스토어 배포 클라이언트(H2 관련) | +| 호스트 SAC | `VerifiedAndReputablePolicyState = 0` | 호스트는 꺼짐. **게스트 기본값은 별도 확인 필요** | +| 설치본 서명 | `NotSigned` (1.21.0-preview.1) | H1의 전제와 부합 | + +## 7. 대응 후보 + +### 7-1. 원인과 무관하게 필요 (관측 가능성 확보) + +1. ✅ **부팅 브레드크럼** — `StartupScript.cmd`가 단계별 결과를 `Desktop\Data\tablecloth-boot.log`에 기록. + staging이 아니라 영속 마운트라 세션 종료 후 호스트에서 회수 가능(§2-1). PR #305 에서 반영. +2. ✅ **실행 실패 가시화** — `TableCloth.exe` 호출 뒤 종료 코드를 검사해 비정상이면 콘솔에 안내 후 `pause`. + PR #305 에서 반영. (`msg *`는 에디션에 따라 없을 수 있어 콘솔 유지 방식을 택했다.) +3. ⬜ **Serilog sink 구성** — 최소한 파일 sink 하나. 지금은 로거를 등록해 놓고 아무 데도 쓰지 않는다. + 브레드크럼은 batch 단계까지만 덮으므로, Spork 진입 이후 구간은 여전히 로그가 없다. + +### 7-2. H1이 맞을 때 + +4. **SAC 우회 사후 검증** — `citool --refresh` 후 상태를 다시 읽어 여전히 1/2면 로그에 남기고 사용자에게 안내 + (우회가 더 이상 통하지 않는 빌드를 조기 감지). +5. 정책 적용 타이밍 완충 — #256의 "샌드박스 안에서 재부팅하면 정상 동작" 관찰은 적용 지연을 시사. 짧은 대기 + 1회 재시도. +6. 근본 해결은 여전히 #256의 장기 과제(서명 + reputation 축적). + +### 7-3. H2가 맞을 때 + +7. **마운트 레이스 방어** — LogonCommand를 `cmd.exe /c "…대기 루프…"`로 감싸 `StartupScript.cmd` 존재를 최대 N초 폴링한 뒤 호출. + LogonCommand가 System32 바이너리만 참조하게 되는 부수 효과도 있다. + +## 8. 참고 + +- [How do you turn off Smart App Control in Windows Sandbox? — Microsoft Q&A](https://learn.microsoft.com/en-us/answers/questions/5641557/how-do-you-turn-off-smart-app-control-in-windows-s) + — `reg + CiTool.exe -r` 우회와 "최근 빌드에서 듣지 않는다"는 후속 보고. +- [Smart App Control — text/plain (2026-04-28)](https://textslashplain.com/2026/04/28/smart-app-control/) + — 평가 모드/차단 표면화 방식, 2026년 토글 개편. +- [Codex Sandbox Is Silently Dead on Windows](https://terminalblog.com/blog/codex-smart-app-control-sandbox-fail/) + — 미서명 자식 프로세스가 커널 단에서 차단되어 부모가 눈치채지 못하는 동일 패턴 사례. diff --git a/src/TableCloth.App/Components/Implementations/SandboxBuilder.cs b/src/TableCloth.App/Components/Implementations/SandboxBuilder.cs index 5a0da14b..e4aa8aad 100644 --- a/src/TableCloth.App/Components/Implementations/SandboxBuilder.cs +++ b/src/TableCloth.App/Components/Implementations/SandboxBuilder.cs @@ -255,10 +255,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)를 에 채워 넣는다. @@ -395,6 +412,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 경로를 시도하며, 이 경로가 // 일부 환경에서 흰 화면이나 렌더링 지연을 유발할 수 있기 때문에 정책 키로 명시적으로 @@ -442,12 +466,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 파일을 함께 첨부해 주시면 큰 도움이 됩니다. + 샌드박스 시작 과정이 어디까지 진행됐는지 기록되어 있습니다. +
diff --git a/tools/diagnose_sandbox_boot.cmd b/tools/diagnose_sandbox_boot.cmd new file mode 100644 index 00000000..16130882 --- /dev/null +++ b/tools/diagnose_sandbox_boot.cmd @@ -0,0 +1,123 @@ +@echo off +rem ========================================================================== +rem TableCloth - Windows Sandbox boot chain diagnostics (issue #304) +rem -------------------------------------------------------------------------- +rem WHERE TO RUN : inside the Windows Sandbox guest, after TableCloth has +rem launched the sandbox but Spork never appeared. +rem HOW TO RUN : put this file into the host folder that TableCloth mounts +rem as the Data drive (default: Documents\TableCloth\Data), +rem launch the sandbox as usual, then inside the sandbox run +rem %USERPROFILE%\Desktop\Data\diagnose_sandbox_boot.cmd +rem OUTPUT : tablecloth-diag.txt next to this script. Because the Data +rem folder is a read-write mount, the file is visible on the +rem host after the sandbox is closed - attach it to the issue. +rem +rem ASCII only on purpose: the guest console codepage is not guaranteed, and +rem mojibake in the report is worse than plain English. +rem ========================================================================== +setlocal enabledelayedexpansion +set LOG=%~dp0tablecloth-diag.txt +set APPDIR=C:\Users\WDAGUtilityAccount\Desktop\App + +echo ==== TableCloth sandbox boot diagnostics (issue #304) ====> "%LOG%" +echo [00] collected at %DATE% %TIME%>> "%LOG%" +whoami>> "%LOG%" 2>&1 +ver>> "%LOG%" 2>&1 + +rem -------------------------------------------------------------------------- +rem [01] Did StartupScript.cmd (the wsb LogonCommand) actually run? +rem The two Edge policy values below are written ONLY by StartupScript.cmd, +rem so their presence proves the logon command executed. This single +rem answer splits the whole investigation in two. +rem -------------------------------------------------------------------------- +echo.>> "%LOG%" +echo [01] did the LogonCommand batch run? (Edge policies are written only by it)>> "%LOG%" +rem /v "1" must stay quoted: bare `/v 1>>` makes cmd parse `1>>` as a stdout +rem redirection, reg then sees /v without a value and fails with a syntax error. +reg query "HKLM\SOFTWARE\Policies\Microsoft\Edge\LocalNetworkAccessAllowedForUrls" /v "1">> "%LOG%" 2>&1 +reg query "HKLM\SOFTWARE\Policies\Microsoft\Edge" /v HardwareAccelerationModeEnabled>> "%LOG%" 2>&1 +rem Is the logon batch still alive and stuck? cmd.exe holding StartupScript.cmd or a +rem surviving CiTool.exe both mean the boot chain is blocked rather than never started. +echo --- cmd.exe / CiTool.exe still running? --->> "%LOG%" +powershell -NoProfile -Command "Get-CimInstance Win32_Process | Where-Object { $_.Name -in 'cmd.exe','CiTool.exe' } | Select-Object Name, ProcessId, CommandLine | Format-List | Out-String">> "%LOG%" 2>&1 + +rem -------------------------------------------------------------------------- +rem [02] Smart App Control state. 0 = off, 1 = enforced, 2 = evaluation. +rem StartupScript.cmd tries to force it to 0 and then runs citool +rem --refresh; if the value is still 1 or 2 here, that workaround no +rem longer takes effect on this build. +rem -------------------------------------------------------------------------- +echo.>> "%LOG%" +echo [02] Smart App Control state (0=off 1=enforce 2=evaluation)>> "%LOG%" +reg query "HKLM\SYSTEM\CurrentControlSet\Control\CI\Policy" /v VerifiedAndReputablePolicyState>> "%LOG%" 2>&1 +powershell -NoProfile -Command "try { 'SmartAppControlState = ' + (Get-MpComputerStatus).SmartAppControlState } catch { 'ERR: ' + $_.Exception.Message }">> "%LOG%" 2>&1 +if exist "%SystemRoot%\System32\citool.exe" (echo citool.exe present>> "%LOG%") else (echo citool.exe MISSING>> "%LOG%") + +rem -------------------------------------------------------------------------- +rem [03] Is the App mount actually there, with the binary we try to launch? +rem -------------------------------------------------------------------------- +echo.>> "%LOG%" +echo [03] App mount contents>> "%LOG%" +if exist "%APPDIR%\TableCloth.exe" ( + dir "%APPDIR%">> "%LOG%" 2>&1 +) else ( + echo %APPDIR%\TableCloth.exe NOT FOUND>> "%LOG%" + dir "C:\Users\WDAGUtilityAccount\Desktop">> "%LOG%" 2>&1 +) + +rem -------------------------------------------------------------------------- +rem [04] Launch it by hand and see whether the process survives creation. +rem A Smart App Control block happens in the kernel at process creation, +rem so the parent just continues and the task list stays empty. +rem -------------------------------------------------------------------------- +echo.>> "%LOG%" +echo [04] launch TableCloth.exe spork and check survival>> "%LOG%" +if exist "%APPDIR%\TableCloth.exe" ( + echo Launching TableCloth.exe - if a "blocked" dialog pops up, screenshot it. + start "" "%APPDIR%\TableCloth.exe" spork + echo start errorlevel=!errorlevel!>> "%LOG%" + ping -n 21 127.0.0.1 >nul + echo --- tasklist after 20s --->> "%LOG%" + tasklist /fi "imagename eq TableCloth.exe">> "%LOG%" 2>&1 +) else ( + echo SKIP - binary not present>> "%LOG%" +) + +rem -------------------------------------------------------------------------- +rem [05] Kernel side evidence of a block. +rem -------------------------------------------------------------------------- +echo.>> "%LOG%" +echo [05] CodeIntegrity operational log (most recent 30 events)>> "%LOG%" +wevtutil qe Microsoft-Windows-CodeIntegrity/Operational /c:30 /rd:true /f:text>> "%LOG%" 2>&1 + +rem -------------------------------------------------------------------------- +rem [06] Windows PowerShell sanity - not used by the boot path since v1.20.0, +rem but per-site install steps still depend on it, so record it anyway. +rem -------------------------------------------------------------------------- +echo.>> "%LOG%" +echo [06] Windows PowerShell availability>> "%LOG%" +if exist "%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe" ( + echo powershell.exe present>> "%LOG%" + powershell -NoProfile -Command "$PSVersionTable.PSVersion.ToString()">> "%LOG%" 2>&1 + powershell -NoProfile -Command "(Get-ExecutionPolicy -List | Out-String)">> "%LOG%" 2>&1 +) else ( + echo powershell.exe MISSING>> "%LOG%" +) + +rem -------------------------------------------------------------------------- +rem [07] Network / DNS - rules out the issue #237 class of failures. +rem -------------------------------------------------------------------------- +echo.>> "%LOG%" +echo [07] DNS and catalog reachability>> "%LOG%" +ipconfig /all>> "%LOG%" 2>&1 +nslookup yourtablecloth.app>> "%LOG%" 2>&1 + +echo.>> "%LOG%" +echo ==== done ====>> "%LOG%" + +echo. +echo Diagnostics finished. +echo Result file: %LOG% +echo The same file is visible on the host under Documents\TableCloth\Data. +echo. +pause diff --git a/tools/issue304/.gitignore b/tools/issue304/.gitignore new file mode 100644 index 00000000..395fea56 --- /dev/null +++ b/tools/issue304/.gitignore @@ -0,0 +1,4 @@ +# run-local-repro.ps1 이 생성 / 게스트가 기록하는 산출물 +local-repro.wsb +result.txt +tablecloth-diag.txt diff --git a/tools/issue304/README.md b/tools/issue304/README.md new file mode 100644 index 00000000..690c19d3 --- /dev/null +++ b/tools/issue304/README.md @@ -0,0 +1,110 @@ +# 이슈 #304 트리아지 작업 폴더 + +> 상태: **종료 — 제보 환경 고유 문제로 확인, 이슈 CLOSED(2026-07-27)** · 최종 갱신 2026-07-27 +> 도구는 남겨 둡니다. 같은 증상의 제보가 다시 오면 그대로 재사용할 수 있습니다. +> 분석 본문: [docs/ISSUE_304_TRIAGE.md](../../docs/ISSUE_304_TRIAGE.md) +> 이슈: [#304](https://github.com/yourtablecloth/TableCloth/issues/304) — Windows 11 25H2에서 샌드박스는 뜨지만 Spork가 실행되지 않고 아무 메시지도 없음 + +이 폴더는 #304를 재현·판별하기 위한 도구 모음입니다. 원인이 확정되면 폴더째 정리하거나 +재사용 가능한 부분만 `tools/` 상위로 승격시키면 됩니다. + +## 어떤 스크립트를 언제 쓰나 + +| 파일 | 실행 주체 | 목적 | +| --- | --- | --- | +| [`../diagnose_sandbox_boot.cmd`](../diagnose_sandbox_boot.cmd) | **제보자** | 식탁보가 띄운 **실제 세션** 안에서 실행. 증거를 `tablecloth-diag.txt`로 모아 호스트에서 회수 | +| `repro-boot-chain.ps1` + `startup-script.cmd.template` | **메인테이너** | 부팅 체인(LogonCommand → StartupScript → Spork)을 식탁보 UI 없이 재현. 단계별 브레드크럼으로 **어디까지 갔는지**를 본다 | +| `local-repro.cmd` + `run-local-repro.ps1` | 메인테이너 | (1차 조사용) 게스트의 SAC 기본 상태 측정. **SAC 가설이 기각되어 역할은 끝났다** | + +세 스크립트의 질문이 다릅니다. 첫째는 "제보자 환경에서 무슨 일이 벌어졌나", +둘째는 "우리 부팅 체인이 어느 줄에서 멈추나", 셋째는 "SAC가 범인인가"(→ 아니었음)입니다. + +## 부팅 체인 재현 (현재 주력) + +```powershell +cd tools/issue304 +./repro-boot-chain.ps1 -Mode repro # 수정 전: citool 에 stdin 리다이렉트 없음 +./repro-boot-chain.ps1 -Mode fixed # 수정 후: citool --refresh ` 아티팩트 링크를 새로 잡아 주세요. 이 경량 아티팩트는 +`build.yml` 에 별도 스텝으로 추가돼 있습니다(Velopack-* 는 651MB 라 제보자에게 부담). + +```bash +gh api "repos/yourtablecloth/TableCloth/actions/runs//artifacts" \ + --jq '.artifacts[] | select(.name|startswith("TableCloth-Portable")) | "\(.name) id=\(.id)"' +# 링크 형식: https://github.com/yourtablecloth/TableCloth/actions/runs//artifacts/ +``` + +### 하니스를 쓸 때 반드시 지킬 것 + +**게스트에서 실행할 스크립트는 순수 ASCII로 작성한다.** 초기 템플릿에 한글 주석을 넣었더니 +UTF-8로 기록된 바이트를 cmd가 OEM 코드페이지로 읽으면서 스크립트가 통째로 무동작이 되었고, +"LogonCommand가 실행되지 않는다"는 **잘못된 결론을 한 번 냈습니다.** 제품 쪽은 +`SandboxStartupScriptTests`가 이 조건을 테스트로 고정해 두었습니다. + +메인테이너 PC는 제보자와 동일 호스트 빌드(25H2 26200.8875)이고 Windows Sandbox는 MSIX 앱 +`MicrosoftWindows.WindowsSandbox 0.8.107.0`입니다. **게스트 OS는 호스트와 달리 26100.8875** — +샌드박스 앱이 자체 베이스 이미지를 들고 다닙니다. 자세한 환경 사실은 분석 본문 §0, §6 참조. diff --git a/tools/issue304/local-repro.cmd b/tools/issue304/local-repro.cmd new file mode 100644 index 00000000..0cfa42e8 --- /dev/null +++ b/tools/issue304/local-repro.cmd @@ -0,0 +1,87 @@ +@echo off +rem ========================================================================== +rem TableCloth issue #304 - LOCAL REPRO probe (maintainer side) +rem -------------------------------------------------------------------------- +rem This is NOT the script we hand to reporters. See tools/diagnose_sandbox_boot.cmd +rem for that one; it runs inside a real TableCloth session. +rem +rem This one runs in a bare Windows Sandbox launched from local-repro.wsb, with +rem no TableCloth involvement, and answers three questions: +rem - does a wsb LogonCommand run at all on this build? -> [00] +rem - what is the guest's Smart App Control state out of the box? -> [01] +rem - does the reg+citool workaround still turn it off? -> [02][03] +rem - does an unsigned .NET single-file exe survive process creation +rem when launched from a mapped folder? -> [04] +rem +rem Result lands in result.txt next to this script (mapped read-write), so the +rem host can read it after the sandbox is gone. +rem +rem ASCII only on purpose: the guest console codepage is not guaranteed. +rem ========================================================================== +setlocal enabledelayedexpansion +set LOG=%~dp0result.txt +set TCDIR=C:\Users\WDAGUtilityAccount\Desktop\current + +echo ==== TableCloth #304 sandbox diagnostics ====> "%LOG%" +echo [00] LogonCommand DID run. %DATE% %TIME%>> "%LOG%" +whoami>> "%LOG%" 2>&1 +ver>> "%LOG%" 2>&1 + +echo.>> "%LOG%" +echo [01] SAC policy state BEFORE workaround>> "%LOG%" +reg query "HKLM\SYSTEM\CurrentControlSet\Control\CI\Policy" /v VerifiedAndReputablePolicyState>> "%LOG%" 2>&1 +echo [01b] Defender SmartAppControlState (0=off 1=on 2=eval)>> "%LOG%" +powershell -NoProfile -Command "try { (Get-MpComputerStatus).SmartAppControlState } catch { 'ERR: ' + $_.Exception.Message }">> "%LOG%" 2>&1 + +echo.>> "%LOG%" +echo [02] apply StartupScript.cmd workaround (reg + citool)>> "%LOG%" +reg add "HKLM\SYSTEM\CurrentControlSet\Control\CI\Policy" /v VerifiedAndReputablePolicyState /t REG_DWORD /d 0 /f>> "%LOG%" 2>&1 +echo reg add errorlevel=!errorlevel!>> "%LOG%" +if exist "%SystemRoot%\System32\citool.exe" ( + "%SystemRoot%\System32\citool.exe" --refresh>> "%LOG%" 2>&1 + echo citool errorlevel=!errorlevel!>> "%LOG%" +) else ( + echo citool.exe NOT FOUND in System32>> "%LOG%" +) + +echo.>> "%LOG%" +echo [03] SAC policy state AFTER workaround>> "%LOG%" +reg query "HKLM\SYSTEM\CurrentControlSet\Control\CI\Policy" /v VerifiedAndReputablePolicyState>> "%LOG%" 2>&1 +powershell -NoProfile -Command "try { (Get-MpComputerStatus).SmartAppControlState } catch { 'ERR: ' + $_.Exception.Message }">> "%LOG%" 2>&1 + +echo.>> "%LOG%" +echo [04] launch the UNSIGNED TableCloth.exe from a mapped folder>> "%LOG%" +if not exist "%TCDIR%\TableCloth.exe" ( + echo SKIP - %TCDIR%\TableCloth.exe not mounted>> "%LOG%" +) else ( + echo Launching TableCloth.exe - if a "blocked" dialog pops up, screenshot it. + start "" "%TCDIR%\TableCloth.exe" spork + echo start errorlevel=!errorlevel!>> "%LOG%" + ping -n 16 127.0.0.1 >nul + echo --- tasklist after 15s --->> "%LOG%" + tasklist /fi "imagename eq TableCloth.exe">> "%LOG%" 2>&1 +) + +echo.>> "%LOG%" +echo [05] CodeIntegrity operational log (most recent 30)>> "%LOG%" +wevtutil qe Microsoft-Windows-CodeIntegrity/Operational /c:30 /rd:true /f:text>> "%LOG%" 2>&1 + +echo.>> "%LOG%" +echo [06] AppLocker EXE and DLL log (most recent 20)>> "%LOG%" +wevtutil qe "Microsoft-Windows-AppLocker/EXE and DLL" /c:20 /rd:true /f:text>> "%LOG%" 2>&1 + +echo.>> "%LOG%" +echo [07] powershell availability (old hypothesis check)>> "%LOG%" +if exist "%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe" ( + echo powershell.exe present>> "%LOG%" + powershell -NoProfile -Command "$PSVersionTable.PSVersion.ToString()">> "%LOG%" 2>&1 + powershell -NoProfile -Command "(Get-ExecutionPolicy -List | Out-String)">> "%LOG%" 2>&1 +) else ( + echo powershell.exe MISSING>> "%LOG%" +) + +echo.>> "%LOG%" +echo ==== done ====>> "%LOG%" +echo Diagnostics finished. Result: %LOG% +echo (This window stays open on purpose.) +cmd /k diff --git a/tools/issue304/local-repro.wsb.template b/tools/issue304/local-repro.wsb.template new file mode 100644 index 00000000..27367457 --- /dev/null +++ b/tools/issue304/local-repro.wsb.template @@ -0,0 +1,25 @@ + + Enable + Disable + + + + __DIAG_DIR__ + false + + + + __TABLECLOTH_DIR__ + true + + + + C:\Users\WDAGUtilityAccount\Desktop\issue304\local-repro.cmd + + diff --git a/tools/issue304/reporter-comment-2026-07-26-b.md b/tools/issue304/reporter-comment-2026-07-26-b.md new file mode 100644 index 00000000..756a6beb --- /dev/null +++ b/tools/issue304/reporter-comment-2026-07-26-b.md @@ -0,0 +1,48 @@ + +보내주신 결과 덕분에 원인 후보가 많이 좁혀졌습니다. 특히 세 번째 댓글의 `citool` 관찰은 저 혼자서는 못 찾았을 내용입니다. 감사합니다. + +먼저 정정드릴 것이 있습니다. 제가 처음에 가장 유력하게 봤던 **Smart App Control은 원인이 아니었습니다.** 보내주신 자료를 보면 `VerifiedAndReputablePolicyState` 값이 `0x2`, 즉 "평가" 모드라서 프로그램을 차단하지 않는 상태였습니다. 실제로 `TableCloth.exe spork`를 직접 실행하셨을 때 정상적으로 떴고, 진단 로그의 CodeIntegrity 이벤트에도 차단 기록이 전혀 없었습니다. 이슈 #256과는 다른 문제입니다. 엉뚱한 방향으로 안내드렸던 점 죄송합니다. + +대신 찾아주신 `citool` 쪽이 훨씬 유력합니다. `citool.exe --refresh`가 "계속하려면 Enter 키를 누르세요."에서 멈춘다는 게 왜 치명적이냐면, `StartupScript.cmd`에서 **멈출 수 있는 줄이 저 한 줄뿐이고 바로 다음 줄이 Spork를 실행하는 줄**이기 때문입니다. 게다가 원래 스크립트는 저 줄의 출력을 감춰두기 때문에 프롬프트조차 화면에 나타나지 않습니다. 표준 입력을 비워서 프롬프트가 떠도 그냥 지나가도록 수정했습니다. + +다만 솔직하게 말씀드리면, **제 PC에서는 이 대기 현상이 재현되지 않았습니다.** 같은 Windows Sandbox 앱 버전(0.8.107.0)으로 여러 번 돌려봤지만 `citool`이 0.5초 안에 끝나고 Spork까지 정상 실행됐습니다. 그래서 이 수정으로 해결될 수도 있지만 확신할 수는 없는 상태입니다. + +그래서 이번에 드리는 테스트 빌드에는 수정과 함께 **부팅 과정을 파일로 기록하는 기능**을 넣었습니다. 지금까지는 Spork가 뜨기 전에 실패하면 아무 흔적도 남지 않았는데, 이제는 이런 식으로 남습니다. + +``` +[00] startup script begin ... +[01] sac policy rc=0 +[02] browser policies applied rc=0 +[03] citool refresh begin ... +[04] citool refresh end rc=0 ... +[05] launching spork ... +[06] spork exited rc=... +``` + +이 기록이 어디서 끊겼는지가 곧 실패 지점이라, 한 번만 실행해 주시면 원인이 확정됩니다. + +빌드는 아래 링크에서 받으실 수 있습니다. GitHub에 로그인된 상태여야 내려받아지고, CI 산출물이라 5일 뒤 자동으로 삭제됩니다. + +**[TableCloth-Portable-x64 내려받기](https://github.com/yourtablecloth/TableCloth/actions/runs/30206595695/artifacts/8633321708)** (약 101MB) + +1. 링크에서 `TableCloth-Portable-x64` 를 내려받아 압축을 풉니다. +2. 그 안의 `TableCloth_1.20.8.0_Release_x64_Portable.zip` 을 다시 풀어주세요. 지금 쓰시는 식탁보 설치본은 건드리지 않고 폴더에서 바로 실행되는 형태입니다. +3. 풀린 폴더의 `TableCloth.exe` 를 실행합니다. 코드 서명이 없는 빌드라 "Windows의 PC 보호" 경고가 뜨는데, **추가 정보 → 실행**을 눌러주세요. +4. 평소처럼 샌드박스를 실행합니다. + +샌드박스를 실행하신 뒤에는 Spork가 뜨든 안 뜨든, 실제 PC의 `문서\TableCloth\Data\tablecloth-boot.log` 파일을 이 이슈에 첨부해 주시면 됩니다. 식탁보 옵션에서 데이터 폴더를 따로 지정하셨다면 그 폴더 안에 있습니다. + +그리고 **Spork가 정상적으로 떴다면 그 사실도 꼭 알려주세요.** 그러면 위 `citool` 수정만으로 해결된 것이 되므로 그대로 정식 릴리스에 반영하겠습니다. + +번거로운 부탁을 계속 드리게 되어 죄송합니다. 보내주시는 자료마다 조사가 한 단계씩 진전되고 있어서 정말 큰 도움이 되고 있습니다. 🙇 + +관련 PR: #305 + + diff --git a/tools/issue304/reporter-comment-2026-07-26.md b/tools/issue304/reporter-comment-2026-07-26.md new file mode 100644 index 00000000..200f7d81 --- /dev/null +++ b/tools/issue304/reporter-comment-2026-07-26.md @@ -0,0 +1,154 @@ + +안녕하세요. 상세한 제보와 영상까지 첨부해주셔서 감사합니다. 적극적으로 대응해주시겠다고 하셔서, 원인을 좁힐 수 있는 진단 방법을 정리해 드립니다. + +## 먼저, "아무 메시지도 없는" 이유 + +식탁보는 샌드박스가 부팅되면 바탕 화면의 `App` 폴더에 있는 `StartupScript.cmd`를 실행하고, 그 배치가 마지막에 `TableCloth.exe spork`를 띄우는 구조입니다. 그런데 지금 코드는 **이 실행이 실패해도 결과를 확인하지 않고 배치가 그대로 끝나도록** 되어 있습니다. 콘솔 창이 즉시 닫히기 때문에 오류 문구조차 화면에 남지 않습니다. + +즉 "메시지가 하나도 없다"는 것은 특수한 증상이 아니라, **Spork 프로세스가 뜨기도 전에 실패했다**는 신호입니다. (로그가 안 남는 것도 같은 이유입니다. 이 부분은 제 쪽에서 개선하겠습니다.) + +바탕 화면에 `App` / `Data` / `NPKI` 폴더가 보이는 것은 폴더 공유가 붙었다는 뜻일 뿐이라, 위 단계가 실행됐는지와는 무관합니다. + +현재 가장 유력하게 보는 원인은 **Windows 11의 Smart App Control(SAC)이 서명되지 않은 식탁보 실행 파일을 커널 단에서 차단**하는 경우입니다. SAC는 Windows 11에만 있는 기능이라, "Windows 10에서는 잘 됐는데 업그레이드 후부터"라는 말씀과 시점이 정확히 맞습니다. (관련 선행 이슈: #256) 다만 이 경우 식탁보가 넣어둔 우회 코드가 동작해야 정상인데 그게 안 듣는 것인지, 아니면 **배치 자체가 실행되지 않은 것**인지에 따라 대응이 완전히 달라집니다. 그래서 이 둘을 가르는 확인이 필요합니다. + +--- + +## 방법 A. 30초 확인 (이것만 해주셔도 큰 도움이 됩니다) + +1. 평소처럼 식탁보로 샌드박스를 실행합니다. +2. Spork가 뜨지 않는 상태에서, **샌드박스 안에서** `Win` + `R` → `cmd` 입력 → 확인. +3. 열린 검은 창에 아래를 붙여넣고 Enter를 눌러주세요. + +```cmd +reg query "HKLM\SOFTWARE\Policies\Microsoft\Edge\LocalNetworkAccessAllowedForUrls" /v 1 +reg query "HKLM\SYSTEM\CurrentControlSet\Control\CI\Policy" /v VerifiedAndReputablePolicyState +powershell -c "(Get-MpComputerStatus).SmartAppControlState" +cd /d "%USERPROFILE%\Desktop\App" && TableCloth.exe spork +``` + +4. 화면에 나온 결과를 **캡처해서** 올려주시면 됩니다. + +특히 마지막 줄을 실행했을 때 무엇이 나오는지가 중요합니다. `액세스가 거부되었습니다`, `이 앱은 차단되었습니다` 같은 문구나 대화상자가 뜨면 그것까지 캡처 부탁드립니다. + +--- + +## 방법 B. 전체 진단 (더 정확합니다) + +증거를 한 번에 파일로 모으는 스크립트입니다. 결과 파일이 호스트(실제 PC) 쪽에도 그대로 보이므로 첨부하기 편합니다. + +1. **실제 PC**에서 `문서` → `TableCloth` → `Data` 폴더를 엽니다. (식탁보 옵션에서 데이터 폴더를 따로 지정하셨다면 그 폴더입니다.) +2. 메모장을 열고 아래 내용을 그대로 붙여넣은 뒤, 그 폴더에 **`diagnose_sandbox_boot.cmd`** 라는 이름으로 저장합니다. (저장할 때 "파일 형식"을 `모든 파일`로 바꿔주세요. `.txt`가 붙으면 실행되지 않습니다.) + +
+스크립트 내용 (펼치기) + +```bat +@echo off +setlocal enabledelayedexpansion +set LOG=%~dp0tablecloth-diag.txt +set APPDIR=C:\Users\WDAGUtilityAccount\Desktop\App + +echo ==== TableCloth sandbox boot diagnostics (issue #304) ====> "%LOG%" +echo [00] collected at %DATE% %TIME%>> "%LOG%" +whoami>> "%LOG%" 2>&1 +ver>> "%LOG%" 2>&1 + +echo.>> "%LOG%" +echo [01] did the LogonCommand batch run?>> "%LOG%" +reg query "HKLM\SOFTWARE\Policies\Microsoft\Edge\LocalNetworkAccessAllowedForUrls" /v 1>> "%LOG%" 2>&1 +reg query "HKLM\SOFTWARE\Policies\Microsoft\Edge" /v HardwareAccelerationModeEnabled>> "%LOG%" 2>&1 + +echo.>> "%LOG%" +echo [02] Smart App Control state (0=off 1=enforce 2=evaluation)>> "%LOG%" +reg query "HKLM\SYSTEM\CurrentControlSet\Control\CI\Policy" /v VerifiedAndReputablePolicyState>> "%LOG%" 2>&1 +powershell -NoProfile -Command "try { 'SmartAppControlState = ' + (Get-MpComputerStatus).SmartAppControlState } catch { 'ERR: ' + $_.Exception.Message }">> "%LOG%" 2>&1 +if exist "%SystemRoot%\System32\citool.exe" (echo citool.exe present>> "%LOG%") else (echo citool.exe MISSING>> "%LOG%") + +echo.>> "%LOG%" +echo [03] App mount contents>> "%LOG%" +if exist "%APPDIR%\TableCloth.exe" ( + dir "%APPDIR%">> "%LOG%" 2>&1 +) else ( + echo %APPDIR%\TableCloth.exe NOT FOUND>> "%LOG%" + dir "C:\Users\WDAGUtilityAccount\Desktop">> "%LOG%" 2>&1 +) + +echo.>> "%LOG%" +echo [04] launch TableCloth.exe spork and check survival>> "%LOG%" +if exist "%APPDIR%\TableCloth.exe" ( + echo Launching TableCloth.exe - if a "blocked" dialog pops up, screenshot it. + start "" "%APPDIR%\TableCloth.exe" spork + echo start errorlevel=!errorlevel!>> "%LOG%" + ping -n 21 127.0.0.1 >nul + echo --- tasklist after 20s --->> "%LOG%" + tasklist /fi "imagename eq TableCloth.exe">> "%LOG%" 2>&1 +) else ( + echo SKIP - binary not present>> "%LOG%" +) + +echo.>> "%LOG%" +echo [05] CodeIntegrity operational log (most recent 30 events)>> "%LOG%" +wevtutil qe Microsoft-Windows-CodeIntegrity/Operational /c:30 /rd:true /f:text>> "%LOG%" 2>&1 + +echo.>> "%LOG%" +echo [06] Windows PowerShell availability>> "%LOG%" +if exist "%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe" ( + echo powershell.exe present>> "%LOG%" + powershell -NoProfile -Command "$PSVersionTable.PSVersion.ToString()">> "%LOG%" 2>&1 + powershell -NoProfile -Command "(Get-ExecutionPolicy -List | Out-String)">> "%LOG%" 2>&1 +) else ( + echo powershell.exe MISSING>> "%LOG%" +) + +echo.>> "%LOG%" +echo [07] DNS and catalog reachability>> "%LOG%" +ipconfig /all>> "%LOG%" 2>&1 +nslookup yourtablecloth.app>> "%LOG%" 2>&1 + +echo.>> "%LOG%" +echo ==== done ====>> "%LOG%" + +echo. +echo Diagnostics finished. Result: %LOG% +echo. +pause +``` + +
+ +3. 식탁보로 평소처럼 샌드박스를 실행합니다. +4. Spork가 뜨지 않으면, 샌드박스 안에서 `Win` + `R` → 아래를 붙여넣고 실행합니다. + +``` +%USERPROFILE%\Desktop\Data\diagnose_sandbox_boot.cmd +``` + +5. 20초 정도 뒤 `Diagnostics finished` 가 나오면 끝입니다. 실제 PC의 `문서\TableCloth\Data\tablecloth-diag.txt` 파일을 이 이슈에 첨부해주세요. + +> 결과 파일에는 샌드박스 안의 네트워크 설정(`ipconfig`)도 포함됩니다. 샌드박스 내부 정보라 민감한 내용은 아니지만, 올리시기 전에 한 번 훑어보시고 불편한 부분이 있으면 지우고 올리셔도 됩니다. + +--- + +## 추가로 알려주시면 좋은 것 + +실제 PC(샌드박스 밖)에서 PowerShell을 열고 아래를 실행한 결과입니다. + +```powershell +Get-AppxPackage -Name "*WindowsSandbox*" | Select-Object Name, Version +reg query "HKLM\SYSTEM\CurrentControlSet\Control\CI\Policy" /v VerifiedAndReputablePolicyState +``` + +25H2부터 Windows Sandbox가 스토어를 통해 배포되는 앱 형태로 바뀌어서, 버전에 따라 동작이 달라질 여지가 있어 확인하려는 것입니다. + +--- + +번거로운 부탁을 드리게 되어 죄송합니다. 제 PC도 제보해주신 것과 동일한 빌드(25H2 26200.8875)라 재현을 시도해보고 있고, 결과를 주시면 그에 맞춰 대응하겠습니다. + +원인과 무관하게, **이렇게 조용히 실패하면 아무 단서도 남지 않는 것 자체가 문제**이므로 다음 버전에서는 부팅 단계 로그를 남기고 실행 실패 시 안내가 뜨도록 개선하겠습니다. 좋은 제보 감사합니다. 🙇 + diff --git a/tools/issue304/repro-boot-chain.ps1 b/tools/issue304/repro-boot-chain.ps1 new file mode 100644 index 00000000..f34b76d2 --- /dev/null +++ b/tools/issue304/repro-boot-chain.ps1 @@ -0,0 +1,124 @@ +<# +.SYNOPSIS + 이슈 #304 — 샌드박스 부팅 체인(LogonCommand → StartupScript.cmd → Spork)을 + 식탁보 UI 없이 그대로 재현한다. + +.DESCRIPTION + SandboxBuilder 가 하는 일(설치 폴더를 staging\App 으로 복사 → StartupScript.cmd 생성 + → wsb 생성 → 실행)을 헤드리스로 흉내 낸다. 다만 StartupScript 에는 단계별 + 브레드크럼이 들어가 있어, 부팅 체인이 **어디까지 갔는지**가 App\boot.log 에 남는다. + + App 폴더는 읽기·쓰기로 마운트되므로 boot.log 는 샌드박스가 살아 있는 동안에도 + 호스트에서 그대로 읽힌다. + +.PARAMETER Mode + repro = 수정 전 그대로(citool 에 stdin 리다이렉트 없음) + fixed = 수정 후(citool --refresh (System32 실행 파일을 거쳐 호출) + probe = cmd.exe /c 로 프로브 파일 기록 + dir + call <.cmd> + "LogonCommand 자체가 안 도는가" vs "마운트의 .cmd 를 실행하지 못하는가" 를 가른다. + +.PARAMETER NoLaunch + staging 과 wsb 만 만들고 실행하지 않는다. + +.EXAMPLE + ./repro-boot-chain.ps1 -Mode repro -LogonStyle direct + ./repro-boot-chain.ps1 -Mode fixed -LogonStyle wrapper +#> +[CmdletBinding()] +param( + [ValidateSet('repro', 'fixed')] + [string] $Mode = 'repro', + [ValidateSet('direct', 'wrapper', 'probe')] + [string] $LogonStyle = 'direct', + [string] $SourceDirectory = (Join-Path $env:LOCALAPPDATA 'TableCloth\current'), + [string] $ScriptFile, + [switch] $NoLaunch +) + +$ErrorActionPreference = 'Stop' + +$here = $PSScriptRoot +$work = Join-Path $env:TEMP 'tablecloth-issue304' +$appDir = Join-Path $work 'App' # leaf 이름이 곧 게스트의 Desktop\App 이 된다 +$bootLog = Join-Path $appDir 'boot.log' +$wsbPath = Join-Path $work 'repro.wsb' + +if (-not (Test-Path $SourceDirectory)) { + throw "식탁보 설치 폴더를 찾을 수 없습니다: $SourceDirectory" +} + +# 이전 실행 잔재가 결과를 오염시키지 않도록 staging 을 통째로 새로 만든다. +if (Test-Path $work) { Remove-Item $work -Recurse -Force } +New-Item -ItemType Directory -Path $appDir -Force | Out-Null + +Write-Host "[1/4] 설치 폴더 복사: $SourceDirectory -> $appDir" +Copy-Item (Join-Path $SourceDirectory '*') $appDir -Recurse -Force +# Images.zip 은 SandboxBuilder 도 제외한다(게스트에는 풀린 images\ 를 따로 넣어줌). +Remove-Item (Join-Path $appDir 'Images.zip') -Force -ErrorAction SilentlyContinue + +if ($ScriptFile) { + # 제품이 실제로 생성한 StartupScript.cmd 를 그대로 검증할 때 쓴다(템플릿 재현이 아니라 실물). + if (-not (Test-Path $ScriptFile)) { throw "스크립트 파일을 찾을 수 없습니다: $ScriptFile" } + Write-Host "[2/4] StartupScript.cmd = $ScriptFile (실물 사용, mode 무시)" + $script = Get-Content $ScriptFile -Raw +} +else { + Write-Host "[2/4] StartupScript.cmd 생성 (mode=$Mode)" + $citoolStdin = if ($Mode -eq 'fixed') { ' 는 반드시 엔티티로 이스케이프해야 한다. +$command = switch ($LogonStyle) { + 'direct' { "$guestApp\StartupScript.cmd" } + 'wrapper' { "cmd.exe /c $guestApp\StartupScript.cmd" } + 'probe' { "cmd.exe /c echo probe %DATE% %TIME% > $probeFile & dir $guestApp >> $probeFile & call $guestApp\StartupScript.cmd" } +} + +@" + + Enable + Disable + + + $appDir + false + + + + $command + + +"@ | Set-Content $wsbPath -Encoding UTF8 + +if ($NoLaunch) { + Write-Host "-NoLaunch 지정 — 실행하지 않고 종료합니다." + Write-Host " wsb : $wsbPath" + Write-Host " boot.log : $bootLog" + return +} + +# WindowsSandboxServer 는 세션이 없어도 상주할 수 있으므로 판정에서 제외한다. +# 실제 세션이 살아 있는지는 RemoteSession/Client 프로세스로 본다. +if (Get-Process -Name 'WindowsSandboxRemoteSession', 'WindowsSandboxClient' -ErrorAction SilentlyContinue) { + throw "Windows Sandbox 세션이 이미 실행 중입니다. 닫고 다시 시도하세요(동시에 하나만 가능)." +} + +Write-Host "[4/4] 샌드박스 실행. 부팅 체인 진행 상황은 아래 파일에 실시간으로 쌓입니다." +Write-Host " boot.log : $bootLog" +Start-Process $wsbPath diff --git a/tools/issue304/run-local-repro.ps1 b/tools/issue304/run-local-repro.ps1 new file mode 100644 index 00000000..243a4c00 --- /dev/null +++ b/tools/issue304/run-local-repro.ps1 @@ -0,0 +1,69 @@ +<# +.SYNOPSIS + 이슈 #304 로컬 재현용 Windows Sandbox 를 만들어 실행한다. + +.DESCRIPTION + local-repro.wsb.template 의 플레이스홀더를 이 머신의 실제 경로로 치환해 + local-repro.wsb 를 만든 뒤 실행한다. 게스트에서는 LogonCommand 로 + local-repro.cmd 가 돌면서 result.txt 를 이 폴더에 남긴다. + + wsb 의 HostFolder 는 절대 경로만 받으므로 템플릿 + 치환 구조를 쓴다. + +.PARAMETER TableClothDirectory + [04] 프로브가 실행해 볼 식탁보 설치 폴더. 기본값은 Velopack 설치 위치. + +.PARAMETER NoLaunch + wsb 파일만 만들고 실행하지 않는다. + +.EXAMPLE + ./run-local-repro.ps1 + ./run-local-repro.ps1 -TableClothDirectory "D:\Projects\TableCloth\publish\win-x64" -NoLaunch +#> +[CmdletBinding()] +param( + [string] $TableClothDirectory = (Join-Path $env:LOCALAPPDATA 'TableCloth\current'), + [switch] $NoLaunch +) + +$ErrorActionPreference = 'Stop' + +$here = $PSScriptRoot +$templatePath = Join-Path $here 'local-repro.wsb.template' +$wsbPath = Join-Path $here 'local-repro.wsb' +$resultPath = Join-Path $here 'result.txt' + +if (-not (Test-Path $templatePath)) { + throw "템플릿을 찾을 수 없습니다: $templatePath" +} + +# leaf 이름이 곧 게스트 경로가 된다. 템플릿의 LogonCommand 는 Desktop\issue304 를 가리키므로 +# 폴더 이름이 바뀌었다면 둘 다 함께 고쳐야 한다. +$leaf = Split-Path $here -Leaf +if ($leaf -ne 'issue304') { + Write-Warning "이 폴더의 이름이 'issue304' 가 아닙니다($leaf). 템플릿의 LogonCommand 경로도 함께 수정하세요." +} + +if (-not (Test-Path $TableClothDirectory)) { + Write-Warning "식탁보 설치 폴더가 없습니다: $TableClothDirectory — [04] 프로브는 건너뜁니다." +} + +# 직전 실행 결과가 섞이지 않도록 정리. +if (Test-Path $resultPath) { Remove-Item $resultPath -Force } + +(Get-Content $templatePath -Raw). + Replace('__DIAG_DIR__', $here). + Replace('__TABLECLOTH_DIR__', $TableClothDirectory) | + Set-Content $wsbPath -Encoding UTF8 + +Write-Host "생성됨: $wsbPath" +Write-Host " 진단 폴더 : $here" +Write-Host " 식탁보 : $TableClothDirectory" + +if ($NoLaunch) { + Write-Host "-NoLaunch 지정 — 실행하지 않고 종료합니다." + return +} + +Write-Host "샌드박스를 실행합니다. 게스트 콘솔이 닫히지 않고 남으니 확인 후 창을 닫으세요." +Write-Host "결과 파일: $resultPath" +Start-Process $wsbPath diff --git a/tools/issue304/startup-script.cmd.template b/tools/issue304/startup-script.cmd.template new file mode 100644 index 00000000..44db3bbc --- /dev/null +++ b/tools/issue304/startup-script.cmd.template @@ -0,0 +1,61 @@ +@echo off +rem ========================================================================== +rem Faithful replica of the StartupScript.cmd that SandboxBuilder generates. +rem Only two differences: +rem 1) breadcrumbs are appended to boot.log at each step (echo never blocks, +rem so the execution characteristics of the original are preserved) +rem 2) the citool stdin redirection is a __CITOOL_STDIN__ token so the same +rem harness can compare before-fix (empty) and after-fix (> when the echoed text ends in a digit. +rem `...=0>> file` makes cmd parse `0>>` as an fd-0 redirection. +rem ========================================================================== +set BOOT=%~dp0boot.log +echo [00] logon batch started %DATE% %TIME% > "%BOOT%" +pushd "%~dp0" +echo [01] pushd ok cwd=%CD% >> "%BOOT%" + +rem Guest SAC state BEFORE we touch anything. The reporter's guest reported 0x2 +rem (evaluation); if this guest starts at 0x0 the citool prompt may simply not +rem be reachable here, which would explain a non-reproduction. +echo [01b] --- SAC state as shipped by the guest image --- >> "%BOOT%" +reg query "HKLM\SYSTEM\CurrentControlSet\Control\CI\Policy" /v VerifiedAndReputablePolicyState >> "%BOOT%" 2>&1 + +reg add "HKLM\SYSTEM\CurrentControlSet\Control\CI\Policy" /v VerifiedAndReputablePolicyState /t REG_DWORD /d 0 /f >nul 2>&1 +echo [02] reg add CI policy errorlevel=%errorlevel% >> "%BOOT%" +reg add "HKLM\SOFTWARE\Policies\Microsoft\Edge" /v HardwareAccelerationModeEnabled /t REG_DWORD /d 0 /f >nul 2>&1 +echo [03] reg add edge gpu errorlevel=%errorlevel% >> "%BOOT%" +reg add "HKLM\SOFTWARE\Policies\Microsoft\Edge\LocalNetworkAccessAllowedForUrls" /v 1 /t REG_SZ /d "*" /f >nul 2>&1 +echo [04] reg add edge LNA errorlevel=%errorlevel% >> "%BOOT%" +reg add "HKLM\SOFTWARE\Policies\Google\Chrome\LocalNetworkAccessAllowedForUrls" /v 1 /t REG_SZ /d "*" /f >nul 2>&1 +echo [05] reg add chrome LNA errorlevel=%errorlevel% >> "%BOOT%" + +echo [06] --- registry state BEFORE citool --- >> "%BOOT%" +reg query "HKLM\SYSTEM\CurrentControlSet\Control\CI\Policy" /v VerifiedAndReputablePolicyState >> "%BOOT%" 2>&1 +reg query "HKLM\SOFTWARE\Policies\Microsoft\Edge\LocalNetworkAccessAllowedForUrls" /v "1" >> "%BOOT%" 2>&1 + +echo [07] calling citool at %DATE% %TIME% >> "%BOOT%" +"%SystemRoot%\System32\citool.exe" --refresh __CITOOL_STDIN__ >nul 2>&1 +echo [08] citool returned errorlevel=%errorlevel% at %DATE% %TIME% >> "%BOOT%" + +rem Does citool block on a prompt in THIS guest? Run it backgrounded without any +rem stdin redirection and see whether the process is still alive 10s later. +rem Backgrounded so a hang cannot wedge the rest of the chain. +echo [08b] --- citool hang probe (no stdin redirect) --- >> "%BOOT%" +start "" /b "%SystemRoot%\System32\citool.exe" --refresh >nul 2>&1 +ping -n 11 127.0.0.1 >nul +tasklist /fi "imagename eq CiTool.exe" >> "%BOOT%" 2>&1 +taskkill /f /im CiTool.exe >nul 2>&1 + +echo [09] --- registry state AFTER citool --- >> "%BOOT%" +reg query "HKLM\SYSTEM\CurrentControlSet\Control\CI\Policy" /v VerifiedAndReputablePolicyState >> "%BOOT%" 2>&1 +reg query "HKLM\SOFTWARE\Policies\Microsoft\Edge\LocalNetworkAccessAllowedForUrls" /v "1" >> "%BOOT%" 2>&1 + +echo [10] launching TableCloth.exe spork at %DATE% %TIME% >> "%BOOT%" +"%~dp0TableCloth.exe" spork +echo [11] exe returned errorlevel=%errorlevel% at %DATE% %TIME% >> "%BOOT%" +popd