fix(local-agent): 重啟自動從 token.bin 讀 token,免每次重配對

app.go 只在 env VISIONA_SESSION_TOKEN 有值時才啟動 tunnel、env 空就等 UI Pair,
從沒觸發 token.bin 的載入 → 每次重啟都要重配對。(Manager.startLocked 本來就會
從 config.TokenStore.Load,只是沒被呼叫。)

修法:抽 shouldAutoStartTunnel — env 有 token 用 env(優先序不變);env 空則
tokenStore.Load(),有值就自動 Start(免重配對)、空/err/nil store 等 UI Pair 不擋啟動。
Unpair→Delete token.bin→重啟 Load 回空→不自動連(round-trip test 覆蓋)。

reviewer 通過(0C/0M/1m 雙Load技術債)。build/vet/test 全過 + 5 分支 + Unpair round-trip 測試。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jim800121chen 2026-08-02 18:54:09 +08:00
parent f6d15b7b14
commit a6c94e21c5
2 changed files with 160 additions and 2 deletions

View File

@ -466,14 +466,60 @@ func (a *App) tryStartTunnel() {
a.appLog("tunnel: manager created (relay=%s, local=%s, mock=%v, paired=%v)",
relayURL, localAddr, mockMode, sessionToken != "")
// 有 session token 才立刻啟動;沒有則等使用者從 UI Pair()
if sessionToken != "" {
// 決定啟動時要不要立刻連 tunnel純函式方便單元測試見 shouldAutoStartTunnel
if shouldAutoStartTunnel(sessionToken, a.tokenStore, a.appLog) {
if err := mgr.Start(a.ctx); err != nil {
a.appLog("tunnel: start failed: %v", err)
}
}
}
// shouldAutoStartTunnel 判斷 agent 啟動時是否要立刻連 tunnel不需使用者從 UI 手動 Pair
//
// 優先序維持既有env 優先):
// 1. envToken 有值(來自 VISIONA_SESSION_TOKEN→ true。dev/demo 直接指定 token 的路徑。
// 2. envToken 空 → 嘗試從 TokenStore 撈上次配對持久化的 tokentoken.bin
// - Load 到非空 token → true免重配對這是修復核心
// - Load 空(沒配對過 / 已 Unpair→ false等 UI Pair()。
// - Load err解密失敗 / I/O 錯誤)→ false不擋啟動等 UI Pair()。
//
// 注意:回 true 走 Load 分支時,本函式不需要把 token 塞回 Manager config——
// Manager.startLocked() 內部本來就會在 cfg.SessionToken == "" 時自己從
// cfg.TokenStore.Load() 補 tokenmanager.go 的「嘗試補 SessionToken」段
// 本函式只負責「要不要呼叫 Start()」這個決策。
//
// 修復前的 bugapp.go 只在 envToken != "" 時才呼叫 mgr.Start()envToken 空時
// 直接跳過,導致 Manager 內部的 TokenStore.Load() 那條路永遠走不到,使用者每次
// 重啟都要重新配對token.bin 有存卻沒被讀)。
//
// logf 用於記錄決策原因(傳 a.appLognil 時不記 log測試方便
func shouldAutoStartTunnel(envToken string, tokenStore tunnel.TokenStore, logf func(string, ...any)) bool {
log := func(format string, args ...any) {
if logf != nil {
logf(format, args...)
}
}
if envToken != "" {
return true
}
if tokenStore == nil {
log("tunnel: no token store; waiting for UI pairing")
return false
}
tok, err := tokenStore.Load()
if err != nil {
// Load 失敗(解密失敗 / I/O 錯誤)不擋啟動,只 log維持未配對狀態等 UI Pair()。
log("tunnel: token store load failed (%v); waiting for UI pairing", err)
return false
}
if tok != "" {
log("tunnel: found persisted session token; auto-starting tunnel (no re-pairing needed)")
return true
}
log("tunnel: no persisted session token; waiting for UI pairing")
return false
}
// runStartupStage5 處理 R5-E 階段 5開瀏覽器。
// AutoOpenBrowser=false → SkipStage 進入階段 6也會被 skip-timeout 規則處理)
// AutoOpenBrowser=true → 呼叫 openBrowser 並 CompleteStage(5)

View File

@ -0,0 +1,112 @@
package main
import (
"errors"
"testing"
"visiona-agent/internal/tunnel"
)
// errTokenStore 是測試用 TokenStoreLoad() 固定回傳指定的 token / error
// 用來覆蓋 shouldAutoStartTunnel 的三種 Load 結果分支。
type errTokenStore struct {
token string
loadErr error
}
func (s *errTokenStore) Save(string) error { return nil }
func (s *errTokenStore) Load() (string, error) { return s.token, s.loadErr }
func (s *errTokenStore) Delete() error { return nil }
// TestShouldAutoStartTunnel 覆蓋 bug 修復的決策邏輯:
// - env 有 token優先序不變
// - env 空 + tokenStore 有 token修復核心免重配對自動連
// - env 空 + tokenStore 空(沒配對過 / Unpair 後:等 UI Pair不自動連
// - env 空 + tokenStore Load 失敗(解密/IO 錯誤:不擋啟動、不自動連)
// - env 空 + tokenStore 為 nilfallback不自動連
func TestShouldAutoStartTunnel(t *testing.T) {
tests := []struct {
name string
envToken string
store tunnel.TokenStore
want bool
}{
{
name: "env token set → start (env 優先dev/demo 路徑不斷)",
envToken: "vAs_from_env",
store: &errTokenStore{token: "vAs_from_store"}, // 即使 store 也有env 仍優先
want: true,
},
{
name: "env empty + store has token → start (修復核心:免重配對)",
envToken: "",
store: &errTokenStore{token: "vAs_from_store"},
want: true,
},
{
name: "env empty + store empty → no start (沒配對過 / Unpair 後等 UI Pair)",
envToken: "",
store: &errTokenStore{token: ""},
want: false,
},
{
name: "env empty + store load error → no start (不擋啟動、等 UI Pair)",
envToken: "",
store: &errTokenStore{loadErr: errors.New("decrypt failed")},
want: false,
},
{
name: "env empty + nil store → no start (fallback)",
envToken: "",
store: nil,
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := shouldAutoStartTunnel(tt.envToken, tt.store, nil)
if got != tt.want {
t.Errorf("shouldAutoStartTunnel(%q, %v) = %v, want %v",
tt.envToken, tt.store, got, tt.want)
}
})
}
}
// TestShouldAutoStartTunnel_EncryptedStoreRoundTrip 用真正的
// EncryptedFileTokenStoretoken.bin驗證「Save 後重啟 → 自動連」的端到端行為,
// 對齊使用者實際情境(配對存 token.bin重啟後應免重配對
func TestShouldAutoStartTunnel_EncryptedStoreRoundTrip(t *testing.T) {
dir := t.TempDir()
// 第一次配對:建立 store 並存 token模擬配對流程的 Save
store1, err := tunnel.NewEncryptedFileTokenStore(dir, nil)
if err != nil {
t.Fatalf("NewEncryptedFileTokenStore: %v", err)
}
if err := store1.Save("vAs_persisted_token"); err != nil {
t.Fatalf("Save: %v", err)
}
// 模擬重啟:用同一個 dataDir 建新 storetoken.bin 已存在env 沒設 token。
store2, err := tunnel.NewEncryptedFileTokenStore(dir, nil)
if err != nil {
t.Fatalf("NewEncryptedFileTokenStore (restart): %v", err)
}
if got := shouldAutoStartTunnel("", store2, nil); !got {
t.Error("重啟後env 空 + token.bin 有 token應自動連 tunnelgot false")
}
// Unpair 後Delete token重啟應回到未配對、等 UI Pair。
if err := store2.Delete(); err != nil {
t.Fatalf("Delete: %v", err)
}
store3, err := tunnel.NewEncryptedFileTokenStore(dir, nil)
if err != nil {
t.Fatalf("NewEncryptedFileTokenStore (after unpair): %v", err)
}
if got := shouldAutoStartTunnel("", store3, nil); got {
t.Error("Unpair 後token.bin 已清)不應自動連 tunnelgot true")
}
}