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:
parent
f6d15b7b14
commit
a6c94e21c5
@ -466,14 +466,60 @@ func (a *App) tryStartTunnel() {
|
|||||||
a.appLog("tunnel: manager created (relay=%s, local=%s, mock=%v, paired=%v)",
|
a.appLog("tunnel: manager created (relay=%s, local=%s, mock=%v, paired=%v)",
|
||||||
relayURL, localAddr, mockMode, sessionToken != "")
|
relayURL, localAddr, mockMode, sessionToken != "")
|
||||||
|
|
||||||
// 有 session token 才立刻啟動;沒有則等使用者從 UI Pair()
|
// 決定啟動時要不要立刻連 tunnel(純函式,方便單元測試;見 shouldAutoStartTunnel)。
|
||||||
if sessionToken != "" {
|
if shouldAutoStartTunnel(sessionToken, a.tokenStore, a.appLog) {
|
||||||
if err := mgr.Start(a.ctx); err != nil {
|
if err := mgr.Start(a.ctx); err != nil {
|
||||||
a.appLog("tunnel: start failed: %v", err)
|
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 撈上次配對持久化的 token(token.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() 補 token(manager.go 的「嘗試補 SessionToken」段)。
|
||||||
|
// 本函式只負責「要不要呼叫 Start()」這個決策。
|
||||||
|
//
|
||||||
|
// 修復前的 bug:app.go 只在 envToken != "" 時才呼叫 mgr.Start(),envToken 空時
|
||||||
|
// 直接跳過,導致 Manager 內部的 TokenStore.Load() 那條路永遠走不到,使用者每次
|
||||||
|
// 重啟都要重新配對(token.bin 有存卻沒被讀)。
|
||||||
|
//
|
||||||
|
// logf 用於記錄決策原因(傳 a.appLog);nil 時不記 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:開瀏覽器。
|
// runStartupStage5 處理 R5-E 階段 5:開瀏覽器。
|
||||||
// AutoOpenBrowser=false → SkipStage 進入階段 6(也會被 skip-timeout 規則處理)
|
// AutoOpenBrowser=false → SkipStage 進入階段 6(也會被 skip-timeout 規則處理)
|
||||||
// AutoOpenBrowser=true → 呼叫 openBrowser 並 CompleteStage(5)
|
// AutoOpenBrowser=true → 呼叫 openBrowser 並 CompleteStage(5)
|
||||||
|
|||||||
112
local-agent/visiona-agent/tunnel_autostart_test.go
Normal file
112
local-agent/visiona-agent/tunnel_autostart_test.go
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"visiona-agent/internal/tunnel"
|
||||||
|
)
|
||||||
|
|
||||||
|
// errTokenStore 是測試用 TokenStore,Load() 固定回傳指定的 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 為 nil(fallback:不自動連)
|
||||||
|
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 用真正的
|
||||||
|
// EncryptedFileTokenStore(token.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 建新 store(token.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)應自動連 tunnel,got 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 已清)不應自動連 tunnel,got true")
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user