---
title: FR-191 Agent 安裝包自助下載
status: v1.0 定稿，已實作完成
lede: "管理員在主系統就能拿到 Agent 安裝包與 .sha256 兩個檔的下載連結，不必再向原廠要。連結限時（預設 30 分鐘，可調）、可複製給裝機的人。"
relates: [FR-172, FR-189, FR-167]
---

# 設計 v1.0（定稿）：Agent 安裝包自助下載

> 📑 **本頁是 v1.0（定稿，已被推翻）。** 2026-11-10 決策者裁改為 R2 直連、時限改由原廠在更新站後台設定，見 **[設計 v2.1](design-v2.1.md)**（現行；v2.0 為中間版）。本頁內容保留定稿原樣，不回頭改。

## 分工概述（30 秒版）

**整案一句話**：主系統更新中心已經會把 Agent 安裝包下載到本機（`agent-bundles/<版>/`，旁邊有 `.sha256` 與簽好章的清單），但人拿不到；這案開一條「登入者產生短效連結 → 任何人憑連結在時限內下載」的路，兩個入口共用。

| 批 | 卡 | 做什麼 | 產出 | 完成判定 |
|---|---|---|---|---|
| 1 | BE | 發連結端點、兩個供檔端點、時限參數讀寫、稽核事件、error code | `api/product_update/routes/agent_bundle_link_route.py`、`app/product_update/service/agent_bundle_link_service.py` | curl 拿到的票在時限內能下載整包與 `.sha256`，過期 401，sha256sum 對得上 |
| 2 | FE | 共用元件「安裝包下載」＋更新中心 Agent 區按鈕＋註冊 token 對話框內嵌＋時限欄位 | `AgentBundleLinks.vue` 等 | 兩個入口都看得到兩列連結、複製鈕在 http＋IP 下也能複製 |
| 3 | 文件 | FR-189 快速版與完整版手冊「出貨包」來源改成「從主系統下載」；FR-191 README 收口 | `docs/user-manual/agent/`、FR-189 `site/index.html` | 手冊 grep 不到「向原廠取得出貨包」 |

**依賴**：批 2 等批 1 合回（要打真端點）；批 3 等批 2（手冊要截新畫面）。

## 1. 這案在做什麼（白話）

要裝一台新的 Agent，裝機的人需要兩個檔：安裝包 `guidant-agent-compose-<版>.tar.gz` 和同名 `.sha256`。現在這兩個檔只有原廠手上有，每次都靠我們跑 `scripts/r2_presign.py` 產 24 小時連結寄過去（FR-189 給 190 就是這樣）。

做完之後：管理員在主系統按一下，就拿到兩條下載連結；自己按下載也行，複製貼給機房同事用 `curl -O` 也行。連結預設 30 分鐘有效，時限可以在更新中心改。

## 2. 現況（首腦查過的事實）

- 主系統更新中心 Agent 區按過「預先下載」或推過更新後，包就在 `<GUIDANT_UPGRADE_DIR>/agent-bundles/<版>/`，三個檔：`guidant-agent-compose-<版>.tar.gz`、`.tar.gz.sha256`（內容 `<sha256>  <檔名>`，`sha256sum -c` 直接吃）、`.release.json`（簽好章的清單）。路徑算法 `app/product_update/service/package_downloader.py:87-99`（`agent_package_paths`），版號格式不對會丟 `ValueError`。
- 現在唯一的供檔端點 `GET /agents/update/bundle`（`api/remote_agent/routes/agent_update_bundle_route.py`）只給 Agent 容器用通行證拿；人拿不到。它用 `send_file(..., conditional=True)` 做續傳、回 `X-Bundle-Sha256` 等標頭，供檔邏輯可照抄。
- 主系統現行可推的包：`AgentReleaseService.current_bundle()`／`bundle(version)`（`agent_release_service.py:127-146、165`），回 `AgentBundle(path, version, sha256, size, min_host_version)`。更新中心 Agent 區 API `GET /system/product-update/agents` 回的 `agent_release.bundle_version`／`bundle_ready` 就是這份。
- 短效簽章票機制已存在：`common.authz` 的 `issue_signed_token(scope, resource_uid)` 與 `signed_token_required(scope, bind_uid_arg)`，實作在 `jedi_iam.authz.signed_token`；驗證時 `verify_signed_token(token, scope, ttl)` 的 `ttl` 是參數，預設 120 秒。既有用法：框架版本 OSCAL 下載（`api/oscal/routes/framework/oscal_framework_version_route.py:108-157`）：登入打發放端點換 `st`，再開 `?st=` 網址。
- 更新中心的系統層設定存 ROOT 租戶 `system_configs`，group `PRODUCT_UPDATE`（`product_update_flow.py:43-46`），讀寫走 `infra/system_config/system_config_root_reader.py` 的 `read_root_config_value`／`write_root_config_value`。
- 更新站的 `/v1/download-url` 預簽網址寫死 15 分鐘（update-center `src/releases.ts:13`），而且客戶主機多半沒外網，不適合當給人的連結。
- FE：註冊 token 對話框 `src/components/remote-agent/AgentEnrollTokenCreateDialog.vue`（120 行）建完顯示 token 與主系統端點、各有複製鈕，複製直接呼叫 `navigator.clipboard`，http＋IP 下會是 undefined。`src/components/common/ProtectedDownloadDialog.vue:30-56` 已有 `execCommand('copy')` 退路寫法。更新中心 Agent 區 `src/views/product-update/AgentRolloutPanel.vue`（499 行）有「預先下載」次要鈕（第 364 行附近動作列）。簽票下載工具 `src/utils/signedDownloadUtil.js`。
- `agent_release_service.py` 目前被 CM-3728、CM-3711 兩個工作區同時改動，本案不碰它，只呼叫。

## 3. 設計決策

| # | 決策 | 理由 | 排除的做法 |
|---|---|---|---|
| D1 | 包從主系統本機 `agent-bundles/` 供檔 | 檔已經在那裡且驗過章；客戶內網不需外網；與 Agent 拉包同一份檔 | 給更新站 R2 預簽連結：寫死 15 分鐘、要外網、改時限得動 update-center |
| D2 | 一條下載路徑、兩個入口：更新中心 Agent 區「取得安裝包」鈕開對話框；註冊 token 對話框建完 token 後下方內嵌同一個元件 | 包住在更新中心，裝機的人在金鑰頁；不做兩套 | 只放一邊：裝機流程得跳頁 |
| D3 | 連結＝簽章票：scope `agent-bundle-download`，`resource_uid` = `<版>:<tarball\|sha256>`，一次發兩枚 | 重用既有軸⑤機制，不另立 token 表；綁死版號與檔種，拿到票只能下那一個檔 | 自建一次性連結表：多一張表、多一套清理 |
| D4 | 時限預設 30 分鐘，存 `PRODUCT_UPDATE.agent_bundle_link_ttl_minutes`（ROOT system_configs），範圍 1～1440；驗票時以當下設定值為 ttl | 「可調參數」用既有設定存法；一個數字欄位，不做下拉（下拉要進 system_menus，沒必要） | 寫死 30 分；或放安全政策頁（跟更新中心無關的頁不好找） |
| D5 | 產生連結要 `system.product-update.read`；改時限要 `system.product-update.update` | 下載不改系統狀態，讀權限夠；改設定是寫 | 一律要 update：看得到版號的人卻拿不到包 |
| D6 | 供檔端點 `GET /system/product-update/agents/bundle/<version>/<kind>?st=`，`kind` ∈ `tarball`／`sha256`；只掛 `signed_token_required`，不要 JWT | 純瀏覽器原生 GET 與 curl 都帶不了 bearer | 雙軌 `signed_token_or_jwt`：這端點沒有 axios 呼叫方，雙軌多一條沒人走的路 |
| D7 | 絕對網址由 FE 以 `window.location.origin` 組；BE 只回相對路徑、`st`、`expires_at`、sha256、大小 | 主系統不知道自己對外網址（註冊端點那條是 Agent 用的 `cloud_endpoint`，不一定等於管理員瀏覽器看到的） | BE 組絕對網址：要新 env var，違反「新增 env 前先查既有」且多一個會填錯的值 |
| D8 | 產生連結寫稽核：reuse 既有事件碼 `AGENT_BUNDLE_DOWNLOAD`（6251）、action 字串 `AGENT_BUNDLE_LINK_ISSUED`、欄位 `kind=link_issued`（誰、哪版、時限）；不新增事件碼，因為 FR-188 M-1／SEC-6 同時在改 `event_code.py`；供檔成功不另寫（檔不含祕密） | 有人拿連結出去要查得到是誰產的 | 供檔也寫：curl 續傳每段一筆，噪音 |
| D9 | 沒有包時對話框顯示「主系統尚未下載 Agent X，請先到更新中心預先下載」＋跳轉連結 | 下載包要外網，是主系統連、不是人連；既有「預先下載」已經做好 | 對話框內直接觸發下載：又一條觸發下載的路徑 |
| D10 | FE 複製鈕抽共用 `src/utils/clipboardUtil.js`（`navigator.clipboard` → `execCommand` 退路），`ProtectedDownloadDialog` 與 `AgentEnrollTokenCreateDialog` 一起換用 | http＋IP 開站複製鈕按了沒反應（FR-169 踩過）；兩處既有寫法不一致 | 各自再寫一次 |
| D11 | 擋連點與灌下載：FE 下載鈕按後停用 10 秒並先探測（HEAD／Range 0-0）再開；BE 同一枚票同時最多 2 條串流、全站人工下載同時最多 3 條（Redis 計數，`call_on_close` 釋放，超限 429＋`Retry-After`）；發票每人每分鐘 10 次。上限值存同一 system_configs group，不開畫面 | 一次下載 1.7 GB 佔住一條 gunicorn 執行緒（預設共 64 條整站共用）；瀏覽器下載默默開始，使用者會重按；curl 可在時限內無限重下。限「同時幾條」不限「總次數」，續傳不受影響 | 前門 nginx `limit_conn`：`/api/1.0` 是單一 location 會連 Agent 拉包一起限；nginx X-Accel 直送：要把包目錄掛進前門容器，是基礎建設改動，頻寬真被吃滿再議 |

## 4. API

### 4.1 `POST /system/product-update/agents/bundle-links`（讀權限）

Request：`{"version": "1.7.4"}`（可省，省略＝`agent_release.bundle_version`）。

Response `data`：
```json
{
  "version": "1.7.4", "size": 1834567890, "sha256": "b771e3…8a4716",
  "expires_at": "2026-11-09T06:35:00+00:00", "ttl_minutes": 30,
  "files": [
    {"kind": "tarball", "name": "guidant-agent-compose-1.7.4.tar.gz",        "path": "/api/system/product-update/agents/bundle/1.7.4/tarball?st=…"},
    {"kind": "sha256",  "name": "guidant-agent-compose-1.7.4.tar.gz.sha256", "path": "/api/system/product-update/agents/bundle/1.7.4/sha256?st=…"}
  ]
}
```
錯誤：版號格式不對 400（沿用 `package_downloader` 的 `ValueError`）；本機沒這版 404 `PRODUCT_UPDATE_404005`。

### 4.2 `GET /system/product-update/agents/bundle/<version>/<kind>?st=`（只驗票）

`signed_token_required(scope="agent-bundle-download")`，驗完再比 payload 的 `resource_uid == f"{version}:{kind}"`，不符 401。ttl 取 D4 設定值換成秒。`send_file(conditional=True, as_attachment=True)`，標頭 `X-Bundle-Version`／`X-Bundle-Sha256`／`X-Bundle-Size` 同既有 Agent 端點。`kind` 不在兩值內 404。

### 4.3 時限設定

`GET /system/product-update/agents/bundle-link-settings`（讀權限）回 `{"ttl_minutes": 30}`；`PUT` 同路徑 `{"ttl_minutes": 1..1440}`（更新權限），範圍外 400 `PRODUCT_UPDATE_400013`。獨立端點而不併進 `agents` overview，是為了不碰 FR-188 SEC-1 正在改的 `product_update_app_service.py`。

## 5. 畫面

- **更新中心 Agent 區**：動作列加次要鈕「取得安裝包」（有包才亮；沒包 tooltip 說先預先下載）。開 Dialog，內容＝共用元件。Agent 區說明旁加「下載連結有效時間（分鐘）」數字欄位，有更新權限才可改。
- **共用元件 `AgentBundleLinks.vue`**：載入時打 `agents` 拿 `bundle_version`；有包→顯示版號、大小、sha256（可複製）、兩列檔名各帶〔下載〕〔複製連結〕、一行「連結 30 分鐘內有效，到 HH:MM」，連結在第一次顯示時就打 `bundle-links` 產好；過期後列變灰並出現「重新產生」。沒包→D9 提示。
- **註冊 token 對話框**：建完 token 的那一頁，在端點欄位下方加區塊「安裝包」，直接放共用元件。
- 禁左側粗色邊框；提示用既有 `hint-bar`。

## 6. 與 FR-188 平行的避撞

FR-188（母卡 CM-3728）同時在跑。本案不碰 `product_update_route.py`、`product_update_app_service.py`（SEC-1）、`event_code.py`、`error_code.py`（M-1、SEC-6）、`_agent_update.py`（SEC-5）、`agent_release_service.py`（SEC-6）；BE 三端點全在新檔。FE 卡等 FR-188 F-1（CM-3741）合回才動 `AgentRolloutPanel.vue` 與 `api.js`。

## 7. 不做

- 不給 R2 連結；不另存包；不做時限下拉；供檔不寫稽核；不做「對話框內觸發下載」；不動前門 nginx、不做 X-Accel 直送。
- 不改 `agent_release_service.py`（兩個工作區在動）。

## 8. 安全

- 票綁 scope＋`<版>:<kind>`，過期即失效；供檔只在 `agent-bundles/` 底下用驗過格式的版號取檔，不接受路徑。
- 包不含任何祕密（憑證、金鑰都是裝機後產生）；外洩代價是「別人拿到產品安裝包」，30 分鐘可接受。
- 產生連結留稽核（D8）。
