- 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 문서 지도 갱신
2944 lines
169 KiB
Markdown
2944 lines
169 KiB
Markdown
# AGY CLI 자동 부트스트랩 · 설치 · 인증 자동화 설계
|
||
|
||
> **이 문서의 역할**: `agy` (Google Antigravity CLI) 가 **없거나 / 낡았거나 / 로그인이 풀렸을 때**, 06:00 무인 배치가 스스로 복구하거나 사용자에게 정확히 개입을 요청하도록 만드는 **부트스트랩 정본**이다. CLI 자체의 사용법·플래그·출력 포맷은 [`05a-agy-cli-ssot.md`](./05a-agy-cli-ssot.md) 가 정본이며 이 문서는 **그 위에 프로비저닝 계층만 쌓는다**. 스케줄러 작업 구성·토스트 라이브러리 선택·Session 0 일반론은 [`08-windows-scheduling-and-resilience.md`](./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 /list` 에 `Target: 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.exe` 가 **3개, 서로 다른 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` 는 쓰지 않는다.** 대신 격리 프로필의 `permissions` 에 **`deny: read_url(*), execute_url(*), mcp(*)`** 를 넣어 프롬프트 인젝션 표면을 물리적으로 잘라낸다. 크롤링은 Python 이 하고 agy 는 텍스트만 요약한다.
|
||
10. **사용자 개입 창은 배치 프로세스가 직접 띄우지 않는다.** 06:00 에 사용자가 로그온해 있지 않을 수 있고, 비대화형 세션에서는 창이 보이지 않을 수 있다. **배치는 `state/auth_required.json` 플래그만 쓰고**, "로그온할 때만 실행"으로 등록된 **별도 대화형 작업**이 그 플래그를 보고 콘솔 창·토스트·MessageBox 를 띄운다(§7).
|
||
|
||
---
|
||
|
||
## 1. 목차
|
||
|
||
- [2. 이 문서의 경계 — 05a / 08 과 무엇이 다른가](#2-이-문서의-경계--05a--08-과-무엇이-다른가)
|
||
- [3. 탐지 — agy 가 설치되어 있는가](#3-탐지--agy-가-설치되어-있는가)
|
||
- [4. 설치 자동화](#4-설치-자동화)
|
||
- [5. 버전 관리와 자동 업데이트](#5-버전-관리와-자동-업데이트)
|
||
- [6. 인증 부트스트랩 — 이 문서의 핵심](#6-인증-부트스트랩--이-문서의-핵심)
|
||
- [7. Windows 프롬프트 창 띄우기 설계](#7-windows-프롬프트-창-띄우기-설계)
|
||
- [8. GEMINI_API_KEY 대체 경로](#8-gemini_api_key-대체-경로)
|
||
- [9. 권한 사전 승인 설계](#9-권한-사전-승인-설계)
|
||
- [10. 종료 코드 규약](#10-종료-코드-규약)
|
||
- [11. scripts/ensure_agy.ps1 전체 코드](#11-scriptsensure_agyps1-전체-코드)
|
||
- [12. 부속 스크립트 전체 코드](#12-부속-스크립트-전체-코드)
|
||
- [13. 운영 체크리스트](#13-운영-체크리스트)
|
||
- [부록 A. 출처 목록](#부록-a-출처-목록)
|
||
- [부록 B. 미해결 질문 / 실측 필요 항목](#부록-b-미해결-질문--실측-필요-항목)
|
||
|
||
---
|
||
|
||
## 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개다
|
||
|
||
```text
|
||
$ 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 가 이전 바이너리를 지우지 않는다**:
|
||
|
||
```text
|
||
-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
|
||
```
|
||
|
||
업스트림 매니페스트 실측 응답(전문):
|
||
|
||
```json
|
||
{
|
||
"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 매니페스트 실측(전문):
|
||
|
||
```yaml
|
||
# 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 agy` 는 **PATH 순서에 따라 낡은 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\bin` 은 **User PATH 에만** 있고 Machine PATH 에 없다 → SYSTEM 세션·서비스에서는 절대 못 찾는다 | ⚠️ 진단용으로만 |
|
||
| 4 | `winget list` | `winget list --id Google.AntigravityCLI -e --disable-interactivity` | ~1–3 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 구성:
|
||
|
||
```text
|
||
Machine PATH : (agy 관련 항목 없음)
|
||
User PATH : C:\Users\encep\AppData\Local\agy\bin
|
||
C:\Users\encep\AppData\Local\Microsoft\WinGet\Links
|
||
...
|
||
```
|
||
|
||
- `agy\bin` 은 **User 범위 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 채택 탐지 알고리즘
|
||
|
||
```text
|
||
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.ps1` — `irm | iex` 를 쓰면 안 되는 이유
|
||
|
||
스크립트 전문을 내려받아 읽었다. 핵심 분기:
|
||
|
||
```powershell
|
||
$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 '.')
|
||
```
|
||
|
||
그리고 마지막:
|
||
|
||
```powershell
|
||
$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 $setupFlags` 를 `try/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](https://github.com/microsoft/winget-cli/issues/4422) | Add support for SYSTEM account when using Winget PowerShell Module | Open |
|
||
| [#548](https://github.com/microsoft/winget-cli/issues/548) | Usage with System Account | Closed |
|
||
| [#2937](https://github.com/microsoft/winget-cli/issues/2937) | App install using System Account fails (`0x80070520`) | Closed |
|
||
|
||
**정리한 결론**:
|
||
|
||
| 실행 컨텍스트 | winget 동작 | 이 프로젝트에서 |
|
||
|---|---|---|
|
||
| 대화형 사용자 세션 | ✅ 동작 | 수동 진단용으로만 허용 |
|
||
| 작업 스케줄러 / 사용자 계정 / 암호 저장(`TASK_LOGON_PASSWORD`) | ✅ 동작(패키지가 그 사용자에게 등록돼 있어야 함) | ❌ **버전 신뢰 불가(§3.1)** 이므로 미사용 |
|
||
| 작업 스케줄러 / 사용자 계정 / S4U | ⚠️ 대체로 동작하나 자격 증명이 필요한 작업은 실패 | ❌ |
|
||
| 작업 스케줄러 / SYSTEM, LocalService, NetworkService | ❌ **공식 미지원** | ❌ 금지 |
|
||
|
||
무인 실행 시 필요한 플래그(참고용, 실제로는 안 씀):
|
||
|
||
```powershell
|
||
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`
|
||
|
||
실측한 종료 코드:
|
||
|
||
```text
|
||
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` 로 플랫폼 결정 (`AMD64`→`windows_amd64`, `ARM64`→`windows_arm64`)
|
||
2. `GET https://antigravity-cli-auto-updater-974169037036.us-central1.run.app/manifests/<platform>.json` → `version` / `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)
|
||
|
||
실측:
|
||
|
||
```text
|
||
Scope ExecutionPolicy
|
||
----- ---------------
|
||
MachinePolicy Undefined
|
||
UserPolicy Undefined
|
||
Process Bypass
|
||
CurrentUser Undefined
|
||
LocalMachine RemoteSigned
|
||
```
|
||
|
||
- 유효 정책은 `LocalMachine = RemoteSigned`. 인터넷에서 내려받은 `install.ps1` 은 **Mark-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 설치 경로 결정 트리
|
||
|
||
```text
|
||
[탐지 결과]
|
||
├─ 정상 설치 + --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/` 디렉터리:
|
||
|
||
```text
|
||
-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"}`<br>최신 상태에서 `agy update` 직후: `{"success":true,"message":"Already on the latest version."}` | **사람이 읽을 수 있는 마지막 결과.** mtime 이 마지막 업데이트 시각. 부트스트랩은 이걸 읽어 로그에 남긴다 |
|
||
| `../last_check.timestamp` | **0 바이트** 파일 | **내용이 없다. 의미는 mtime 뿐이다**(실측 2026-09-02 22:08 = 마지막 업데이트 확인 시각) |
|
||
|
||
05a 가 인용한 경고 문자열은 그대로 유효하다:
|
||
|
||
```text
|
||
Warning: another background updater process is already active (update.lock)
|
||
```
|
||
|
||
이 문자열은 **stderr 로 나올 때만** 의미가 있다. 파일 존재는 의미가 없다.
|
||
|
||
### 5.3 `agy update` 실측
|
||
|
||
```text
|
||
$ 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 --help` 는 `Usage of update:` 만 출력) |
|
||
|
||
`agy --help` 실측(1.1.24)에서 확인한 서브커맨드 목록 — **로그인/로그아웃 서브커맨드는 존재하지 않는다**:
|
||
|
||
```text
|
||
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중 방어**:
|
||
|
||
```powershell
|
||
# 방어 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).
|
||
|
||
```powershell
|
||
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 재실측:
|
||
|
||
```text
|
||
$ 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 로 메타데이터를 읽은 결과:
|
||
|
||
```text
|
||
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 로 디코드해 **값이 아니라 키만** 확인한 결과:
|
||
|
||
```text
|
||
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
|
||
```
|
||
|
||
한편 파일 쪽:
|
||
|
||
```text
|
||
$ 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/` 트리가 **새로** 만들어지고 **토큰 파일은 존재하지 않는다.** 그런데도:
|
||
|
||
```json
|
||
{"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 로그에서 확인한 문자열:
|
||
|
||
```text
|
||
I0902 23:27:07.721484 model_configs.go:62] Auth mode is unspecified, skipping fetchAvailableModels and returning empty response
|
||
```
|
||
|
||
즉 **인증되지 않으면 `agy models` 가 빈 목록을 반환**한다. 정상 상태의 실측 출력(11줄):
|
||
|
||
```text
|
||
$ 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`)에 나타난 문자열들(실측 발췌):
|
||
|
||
```text
|
||
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`** 뿐이다.
|
||
|
||
| 신호 | 신뢰도 | 사용처 |
|
||
|---|---|---|
|
||
| `CredRead` → `ERROR_NOT_FOUND (1168)` | ★★★ 확정 | 미인증 (한 번도 로그인 안 함 / `/logout` 함) |
|
||
| `CredRead` → `ERROR_NO_SUCH_LOGON_SESSION (1312)` | ★★★ 확정 | **잘못된 실행 컨텍스트**(S4U/네트워크 로그온) → exit 30 |
|
||
| `agy models` 출력 0줄 | ★★☆ | 자격증명이 서버에서 거부됨(만료·취소) |
|
||
| JSON 봉투 `status:"ERROR"` + `error` 에 auth 관련 문구 | ★★☆ | 실작업 중 인증 실패 |
|
||
| 로그의 `You are not logged into Antigravity.` | ★☆☆ **오탐** | 진단 참고용으로만 |
|
||
| 파일 `antigravity-oauth-token` 의 `expiry` | ☆ **항상 과거** | **사용 금지** |
|
||
| 파일 존재 여부 | ★☆☆ | 보조 신호(자격 증명 관리자가 우선) |
|
||
|
||
### 6.4 access_token 수명과 갱신
|
||
|
||
- 실측: `expiry` = 기록 시각 + **정확히 1시간**. (`LastWritten 2026-09-02T14:27:08Z` → `expiry 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` 로 안전하게 등록하는 형태(암호 저장 방식):
|
||
|
||
```cmd
|
||
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)한다.
|
||
|
||
```text
|
||
D:\workspace\DMF_Crawler\state\auth_required.json
|
||
```
|
||
|
||
```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" 의 파일 기반 축소판이다**:
|
||
|
||
```text
|
||
┌─ 작업 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 를 띄우고 사용자가 로그인을 마치게 하는 것**이 유일한 방법이다.
|
||
|
||
```powershell
|
||
# 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 — 토스트 알림 + 프로토콜 활성화 버튼
|
||
|
||
**실측한 제약(중요)**:
|
||
|
||
```text
|
||
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 에 등록한다(관리자 권한 불필요).
|
||
|
||
```powershell
|
||
# 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 로 버튼 두 개짜리 토스트를 띄우는 형태:
|
||
|
||
```powershell
|
||
# 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-BTButton` 의 `ActivationType` 공식 설명: *"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(대화형 작업)에서만 유효하다.**
|
||
|
||
```powershell
|
||
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` 존재.
|
||
|
||
```cmd
|
||
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` 에 다음을 넣는다:
|
||
|
||
```json
|
||
{
|
||
"modelProvider": "gemini"
|
||
}
|
||
```
|
||
|
||
- 환경변수를 설정한다:
|
||
|
||
```bash
|
||
export GEMINI_API_KEY="your-api-key"
|
||
```
|
||
|
||
- 대체 엔드포인트:
|
||
|
||
```bash
|
||
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 용 완결 설정 예시(격리 프로필 기준):
|
||
|
||
```json
|
||
{
|
||
"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 에서 키 주입(작업 스케줄러 래퍼 안):
|
||
|
||
```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회, 대화형):
|
||
|
||
```powershell
|
||
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` 은 다음을 담고 있다(문제의 크기를 보여주기 위해 인용):
|
||
|
||
```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 실행이 무조건 승인된다.** 대화형 사용에는 편하지만 **배치에서는 인젝션의 완벽한 발판**이다.
|
||
- `trustedWorkspaces` 에 `D:\workspace\DMF_Crawler` 는 **없다.**
|
||
- 여기에 이 프로젝트용 `deny` 를 추가하면 사용자의 다른 8개 워크스페이스 작업이 깨진다.
|
||
|
||
**해결책 — `USERPROFILE` 격리** (§6.1 에서 실증):
|
||
|
||
```powershell
|
||
$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. `USERPROFILE` 은 **agy 자식 프로세스에만** 적용해야 한다. 배치 전체에 적용하면 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`:
|
||
|
||
```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 규칙 + 아래 두 플래그**로 같은 목표(멈추지 않는 배치)를 달성한다:
|
||
|
||
```powershell
|
||
$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 호출 측 패턴:
|
||
|
||
```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` 로 호출.
|
||
|
||
```powershell
|
||
<#
|
||
.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` 안에도 같은 로직이 있지만, **수동 복구용으로 단독 실행 가능한 버전**을 따로 둔다.
|
||
|
||
```powershell
|
||
<#
|
||
.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회 계획 업데이트
|
||
|
||
```powershell
|
||
<#
|
||
.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)
|
||
|
||
```powershell
|
||
<#
|
||
.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` — 재로그인 콘솔 창
|
||
|
||
```powershell
|
||
<#
|
||
.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` — 토스트 버튼용 프로토콜 등록
|
||
|
||
```powershell
|
||
<#
|
||
.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` 을 읽어 **격리 프로필과 절대경로를 그대로 적용**한다.
|
||
|
||
```powershell
|
||
<# 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\bin` 은 **User 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-go` — **05a §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 토스트 타입 로드 | **FAIL** — `Unable 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.allow` 에 `command(*.exe)` 존재, `trustedWorkspaces` 8개(**DMF_Crawler 없음**), `modelProvider` 없음 | §9.2 |
|
||
|
||
---
|
||
|
||
## 부록 B. 미해결 질문 / 실측 필요 항목
|
||
|
||
### B.1 인증·자격증명 (최우선)
|
||
|
||
- [ ] **B-1. 자격 증명 관리자 항목을 지우면 `agy` 는 토큰 파일로 폴백하는가?** 파괴적 실험이라 하지 않았다. `cmdkey /delete:gemini:antigravity` 후 `agy models` 를 돌려 확인해야 한다. **`Set-AuthRequiredFlag` 의 `CRED_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.pbtxt` 의 `post_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 models` 로 **0 토큰** 대체 가능 | 갱신 권장 |
|
||
| §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 소유자에게 통보한다.*
|