# evidence-agent 安裝精靈(install.sh)Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** 在 `evidence-agent` repo 做一支 `deploy/install.sh` 安裝精靈,把客戶端 8 步手動 docker-compose 部署收斂成「解壓一包 → `./install.sh` → 答 4 題 → 完成」。

**Architecture:** `install.sh`(薄進入點:arg 解析 + 串四階段)source `install-lib.sh`(所有函式,純邏輯與 docker 編排分離)。純邏輯函式可在開發機(macOS bash 3.2)直接跑自帶 assert 測試;docker 編排 / 自我驗證在客戶 Linux 上手動驗證。收 8080 改由「base compose 不 publish、demo override 才加回」達成。

**Tech Stack:** POSIX sh / bash(相容 bash 3.2 以利 mac 開發機測試)、docker compose v2、既有 `jedi-file-upload` agent image、自帶 shell assert 測試(無外部框架)。

**實作 repo:** `~/Projects/Billows/Audit-Manager/evidence-agent/`(**非** compliance-manager-be)。動工前先讀該 repo `CLAUDE.md` 與 `deploy/` 下既有檔。

**規範重點(每個 commit 都守):**
- 各 repo 分開 commit、顯式 `git add` 檔名、**禁用 `-am`**、commit 訊息末尾加 `Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>`、**push 等 user 明示**。
- 不切 branch;在 user 當下 branch 工作。
- 精靈**絕不刪** `pgdata`/`filedata`/`certs`;**不**把密碼/token/key 印到 stdout 或寫進 `answers.conf`;`.env` 一律 `chmod 600`。

---

## File Structure

```
evidence-agent/deploy/
├── install.sh                  ← 新增:薄進入點(arg 解析 + 串四階段 + --dry-run)
├── install-lib.sh              ← 新增:所有函式(純邏輯 + docker 編排,可被測試 source)
├── docker-compose.yml          ← 修改:file-agent 不再無條件 publish 8080
├── docker-compose.demo.yml     ← 新增:demo(none 模式)override,加回 8080:8000
├── answers.conf.example        ← 新增:無人值守必填清單範本(無密碼)
├── .env.example                ← 沿用(精靈產生實際 .env)
├── collect-host-id.sh          ← 沿用(精靈自動呼叫)
├── nginx/agent.conf            ← 沿用
├── README.md                   ← 修改:改以 install.sh 為主流程,手動步驟降為附錄
└── test/
    ├── assert.sh               ← 新增:極簡 assert helper(無外部框架)
    └── test_install_lib.sh     ← 新增:對 install-lib.sh 純函式的測試
```

**職責邊界:**
- `install-lib.sh` 函式分群:`log_*`(輸出)、`pf_*`(preflight)、`cfg_*`(設定收集/讀寫/IP 推導/產密碼)、`orch_*`(docker 編排 + 憑證輪詢)、`vrf_*`(自我驗證 + 交棒提示)。
- 純函式(`cfg_*`、`pf_check_port`、`orch_wait_for` 的判斷邏輯)不直接呼叫 docker,可在 mac 測;真正碰 docker 的薄 wrapper 函式(`orch_compose_up`、`vrf_*`)在 Linux 手動驗。
- 測試 source `install-lib.sh` 後只測純函式;碰 docker 的函式用 shell function shadow 樁掉(例如測試內 `docker(){ ...; }`)。

**相容性注意:** 被測試覆蓋的函式一律寫成 bash 3.2 相容(不用關聯陣列 / `${var^^}` 等 4.x 語法),確保 mac 開發機跑得動。`install.sh` 本體可用 bash,但目標執行環境是客戶 Linux。

---

## Task 0: Scaffold + 測試骨架

**Files:**
- Create: `deploy/test/assert.sh`
- Create: `deploy/test/test_install_lib.sh`
- Create: `deploy/install-lib.sh`(空殼,先放 shebang + 區段註解)
- Create: `deploy/install.sh`(空殼,先 `source install-lib.sh` + `echo TODO`)

- [ ] **Step 1: 寫 assert helper**

`deploy/test/assert.sh`:
```sh
#!/usr/bin/env bash
# 極簡 assert helper（無外部框架）。每個 assert 印 PASS/FAIL，累計失敗數。
ASSERT_FAILS=0
assert_eq() { # $1=expected $2=actual $3=msg
  if [ "$1" = "$2" ]; then printf 'PASS: %s\n' "$3"
  else printf 'FAIL: %s\n  expected=[%s]\n  actual=  [%s]\n' "$3" "$1" "$2"; ASSERT_FAILS=$((ASSERT_FAILS+1)); fi
}
assert_contains() { # $1=haystack $2=needle $3=msg
  case "$1" in *"$2"*) printf 'PASS: %s\n' "$3";; *) printf 'FAIL: %s\n  [%s] does not contain [%s]\n' "$3" "$1" "$2"; ASSERT_FAILS=$((ASSERT_FAILS+1));; esac
}
assert_not_contains() { # $1=haystack $2=needle $3=msg
  case "$1" in *"$2"*) printf 'FAIL: %s\n  [%s] unexpectedly contains [%s]\n' "$3" "$1" "$2"; ASSERT_FAILS=$((ASSERT_FAILS+1));; *) printf 'PASS: %s\n' "$3";; esac
}
assert_summary() { if [ "$ASSERT_FAILS" -eq 0 ]; then printf '\nALL PASS\n'; else printf '\n%d FAILED\n' "$ASSERT_FAILS"; exit 1; fi; }
```

- [ ] **Step 2: 寫測試 runner 空殼**

`deploy/test/test_install_lib.sh`:
```sh
#!/usr/bin/env bash
set -u
DIR="$(cd "$(dirname "$0")" && pwd)"
. "$DIR/assert.sh"
. "$DIR/../install-lib.sh"   # source 函式（install-lib.sh 不得在 source 時執行副作用）
# 後續 Task 在此追加測試
assert_summary
```

- [ ] **Step 3: install-lib.sh 空殼**

`deploy/install-lib.sh` 開頭:
```sh
#!/usr/bin/env bash
# evidence-agent 安裝精靈函式庫。被 install.sh 與 test 同時 source。
# 關鍵：本檔被 source 時【不得】有副作用（只定義函式），否則測試會誤跑。
# ── log_* ── / ── pf_* ── / ── cfg_* ── / ── orch_* ── / ── vrf_* ──（各 Task 填入）
```

- [ ] **Step 4: install.sh 空殼**
```sh
#!/usr/bin/env bash
set -eu
DIR="$(cd "$(dirname "$0")" && pwd)"
. "$DIR/install-lib.sh"
echo "TODO: wire stages"
```

- [ ] **Step 5: 跑測試確認骨架可執行**

Run: `bash deploy/test/test_install_lib.sh`
Expected: 印 `ALL PASS`(目前無測試案例)。

- [ ] **Step 6: Commit**
```bash
cd ~/Projects/Billows/Audit-Manager/evidence-agent
git add deploy/test/assert.sh deploy/test/test_install_lib.sh deploy/install-lib.sh deploy/install.sh
git commit -m "feat(install): scaffold 安裝精靈骨架 + 自帶 shell 測試 harness

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```

---

## Task 1: log/prompt 輸出函式

**Files:**
- Modify: `deploy/install-lib.sh`(加 `log_*` / `ask_*`)
- Test: `deploy/test/test_install_lib.sh`

- [ ] **Step 1: 寫失敗測試**(在 runner `assert_summary` 前追加)
```sh
# log_redact 應遮蔽密碼樣式的值
out="$(log_redact "DB_PASSWORD=sup3rs3cret other=ok")"
assert_not_contains "$out" "sup3rs3cret" "log_redact 遮蔽密碼值"
assert_contains "$out" "other=ok" "log_redact 保留非敏感內容"
```

- [ ] **Step 2: 跑測試確認失敗**

Run: `bash deploy/test/test_install_lib.sh`
Expected: FAIL `log_redact: command not found` / 該案例 FAIL。

- [ ] **Step 3: 實作**
```sh
log_info()  { printf '\033[0;36m[i]\033[0m %s\n' "$*"; }
log_ok()    { printf '\033[0;32m[✓]\033[0m %s\n' "$*"; }
log_warn()  { printf '\033[0;33m[!]\033[0m %s\n' "$*" >&2; }
log_err()   { printf '\033[0;31m[✗]\033[0m %s\n' "$*" >&2; }
# 遮蔽 KEY=VALUE 中疑似敏感欄位的值（印 log 前過濾，永不外漏明文）
log_redact() {
  printf '%s' "$1" | sed -E 's/(PASSWORD|SECRET_KEY|TOKEN|ACCESS_KEY)=[^[:space:]]*/\1=***REDACTED***/g'
}
# 互動提問：$1=提示 $2=預設值（可空）；回傳使用者輸入或預設
ask() {
  _p="$1"; _d="${2:-}"
  if [ -n "$_d" ]; then printf '%s [%s]: ' "$_p" "$_d" >&2; else printf '%s: ' "$_p" >&2; fi
  IFS= read -r _ans || true
  if [ -z "$_ans" ]; then printf '%s' "$_d"; else printf '%s' "$_ans"; fi
}
```

- [ ] **Step 4: 跑測試確認通過**

Run: `bash deploy/test/test_install_lib.sh`
Expected: PASS。

- [ ] **Step 5: Commit**
```bash
git add deploy/install-lib.sh deploy/test/test_install_lib.sh
git commit -m "feat(install): log/ask 輸出函式 + log_redact 敏感值遮蔽

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```

---

## Task 2: 預設路由 IP 推導(cfg_derive_ip)

**Files:**
- Modify: `deploy/install-lib.sh`
- Test: `deploy/test/test_install_lib.sh`

- [ ] **Step 1: 寫失敗測試**(用 shell function shadow 樁掉 `ip` 指令)
```sh
# 樁：模擬 `ip route show default` 輸出
ip() { echo "default via 192.168.50.1 dev eth0 src 192.168.50.121 metric 100"; }
assert_eq "192.168.50.121" "$(cfg_derive_ip)" "cfg_derive_ip 從 default route 取 src IP"
unset -f ip
```

- [ ] **Step 2: 跑測試確認失敗**

Run: `bash deploy/test/test_install_lib.sh`
Expected: 該案例 FAIL。

- [ ] **Step 3: 實作**
```sh
# 推導本機對雲端可達的 IP（default route 的 src）。取不到回空字串，由 caller 改問。
cfg_derive_ip() {
  ip route show default 2>/dev/null \
    | sed -nE 's/.* src ([0-9]+\.[0-9]+\.[0-9]+\.[0-9]+).*/\1/p' \
    | head -n1
}
```

- [ ] **Step 4: 跑測試確認通過**

Run: `bash deploy/test/test_install_lib.sh`
Expected: PASS。

- [ ] **Step 5: Commit**
```bash
git add deploy/install-lib.sh deploy/test/test_install_lib.sh
git commit -m "feat(install): cfg_derive_ip 從 default route 推導本機 IP

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```

---

## Task 3: 強密碼產生(cfg_gen_password)

**Files:**
- Modify: `deploy/install-lib.sh`
- Test: `deploy/test/test_install_lib.sh`

- [ ] **Step 1: 寫失敗測試**
```sh
pw="$(cfg_gen_password)"
assert_eq "32" "$(printf '%s' "$pw" | wc -c | tr -d ' ')" "cfg_gen_password 長度 32"
# 只含 URL-safe 字元（避免 DB URL 解析問題；雖然 config.py 會 quote_plus，仍從源頭避雷）
clean="$(printf '%s' "$pw" | tr -d 'A-Za-z0-9_-')"
assert_eq "" "$clean" "cfg_gen_password 只含 [A-Za-z0-9_-]"
```

- [ ] **Step 2: 跑測試確認失敗**

Run: `bash deploy/test/test_install_lib.sh`
Expected: 該案例 FAIL。

- [ ] **Step 3: 實作**
```sh
# 產生 32 字元 URL-safe 隨機密碼。用 LC_ALL=C 處理二進位以相容 mac/Linux。
cfg_gen_password() {
  LC_ALL=C tr -dc 'A-Za-z0-9_-' < /dev/urandom 2>/dev/null | head -c 32
}
```

- [ ] **Step 4: 跑測試確認通過**

Run: `bash deploy/test/test_install_lib.sh`
Expected: PASS。

- [ ] **Step 5: Commit**(同前 pattern;`git add` 兩檔 + co-author trailer)

---

## Task 4: 讀 answers.conf / 套預設(cfg_load_answers)

**Files:**
- Modify: `deploy/install-lib.sh`
- Test: `deploy/test/test_install_lib.sh`

說明:`answers.conf` 是無密碼的 `KEY=value` 檔。`cfg_load_answers <file>` 把值載入成 shell 變數(`CFG_<KEY>`),供互動模式當預設、無人值守模式直接用。重跑時也讀既有 `.env`(可含密碼)當預設。

- [ ] **Step 1: 寫失敗測試**
```sh
tmp="$(mktemp)"; printf 'STORAGE_TYPE=minio\nCLOUD_ENDPOINT=https://c.example.com\n# comment\n\n' > "$tmp"
cfg_load_answers "$tmp"
assert_eq "minio" "${CFG_STORAGE_TYPE:-}" "cfg_load_answers 載入 STORAGE_TYPE"
assert_eq "https://c.example.com" "${CFG_CLOUD_ENDPOINT:-}" "cfg_load_answers 載入 CLOUD_ENDPOINT"
rm -f "$tmp"
```

- [ ] **Step 2: 跑測試確認失敗** → FAIL。

- [ ] **Step 3: 實作**
```sh
# 安全解析 KEY=value（略過註解/空行）；不 source（避免任意執行）。設成 CFG_<KEY>。
cfg_load_answers() {
  [ -f "$1" ] || return 0
  while IFS= read -r _line || [ -n "$_line" ]; do
    case "$_line" in ''|\#*) continue;; esac
    _k="${_line%%=*}"; _v="${_line#*=}"
    case "$_k" in *[!A-Za-z0-9_]*) continue;; esac   # 非法 key 名跳過
    eval "CFG_${_k}=\$_v"
  done < "$1"
}
```

- [ ] **Step 4: 跑測試確認通過** → PASS。

- [ ] **Step 5: Commit**(同 pattern)

---

## Task 5: 寫 .env(chmod 600)與 answers.conf(無密碼)

**Files:**
- Modify: `deploy/install-lib.sh`
- Test: `deploy/test/test_install_lib.sh`

說明:`cfg_write_env <dir>` 用收集好的 `CFG_*` 變數寫出 `<dir>/.env`(含密碼,chmod 600);`cfg_write_answers <dir>` 寫 `<dir>/answers.conf`(**排除所有密碼/secret/token 欄位**)。

- [ ] **Step 1: 寫失敗測試**
```sh
d="$(mktemp -d)"
CFG_STORAGE_TYPE=minio CFG_DB_PASSWORD="sup3rs3cret" CFG_MINIO_SECRET_KEY="mk-secret" \
CFG_REGISTRATION_TOKEN="tok-123" CFG_CLOUD_ENDPOINT="https://c.example.com" \
CFG_AGENT_BASE_URL="https://192.168.50.121:8443" CFG_MINIO_ENDPOINT="192.168.50.121:9000" \
CFG_MINIO_ACCESS_KEY="admin" CFG_MINIO_BUCKET="guidant-ai" CFG_AGENT_IMAGE="evidence-agent:0.1.0"
cfg_write_env "$d"
cfg_write_answers "$d"
# .env 含密碼且權限 600
assert_contains "$(cat "$d/.env")" "DB_PASSWORD=sup3rs3cret" ".env 含 DB 密碼"
assert_eq "600" "$(stat -f '%Lp' "$d/.env" 2>/dev/null || stat -c '%a' "$d/.env")" ".env 權限 600"
# answers.conf 不含任何密碼/secret/token
ans="$(cat "$d/answers.conf")"
assert_not_contains "$ans" "sup3rs3cret" "answers.conf 不含 DB 密碼"
assert_not_contains "$ans" "mk-secret" "answers.conf 不含 MINIO_SECRET_KEY"
assert_not_contains "$ans" "tok-123" "answers.conf 不含 REGISTRATION_TOKEN"
assert_contains "$ans" "STORAGE_TYPE=minio" "answers.conf 保留非敏感欄位"
rm -rf "$d"
```

- [ ] **Step 2: 跑測試確認失敗** → FAIL。

- [ ] **Step 3: 實作**
```sh
cfg_write_env() {
  _d="$1"; _f="$_d/.env"
  umask 077                       # 確保新檔預設不可被他人讀
  {
    printf 'AGENT_IMAGE=%s\n' "${CFG_AGENT_IMAGE:-evidence-agent:0.1.0}"
    printf 'STORAGE_TYPE=%s\n' "${CFG_STORAGE_TYPE:-minio}"
    printf 'MINIO_ENDPOINT=%s\n' "${CFG_MINIO_ENDPOINT:-}"
    printf 'MINIO_ACCESS_KEY=%s\n' "${CFG_MINIO_ACCESS_KEY:-}"
    printf 'MINIO_SECRET_KEY=%s\n' "${CFG_MINIO_SECRET_KEY:-}"
    printf 'MINIO_BUCKET=%s\n' "${CFG_MINIO_BUCKET:-}"
    printf 'BASE_DIR=%s\n' "${CFG_BASE_DIR:-/data/upload}"
    printf 'DB_PASSWORD=%s\n' "${CFG_DB_PASSWORD:-}"
    printf 'AGENT_AUTH_MODE=%s\n' "${CFG_AGENT_AUTH_MODE:-full}"
    printf 'CLOUD_ENDPOINT=%s\n' "${CFG_CLOUD_ENDPOINT:-}"
    printf 'REGISTRATION_TOKEN=%s\n' "${CFG_REGISTRATION_TOKEN:-}"
    printf 'AGENT_BASE_URL=%s\n' "${CFG_AGENT_BASE_URL:-}"
    printf 'HEARTBEAT_INTERVAL_SEC=%s\n' "${CFG_HEARTBEAT_INTERVAL_SEC:-300}"
  } > "$_f"
  chmod 600 "$_f"
}
# answers.conf：只寫非敏感欄位（白名單），方便複製到別台 / 版控。
cfg_write_answers() {
  _d="$1"; _f="$_d/answers.conf"
  {
    printf '# evidence-agent 無人值守安裝答案（不含任何密碼/secret/token）\n'
    printf '# 用法：./install.sh --config answers.conf --non-interactive\n'
    printf 'STORAGE_TYPE=%s\n' "${CFG_STORAGE_TYPE:-minio}"
    printf 'MINIO_ENDPOINT=%s\n' "${CFG_MINIO_ENDPOINT:-}"
    printf 'MINIO_ACCESS_KEY=%s\n' "${CFG_MINIO_ACCESS_KEY:-}"
    printf 'MINIO_BUCKET=%s\n' "${CFG_MINIO_BUCKET:-}"
    printf 'BASE_DIR=%s\n' "${CFG_BASE_DIR:-/data/upload}"
    printf 'CLOUD_ENDPOINT=%s\n' "${CFG_CLOUD_ENDPOINT:-}"
    printf 'AGENT_BASE_URL=%s\n' "${CFG_AGENT_BASE_URL:-}"
    printf 'HEARTBEAT_INTERVAL_SEC=%s\n' "${CFG_HEARTBEAT_INTERVAL_SEC:-300}"
  } > "$_f"
}
```
> 注意:`answers.conf` 白名單刻意**不含** `DB_PASSWORD` / `MINIO_ACCESS_KEY`? — `MINIO_ACCESS_KEY` 是帳號非密碼,保留;`MINIO_SECRET_KEY` / `DB_PASSWORD` / `REGISTRATION_TOKEN` 一律排除。無人值守重跑時這三項仍需互動補或從既有 `.env` 帶。

- [ ] **Step 4: 跑測試確認通過** → PASS。

- [ ] **Step 5: Commit**(同 pattern)

---

## Task 6: Preflight 檢查(pf_*)

**Files:**
- Modify: `deploy/install-lib.sh`
- Test: `deploy/test/test_install_lib.sh`

說明:`pf_has_cmd`、`pf_check_port`(可樁)、`pf_check_host_files`、`pf_ensure_image`(樁 docker)。純判斷可測;真碰 docker 的薄包裝在 Linux 手動驗。

- [ ] **Step 1: 寫失敗測試**
```sh
assert_eq "0" "$(pf_has_cmd sh; echo $?)" "pf_has_cmd 偵測存在的指令"
assert_eq "1" "$(pf_has_cmd __definitely_no_such_cmd__; echo $?)" "pf_has_cmd 偵測不存在的指令"
# pf_check_port 用樁判斷：樁 ss 回有人 listen 8443 → 應回非 0（被占用）
ss() { echo "LISTEN 0 0 0.0.0.0:8443 0.0.0.0:*"; }
assert_eq "1" "$(pf_check_port 8443; echo $?)" "pf_check_port 偵測 8443 被占用"
ss() { echo ""; }
assert_eq "0" "$(pf_check_port 8443; echo $?)" "pf_check_port 偵測 8443 空閒"
unset -f ss
```

- [ ] **Step 2: 跑測試確認失敗** → FAIL。

- [ ] **Step 3: 實作**
```sh
pf_has_cmd() { command -v "$1" >/dev/null 2>&1; }
# port 被 listen 回 1（占用），空閒回 0。優先 ss，退而求其次 netstat。
pf_check_port() {
  _p="$1"
  if pf_has_cmd ss; then
    ss -ltn 2>/dev/null | grep -qE "[:.]$_p[[:space:]]" && return 1 || return 0
  elif pf_has_cmd netstat; then
    netstat -ltn 2>/dev/null | grep -qE "[:.]$_p[[:space:]]" && return 1 || return 0
  fi
  return 0   # 無工具可查時不擋（保守放行，README 提醒人工確認）
}
# host 指紋來源檔可讀（Linux only；mac 上會 false，僅 Linux 部署時呼叫）
pf_check_host_files() {
  [ -r /sys/class/dmi/id/product_uuid ] && [ -r /etc/machine-id ]
}
# 確保 image 在；不在則 docker load 同目錄精確檔名 image tar。
pf_ensure_image() {
  _img="$1"; _dir="$2"
  if docker image inspect "$_img" >/dev/null 2>&1; then return 0; fi
  _tar="$_dir/$(printf '%s' "$_img" | tr ':' '-').image.tar"   # evidence-agent-0.1.0.image.tar
  if [ -f "$_tar" ]; then log_info "載入 image: $_tar"; docker load -i "$_tar"; else
    log_err "找不到 image $_img,也找不到 $_tar"; return 1; fi
}
```
> 回應 review rec #3:image tar 用**精確檔名**(由 image tag 推導,`:`→`-`),不 glob,避免多檔誤選。

- [ ] **Step 4: 跑測試確認通過** → PASS。

- [ ] **Step 5: Commit**(同 pattern)

---

## Task 7: base compose 收 8080 + demo override

**Files:**
- Modify: `deploy/docker-compose.yml`
- Create: `deploy/docker-compose.demo.yml`

說明:full 模式 file-agent **不需要**對外 8080(控制面 agent 主動撥出、資料面走 nginx:8443,enroll 期間亦然)。所以 base compose 移除 file-agent 的 `ports: 8080:8000`;demo(none 模式測試)才用 override 加回。回應 review rec #1/#2。

- [ ] **Step 1: 改 base compose** — 刪除 `file-agent` 服務下的:
```yaml
    ports:
      - "8080:8000"
```
nginx 仍以 service 名 `file-agent:8000` 在 compose 內網 proxy,不受影響。在該處補註解:
```yaml
    # full 模式不對外 publish 8080：控制面 agent 主動撥出、資料面走 nginx:8443。
    # demo（none 模式）測試需要直打 8080 時，疊用 docker-compose.demo.yml。
```

- [ ] **Step 2: 新增 demo override** `deploy/docker-compose.demo.yml`:
```yaml
# demo / 測試用（AGENT_AUTH_MODE=none，雲端直打 8080，不經 mTLS）。
# 用法：docker compose -f docker-compose.yml -f docker-compose.demo.yml up -d
# ⚠️ 正式環境一律 full，不要疊這個 override（會繞過 mTLS）。
services:
  file-agent:
    ports:
      - "8080:8000"
```

- [ ] **Step 3: 驗證 compose 設定可解析**(在有 docker 的機器;mac 有 docker desktop 亦可)

Run(full,預設):`cd deploy && docker compose config | grep -A3 'file-agent:' ` — 確認 file-agent **無** 8080 published。
Run(demo):`docker compose -f docker-compose.yml -f docker-compose.demo.yml config | grep '8080'` — 確認**有** 8080。
> 若開發機無 docker,標記為 Linux 手動驗證項,於 Task 12 e2e 清單一併驗。

- [ ] **Step 4: Commit**
```bash
git add deploy/docker-compose.yml deploy/docker-compose.demo.yml
git commit -m "feat(install): full 模式 base compose 不對外 publish 8080,demo override 才加回

控制面 agent 主動撥出、資料面走 nginx:8443，enroll 期間亦不需 8080。
解決安裝精靈 review 的 8080 繞過 mTLS 與 enroll 期 port 衝突疑慮。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```

---

## Task 8: 啟動編排 + 憑證輪詢(orch_*)

**Files:**
- Modify: `deploy/install-lib.sh`
- Test: `deploy/test/test_install_lib.sh`(只測輪詢判斷邏輯,docker 動作樁掉)

說明:核心價值。`orch_wait_cert <certs_dir> <timeout_sec> <interval>` 輪詢 `agent.crt` 出現(代表 enroll 成功),逾時回非 0。`orch_up_agent` / `orch_up_nginx` 是薄 `docker compose` 包裝(Linux 手動驗)。

- [ ] **Step 1: 寫失敗測試**(輪詢邏輯可測:用短 timeout + 預先放/不放檔)
```sh
d="$(mktemp -d)"; mkdir -p "$d/certs"
# 還沒有 agent.crt → 1 秒 timeout 應逾時回非 0
assert_eq "1" "$(orch_wait_cert "$d/certs" 1 1; echo $?)" "orch_wait_cert 無憑證逾時回非 0"
# 放上 agent.crt → 應立即回 0
: > "$d/certs/agent.crt"
assert_eq "0" "$(orch_wait_cert "$d/certs" 2 1; echo $?)" "orch_wait_cert 憑證就緒回 0"
rm -rf "$d"
```

- [ ] **Step 2: 跑測試確認失敗** → FAIL。

- [ ] **Step 3: 實作**
```sh
# 輪詢 certs/agent.crt 出現（= 雲端簽回憑證、enroll 成功）。逾時回 1。
orch_wait_cert() {
  _dir="$1"; _timeout="${2:-120}"; _interval="${3:-3}"; _waited=0
  while [ "$_waited" -lt "$_timeout" ]; do
    [ -f "$_dir/agent.crt" ] && return 0
    sleep "$_interval"; _waited=$((_waited+_interval))
  done
  return 1
}
# 以下為 docker 薄包裝（Linux 手動驗；DRY_RUN 時只印不執行）。
orch_compose() { if [ "${DRY_RUN:-0}" = "1" ]; then log_info "[dry-run] docker compose $*"; else docker compose "$@"; fi; }
orch_up_agent() { orch_compose up -d file-agent agent-db; }
orch_up_nginx() { orch_compose --profile full up -d nginx; }
orch_dump_agent_log() { orch_compose logs --tail 50 file-agent; }
```

- [ ] **Step 4: 跑測試確認通過** → PASS。

- [ ] **Step 5: Commit**(同 pattern)

---

## Task 9: 自我驗證 + 交棒(vrf_*)

**Files:**
- Modify: `deploy/install-lib.sh`
- Test: `deploy/test/test_install_lib.sh`(交棒訊息可測;碰 curl/docker 的樁掉)

說明:採設計 Q4(c)。`vrf_local_health`(樁 curl)、`vrf_local_tls`(本機 curl :8443 預期 400)、`vrf_print_handoff <base_url>`(印雲端下一步)。

- [ ] **Step 1: 寫失敗測試**
```sh
out="$(vrf_print_handoff 'https://192.168.50.121:8443')"
assert_contains "$out" "https://192.168.50.121:8443" "交棒訊息含 base_url"
assert_contains "$out" "storage-config" "交棒訊息提示回雲端設 storage-config"
# vrf_local_tls 回傳碼：400/000 視為通，其餘（含 500/502 死站）視為失敗（stub curl）
curl() { echo 400; }; assert_eq "0" "$(vrf_local_tls; echo $?)" "vrf_local_tls 400 視為通"
curl() { echo 000; }; assert_eq "0" "$(vrf_local_tls; echo $?)" "vrf_local_tls 000(握手失敗)放行"
curl() { echo 500; }; assert_eq "1" "$(vrf_local_tls; echo $?)" "vrf_local_tls 500 視為失敗"
unset -f curl
```

- [ ] **Step 2: 跑測試確認失敗** → FAIL。

- [ ] **Step 3: 實作**
```sh
# /health 200 視為 OK（樁：測試可 shadow curl）
vrf_local_health() { _code="$(curl -s -o /dev/null -w '%{http_code}' http://localhost:8000/health 2>/dev/null || echo 000)"; [ "$_code" = "200" ]; }
# 本機打 nginx:8443，預期被 mTLS 擋成 400 → 證明 TLS+port 通。
# 400=mTLS 正常擋；000=curl 對 mTLS 握手失敗（部分環境如此）也放行；其餘（500/502/死站）視為失敗。
vrf_local_tls() {
  _code="$(curl -sk -o /dev/null -w '%{http_code}' https://localhost:8443/ 2>/dev/null || echo 000)"
  case "$_code" in 400|000) return 0;; *) return 1;; esac
}
vrf_certs_present() { _d="$1"; [ -f "$_d/agent.crt" ] && [ -f "$_d/agent.key" ] && [ -f "$_d/ca.crt" ]; }
vrf_print_handoff() {
  _base="$1"
  cat <<EOF
─────────────────────────────────────────────
安裝完成（本機這側驗證通過）。下一步請在雲端做：
  1) 用該 tenant 帳號登入前端 → 儲存設定(storage-config) → storage_type 選 remote_agent
     → 選這台 agent（base_url = $_base）→ 存檔
  2) 從雲端那台（有 client 憑證）跑三層認證驗證：
       不帶憑證 → 400 / 帶憑證 → 200 / /blob 不帶 JWT → 401
     （指令見 deploy/README.md §9-1）
─────────────────────────────────────────────
EOF
}
```
> `vrf_local_tls` 的 400/000 都放行:有些環境 curl 對 mTLS 握手失敗回 000,不應誤判失敗;真正的資料面驗證交棒雲端。
> 說明:設計 §④ 列的「agent log 出現 `agent enrolled` + `heartbeat`」這項**刻意不**做成 vrf 函式 — enroll 失敗已在 Task 10 ③ 的 `orch_wait_cert` 逾時 + `orch_dump_agent_log` 涵蓋;log 人工檢查交棒給交棒訊息 / README §7,非遺漏。

- [ ] **Step 4: 跑測試確認通過** → PASS。

- [ ] **Step 5: Commit**(同 pattern)

---

## Task 10: install.sh 主流程串接 + arg 解析

**Files:**
- Modify: `deploy/install.sh`
- Test: 以 `--dry-run` 手動跑(不需 docker)

說明:串四階段 + 解析 `--config <file>` / `--non-interactive` / `--dry-run` / `-h`。互動收集 4 類設定(storage / cloud_endpoint / token / db 密碼),自動推導 IP 並確認,寫 `.env` + `answers.conf`,編排啟動,自我驗證。

- [ ] **Step 1: 實作 install.sh**(完整流程;關鍵片段)
```bash
#!/usr/bin/env bash
set -eu
DIR="$(cd "$(dirname "$0")" && pwd)"
. "$DIR/install-lib.sh"

CONFIG_FILE=""; NON_INTERACTIVE=0; DRY_RUN=0
while [ $# -gt 0 ]; do case "$1" in
  --config) CONFIG_FILE="$2"; shift 2;;
  --non-interactive) NON_INTERACTIVE=1; shift;;
  --dry-run) DRY_RUN=1; shift;;
  -h|--help) echo "用法: ./install.sh [--config answers.conf] [--non-interactive] [--dry-run]"; exit 0;;
  *) log_err "未知參數: $1"; exit 2;;
esac; done
export DRY_RUN

# 載入既有設定當預設（重跑友善）：先 .env 後 answers.conf 後 --config
cfg_load_answers "$DIR/.env"
cfg_load_answers "$DIR/answers.conf"
[ -n "$CONFIG_FILE" ] && cfg_load_answers "$CONFIG_FILE"

# ① Preflight（dry-run 跳過所有環境相依檢查，方便 mac 上驗流程）
log_info "① Preflight 檢查"
if [ "$DRY_RUN" != "1" ]; then
  pf_has_cmd docker || { log_err "未找到 docker，請先安裝 Docker 與 compose plugin"; exit 1; }
  docker compose version >/dev/null 2>&1 || { log_err "未找到 docker compose v2 plugin"; exit 1; }
  pf_check_host_files || { log_err "讀不到 /sys/class/dmi/id/product_uuid 或 /etc/machine-id（agent 必須跑在 Linux）"; exit 1; }
  pf_check_port 8443 || { log_err "port 8443 已被占用，請先釋放再重跑"; exit 1; }
  pf_ensure_image "${CFG_AGENT_IMAGE:-evidence-agent:0.1.0}" "$DIR/.." || exit 1
fi

# ② 設定收集
log_info "② 設定收集"
if [ "$NON_INTERACTIVE" = "0" ]; then
  CFG_STORAGE_TYPE="$(ask '儲存模式 (minio/local)' "${CFG_STORAGE_TYPE:-minio}")"
  if [ "$CFG_STORAGE_TYPE" = "minio" ]; then
    CFG_MINIO_ENDPOINT="$(ask 'MinIO endpoint (LAN IP:9000)' "${CFG_MINIO_ENDPOINT:-}")"
    CFG_MINIO_ACCESS_KEY="$(ask 'MinIO access key' "${CFG_MINIO_ACCESS_KEY:-}")"
    CFG_MINIO_SECRET_KEY="$(ask 'MinIO secret key' "${CFG_MINIO_SECRET_KEY:-}")"
    CFG_MINIO_BUCKET="$(ask 'MinIO bucket' "${CFG_MINIO_BUCKET:-guidant-ai}")"
  fi
  CFG_CLOUD_ENDPOINT="$(ask '雲端 base URL (不含 /api/1.0)' "${CFG_CLOUD_ENDPOINT:-}")"
  CFG_REGISTRATION_TOKEN="$(ask '註冊 token (雲端管理頁產生)' "${CFG_REGISTRATION_TOKEN:-}")"
  if [ -z "${CFG_DB_PASSWORD:-}" ]; then
    _g="$(ask 'agent DB 密碼 (留空=自動產生強密碼)' '')"
    [ -z "$_g" ] && _g="$(cfg_gen_password)" && log_ok "已自動產生 agent DB 密碼（寫入 .env，不顯示）"
    CFG_DB_PASSWORD="$_g"
  fi
  _ip="$(cfg_derive_ip)"
  CFG_AGENT_BASE_URL="$(ask '本機對雲端可達 URL (必須 https + 8443)' "${CFG_AGENT_BASE_URL:-https://${_ip}:8443}")"
fi
CFG_AGENT_AUTH_MODE=full
# 必填校驗
for _req in CLOUD_ENDPOINT REGISTRATION_TOKEN AGENT_BASE_URL DB_PASSWORD; do
  eval "_v=\${CFG_$_req:-}"; [ -n "$_v" ] || { log_err "缺必填: $_req"; exit 2; }
done
case "$CFG_AGENT_BASE_URL" in https://*:8443) :;; *) log_err "AGENT_BASE_URL 必須是 https://<ip>:8443"; exit 2;; esac
cfg_write_env "$DIR"; cfg_write_answers "$DIR"
log_ok "已寫出 $DIR/.env (600) 與 $DIR/answers.conf (無密碼)"

# ③ 啟動編排
log_info "③ 啟動編排"
[ "$DRY_RUN" = "1" ] || sh "$DIR/collect-host-id.sh"
( cd "$DIR" && orch_up_agent )   # orch_* 經 orch_compose 在 DRY_RUN 下自我跳過(只印不執行)
log_info "等待 agent 向雲端註冊、簽回憑證 ..."
if [ "$DRY_RUN" != "1" ] && ! ( cd "$DIR" && orch_wait_cert "$DIR/certs" 180 3 ); then
  log_err "等不到 certs/agent.crt（enroll 失敗）。以下為 agent log:"; ( cd "$DIR" && orch_dump_agent_log ); exit 1
fi
( cd "$DIR" && orch_up_nginx )

# ④ 自我驗證 + 交棒
log_info "④ 自我驗證"
if [ "$DRY_RUN" != "1" ]; then
  vrf_certs_present "$DIR/certs" && log_ok "憑證已落地" || log_warn "憑證不齊，請查 agent log"
  vrf_local_health && log_ok "file-agent /health 200" || log_warn "/health 未回 200"
  vrf_local_tls && log_ok "本機 :8443 TLS 通（mTLS 擋為預期）" || log_warn ":8443 未回預期碼（檢查 nginx 是否起來）"
fi
vrf_print_handoff "$CFG_AGENT_BASE_URL"
```

- [ ] **Step 2: --dry-run 手動驗**(mac 上,docker 動作只印不跑;需先有一份 answers.conf 或用 --non-interactive 前先 export CFG_*)

Run:
```bash
cd ~/Projects/Billows/Audit-Manager/evidence-agent/deploy
printf 'STORAGE_TYPE=local\nCLOUD_ENDPOINT=https://c.example.com\nAGENT_BASE_URL=https://192.168.50.121:8443\n' > /tmp/ans.conf
# 補密碼/token（非互動需要）
CFG_DB_PASSWORD=x CFG_REGISTRATION_TOKEN=y bash install.sh --config /tmp/ans.conf --non-interactive --dry-run
```
Expected:印出四階段、`[dry-run] docker compose ...`、最後印交棒訊息;**不**實際呼叫 docker。
> dry-run 的 preflight 跳過已內建於 Step 1 的 `if [ "$DRY_RUN" != "1" ]` 區塊(含 docker / host-files / port / image 全跳),正式安裝不跳。collect-host-id / orch_wait_cert / 自我驗證也都在 `DRY_RUN != 1` 下才實跑。

- [ ] **Step 3: 跑既有單元測試確認沒被改壞**

Run: `bash deploy/test/test_install_lib.sh`
Expected: `ALL PASS`。

- [ ] **Step 4: Commit**
```bash
git add deploy/install.sh deploy/install-lib.sh
git commit -m "feat(install): install.sh 主流程串接四階段 + arg 解析 + dry-run

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```

---

## Task 11: answers.conf.example + README 改寫

**Files:**
- Create: `deploy/answers.conf.example`
- Modify: `deploy/README.md`

- [ ] **Step 1: 寫 answers.conf.example**
```sh
# evidence-agent 無人值守安裝答案範本（複製成 answers.conf;不含任何密碼/secret/token）
# 密碼類（DB_PASSWORD / MINIO_SECRET_KEY / REGISTRATION_TOKEN）安裝時互動補，或由精靈自動產生。
STORAGE_TYPE=minio
MINIO_ENDPOINT=192.168.50.121:9000
MINIO_ACCESS_KEY=admin
MINIO_BUCKET=guidant-ai
CLOUD_ENDPOINT=https://cloud.example.com
AGENT_BASE_URL=https://192.168.50.121:8443
HEARTBEAT_INTERVAL_SEC=300
```

- [ ] **Step 2: 改 README** — 頂部新增「快速安裝(精靈)」段:解壓一包 → `./install.sh` → 答 4 題;把現有 §4~§9 的手動步驟標為「附錄:手動部署 / 疑難排解」。明確寫:**正式一律精靈 + full;手動步驟僅供除錯**。§12「切回 demo」更新成 `docker compose -f docker-compose.yml -f docker-compose.demo.yml up -d`。

- [ ] **Step 3: Commit**
```bash
git add deploy/answers.conf.example deploy/README.md
git commit -m "docs(install): answers.conf 範本 + README 改以 install.sh 為主流程

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```

---

## Task 12: Linux e2e 手動驗證清單(無法在開發機跑)

**Files:** 無(驗證 only)。在一台客戶型 Linux server(已裝 docker)上跑。

- [ ] **Step 1: 打包出貨包**
```bash
cd ~/Projects/Billows/Audit-Manager/evidence-agent
docker save evidence-agent:0.1.0 -o evidence-agent-0.1.0.image.tar
tar czf evidence-agent-deploy-0.1.0.tar.gz \
  deploy/install.sh deploy/install-lib.sh deploy/docker-compose.yml deploy/docker-compose.demo.yml \
  deploy/nginx deploy/collect-host-id.sh deploy/.env.example deploy/answers.conf.example deploy/README.md \
  evidence-agent-0.1.0.image.tar
```
> image tar 命名須與 `pf_ensure_image` 推導一致:`evidence-agent-0.1.0.image.tar`。

- [ ] **Step 2: 在 Linux 解壓 → 跑精靈(full)**
```bash
tar xzf evidence-agent-deploy-0.1.0.tar.gz && cd deploy && ./install.sh
```
驗:preflight 過 → 答 4 題 → 自動 load image / collect-host-id / 起 agent → 等到憑證 → 起 nginx → 自我驗證印交棒。

- [ ] **Step 3: 驗關鍵不變式**
  - `ls certs` 有 `agent.crt agent.key ca.crt`
  - `docker compose ps` file-agent **無** 8080 對外、nginx 8443 有
  - `.env` 權限 600;`answers.conf` `grep -E 'PASSWORD|SECRET_KEY|TOKEN'` → 無命中
  - `docker compose config` 確認 file-agent 無 published 8080

- [ ] **Step 4: 重跑冪等驗**
  - 再跑一次 `./install.sh`:讀既有 `.env`/`answers.conf` 當預設、不刪 `pgdata`/`filedata`/`certs`、可順利再起。

- [ ] **Step 5: demo 模式驗(可選)**
```bash
docker compose -f docker-compose.yml -f docker-compose.demo.yml config | grep 8080   # 應有 8080
```

- [ ] **Step 6: 雲端端到端**
  - 回雲端 FE 設 storage-config 指向本台 → 上傳 / 下載 / 預覽都成功。
  - 從雲端跑三層認證(400/200/401)。

- [ ] **Step 7: 收尾(等 user 明示才做)**
  - changelog(`docs/changelog/`,type=feat)、依需要回填 design/README、Notion 任務 — 依 `closing-and-handoff` skill,**等 user 下令**。

---

## 完成準則(Definition of Done)

- `bash deploy/test/test_install_lib.sh` → `ALL PASS`。
- Linux 上「解壓一包 → `./install.sh` → 答 4 題 → 完成」全程通,憑證兩段式由精靈自動處理,部署人員不需手動 `docker compose` 任何一步。
- full 模式 file-agent 不對外 8080;`.env` 600;`answers.conf` 零密碼。
- 重跑冪等,`pgdata`/`filedata`/`certs` 全程不被刪。
