從 local-tool 複製出獨立的「visionA Agent」桌面應用(A3 純橋樑: tunnel client + 配對 UI + 設定,不開 HTTP port、不做本機裝置/推論 UI)。 Bundle ID 與 local-tool 不同(com.innovedus.visiona-agent vs visiona-local), 雙 app 可共存。fork 後不主動 sync,需要時手動 cherry-pick。 Backend / Wails Go(AB1-AB13): - internal/tunnel:6 狀態機(Idle/Connecting/Connected/Reconnecting/Failed/Stopped) + Pair/Unpair/Reconnect/Disconnect binding + ClientHooks event - internal/auth:encrypted file token store(AES-GCM + scrypt + machineID fallback salt + 13 tests) - internal/config:YAML validation + atomic write + 11 tests - internal/log:ring buffer + ExportLog 升級 zip - visionA-backend /api/pairing/exchange:SessionTokenStore + 17 new tests - 三平台 build 驗證(macOS DMG 160 MB / Windows EXE / Linux AppImage) - end-to-end 5 milestone 全綠(pairing → tunnel → forward → reuse 防護 → tunnel drop failover) Frontend / Next.js(AF1-AF7,沿用 visionA-frontend 基礎): - AppShell + Header + TabNav(StatusView / PairView / SettingsView 三 tab) - ConnectionStatusBadge 5 種狀態 - TokenInput regex 驗證 + 7 種錯誤 + 0.5s auto-switch 到狀態頁 - 設定頁 4 區塊(含重新配對 AlertDialog) - agent-api.ts 封裝 Wails bindings(mock/real 雙實作)+ 90 tests Phase 0.7 review-driven fix(Round 2): - A1 Session fixation 防護(RotateSessionID) - A3 mock pairing 預設改 false(必須明確 opt-in)+ startup log - A4 Pair 失敗後 state 清理矩陣(exchange/Save/Start fail 各自終態) - A5 Pair/Unpair/Reconnect lifecycleMu + 50 goroutine race test - F1 重新配對次按鈕 / F2 PairView Esc cancel / F3 Wails BrowserOpenURL / F4 Settings draft 持久 + 未儲存 badge 驗證:agent backend go test -race -count=3 ./... 4 packages 全綠 / agent frontend pnpm test 119 tests 全綠 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
273 lines
9.3 KiB
Go
273 lines
9.3 KiB
Go
// pairing.go — 雛形配對流程實作(AB5 範圍)。
|
||
//
|
||
// 對應 TDD §4.3「配對流程(雛形 + Phase 1)」與 Design spec §5。
|
||
//
|
||
// 責任:
|
||
// 1. 驗證 Pairing Token 格式(vAc_ + 32 hex)
|
||
// 2. 呼叫雲端 visionA-backend 的 POST /api/pairing/exchange
|
||
// 3. 若 mock mode = true(AB11 尚未上線),本地產生假 Session Token 供 dev 測試
|
||
// 4. 回傳 session token + account + relay URL 給 Manager 寫入 TokenStore
|
||
//
|
||
// ⚠️ 這個檔不動 visionA-backend 程式碼(那是 AB11 的事)。當 AB11 做完
|
||
// /api/pairing/exchange 上線,把 config.MockMode 設回 false 就會走真實呼叫。
|
||
package tunnel
|
||
|
||
import (
|
||
"bytes"
|
||
"crypto/rand"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"regexp"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
// PairingMockEnvVar 是控制 mock pairing 模式的環境變數名。
|
||
// 預設行為:unset / 任何 ≠ "true" 的值 → 走真實 exchange(production-safe default)。
|
||
// 必須明確設成 "true"(不分大小寫)才啟用 mock 模式。
|
||
//
|
||
// 此預設策略由 Fix-A3 引入;歷史上預設為 mock=on,AB11 完成後改為明確 opt-in 避免
|
||
// 使用者忘設環境變數誤用 mock token。
|
||
const PairingMockEnvVar = "VISIONA_PAIRING_MOCK"
|
||
|
||
// IsPairingMockOptIn 根據環境變數判斷是否啟用 mock pairing 模式。
|
||
//
|
||
// 規則(必須明確 opt-in):
|
||
// - "true"(不分大小寫)→ true
|
||
// - 其他任何值(unset / "false" / "1" / 拼錯字 / 空字串)→ false
|
||
//
|
||
// 抽出來的目的:app.go 與測試共用同一份判斷邏輯,避免規則飄移。
|
||
func IsPairingMockOptIn(envValue string) bool {
|
||
return strings.EqualFold(envValue, "true")
|
||
}
|
||
|
||
// ErrInvalidTokenFormat 表示 Pairing Token 不符合 vAc_ + 32 hex 格式。
|
||
var ErrInvalidTokenFormat = errors.New("invalid pairing token format (expected vAc_ + 32 hex)")
|
||
|
||
// ErrTokenInvalid / ErrTokenExpired / ErrTokenUsed / ErrTokenRevoked 對應雲端
|
||
// /api/pairing/exchange 401 回應的四種 code(Design spec §5.4)。
|
||
// Manager 呼叫 Pair() 失敗時會把這些錯誤 emit 成 pairing:result event,前端依
|
||
// code 顯示本地化訊息。
|
||
var (
|
||
ErrTokenInvalid = errors.New("pairing token invalid")
|
||
ErrTokenExpired = errors.New("pairing token expired")
|
||
ErrTokenUsed = errors.New("pairing token already used")
|
||
ErrTokenRevoked = errors.New("pairing token revoked")
|
||
)
|
||
|
||
// ErrExchangeNetwork 為 network 層錯誤(DNS / TCP / TLS)。
|
||
var ErrExchangeNetwork = errors.New("exchange network error")
|
||
|
||
// pairingTokenRegex 對應 TDD §4.3:vAc_ 開頭 + 正好 32 個小寫 hex。
|
||
// 大寫 hex 不接受,與 visionA-backend 雛形生成格式一致。
|
||
var pairingTokenRegex = regexp.MustCompile(`^vAc_[0-9a-f]{32}$`)
|
||
|
||
// ValidatePairingToken 回傳 nil 代表格式正確。
|
||
func ValidatePairingToken(token string) error {
|
||
if !pairingTokenRegex.MatchString(token) {
|
||
return ErrInvalidTokenFormat
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// ExchangeResult 是 exchange 成功後回傳給 Manager 的資料。
|
||
type ExchangeResult struct {
|
||
SessionToken string
|
||
// Account 是雲端帳號 email。雛形 mock 下為 "demo@visionA.local"。
|
||
Account string
|
||
// RelayURL 雲端告訴 agent 接下來要連哪個 relay(ws(s)://host/tunnel/connect)。
|
||
// 若回應沒帶此欄位,Manager 會 fallback 用 Config 中原本的 RelayURL。
|
||
RelayURL string
|
||
}
|
||
|
||
// exchangeRequest 對應 TDD §4.3 定義的 request body。
|
||
type exchangeRequest struct {
|
||
PairingToken string `json:"pairing_token"`
|
||
}
|
||
|
||
// exchangeResponse 對齊雛形雲端 handler 回傳欄位。
|
||
// 雛形 handler 只回 session_token + expires_at;account / relay_url 視為選填
|
||
// (Phase 1 才有;沒有時 Manager 用既有 Config fallback)。
|
||
type exchangeResponse struct {
|
||
SessionToken string `json:"session_token"`
|
||
ExpiresAt string `json:"expires_at,omitempty"`
|
||
Account string `json:"account,omitempty"`
|
||
RelayURL string `json:"relay_url,omitempty"`
|
||
}
|
||
|
||
// exchangeErrorResponse 對齊雛形雲端 401 body: { "code": "token_invalid" | ... }。
|
||
type exchangeErrorResponse struct {
|
||
Code string `json:"code"`
|
||
}
|
||
|
||
// PairingExchanger 介面讓 Manager 在測試時能注入 fake(避免真的打 HTTP)。
|
||
type PairingExchanger interface {
|
||
Exchange(pairingToken string) (ExchangeResult, error)
|
||
}
|
||
|
||
// HTTPPairingExchanger 是生產用的實作,打真實的 HTTP 端點。
|
||
// MockMode = true 時不打 HTTP,改為本地產 fake session token,方便 AB11 未完成前
|
||
// 先做 end-to-end 驗證。
|
||
type HTTPPairingExchanger struct {
|
||
// CloudAPIURL 是 visionA-backend 的 base URL(不含 path)。
|
||
// 例:https://api.visionA.cloud
|
||
CloudAPIURL string
|
||
|
||
// Client 可注入自訂 http.Client(timeout 測試);nil 用預設 10 秒 timeout。
|
||
Client *http.Client
|
||
|
||
// MockMode = true 時跳過真實 HTTP、直接產假 session token。
|
||
// 僅用於 AB11 尚未落地的 dev 流程;正式上線必須 false。
|
||
MockMode bool
|
||
|
||
// MockAccount 可覆寫 mock 模式下回傳的 account email;空值用預設。
|
||
MockAccount string
|
||
|
||
// MockRelayURL 可覆寫 mock 模式下回傳的 relay URL;空值時不帶,讓 Manager
|
||
// fallback 用 Config.RelayURL。
|
||
MockRelayURL string
|
||
}
|
||
|
||
// NewHTTPPairingExchanger 建立一個生產預設實例。
|
||
func NewHTTPPairingExchanger(cloudAPIURL string) *HTTPPairingExchanger {
|
||
return &HTTPPairingExchanger{
|
||
CloudAPIURL: cloudAPIURL,
|
||
Client: &http.Client{Timeout: 10 * time.Second},
|
||
}
|
||
}
|
||
|
||
// Exchange 執行 pairing exchange。Manager 會在 Pair() 流程呼叫。
|
||
func (e *HTTPPairingExchanger) Exchange(pairingToken string) (ExchangeResult, error) {
|
||
if err := ValidatePairingToken(pairingToken); err != nil {
|
||
return ExchangeResult{}, err
|
||
}
|
||
if e.MockMode {
|
||
return e.exchangeMock(pairingToken)
|
||
}
|
||
return e.exchangeReal(pairingToken)
|
||
}
|
||
|
||
// exchangeMock 不打 HTTP,僅用 crypto/rand 產一個合法格式的 Session Token。
|
||
// 格式:vAs_ + 64 hex,對齊 TDD §4.3 雛形規格。
|
||
func (e *HTTPPairingExchanger) exchangeMock(_ string) (ExchangeResult, error) {
|
||
token, err := generateMockSessionToken()
|
||
if err != nil {
|
||
return ExchangeResult{}, err
|
||
}
|
||
account := e.MockAccount
|
||
if account == "" {
|
||
account = "demo@visionA.local"
|
||
}
|
||
return ExchangeResult{
|
||
SessionToken: token,
|
||
Account: account,
|
||
RelayURL: e.MockRelayURL, // 空字串代表讓 Manager 用既有 RelayURL
|
||
}, nil
|
||
}
|
||
|
||
// exchangeReal 真實呼叫 visionA-backend /api/pairing/exchange。
|
||
func (e *HTTPPairingExchanger) exchangeReal(pairingToken string) (ExchangeResult, error) {
|
||
if e.CloudAPIURL == "" {
|
||
return ExchangeResult{}, errors.New("cloud API URL not configured")
|
||
}
|
||
client := e.Client
|
||
if client == nil {
|
||
client = &http.Client{Timeout: 10 * time.Second}
|
||
}
|
||
|
||
body, err := json.Marshal(exchangeRequest{PairingToken: pairingToken})
|
||
if err != nil {
|
||
return ExchangeResult{}, err
|
||
}
|
||
|
||
endpoint := strings.TrimRight(e.CloudAPIURL, "/") + "/api/pairing/exchange"
|
||
req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(body))
|
||
if err != nil {
|
||
return ExchangeResult{}, err
|
||
}
|
||
req.Header.Set("Content-Type", "application/json")
|
||
|
||
resp, err := client.Do(req)
|
||
if err != nil {
|
||
return ExchangeResult{}, fmt.Errorf("%w: %v", ErrExchangeNetwork, err)
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
respBody, _ := io.ReadAll(resp.Body)
|
||
|
||
switch resp.StatusCode {
|
||
case http.StatusOK:
|
||
var ok exchangeResponse
|
||
if err := json.Unmarshal(respBody, &ok); err != nil {
|
||
return ExchangeResult{}, fmt.Errorf("decode exchange response: %w", err)
|
||
}
|
||
if ok.SessionToken == "" {
|
||
return ExchangeResult{}, errors.New("exchange response missing session_token")
|
||
}
|
||
return ExchangeResult{
|
||
SessionToken: ok.SessionToken,
|
||
Account: ok.Account,
|
||
RelayURL: ok.RelayURL,
|
||
}, nil
|
||
case http.StatusUnauthorized:
|
||
var errResp exchangeErrorResponse
|
||
_ = json.Unmarshal(respBody, &errResp)
|
||
return ExchangeResult{}, mapExchangeErrorCode(errResp.Code)
|
||
case http.StatusNotFound:
|
||
// AB11 尚未完成時的清楚訊號;訊息指引使用者往 mock_mode 或等 AB11。
|
||
return ExchangeResult{}, fmt.Errorf("exchange endpoint not found (AB11 pending; set mock_mode or wait for backend deploy)")
|
||
default:
|
||
return ExchangeResult{}, fmt.Errorf("exchange failed: http %d: %s", resp.StatusCode, truncate(string(respBody), 256))
|
||
}
|
||
}
|
||
|
||
func mapExchangeErrorCode(code string) error {
|
||
switch code {
|
||
case "token_invalid":
|
||
return ErrTokenInvalid
|
||
case "token_expired":
|
||
return ErrTokenExpired
|
||
case "token_used":
|
||
return ErrTokenUsed
|
||
case "token_revoked":
|
||
return ErrTokenRevoked
|
||
default:
|
||
return fmt.Errorf("%w (code=%q)", ErrTokenInvalid, code)
|
||
}
|
||
}
|
||
|
||
// generateMockSessionToken 產生 vAs_ + 64 hex 的假 token(mock mode 用)。
|
||
func generateMockSessionToken() (string, error) {
|
||
buf := make([]byte, 32) // 32 bytes → 64 hex chars
|
||
if _, err := rand.Read(buf); err != nil {
|
||
return "", err
|
||
}
|
||
return "vAs_" + hex.EncodeToString(buf), nil
|
||
}
|
||
|
||
// MaskSessionToken 產生 Session Token 的遮蔽顯示字串,供 UI / log 使用。
|
||
// 格式:前綴(vAs_) + 前 8 hex + " ··· " + 後 4 hex,例「vAs_a1b2c3d4 ··· e7f8」。
|
||
// 對齊 Design spec §4.2 (B) 的 Session Token 遮蔽規則。
|
||
func MaskSessionToken(token string) string {
|
||
if !strings.HasPrefix(token, "vAs_") {
|
||
// 非預期格式;回空字串避免洩漏
|
||
return ""
|
||
}
|
||
rest := strings.TrimPrefix(token, "vAs_")
|
||
if len(rest) < 12 {
|
||
return ""
|
||
}
|
||
return "vAs_" + rest[:8] + " ··· " + rest[len(rest)-4:]
|
||
}
|
||
|
||
func truncate(s string, n int) string {
|
||
if len(s) <= n {
|
||
return s
|
||
}
|
||
return s[:n]
|
||
}
|