FR-191 Agent 安裝包自助下載(設計 v2.0)

客戶還沒裝 Agent 時,要有地方拿到安裝包。管理員在主系統點選「取得安裝包」,主系統向原廠更新站換兩條 Cloudflare R2 預簽下載網址(安裝包、.sha256),直接下載或複製給裝機的人。時限由原廠在更新站後台設定,客戶端不能改。

設計 v2.0(定稿):Agent 安裝包自助下載——R2 直連

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

⚠️ 本頁已由 設計 v2.1 取代。 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:

{
  "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. 流程

%%{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
圖 1 管理員產生 R2 下載網址。第 3~6 步主系統以主機授權身分向更新站換網址;檔案下載(第 9 步)完全不經主系統。

第 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 秒連點鎖 與來源無關