jim800121chen 3f0175f1a9 feat(local-agent): Phase 0.5 visionA Agent — Wails 桌面 + tunnel client + 配對 UI
從 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>
2026-05-01 11:22:01 +08:00

273 lines
9.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 = trueAB11 尚未上線),本地產生假 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" 的值 → 走真實 exchangeproduction-safe default
// 必須明確設成 "true"(不分大小寫)才啟用 mock 模式。
//
// 此預設策略由 Fix-A3 引入;歷史上預設為 mock=onAB11 完成後改為明確 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 回應的四種 codeDesign 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.3vAc_ 開頭 + 正好 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 接下來要連哪個 relayws(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_ataccount / 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.Clienttimeout 測試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 的假 tokenmock 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]
}