DMF_Crawler/docs/research/09-agy-bootstrap-and-provisioning.md
Yun Chan 56a6e2da93 chore: 저장소 구조 정리 및 문서화, 첫 커밋
- src/dist 산출물 분리 원칙 정리(.gitignore, .gitattributes)
- 루트 및 주요 폴더(config/scripts/prompts/tests/src, 런타임 폴더 5종)에
  안내용 README.md 추가
- CHANGELOG.md, LICENSE, docs/ops/05-release-and-versioning.md 추가
- docs/README.md 문서 지도 갱신
2026-09-04 09:25:44 +09:00

169 KiB
Raw Permalink Blame History

AGY CLI 자동 부트스트랩 · 설치 · 인증 자동화 설계

이 문서의 역할: agy (Google Antigravity CLI) 가 없거나 / 낡았거나 / 로그인이 풀렸을 때, 06:00 무인 배치가 스스로 복구하거나 사용자에게 정확히 개입을 요청하도록 만드는 부트스트랩 정본이다. CLI 자체의 사용법·플래그·출력 포맷은 05a-agy-cli-ssot.md 가 정본이며 이 문서는 그 위에 프로비저닝 계층만 쌓는다. 스케줄러 작업 구성·토스트 라이브러리 선택·Session 0 일반론은 08-windows-scheduling-and-resilience.md 가 정본이다.

작성 기준일: 2026-09-02 검증 방식: 공식 문서 직접 열람(WebFetch) + 로컬 머신 실측 실측 환경: Windows 11 Pro 10.0.26220 / PowerShell 7.6.5 / winget v1.29.250 / agy 1.1.24 실측 계정: encep (C:\Users\encep)


0. 한눈에 보기

  1. ⚠️ 05a 의 인증 서술을 정정한다. agy 의 실제 자격증명 저장소는 파일이 아니라 Windows 자격 증명 관리자다. 실측: cmdkey /listTarget: gemini:antigravity, Type: Generic, User: antigravity, Local machine persistence 항목이 존재한다. CredRead P/Invoke 로 읽으면 BlobSize = 504 바이트, 내용은 {"token":{"access_token","token_type","refresh_token","expiry"},"auth_method":"consumer"} 이며 파일 ~/.gemini/antigravity-cli/antigravity-oauth-token 과 정확히 같은 504 바이트다. 결정적으로 LastWritten 이 매 실행마다 갱신된다(실측: 테스트 실행 시각 2026-09-02T14:27:08Z, expiry +1h). 반면 파일 쪽 mtime 은 3일 전(Aug 30)에서 멈춰 있다.자격 증명 관리자가 정본(authoritative), 파일은 낡은 미러다.
  2. 그 결과 배치의 인증 헬스체크는 공짜가 된다. 05a 가 권했던 "PONG 프롬프트"(input 28,317 토큰 / 33.7초) 대신 CredRead("gemini:antigravity")(오프라인·0토큰·수 ms) + agy models(3.1초 / 0토큰 / 네트워크 실검증) 2단 프로브를 쓴다. 미존재 시 GetLastError = 1168 (ERROR_NOT_FOUND).
  3. 작업 스케줄러를 S4U("암호를 저장하지 않음")로 만들면 인증이 깨진다. CredRead 문서 원문: "The credential set used is the one associated with the logon session of the current token." / "ERROR_NO_SUCH_LOGON_SESSION … Network logon sessions do not have an associated credential set."TASK_LOGON_PASSWORD(암호 저장) 또는 TASK_LOGON_INTERACTIVE_TOKEN(로그온 시에만 실행)만 허용. 이는 08번 문서의 스케줄러 설계에 대한 제약 추가다.
  4. winget 은 SYSTEM 컨텍스트에서 원천적으로 못 쓴다 — 확정. Microsoft Learn 원문: "As packages can be registered for any user except NT AUTHORITY\SYSTEM (aka LocalSystem, aka System), the WinGet CLI is not supported in the system context." 사용자 계정 세션에서는 동작하지만, 아래 5번 때문에 설치·업데이트 수단으로는 탈락한다.
  5. winget 의 버전 정보는 신뢰할 수 없다 — 실측으로 확정. 이 머신에 agy.exe3개, 서로 다른 3개 버전으로 존재한다: %LOCALAPPDATA%\agy\bin\agy.exe = 1.1.24, %LOCALAPPDATA%\Microsoft\WinGet\Links\agy.EXE = 1.1.22, winget list 가 보고하는 ARP 버전 = 1.1.10(available 1.1.23), 업스트림 매니페스트 = 1.1.24. self-update 가 winget 바깥에서 바이너리를 갈아끼우기 때문이다. → 탐지·실행 모두 %LOCALAPPDATA%\agy\bin\agy.exe 절대경로 1개로 고정한다.
  6. 설치 수단은 install.ps1 을 채택하되 irm | iex 파이프는 쓰지 않는다. 전문을 읽은 결과, 스크립트는 $isSourced 가 참일 때(= iex 로 실행될 때) 실패 시 exit 1 이 아니라 throw 한다. 파이프 방식은 종료 코드로 실패를 알 수 없다.파일로 내려받아 -File 로 실행하고 종료 코드를 받는다. 3순위로 매니페스트 직접 다운로드(엔드포인트·SHA512 검증 포함) 폴백을 둔다.
  7. update.lock 은 "업데이트 중" 신호가 아니다 — 실측. 0바이트 파일이고 mtime 이 2026-07-26 에 고정되어 있으며 agy update 를 돌려도 변하지 않는다(파일 존재 = 락 핸들 대상). 대신 updater/update_status.json 이 사람이 읽을 수 있는 결과를 남긴다: {"success":true,"message":"Update successful, restart CLI to use"} / {"success":true,"message":"Already on the latest version."}. last_check.timestamp 도 0바이트이며 의미는 mtime 에만 있다.
  8. 배치 전용 프로필 격리를 권장한다(신규 설계). 실측: 자식 프로세스의 USERPROFILE 을 바꾸면 agy설정·로그·대화·MCP 를 그 경로 아래에 새로 만들지만, 자격 증명은 자격 증명 관리자에서 그대로 읽어 인증에 성공한다(exit 0, status:SUCCESS). → 사용자의 전역 settings.json(현재 command(*.exe) 같은 위험한 allow 규칙과 8개 trustedWorkspaces 보유)을 건드리지 않고, 프로젝트 전용 permissions / MCP 없음 / 로그 격리를 동시에 얻는다. 같은 실측에서 input_tokens 가 28,317 → 14,056 으로 떨어졌다(교란요인 있음, §5.6).
  9. --dangerously-skip-permissions 는 쓰지 않는다. 대신 격리 프로필의 permissionsdeny: read_url(*), execute_url(*), mcp(*) 를 넣어 프롬프트 인젝션 표면을 물리적으로 잘라낸다. 크롤링은 Python 이 하고 agy 는 텍스트만 요약한다.
  10. 사용자 개입 창은 배치 프로세스가 직접 띄우지 않는다. 06:00 에 사용자가 로그온해 있지 않을 수 있고, 비대화형 세션에서는 창이 보이지 않을 수 있다. 배치는 state/auth_required.json 플래그만 쓰고, "로그온할 때만 실행"으로 등록된 별도 대화형 작업이 그 플래그를 보고 콘솔 창·토스트·MessageBox 를 띄운다(§7).

1. 목차


2. 이 문서의 경계 — 05a / 08 과 무엇이 다른가

주제 정본 문서 이 문서에서의 취급
agy 플래그 전체표, 출력 포맷 3종, --json-schema 함정, 모델 목록, 크레딧 05a 참조만. 재서술 금지
설치 경로, install.ps1 동작 9단계, agy install 서브커맨드 05a §3 전문 코드 기준으로 심화(종료 코드·iex 함정·프록시)
인증 방식 4종 개요, 토큰 파일 위치 05a §4 ⚠️ 정정 + 자격 증명 관리자 실측으로 대체(§6)
작업 스케줄러 작업 3개 구성, 재부팅 복구, healthchecks.io, 웹훅 08 참조만. 로그온 타입 제약만 추가(§6.5)
토스트 라이브러리 비교(BurntToast / win11toast / windows-toasts), Session 0 일반론 08 §8, §11 참조만. agy 인증 전용 배선만 신규(§7)
권한 엔진 문법(action(target), Deny>Ask>Allow, 경로 정규화) 05a §9 참조. 이 프로젝트용 실제 규칙 세트만 신규(§9)

중복 서술 금지 원칙: 위 표에서 "참조만"인 항목은 이 문서에서 결론과 링크만 남기고 근거는 원 문서에 둔다.


3. 탐지 — agy 가 설치되어 있는가

3.1 실측: 이 머신에는 agy.exe 가 3개, 버전이 3개다

$ where.exe agy
C:\Users\encep\AppData\Local\agy\bin\agy.exe
C:\Users\encep\AppData\Local\Microsoft\WinGet\Links\agy.EXE

각각을 직접 실행해 본 결과:

경로 --version 파일 크기 mtime 출처
%LOCALAPPDATA%\agy\bin\agy.exe 1.1.24 187,601,560 B 2026-09-02 22:08 install.ps1 설치 + self-update 가 갱신
%LOCALAPPDATA%\Microsoft\WinGet\Links\agy.EXE 1.1.22 186,767,512 B 2026-08-29 22:06 winget portable 패키지
winget list --id Google.AntigravityCLI 보고값 1.1.10 (available 1.1.23) winget ARP DB
업스트림 매니페스트 windows_amd64.json 1.1.24 자동 업데이터 서버

%LOCALAPPDATA%\agy\bin\ 실측 목록 — self-update 가 이전 바이너리를 지우지 않는다:

-rwxr-xr-x 187,601,560  2026-09-02 22:08  agy.exe
-rwxr-xr-x 186,767,512  2026-08-27 13:12  agy.exe.1788354501993998300.old

업스트림 매니페스트 실측 응답(전문):

{
  "version": "1.1.24",
  "url": "https://storage.googleapis.com/antigravity-public/antigravity-cli/1.1.24-6130423206641664/windows-x64/cli_windows_x64.exe",
  "sha512": "8d45e36d0f66bb5d5b809c10b108dbd411e621f6e7e37d908f2e0369e88d3b809fb1a0ad8cd26858ed63fb4f08a1cf84a3658ba6907938a267dcdd3a387f0c11"
}

winget-pkgs 매니페스트 실측(전문):

# Created with YamlCreate.ps1 Dumplings Mod
# yaml-language-server: $schema=https://aka.ms/winget-manifest.installer.1.12.0.schema.json

PackageIdentifier: Google.AntigravityCLI
PackageVersion: 1.1.23
InstallerType: portable
Commands:
- agy
ReleaseDate: 2026-08-31
Installers:
- Architecture: x64
  InstallerUrl: https://storage.googleapis.com/antigravity-public/antigravity-cli/1.1.23-6260551186251776/windows-x64/cli_windows_x64.exe
  InstallerSha256: BFFA9C1227A517D0DBD7DDFC71A64BF6473C52FAB95369CABE09FF42BD9B3B3E
- Architecture: arm64
  InstallerUrl: https://storage.googleapis.com/antigravity-public/antigravity-cli/1.1.23-6260551186251776/windows-arm/cli_windows_arm64.exe
  InstallerSha256: 519ED7E208ADFAE7DC07F62EA83623AE311CCA02F7F64DF5FD886336B25BC002
ManifestType: installer
ManifestVersion: 1.12.0

InstallerType: portable + Commands: [agy] 이므로 winget 은 실행 파일을 WinGet\Packages\Google.AntigravityCLI_Microsoft.Winget.Source_8wekyb3d8bbwe\ 에 풀고 WinGet\Links\agy.EXE 에 심(shim)을 만든다. 실측상 이 Links 항목은 심볼릭 링크가 아니라 186 MB 실파일 복사본이었고(다른 winget portable 패키지들은 심볼릭 링크였다), self-update 대상이 아니므로 시간이 지날수록 낡는다.

결론 1: where agy / Get-Command agyPATH 순서에 따라 낡은 1.1.22 를 반환할 수 있다. 실제로 이 머신의 User PATH 는 ...\agy\bin...\WinGet\Links 보다 앞서 있어 우연히 최신이 잡히지만, 이 순서에 의존해선 안 된다. 결론 2: winget list 의 버전 필드는 14개 릴리스만큼 낡아 있었다. 버전 판정에 절대 쓰지 않는다.

3.2 탐지 방법 5종 비교

# 방법 명령 비용 실패 모드 채택
1 절대경로 존재 확인 Test-Path "$env:LOCALAPPDATA\agy\bin\agy.exe" ~0 ms 사용자가 -d/--dir 로 다른 경로에 설치한 경우 놓침. %LOCALAPPDATA% 자체가 다른 세션(SYSTEM 은 C:\Windows\system32\config\systemprofile\AppData\Local) 1순위
2 버전 실행 & $agy --version 79 ms (실측), 종료코드 0, 출력 1.1.24 파일은 있으나 손상/차단(MotW)/AV 격리 시 비0. 187 MB 바이너리라 디스크 캐시 미스면 첫 로드가 느릴 수 있음 2순위(무결성 확인)
3 Get-Command / where.exe (Get-Command agy -EA SilentlyContinue).Source ~10 ms PATH 미반영 시 null. 실측: $env:PATH='C:\Windows\System32' 로 좁히면 Get-Command agy$null. agy\binUser PATH 에만 있고 Machine PATH 에 없다 → SYSTEM 세션·서비스에서는 절대 못 찾는다 ⚠️ 진단용으로만
4 winget list winget list --id Google.AntigravityCLI -e --disable-interactivity ~13 s 버전이 낡음(1.1.10). SYSTEM 컨텍스트 미지원. 미설치 시 종료코드 -1978335212 = 0x8A150014 = APPINSTALLER_CLI_ERROR_NO_APPLICATIONS_FOUND (실측) 버전 판정 금지
5 레지스트리 Uninstall 키 HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\* ~50 ms portable 패키지는 winget 자체 ARP 항목만 만들고 install.ps1 설치분은 항목이 없음

3.3 PATH 가 스케줄러 세션에 반영되지 않는 문제

실측한 PATH 구성:

Machine PATH  : (agy 관련 항목 없음)
User PATH     : C:\Users\encep\AppData\Local\agy\bin
                C:\Users\encep\AppData\Local\Microsoft\WinGet\Links
                ...
  • agy\binUser 범위 PATH 에만 등록된다(agy install 이 그렇게 한다).
  • 작업 스케줄러가 해당 사용자 계정으로 작업을 실행하면 사용자 환경 블록이 로드되므로 User PATH 가 들어온다.
  • 그러나 작업 등록 이후에 PATH 가 바뀌었다면 이미 떠 있던 프로세스 트리는 갱신을 못 본다. 그리고 SYSTEM/서비스 계정은 애초에 User PATH 가 없다.
  • 해결책은 단 하나: 절대경로 사용. 부트스트랩과 배치 모두 $AgyExe = Join-Path $env:LOCALAPPDATA 'agy\bin\agy.exe' 를 쓰고 PATH 를 신뢰하지 않는다.
  • %LOCALAPPDATA% 조차 신뢰할 수 없는 경우(다른 계정으로 실행)를 대비해, 부트스트랩은 실행 계정을 먼저 검증한다(§11 의 Assert-RunContext).

3.4 채택 탐지 알고리즘

1) 실행 컨텍스트 검증
   - [Security.Principal.WindowsIdentity]::GetCurrent().Name 이
     'NT AUTHORITY\SYSTEM' / 'NT AUTHORITY\LOCAL SERVICE' / 'NT AUTHORITY\NETWORK SERVICE'
     이면 → 즉시 exit 30
     (자격 증명 관리자·User PATH·%LOCALAPPDATA% 가 전부 다르다)
2) 후보 경로 순서대로 Test-Path
   a) $env:DMF_AGY_EXE            (환경변수 오버라이드, 선택)
   b) %LOCALAPPDATA%\agy\bin\agy.exe                  ← 정본
   c) %LOCALAPPDATA%\Microsoft\WinGet\Links\agy.exe   ← 낡을 수 있음. 발견 시 경고만
3) 찾은 실행 파일로 `--version` 실행 (타임아웃 30초)
   - 종료코드 0 && 출력이 semver 정규식 `^\d+\.\d+\.\d+` 매치 → 설치됨
   - 아니면 손상으로 간주 → 재설치 경로로
4) 두 개 이상 발견되면 버전을 모두 로그에 남기고,
   경로는 (b) 를 강제 선호한다(버전 비교로 (c) 를 고르지 않는다 — 갱신되지 않는 사본이므로)

4. 설치 자동화

4.1 세 경로 비교

항목 (a) install.ps1 (b) winget install (c) 매니페스트 직접 다운로드
비대화형 적합성 완전 무인 ⚠️ 사용자 세션 한정 완전 무인
SYSTEM/서비스 세션 ⚠️ 동작하나 경로가 systemprofile 로 감 원천 불가(공식) ⚠️ 동일
최신 버전 보장 업데이터 매니페스트 직결 (1.1.24) 커뮤니티 지연 (1.1.23) (1.1.24)
무결성 검증 SHA512 내장 SHA256 (매니페스트) 직접 구현 필요(SHA512)
PATH 등록 agy install 핸드오프 Links 심 직접 해야 함
재실행 안전성 이미 있으면 no-op, exit 0 (--no-upgrade) 직접 구현
종료 코드 신뢰성 ⚠️ iex 사용 시 깨짐(§4.2) 명확한 HRESULT 직접 통제
프록시 Invoke-WebRequest 기본 프록시 --proxy 플래그 지원 직접 통제
채택 1순위(파일 실행 방식) 2순위 폴백

4.2 install.ps1irm | iex 를 쓰면 안 되는 이유

스크립트 전문을 내려받아 읽었다. 핵심 분기:

$hasPath = ($null -ne $MyInvocation.MyCommand) -and ($null -ne $MyInvocation.MyCommand.PSObject.Properties['Path'])
$scriptPath = if ($hasPath) { $MyInvocation.MyCommand.Path } else { $null }
$isSourced = [string]::IsNullOrEmpty($scriptPath) -or ($MyInvocation.InvocationName -eq '.')

그리고 마지막:

$exitCode = $script:installExitCode

if ($exitCode -ne 0) {
    if ($isSourced) {
        throw "Fatal: Installation failed."
    } else {
        exit $exitCode
    }
}
  • irm https://antigravity.google/cli/install.ps1 | iex 로 실행하면 $MyInvocation.MyCommand.Path 가 비어 있으므로 $isSourced = $true 가 된다.
  • 그러면 실패는 exit 1 이 아니라 throw 로 표면화된다. 호출 스크립트가 try/catch 로 감싸지 않으면 $LASTEXITCODE 는 0 인 채로 흘러가고, 부트스트랩이 "설치 성공" 으로 오판한다.
  • 파일로 저장해 powershell.exe -File install.ps1 로 실행하면 $isSourced = $false 가 되어 정상적으로 exit 1 한다.

규칙: 이 프로젝트는 install.ps1파일로 내려받아 -File 로 실행한다. iex 파이프는 금지.

스크립트가 뱉는 실패 문자열(정확한 매칭용, 전문에서 발췌):

상황 정확한 문자열
이미 설치됨(정상 종료, exit 0) Notice: 'agy.exe' is already installed at <path>.
〃 (후속 3줄) The Antigravity CLI automatically self-updates in the background. / If you want to perform a fresh installation, delete the binary first: / Remove-Item "<path>" -Force
디렉터리 옵션 값 누락 Error: Missing value for directory option.
아키텍처 미지원 Fatal: Unsupported CPU architecture.
매니페스트 실패 Fatal: Failed to download release manifest from <url>. Network or DNS issue?
스테이징 디렉터리 실패 Fatal: Failed to create staging directory at <path>. Please check write permissions.
바이너리 다운로드 실패 Fatal: Failed to download binary from <url>. Network or DNS issue?
해시 계산 실패 Fatal: Failed to compute file hash for verification.
체크섬 불일치 Security Halt: Checksum verification failed. The downloaded file may be corrupted or compromised.
배치 실패(파일 잠김 등) Write Error: Permission denied or failed to write binary to <path>. + Please check directory permissions or if the file is locked (e.g. if 'agy.exe' is currently running).

추가로 확인한 동작들:

  • TLS: if ($ExecutionContext.SessionState.LanguageMode -ne 'ConstrainedLanguage') { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 } — ConstrainedLanguage 모드에서는 건너뛴다(AppLocker/WDAC 환경 주의).
  • 해시: Get-FileHash -Algorithm SHA512 우선, 실패 시 certutil -hashfile <file> SHA512 폴백. ConstrainedLanguage 에서는 Get-FileHash 경로가 통째로 스킵되어 certutil 로 간다.
  • --force 같은 재설치 플래그가 없다. 기존 바이너리가 있으면 무조건 no-op. 강제 재설치는 Remove-Item <binary> -Force 후 재실행이 유일한 방법이다.
  • 스테이징 경로: %LOCALAPPDATA%\antigravity\staging\agy.exe (주의: agy 가 아니라 antigravity). finally 블록에서 삭제한다.
  • 핸드오프: & $binaryPath install $setupFlagstry/catch 로 감싸 실패를 삼킨다("Absorb setup warnings/failures to align with Unix '|| true'. The binary is successfully copied and functional on disk."). 즉 PATH 등록 실패는 설치 성공으로 보고된다. → 부트스트랩은 설치 후 PATH 를 직접 확인·보정해야 한다.
  • -d / --dir 이외의 인자는 전부 agy install 로 패스스루된다. 즉 -File install.ps1 --skip-aliases 라고 주면 agy install --skip-aliases 가 실행된다.
  • 프록시: Invoke-RestMethod / Invoke-WebRequest 를 그대로 쓴다. PowerShell 7 은 HTTP_PROXY / HTTPS_PROXY / NO_PROXY 환경변수를 존중하고, Windows PowerShell 5.1 은 시스템(IE) 프록시 설정을 쓴다. 사내 프록시 환경이라면 작업 스케줄러 작업의 환경에 HTTPS_PROXY 를 명시해야 한다.

4.3 winget — 스케줄러/서비스 세션에서 쓸 수 있는가? 결론: 쓰지 않는다

Microsoft Learn 「Debugging and troubleshooting issues with WinGet」 의 System Context 절 원문:

"WinGet is delivered via the App Installer as a packaged application. MSIX (packaged) applications depend on the package being registered for the user. As packages can be registered for any user except NT AUTHORITY\SYSTEM (aka LocalSystem, aka System), the WinGet CLI is not supported in the system context. The Microsoft.WinGet.Client PowerShell module can be used in the system context with applications that are installed machine wide."

또한 winget 개요 문서:

"WinGet will not be available until you have logged into Windows as a user for the first time, triggering Microsoft Store to register the Windows Package Manager as part of an asynchronous process. If you have recently logged in as a user for the first time and find that WinGet is not yet available, you can open PowerShell and enter the following command to request this WinGet registration: Add-AppxPackage -RegisterByFamilyName -MainPackage Microsoft.DesktopAppInstaller_8wekyb3d8bbwe."

관련 이슈(제목·상태 확인):

번호 제목 상태
#4422 Add support for SYSTEM account when using Winget PowerShell Module Open
#548 Usage with System Account Closed
#2937 App install using System Account fails (0x80070520) Closed

정리한 결론:

실행 컨텍스트 winget 동작 이 프로젝트에서
대화형 사용자 세션 동작 수동 진단용으로만 허용
작업 스케줄러 / 사용자 계정 / 암호 저장(TASK_LOGON_PASSWORD) 동작(패키지가 그 사용자에게 등록돼 있어야 함) 버전 신뢰 불가(§3.1) 이므로 미사용
작업 스케줄러 / 사용자 계정 / S4U ⚠️ 대체로 동작하나 자격 증명이 필요한 작업은 실패
작업 스케줄러 / SYSTEM, LocalService, NetworkService 공식 미지원 금지

무인 실행 시 필요한 플래그(참고용, 실제로는 안 씀):

winget install --id Google.AntigravityCLI --exact `
  --silent `
  --accept-package-agreements `
  --accept-source-agreements `
  --disable-interactivity `
  --nowarn

각 플래그의 공식 정의:

플래그 공식 설명(원문)
-h, --silent "Runs the installer in silent mode. This suppresses all UI. The default experience shows installer progress."
--accept-package-agreements "Accepts any license agreements or EULAs presented by the package installer, suppressing the interactive prompt. This applies to the package's own license terms only … For a fully non-interactive install, combine with --silent (-h)."
--accept-source-agreements "Accepts the license agreement for the WinGet source (repository), suppressing the interactive prompt. This is separate from any package license."
--disable-interactivity "Disable interactive prompts."
-e, --exact "Uses the exact string in the query, including checking for case-sensitivity. It will not use the default behavior of a substring."
--proxy "Set a proxy to use for this execution." / --no-proxy "Disable the use of proxy for this execution."
--no-upgrade "Skips upgrade if an installed version already exists."
-o, --log "Directs the logging to a log file. You must provide a path to a file that you have the write rights to."

winget 로그 기본 경로(공식): %LOCALAPPDATA%\Packages\Microsoft.DesktopAppInstaller_8wekyb3d8bbwe\LocalState\DiagOutputDir\*.log

실측한 종료 코드:

winget list --id Google.AntigravityCLI --exact --disable-interactivity   → exit 0
winget list --id No.Such.Package.Xyz  --exact --disable-interactivity    → exit -1978335212 (0x8A150014)

관련 HRESULT (winget-cli returnCodes.md 발췌 — 부트스트랩에서 분기할 값만):

Hex 심볼 의미
0x8A150014 APPINSTALLER_CLI_ERROR_NO_APPLICATIONS_FOUND 패키지 없음
0x8A150011 APPINSTALLER_CLI_ERROR_INSTALLER_HASH_MISMATCH 설치 파일 해시가 매니페스트와 불일치
0x8A15002D APPINSTALLER_CLI_ERROR_INSTALLER_SECURITY_CHECK_FAILED 설치 파일 보안 검사 실패
0x8A150041 APPINSTALLER_CLI_ERROR_PACKAGE_AGREEMENTS_NOT_ACCEPTED 패키지 약관 미동의
0x8A150046 APPINSTALLER_CLI_ERROR_SOURCE_AGREEMENTS_NOT_ACCEPTED 소스 약관 미동의
0x8A150019 APPINSTALLER_CLI_ERROR_COMMAND_REQUIRES_ADMIN 관리자 권한 필요
0x8A15003A APPINSTALLER_CLI_ERROR_BLOCKED_BY_POLICY 그룹 정책 차단
0x8A150061 APPINSTALLER_CLI_ERROR_PACKAGE_ALREADY_INSTALLED 동일 버전 이미 설치
0x8A150107 APPINSTALLER_CLI_ERROR_INSTALL_NO_NETWORK 네트워크 없음
0x8A150086 APPINSTALLER_CLI_ERROR_INSTALLER_ZERO_BYTE_FILE 0바이트 다운로드
0x8A15006D APPINSTALLER_CLI_ERROR_SERVICE_UNAVAILABLE 필요 서비스 busy/unavailable
0x8A150052 APPINSTALLER_CLI_ERROR_PORTABLE_INSTALL_FAILED portable 패키지 설치 실패(agy 가 portable 타입)
0x8A150054 APPINSTALLER_CLI_ERROR_PORTABLE_PACKAGE_ALREADY_EXISTS 다른 소스의 portable 패키지가 이미 있음

⚠️ 주의: PowerShell 에서 $LASTEXITCODE부호 있는 32비트로 나온다. 0x8A150014-1978335212 로 보인다. 비교할 때는 ('0x{0:X8}' -f $LASTEXITCODE) 로 정규화하라.

4.4 매니페스트 직접 다운로드 (2순위 폴백)

install.ps1 이 하는 일을 그대로 재현하되, 모든 실패를 우리 종료 코드로 통제한다. 완결 함수는 §12.1 Install-AgyFromManifest 에 있다. 요지:

  1. PROCESSOR_ARCHITEW6432 → 없으면 PROCESSOR_ARCHITECTURE 로 플랫폼 결정 (AMD64windows_amd64, ARM64windows_arm64)
  2. GET https://antigravity-cli-auto-updater-974169037036.us-central1.run.app/manifests/<platform>.jsonversion / url / sha512
  3. %TEMP%\dmf-agy-staging\agy.exe 로 다운로드 (install.ps1 의 스테이징 경로와 일부러 다르게 둔다 — 동시 실행 충돌 방지)
  4. Get-FileHash -Algorithm SHA512 비교 (대소문자 무시). 불일치 → 즉시 중단, 파일 삭제, exit 20
  5. %LOCALAPPDATA%\agy\bin\ 생성 후 Copy-Item -Force + Unblock-File
  6. & $binaryPath install --skip-aliases 로 PATH 등록 (실패해도 계속하되 로그에 남긴다)
  7. & $binaryPath --version 으로 최종 검증

4.5 실행 정책 (ExecutionPolicy)

실측:

        Scope ExecutionPolicy
        ----- ---------------
MachinePolicy       Undefined
   UserPolicy       Undefined
      Process          Bypass
  CurrentUser       Undefined
 LocalMachine    RemoteSigned
  • 유효 정책은 LocalMachine = RemoteSigned. 인터넷에서 내려받은 install.ps1Mark-of-the-Web 때문에 차단될 수 있다.
  • 공식 문서: 우선순위는 Process > CurrentUser > LocalMachine (그룹 정책 MachinePolicy / UserPolicy 가 있으면 그것이 최상위). Process 범위는 $Env:PSExecutionPolicyPreference 에 저장되며 세션 종료 시 사라진다.
  • 채택: 스케줄러 작업의 액션을 항상 powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "<script>" 형태로 등록한다.
  • 추가 근거(공식 문서의 함정 경고, 로그온 트리거 작업에 직결):

    "You could also get this error on any Windows system if the Windows Desktop Shell is unavailable or unresponsive. For example, during sign on, a PowerShell logon script could start execution before the Windows Desktop is ready, resulting in failure. Using an execution policy of ByPass or AllSigned doesn't require a Zone check which avoids the problem." → "로그온 시 실행" 대화형 작업(§7)에서 Bypass선택이 아니라 필수다.

  • Invoke-WebRequest / Invoke-RestMethod / curl.exe 로 받은 파일은 MotW 가 붙지 않는다(공식 문서 Note: "Other methods of downloading files may not mark the files as coming from the Internet Zone. Some examples include: curl.exe, Invoke-RestMethod, Invoke-WebRequest"). 그래도 방어적으로 Unblock-File 을 호출한다.
  • Bypass 의 공식 정의: "Nothing is blocked and there are no warnings or prompts. This execution policy is designed for configurations in which a PowerShell script is built into a larger application…" — 정확히 우리 상황이다.

4.6 설치 경로 결정 트리

[탐지 결과]
  ├─ 정상 설치 + --version OK ────────────────────────► 설치 단계 스킵
  ├─ 미설치
  │    ├─ 1) install.ps1 파일 다운로드 → -File 실행
  │    │      ├─ exit 0 → 검증
  │    │      └─ exit≠0 또는 예외 → 2)
  │    ├─ 2) 매니페스트 직접 다운로드 (§12.1)
  │    │      ├─ 성공 → 검증
  │    │      └─ 실패 → exit 10 (네트워크/해시 원인이면 20) + 사용자 알림 큐
  │    └─ (winget 은 시도하지 않는다)
  └─ 파일은 있으나 --version 실패(손상)
       └─ Remove-Item <binary> -Force → 미설치 경로와 동일하게 진행
          (단, agy 가 실행 중이면 파일이 잠겨 실패한다 → 프로세스 종료 후 재시도, §11)

5. 버전 관리와 자동 업데이트

5.1 업데이트 주체가 셋이다

주체 트리거 대상 파일 통제 수단
백그라운드 self-update agy 실행 시 자동 %LOCALAPPDATA%\agy\bin\agy.exe AGY_CLI_DISABLE_AUTO_UPDATE=true
agy update 수동 명시 호출 동일 우리가 호출 시점 결정
winget upgrade 사용자/다른 스크립트 WinGet\Links\agy.EXE (별개 사본) 우리가 안 씀. 하지만 사용자가 돌릴 수 있다

세 주체가 서로를 모른다. 그래서 §3.1 같은 버전 3중 분기가 생긴다.

5.2 업데이터 상태 파일 실측

~/.gemini/antigravity-cli/updater/ 디렉터리:

-rw-r--r--  0 bytes  2026-07-26 20:00:55  update.lock
-rw-r--r-- 59 bytes  2026-09-02 23:29:59  update_status.json
파일 실측 내용 올바른 해석
update.lock 0 바이트, mtime 이 2026-07-26 에 고정. agy update 를 실행해도 변하지 않음 "업데이트 중" 표시가 아니다. 최초 1회 생성된 뒤 OS 파일 락의 대상으로만 쓰이는 껍데기다. 존재 여부로 업데이트 진행을 판정하면 100% 오탐이다.
update_status.json 업데이트 직후: {"success":true,"message":"Update successful, restart CLI to use"}
최신 상태에서 agy update 직후: {"success":true,"message":"Already on the latest version."}
사람이 읽을 수 있는 마지막 결과. mtime 이 마지막 업데이트 시각. 부트스트랩은 이걸 읽어 로그에 남긴다
../last_check.timestamp 0 바이트 파일 내용이 없다. 의미는 mtime 뿐이다(실측 2026-09-02 22:08 = 마지막 업데이트 확인 시각)

05a 가 인용한 경고 문자열은 그대로 유효하다:

Warning: another background updater process is already active (update.lock)

이 문자열은 stderr 로 나올 때만 의미가 있다. 파일 존재는 의미가 없다.

5.3 agy update 실측

$ agy update
⟳ Checking for updates... (current version 1.1.24)
✓ You are already on the latest version.
항목 실측값
종료 코드 0
소요 시간 452 ms
부수효과 updater/update_status.json{"success":true,"message":"Already on the latest version."} 로 갱신. update.lock미변경
플래그 없음 (agy update --helpUsage of update: 만 출력)

agy --help 실측(1.1.24)에서 확인한 서브커맨드 목록 — 로그인/로그아웃 서브커맨드는 존재하지 않는다:

Available subcommands:
  agent           List available agents
  agents          List available agents
  changelog       Show changelog and release notes
  help            Show help for subcommands
  install         Configure environment paths and shell settings
  mcp             Manage MCP servers (add, remove, list, enable, disable)
  mic-serve       Serve this machine's microphone to a CLI on another host
  models          List available models
  plugin          Manage plugins (install, uninstall, list, enable, disable)
  plugins         Alias for plugin
  update          Update CLI

함의: 로그인은 대화형 TUI 안에서만 가능하다(/logout 도 슬래시 명령이다). 그래서 §7 의 "콘솔 창 띄우기" 가 유일한 재로그인 경로다. 함의 2: --version--help 목록에 없지만 동작한다(실측 exit 0, 79 ms, 1.1.24 출력). 문서화되지 않은 플래그에 의존하는 셈이므로 부트스트랩은 실패 시 agy models 로 폴백한다.

5.4 배치 실행 중 업데이트가 끼어드는 위험

위험 시나리오:

  1. 06:00 배치가 agy -p ... 를 10분짜리 프롬프트로 실행
  2. 그 사이 백그라운드 업데이터가 agy.exe 를 새 버전으로 교체
  3. 실행 중 프로세스는 살아남지만(Windows 는 실행 중 이미지 삭제를 막고, 업데이터는 .old 로 rename 후 교체) 다음 호출부터 새 바이너리
  4. 같은 배치 안에서 버전이 섞인다. 프롬프트 포맷·출력 봉투가 릴리스 사이에 바뀌면 파싱이 깨진다

회피 3중 방어:

# 방어 1: 배치 프로세스 환경에 자동 업데이트 비활성화
$env:AGY_CLI_DISABLE_AUTO_UPDATE = 'true'

# 방어 2: 배치 시작 시 버전을 한 번 고정해서 기록하고, 배치 종료 시 다시 확인
$verBefore = (& $AgyExe --version 2>$null | Select-Object -First 1).Trim()
# ... 배치 본문 ...
$verAfter  = (& $AgyExe --version 2>$null | Select-Object -First 1).Trim()
if ($verBefore -ne $verAfter) {
    Write-Warning "agy 버전이 배치 도중 $verBefore -> $verAfter 로 바뀌었다. 결과 검증 필요."
}

# 방어 3: 업데이트는 주 1회 계획 작업에서만 (§5.5)

⚠️ 주의: AGY_CLI_DISABLE_AUTO_UPDATE프로세스 환경변수로 줘야 한다. 시스템/사용자 환경변수에 영구 설정하면 사용자의 대화형 agy 도 영영 업데이트되지 않는다. 작업 스케줄러 액션에서 래퍼 스크립트가 설정하는 방식이 옳다.

5.5 주 1회 계획 업데이트 작업

항목
작업 이름 DMF_Crawler\AgyWeeklyUpdate
트리거 매주 일요일 05:00 (일일 배치 06:00 보다 1시간 앞)
실행 계정 배치와 동일 사용자, 암호 저장(TASK_LOGON_PASSWORD)
액션 powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "D:\workspace\DMF_Crawler\scripts\update_agy.ps1"
조건 "네트워크 연결이 가능할 때만 시작" 체크

update_agy.ps1 전체 코드는 §12.2.

락 안전장치: 업데이트 작업과 배치 작업이 겹치지 않도록 파일 락을 공유한다. 배치는 시작 시 state\agy.runlock 을 배타 잠금으로 열고, 업데이트 작업도 같은 파일을 잠근다. 잠금 획득에 실패하면 그 실행은 스킵한다(§12.2, §11).

5.6 .old 바이너리 누적과 디스크

  • self-update 는 이전 바이너리를 agy.exe.<nanotimestamp>.old 로 남긴다. 실측: agy.exe.1788354501993998300.old = 186 MB.
  • WinGet\Links 에도 agy.EXE.1788008780374336100.old 심볼릭 링크가 남아 있었다.
  • 릴리스가 잦으므로(1.1.10 → 1.1.24 가 한 달 남짓) 방치하면 GB 단위로 쌓인다.
  • 주간 업데이트 작업에서 7일 이상 된 .old 파일을 정리한다(§12.2).
Get-ChildItem (Join-Path $env:LOCALAPPDATA 'agy\bin') -Filter 'agy.exe.*.old' -ErrorAction SilentlyContinue |
    Where-Object { $_.LastWriteTime -lt (Get-Date).AddDays(-7) } |
    Remove-Item -Force -ErrorAction SilentlyContinue

5.7 첫 호출 오버헤드 재측정 — 프로필 격리의 부수 효과

측정 프로필 cwd input_tokens duration
05a 기록 사용자 기본 프로필 프로젝트 디렉터리 28,317 33.7 s
이 문서 실측 격리 프로필(빈 .gemini) 스크래치 디렉터리 14,056 2.1 s

⚠️ 교란요인 주의: 두 측정은 프로필뿐 아니라 cwd, 모델 캐시 워밍, 릴리스 버전(1.1.22 vs 1.1.24) 이 모두 다르다. "격리 프로필이 토큰을 절반으로 줄인다"고 단정할 수 없다. 다만 격리 프로필에서 rules/, knowledge/, skills/, MCP 설정이 전부 비어 있다는 사실은 시스템 프롬프트가 작아질 이유로 충분하다. → 부록 B 의 통제 실험 항목.


6. 인증 부트스트랩 — 이 문서의 핵심

6.1 ⚠️ 05a 정정: 자격증명의 정본은 Windows 자격 증명 관리자다

05a §4.2 는 다음과 같이 기록했다:

한편 Windows 자격증명 관리자에는 관련 항목이 없었다: $ cmdkey /list | grep -i -E "antigrav|agy|google" → (결과 없음)

이 기록은 현재 사실과 다르다. 2026-09-02 재실측:

$ cmdkey /list:gemini:antigravity

Currently stored credentials for gemini:antigravity:

    Target: gemini:antigravity
    Type: Generic
    User: antigravity
    Local machine persistence

advapi32.dll!CredReadW P/Invoke 로 메타데이터를 읽은 결과:

CredRead OK   Type=1 (CRED_TYPE_GENERIC)   Persist=2 (CRED_PERSIST_LOCAL_MACHINE)
BlobSize=504   User=antigravity
LastWritten(UTC): 2026-09-02T14:27:08.0217590Z

블롭을 UTF-8 로 디코드해 값이 아니라 키만 확인한 결과:

first char: '{'   last char: '}'
top-level keys: token, auth_method
token keys    : access_token, token_type, refresh_token, expiry
auth_method   : consumer
expiry        : 09/03/2026 00:27:07
has refresh_token: True

한편 파일 쪽:

$ ls -la ~/.gemini/antigravity-cli/antigravity-oauth-token
-rw-r--r-- 504  2026-08-30 09:32   antigravity-oauth-token

# 구조 (값은 마스킹)
token.access_token  = ya29.a0AdMD6Eh…
token.token_type    = Bearer
token.refresh_token = 1//0eOPMRXFC0M_8Cg…
token.expiry        = 2026-08-30T10:32:43.9493083+09:00
auth_method         = consumer

두 저장소의 비교:

항목 자격 증명 관리자 gemini:antigravity 파일 antigravity-oauth-token
크기 504 바이트 504 바이트 (동일)
스키마 {token:{access_token,token_type,refresh_token,expiry}, auth_method} 동일
마지막 기록 2026-09-02 14:27 UTC (= 방금 실행한 테스트 시각) 2026-08-30 00:32 UTC (3일 전, 정지)
expiry 2026-09-03 00:27 (= 기록 시각 +1h, 유효) 2026-08-30 10:32 (이미 만료)
결론 정본. 매 실행마다 갱신된다 낡은 미러. 만료된 토큰이 들어 있다

결정적 실험: 자식 프로세스의 USERPROFILE/HOME 을 빈 임시 디렉터리로 바꿔 실행했다. 그러면 .gemini/antigravity-cli/ 트리가 새로 만들어지고 토큰 파일은 존재하지 않는다. 그런데도:

{"conversation_id":"44ce7fb9-7829-4da9-8559-808f0c4202cb","status":"SUCCESS","response":"PONG\n","duration_seconds":2.132069,"num_turns":1,"usage":{"input_tokens":14056,"output_tokens":74,"thinking_tokens":72,"cache_read_tokens":0,"total_tokens":14130}}
EXITCODE=0

토큰 파일 없이 인증에 성공했다. 그리고 그 실행 시각에 자격 증명 관리자의 LastWritten 이 갱신됐다. → agy 는 자격 증명 관리자에서 읽고 거기에 쓴다.

운영상 함의:

사실 함의
정본이 자격 증명 관리자(DPAPI 보호) 평문 파일 유출 걱정이 05a 가 우려한 것보다 작다. 다만 파일에도 만료된 access_token 과 살아 있는 refresh_token 이 남아 있으므로 여전히 .gitignore 필수
CRED_PERSIST_LOCAL_MACHINE 같은 사용자의 이 머신 모든 로그온 세션에서 접근 가능. 로밍 프로필로 따라가지 않는다
CredRead로그온 세션의 자격증명 집합에서 읽는다 S4U 금지의 근거(§6.5)
파일의 expiry 는 항상 과거 파일 expiry 로 인증 만료를 판정하면 100% 오탐
USERPROFILE 을 바꿔도 인증이 유지됨 프로필 격리 설계가 성립한다(§9.2)

⚠️ 미검증: 파일과 자격 증명 관리자 중 어느 쪽을 먼저 읽는지, 자격 증명 관리자가 없고 파일만 있을 때 동작하는지는 확인하지 못했다(파괴적 실험이 필요). 부트스트랩은 둘 중 하나라도 유효하면 "자격증명 있음" 으로 판정하되, 자격 증명 관리자를 1순위 신호로 쓴다.

6.2 인증 상태 비대화형 판정 — 3단 프로브

단계 프로브 실측 비용 판정하는 것 실패 시
P1 Test-Path $AgyExe + & $AgyExe --version 79 ms, 0 토큰, 네트워크 불필요 바이너리 존재·무결성 exit 10
P2 CredReadW("gemini:antigravity", CRED_TYPE_GENERIC) ~1 ms, 0 토큰, 오프라인 자격증명 존재 + refresh_token 보유 exit 11
P3 & $AgyExe models 3,121 ms, 0 토큰, 네트워크 O 자격증명이 서버에서 실제로 유효 exit 11

P3 가 왜 인증 프로브인가: 미인증 상태의 CLI 로그에서 확인한 문자열:

I0902 23:27:07.721484  model_configs.go:62] Auth mode is unspecified, skipping fetchAvailableModels and returning empty response

인증되지 않으면 agy models 가 빈 목록을 반환한다. 정상 상태의 실측 출력(11줄):

$ agy models          # exit=0, 3121 ms
Fetching available models...
gemini-3.7-flash-high	Gemini 3.7 Flash (High)
gemini-3.7-flash-medium	Gemini 3.7 Flash (Medium)
gemini-3.7-flash-low	Gemini 3.7 Flash (Low)
gemini-3.6-flash-high	Gemini 3.6 Flash (High)
gemini-3.6-flash-medium	Gemini 3.6 Flash (Medium)
gemini-3.6-flash-low	Gemini 3.6 Flash (Low)
gemini-3.1-pro-high	Gemini 3.1 Pro (High)
gemini-3.1-pro-low	Gemini 3.1 Pro (Low)
claude-sonnet-4-6	Claude Sonnet 4.6 (Thinking)
claude-opus-4-6-thinking	Claude Opus 4.6 (Thinking)
gpt-oss-120b-medium	GPT-OSS 120B (Medium)

판정 규칙: <슬러그>\t<표시이름> 형태의 줄이 1줄 이상이면 인증 유효. 0줄이면 인증 실패.

⚠️ 부분 검증: "빈 목록 = 미인증" 은 미인증 로그의 문자열에서 도출한 것이고, 실제로 미인증 상태에서 agy models 를 돌려 빈 출력을 눈으로 확인하지는 못했다(로그아웃이 파괴적이라 실행 안 함). 부록 B 항목.

이 3단 프로브가 05a 의 PONG 헬스체크를 대체한다:

05a PONG 방식 이 문서 3단 프로브
토큰 28,317 input 0
시간 33.7 s 0.08 + 0.001 + 3.1 ≈ 3.2 s
크레딧 소모 있음 없음
인증 유효성 실검증 (P3)

6.3 미인증 오류 표면 — 무엇을 신호로 삼고 무엇을 무시할 것인가

빈 프로필로 실행했을 때 CLI 로그(~/.gemini/antigravity-cli/log/cli-YYYYMMDD_HHMMSS.log)에 나타난 문자열들(실측 발췌):

E0902 23:27:07.720428  errorreport.go:223] Failed to poll ListExperiments: error getting token source: You are not logged into Antigravity.
I0902 23:27:07.721484  model_configs.go:62] Auth mode is unspecified, skipping fetchAvailableModels and returning empty response
W0902 23:27:07.732219  cache.go:135] Cache(loadCodeAssistResponse): Singleflight refresh failed: error getting token source: You are not logged into Antigravity.
W0902 23:27:07.734288  cache.go:135] Cache(userInfo): Singleflight refresh failed: failed to get load code assist response: error getting token source: You are not logged into Antigravity.
I0902 23:27:07.781443  server.go:2873] Auth succeeded, refreshing features and managers
I0902 23:27:07.784685  auth_provider.go:755] [AuthProvider] SetEnableBusinessLogin called with enable: true

핵심 함정: 위 로그에는 You are not logged into Antigravity.수십 번 나오지만, 같은 실행이 status:SUCCESS, exit 0 으로 끝났다. 시작 시점에 자격증명 로딩이 끝나기 전 워커들이 먼저 폴링해서 나는 소음이고, 곧이어 Auth succeeded 가 찍힌다.

규칙: "You are not logged into Antigravity." 문자열의 존재만으로 미인증을 판정하지 마라. 무조건 오탐이다. 판정에 쓸 수 있는 것은 CredRead 결과, ② agy models 의 모델 줄 개수, ③ JSON 봉투의 status / error 뿐이다.

신호 신뢰도 사용처
CredReadERROR_NOT_FOUND (1168) ★★★ 확정 미인증 (한 번도 로그인 안 함 / /logout 함)
CredReadERROR_NO_SUCH_LOGON_SESSION (1312) ★★★ 확정 잘못된 실행 컨텍스트(S4U/네트워크 로그온) → exit 30
agy models 출력 0줄 ★★☆ 자격증명이 서버에서 거부됨(만료·취소)
JSON 봉투 status:"ERROR" + error 에 auth 관련 문구 ★★☆ 실작업 중 인증 실패
로그의 You are not logged into Antigravity. ★☆☆ 오탐 진단 참고용으로만
파일 antigravity-oauth-tokenexpiry 항상 과거 사용 금지
파일 존재 여부 ★☆☆ 보조 신호(자격 증명 관리자가 우선)

6.4 access_token 수명과 갱신

  • 실측: expiry = 기록 시각 + 정확히 1시간. (LastWritten 2026-09-02T14:27:08Zexpiry 2026-09-03 00:27:07 KST = 2026-09-02T15:27:07Z)
  • refresh_token (1//0e… 접두사 = Google OAuth refresh token) 으로 자동 갱신하고, 갱신 결과를 자격 증명 관리자에 되쓴다.
  • 따라서 배치가 하루 한 번 돌아도 문제없다. 문제가 되는 경우는:
    1. 사용자가 Google 계정에서 앱 접근 권한을 취소
    2. 비밀번호 변경으로 refresh_token 무효화
    3. 6개월 이상 미사용(Google OAuth refresh token 만료 정책)
    4. /logout 실행
  • 이 4가지는 모두 P3(agy models)에서 잡힌다.

6.5 작업 스케줄러 로그온 타입 — S4U 금지

CredRead 공식 문서 원문:

"The CredRead function reads a credential from the user's credential set. The credential set used is the one associated with the logon session of the current token. The token must not have the user's SID disabled."

반환 코드:

  • ERROR_NOT_FOUND — No credential exists with the specified TargetName.
  • ERROR_NO_SUCH_LOGON_SESSION — The logon session does not exist or there is no credential set associated with this logon session. Network logon sessions do not have an associated credential set.
  • ERROR_INVALID_FLAGS — A flag that is not valid was specified for the Flags parameter.

Task Scheduler 공식 문서(Security Contexts for Tasks):

"Tasks registered with the TASK_LOGON_PASSWORD or TASK_LOGON_S4U flag will only launch if the specified user has the Logon as Batch privilege enabled. Administrators and Backup Operators group users have this privilege enabled by default."

"When you register a task from a user account that is not a member of the Administrators group, then you do not need to specify a password when registering the task if you register the task to run under the security context of your account and you use the S4U or interactive logon type."

결론 표:

작업 스케줄러 UI 설정 TASK_LOGON_TYPE 자격 증명 관리자 접근 06:00 무인 실행 이 프로젝트
"사용자가 로그온한 경우에만 실행" TASK_LOGON_INTERACTIVE_TOKEN 가능 로그오프 상태면 안 돎 대화형 알림 작업에만 사용
"사용자의 로그온 여부에 관계없이 실행" + 암호 입력 TASK_LOGON_PASSWORD 가능(대화형 로그온 세션이 생성됨) 일일 배치에 채택
"사용자의 로그온 여부에 관계없이 실행" + "암호를 저장하지 않음" 체크 TASK_LOGON_S4U 네트워크 로그온 세션 → 자격증명 집합 없음 돌긴 함 금지
"SYSTEM 계정으로 실행" TASK_LOGON_SERVICE_ACCOUNT 다른 사용자 금지

⚠️ 08번 문서에 대한 제약 추가: 08 이 S4U 를 권하고 있다면 이 문서의 결론이 우선한다. S4U 는 네트워크 드라이브 접근이 안 되는 것으로 널리 알려져 있는데, 같은 이유(네트워크 로그온 세션)로 자격 증명 관리자도 안 된다. ⚠️ 미검증: S4U 작업에서 CredRead 가 실제로 1312 를 반환하는지는 실작업을 등록해 확인하지 못했다. 문서상 메커니즘은 명확하나 실측이 필요하다(부록 B). 그래서 §11 의 스크립트는 1312 를 만나면 명시적으로 exit 30 + 안내 메시지를 내도록 만들었다.

schtasks 로 안전하게 등록하는 형태(암호 저장 방식):

schtasks /Create ^
  /TN "DMF_Crawler\DailyCrawl" ^
  /TR "powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File \"D:\workspace\DMF_Crawler\scripts\run_daily.ps1\"" ^
  /SC DAILY /ST 06:00 ^
  /RU "%COMPUTERNAME%\%USERNAME%" /RP * ^
  /RL LIMITED ^
  /F
  • /RP * 는 암호를 대화형으로 물어본다(스크립트에 암호를 박지 않는다). TASK_LOGON_PASSWORD 가 된다.
  • /RL LIMITED — 관리자 권한이 필요 없다. agy 도 크롤러도 사용자 권한으로 충분하다.
  • /NP 를 쓰면 S4U 가 되어 인증이 깨진다. 절대 쓰지 마라.

6.6 인증 실패 시 무엇을 하는가 (핸드셰이크 설계)

배치는 창을 띄우지 않는다. 플래그 파일을 쓰고 정상 종료(exit 11)한다.

D:\workspace\DMF_Crawler\state\auth_required.json
{
  "schema": 1,
  "raised_at": "2026-09-03T06:00:12+09:00",
  "run_id": "20260903_060000",
  "reason": "AGY_MODELS_EMPTY",
  "detail": "agy models returned 0 model rows (exit=0). Credential likely revoked or expired.",
  "agy_exe": "C:\\Users\\encep\\AppData\\Local\\agy\\bin\\agy.exe",
  "agy_version": "1.1.24",
  "cred_target": "gemini:antigravity",
  "cred_present": true,
  "attempts": 1,
  "last_notified_at": null,
  "resolved_at": null
}

reason 값 목록(부트스트랩이 쓰는 것 전부):

reason 의미 사용자에게 요구할 행동
CRED_NOT_FOUND 자격 증명 관리자에 항목 없음(1168) 최초 로그인
CRED_NO_LOGON_SESSION 1312 — 잘못된 실행 컨텍스트 작업 설정 수정(S4U 해제)
CRED_NO_REFRESH_TOKEN 블롭은 있으나 refresh_token 이 비어 있음 재로그인
AGY_MODELS_EMPTY agy models 가 0줄 재로그인
AGY_MODELS_FAILED agy models 가 비0 종료 네트워크 확인 후 재로그인
RUN_STATUS_ERROR_AUTH 실작업 봉투가 status:ERROR + auth 문구 재로그인

해소(resolve): 대화형 로그인 창(§7)이 성공하면 resolved_at 을 채우고 파일을 state\auth_resolved\<run_id>.json 으로 옮긴다. 다음 배치는 파일이 없으므로 정상 진행한다.

중복 알림 억제: last_notified_at 이 6시간 이내면 다시 알리지 않는다. 사용자가 잠들어 있는 새벽에 토스트를 반복하지 않기 위함이다.


7. Windows 프롬프트 창 띄우기 설계

토스트 라이브러리 비교·설치·Session 0 일반론은 08 §8/§11 이 정본이다. 여기서는 agy 재로그인 전용 배선만 다룬다.

7.1 왜 배치가 직접 창을 띄우면 안 되는가

Microsoft 공식 문서(Interactive Services):

"Services cannot directly interact with a user as of Windows Vista." "All services run in Terminal Services session 0. Therefore, if an interactive service displays a user interface, it is visible only to the user who connected to session 0. Because there is no way to guarantee that the interactive user is connected to session 0, do not configure a service to run as an interactive service…"

간접 상호작용 수단으로 문서가 제시하는 것:

  • Display a dialog box in the user's session using the WTSSendMessage function.
  • Create a separate hidden GUI application and use the CreateProcessAsUser function to run the application within the context of the interactive user. Design the GUI application to communicate with the service through some method of interprocess communication (IPC), for example, named pipes. … If this service runs on a multiuser system, add the application to the following key so that it is run in each session: HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\Run.

우리 상황은 서비스가 아니라 작업 스케줄러 작업이지만, TASK_LOGON_PASSWORD 로 "로그온 여부와 무관하게" 실행하면 사용자가 물리적으로 로그오프한 상태일 수 있다. 그때 창을 띄워봐야 아무도 못 본다.

그래서 채택한 구조는 위 문서가 권하는 "별도 애플리케이션 + IPC" 의 파일 기반 축소판이다:

┌─ 작업 A: DMF_Crawler\DailyCrawl ────────────────────────────┐
│  트리거: 매일 06:00                                          │
│  로그온: TASK_LOGON_PASSWORD (암호 저장)                     │
│  RunLevel: LIMITED                                           │
│  → ensure_agy.ps1 → 인증 실패 시                             │
│      state\auth_required.json 기록 후 exit 11                │
│  (창을 띄우지 않는다)                                         │
└──────────────────────────────────────────────────────────────┘
                              │ 파일
                              ▼
┌─ 작업 B: DMF_Crawler\AuthPrompt ────────────────────────────┐
│  트리거: ① 로그온 시(지연 2분) ② 15분마다 반복               │
│  로그온: TASK_LOGON_INTERACTIVE_TOKEN                        │
│          ("사용자가 로그온한 경우에만 실행")                  │
│  → auth_required.json 있고 last_notified 6h 초과면            │
│      토스트/MessageBox → 사용자가 누르면 콘솔 창에서 agy 실행 │
└──────────────────────────────────────────────────────────────┘

작업 B 는 정의상 사용자 세션에서만 돈다. Session 0 문제가 원천적으로 없다.

7.2 방법 1 — 새 콘솔 창에서 대화형 agy 실행 (채택, 최종 수단)

agy 는 로그인 서브커맨드가 없으므로(§5.3) TUI 를 띄우고 사용자가 로그인을 마치게 하는 것이 유일한 방법이다.

# scripts/agy_login_window.ps1 의 핵심 (전체 코드는 §12.4)
$agy = Join-Path $env:LOCALAPPDATA 'agy\bin\agy.exe'

$inner = @"
`$Host.UI.RawUI.WindowTitle = 'DMF Crawler — Antigravity 재로그인'
Write-Host ''
Write-Host '  DMF Crawler 일일 배치가 Antigravity 인증에 실패했습니다.' -ForegroundColor Yellow
Write-Host '  아래에서 agy 가 열리면 로그인을 완료한 뒤 /quit 로 종료하세요.' -ForegroundColor Yellow
Write-Host '  (브라우저가 자동으로 열립니다)' -ForegroundColor DarkGray
Write-Host ''
& '$agy'
Write-Host ''
Write-Host '  인증 상태를 다시 확인합니다...' -ForegroundColor Cyan
& '$PSScriptRoot\ensure_agy.ps1' -CheckOnly
if (`$LASTEXITCODE -eq 0) {
    Write-Host '  ✓ 인증 성공. 다음 배치부터 정상 동작합니다.' -ForegroundColor Green
} else {
    Write-Host "  ✗ 여전히 인증 실패 (exit `$LASTEXITCODE)." -ForegroundColor Red
}
Write-Host ''
Read-Host '  아무 키나 누르면 닫힙니다'
"@

$encoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($inner))

Start-Process -FilePath 'powershell.exe' `
  -ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-NoExit','-EncodedCommand',$encoded) `
  -WindowStyle Normal `
  -WorkingDirectory 'D:\workspace\DMF_Crawler'

설계 근거와 제약:

항목 근거 / 제약
-WindowStyle Normal 공식 문서: "Specifies the state of the window that's used for the new process. The default value is Normal. The acceptable values … Normal, Hidden, Minimized, Maximized." -NoNewWindow 와 동시 사용 불가.
powershell.exe (5.1) 사용 pwsh.exe 도 되지만, 이 머신의 5.1 은 항상 존재가 보장된다(C:\WINDOWS\System32\WindowsPowerShell\v1.0\powershell.exe). 프로필 로딩 없이(-NoProfile) 뜬다
-EncodedCommand 한글·따옴표·경로 공백이 섞인 스크립트를 -ArgumentList 로 전달할 때 인용 지옥을 피하는 가장 안전한 방법. 공식 문서 경고: "If parameters or parameter values contain a space or quotes, they need to be surrounded with escaped double quotes."
-NoExit agy TUI 가 끝난 뒤에도 결과 메시지를 보여주기 위함
비대화형 세션에서 호출하면 프로세스는 뜨지만 창이 보이지 않는다. 그래서 작업 B(대화형 작업)에서만 호출한다
Start-Process 는 기본 비동기 공식: "By default, Start-Process launches a process asynchronously." → 알림 스크립트는 창을 띄우고 바로 종료해도 된다
원격 세션 주의 공식: "On a remote system, the new process is terminated when the remote session ends" — RDP 로 관리한다면 -Wait 를 붙여야 한다

7.3 방법 2 — 토스트 알림 + 프로토콜 활성화 버튼

실측한 제약(중요):

PowerShell 7.6.5 : [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType=WindowsRuntime]
                   → FAIL: Unable to find type […]
PowerShell 5.1   : 동일 코드 → OK

결론: WinRT 토스트 API 를 직접 쓰려면 반드시 powershell.exe(5.1) 에서 실행해야 한다. PowerShell 7 에서는 로드되지 않는다. BurntToast 모듈은 Microsoft.Toolkit.Uwp.Notifications 를 번들해 PS7 에서도 동작하지만, 이 머신에는 BurntToast 가 설치돼 있지 않다(실측: Get-Module -ListAvailable BurntToast → 없음). 부트스트랩은 모듈 없음을 정상 상태로 간주하고 폴백해야 한다.

토스트 버튼이 스크립트를 실행하게 하려면 커스텀 URI 프로토콜을 HKCU 에 등록한다(관리자 권한 불필요).

# scripts/register_login_protocol.ps1 — 최초 1회만 실행
$proto   = 'dmf-agy-login'
$script  = 'D:\workspace\DMF_Crawler\scripts\agy_login_window.ps1'
$ps51    = 'C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe'

$root = "HKCU:\Software\Classes\$proto"
New-Item -Path $root -Force | Out-Null
Set-ItemProperty -Path $root -Name '(Default)'  -Value 'URL:DMF Crawler agy login'
Set-ItemProperty -Path $root -Name 'URL Protocol' -Value ''

$cmdKey = "$root\shell\open\command"
New-Item -Path $cmdKey -Force | Out-Null
Set-ItemProperty -Path $cmdKey -Name '(Default)' `
  -Value ('"{0}" -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File "{1}"' -f $ps51, $script)

Write-Host "등록 완료. 테스트: Start-Process '$proto`:'"

BurntToast 로 버튼 두 개짜리 토스트를 띄우는 형태:

# BurntToast 가 있을 때만
if (Get-Module -ListAvailable -Name BurntToast) {
    Import-Module BurntToast -ErrorAction Stop

    $btnLogin = New-BTButton -Content '지금 재로그인' -Arguments 'dmf-agy-login:' -ActivationType Protocol
    $btnLog   = New-BTButton -Content '로그 열기'     -Arguments 'D:\workspace\DMF_Crawler\logs' -ActivationType Protocol
    $btnLater = New-BTButton -Dismiss -Content '나중에'

    New-BurntToastNotification `
        -Text 'DMF Crawler — Antigravity 로그인 필요',
              '일일 배치가 인증에 실패했습니다. 재로그인하면 다음 실행부터 정상화됩니다.' `
        -Button $btnLogin, $btnLog, $btnLater `
        -UniqueIdentifier 'dmf-agy-auth'
}
  • New-BTButtonActivationType 공식 설명: "Defines the activation type that triggers when the button is pressed. Defaults to Protocol" → 명시하지 않아도 Protocol 이지만 의도를 드러내기 위해 씀.
  • -UniqueIdentifier 로 같은 알림이 중복 쌓이지 않게 한다.
  • BurntToast v1.0.0 breaking change: AppId 커스터마이징이 제거됐다(08 §11.2 참조). 그래서 토스트는 "Windows PowerShell" 이름으로 뜬다. 브랜딩이 필요하면 AUMID 를 가진 바로 가기를 만들어야 한다.

토스트가 뜨지 않는 조건(08 §11 과 동일, 재확인용): 로그온 전 / Session 0 / 집중 지원(방해 금지) 모드 / 알림 설정에서 PowerShell 차단 / AUMID 미등록.

7.4 방법 3 — MessageBox (폴백)

WinRT 도 BurntToast 도 없을 때의 최후 GUI 수단. 작업 B(대화형 작업)에서만 유효하다.

Add-Type -AssemblyName System.Windows.Forms
$result = [System.Windows.Forms.MessageBox]::Show(
    "DMF Crawler 일일 배치가 Antigravity 인증에 실패했습니다.`n`n지금 재로그인 창을 여시겠습니까?",
    'DMF Crawler — 인증 필요',
    [System.Windows.Forms.MessageBoxButtons]::YesNo,
    [System.Windows.Forms.MessageBoxIcon]::Warning,
    [System.Windows.Forms.MessageBoxDefaultButton]::Button1,
    [System.Windows.Forms.MessageBoxOptions]::DefaultDesktopOnly
)
if ($result -eq [System.Windows.Forms.DialogResult]::Yes) {
    & "$PSScriptRoot\agy_login_window.ps1"
}
  • MessageBoxOptions::DefaultDesktopOnly 는 Win32 의 MB_DEFAULT_DESKTOP_ONLY 에 대응한다. 서비스(Session 0)에서 쓰지 마라 — 공식 문서가 MB_SERVICE_NOTIFICATION 을 별도로 언급하며 Windows Server 2003/XP 한정이라고 못박고 있다.
  • MessageBox 는 블로킹이다. 작업 B 가 15분마다 반복 실행되므로, 이전 인스턴스가 대화상자를 띄운 채 남아 있지 않도록 뮤텍스로 단일 인스턴스를 강제한다(§12.3).

7.5 방법 4 — msg.exe (최후 폴백)

실측: C:\WINDOWS\system32\msg.exe 존재.

msg.exe %USERNAME% /TIME:600 "DMF Crawler: Antigravity 재로그인이 필요합니다. D:\workspace\DMF_Crawler\scripts\agy_login_window.ps1 을 실행하세요."
  • 버튼이 없다. 정보 전달만 가능하다.
  • 로그온한 세션이 있어야 한다. 없으면 조용히 실패한다.
  • 08 §11 이 이미 폴백 2단계로 지정한 수단이다. 여기서는 문구만 agy 전용으로 맞춘다.

7.6 4단 폴백 순서 (최종)

순위 수단 조건 사용자 행동 가능?
1 BurntToast 토스트 + dmf-agy-login: 버튼 모듈 설치됨 + 사용자 세션 클릭 한 번
2 WinRT 토스트(powershell.exe 5.1 경유) PS 5.1 존재(항상 참) + 사용자 세션 클릭
3 MessageBox(Yes/No) 사용자 세션 Yes 클릭
4 msg.exe 세션 존재 안내만
5 웹훅(디스코드/텔레그램) 08 §11 의 3단 폴백. PC 가 꺼져 있어도 도달 안내만

모든 단계가 실패해도 배치는 계속 성공한다. AI 요약 없이 xlsx 리포트만 생성한다(05a §16 의 graceful degradation 원칙).


8. GEMINI_API_KEY 대체 경로 — 무인 실행에 더 적합한가?

8.1 공식 문서가 말하는 것

Antigravity CLI 「Installation & Authentication」 원문 요지:

  • ~/.gemini/antigravity-cli/settings.json 에 다음을 넣는다:
{
    "modelProvider": "gemini"
}
  • 환경변수를 설정한다:
export GEMINI_API_KEY="your-api-key"
  • 대체 엔드포인트:
export GOOGLE_GEMINI_BASE_URL="https://your-endpoint.example.com"
  • 문서 원문: "The CLI skips the sign-in screen and opens the main interface directly."
  • 문서 원문(제약): "When you use the authentication with a GEMINI_API_KEY, /logout has no effect because there is no stored session to clear."

Windows 용 완결 설정 예시(격리 프로필 기준):

{
  "modelProvider": "gemini",
  "agentMode": "accept-edits",
  "notifications": false,
  "showTips": false,
  "showFeedbackSurvey": false,
  "enableTelemetry": false,
  "verbosity": "low",
  "permissions": {
    "allow": ["read_file(workspace/DMF_Crawler)"],
    "deny": ["read_url(*)", "execute_url(*)", "mcp(*)", "command(*)"],
    "ask": []
  }
}

PowerShell 에서 키 주입(작업 스케줄러 래퍼 안):

# 키는 DPAPI 로 사용자 스코프 암호화해 파일에 저장하고, 실행 시에만 복호화한다
$keyFile = 'D:\workspace\DMF_Crawler\state\gemini_api_key.sec'
$env:GEMINI_API_KEY = [Runtime.InteropServices.Marshal]::PtrToStringBSTR(
    [Runtime.InteropServices.Marshal]::SecureStringToBSTR(
        (Get-Content -Raw $keyFile | ConvertTo-SecureString)))

키 파일 최초 생성(1회, 대화형):

Read-Host '오직 Gemini API 키만 붙여넣으세요' -AsSecureString |
    ConvertFrom-SecureString |
    Set-Content -NoNewline 'D:\workspace\DMF_Crawler\state\gemini_api_key.sec'

ConvertFrom-SecureString 은 DPAPI 사용자 스코프로 암호화한다. 다른 사용자·다른 머신에서는 복호화되지 않는다. 작업 스케줄러가 동일 사용자로 돌 때만 동작한다 — 즉 §6.5 의 제약이 여기에도 그대로 적용된다.

8.2 두 경로 비교

항목 OAuth (자격 증명 관리자) GEMINI_API_KEY
최초 대화형 개입 필요(1회 TUI 로그인) 불필요(키만 발급)
만료/취소 위험 있음(refresh_token 무효화, 계정 정책) 낮음(키를 폐기하지 않는 한)
재로그인 UI 필요 필요(§7 전체) 불필요
실행 컨텍스트 제약 S4U 금지(자격 증명 관리자) DPAPI 파일을 쓰면 동일 제약. 평문 환경변수로 주면 제약 없음(대신 유출 위험)
사용 가능 모델 실측 11종: Gemini 3.7/3.6 Flash, 3.1 Pro, Claude Sonnet 4.6 / Opus 4.6, GPT-OSS 120B ⚠️ Gemini 계열만일 가능성이 높다. modelProvider: "gemini" 라는 이름 자체가 프로바이더를 Gemini API 로 못박는다. Claude/GPT-OSS 는 Antigravity 백엔드가 중개하는 것이므로 직결 API 키로는 안 될 것으로 본다 — 미검증
과금 Antigravity 플랜 크레딧(/credits, useG1Credits) Google AI Studio / Vertex 과금. 별도 청구
쿼터 05a §14 — 공식 문서에 수치 없음 Gemini API rate limits 문서에 티어 존재: Free / Tier 1(빌링 연결, $250 billing cap, 10분당 $10 지출 한도) / Tier 2($100 + 최초 결제 후 3일, $2,000 cap, 10분당 $50) / Tier 3($1,000 + 30일, $20,000$100,000+ cap, 10분당 $200). 측정 축은 RPM / TPM(input) / RPD
배치 규모 적합성 하루 1~2회 호출 = 크레딧 소모 미미 동일. Free Tier 로도 충분할 가능성
프롬프트 인젝션 표면 동일 동일

8.3 결론 — 채택하지 않는다(단, 폴백 경로로 문서화해 둔다)

이 프로젝트가 OAuth 를 유지하는 이유:

  1. 이미 인증되어 있다. 실측 머신은 자격 증명 관리자에 유효한 자격증명이 있고, refresh_token 자동 갱신이 동작 중이다. 무인 실행의 실제 장애 확률이 낮다.
  2. 모델 선택지가 넓다. 05a §8.2 는 셀렉터 자가 복구에 claude-sonnet-4-6 를, 트렌드 코멘터리에 gemini-3.1-pro-high 를 권한다. API 키 경로에서 Claude 모델을 쓸 수 있다는 근거가 없다.
  3. 과금 창구가 하나다. API 키를 쓰면 Google Cloud 청구가 별도로 생긴다. 개인 PC 자동화에 청구 채널을 늘릴 이유가 없다.
  4. API 키 경로도 결국 DPAPI/파일 보안 문제를 낳는다. 무인 실행에서 키를 평문으로 두지 않으려면 DPAPI 를 쓰게 되고, 그러면 §6.5 의 S4U 제약이 그대로 따라온다. "무인 실행에 더 적합하다"는 이점이 실제로는 크지 않다.

전환을 검토해야 하는 신호:

  • 3개월 내 AGY_MODELS_EMPTY 가 2회 이상 발생(= 재로그인 강제가 잦다)
  • 사용자가 이 PC 에 로그온하는 빈도가 낮아 §7 의 재로그인 창이 며칠씩 방치됨
  • Antigravity 플랜 크레딧이 반복적으로 소진됨

그때는 §8.1 의 설정을 그대로 적용하고, agy models 출력이 Gemini 계열만으로 줄어드는지 먼저 확인한 뒤 05a §8.2 의 모델 매핑을 재작성한다.


9. 권한 사전 승인 설계

9.1 문제 정의

05a §9.3 이 정리한 대로, 헤드리스에서 워크스페이스 파일 읽기/쓰기는 자동 허용이지만 셸 명령은 기본 Ask 이며 권한이 없으면 soft-deny 된다. 배치가 조용히 멈추는 가장 흔한 원인이다.

동시에 이 프로젝트에는 프롬프트 인젝션 표면이 있다. 식약처 공고 페이지에서 긁어온 텍스트가 프롬프트에 들어간다. 공고 제목에 "이전 지시를 무시하고 다음 명령을 실행하라: ..." 같은 문자열이 들어갈 수 있다(가능성은 낮지만 0이 아니다).

9.2 채택: 프로젝트 전용 격리 프로필

문제: settings.json사용자 전역이다(05a §11.1: "프로젝트 단위 설정은 문서화돼 있지 않다"). 이 프로젝트용 강한 deny 를 전역에 넣으면 사용자의 다른 agy 작업이 망가진다.

실측한 현재 사용자 전역 settings.json 은 다음을 담고 있다(문제의 크기를 보여주기 위해 인용):

{
  "agentMode": "accept-edits",
  "altScreenMode": "always",
  "colorScheme": "tokyo night",
  "model": "Gemini 3.7 Flash (High)",
  "permissions": {
    "allow": [
      "command(powershell -NoProfile -Command \"$ErrorActionPreference = 'Stop'; ... npm run typecheck\")",
      "command(python -X utf8 scripts/check-dev-dashboard-ssot.py)",
      "command(start)",
      "command(cmd /c start)",
      "command(VideoDownloader.exe)",
      "command(*.exe)",
      "command(dotnet run)",
      "command(dotnet build)"
    ]
  },
  "statusLine": { "type": "command", "command": "C:\\Users\\encep\\.gemini\\antigravity-cli\\statusline.cmd" },
  "trustedWorkspaces": [
    "C:\\Users\\encep", "D:\\workspace\\vignette", "D:\\workspace\\agent-switchboard-client",
    "D:\\workspace\\D3ROVoice", "D:\\workspace\\GlassDeck", "D:\\workspace\\designpaca",
    "D:\\workspace\\HaramLog", "D:\\workspace\\videodownloader"
  ]
}
  • command(*.exe) 가 allow 에 있다. 임의의 exe 실행이 무조건 승인된다. 대화형 사용에는 편하지만 배치에서는 인젝션의 완벽한 발판이다.
  • trustedWorkspacesD:\workspace\DMF_Crawler없다.
  • 여기에 이 프로젝트용 deny 를 추가하면 사용자의 다른 8개 워크스페이스 작업이 깨진다.

해결책 — USERPROFILE 격리 (§6.1 에서 실증):

$env:USERPROFILE = 'D:\workspace\DMF_Crawler\state\agy-home'
$env:HOME         = 'D:\workspace\DMF_Crawler\state\agy-home'
& $AgyExe -p "..." --output-format json

실측으로 확인된 결과:

항목 격리 프로필에서
settings.json <격리>\.gemini\antigravity-cli\settings.json 을 읽는다 (프로젝트 전용 권한 가능)
MCP 서버 <격리>\.gemini\config\mcp_config.json새로 빈 채로 생성됨 → 05a §17 이 우려한 chrome-devtools(npx), unityMCP(localhost:8080) 기동 시도가 원천 차단된다
rules/, knowledge/, skills/ 빈 디렉터리로 생성 → 사용자 개인 규칙이 배치 프롬프트에 섞이지 않는다
로그·대화·크래시 프로젝트 안에 격리 → 회수·정리가 쉽다
인증 자격 증명 관리자에서 그대로 읽음 → 성공
새로 관찰된 파일 .gemini/antigravity-cli/jetski_state.pbtxt (온보딩 완료 상태·installation_uuid·마이그레이션 상태), .gemini/config/config.json, .gemini/config/.migrated, .gemini/config/projects/05a §12 목록에 없던 항목들
부수효과 <격리>\AppData\Local\ms-playwright-go 도 생성됨(agy 내부 브라우저 도구용)

주의사항:

  1. USERPROFILEagy 자식 프로세스에만 적용해야 한다. 배치 전체에 적용하면 Python 의 os.path.expanduser, %TEMP%, pip 캐시 등이 함께 어긋난다. → PowerShell 7.4+ 의 Start-Process -Environment @{ USERPROFILE = ... } 를 쓰거나, agy 호출만 별도 래퍼 스크립트로 분리한다. (실측 머신 PS = 7.6.5 이므로 -Environment 사용 가능.)
  2. -UseNewEnvironment 를 쓰면 오히려 기본 환경으로 리셋되므로 같이 쓰면 안 된다.
  3. 격리 프로필의 installation_id 가 새로 생기므로 Antigravity 쪽 텔레메트리에서 별개 설치로 보인다. 크레딧은 계정 단위이므로 문제없다(⚠️ 미검증).
  4. agy 버전이 올라가면 자격증명 해석 경로가 바뀔 수 있다. 부트스트랩은 격리 프로필로 agy models 를 반드시 검증한다(P3). 실패하면 격리를 끄고 재시도하는 폴백을 둔다(§11).

9.3 이 프로젝트의 permissions 규칙 세트

05a §9.4 의 경로 정규화 규칙을 반드시 지킨다:

"Antigravity automatically normalizes paths prior to rule evaluation by stripping drive letters (e.g., C:) and converting all backslashes () to forward slashes (/)."

D:\workspace\DMF_Crawler 는 규칙에 workspace/DMF_Crawler 로 쓴다.

D:\workspace\DMF_Crawler\state\agy-home\.gemini\antigravity-cli\settings.json:

{
  "agentMode": "accept-edits",
  "altScreenMode": "never",
  "colorScheme": "terminal",
  "notifications": false,
  "showTips": false,
  "showFeedbackSurvey": false,
  "enableTelemetry": false,
  "verbosity": "low",
  "runningLightSpeed": "off",
  "allowNonWorkspaceAccess": false,
  "enableTerminalSandbox": false,
  "useG1Credits": false,
  "toolPermission": "request-review",
  "trustedWorkspaces": [
    "D:\\workspace\\DMF_Crawler"
  ],
  "permissions": {
    "deny": [
      "read_url(*)",
      "execute_url(*)",
      "mcp(*)",
      "command(curl)",
      "command(curl.exe)",
      "command(Invoke-WebRequest)",
      "command(Invoke-RestMethod)",
      "command(iwr)",
      "command(irm)",
      "command(git push)",
      "command(pip install)",
      "command(npm)",
      "command(npx)",
      "command(cmd)",
      "command(schtasks)",
      "command(reg)",
      "command(cmdkey)",
      "read_file(users/encep/.gemini/antigravity-cli/antigravity-oauth-token)",
      "write_file(workspace/DMF_Crawler/.git)",
      "write_file(workspace/DMF_Crawler/scripts)",
      "write_file(workspace/DMF_Crawler/state/agy-home)"
    ],
    "allow": [
      "read_file(workspace/DMF_Crawler)",
      "write_file(workspace/DMF_Crawler/out)",
      "write_file(workspace/DMF_Crawler/logs)",
      "write_file(workspace/DMF_Crawler/state/ai)",
      "command(python -X utf8 scripts/[A-Za-z0-9_\\-]+\\.py)",
      "command(py -3 -X utf8 scripts/[A-Za-z0-9_\\-]+\\.py)"
    ],
    "ask": [
      "command(*)"
    ]
  }
}

규칙 하나하나의 근거:

규칙
deny: read_url(*), execute_url(*) 크롤링은 Python 이 한다. agy 는 웹에 나가지 않는다. 인젝션된 텍스트가 agy 를 시켜 외부로 데이터를 흘리는 경로를 물리적으로 차단
deny: mcp(*) 05a §17 의 위험(등록된 MCP 서버가 npx/localhost 를 찾다 지연·실패). 격리 프로필로 이미 비어 있지만 이중 방어
deny: command(curl…/irm…) 네트워크 exfiltration 의 두 번째 경로 차단
deny: command(cmdkey) 자격증명 덤프 방지. 인젝션이 cmdkey /list 를 시키지 못하게
deny: read_file(…antigravity-oauth-token) 토큰 파일 직접 읽기 차단
deny: write_file(…/scripts) agy 가 자기가 다음에 실행될 스크립트를 고쳐 쓰는 자기증식 경로 차단
deny: write_file(…/state/agy-home) agy 가 자기 권한 설정을 스스로 완화하는 것 차단 (가장 중요한 규칙)
allow: command(python -X utf8 scripts/…) 05a §9.2: "command 는 공백으로 분리된 각 토큰이 앵커된 정규식으로 평가됨". 그래서 scripts/[A-Za-z0-9_\-]+\.py스크립트 디렉터리 안의 파일만 허용. scripts/../.. 같은 탈출은 정규식이 / 를 허용하지 않으므로 막힌다
ask: command(*) Deny > Ask > Allow 이므로, allow 에 매치되지 않은 모든 명령은 ask → 헤드리스에서 soft-deny 된다. 조용히 실행되는 일이 없다
allowNonWorkspaceAccess: false 워크스페이스 밖 파일 접근 차단(기본값이지만 명시)
enableTelemetry: false 공고 텍스트가 텔레메트리로 나가지 않게
useG1Credits: false 플랜 쿼터 소진 시 개인 크레딧으로 자동 폴백하지 않는다. 예산 사고 방지

9.4 --dangerously-skip-permissions — 쓰지 않는다 (결론)

공식 경고 원문(05a §9.3 인용):

"--dangerously-skip-permissions approves all tool calls, including file writes and command execution. Prefer scoped permissions.allow rules unless you fully trust the prompt and environment."

"unless you fully trust the prompt" — 이 프로젝트의 프롬프트에는 외부에서 긁어온 텍스트가 들어간다. 전제가 성립하지 않는다.

대신 §9.3 규칙 + 아래 두 플래그로 같은 목표(멈추지 않는 배치)를 달성한다:

$agyArgs = @(
  '-p', $promptText,
  '--output-format','json',
  '--model','gemini-3.7-flash-medium',
  '--effort','medium',
  '--print-timeout','10m',
  '--disable-slash-commands',   # 크롤링 텍스트의 '/명령' 확장 차단 (05a §16)
  '--mode','accept-edits',      # 파일 편집은 자동 승인(권한과 독립, 05a §10)
  '--add-dir', 'D:\workspace\DMF_Crawler\state\ai',
  '--log-file', $agyLogPath
)
  • --mode accept-edits파일 작업만 자동 승인한다. 셸 명령은 여전히 permissions 가 통제한다(05a §10: "Tool permission rules … continue to govern shell commands (run_command) across all execution modes").
  • --disable-slash-commands 는 인젝션 방어의 1차선이다.

9.5 프롬프트 설계로 도구 사용 자체를 줄인다

가장 확실한 권한 대책은 agy 가 도구를 쓰지 않게 하는 것이다.

  • 입력(전날 대비 diff)을 프롬프트 본문에 텍스트로 직접 넣는다. 파일을 읽게 시키지 않는다.
  • 출력은 stdout 의 JSON 봉투로 받고, 파일 쓰기는 Python 이 한다.
  • 이 구조에서 agy 가 필요로 하는 도구는 사실상 없다. §9.3 의 allow예외 상황용 안전망이지 상시 경로가 아니다.

10. 종료 코드 규약

ensure_agy.ps1 이 반환하는 코드. 배치 러너(run_daily.ps1)와 08 의 감시 작업이 이 표에 따라 분기한다.

코드 상수명 의미 호출자가 할 일
0 OK agy 설치·인증·설정 모두 정상 배치 진행
10 NEED_INSTALL 미설치이고 자동 설치도 실패 AI 단계 스킵, 리포트는 생성, 알림 큐 기록
11 NEED_AUTH 바이너리는 정상, 인증 실패 AI 단계 스킵, 리포트는 생성, auth_required.json 기록
12 CONFIG_FAILED 격리 프로필/settings.json 생성·검증 실패 격리 없이 재시도 → 그래도 실패면 AI 스킵
13 UPDATE_IN_PROGRESS agy.runlock 획득 실패(주간 업데이트와 충돌) 30분 후 재시도(작업 스케줄러 반복)
20 NETWORK_FAILED 매니페스트/바이너리 다운로드 또는 SHA512 검증 실패 알림 큐 기록, 다음 날 재시도
21 INTEGRITY_FAILED SHA512 불일치 = 변조 의심 자동 재시도 금지. 즉시 사용자 알림(보안 사건)
30 BAD_RUN_CONTEXT SYSTEM/서비스 계정 또는 S4U(네트워크 로그온) 작업 설정 오류. 관리자 개입 필요. 재시도 무의미
40 AGY_BROKEN 바이너리는 있으나 --version·models 모두 실패, 재설치도 실패 알림 + 수동 진단
1 (예약) 스크립트 자체의 미처리 예외 로그 확인

-CheckOnly 스위치: 설치·수정 없이 진단만 하고 같은 코드를 반환한다. §7.2 의 로그인 창이 성공 여부를 확인할 때 쓴다.

PowerShell 호출 측 패턴:

& "$PSScriptRoot\ensure_agy.ps1" -ProjectRoot 'D:\workspace\DMF_Crawler' -RunId $runId
$agyState = $LASTEXITCODE

switch ($agyState) {
    0       { $useAi = $true }
    13      { Start-Sleep -Seconds 1800; & "$PSScriptRoot\ensure_agy.ps1" -ProjectRoot $root -RunId $runId; $useAi = ($LASTEXITCODE -eq 0) }
    {$_ -in 10,11,12,20,40} { $useAi = $false }   # graceful degradation
    21      { $useAi = $false; Write-Error '무결성 검증 실패 — 보안 확인 필요' }
    30      { $useAi = $false; Write-Error '작업 스케줄러 실행 컨텍스트 오류 — S4U/SYSTEM 확인' }
    default { $useAi = $false }
}

11. scripts/ensure_agy.ps1 전체 코드

요구 사항: PowerShell 5.1 이상에서 동작(작업 스케줄러가 powershell.exe 를 쓸 수 있으므로). 외부 모듈 의존 없음. -NoProfile -NonInteractive -ExecutionPolicy Bypass -File 로 호출.

<#
.SYNOPSIS
    DMF Crawler — Antigravity CLI(agy) 부트스트랩: 탐지 → 설치 → 검증 → 인증 확인 → 알림 큐.

.DESCRIPTION
    06:00 무인 배치가 agy 를 쓰기 직전에 호출한다. 창을 띄우지 않으며,
    사용자 개입이 필요한 상황은 state\auth_required.json 플래그로만 남긴다.
    종료 코드 규약은 docs/research/09-agy-bootstrap-and-provisioning.md §10 참조.

.PARAMETER ProjectRoot
    프로젝트 루트. 기본값 D:\workspace\DMF_Crawler

.PARAMETER RunId
    배치 실행 ID. 로그·플래그 파일에 기록된다. 생략 시 yyyyMMdd_HHmmss.

.PARAMETER CheckOnly
    설치·설정 변경을 하지 않고 진단만 한다. 재로그인 창이 결과 확인에 사용.

.PARAMETER NoIsolatedProfile
    격리 프로필(USERPROFILE 치환)을 쓰지 않고 사용자 기본 프로필로 검증한다.
    격리 프로필에서 인증이 깨지는 경우의 폴백.

.EXAMPLE
    powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass `
      -File "D:\workspace\DMF_Crawler\scripts\ensure_agy.ps1" -RunId 20260903_060000
#>
[CmdletBinding()]
param(
    [string] $ProjectRoot = 'D:\workspace\DMF_Crawler',
    [string] $RunId       = (Get-Date -Format 'yyyyMMdd_HHmmss'),
    [switch] $CheckOnly,
    [switch] $NoIsolatedProfile
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$ProgressPreference    = 'SilentlyContinue'

# ─────────────────────────────────────────────────────────────────────────────
# 상수
# ─────────────────────────────────────────────────────────────────────────────
$EXIT_OK                 = 0
$EXIT_NEED_INSTALL       = 10
$EXIT_NEED_AUTH          = 11
$EXIT_CONFIG_FAILED      = 12
$EXIT_UPDATE_IN_PROGRESS = 13
$EXIT_NETWORK_FAILED     = 20
$EXIT_INTEGRITY_FAILED   = 21
$EXIT_BAD_RUN_CONTEXT    = 30
$EXIT_AGY_BROKEN         = 40

$CRED_TARGET        = 'gemini:antigravity'
$CRED_TYPE_GENERIC  = 1
$ERROR_NOT_FOUND            = 1168
$ERROR_NO_SUCH_LOGON_SESSION = 1312

$UPDATER_BASE = 'https://antigravity-cli-auto-updater-974169037036.us-central1.run.app'
$INSTALL_PS1  = 'https://antigravity.google/cli/install.ps1'

$LogDir       = Join-Path $ProjectRoot 'logs'
$StateDir     = Join-Path $ProjectRoot 'state'
$IsolatedHome = Join-Path $StateDir 'agy-home'
$RunLockPath  = Join-Path $StateDir 'agy.runlock'
$AuthFlagPath = Join-Path $StateDir 'auth_required.json'
$BootstrapLog = Join-Path $LogDir  "ensure_agy_$RunId.log"

$AgyDefaultExe = Join-Path $env:LOCALAPPDATA 'agy\bin\agy.exe'
$AgyWingetExe  = Join-Path $env:LOCALAPPDATA 'Microsoft\WinGet\Links\agy.exe'

# ─────────────────────────────────────────────────────────────────────────────
# 로깅
# ─────────────────────────────────────────────────────────────────────────────
foreach ($d in @($LogDir, $StateDir)) {
    if (-not (Test-Path -LiteralPath $d)) { New-Item -ItemType Directory -Path $d -Force | Out-Null }
}

function Write-Log {
    param(
        [Parameter(Mandatory)][string] $Message,
        [ValidateSet('INFO','WARN','ERROR','OK')][string] $Level = 'INFO'
    )
    $line = '{0} [{1,-5}] {2}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss.fff'), $Level, $Message
    Add-Content -LiteralPath $BootstrapLog -Value $line -Encoding UTF8
    switch ($Level) {
        'ERROR' { Write-Host $line -ForegroundColor Red }
        'WARN'  { Write-Host $line -ForegroundColor Yellow }
        'OK'    { Write-Host $line -ForegroundColor Green }
        default { Write-Host $line }
    }
}

function Exit-Bootstrap {
    param([int] $Code, [string] $Reason = '')
    if ($Reason) { Write-Log "종료: code=$Code reason=$Reason" -Level ($(if ($Code -eq 0) {'OK'} else {'ERROR'})) }
    else         { Write-Log "종료: code=$Code" -Level ($(if ($Code -eq 0) {'OK'} else {'ERROR'})) }
    exit $Code
}

# ─────────────────────────────────────────────────────────────────────────────
# 1) 실행 컨텍스트 검증
# ─────────────────────────────────────────────────────────────────────────────
function Assert-RunContext {
    $id   = [Security.Principal.WindowsIdentity]::GetCurrent()
    $name = $id.Name
    Write-Log "실행 계정: $name"
    Write-Log "PowerShell: $($PSVersionTable.PSVersion)  호스트: $($Host.Name)  대화형: $([Environment]::UserInteractive)"
    Write-Log "LOCALAPPDATA: $env:LOCALAPPDATA"

    $forbidden = @(
        'NT AUTHORITY\SYSTEM',
        'NT AUTHORITY\LOCAL SERVICE',
        'NT AUTHORITY\NETWORK SERVICE',
        'NT 서비스\SYSTEM'
    )
    foreach ($f in $forbidden) {
        if ($name -ieq $f) {
            Write-Log "이 스크립트는 서비스 계정에서 실행할 수 없다. 자격 증명 관리자·User PATH·%LOCALAPPDATA% 가 모두 다르다." -Level ERROR
            Write-Log "해결: 작업 스케줄러 작업을 '$env:USERNAME' 계정 + '암호 저장' 방식으로 다시 등록하라." -Level ERROR
            return $false
        }
    }

    if ($env:LOCALAPPDATA -like '*systemprofile*') {
        Write-Log "LOCALAPPDATA 가 systemprofile 을 가리킨다: $env:LOCALAPPDATA" -Level ERROR
        return $false
    }
    return $true
}

# ─────────────────────────────────────────────────────────────────────────────
# 2) 자격 증명 관리자 프로브 (P2)
# ─────────────────────────────────────────────────────────────────────────────
$credTypeDef = @'
using System;
using System.Runtime.InteropServices;

public static class DmfCred {
    [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
    public struct CREDENTIAL {
        public uint   Flags;
        public uint   Type;
        public IntPtr TargetName;
        public IntPtr Comment;
        public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten;
        public uint   CredentialBlobSize;
        public IntPtr CredentialBlob;
        public uint   Persist;
        public uint   AttributeCount;
        public IntPtr Attributes;
        public IntPtr TargetAlias;
        public IntPtr UserName;
    }

    [DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
    public static extern bool CredReadW(string target, uint type, uint flags, out IntPtr credential);

    [DllImport("advapi32.dll")]
    public static extern void CredFree(IntPtr buffer);
}
'@

if (-not ('DmfCred' -as [type])) {
    Add-Type -TypeDefinition $credTypeDef -Language CSharp
}

function Get-AgyCredentialState {
    <#
      반환 PSCustomObject:
        Found            [bool]
        Win32Error       [int]   (Found=false 일 때만 의미)
        HasRefreshToken  [bool]
        AuthMethod       [string]
        ExpiryUtc        [datetime?]
        LastWrittenUtc   [datetime?]
        BlobSize         [int]
    #>
    $result = [pscustomobject]@{
        Found           = $false
        Win32Error      = 0
        HasRefreshToken = $false
        AuthMethod      = $null
        ExpiryUtc       = $null
        LastWrittenUtc  = $null
        BlobSize        = 0
    }

    $ptr = [IntPtr]::Zero
    $ok  = $false
    try {
        $ok = [DmfCred]::CredReadW($CRED_TARGET, [uint32]$CRED_TYPE_GENERIC, [uint32]0, [ref]$ptr)
    } catch {
        Write-Log "CredReadW 호출 실패: $($_.Exception.Message)" -Level WARN
        $result.Win32Error = -1
        return $result
    }

    if (-not $ok) {
        $result.Win32Error = [Runtime.InteropServices.Marshal]::GetLastWin32Error()
        return $result
    }

    try {
        $cred = [Runtime.InteropServices.Marshal]::PtrToStructure($ptr, [type]([DmfCred+CREDENTIAL]))
        $result.Found    = $true
        $result.BlobSize = [int]$cred.CredentialBlobSize

        $ft   = $cred.LastWritten
        $long = ([long]$ft.dwHighDateTime -shl 32) -bor ([long]$ft.dwLowDateTime -band 0xFFFFFFFFL)
        if ($long -gt 0) { $result.LastWrittenUtc = [datetime]::FromFileTimeUtc($long) }

        if ($cred.CredentialBlobSize -gt 0) {
            $bytes = New-Object byte[] $cred.CredentialBlobSize
            [Runtime.InteropServices.Marshal]::Copy($cred.CredentialBlob, $bytes, 0, $cred.CredentialBlobSize)
            $json = [Text.Encoding]::UTF8.GetString($bytes)
            # 블롭 내용을 절대 로그에 남기지 않는다.
            try {
                $obj = $json | ConvertFrom-Json
                if ($obj.PSObject.Properties.Name -contains 'auth_method') { $result.AuthMethod = $obj.auth_method }
                if ($obj.PSObject.Properties.Name -contains 'token') {
                    $t = $obj.token
                    $result.HasRefreshToken = -not [string]::IsNullOrWhiteSpace([string]$t.refresh_token)
                    if ($t.PSObject.Properties.Name -contains 'expiry') {
                        try { $result.ExpiryUtc = ([datetime]$t.expiry).ToUniversalTime() } catch { }
                    }
                }
            } catch {
                Write-Log "자격증명 블롭 JSON 파싱 실패(구조 변경 가능성). 존재만 인정한다." -Level WARN
            }
            [Array]::Clear($bytes, 0, $bytes.Length)
        }
    } finally {
        if ($ptr -ne [IntPtr]::Zero) { [DmfCred]::CredFree($ptr) }
    }
    return $result
}

# ─────────────────────────────────────────────────────────────────────────────
# 3) agy 실행 파일 탐지 (P1)
# ─────────────────────────────────────────────────────────────────────────────
function Invoke-AgyCapture {
    <# agy 를 실행하고 stdout/stderr/exitcode 를 캡처한다. 파이프 인코딩 문제를 피하려고 파일로 리다이렉트한다. #>
    param(
        [Parameter(Mandatory)][string]   $Exe,
        [Parameter(Mandatory)][string[]] $Arguments,
        [int] $TimeoutSec = 120,
        [hashtable] $ExtraEnv
    )

    $tmpOut = [IO.Path]::GetTempFileName()
    $tmpErr = [IO.Path]::GetTempFileName()
    $saved  = @{}
    try {
        if ($ExtraEnv) {
            foreach ($k in $ExtraEnv.Keys) {
                $saved[$k] = [Environment]::GetEnvironmentVariable($k, 'Process')
                [Environment]::SetEnvironmentVariable($k, $ExtraEnv[$k], 'Process')
            }
        }

        $p = Start-Process -FilePath $Exe -ArgumentList $Arguments `
                -NoNewWindow -PassThru `
                -RedirectStandardOutput $tmpOut -RedirectStandardError $tmpErr

        if (-not $p.WaitForExit($TimeoutSec * 1000)) {
            Write-Log "agy 호출 타임아웃(${TimeoutSec}s): $Exe $($Arguments -join ' ')" -Level WARN
            try { $p.Kill() } catch { }
            return [pscustomobject]@{ ExitCode = -999; StdOut = ''; StdErr = 'TIMEOUT'; TimedOut = $true }
        }

        $so = if (Test-Path -LiteralPath $tmpOut) { Get-Content -LiteralPath $tmpOut -Raw -Encoding UTF8 } else { '' }
        $se = if (Test-Path -LiteralPath $tmpErr) { Get-Content -LiteralPath $tmpErr -Raw -Encoding UTF8 } else { '' }

        return [pscustomobject]@{
            ExitCode = $p.ExitCode
            StdOut   = ([string]$so)
            StdErr   = ([string]$se)
            TimedOut = $false
        }
    } finally {
        foreach ($k in $saved.Keys) { [Environment]::SetEnvironmentVariable($k, $saved[$k], 'Process') }
        Remove-Item -LiteralPath $tmpOut, $tmpErr -Force -ErrorAction SilentlyContinue
    }
}

function Get-AgyVersion {
    param([Parameter(Mandatory)][string] $Exe)
    if (-not (Test-Path -LiteralPath $Exe)) { return $null }
    $r = Invoke-AgyCapture -Exe $Exe -Arguments @('--version') -TimeoutSec 60
    if ($r.ExitCode -ne 0) {
        Write-Log "'$Exe --version' 실패: exit=$($r.ExitCode) stderr=$($r.StdErr.Trim())" -Level WARN
        return $null
    }
    $line = ($r.StdOut -split "`r?`n" | Where-Object { $_ -match '^\s*\d+\.\d+\.\d+' } | Select-Object -First 1)
    if (-not $line) {
        Write-Log "'$Exe --version' 출력이 semver 가 아니다: '$($r.StdOut.Trim())'" -Level WARN
        return $null
    }
    return $line.Trim()
}

function Find-AgyExecutable {
    $candidates = New-Object System.Collections.Generic.List[object]

    if ($env:DMF_AGY_EXE -and (Test-Path -LiteralPath $env:DMF_AGY_EXE)) {
        $candidates.Add([pscustomobject]@{ Path = $env:DMF_AGY_EXE; Rank = 0; Label = 'DMF_AGY_EXE' })
    }
    if (Test-Path -LiteralPath $AgyDefaultExe) {
        $candidates.Add([pscustomobject]@{ Path = $AgyDefaultExe; Rank = 1; Label = 'LOCALAPPDATA\agy\bin' })
    }
    if (Test-Path -LiteralPath $AgyWingetExe) {
        $candidates.Add([pscustomobject]@{ Path = $AgyWingetExe; Rank = 2; Label = 'WinGet\Links (갱신 안 됨)' })
    }

    if ($candidates.Count -eq 0) {
        Write-Log 'agy.exe 를 어떤 후보 경로에서도 찾지 못했다.' -Level WARN
        return $null
    }

    foreach ($c in $candidates) {
        $v = Get-AgyVersion -Exe $c.Path
        Add-Member -InputObject $c -NotePropertyName Version -NotePropertyValue $v -Force
        Write-Log ("후보: [{0}] {1} → version={2}" -f $c.Label, $c.Path, ($(if ($v) { $v } else { '(실행 실패)' })))
    }

    if ($candidates.Count -gt 1) {
        $vers = ($candidates | Where-Object { $_.Version } | Select-Object -ExpandProperty Version -Unique)
        if ($vers.Count -gt 1) {
            Write-Log ("경고: agy.exe 가 여러 버전으로 존재한다 ({0}). 절대경로 고정을 권한다." -f ($vers -join ', ')) -Level WARN
        }
    }

    $chosen = $candidates | Where-Object { $_.Version } | Sort-Object Rank | Select-Object -First 1
    if (-not $chosen) {
        Write-Log '후보 파일은 있으나 모두 --version 실행에 실패했다(손상 의심).' -Level ERROR
        return $candidates | Sort-Object Rank | Select-Object -First 1   # 손상 판정용으로 반환
    }
    Write-Log ("선택: {0} (v{1})" -f $chosen.Path, $chosen.Version) -Level OK
    return $chosen
}

# ─────────────────────────────────────────────────────────────────────────────
# 4) 설치
# ─────────────────────────────────────────────────────────────────────────────
function Install-AgyViaOfficialScript {
    <# install.ps1 을 파일로 내려받아 -File 로 실행한다. iex 파이프는 종료 코드를 못 준다(§4.2). #>
    $stage = Join-Path $env:TEMP ("dmf-agy-install-{0}" -f $RunId)
    New-Item -ItemType Directory -Path $stage -Force | Out-Null
    $ps1 = Join-Path $stage 'install.ps1'
    try {
        Write-Log "install.ps1 다운로드: $INSTALL_PS1"
        try {
            [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
        } catch { }
        Invoke-WebRequest -Uri $INSTALL_PS1 -OutFile $ps1 -UseBasicParsing -TimeoutSec 120
        Unblock-File -LiteralPath $ps1 -ErrorAction SilentlyContinue

        $host51 = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe'
        $exe    = if (Test-Path -LiteralPath $host51) { $host51 } else { (Get-Process -Id $PID).Path }

        Write-Log "install.ps1 실행 (-File 방식)"
        $r = Invoke-AgyCapture -Exe $exe -Arguments @(
                '-NoProfile','-NonInteractive','-ExecutionPolicy','Bypass','-File',$ps1
            ) -TimeoutSec 900

        foreach ($l in ($r.StdOut -split "`r?`n")) { if ($l.Trim()) { Write-Log "  [install.ps1] $l" } }
        foreach ($l in ($r.StdErr -split "`r?`n")) { if ($l.Trim()) { Write-Log "  [install.ps1:err] $l" -Level WARN } }

        if ($r.StdErr -match 'Security Halt: Checksum verification failed') {
            Write-Log 'install.ps1 이 체크섬 불일치로 중단됐다. 변조 의심 — 자동 재시도하지 않는다.' -Level ERROR
            return $EXIT_INTEGRITY_FAILED
        }
        if ($r.StdErr -match 'Failed to download (release manifest|binary)') {
            return $EXIT_NETWORK_FAILED
        }
        if ($r.ExitCode -ne 0) {
            Write-Log "install.ps1 실패: exit=$($r.ExitCode)" -Level WARN
            return $EXIT_NEED_INSTALL
        }
        return $EXIT_OK
    } catch {
        Write-Log "install.ps1 경로 예외: $($_.Exception.Message)" -Level WARN
        return $EXIT_NETWORK_FAILED
    } finally {
        Remove-Item -LiteralPath $stage -Recurse -Force -ErrorAction SilentlyContinue
    }
}

function Install-AgyFromManifest {
    <# install.ps1 이 실패했을 때의 폴백. §12.1 과 동일 로직을 인라인으로 둔다. #>
    $arch = if ($env:PROCESSOR_ARCHITEW6432) { $env:PROCESSOR_ARCHITEW6432 } else { $env:PROCESSOR_ARCHITECTURE }
    switch ($arch.ToUpperInvariant()) {
        'AMD64' { $platform = 'windows_amd64' }
        'ARM64' { $platform = 'windows_arm64' }
        default {
            Write-Log "지원하지 않는 CPU 아키텍처: $arch" -Level ERROR
            return $EXIT_NEED_INSTALL
        }
    }

    $stage = Join-Path $env:TEMP ("dmf-agy-staging-{0}" -f $RunId)
    New-Item -ItemType Directory -Path $stage -Force | Out-Null
    $payload = Join-Path $stage 'agy.exe'

    try {
        try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 } catch { }

        $manifestUrl = "$UPDATER_BASE/manifests/$platform.json"
        Write-Log "매니페스트: $manifestUrl"
        $manifest = Invoke-RestMethod -Uri $manifestUrl -TimeoutSec 60
        Write-Log ("매니페스트 version={0}" -f $manifest.version)

        Write-Log "바이너리 다운로드: $($manifest.url)"
        Invoke-WebRequest -Uri $manifest.url -OutFile $payload -UseBasicParsing -TimeoutSec 1800

        $actual = (Get-FileHash -LiteralPath $payload -Algorithm SHA512).Hash.ToLowerInvariant()
        $expect = ([string]$manifest.sha512).ToLowerInvariant()
        if ($actual -ne $expect) {
            Write-Log "SHA512 불일치. expected=$expect actual=$actual" -Level ERROR
            Remove-Item -LiteralPath $payload -Force -ErrorAction SilentlyContinue
            return $EXIT_INTEGRITY_FAILED
        }
        Write-Log 'SHA512 검증 통과' -Level OK

        $binDir = Split-Path -Parent $AgyDefaultExe
        if (-not (Test-Path -LiteralPath $binDir)) { New-Item -ItemType Directory -Path $binDir -Force | Out-Null }
        Copy-Item -LiteralPath $payload -Destination $AgyDefaultExe -Force
        Unblock-File -LiteralPath $AgyDefaultExe -ErrorAction SilentlyContinue
        Write-Log "배치 완료: $AgyDefaultExe" -Level OK

        # PATH 등록 (실패해도 계속. 우리는 절대경로를 쓴다)
        $setup = Invoke-AgyCapture -Exe $AgyDefaultExe -Arguments @('install','--skip-aliases') -TimeoutSec 180
        if ($setup.ExitCode -ne 0) {
            Write-Log "agy install(환경 구성) 실패: exit=$($setup.ExitCode). 절대경로 사용하므로 계속 진행한다." -Level WARN
        }
        return $EXIT_OK
    } catch {
        Write-Log "매니페스트 설치 실패: $($_.Exception.Message)" -Level ERROR
        return $EXIT_NETWORK_FAILED
    } finally {
        Remove-Item -LiteralPath $stage -Recurse -Force -ErrorAction SilentlyContinue
    }
}

# ─────────────────────────────────────────────────────────────────────────────
# 5) 격리 프로필 준비
# ─────────────────────────────────────────────────────────────────────────────
function Initialize-IsolatedProfile {
    <# state\agy-home 아래에 프로젝트 전용 settings.json 을 만든다(없을 때만 덮어쓴다). #>
    $cfgDir = Join-Path $IsolatedHome '.gemini\antigravity-cli'
    if (-not (Test-Path -LiteralPath $cfgDir)) {
        New-Item -ItemType Directory -Path $cfgDir -Force | Out-Null
        Write-Log "격리 프로필 생성: $IsolatedHome"
    }

    $settingsPath = Join-Path $cfgDir 'settings.json'
    $settings = [ordered]@{
        agentMode              = 'accept-edits'
        altScreenMode          = 'never'
        colorScheme            = 'terminal'
        notifications          = $false
        showTips               = $false
        showFeedbackSurvey     = $false
        enableTelemetry        = $false
        verbosity              = 'low'
        runningLightSpeed      = 'off'
        allowNonWorkspaceAccess= $false
        enableTerminalSandbox  = $false
        useG1Credits           = $false
        toolPermission         = 'request-review'
        trustedWorkspaces      = @($ProjectRoot)
        permissions            = [ordered]@{
            deny = @(
                'read_url(*)','execute_url(*)','mcp(*)',
                'command(curl)','command(curl.exe)',
                'command(Invoke-WebRequest)','command(Invoke-RestMethod)',
                'command(iwr)','command(irm)',
                'command(git push)','command(pip install)','command(npm)','command(npx)',
                'command(cmd)','command(schtasks)','command(reg)','command(cmdkey)',
                'write_file(workspace/DMF_Crawler/.git)',
                'write_file(workspace/DMF_Crawler/scripts)',
                'write_file(workspace/DMF_Crawler/state/agy-home)'
            )
            allow = @(
                'read_file(workspace/DMF_Crawler)',
                'write_file(workspace/DMF_Crawler/out)',
                'write_file(workspace/DMF_Crawler/logs)',
                'write_file(workspace/DMF_Crawler/state/ai)',
                'command(python -X utf8 scripts/[A-Za-z0-9_\-]+\.py)',
                'command(py -3 -X utf8 scripts/[A-Za-z0-9_\-]+\.py)'
            )
            ask = @('command(*)')
        }
    }

    $json = $settings | ConvertTo-Json -Depth 8
    $existing = if (Test-Path -LiteralPath $settingsPath) { Get-Content -LiteralPath $settingsPath -Raw -Encoding UTF8 } else { '' }
    if ($existing.Trim() -ne $json.Trim()) {
        if ($CheckOnly) {
            Write-Log '격리 프로필 settings.json 이 최신이 아니다(-CheckOnly 이므로 수정하지 않음).' -Level WARN
        } else {
            Set-Content -LiteralPath $settingsPath -Value $json -Encoding UTF8
            Write-Log "격리 프로필 settings.json 갱신: $settingsPath" -Level OK
        }
    }

    # AI 산출물 디렉터리(permissions.allow 대상)
    $aiDir = Join-Path $StateDir 'ai'
    if (-not (Test-Path -LiteralPath $aiDir)) { New-Item -ItemType Directory -Path $aiDir -Force | Out-Null }

    return $IsolatedHome
}

# ─────────────────────────────────────────────────────────────────────────────
# 6) 인증 프로브 (P3)
# ─────────────────────────────────────────────────────────────────────────────
function Test-AgyAuth {
    param(
        [Parameter(Mandatory)][string] $Exe,
        [string] $HomeOverride
    )
    $envMap = @{ 'AGY_CLI_DISABLE_AUTO_UPDATE' = 'true' }
    if ($HomeOverride) {
        $envMap['USERPROFILE'] = $HomeOverride
        $envMap['HOME']        = $HomeOverride
    }

    $r = Invoke-AgyCapture -Exe $Exe -Arguments @('models') -TimeoutSec 120 -ExtraEnv $envMap
    if ($r.TimedOut) {
        return [pscustomobject]@{ Ok = $false; Reason = 'AGY_MODELS_FAILED'; Detail = 'agy models timed out'; ModelCount = 0 }
    }
    if ($r.ExitCode -ne 0) {
        return [pscustomobject]@{
            Ok = $false; Reason = 'AGY_MODELS_FAILED'
            Detail = "agy models exit=$($r.ExitCode) stderr=$($r.StdErr.Trim())"
            ModelCount = 0
        }
    }

    # '<slug>\t<display name>' 형태의 줄만 센다. 'Fetching available models...' 는 제외된다.
    $models = @($r.StdOut -split "`r?`n" | Where-Object { $_ -match '^[a-z0-9][a-z0-9._\-]*\t\S' })
    Write-Log "agy models: $($models.Count) 개 모델"
    foreach ($m in $models) { Write-Log "  model: $($m -replace "`t", ' | ')" }

    if ($models.Count -eq 0) {
        return [pscustomobject]@{
            Ok = $false; Reason = 'AGY_MODELS_EMPTY'
            Detail = 'agy models returned 0 model rows (exit=0). Credential likely revoked or expired.'
            ModelCount = 0
        }
    }
    return [pscustomobject]@{ Ok = $true; Reason = $null; Detail = $null; ModelCount = $models.Count }
}

# ─────────────────────────────────────────────────────────────────────────────
# 7) 인증 필요 플래그
# ─────────────────────────────────────────────────────────────────────────────
function Set-AuthRequiredFlag {
    param(
        [Parameter(Mandatory)][string] $Reason,
        [string] $Detail,
        [string] $AgyExe,
        [string] $AgyVersion,
        [bool]   $CredPresent
    )
    if ($CheckOnly) { Write-Log "(-CheckOnly) 플래그를 쓰지 않는다: $Reason" -Level WARN; return }

    $attempts = 1
    $lastNotified = $null
    if (Test-Path -LiteralPath $AuthFlagPath) {
        try {
            $prev = Get-Content -LiteralPath $AuthFlagPath -Raw -Encoding UTF8 | ConvertFrom-Json
            if ($prev.PSObject.Properties.Name -contains 'attempts')         { $attempts = [int]$prev.attempts + 1 }
            if ($prev.PSObject.Properties.Name -contains 'last_notified_at') { $lastNotified = $prev.last_notified_at }
        } catch { }
    }

    $payload = [ordered]@{
        schema           = 1
        raised_at        = (Get-Date).ToString('o')
        run_id           = $RunId
        reason           = $Reason
        detail           = $Detail
        agy_exe          = $AgyExe
        agy_version      = $AgyVersion
        cred_target      = $CRED_TARGET
        cred_present     = $CredPresent
        attempts         = $attempts
        last_notified_at = $lastNotified
        resolved_at      = $null
    }
    $payload | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $AuthFlagPath -Encoding UTF8
    Write-Log "인증 필요 플래그 기록: $AuthFlagPath (reason=$Reason, attempts=$attempts)" -Level WARN
}

function Clear-AuthRequiredFlag {
    if ($CheckOnly) { return }
    if (-not (Test-Path -LiteralPath $AuthFlagPath)) { return }
    $doneDir = Join-Path $StateDir 'auth_resolved'
    if (-not (Test-Path -LiteralPath $doneDir)) { New-Item -ItemType Directory -Path $doneDir -Force | Out-Null }
    try {
        $obj = Get-Content -LiteralPath $AuthFlagPath -Raw -Encoding UTF8 | ConvertFrom-Json
        $obj | Add-Member -NotePropertyName resolved_at -NotePropertyValue ((Get-Date).ToString('o')) -Force
        $obj | ConvertTo-Json -Depth 5 |
            Set-Content -LiteralPath (Join-Path $doneDir "$RunId.json") -Encoding UTF8
    } catch { }
    Remove-Item -LiteralPath $AuthFlagPath -Force -ErrorAction SilentlyContinue
    Write-Log '인증 필요 플래그 해소' -Level OK
}

# ─────────────────────────────────────────────────────────────────────────────
# 8) 실행 락 (주간 업데이트 작업과의 충돌 방지)
# ─────────────────────────────────────────────────────────────────────────────
function Open-RunLock {
    if (-not (Test-Path -LiteralPath $RunLockPath)) {
        Set-Content -LiteralPath $RunLockPath -Value '' -Encoding ASCII
    }
    try {
        return [IO.File]::Open($RunLockPath, [IO.FileMode]::Open, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None)
    } catch {
        return $null
    }
}

# ═════════════════════════════════════════════════════════════════════════════
# 메인
# ═════════════════════════════════════════════════════════════════════════════
Write-Log "===== ensure_agy.ps1 시작 (RunId=$RunId, CheckOnly=$CheckOnly, NoIsolatedProfile=$NoIsolatedProfile) ====="

if (-not (Assert-RunContext)) { Exit-Bootstrap $EXIT_BAD_RUN_CONTEXT '잘못된 실행 컨텍스트' }

$lock = Open-RunLock
if (-not $lock) {
    Write-Log "실행 락 획득 실패($RunLockPath). 주간 업데이트 작업이 실행 중일 수 있다." -Level WARN
    Exit-Bootstrap $EXIT_UPDATE_IN_PROGRESS '락 충돌'
}

try {
    # ── 1) 탐지 ────────────────────────────────────────────────────────────
    $found = Find-AgyExecutable

    # ── 2) 필요 시 설치 ────────────────────────────────────────────────────
    if (-not $found -or -not $found.Version) {
        if ($CheckOnly) { Exit-Bootstrap $EXIT_NEED_INSTALL '미설치/손상 (CheckOnly)' }

        if ($found -and (Test-Path -LiteralPath $found.Path)) {
            Write-Log "손상된 바이너리 제거 시도: $($found.Path)" -Level WARN
            # 실행 중이면 잠겨 있다. 먼저 프로세스를 정리한다.
            Get-Process -Name 'agy' -ErrorAction SilentlyContinue |
                Where-Object { $_.Path -eq $found.Path } |
                ForEach-Object {
                    Write-Log "실행 중인 agy 프로세스 종료: PID=$($_.Id)" -Level WARN
                    try { $_.Kill(); $_.WaitForExit(15000) } catch { }
                }
            Remove-Item -LiteralPath $found.Path -Force -ErrorAction SilentlyContinue
        }

        Write-Log 'agy 설치를 시작한다 (1순위: 공식 install.ps1)'
        $rc = Install-AgyViaOfficialScript
        if ($rc -eq $EXIT_INTEGRITY_FAILED) { Exit-Bootstrap $EXIT_INTEGRITY_FAILED '체크섬 불일치' }
        if ($rc -ne $EXIT_OK) {
            Write-Log '2순위: 매니페스트 직접 다운로드' -Level WARN
            $rc = Install-AgyFromManifest
            if ($rc -eq $EXIT_INTEGRITY_FAILED) { Exit-Bootstrap $EXIT_INTEGRITY_FAILED '체크섬 불일치' }
            if ($rc -ne $EXIT_OK) { Exit-Bootstrap $rc '설치 실패' }
        }

        $found = Find-AgyExecutable
        if (-not $found -or -not $found.Version) { Exit-Bootstrap $EXIT_AGY_BROKEN '설치 후에도 --version 실패' }
    }

    $agyExe     = $found.Path
    $agyVersion = $found.Version
    Write-Log "agy 확정: $agyExe (v$agyVersion)" -Level OK

    # 업데이터 상태 참고 로그
    $updStatus = Join-Path $env:USERPROFILE '.gemini\antigravity-cli\updater\update_status.json'
    if (Test-Path -LiteralPath $updStatus) {
        Write-Log ("업데이터 상태: {0} (mtime={1:yyyy-MM-dd HH:mm:ss})" -f `
            ((Get-Content -LiteralPath $updStatus -Raw -Encoding UTF8).Trim()),
            (Get-Item -LiteralPath $updStatus).LastWriteTime)
    }

    # ── 3) 자격증명 존재 확인 (P2) ─────────────────────────────────────────
    $cred = Get-AgyCredentialState
    if (-not $cred.Found) {
        switch ($cred.Win32Error) {
            $ERROR_NO_SUCH_LOGON_SESSION {
                Write-Log 'CredRead → ERROR_NO_SUCH_LOGON_SESSION(1312). 네트워크 로그온 세션이다.' -Level ERROR
                Write-Log '원인: 작업 스케줄러가 S4U("암호를 저장하지 않음")로 등록됐을 가능성이 매우 높다.' -Level ERROR
                Write-Log '해결: 작업을 삭제하고 /RP 로 암호를 저장하는 방식으로 재등록하라(문서 §6.5).' -Level ERROR
                Set-AuthRequiredFlag -Reason 'CRED_NO_LOGON_SESSION' `
                    -Detail 'CredRead returned 1312 (ERROR_NO_SUCH_LOGON_SESSION). Task is likely registered with S4U logon type.' `
                    -AgyExe $agyExe -AgyVersion $agyVersion -CredPresent $false
                Exit-Bootstrap $EXIT_BAD_RUN_CONTEXT 'S4U/네트워크 로그온'
            }
            default {
                Write-Log "CredRead → 자격증명 없음 (Win32Error=$($cred.Win32Error))" -Level WARN
                Set-AuthRequiredFlag -Reason 'CRED_NOT_FOUND' `
                    -Detail "CredRead('$CRED_TARGET') failed with Win32Error=$($cred.Win32Error). Interactive login required." `
                    -AgyExe $agyExe -AgyVersion $agyVersion -CredPresent $false
                Exit-Bootstrap $EXIT_NEED_AUTH '자격증명 없음'
            }
        }
    }

    Write-Log ("자격증명 존재: blob={0}B  auth_method={1}  lastWrittenUtc={2}  refresh_token={3}" -f `
        $cred.BlobSize, $cred.AuthMethod, $cred.LastWrittenUtc, $cred.HasRefreshToken) -Level OK

    if (-not $cred.HasRefreshToken) {
        Write-Log 'refresh_token 이 비어 있다. 자동 갱신이 불가능하다.' -Level WARN
        Set-AuthRequiredFlag -Reason 'CRED_NO_REFRESH_TOKEN' `
            -Detail 'Credential blob has no refresh_token; re-login required.' `
            -AgyExe $agyExe -AgyVersion $agyVersion -CredPresent $true
        Exit-Bootstrap $EXIT_NEED_AUTH 'refresh_token 없음'
    }

    # 참고: expiry 는 대개 과거다(access_token 수명 1시간). 판정에 쓰지 않는다.
    if ($cred.ExpiryUtc) {
        $age = (Get-Date).ToUniversalTime() - $cred.ExpiryUtc
        Write-Log ("access_token expiry(UTC)={0} (경과 {1:N1}시간) — 판정에는 사용하지 않음" -f $cred.ExpiryUtc, $age.TotalHours)
    }

    # ── 4) 격리 프로필 ─────────────────────────────────────────────────────
    $homeOverride = $null
    if (-not $NoIsolatedProfile) {
        try {
            $homeOverride = Initialize-IsolatedProfile
        } catch {
            Write-Log "격리 프로필 준비 실패: $($_.Exception.Message)" -Level WARN
            $homeOverride = $null
        }
    }

    # ── 5) 인증 실검증 (P3) ────────────────────────────────────────────────
    $auth = Test-AgyAuth -Exe $agyExe -HomeOverride $homeOverride

    if (-not $auth.Ok -and $homeOverride) {
        Write-Log '격리 프로필에서 인증 검증 실패. 기본 프로필로 재시도한다.' -Level WARN
        $authFallback = Test-AgyAuth -Exe $agyExe -HomeOverride $null
        if ($authFallback.Ok) {
            Write-Log '기본 프로필에서는 성공했다. 격리 프로필이 인증을 방해한다(agy 버전 변화 가능성).' -Level ERROR
            Write-Log '이번 실행은 격리 없이 진행한다. 부록 B 항목으로 보고하라.' -Level WARN
            $homeOverride = $null
            $auth = $authFallback
        }
    }

    if (-not $auth.Ok) {
        Write-Log "인증 검증 실패: $($auth.Reason)$($auth.Detail)" -Level ERROR
        Set-AuthRequiredFlag -Reason $auth.Reason -Detail $auth.Detail `
            -AgyExe $agyExe -AgyVersion $agyVersion -CredPresent $true
        Exit-Bootstrap $EXIT_NEED_AUTH $auth.Reason
    }

    Write-Log "인증 유효 (모델 $($auth.ModelCount)종 조회 성공)" -Level OK
    Clear-AuthRequiredFlag

    # ── 6) 배치가 쓸 컨텍스트를 파일로 넘긴다 ──────────────────────────────
    if (-not $CheckOnly) {
        $ctx = [ordered]@{
            schema        = 1
            run_id        = $RunId
            checked_at    = (Get-Date).ToString('o')
            agy_exe       = $agyExe
            agy_version   = $agyVersion
            home_override = $homeOverride
            model_count   = $auth.ModelCount
            env           = [ordered]@{
                AGY_CLI_DISABLE_AUTO_UPDATE = 'true'
                USERPROFILE                 = $homeOverride
                HOME                        = $homeOverride
            }
        }
        $ctxPath = Join-Path $StateDir 'agy_context.json'
        $ctx | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $ctxPath -Encoding UTF8
        Write-Log "실행 컨텍스트 기록: $ctxPath" -Level OK
    }

    Exit-Bootstrap $EXIT_OK
}
catch {
    Write-Log "미처리 예외: $($_.Exception.GetType().FullName): $($_.Exception.Message)" -Level ERROR
    Write-Log ($_.ScriptStackTrace) -Level ERROR
    Exit-Bootstrap 1 '미처리 예외'
}
finally {
    if ($lock) { $lock.Dispose() }
}

11.1 이 스크립트가 의도적으로 하지 않는 것

하지 않는 것 이유
창을 띄우지 않는다 06:00 에 사용자가 없을 수 있고, 비대화형 세션에서는 보이지 않는다(§7.1)
agy update 를 호출하지 않는다 배치 도중 바이너리 교체 위험(§5.4). 업데이트는 주간 작업 전담
winget 을 호출하지 않는다 버전 신뢰 불가 + SYSTEM 미지원(§3.1, §4.3)
프롬프트를 실행하지 않는다 인증 검증은 agy models(0 토큰)로 충분(§6.2)
자격증명 블롭을 로그에 남기지 않는다 토큰 유출 방지. 키 이름과 boolean 만 기록
사용자 전역 settings.json 을 수정하지 않는다 격리 프로필만 건드린다(§9.2)
실패 시 배치를 중단시키지 않는다 호출자가 종료 코드로 graceful degradation 을 결정한다(§10)

12. 부속 스크립트 전체 코드

12.1 scripts/install_agy_from_manifest.ps1 — 독립 실행형 폴백 설치기

ensure_agy.ps1 안에도 같은 로직이 있지만, 수동 복구용으로 단독 실행 가능한 버전을 따로 둔다.

<#
.SYNOPSIS
    Antigravity CLI(agy) 를 자동 업데이터 매니페스트에서 직접 내려받아 설치한다.
.DESCRIPTION
    공식 install.ps1 이 실패했을 때의 폴백. SHA512 검증 후 %LOCALAPPDATA%\agy\bin\agy.exe 에 배치한다.
    이미 설치돼 있으면 -Force 없이는 아무것도 하지 않는다.
.PARAMETER Force
    기존 바이너리를 삭제하고 재설치한다.
.PARAMETER TargetDir
    설치 디렉터리. 기본 %LOCALAPPDATA%\agy\bin
#>
[CmdletBinding()]
param(
    [switch] $Force,
    [string] $TargetDir = (Join-Path $env:LOCALAPPDATA 'agy\bin')
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$ProgressPreference    = 'SilentlyContinue'

$UPDATER_BASE = 'https://antigravity-cli-auto-updater-974169037036.us-central1.run.app'
$binaryPath   = Join-Path $TargetDir 'agy.exe'

# 0) 아키텍처
$arch = if ($env:PROCESSOR_ARCHITEW6432) { $env:PROCESSOR_ARCHITEW6432 } else { $env:PROCESSOR_ARCHITECTURE }
$platform = switch ($arch.ToUpperInvariant()) {
    'AMD64' { 'windows_amd64' }
    'ARM64' { 'windows_arm64' }
    default { throw "지원하지 않는 CPU 아키텍처: $arch" }
}
Write-Host "플랫폼: $platform"

# 1) 기존 설치 처리
if (Test-Path -LiteralPath $binaryPath) {
    if (-not $Force) {
        Write-Host "이미 설치되어 있다: $binaryPath"
        try { & $binaryPath --version } catch { }
        exit 0
    }
    Write-Host "기존 바이너리를 제거한다(-Force): $binaryPath"
    Get-Process -Name 'agy' -ErrorAction SilentlyContinue |
        Where-Object { $_.Path -eq $binaryPath } |
        ForEach-Object { Write-Host "  실행 중인 agy 종료: PID=$($_.Id)"; $_.Kill(); $_.WaitForExit(15000) }
    Remove-Item -LiteralPath $binaryPath -Force
}

# 2) TLS + 매니페스트
try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 } catch { }

$manifestUrl = "$UPDATER_BASE/manifests/$platform.json"
Write-Host "매니페스트: $manifestUrl"
$manifest = Invoke-RestMethod -Uri $manifestUrl -TimeoutSec 60
Write-Host "  version = $($manifest.version)"
Write-Host "  url     = $($manifest.url)"
Write-Host "  sha512  = $($manifest.sha512.Substring(0,16))…"

# 3) 스테이징 다운로드
$stage = Join-Path $env:TEMP ('dmf-agy-staging-' + [Guid]::NewGuid().ToString('N').Substring(0,8))
New-Item -ItemType Directory -Path $stage -Force | Out-Null
$payload = Join-Path $stage 'agy.exe'

try {
    Write-Host "다운로드 중… (약 187 MB)"
    $sw = [Diagnostics.Stopwatch]::StartNew()
    Invoke-WebRequest -Uri $manifest.url -OutFile $payload -UseBasicParsing -TimeoutSec 1800
    $sw.Stop()
    $sizeMB = [math]::Round((Get-Item -LiteralPath $payload).Length / 1MB, 1)
    Write-Host "  완료: ${sizeMB} MB / $([math]::Round($sw.Elapsed.TotalSeconds,1)) s"

    # 4) SHA512 검증 (Get-FileHash → 실패 시 certutil 폴백, install.ps1 과 동일한 이중화)
    $actual = $null
    if ($ExecutionContext.SessionState.LanguageMode -ne 'ConstrainedLanguage') {
        try { $actual = (Get-FileHash -LiteralPath $payload -Algorithm SHA512).Hash.ToLowerInvariant() } catch { }
    }
    if (-not $actual) {
        $out = certutil -hashfile $payload SHA512
        if ($LASTEXITCODE -eq 0 -and $out.Count -ge 2) { $actual = ($out[1] -replace '\s').ToLowerInvariant() }
    }
    if (-not $actual) { throw '해시를 계산하지 못했다.' }

    $expect = ([string]$manifest.sha512).ToLowerInvariant()
    if ($actual -ne $expect) {
        throw "Security Halt: SHA512 불일치.`n  expected = $expect`n  actual   = $actual"
    }
    Write-Host "  SHA512 검증 통과" -ForegroundColor Green

    # 5) 배치
    if (-not (Test-Path -LiteralPath $TargetDir)) { New-Item -ItemType Directory -Path $TargetDir -Force | Out-Null }
    Copy-Item -LiteralPath $payload -Destination $binaryPath -Force
    Unblock-File -LiteralPath $binaryPath -ErrorAction SilentlyContinue
    Write-Host "배치 완료: $binaryPath" -ForegroundColor Green

    # 6) 환경 구성(PATH). 실패해도 치명적이지 않다 — 우리는 절대경로를 쓴다.
    try {
        & $binaryPath install --skip-aliases
        if ($LASTEXITCODE -ne 0) { Write-Warning "agy install 종료 코드 $LASTEXITCODE (무시하고 계속)" }
    } catch {
        Write-Warning "agy install 실패(무시): $($_.Exception.Message)"
    }

    # 7) 검증
    $v = & $binaryPath --version
    if ($LASTEXITCODE -ne 0) { throw "설치 후 --version 실패 (exit=$LASTEXITCODE)" }
    Write-Host "설치 검증 완료: v$($v.Trim())" -ForegroundColor Green
    exit 0
}
finally {
    Remove-Item -LiteralPath $stage -Recurse -Force -ErrorAction SilentlyContinue
}

12.2 scripts/update_agy.ps1 — 주 1회 계획 업데이트

<#
.SYNOPSIS
    주 1회 계획 업데이트: 배치와 락을 공유해 실행 중 충돌을 피하고, .old 바이너리를 정리한다.
.NOTES
    작업 스케줄러 등록 예:
      schtasks /Create /TN "DMF_Crawler\AgyWeeklyUpdate" ^
        /TR "powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File \"D:\workspace\DMF_Crawler\scripts\update_agy.ps1\"" ^
        /SC WEEKLY /D SUN /ST 05:00 /RU "%COMPUTERNAME%\%USERNAME%" /RP * /RL LIMITED /F
    (/NP 는 S4U 가 되므로 절대 쓰지 마라 — 문서 §6.5)
#>
[CmdletBinding()]
param(
    [string] $ProjectRoot = 'D:\workspace\DMF_Crawler',
    [int]    $OldRetentionDays = 7
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

$StateDir    = Join-Path $ProjectRoot 'state'
$LogDir      = Join-Path $ProjectRoot 'logs'
$RunLockPath = Join-Path $StateDir 'agy.runlock'
$LogPath     = Join-Path $LogDir ('update_agy_{0}.log' -f (Get-Date -Format 'yyyyMMdd_HHmmss'))
$AgyExe      = Join-Path $env:LOCALAPPDATA 'agy\bin\agy.exe'
$AgyBinDir   = Split-Path -Parent $AgyExe

foreach ($d in @($StateDir, $LogDir)) {
    if (-not (Test-Path -LiteralPath $d)) { New-Item -ItemType Directory -Path $d -Force | Out-Null }
}

function Log {
    param([string]$m, [string]$lvl = 'INFO')
    $line = '{0} [{1,-5}] {2}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $lvl, $m
    Add-Content -LiteralPath $LogPath -Value $line -Encoding UTF8
    Write-Host $line
}

# 실행 컨텍스트 검증 (배치와 동일 규칙)
$who = [Security.Principal.WindowsIdentity]::GetCurrent().Name
Log "실행 계정: $who"
if ($who -imatch '^NT (AUTHORITY|서비스)\\') {
    Log '서비스 계정에서는 실행할 수 없다.' 'ERROR'
    exit 30
}

if (-not (Test-Path -LiteralPath $AgyExe)) {
    Log "agy 가 설치돼 있지 않다: $AgyExe. ensure_agy.ps1 을 먼저 실행하라." 'ERROR'
    exit 10
}

# 락 획득 — 배치가 돌고 있으면 이번 주는 건너뛴다
if (-not (Test-Path -LiteralPath $RunLockPath)) { Set-Content -LiteralPath $RunLockPath -Value '' -Encoding ASCII }
$lock = $null
try {
    $lock = [IO.File]::Open($RunLockPath, [IO.FileMode]::Open, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None)
} catch {
    Log '배치가 실행 중이다(락 점유). 이번 업데이트는 건너뛴다.' 'WARN'
    exit 13
}

try {
    $before = (& $AgyExe --version 2>$null | Select-Object -First 1)
    Log "업데이트 전 버전: $($before.Trim())"

    Log 'agy update 실행'
    $out = & $AgyExe update 2>&1
    $rc  = $LASTEXITCODE
    foreach ($l in $out) { Log "  [agy update] $l" }
    Log "agy update 종료 코드: $rc"

    $statusPath = Join-Path $env:USERPROFILE '.gemini\antigravity-cli\updater\update_status.json'
    if (Test-Path -LiteralPath $statusPath) {
        Log "update_status.json: $((Get-Content -LiteralPath $statusPath -Raw -Encoding UTF8).Trim())"
    }

    $after = (& $AgyExe --version 2>$null | Select-Object -First 1)
    Log "업데이트 후 버전: $($after.Trim())"

    if ($before.Trim() -ne $after.Trim()) {
        Log "버전 변경: $($before.Trim())$($after.Trim())" 'INFO'
        # 업데이트 직후 인증·모델 목록이 여전히 유효한지 즉시 확인한다.
        & (Join-Path $ProjectRoot 'scripts\ensure_agy.ps1') -ProjectRoot $ProjectRoot -CheckOnly
        Log "ensure_agy -CheckOnly 결과: exit=$LASTEXITCODE"
    } else {
        Log '버전 변경 없음'
    }

    # .old 바이너리 정리 (실측: 개당 약 186 MB)
    $cutoff = (Get-Date).AddDays(-$OldRetentionDays)
    $olds = @(Get-ChildItem -LiteralPath $AgyBinDir -Filter 'agy.exe.*.old' -ErrorAction SilentlyContinue |
              Where-Object { $_.LastWriteTime -lt $cutoff })
    if ($olds.Count -gt 0) {
        $freed = [math]::Round(($olds | Measure-Object Length -Sum).Sum / 1MB, 1)
        foreach ($o in $olds) {
            Remove-Item -LiteralPath $o.FullName -Force -ErrorAction SilentlyContinue
            Log "정리: $($o.Name) ($([math]::Round($o.Length/1MB,1)) MB)"
        }
        Log "총 ${freed} MB 회수" 'INFO'
    } else {
        Log "정리 대상 .old 파일 없음 (기준: ${OldRetentionDays}일)"
    }

    exit 0
}
catch {
    Log "예외: $($_.Exception.Message)" 'ERROR'
    exit 1
}
finally {
    if ($lock) { $lock.Dispose() }
}

12.3 scripts/notify_auth_required.ps1 — 대화형 알림 작업(작업 B)

<#
.SYNOPSIS
    state\auth_required.json 이 있으면 사용자에게 재로그인을 요청한다.
.DESCRIPTION
    반드시 "사용자가 로그온한 경우에만 실행"(TASK_LOGON_INTERACTIVE_TOKEN) 작업으로 등록한다.
    4단 폴백: BurntToast → WinRT(PS5.1) → MessageBox → msg.exe
.NOTES
    schtasks /Create /TN "DMF_Crawler\AuthPrompt" ^
      /TR "powershell.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File \"D:\workspace\DMF_Crawler\scripts\notify_auth_required.ps1\"" ^
      /SC ONLOGON /DELAY 0002:00 /RL LIMITED /F
    + 별도로 15분 반복 트리거를 taskschd.msc 또는 XML 로 추가한다.
#>
[CmdletBinding()]
param(
    [string] $ProjectRoot     = 'D:\workspace\DMF_Crawler',
    [int]    $QuietHours      = 6,        # 같은 건으로 재알림하지 않는 시간
    [switch] $Force           # 억제를 무시하고 강제로 알린다(테스트용)
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

$StateDir     = Join-Path $ProjectRoot 'state'
$FlagPath     = Join-Path $StateDir 'auth_required.json'
$LoginScript  = Join-Path $ProjectRoot 'scripts\agy_login_window.ps1'
$LogDir       = Join-Path $ProjectRoot 'logs'
$LogPath      = Join-Path $LogDir 'notify_auth.log'
$MutexName    = 'Global\DMF_Crawler_AuthPrompt'

function Log { param([string]$m)
    if (-not (Test-Path -LiteralPath $LogDir)) { New-Item -ItemType Directory -Path $LogDir -Force | Out-Null }
    Add-Content -LiteralPath $LogPath -Value ('{0} {1}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $m) -Encoding UTF8
}

if (-not (Test-Path -LiteralPath $FlagPath)) { Log '플래그 없음 — 종료'; exit 0 }

# 단일 인스턴스 (MessageBox 블로킹 중 중복 실행 방지)
$createdNew = $false
$mutex = New-Object System.Threading.Mutex($true, $MutexName, [ref]$createdNew)
if (-not $createdNew) { Log '다른 인스턴스가 실행 중 — 종료'; exit 0 }

try {
    $flag = Get-Content -LiteralPath $FlagPath -Raw -Encoding UTF8 | ConvertFrom-Json

    if (-not $Force -and $flag.last_notified_at) {
        $since = (Get-Date) - [datetime]$flag.last_notified_at
        if ($since.TotalHours -lt $QuietHours) {
            Log ("최근 알림 후 {0:N1}시간 — 억제(기준 {1}시간)" -f $since.TotalHours, $QuietHours)
            exit 0
        }
    }

    $reasonText = switch ($flag.reason) {
        'CRED_NOT_FOUND'        { 'Antigravity 로그인 기록이 없습니다.' }
        'CRED_NO_REFRESH_TOKEN' { '갱신 토큰이 없어 자동 갱신이 불가능합니다.' }
        'AGY_MODELS_EMPTY'      { '저장된 자격증명이 서버에서 거부되었습니다(만료·취소 추정).' }
        'AGY_MODELS_FAILED'     { 'Antigravity 서버에 연결하지 못했습니다.' }
        'CRED_NO_LOGON_SESSION' { '작업 스케줄러가 잘못된 로그온 방식(S4U)으로 등록되어 있습니다.' }
        default                 { "인증 확인에 실패했습니다 ($($flag.reason))." }
    }
    $title = 'DMF Crawler — Antigravity 재로그인 필요'
    $body  = "$reasonText`n일일 배치가 $($flag.attempts)회 실패했습니다. 재로그인하면 다음 실행부터 정상화됩니다."
    Log "알림 시작: reason=$($flag.reason) attempts=$($flag.attempts)"

    $delivered = $false

    # ── 폴백 1: BurntToast (버튼 있음) ────────────────────────────────────
    if (-not $delivered -and (Get-Module -ListAvailable -Name BurntToast)) {
        try {
            Import-Module BurntToast -ErrorAction Stop
            $btnLogin = New-BTButton -Content '지금 재로그인' -Arguments 'dmf-agy-login:' -ActivationType Protocol
            $btnLog   = New-BTButton -Content '로그 폴더'     -Arguments $LogDir          -ActivationType Protocol
            $btnLater = New-BTButton -Dismiss -Content '나중에'
            New-BurntToastNotification -Text $title, $body `
                -Button $btnLogin, $btnLog, $btnLater `
                -UniqueIdentifier 'dmf-agy-auth' -ErrorAction Stop
            $delivered = $true; Log '전달: BurntToast'
        } catch { Log "BurntToast 실패: $($_.Exception.Message)" }
    }

    # ── 폴백 2: WinRT 토스트 (powershell.exe 5.1 경유. PS7 에서는 타입 로드 실패함) ──
    if (-not $delivered) {
        try {
            $ps51 = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe'
            $xml  = @"
<toast activationType="protocol" launch="dmf-agy-login:">
  <visual><binding template="ToastGeneric">
    <text>$([Security.SecurityElement]::Escape($title))</text>
    <text>$([Security.SecurityElement]::Escape($body))</text>
  </binding></visual>
  <actions>
    <action content="지금 재로그인" activationType="protocol" arguments="dmf-agy-login:"/>
    <action content="나중에" activationType="system" arguments="dismiss"/>
  </actions>
</toast>
"@
            $inner = @"
[void][Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType=WindowsRuntime]
[void][Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom, ContentType=WindowsRuntime]
`$doc = New-Object Windows.Data.Xml.Dom.XmlDocument
`$doc.LoadXml(@'
$xml
'@)
`$toast = New-Object Windows.UI.Notifications.ToastNotification `$doc
[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\WindowsPowerShell\v1.0\powershell.exe').Show(`$toast)
"@
            $enc = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($inner))
            $p = Start-Process -FilePath $ps51 `
                    -ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-EncodedCommand',$enc) `
                    -WindowStyle Hidden -PassThru -Wait
            if ($p.ExitCode -eq 0) { $delivered = $true; Log '전달: WinRT 토스트(PS5.1)' }
            else { Log "WinRT 토스트 실패: exit=$($p.ExitCode)" }
        } catch { Log "WinRT 토스트 예외: $($_.Exception.Message)" }
    }

    # ── 폴백 3: MessageBox ────────────────────────────────────────────────
    if (-not $delivered) {
        try {
            Add-Type -AssemblyName System.Windows.Forms
            $r = [System.Windows.Forms.MessageBox]::Show(
                    "$body`n`n지금 재로그인 창을 여시겠습니까?", $title,
                    [System.Windows.Forms.MessageBoxButtons]::YesNo,
                    [System.Windows.Forms.MessageBoxIcon]::Warning,
                    [System.Windows.Forms.MessageBoxDefaultButton]::Button1,
                    [System.Windows.Forms.MessageBoxOptions]::DefaultDesktopOnly)
            $delivered = $true; Log "전달: MessageBox (선택=$r)"
            if ($r -eq [System.Windows.Forms.DialogResult]::Yes) { & $LoginScript }
        } catch { Log "MessageBox 실패: $($_.Exception.Message)" }
    }

    # ── 폴백 4: msg.exe ───────────────────────────────────────────────────
    if (-not $delivered) {
        try {
            & "$env:SystemRoot\System32\msg.exe" $env:USERNAME /TIME:600 `
                "$title$reasonText  재로그인: $LoginScript"
            if ($LASTEXITCODE -eq 0) { $delivered = $true; Log '전달: msg.exe' }
        } catch { Log "msg.exe 실패: $($_.Exception.Message)" }
    }

    if (-not $delivered) { Log '모든 알림 수단 실패 — 웹훅 폴백은 08 문서의 notify 모듈이 담당' }

    # 알림 시각 기록
    $flag | Add-Member -NotePropertyName last_notified_at -NotePropertyValue ((Get-Date).ToString('o')) -Force
    $flag | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $FlagPath -Encoding UTF8
    exit 0
}
catch {
    Log "예외: $($_.Exception.Message)"
    exit 1
}
finally {
    if ($mutex) { $mutex.ReleaseMutex(); $mutex.Dispose() }
}

⚠️ 미검증: 위 WinRT 토스트의 CreateToastNotifier 에 넘긴 AUMID {1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\WindowsPowerShell\v1.0\powershell.exe 는 Windows PowerShell 5.1 의 시작 메뉴 바로 가기 AUMID 로 널리 쓰이는 값이지만, 이 머신에서 실제 표시까지 확인하지는 못했다. BurntToast 설치를 1순위로 권장하는 이유다(08 §11.1 도 BurntToast 1순위).

12.4 scripts/agy_login_window.ps1 — 재로그인 콘솔 창

<#
.SYNOPSIS
    새 콘솔 창을 열어 대화형 agy 를 실행하고, 종료 후 인증 상태를 재검증한다.
.DESCRIPTION
    agy 에는 login 서브커맨드가 없다(agy --help 실측). TUI 를 띄우는 것이 유일한 로그인 경로다.
    반드시 사용자 세션에서 호출해야 한다(§7.1).
#>
[CmdletBinding()]
param(
    [string] $ProjectRoot = 'D:\workspace\DMF_Crawler'
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

$agy    = Join-Path $env:LOCALAPPDATA 'agy\bin\agy.exe'
$ensure = Join-Path $ProjectRoot 'scripts\ensure_agy.ps1'
$ps51   = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe'

if (-not (Test-Path -LiteralPath $agy)) {
    Add-Type -AssemblyName System.Windows.Forms
    [void][System.Windows.Forms.MessageBox]::Show(
        "agy 가 설치되어 있지 않습니다:`n$agy`n`n먼저 다음을 실행하세요:`n$ProjectRoot\scripts\install_agy_from_manifest.ps1",
        'DMF Crawler', 'OK', 'Error')
    exit 10
}

# 새 콘솔 안에서 돌 스크립트. 백틱으로 이스케이프한 $ 는 자식에서 평가된다.
$inner = @"
`$Host.UI.RawUI.WindowTitle = 'DMF Crawler — Antigravity 재로그인'
`$ErrorActionPreference = 'Continue'
[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new(`$false)

Write-Host ''
Write-Host '  ══════════════════════════════════════════════════════════════' -ForegroundColor DarkCyan
Write-Host '   DMF Crawler — Antigravity 재로그인' -ForegroundColor Cyan
Write-Host '  ══════════════════════════════════════════════════════════════' -ForegroundColor DarkCyan
Write-Host ''
Write-Host '   일일 배치가 Antigravity 인증에 실패했습니다.' -ForegroundColor Yellow
Write-Host '   잠시 후 agy 가 열립니다. 로그인을 완료한 뒤 /quit 로 종료하세요.' -ForegroundColor Yellow
Write-Host '   (기본 브라우저가 자동으로 열립니다)' -ForegroundColor DarkGray
Write-Host ''

# 자동 업데이트가 로그인 도중 끼어들지 않게 한다.
`$env:AGY_CLI_DISABLE_AUTO_UPDATE = 'true'
& '$agy'

Write-Host ''
Write-Host '   인증 상태를 다시 확인합니다...' -ForegroundColor Cyan
& '$ps51' -NoProfile -ExecutionPolicy Bypass -File '$ensure' -ProjectRoot '$ProjectRoot' -CheckOnly
`$rc = `$LASTEXITCODE
Write-Host ''
if (`$rc -eq 0) {
    Write-Host '   ✓ 인증 성공. 다음 배치부터 정상 동작합니다.' -ForegroundColor Green
    Remove-Item -LiteralPath '$ProjectRoot\state\auth_required.json' -Force -ErrorAction SilentlyContinue
} elseif (`$rc -eq 30) {
    Write-Host '   ✗ 작업 스케줄러 실행 컨텍스트 오류(코드 30).' -ForegroundColor Red
    Write-Host '     작업을 삭제하고 "암호를 저장하지 않음" 체크를 해제해 재등록하세요.' -ForegroundColor Red
} else {
    Write-Host "   ✗ 여전히 인증 실패 (exit `$rc). logs\ 를 확인하세요." -ForegroundColor Red
}
Write-Host ''
Read-Host '   Enter 를 누르면 창이 닫힙니다'
"@

$encoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($inner))

Start-Process -FilePath $ps51 `
    -ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-EncodedCommand',$encoded) `
    -WindowStyle Normal `
    -WorkingDirectory $ProjectRoot
exit 0

12.5 scripts/register_login_protocol.ps1 — 토스트 버튼용 프로토콜 등록

<#
.SYNOPSIS
    토스트 알림 버튼이 재로그인 창을 열 수 있도록 dmf-agy-login: URI 프로토콜을 HKCU 에 등록한다.
.DESCRIPTION
    HKCU 라서 관리자 권한이 필요 없다. 최초 1회만 실행하면 된다.
    해제는 -Unregister.
#>
[CmdletBinding()]
param(
    [string] $ProjectRoot = 'D:\workspace\DMF_Crawler',
    [switch] $Unregister
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

$proto  = 'dmf-agy-login'
$root   = "HKCU:\Software\Classes\$proto"
$script = Join-Path $ProjectRoot 'scripts\agy_login_window.ps1'
$ps51   = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe'

if ($Unregister) {
    if (Test-Path -LiteralPath $root) {
        Remove-Item -LiteralPath $root -Recurse -Force
        Write-Host "해제 완료: $proto"
    } else {
        Write-Host "등록되어 있지 않다: $proto"
    }
    exit 0
}

if (-not (Test-Path -LiteralPath $script)) { throw "로그인 스크립트를 찾을 수 없다: $script" }

New-Item -Path $root -Force | Out-Null
Set-ItemProperty -Path $root -Name '(Default)'    -Value 'URL:DMF Crawler agy login'
Set-ItemProperty -Path $root -Name 'URL Protocol' -Value ''

$cmdKey = Join-Path $root 'shell\open\command'
New-Item -Path $cmdKey -Force | Out-Null
$cmdLine = '"{0}" -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File "{1}" -ProjectRoot "{2}"' -f $ps51, $script, $ProjectRoot
Set-ItemProperty -Path $cmdKey -Name '(Default)' -Value $cmdLine

Write-Host "등록 완료: $proto"
Write-Host "  명령줄: $cmdLine"
Write-Host ""
Write-Host "테스트: Start-Process '$proto`:'"

12.6 배치에서 agy 를 호출하는 래퍼 (컨텍스트 소비 측)

ensure_agy.ps1 이 남긴 state\agy_context.json 을 읽어 격리 프로필과 절대경로를 그대로 적용한다.

<# scripts/invoke_agy.ps1 — 배치 본문에서 agy 를 한 번 호출한다. #>
[CmdletBinding()]
param(
    [Parameter(Mandatory)][string] $PromptPath,     # 프롬프트 텍스트 파일
    [Parameter(Mandatory)][string] $OutJsonPath,    # 봉투 저장 경로
    [string] $ProjectRoot = 'D:\workspace\DMF_Crawler',
    [string] $Model       = 'gemini-3.7-flash-medium',
    [string] $Effort      = 'medium',
    [string] $PrintTimeout= '10m'
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

$ctxPath = Join-Path $ProjectRoot 'state\agy_context.json'
if (-not (Test-Path -LiteralPath $ctxPath)) { throw "agy_context.json 이 없다. ensure_agy.ps1 을 먼저 실행하라." }
$ctx = Get-Content -LiteralPath $ctxPath -Raw -Encoding UTF8 | ConvertFrom-Json

$runId   = $ctx.run_id
$logDir  = Join-Path $ProjectRoot 'logs'
$stderrP = Join-Path $logDir "agy_${runId}.stderr.log"
$cliLog  = Join-Path $logDir "agy_cli_${runId}.log"

$prompt = Get-Content -LiteralPath $PromptPath -Raw -Encoding UTF8

$agyArgs = @(
    '-p', $prompt,
    '--output-format','json',
    '--model', $Model,
    '--effort', $Effort,
    '--print-timeout', $PrintTimeout,
    '--disable-slash-commands',
    '--mode','accept-edits',
    '--log-file', $cliLog
)

# 환경: 자동 업데이트 차단 + (있다면) 격리 프로필
$envMap = @{ AGY_CLI_DISABLE_AUTO_UPDATE = 'true' }
if ($ctx.home_override) {
    $envMap['USERPROFILE'] = $ctx.home_override
    $envMap['HOME']        = $ctx.home_override
}

# PowerShell 7.4+ 는 -Environment 로 자식 프로세스 환경만 바꿀 수 있다.
$supportsEnvParam = (Get-Command Start-Process).Parameters.ContainsKey('Environment')

if ($supportsEnvParam) {
    $p = Start-Process -FilePath $ctx.agy_exe -ArgumentList $agyArgs `
            -NoNewWindow -Wait -PassThru `
            -RedirectStandardOutput $OutJsonPath -RedirectStandardError $stderrP `
            -Environment $envMap `
            -WorkingDirectory $ProjectRoot
} else {
    # PS 5.1 폴백: 프로세스 환경을 임시로 바꿨다가 되돌린다.
    $saved = @{}
    foreach ($k in $envMap.Keys) {
        $saved[$k] = [Environment]::GetEnvironmentVariable($k,'Process')
        [Environment]::SetEnvironmentVariable($k, $envMap[$k], 'Process')
    }
    try {
        $p = Start-Process -FilePath $ctx.agy_exe -ArgumentList $agyArgs `
                -NoNewWindow -Wait -PassThru `
                -RedirectStandardOutput $OutJsonPath -RedirectStandardError $stderrP `
                -WorkingDirectory $ProjectRoot
    } finally {
        foreach ($k in $saved.Keys) { [Environment]::SetEnvironmentVariable($k, $saved[$k], 'Process') }
    }
}

if ($p.ExitCode -ne 0) { throw "agy exit=$($p.ExitCode). stderr: $stderrP" }

$envelope = Get-Content -LiteralPath $OutJsonPath -Raw -Encoding UTF8 | ConvertFrom-Json
if ($envelope.status -ne 'SUCCESS') { throw "agy status=$($envelope.status): $($envelope.error)" }

# 토큰 사용량 로깅 (05a §16 의 일일 상한 강제에 사용)
$u = $envelope.usage
Write-Host ("usage: in={0} out={1} think={2} cacheRead={3} total={4} dur={5}s" -f `
    $u.input_tokens, $u.output_tokens, $u.thinking_tokens, $u.cache_read_tokens, $u.total_tokens, $envelope.duration_seconds)
exit 0

-Environment 가 왜 중요한가: $env:USERPROFILE 을 배치 프로세스 전체에 설정하면 Python 의 expanduser, pip 캐시, %TEMP% 해석이 함께 어긋난다. agy 자식 프로세스에만 적용해야 한다. PowerShell 7.4 부터 -Environment 가 정확히 이 일을 한다("Specifies one or more environment variables to override for the process as a hash table"). 실측 머신은 7.6.5 이므로 사용 가능하다. 5.1 폴백은 finally 로 원복한다.


13. 운영 체크리스트

13.1 최초 1회 프로비저닝

  • agy 를 사용자 세션에서 대화형으로 1회 실행해 로그인을 마친다.
  • cmdkey /list:gemini:antigravity 로 자격증명 등록을 확인한다.
  • powershell -File scripts\ensure_agy.ps1 -CheckOnly → exit 0 확인.
  • scripts\register_login_protocol.ps1 실행 → Start-Process 'dmf-agy-login:' 으로 창이 뜨는지 테스트.
  • Install-Module BurntToast -Scope CurrentUser -Force (실측 결과 미설치였다). 08 §11.2 참조.
  • state\agy-home\.gemini\antigravity-cli\settings.json 이 §9.3 내용대로 생성됐는지 확인.
  • .gitignore 에 다음을 추가: state/agy-home/, state/gemini_api_key.sec, state/agy_context.json, logs/
  • 일일 배치 작업을 /RP * (암호 저장) 로 등록. /NP 금지.
  • 알림 작업을 "사용자가 로그온한 경우에만 실행" 으로 등록.
  • 주간 업데이트 작업을 일요일 05:00 로 등록.

13.2 매 배치 실행 시 (스크립트가 자동으로 수행)

  • 실행 계정이 서비스 계정이 아닌지 확인 → 아니면 exit 30
  • agy.runlock 배타 잠금 획득 → 실패 시 exit 13
  • %LOCALAPPDATA%\agy\bin\agy.exe 절대경로 사용 (PATH 미사용)
  • --version 79 ms 프로브
  • CredRead('gemini:antigravity') 프로브 (0 토큰)
  • 격리 프로필 settings.json 동기화
  • agy models 프로브 (0 토큰, 3.1 s)
  • AGY_CLI_DISABLE_AUTO_UPDATE=true 를 자식 환경에 설정
  • state\agy_context.json 기록
  • 실패 시 state\auth_required.json 기록 후 정상 종료(배치는 계속)

13.3 하지 말아야 할 것 (금지 목록)

금지
schtasks /NP (암호 저장 안 함 = S4U) 자격 증명 관리자 접근 불가 → 인증 실패(§6.5)
SYSTEM 계정으로 작업 등록 winget 미지원 + 자격증명·PATH·LOCALAPPDATA 전부 다름
irm … install.ps1 | iex 실패가 종료 코드로 전달되지 않는다(§4.2)
winget list 로 버전 판정 실측 14 릴리스만큼 낡아 있었다(§3.1)
where agy / PATH 의존 낡은 WinGet\Links 사본이 잡힐 수 있다(§3.3)
update.lock 존재로 업데이트 진행 판정 항상 존재한다. 100% 오탐(§5.2)
토큰 파일의 expiry 로 만료 판정 항상 과거다. 100% 오탐(§6.3)
로그의 You are not logged into Antigravity. 로 미인증 판정 정상 실행에서도 수십 번 찍힌다(§6.3)
--dangerously-skip-permissions 프롬프트에 외부 크롤링 텍스트가 들어간다(§9.4)
사용자 전역 settings.json 수정 다른 8개 워크스페이스 작업이 깨진다(§9.2)
배치 프로세스 전체에 $env:USERPROFILE 변경 Python·pip·TEMP 가 함께 어긋난다(§12.6)
배치에서 agy update 호출 실행 중 바이너리 교체 위험(§5.4)
배치 프로세스가 직접 창/토스트를 띄움 사용자가 로그오프 상태일 수 있다(§7.1)
인증 헬스체크로 프롬프트 실행 28k 토큰이 든다. agy models 로 충분(§6.2)

13.4 장애 대응 플레이북

증상 확인 명령 조치
exit 30 whoami / 작업 스케줄러 "보안 옵션" 탭 S4U 해제, /RP * 로 재등록
exit 11 + CRED_NOT_FOUND cmdkey /list:gemini:antigravity agy_login_window.ps1 실행
exit 11 + AGY_MODELS_EMPTY agy models 직접 실행 재로그인. 계정 권한 취소 여부 확인
exit 13 반복 state\agy.runlock 핸들 주간 업데이트 작업이 멈춰 있는지 확인, 프로세스 정리
exit 21 logs\ensure_agy_*.log 자동 재시도 금지. 네트워크 경로/프록시 변조 여부 확인 후 수동 설치
버전이 배치 중 바뀜 logs\ 의 before/after 버전 AGY_CLI_DISABLE_AUTO_UPDATE 가 자식에 전달됐는지 확인
디스크 급증 dir %LOCALAPPDATA%\agy\bin\*.old 주간 작업의 .old 정리가 도는지 확인(개당 186 MB)
토스트가 안 뜸 logs\notify_auth.log BurntToast 설치, 집중 지원 모드 해제, 08 §11 참조

부록 A. 출처 목록

A.1 Antigravity 공식

제목 URL 확인
Antigravity CLI — Installation & Auth https://antigravity.google/docs/cli/install 직접 열람 (인증 4방식, modelProvider: gemini, GEMINI_API_KEY, GOOGLE_GEMINI_BASE_URL, /logout 제약)
Windows 설치 스크립트 전문 https://antigravity.google/cli/install.ps1 원본 다운로드 후 전체 코드 정독 ($isSourced 분기·오류 문자열·스테이징 경로·핸드오프)
자동 업데이터 매니페스트 (x64) https://antigravity-cli-auto-updater-974169037036.us-central1.run.app/manifests/windows_amd64.json 직접 열람. version=1.1.24, sha512=8d45e36d…
macOS/Linux 설치 스크립트 https://antigravity.google/cli/install.sh 미열람 (Windows 전용 프로젝트)
Windows CMD 설치 스크립트 https://antigravity.google/cli/install.cmd 미열람
Antigravity CLI — Headless / Permissions / Settings / Modes / Troubleshooting / Best Practices / Credits / Reference (05a 부록 A 참조) 05a 에서 확인됨. 이 문서는 재인용만
Antigravity CLI — Plans / Quota https://antigravity.google/docs/plans 미열람 (05a 부록 B 미해결 항목)

A.2 Microsoft — winget

제목 URL 확인
Use WinGet to install and manage applications https://learn.microsoft.com/en-us/windows/package-manager/winget/ 직접 열람 (전역 옵션표, 지원 설치 포맷, App Installer 등록 주의)
install Command https://learn.microsoft.com/en-us/windows/package-manager/winget/install 직접 열람 (전체 옵션표, --accept-* 원문, 로컬 매니페스트)
Debugging and troubleshooting issues with WinGet https://learn.microsoft.com/en-us/windows/package-manager/winget/troubleshooting 직접 열람 — "System Context" 절이 이 문서의 §4.3 결론 근거
WinGet Return codes (returnCodes.md) https://raw.githubusercontent.com/microsoft/winget-cli/master/doc/windows/package-manager/winget/returnCodes.md 직접 열람 (전체 HRESULT 표)
winget-pkgs — Google.AntigravityCLI 1.1.23 installer manifest https://raw.githubusercontent.com/microsoft/winget-pkgs/master/manifests/g/Google/AntigravityCLI/1.1.23/Google.AntigravityCLI.installer.yaml 전문 확인 (InstallerType: portable, SHA256 2종)
winget-cli issue #4422 — SYSTEM account (PS module) https://github.com/microsoft/winget-cli/issues/4422 제목·상태(Open) 확인
winget-cli issue #548 — Usage with System Account https://github.com/microsoft/winget-cli/issues/548 제목·상태(Closed) 확인
winget-cli issue #2937 — App install using System Account fails (0x80070520) https://github.com/microsoft/winget-cli/issues/2937 제목·상태(Closed) 확인
winget-cli issue #3151 https://github.com/microsoft/winget-cli/issues/3151 열람했으나 주제 무관(private REST source). 이 문서에 미반영

A.3 Microsoft — Windows / PowerShell

제목 URL 확인
Interactive Services (Session 0 격리) https://learn.microsoft.com/en-us/windows/win32/services/interactive-services 직접 열람 (WTSSendMessage / CreateProcessAsUser / NoInteractiveServices / 세션 0 원문)
Security Contexts for Tasks https://learn.microsoft.com/en-us/windows/win32/taskschd/security-contexts-for-running-tasks 직접 열람 (TASK_LOGON_PASSWORD / TASK_LOGON_S4U / Logon as Batch / RunLevel)
CredReadW function (wincred.h) https://learn.microsoft.com/en-us/windows/win32/api/wincred/nf-wincred-credreadw 직접 열람 — "Network logon sessions do not have an associated credential set." 가 §6.5 의 핵심 근거
Start-Process (PowerShell 7.6) https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.management/start-process 직접 열람 (-WindowStyle / -NoNewWindow 배타 / -Environment (7.4+) / 비동기 기본 / 원격 세션 주의)
about_Execution_Policies (PowerShell 7.6) https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_execution_policies 직접 열람 (정책 6종, 범위 우선순위, -ExecutionPolicy 로 Process 범위 설정, 로그온 스크립트 Zone check 함정, curl/irm 은 MotW 미부착)
Task Security Context (구 URL) https://learn.microsoft.com/en-us/windows/win32/taskschd/task-security-context HTTP 404. 위의 security-contexts-for-running-tasks 로 대체

A.4 Google — Gemini API

제목 URL 확인
Gemini API — Rate limits https://ai.google.dev/gemini-api/docs/rate-limits 직접 열람 (Free / Tier 1·2·3 조건, billing cap, 10분당 지출 한도, RPM/TPM/RPD 축, Batch enqueued tokens)

A.5 검색 엔진 (WebSearch 예산 소진으로 HTML 우회 시도)

대상 URL 결과
DuckDuckGo HTML https://html.duckduckgo.com/html/?q=… CAPTCHA 페이지 반환 — 사용 불가
Bing HTML https://www.bing.com/search?q=… △ 페이지는 열렸으나 스니펫이 비어 실질 정보 없음
GitHub Search API https://api.github.com/search/issues?q=repo:microsoft/winget-cli+SYSTEM+account+in:title 유효 — 검색 대안으로 이것이 가장 잘 동작했다

후속 조사자에게: 이 세션에서 검색이 필요할 때 가장 잘 동작한 우회 경로는 GitHub / npm / raw.githubusercontent 의 공개 REST API 였다. Bing/DDG HTML 은 신뢰할 수 없다.

A.6 로컬 실측 (이 문서의 1차 자료)

모든 값은 2026-09-02 밤 C:\Users\encep 계정으로 직접 실행해 얻었다.

# 명령 / 확인 얻은 사실 인용 위치
1 where.exe agy 2개 경로 §3.1
2 …\agy\bin\agy.exe --version 1.1.24, exit 0, 79 ms §3.1, §6.2
3 …\WinGet\Links\agy.EXE --version 1.1.22 §3.1
4 winget list --id Google.AntigravityCLI 1.1.10 (available 1.1.23) §3.1
5 winget --version v1.29.250 헤더
6 winget list --id No.Such.Package.Xyz -e exit -1978335212 = 0x8A150014 §3.2, §4.3
7 dir %LOCALAPPDATA%\agy\bin agy.exe 187,601,560 B + agy.exe.1788354501993998300.old 186,767,512 B §3.1, §5.6
8 dir %LOCALAPPDATA%\Microsoft\WinGet\Links agy.EXE실파일 복사본(다른 패키지는 심볼릭 링크) §3.1
9 Get-ExecutionPolicy -List LocalMachine=RemoteSigned, Process=Bypass §4.5
10 Machine/User PATH 분리 확인 agy\binUser PATH 에만 존재 §3.3
11 $env:PATH='C:\Windows\System32'; Get-Command agy $null §3.2
12 $PSVersionTable.PSVersion 7.6.5 헤더, §12.6
13 cmdkey /list:gemini:antigravity Target: gemini:antigravity, Generic, User antigravity, Local machine persistence §6.1
14 CredReadW P/Invoke Type=1, Persist=2, BlobSize=504, LastWritten=2026-09-02T14:27:08.0217590Z §6.1
15 블롭 JSON 키 파싱 {token:{access_token,token_type,refresh_token,expiry}, auth_method:"consumer"}, expiry = 기록+1h §6.1, §6.4
16 CredReadW('gemini:no-such-target-xyz') false, GetLastError = 1168 §6.2, §11
17 토큰 파일 antigravity-oauth-token 504 B, mtime 2026-08-30 09:32, expiry 2026-08-30T10:32 (만료) §6.1, §6.3
18 USERPROFILE 치환 후 agy -p 실행 status:SUCCESS, exit 0, 토큰 파일 없이 인증 성공, input_tokens 14,056, 2.13 s §6.1, §9.2, §5.7
19 격리 홈에 생성된 트리 jetski_state.pbtxt, .gemini/config/{config.json,mcp_config.json,.migrated,projects}, AppData/Local/ms-playwright-go05a §12 미기재 항목 §9.2
20 격리 홈의 cli-*.log error getting token source: You are not logged into Antigravity. 가 수십 회 → 이어서 server.go:2873] Auth succeeded §6.3
21 같은 로그 model_configs.go:62] Auth mode is unspecified, skipping fetchAvailableModels and returning empty response §6.2
22 agy models exit 0, 3,121 ms, 11개 모델 §6.2
23 agy update (이미 최신) exit 0, 452 ms, ⟳ Checking for updates... (current version 1.1.24) / ✓ You are already on the latest version. §5.3
24 updater/update_status.json {"success":true,"message":"Already on the latest version."} / 이전 값 {"success":true,"message":"Update successful, restart CLI to use"} §5.2
25 updater/update.lock 0 B, mtime 2026-07-26 20:00:55, agy update 후에도 불변 §5.2
26 last_check.timestamp 0 B — 내용 없음, mtime 만 의미 §5.2
27 agy --help (1.1.24) 서브커맨드 10종. login/logout 없음. --version 은 목록에 없으나 동작 §5.3
28 PS 7.6.5 에서 WinRT 토스트 타입 로드 FAILUnable to find type [Windows.UI.Notifications.ToastNotificationManager…] §7.3
29 PS 5.1 에서 동일 타입 로드 OK §7.3
30 Get-Module -ListAvailable BurntToast 미설치 §7.3, §13.1
31 Get-Command msg.exe C:\WINDOWS\system32\msg.exe 존재 §7.5
32 사용자 전역 settings.json permissions.allowcommand(*.exe) 존재, trustedWorkspaces 8개(DMF_Crawler 없음), modelProvider 없음 §9.2

부록 B. 미해결 질문 / 실측 필요 항목

B.1 인증·자격증명 (최우선)

  • B-1. 자격 증명 관리자 항목을 지우면 agy 는 토큰 파일로 폴백하는가? 파괴적 실험이라 하지 않았다. cmdkey /delete:gemini:antigravityagy models 를 돌려 확인해야 한다. Set-AuthRequiredFlagCRED_NOT_FOUND 판정이 오탐일 가능성이 여기 걸려 있다. (권장: 먼저 CredReadW 로 블롭을 백업한 뒤 실험)
  • B-2. S4U 작업에서 CredRead 가 실제로 1312 를 반환하는가? schtasks /Create … /NP 로 테스트 작업을 만들어 ensure_agy.ps1 -CheckOnly 를 돌리면 5분이면 확정된다. §6.5 결론의 유일한 미검증 고리다.
  • B-3. 미인증 상태에서 agy models 가 실제로 빈 목록을 반환하는가? 로그 문자열(Auth mode is unspecified, … returning empty response)에서 추론했을 뿐이다. B-1 실험과 함께 확인 가능.
  • B-4. 미인증 상태에서 agy -p … --output-format json 봉투의 error 문자열과 종료 코드는 정확히 무엇인가? 배치 본문의 인증 실패 감지 로직을 정밀화하려면 필요하다.
  • B-5. auth_method: "consumer" 외에 어떤 값이 있는가? (business? api_key?) 로그에 SetEnableBusinessLogin called with enable: true 가 있었다. 기업 계정에서의 동작이 다를 수 있다.
  • B-6. Google OAuth refresh_token 의 실제 무효화 조건과 주기. 6개월 미사용, 비밀번호 변경, 앱 권한 취소 — 어느 것이 이 CLI 에 적용되는지 문서에 없다.
  • B-7. modelProvider: "gemini" + GEMINI_API_KEY 경로에서 agy models 목록이 어떻게 달라지는가? 특히 claude-sonnet-4-6 / claude-opus-4-6-thinking / gpt-oss-120b-medium 이 사라지는지. §8.3 의 채택 결론이 여기에 의존한다.

B.2 격리 프로필 (신규 설계의 검증)

  • B-8. USERPROFILE 치환 격리가 agy 버전 업그레이드 후에도 유지되는가? 자격증명 해석 경로가 바뀌면 조용히 깨진다. 주간 업데이트 작업이 ensure_agy.ps1 -CheckOnly 를 돌리도록 만들어 뒀지만, 버전마다 재확인이 필요하다.
  • B-9. 격리 프로필의 permissions 가 실제로 적용되는가? deny: command(cmdkey) 상태에서 agy 에게 cmdkey /list 를 시켜 soft-deny 되는지 확인해야 한다. 05a §9.2 의 "공백 분리 토큰 앵커 정규식" 해석이 맞는지도 함께.
  • B-10. 격리 프로필에서 MCP 서버가 정말 기동되지 않는가? .gemini/config/mcp_config.json 이 빈 채로 생성된 것은 확인했으나, agy mcp list 로 0개임을 확인해야 한다.
  • B-11. input_tokens 28,317 vs 14,056 의 진짜 원인. 통제 실험: 같은 버전·같은 cwd 에서 프로필만 바꿔 3회씩 측정. 배치 비용 설계에 직결된다(05a 부록 B 와 중복 항목).
  • B-12. 격리 프로필의 installation_id 가 새로 생기는 것이 크레딧·쿼터에 영향을 주는가? 계정 단위 과금이면 무해하지만 확인 필요.
  • B-13. jetski_state.pbtxtpost_onboarding.completed_steps 가 비어 있으면 agy -p 가 온보딩을 요구하는가? 격리 프로필 첫 실행에서는 자동 생성됐고 즉시 동작했지만, 조건이 다르면 막힐 수 있다.

B.3 설치·업데이트

  • B-14. install.ps1-File 로 실행할 때의 종료 코드를 실제로 확인. $isSourced=false 경로가 정말 exit 1 을 주는지, 이미 설치된 상태에서 exit 0 인지 검증(현재는 코드 정독 기반 추론).
  • B-15. ConstrainedLanguage / AppLocker 환경에서 install.ps1 이 동작하는가? Get-FileHash 스킵 → certutil 폴백 경로가 실제로 도는지.
  • B-16. 프록시 환경에서의 동작. PS7 이 HTTPS_PROXY 를 존중한다는 것은 알려져 있으나, install.ps1 이 5.1 로 실행될 때 시스템 프록시를 타는지 실측 필요.
  • B-17. agy update 가 실행 중인 다른 agy 인스턴스가 있을 때 어떻게 동작하는가? 종료 코드와 update_status.json 값. §5.5 의 락 설계가 과잉인지 필수인지 결정된다.
  • B-18. .old 파일을 삭제해도 agy 가 정상 동작하는가? 롤백 기능이 그걸 참조하고 있을 가능성. §12.2 의 정리 로직이 위험할 수 있다.
  • B-19. winget 으로 설치된 WinGet\Links\agy.EXE 를 제거해도 되는가? 두 사본이 공존하는 현재 상태가 혼란의 원인이다. winget uninstall Google.AntigravityCLI 로 정리하는 것이 옳은지.

B.4 알림·프롬프트 창

  • B-20. §12.3 의 WinRT 토스트 AUMID 가 이 머신에서 실제로 표시되는가? {1AC14E77-…}\WindowsPowerShell\v1.0\powershell.exe 값의 유효성.
  • B-21. dmf-agy-login: 프로토콜 버튼이 토스트에서 실제로 스크립트를 띄우는가? HKCU 프로토콜 등록 후 토스트 클릭까지 end-to-end 테스트.
  • B-22. TASK_LOGON_PASSWORD 작업에서 Start-Process -WindowStyle Normal 이 사용자 데스크톱에 창을 띄우는가? 문서상으로는 사용자 세션이 있으면 보일 것으로 보이나, "로그온 여부에 관계없이 실행" 작업이 만드는 세션의 성격을 실측해야 한다. (이 문서의 설계는 띄우지 않는 쪽을 택했으므로 결론이 바뀌지는 않지만, 바뀌면 아키텍처를 단순화할 수 있다.)
  • B-23. 15분 반복 트리거를 schtasks 만으로 등록할 수 있는가? /SC ONLOGON 과 반복 트리거를 함께 주려면 XML 이 필요할 수 있다 → 08 문서와 협의.

B.5 05a 문서에 반영해야 할 정정 사항

이 문서가 05a 와 충돌하는 지점. 05a 소유자가 반영해야 한다.

05a 위치 05a 서술 이 문서의 실측 조치
§0 한눈에 보기 3번째 불릿 "인증 토큰은 파일에 저장된다 … 실측 머신의 cmdkey /list 에는 관련 항목이 없었다" gemini:antigravity 항목이 존재하고, 그쪽이 정본이다 정정 필요
§4.2 표 "Windows Credential Manager 를 쓰지 않음(실측)" → "S4U 로 실행해도 키링 접근 실패 위험이 낮다" 정반대. S4U 는 자격 증명 관리자 접근이 불가하다 정정 필요 — 운영 리스크 직결
§4.3 헬스체크로 PONG 프롬프트(28k 토큰) 권장 CredRead + agy models0 토큰 대체 가능 갱신 권장
§3.5 winget SYSTEM 미동작 "⚠️ 미검증" 공식 문서로 확정 확정 표시로 갱신
§12 디렉터리 목록 jetski_state.pbtxt, ~/.gemini/config/ 계층 없음 추가 발견 보강 권장
§16 체크리스트 "SYSTEM 계정은 토큰 파일에 접근할 수 없다" 이유가 파일이 아니라 자격 증명 관리자 근거 문구 정정

B.6 이 문서 자체의 한계

  • 이 문서의 핵심 결론(자격 증명 관리자가 정본)은 단일 머신 · 단일 계정 · agy 1.1.24 에서의 관측이다. 다른 버전·다른 인증 방식(business 계정)에서 달라질 수 있다.
  • USERPROFILE 격리 설계는 문서화되지 않은 동작에 의존한다. Google 이 이를 보장하지 않으므로 버전 업그레이드마다 §11 의 P3 검증이 반드시 통과해야 한다. 실패 시 자동으로 격리를 끄는 폴백을 스크립트에 넣어 두었다.
  • §11/§12 의 스크립트는 작성 후 실행 검증을 하지 못했다(프로젝트 루트가 아직 비어 있다). 첫 배포 시 -CheckOnly 로 드라이런한 뒤 사용하라.

이 문서는 agy 프로비저닝의 SSOT 다. 새로운 사실을 확인하면 여기를 갱신하고, 05a 와 충돌하는 사실이 나오면 부록 B.5 표에 추가한 뒤 05a 소유자에게 통보한다.