---
title: FR-182 Agent 授權照（一台一張、機器碼開通、離線可用）
status: P1 Done 已上 STG／POC；P2／P3 已開卡
lede: "Agent 比照主系統拿照：License Center 簽發、給序號、客戶抄 Agent 機器碼來開通、拿到綁機的照上傳。Agent 不分功能，只分有照／沒照；客戶封閉網路靠離線開通。第一期只做 License Center 端。"
relates: [FR-062, FR-066, FR-166, FR-179, FR-181]
---

# FR-182 Agent 授權照 v1.0（定案，P1 已開卡）

## 這案在做什麼

主系統每個租戶要有一張簽過章的授權照才能用（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）。

## 分工概述（30 秒版）

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

## 關鍵決策（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 用不上。到期狀態機很小，兩邊各一份。

## 現況接入點

| 元件 | 現況 | 本案動作 |
|---|---|---|
| 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） |

## 詳細設計

### 4.1 端到端

```{.mermaid cap="圖 1 — Agent 照的離線開通（P1＋P2）與線上開通（P3）"}
%%{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
```

### 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](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](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`（第二期候選）。

## 怎麼拆（P1，license_center repo，branch `feature/FR-172`）

拆卡表由 [`handoff/cards.py`](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`](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`](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）。

## 端到端驗收（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` 零警告。

## 不做什麼

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