FR-182 Agent 授權照(一台一張、機器碼開通、離線可用)

Agent 比照主系統拿照:License Center 簽發、給序號、客戶抄 Agent 機器碼來開通、拿到綁機的照上傳。Agent 不分功能,只分有照/沒照;客戶封閉網路靠離線開通。第一期只做 License Center 端。

FR-182 Agent 授權照 v1.0(定案,P1 已開卡)

§1

這案在做什麼

主系統每個租戶要有一張簽過章的授權照才能用(FR-062),Agent 完全沒有:拿到註冊 token 就能裝、能跑、裝幾台都行。決策者 2026-11-07 定調 Agent 要用跟主系統一樣的模式拿照,做完後的狀態:

現況 目標
License Center 只認主系統的照 多一種「Agent 照」:新模組、新表、後台五頁、三支機器 API、五支 CLI
一台 Agent 無授權概念 一張照、綁那台的機器碼、有到期日、沒有模組清單
客戶怎麼拿 — 員工在 LC 簽發拿序號 → 客戶在 Agent 主控台看機器碼 → 序號+機器碼交回 → 員工在 LC 離線開通、寄回照 → 客戶上傳
沒照的 Agent — 主控台可登入、看機器碼、上傳照;不註冊、不心跳、不收指令、不掃描
主系統 — Agent 列表多一欄授權到期(P3)

分三期:P1 License Center(本版開卡)、P2 Agent 端、P3 主系統端。P2/P3 等 FR-181 與 FR-179 的 Agent 卡合回再拆(§6)。

§2

分工概述(30 秒版)

階段 做什麼 產出 完成怎麼判定(決策者檢查法)
P1 License Center 新模組 agent_licensing:表、簽發/開通/換綁/補發/展延、後台五頁、三支機器 API、五支 CLI、文件 license_center 一版 開 LC 後台 → 「Agent 授權」→ 簽一張(數量 3)→ 3 組序號 → 拿一組序號+一串 64 碼機器碼走「離線開通」→ 下載 .license → CLI agent-verify 過。對照雛形 prototype/lc-agent-license.html 七個場景
P2 Agent 端 驗 Agent 照、主控台「授權」頁、沒照不起 sync 模組 evidence-agent 一版 123 的 Agent 刪掉照 → 主控台「尚未開通」+機器碼、主系統列表離線;上傳 P1 的照 → 「已授權,到期 …」、主系統恢復上線。對照 prototype/agent-console-license.html 七個場景
P3 主系統端 Agent 列表授權欄;線上開通代打 LC BE+FE 一版 主系統「遠端 Agent」頁每台看得到到期日;Agent 主控台輸入序號按線上開通 10 秒內變已授權
§3

關鍵決策(D1~D8,決策者 11-07 全數同意首腦建議)

  • D1 獨立一張照,記上層主系統照號。 不把台數塞進主系統照的 limits.max_agents——Agent 本機沒東西可驗、離線開通做不出來、加買要重簽主系統的照。照上 customer_code 必填、parent_license_id 選填,LC 看得出「這客戶買了主系統+N 台 Agent」。
  • D2 LC 新模組 agent_licensing、新表 agent_license_issuance,不動 license_issuance。 既有表 tenant_id/plan_code/modules 必填,塞進去要全改可空、總覽與狀態推算到處加「如果是 Agent」分支。序號邏輯與私鑰解鎖抽到共用層(core/activation_code.py、web/unlock_helper.py),不從 licensing 模組 import(鐵律③)。
  • D3 照格式:同一個信封、payload_type: "agent_license",沒有模組與方案,到期政策兩段式。 欄位見 §4.2。payload_type 缺鍵=主系統照,是向後相容的支點;Agent 驗章指定型別,拿主系統的照來上傳會被拒。
  • D4 機器碼直接用 Agent 的 device_uuid(sha256(product_uuid|machine-id)),不另算。VM 模板複製兩台同碼的風險由主系統註冊撞號偵測擋,LC 不處理。
  • D5 第一期只做離線開通;線上開通走 Agent → 主系統 → LC,Agent 永不直連 LC。 LC 第一期把線上開通端點做出來(薄殼),主系統端第二期接。
  • D6 沒照或過期不擋啟動,但整個 sync 模組不起(註冊、心跳、設定拉取、FR-181 長輪詢一道門關掉,不逐條加 if)。寬限期全功能,過了寬限回到「尚未開通」。上傳照後同行程放行、不用重啟。
  • D7 簽發表單加「數量」,一次產 N 張各自獨立的照與序號,共一個 batch_id。 不做一張照 N 座位的共享池。
  • D8 Agent 端驗章用既有 common/integrity/verify.py,不裝 jedi-license-runtime。 後者帶 PG 表、租戶解析、Flask API,Agent 是單租戶 SQLite 用不上。到期狀態機很小,兩邊各一份。
§4

現況接入點

元件 現況 本案動作
LC core/crypto/engine.py sign_payload/verify_payload/armor 按 payload_type 分型,已簽過 manifest/unlock/update_ticket reuse,加常數 PAYLOAD_TYPE_AGENT_LICENSE
LC modules/licensing/service.py 序號三支 與照無關的純序號邏輯 extend:抽到 core/activation_code.py
LC web/views/issuance.py 解鎖兩支 只被 issuance 用 extend:搬到 web/unlock_helper.py
LC modules/auth/service.py 開通鎖定 來源 IP、15 分鐘 5 次、全站預算,表 activation_attempts reuse,Agent 線上開通共用同表(同 IP 連錯主系統與 Agent 序號合計,寫進合約文件)
LC core/status.py compute_status 四段式 extend:加 compute_agent_status 兩段式
LC web/views/internal_api.py token 驗證 492 行、超標 extend:抽到 web/internal_auth.py 共用,不往裡加
LC 四支凍結 API 合約守衛 不動;Agent 三支全部新路徑
Agent platform/fingerprint.py device_uuid 註冊主系統用 reuse(P2)
Agent common/integrity/verify.py+PUBLIC_KEYS 驗產物清單 reuse,加 expected_type(P2)
Agent sync/__init__.py setup_agent_auth → enroll_for_auth 起註冊/心跳/各背景元件 extend:前面加授權閘(P2)
Agent platform/registry.py MODULES 模組申報 status_fields/blueprint extend:登記新模組 license(P2,排 FR-181 F-2 之後)
主系統 activate_license_online() 主系統代打 LC 開通 reuse 做法(P3)
§5

詳細設計

4.1 端到端

%%{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
    participant E as 我們的員工
    participant L as License Center
    participant C as 客戶
    participant A as Agent 主控台
    participant M as 主系統

    E->>L: 「Agent 授權」→ 簽發:客戶、訂單、數量 3、天數 365
    L-->>E: 3 張照(未綁機)+ 3 組序號
    E->>C: 交付序號
    rect rgb(238, 242, 243)
    Note over C,A: 離線開通(P1 LC 端+P2 Agent 端)
    C->>A: 登入主控台 → 「授權」頁顯示「尚未開通」+機器碼
    C->>E: 序號 + 機器碼
    E->>L: 「Agent 離線開通」:序號+機器碼
    L-->>E: 綁機的照(.license)
    E->>C: 回寄照檔
    C->>A: 上傳照
    A->>A: 驗章+核對機器碼+到期 → 已授權 → 起 sync 模組
    A->>M: 註冊/心跳/狀態事件帶授權
    end
    rect rgb(226, 240, 241)
    Note over C,L: 線上開通(P3,認證用註冊 token)
    C->>A: 「授權」頁輸入序號
    A->>M: 序號+機器碼+註冊 token(出站)
    M->>L: 代打 /api/activation/agent/activate
    L-->>M: 綁機的照
    M-->>A: 照
    A->>A: 驗章 → 已授權
    end
圖 1 — Agent 照的離線開通(P1+P2)與線上開通(P3)

4.2 Agent 照 payload

欄位 值
payload_type "agent_license"(主系統照無此鍵)
license_id alic- + 12 碼
customer_code/order_no/issued_to 同主系統
parent_license_id 主系統照號,可空;只對照用,Agent 不驗
type formal/trial
issuer/issued_at/starts_at/expires_at 同主系統(ISO 8601)
expiry_policy {"notify_days_before": 30, "grace_days": 14}
machine_fingerprint Agent device_uuid,開通前 null
product_major 1
kid 簽章鑰指紋

信封與主系統照相同(format_version 2、Ed25519、armor 後 .license)。core/schema.py 加 AgentLicense dataclass,與 License 並列不繼承。

4.3 表 agent_license_issuance(append-only 事件帳)

欄位 型別 說明
id serial PK
agent_license_id varchar(64) unique alic-…
batch_id varchar(32) 同一次簽發 N 張共用;索引
customer_code varchar(64) 索引
customer_name varchar(256)
order_no varchar(64)
parent_license_id varchar(64) null 字串、不設 FK(跨模組不碰對方的表)
license_type varchar(16) formal/trial
product_major int default 1
starts_at/expires_at timestamptz
expiry_policy jsonb 兩個數字
activation_code varchar(64) null token_urlsafe(16)
activation_code_expires_at/activation_code_revoked_at timestamptz null 30 天效期;補發作廢舊序號
machine_fingerprint varchar(128) null
activated_at timestamptz null
rebind_history jsonb default []
issuer varchar(128)
event_type varchar(16) new/activate/rebind/reissue/extend
license_payload jsonb 完整簽章信封
created_at timestamptz default now()

Alembic revision 一支;migrations/env.py 加 import。

4.4 模組 modules/agent_licensing/service.py(六支,依賴由入口傳入、不 commit)

函式 做什麼
issue_agent_licenses(repo, private_key, *, customer_code, order_no, issued_to, parent_license_id, license_type, days, count, notify_days_before, grace_days, issuer) 產 batch_id,迴圈 N 次各簽一張未綁機照+序號,回 list[AgentIssueResult]
activate_agent_license(repo, private_key, *, activation_code, machine_fingerprint, issuer) 查序號(未作廢)→ 已用 409 → 逾期 → 照過期 → 補指紋重簽新照、event_type=activate、mark_activated
rebind_agent_license(repo, private_key, *, agent_license_id, new_machine_fingerprint, issuer) 同主系統 rebind_license
reissue_agent_license(repo, *, agent_license_id) 重出同一張;未開通的補發產新序號、舊序號作廢
extend_agent_license(repo, private_key, *, agent_license_id, days, issuer) 起算基準 max(原到期, now),同主系統
verify_agent_license(signed, public_keys) -> AgentLicense verify_payload(expected_type=agent_license) 後組 dataclass

例外類別比照 licensing(AgentLicenseNotFoundError、ActivationCodeExpiredError 共用 core/activation_code.py 的)。

4.5 後台五頁(web/views/agent_licenses.py,雛形 prototype/lc-agent-license.html)

頁 路徑 雛形場景
清單 GET /agent-licenses/ 1:搜尋客戶代碼/名稱/訂單,篩狀態/開通/型態,只列現行照;列尾下拉詳情/下載/展延/補發/換綁;待開通列顯示序號剩幾天逾期
簽發 GET/POST /agent-licenses/issue 2:客戶代碼、名稱、訂單、上層主系統照下拉(選填,呼叫 licensing service 查該客戶現行照)、型態、天數、數量、提醒天數、寬限天數、發照人
簽發結果 GET /agent-licenses/batch/<batch_id> 3:N 列照號+序號+下載未綁機照;zip 全部下載;提示序號 30 天內開通
離線開通 GET/POST /agent-licenses/activate、GET …/activate/<id>/result 4、5:序號+機器碼+發照人 → 結果頁明文+下載
詳情 GET /agent-licenses/<id>、…/download、…/extend、…/reissue、…/rebind 6:明文、事件歷史、操作下拉;非現行照標「已被取代」不給下載
客戶時間軸 既有頁加一張卡 7:「Agent 照 N 台(已開通 x/待開通 y)→ 看清單」;事件混進時間軸前綴「Agent」

導覽列加「Agent 授權」。log:簽發/開通/換綁/補發/展延/下載六種 INFO 帶 agent_license_id、customer_code、actor;序號只記 activation_code_digest。

4.6 機器 API(新路徑,凍結四支不動)

端點 認證 Request 成功
POST /api/activation/agent/activate 無 token;序號+機器碼+IP 鎖定(共用 activation_attempts) activation_code、machine_fingerprint(strip 後必填) {"license": {…}, "license_id": "alic-…"}
POST /api/internal/agent/issue X-API-Token customer_code、order_no、issued_to 必填;parent_license_id、license_type、days、count(預設 1、上限 50)、notify_days_before、grace_days 選填 {"batch_id", "licenses": [{"license_id", "activation_code", "license"}]}
GET /api/internal/agent/licenses?customer_code= X-API-Token {"licenses": [{license_id, parent_license_id, type, expires_at, status, machine_fingerprint_prefix, activated}]}

失敗 {"error": "…"};驗證順序與錯誤字串比照既有 /api/activation/activate。schema 進 web/api_schemas.py、@bp.doc 進 OpenAPI、tests/test_api_contract.py 加情境表、docs/api-contract-baseline.md 加三節。

4.7 CLI

agent-issue --count N、agent-activate <序號> --fingerprint <64 hex>、agent-verify <檔>、agent-extend、agent-rebind,放 cli/agent_commands.py,cli/main.py 一行掛。

4.8 Agent 端(P2,定方向,雛形 prototype/agent-console-license.html)

  • 照存 AGENT_DATA_DIR/license/agent.license。
  • 新模組 guidant_agent/license/(domain:狀態機;app:載入/驗證/上傳;api:主控台「授權」分頁三支端點——看狀態+機器碼、上傳照、(P3)線上開通),登記進 registry.MODULES,申報 status_fields(license: {status, expires_at, license_id})。
  • 狀態:尚未開通/正常/到期提醒/寬限/已過期(§雛形場景 1~5);機器碼對不上視同沒照並明講綁的是哪台(場景 6)。
  • 閘門:setup_agent_auth 內 enroll_for_auth 之前,狀態不是正常/提醒/寬限就不起 sync 的任何背景元件;上傳成功後同行程起。
  • 與 FR-179 A2 首次設定頁的順序:授權 → 註冊,沒照時註冊步驟灰掉。
  • 線上開通(P3)的主系統端點與 /agents/register 同一道門:註冊 token 認證(沒照的 Agent 還沒有通行證)。

4.9 主系統端(P3,定方向)

  • agents 表加 license_status、license_expires_at,從 agent_status 事件落;遠端 Agent 列表加一欄(雛形場景 7)。
  • POST /api/1.0/agents/license/activate(註冊 token 認證)代打 LC /api/activation/agent/activate,照原樣回 Agent,主系統不落照。
  • root 後台「向 LC 請 Agent 照」比照 lc-issue(第二期候選)。
§6

怎麼拆(P1,license_center repo,branch feature/FR-172)

拆卡表由 handoff/cards.py 產,兩個斷言(依賴在更早的批、同批不改同一檔)已過。

批 卡 做什麼 reuse/extend/new 改哪些既有檔 依賴
1 L1 模組地基:表、六支 service、序號與解鎖抽共用、AgentLicense、compute_agent_status、CLI 五支、空 blueprint 先掛(登記型 app.py/csrf.py owner) extend 四處、new 模組 core/crypto/engine.py、core/schema.py、core/status.py、modules/licensing/service.py、web/views/issuance.py、web/views/activation.py、cli/main.py、web/app.py、web/csrf.py、migrations/env.py —
2 L2 後台五頁+客戶時間軸一張卡 new 五頁、extend 時間軸 web/views/agent_licenses.py(填空殼)、templates/base.html、web/views/customer_timeline.py+模板 L1
2 L3 機器 API 三支、token 驗證抽 internal_auth.py、合約測試 new 三支、extend 抽共用 web/views/agent_activation_api.py/internal_agent_api.py(填空殼)、web/views/internal_api.py、web/api_schemas.py、tests/test_api_contract.py、tests/test_csrf.py、docs/api-contract-baseline.md L1
3 L4 文件站四頁、CLAUDE.md、DEV 端到端證據 extend docs/handbook/*.md、CLAUDE.md L2、L3

P2 第一批(Stage 4,已開卡 CM-3621~3623):拆卡表 handoff/cards_p2.py,檔頭有與 FR-181/FR-179 的撞檔核對——P2-1 Agent 驗照+閘門+模組登記(registry.py owner,F-2 已合回)、P2-2 主控台授權 API、P2-3 主控台授權頁 FE,三張同批零撞檔。P2 後補:未開通時主控台放行、首次設定頁插「上傳照」一步——等 FR-179 A1/A2 合回。P3(已開卡 CM-3624~3630,拆卡表 handoff/cards_p3.py):批 5 P3-1 契約兩鍵+remote_agents 兩欄+套件 migration 020(只改檔不發版)、P3-2 主系統落庫+線上開通代打 LC(註冊 token get_enabled_by_hash 只驗不消耗、主系統不驗章不落照、開發期 path dependency 不 commit);批 6 P3-3 Agent 送狀態+線上開通、P3-3b 主控台畫面;批 7 P3-4a 兩套件發版、P3-4b 主系統列表授權欄+error-code;批 8 P3-4 釘版+SPEC+手冊。FR-181 已於 11-07 全合回,批 7/8 前置解除;P3-4a 發 0.5.2/1.17.2(181 已發 0.5.1/1.17.1)。

§7

端到端驗收(P1)

  1. LC 後台登入 → 導覽「Agent 授權」→ 簽發(ACME、數量 3、365 天)→ 結果頁 3 組序號、zip 下載。
  2. 離線開通:序號①+隨機 64 hex → 結果頁下載 alic-….license → agent-verify 印 payload_type=agent_license、machine_fingerprint 相符、狀態 valid。
  3. 拿主系統的 .license 餵 agent-verify → 拒(型別不符)。
  4. 清單:①「已開通」、②③「待開通(序號 N 天後逾期)」;詳情①事件歷史兩筆;客戶時間軸「Agent 照 3 台」。
  5. curl POST /api/activation/agent/activate 序號② → 200 帶信封;再打 → 409;錯序號連打 6 次 → 429。
  6. curl POST /api/internal/agent/issue count=2 → 2 張;無 token → 401 先於 body 錯。
  7. pytest 全綠(含既有合約測試零變動);alembic check 無漂移;mkdocs build --strict 零警告。
§8

不做什麼

Agent 功能分級;Agent 直連 LC;一張照多台;主系統照格式與四支凍結 API 變動;Agent 唯讀/鎖定四段式;時鐘倒轉防護(P2 第二期候選);自助換綁;Agent 照當更新站身分票。