---
title: FR-191 Agent 安裝包自助下載（設計 v2.0）
status: v2.0 定稿（2026-11-10 決策者裁改 R2 直連）
lede: "客戶還沒裝 Agent 時，要有地方拿到安裝包。管理員在主系統點選「取得安裝包」，主系統向原廠更新站換兩條 Cloudflare R2 預簽下載網址（安裝包、.sha256），直接下載或複製給裝機的人。時限由原廠在更新站後台設定，客戶端不能改。"
relates: [FR-172, FR-189, FR-167]
---

# 設計 v2.0（定稿）：Agent 安裝包自助下載——R2 直連

> 📑 本頁是 **v2.0（定稿）**，取代 [設計 v1.0](design.md)。v1.0 是「主系統本機供檔」，已實作並合回；v2.0 推翻供檔來源與時限歸屬，v1.0 的實作要拿掉。差異見最後一節「與 v1.0 的異動」。

> ⚠️ **本頁已由 [設計 v2.1](design-v2.1.md) 取代。** v2.1 只改 D3：零 Agent 時的查詢基準版從 `0.0.0` 改為 `1.0.0`（正式站實測 `0.0.0` 拿不到任何版本）。本頁內容保留定稿原樣，不回頭改。

## 分工概述

**整案一句話**：管理員在主系統更新中心 Agent 區、或建立註冊 token 的對話框點選一次，就拿到 Agent 最新版安裝包與 `.sha256` 兩條 Cloudflare R2 預簽下載網址；網址由原廠更新站簽發，有效時間由原廠在更新站後台設定，主系統只轉交、不存包、不供檔。

| 批 | 卡 | Repo | 做什麼 | 完成判定 |
|---|---|---|---|---|
| 1 | U | update-center | `/v1/download-url` 加 `artifact=sha256`、預簽網址帶 `response-content-disposition`、有效時間改讀 D1 設定、後台加「下載網址有效時間」欄位、`/v1/check` 讓 Agent 線可不帶 Agent 版號查最新 | vitest 綠；wrangler dev 打兩種 artifact 拿到兩條可下載網址，時限隨後台設定變 |
| 2 | B | main | 主系統換網址端點改成「向更新站換 R2 網址」；拿掉本機供檔端點、時限設定端點、併發計數；稽核保留 | 接 DEV 更新站（`wrangler dev --env dev`）打一次拿到兩條 R2 網址、`sha256sum -c` 通過 |
| 3 | F | fe | 共用元件改顯示 R2 網址與到期時間、拿掉時限欄位與探測／429 處理、拿掉「沒包先預先下載」改成「更新站連不上」提示 | 兩個入口畫面截圖；複製出的網址在另一台機器 curl 下得到 |
| 4 | D | main | FINAL-SPEC、手冊取包句、README／STATE／LOG 收口 | 手冊 grep 不到「本機」「主系統下載」說法 |

**依賴**：U → B（B 要打真的更新站端點）→ F（F 要打 B 的新形狀）→ D（手冊要新畫面）。四棒嚴格序列。

## 1. 問題與情境

客戶第一次導入 Guidant AI 時，主系統先裝好，接著要在客戶內網挑一台 Linux 主機裝 Agent。裝 Agent 需要兩個檔：`guidant-agent-compose-<版>.tar.gz` 和同名 `.sha256`。這兩個檔目前只在原廠的 Cloudflare R2 上，客戶沒有任何管道自己拿，每次都要原廠人工跑 `scripts/r2_presign.py` 產 24 小時連結寄過去。

情境的重點是「**還沒裝 Agent**」。這時主系統上通常也沒有任何 Agent 包（主系統只在推送 Agent 更新時才會預先下載），所以 v1.0「從主系統本機供檔」在最需要它的時候反而沒東西可給，必須先去更新中心按「預先下載」把 1.7 GB 拉進主系統，再從主系統下載一次，多繞一圈且多佔主系統磁碟與頻寬。

R2 本來就是發布物的正本所在地，更新站本來就會簽 R2 預簽網址給主系統下載升級包。讓管理員直接拿到同一種網址，是最短路徑。

## 2. 現況（實查）

**更新站（update-center，Cloudflare Worker）**

- `POST /v1/download-url`（`src/routes/client.ts:67-89`）：主系統帶授權身分與 `product`（host／agent）、`version`，Worker 驗授權、確認這張授權看得到這一版，回 `{version, sha256, size, url, expires_in}`。`artifact` 目前只認 `ops`（主系統維運小檔，Agent 不准帶）。
- 預簽在 `src/releases.ts:203-216` `presignDownload()`，有效時間是常數 `DOWNLOAD_URL_TTL_SECONDS = 15 * 60`（`:13`）。
- R2 物件路徑 `agent/v1/<版>/guidant-agent-compose-<版>.tar.gz`、同目錄 `.tar.gz.sha256`、`release.json`（`releaseObjectKeys()`，`:55-63`）。2026-11-10 實查 1.7.4 三個物件都在 R2 上（包 1737.7 MB）。
- 後台 `/admin`（`src/routes/admin.ts`、`public/admin.html`），Cloudflare Access 保護，只有原廠放行的信箱進得去。現有分頁：版本、內部機器、黑名單、簽章金鑰、CI token。D1 沒有通用設定表。
- `/v1/check` 的 Agent 線以「各台 Agent 現行版中最舊」當 `current_version` 算可升路徑；一台 Agent 都沒有時主系統送 `0.0.0`（`agent_release_service.py:197`）。

**主系統（compliance-manager-be）**

- 更新站客戶端 `app/product_update/service/update_site_client.py`：`download_url(product, …, version)`（`:160-165`）回 URL，並檢查網址主機在 `DOWNLOAD_HOSTS`（R2 端點）白名單內才放行。
- Agent 版本線 `AgentReleaseService`：`check()`（`:190-213`）問更新站 Agent 最新版、驗簽章清單，結果寫 `PRODUCT_UPDATE.agent_last_check`；`public_last_check()` 給畫面用。主機身分 `_host_identity()`（`product_update_flow.py:186-197`）取持照租戶的授權檔原文、機器指紋、更新站身分票。
- FR-191 v1.0 已合回的程式：`api/product_update/routes/agent_bundle_link_route.py`、`api/product_update/serializers/agent_bundle_link.py`、`app/product_update/service/agent_bundle_link_service.py`、`test/test_agent_bundle_link_route.py`、DI 一個 Factory、`product_update_error_code.py` 四個碼。

**主系統前端（compliance-manager-fe）**

- v1.0 已合回：`src/components/product-update/AgentBundleLinks.vue`、`AgentBundleButton.vue`、`AgentBundleLinkTtlField.vue`、`src/composables/useAgentBundleLinks.js`、`src/utils/clipboardUtil.js`；掛點在 `AgentRolloutPanel.vue` 與 `AgentEnrollTokenCreateDialog.vue`。

## 3. 設計決策

### D1　下載網址直接是 Cloudflare R2 預簽網址

管理員拿到的兩條網址是 `https://<account>.r2.cloudflarestorage.com/guidant-ai-release/agent/v1/<版>/…?X-Amz-…`，由更新站用 R2 的 S3 相容金鑰簽發。主系統不存包、不供檔、不經手檔案內容。

**為什麼**：情境是「還沒裝 Agent」，主系統手上通常沒有包；R2 是正本，直接給最短。下載走 Cloudflare 網路，速度與頻寬都不壓在客戶主系統上，v1.0 為了保護主系統執行緒而做的併發上限整套不需要。

**排除：主系統本機供檔（v1.0）**。要先在更新中心「預先下載」1.7 GB 才有東西給，裝第一台 Agent 時多一道且多一份磁碟；1.7 GB 走主系統 API 串流佔執行緒，因此又長出併發計數與 429。

**排除：兩條都給（本機＋R2）**。兩套來源、兩套時限、兩套錯誤處理，客戶看到兩組網址不知道用哪個；客戶內網沒有外網時，管理員本來就是在自己電腦下載再帶進機房，R2 足夠。

**代價**：下載者的電腦要能連 Cloudflare。主系統本身連不到更新站（封閉網路、沒設 `UPDATE_SITE_URL`）時拿不到網址，畫面明講「主系統連不到原廠更新站，請向原廠索取安裝包」。

### D2　主系統向更新站換網址，瀏覽器不直接打更新站

流程：瀏覽器 → 主系統 `POST agents/bundle-links` → 主系統以主機授權身分打更新站 `/v1/download-url` 兩次（安裝包、`.sha256`）→ 回兩條網址給瀏覽器。

**為什麼**：更新站只認授權身分（授權檔、機器指紋、身分票），這些只在主系統伺服器端，不能交給瀏覽器。沿用既有 `update_site_client.download_url()` 與 `DOWNLOAD_HOSTS` 白名單，主系統拿到的網址主機不是原廠 R2 就拒絕轉交。

### D3　版號＝更新站上「這張授權看得到的 Agent 最新版」

主系統換網址時先問 `/v1/check` product=agent 拿 `latest`，再對這一版換網址；不依賴主系統本機有沒有包、不依賴更新中心有沒有按過「檢查」。版號與主系統的相容性（`min_host_version`）沿用 Agent 線既有判斷：最新版要求較新的主系統時，回 412 並說明要先升主系統到哪一版。

**為什麼**：管理員要的是「現在可以裝的那一版」，不是上次檢查的快照。

**查詢基準版**：一台 Agent 都沒有時送 `0.0.0`，更新站回的 `latest` 就是這條線最新版（`agent_release_service.check()` 已是這個行為）。為了不覆寫更新中心 Agent 區的「上次檢查」紀錄，換網址時的查詢**不寫** `agent_last_check`，另走一支唯讀查詢。

### D4　有效時間由原廠在更新站後台設定

更新站 D1 新增 `site_settings`（key／value／updated_at／updated_by），key `download_url_ttl_seconds`，預設 900（15 分鐘，與現值相同），範圍 300～86400。後台新增「設定」分頁一個數字欄位（單位分鐘）。`presignDownload()` 改讀這個設定，主系統升級包、ops 小檔、Agent 包全部共用同一個值。

**為什麼**：決策者 2026-11-10 裁「時限不能讓客戶改，要產品管理員改」。網址是更新站簽的，時限自然由簽發方決定；放在主系統讓客戶管理員改，等於讓客戶延長原廠發布物的公開下載時間。

**排除：主系統保留可調欄位**。客戶不應有這個權限。**排除：只給 Agent 安裝包獨立時限**。同一個預簽函式多一個分支、兩個值，目前沒有需求要它們不同。

**影響**：主系統自己下載升級包時拿到的網址時限也跟著變。主系統下載用的是續傳＋每段重拿網址（`package_downloader.py`），時限變短或變長都不影響正確性；下限 5 分鐘確保單段 50 MB 拿得完。

### D5　`.sha256` 用 `artifact=sha256` 換，網址帶下載檔名

`/v1/download-url` 的 `artifact` 從只認 `ops` 擴成認 `ops`／`sha256`。`sha256` 兩條產品線都可帶（主系統升級包旁也有 `.sha256`），物件不在 R2 上回 404。

兩種 artifact 的預簽網址都帶 `response-content-disposition=attachment; filename="<檔名>"`。沒有它的話，瀏覽器點 `.sha256` 會在分頁裡顯示文字而不是存檔，安裝包則會用 R2 物件路徑的最後一段當檔名（剛好正確，但不保證）。

`/v1/download-url` 的下載紀錄（`release_download_log`）只在拿整包時記，`.sha256` 不記，與 `ops` 一致。

### D6　兩個入口、一個共用元件（沿用 v1.0）

更新中心 Agent 區「取得安裝包」鈕（讀權限即可見）與建立註冊 token 對話框的「Agent 安裝包」區塊，沿用 v1.0 的 `AgentBundleLinks.vue` 與 `AgentBundleButton.vue`。鈕不再因為「主系統沒有包」而停用；改為主系統沒設更新站時停用並提示。

### D7　產生網址的權限與節流

產生網址要 `system.product-update.read`（v1.0 D5 不變）。每人每分鐘 10 次（v1.0 D11 的發票節流保留），因為每次產生會讓主系統打更新站兩到三次。

### D8　稽核

每次產生網址寫一筆系統日誌，事件碼 6251（與更新中心「預先下載」共用），action `AGENT_BUNDLE_LINK_ISSUED`，欄位 actor、version、expires_in、source=r2。

### D9　前端保護只留「連點」

v1.0 的下載前 HEAD 探測、429／401 處理整段拿掉：R2 預簽網址被打 HEAD 也要簽章且簽的是 GET（會 403），而且 R2 沒有併發上限要擋。保留「下載」鈕按下後鎖 10 秒與「請看瀏覽器下載列」提示，防的是使用者以為沒反應再按。到期判斷沿用「收到網址的時刻＋`expires_in`」，不比伺服器時間。

### D10　拿掉的 v1.0 程式

| 拿掉 | 原因 |
|---|---|
| 本機憑票供檔端點 `agents/bundle/<version>/<kind>` | 不再從主系統供檔 |
| 時限設定端點 `agents/bundle-link-settings`、`AgentBundleLinkTtlField.vue` | 時限移到更新站後台 |
| 串流併發計數（`StreamSlots`、兩個 system_configs key）、`PRODUCT_UPDATE_429001` | R2 不經主系統 |
| 時限錯誤碼 `PRODUCT_UPDATE_400013` | 主系統不再驗時限 |
| 簽章票 scope `agent_bundle_download` | 不再發主系統自己的票 |
| FE 探測、429／401 處理 | 見 D9 |

保留：`AgentBundleLinks.vue`、`AgentBundleButton.vue`、`useAgentBundleLinks.js`（改內容）、`clipboardUtil.js`、兩個掛點、`PRODUCT_UPDATE_429002`（產生太頻繁）、`PRODUCT_UPDATE_404005`（改語意為「更新站上沒有可用的 Agent 版本」）。

錯誤碼字串一旦對外就是契約；`429001`、`400013` 從 FE `error-code.json` 拿掉前，B 卡確認沒有其他呼叫端（grep BE 與 FE）。

## 4. API

### 4.1 主系統 `POST /api/1.0/system/product-update/agents/bundle-links`

守門：登入、`system.product-update.read`、每人每分鐘 10 次。Request 無 body（不再接受指定版號：v2.0 只給最新相容版）。

Response `data`：

```json
{
  "version": "1.7.4",
  "size": 1822082560,
  "sha256": "b771e363…8a4716",
  "expires_in": 900,
  "expires_at": "2026-11-10T03:15:00+00:00",
  "files": [
    {"kind": "tarball", "name": "guidant-agent-compose-1.7.4.tar.gz",        "url": "https://<account>.r2.cloudflarestorage.com/guidant-ai-release/agent/v1/1.7.4/guidant-agent-compose-1.7.4.tar.gz?X-Amz-…"},
    {"kind": "sha256",  "name": "guidant-agent-compose-1.7.4.tar.gz.sha256", "url": "https://<account>.r2.cloudflarestorage.com/…/guidant-agent-compose-1.7.4.tar.gz.sha256?X-Amz-…"}
  ]
}
```

`url` 是完整網址，前端直接用，不再拼 origin。`sha256` 與 `size` 取自更新站 `/v1/check` 回的簽章清單（主系統驗過簽章），不取 `/v1/download-url` 回的未簽欄位。

| 情況 | HTTP | 錯誤碼 |
|---|---|---|
| 主系統沒設更新站 | 412 | `PRODUCT_UPDATE_412001`（既有） |
| 連不到更新站 | 503 | `PRODUCT_UPDATE_503001`（既有） |
| 沒有授權照 | 412 | `PRODUCT_UPDATE_412003`（既有） |
| 更新站上沒有這張授權看得到的 Agent 版本 | 404 | `PRODUCT_UPDATE_404005` |
| 最新版要求較新的主系統 | 412 | `PRODUCT_UPDATE_412014`（既有，`AGENT_REQUIRES_HOST`） |
| 更新站給的網址主機不在白名單 | 503 | `PRODUCT_UPDATE_503001` |
| 一分鐘超過 10 次 | 429 | `PRODUCT_UPDATE_429002` |

### 4.2 更新站 `POST /v1/download-url`（擴充）

`artifact`：不帶＝整包；`ops`＝主系統維運小檔（僅 host）；`sha256`＝整包旁的雜湊檔（host、agent 皆可）。物件不在 R2 回 404。回應形狀不變：`{version, artifact?, url, expires_in}`（整包另帶 `sha256`、`size`）。

### 4.3 更新站後台 `GET／PUT /admin/settings`

Cloudflare Access 保護、同源 JSON（沿用 `crossSiteWrite`）。Body `{"download_url_ttl_seconds": 900}`，範圍 300～86400，整數。PUT 寫 `updated_by`＝Access 身分信箱。後台頁新增「設定」分頁，欄位以分鐘顯示。

## 5. 流程

```{.mermaid cap="圖 1　管理員產生 R2 下載網址。第 3～6 步主系統以主機授權身分向更新站換網址；檔案下載（第 9 步）完全不經主系統。"}
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','secondaryColor':'#EEF2F3','secondaryTextColor':'#14201F','tertiaryColor':'#FBFCFC','tertiaryTextColor':'#14201F','lineColor':'#4A5A5C','textColor':'#14201F','mainBkg':'#E2F0F1','nodeBorder':'#0E7C86','nodeTextColor':'#14201F','edgeLabelBackground':'#FBFCFC','titleColor':'#14201F','clusterBkg':'#FBFCFC','clusterBorder':'#E4EAEB','actorBkg':'#E2F0F1','actorTextColor':'#14201F','actorBorder':'#0E7C86','actorLineColor':'#C7D1D2','signalColor':'#4A5A5C','signalTextColor':'#14201F','labelBoxBkgColor':'#EEF2F3','labelBoxBorderColor':'#C7D1D2','labelTextColor':'#14201F','loopTextColor':'#14201F','noteBkgColor':'#F6EBD5','noteTextColor':'#14201F','noteBorderColor':'#9C6B12','activationBkgColor':'#EEF2F3','activationBorderColor':'#C7D1D2','sequenceNumberColor':'#FFFFFF'}}}%%
sequenceDiagram
  autonumber
  actor A as 管理員（瀏覽器）
  participant B as 主系統 API
  participant U as 更新站（Cloudflare Worker）
  participant R as Cloudflare R2
  actor C as 裝機人員
  A->>B: POST agents/bundle-links（bearer、read 權限）
  B->>B: 每人每分鐘 10 次節流
  B->>U: POST /v1/check product=agent current=0.0.0（主機授權身分）
  U-->>B: latest、簽章清單（sha256、size、min_host_version）
  B->>B: 用發布公鑰驗清單、比 min_host_version
  B->>U: POST /v1/download-url product=agent version=<latest>（整包、artifact=sha256 各一次）
  U-->>B: 兩條 R2 預簽網址、expires_in（後台設定）
  B-->>A: 版號、大小、SHA-256、兩條網址、到期時間；寫稽核
  A->>C: 下載，或複製網址交給裝機人員
  C->>R: GET 預簽網址（不需任何帳號）
  R-->>C: 檔案（Content-Disposition 帶檔名）
  C->>C: sha256sum -c
```

第 3 步的 `current=0.0.0` 讓更新站回這條線的最新版，與目前有沒有 Agent、Agent 是幾版無關。第 5 步沿用 Agent 線既有的驗章（`read_signed_manifest`）與相容判斷；最新版要求較新主系統時在這裡回 412，不給網址。第 6 步的兩次呼叫任一失敗就整個失敗，不回半組網址。第 9 步之後主系統不知道有沒有人下載，也不需要知道。

## 6. 畫面

- **更新中心 Agent 區**：「取得安裝包」鈕永遠顯示（讀權限）；主系統沒設更新站時停用並提示「主系統未連線原廠更新站」。按下開對話框，內容是共用元件。**對話框裡沒有時限欄位。**
- **共用元件**：載入時呼叫 `bundle-links`；顯示版號、大小、SHA-256（可複製）、兩列檔名與完整網址、各帶〔下載〕〔複製連結〕、一行「連結 N 分鐘內有效，到 HH:MM」。過期後整塊變灰、出現「重新產生」。錯誤時（412／503／404）整塊換成對應白話訊息，412001／503001 的訊息附一句「請向原廠索取安裝包」。
- **〔下載〕**：直接 `<a href download>`，按下鎖 10 秒並顯示「已開始下載，請看瀏覽器下載列」。
- **建立註冊 token 對話框**：建完那頁的「Agent 安裝包」區塊不變（掛讀權限）。
- 不准左側粗色邊框；網址與雜湊用等寬字；資料區塊上下疊。

## 7. 安全

- R2 金鑰只在更新站 Worker 內，網址只有簽章不含金鑰（既有）。
- 預簽網址任何人拿到都能在時限內下載。包不含機敏資訊（憑證、金鑰都是裝機後才產生），外洩代價是「別人拿到產品安裝包」。時限由原廠控，預設 15 分鐘。
- 主系統轉交前檢查網址主機在 `DOWNLOAD_HOSTS` 白名單（既有），防更新站被冒充時把管理員導到別處。
- 產生網址留稽核（D8）。更新站整包下載另有 `release_download_log`。

## 8. 不做

- 不給主系統本機連結、不做兩條來源並存。
- 客戶端不能改時限；不做 Agent 包專屬時限。
- 不讓管理員選版號（只給最新相容版）。
- 不做前端探測與 429 處理。

## 9. 與 v1.0 的異動

| 項目 | v1.0 | v2.0 | 為什麼改 |
|---|---|---|---|
| 檔案來源 | 主系統本機 `agent-bundles/<版>/`，憑主系統簽章票下載 | Cloudflare R2，更新站預簽網址 | 情境是還沒裝 Agent，主系統通常沒有包；R2 是正本（決策者 2026-11-10「直接給 R2」） |
| 時限設定者 | 主系統更新中心，客戶管理員可改（1～1440 分） | 原廠在更新站後台改（5～1440 分），所有預簽網址共用 | 決策者 2026-11-10「這應該不能改，要讓產品管理員改」 |
| 前置條件 | 主系統要先「預先下載」 | 主系統能連更新站 | 同第一列 |
| 併發上限 | 同票 2 條、全站 3 條、429 | 拿掉 | 下載不經主系統 |
| 前端探測 | HEAD 探測、處理 429／401 | 拿掉，只留 10 秒連點鎖 | R2 預簽網址不接 HEAD；無併發限制 |
| 版號 | 可指定，預設主系統現行可推的包 | 只給更新站最新相容版 | 管理員要的是「現在能裝的」 |
| `.sha256` 來源 | 主系統本機檔 | R2 物件，`artifact=sha256` | 同第一列 |
| 改 update-center | 不改 | 改（artifact、Content-Disposition、TTL 設定、後台分頁） | 時限與 sha256 都在簽發端 |
| 被推翻的 v1.0 決策 | — | D1（本機供檔）、D4（主系統可調時限）、D9（沒包先預先下載）、D11 的併發與探測部分 | 見上 |
| 保留的 v1.0 決策 | — | D2 兩入口共用元件、D5 讀權限、D8 稽核（欄位改）、D10 剪貼簿退路、D11 的發票節流與 10 秒連點鎖 | 與來源無關 |
