дȫÏîĿÉó²éÎÊÌⲢ·¢²¼ v1.1.9
¸ÄÓñ¾»ú¶ÀÁ¢ CA£¬»ָ´ CLI TLS УÑ飬±£»¤ƾ¾ÝÓëÅäÖò¢·¢±£´档 д Cursor Êý¾ݡ¢»Ự·¾¶¡¢´úÀíÉúÃüÖÜÆڡ¢ģÐÍÇëÇóÓë Skills Îļþ²Ù×÷¡£ Ϊ¶ÀÁ¢ Tab ·þÎñÔö¼ӿͻ§¶˼øȨ£¬Éý¼¶ÒÀÀµÓ빤¾ßÁ´£¬ÍêÉƷ¢²¼ԪÐÅϢͬ²½¡£ Ñé֤£ºÁ½¸ö Go ģ¿éµIJâÊÔ/vet¡¢¹ؼüģ¿é race¡¢ǰ¶˲âÊÔ/Éú²ú¹¹½¨/Éó¼ơ¢ Go ¿ɴï©¶´ɨÃèÓë macOS/Linux ¿ÉÐÐģ¿齻²æ±àÒë¾ùͨ¹ý¡£ Windows ¸ôÀëÆô¶¯ͨ¹ý£»δÍê³ɵĵã»÷ðÑ̺ÍԭÉúƽ̨Ñé֤¼ûÉó²鱨¸档
This commit is contained in:
@@ -2,7 +2,6 @@
|
||||
|
||||
`internal/backend` 当前支持本地助手模式与直连上游模式。
|
||||
|
||||
关于 backend agent「最小事实集合」的第一阶段研究文档,见 [`../../docs/backend-agent-minimum-facts-phase1.md`](../../docs/backend-agent-minimum-facts-phase1.md)。
|
||||
|
||||
核心边界:
|
||||
|
||||
@@ -98,7 +97,6 @@ internal/backend/
|
||||
|
||||
- `~/.cursor-local-assistant-v2/config.yaml`
|
||||
- `~/.cursor-local-assistant-v2/data/ca.crt`
|
||||
- `~/.cursor-local-assistant-v2/data/ads/`
|
||||
- `~/.cursor-local-assistant-v2/history/`
|
||||
- `~/.cursor-local-assistant-v2/logs/`
|
||||
|
||||
@@ -106,7 +104,6 @@ internal/backend/
|
||||
|
||||
- `config.yaml` 是用户配置
|
||||
- `data/ca.crt` 是注入给宿主的 CA 证书
|
||||
- `data/ads/` 是广告包与资源缓存目录
|
||||
- `history/` 是会话事实与全局 usage JSON 目录,不属于日志
|
||||
- `logs/` 只保留必要文本运行日志
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"google.golang.org/protobuf/proto"
|
||||
"html"
|
||||
"io"
|
||||
"mime"
|
||||
@@ -325,7 +326,7 @@ func (bridge *Bridge) openSwitchMode(toolCall runtimecore.ToolInvocation) (*agen
|
||||
},
|
||||
},
|
||||
}
|
||||
argsPayload, _ := json.Marshal(args)
|
||||
argsPayload, _ := json.Marshal(&args)
|
||||
return serverMessage, runtimecore.PendingInteraction{
|
||||
InteractionID: fmt.Sprintf("%d", messageID),
|
||||
ArgsJSON: argsPayload,
|
||||
@@ -679,7 +680,7 @@ func truncateWebSearchReplay(searchTerm string, references []*agentv1.WebSearchR
|
||||
if reference == nil {
|
||||
continue
|
||||
}
|
||||
next := *reference
|
||||
next := proto.Clone(reference).(*agentv1.WebSearchReference)
|
||||
title := truncateInteractionText("WebSearch title", next.GetTitle(), webSearchTitleLimit)
|
||||
chunk := truncateInteractionText("WebSearch snippet", next.GetChunk(), webSearchChunkLimit)
|
||||
if title != next.GetTitle() || chunk != next.GetChunk() {
|
||||
@@ -687,7 +688,7 @@ func truncateWebSearchReplay(searchTerm string, references []*agentv1.WebSearchR
|
||||
}
|
||||
next.Title = title
|
||||
next.Chunk = chunk
|
||||
nextReferences = append(nextReferences, &next)
|
||||
nextReferences = append(nextReferences, next)
|
||||
}
|
||||
nextPayload := formatWebSearchPayload(searchTerm, nextReferences)
|
||||
if strings.TrimSpace(payload) != "" && len(nextPayload) == 0 {
|
||||
|
||||
94
internal/backend/agent/model/http_integration_test.go
Normal file
94
internal/backend/agent/model/http_integration_test.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package modeladapter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestProvidersForwardAuthenticationOverridesAndFinishStream(t *testing.T) {
|
||||
for _, provider := range []string{"openai", "anthropic"} {
|
||||
t.Run(provider, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("X-Gateway-Key") != "fixture-gateway" || r.Host != "gateway.fixture" {
|
||||
t.Error("custom headers not applied")
|
||||
}
|
||||
if provider == "openai" && r.Header.Get("Authorization") != "Bearer fixture-key" {
|
||||
t.Error("OpenAI authentication missing")
|
||||
}
|
||||
if provider == "anthropic" && r.Header.Get("x-api-key") != "fixture-key" {
|
||||
t.Error("Anthropic authentication missing")
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if body["model"] != "fixture-model" {
|
||||
t.Error("model mapping lost")
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
if provider == "openai" {
|
||||
fmt.Fprint(w, "data: {\"choices\":[{\"delta\":{\"content\":\"ok\"},\"finish_reason\":null}]}\n\ndata: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":1,\"completion_tokens\":1}}\n\ndata: [DONE]\n\n")
|
||||
} else {
|
||||
for _, event := range []string{`{"type":"message_start","message":{"id":"fixture","type":"message","role":"assistant","content":[],"model":"fixture-model","usage":{"input_tokens":1,"output_tokens":0}}}`, `{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`, `{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"ok"}}`, `{"type":"content_block_stop","index":0}`, `{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":1}}`, `{"type":"message_stop"}`} {
|
||||
var envelope struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(event), &envelope)
|
||||
fmt.Fprintf(w, "event: %s\ndata: %s\n\n", envelope.Type, event)
|
||||
}
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
req := StreamRequest{BaseURL: server.URL, APIKey: "fixture-key", ProviderModelID: "fixture-model", ModelID: "fixture-model", OpenAIEndpoint: "/v1/chat/completions", Messages: []Message{{Role: "user", Content: "hello"}}, MaxTokens: 32, Stream: true, CustomHeadersEnabled: true, CustomHeadersJSON: `{"X-Gateway-Key":"fixture-gateway","Host":"gateway.fixture"}`}
|
||||
var text strings.Builder
|
||||
finished := false
|
||||
sink := func(event ModelEvent) error {
|
||||
if event.Kind == ModelEventKindTextDelta {
|
||||
text.WriteString(event.Text)
|
||||
}
|
||||
if event.Kind == ModelEventKindTurnFinished {
|
||||
finished = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
var err error
|
||||
if provider == "openai" {
|
||||
adapter := NewOpenAIAdapter()
|
||||
adapter.client = server.Client()
|
||||
err = adapter.Stream(ctx, req, sink)
|
||||
} else {
|
||||
adapter := NewAnthropicAdapter()
|
||||
adapter.client = server.Client()
|
||||
err = adapter.Stream(ctx, req, sink)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if text.String() != "ok" || !finished {
|
||||
t.Fatalf("stream did not finish correctly: %q %t", text.String(), finished)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderDeadlineCancelsPendingHTTPResponse(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = io.Copy(io.Discard, r.Body); <-r.Context().Done() }))
|
||||
defer server.Close()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Millisecond)
|
||||
defer cancel()
|
||||
_, err := DoProviderRequestWithRetry(ctx, server.Client(), "fixture", "", "", func(ctx context.Context) (*http.Request, error) {
|
||||
return http.NewRequestWithContext(ctx, http.MethodPost, server.URL, strings.NewReader("fixture"))
|
||||
})
|
||||
if err == nil || ctx.Err() == nil {
|
||||
t.Fatal("provider ignored cancellation")
|
||||
}
|
||||
}
|
||||
@@ -85,7 +85,11 @@ func ApplyCustomHeaders(httpReq *http.Request, enabled bool, headersJSON string)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
httpReq.Header.Set(name, value)
|
||||
if strings.EqualFold(name, "Host") {
|
||||
httpReq.Host = value
|
||||
} else {
|
||||
httpReq.Header.Set(name, value)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package modeladapter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"cursor/internal/netproxy"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
@@ -33,7 +34,7 @@ func doProviderRequestWithRetry(
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := client.Do(httpReq)
|
||||
resp, err := netproxy.CredentialSafeClient(client).Do(httpReq)
|
||||
if err != nil {
|
||||
if resp != nil && resp.Body != nil {
|
||||
_ = resp.Body.Close()
|
||||
|
||||
@@ -746,7 +746,16 @@ func validateConversationID(conversationID string) (string, error) {
|
||||
if normalized == "" {
|
||||
return "", fmt.Errorf("conversation_id is required")
|
||||
}
|
||||
if strings.Contains(normalized, "/") || strings.Contains(normalized, string(os.PathSeparator)) {
|
||||
base := strings.ToUpper(strings.Split(normalized, ".")[0])
|
||||
if base == "CON" || base == "PRN" || base == "AUX" || base == "NUL" || (len(base) == 4 && (strings.HasPrefix(base, "COM") || strings.HasPrefix(base, "LPT")) && base[3] >= '1' && base[3] <= '9') {
|
||||
return "", fmt.Errorf("conversation_id is a reserved filename")
|
||||
}
|
||||
for _, r := range normalized {
|
||||
if r < 32 || r == 127 {
|
||||
return "", fmt.Errorf("invalid conversation_id")
|
||||
}
|
||||
}
|
||||
if normalized == "." || normalized == ".." || !filepath.IsLocal(normalized) || strings.ContainsAny(normalized, "/\\:\x00<>\"|?*") || strings.TrimRight(normalized, " .") != normalized {
|
||||
return "", fmt.Errorf("conversation_id must not contain path separators")
|
||||
}
|
||||
return normalized, nil
|
||||
|
||||
20
internal/backend/forwarder/file_store_security_test.go
Normal file
20
internal/backend/forwarder/file_store_security_test.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package forwarder
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestConversationIDRejectsTraversalAndWindowsAliases(t *testing.T) {
|
||||
for _, id := range []string{"", ".", "..", "../outside", `..\outside`, "C:evil", "CON", "id.", "id ", "a\x00b", "/absolute"} {
|
||||
// Whitespace is trimmed for compatibility; the remaining alias must be safe.
|
||||
if id == "id " {
|
||||
continue
|
||||
}
|
||||
if _, err := validateConversationID(id); err == nil {
|
||||
t.Errorf("accepted %q", id)
|
||||
}
|
||||
}
|
||||
for _, id := range []string{"conversation-123", "a_b.c"} {
|
||||
if _, err := validateConversationID(id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package forwarder
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -27,7 +28,7 @@ func TestUpsertEventIdempotentBeyondRecentLimit(t *testing.T) {
|
||||
// 填充超过 RecentEvents 上限的无关事件,把目标事件挤出 RecentEvents。
|
||||
for i := range usageRecentEventLimit + 10 {
|
||||
if err := store.UpsertEvent(usageFileEvent{
|
||||
EventID: "filler",
|
||||
EventID: fmt.Sprintf("filler-%d", i),
|
||||
Kind: usageEventKindProvider,
|
||||
At: base.Add(time.Duration(i+1) * time.Second),
|
||||
InputTokens: 1,
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
serverconfig "cursor/internal/backend/server/config"
|
||||
"cursor/internal/backend/server/upstream"
|
||||
"cursor/internal/certs"
|
||||
"cursor/internal/localaccess"
|
||||
"cursor/internal/logger"
|
||||
"cursor/internal/netproxy"
|
||||
legacyruntime "cursor/internal/runtime"
|
||||
@@ -27,21 +28,22 @@ import (
|
||||
const healthPath = "/healthz"
|
||||
|
||||
type Host struct {
|
||||
store *serverconfig.Store
|
||||
listenAddr string
|
||||
lifecycleMu sync.Mutex
|
||||
store *serverconfig.Store
|
||||
listenAddr string
|
||||
cliHTTPSListenAddr string
|
||||
tabServerBaseURL string
|
||||
configs *serverconfig.Manager
|
||||
healthHTTP *http.Client
|
||||
certManager *certs.Manager
|
||||
tabServerBaseURL string
|
||||
configs *serverconfig.Manager
|
||||
healthHTTP *http.Client
|
||||
certManager *certs.Manager
|
||||
|
||||
runMu sync.RWMutex
|
||||
httpServer *http.Server
|
||||
httpsServer *http.Server
|
||||
runMu sync.RWMutex
|
||||
httpServer *http.Server
|
||||
httpsServer *http.Server
|
||||
|
||||
lastRunErr error
|
||||
lastRunErr error
|
||||
|
||||
mux http.Handler
|
||||
mux http.Handler
|
||||
}
|
||||
|
||||
func NewHost(store *serverconfig.Store, certManager *certs.Manager) (*Host, error) {
|
||||
@@ -54,13 +56,13 @@ func NewHost(store *serverconfig.Store, certManager *certs.Manager) (*Host, erro
|
||||
}
|
||||
cfg := configs.Current()
|
||||
host := &Host{
|
||||
store: store,
|
||||
listenAddr: cfg.BackendListenAddr,
|
||||
store: store,
|
||||
listenAddr: cfg.BackendListenAddr,
|
||||
cliHTTPSListenAddr: cfg.CLIHTTPSListenAddr,
|
||||
tabServerBaseURL: cfg.TabServerBaseURL,
|
||||
configs: configs,
|
||||
healthHTTP: newLoopbackHTTPClient(),
|
||||
certManager: certManager,
|
||||
tabServerBaseURL: cfg.TabServerBaseURL,
|
||||
configs: configs,
|
||||
healthHTTP: newLoopbackHTTPClient(),
|
||||
certManager: certManager,
|
||||
}
|
||||
if err := host.rebuild(cfg); err != nil {
|
||||
return nil, err
|
||||
@@ -86,6 +88,8 @@ func (host *Host) SaveConfig(ctx context.Context, cfg serverconfig.Config) (serv
|
||||
if host == nil || host.configs == nil {
|
||||
return serverconfig.Config{}, fmt.Errorf("backend config manager is not initialized")
|
||||
}
|
||||
host.lifecycleMu.Lock()
|
||||
defer host.lifecycleMu.Unlock()
|
||||
normalized, err := host.configs.Save(ctx, cfg)
|
||||
if err != nil {
|
||||
return serverconfig.Config{}, err
|
||||
@@ -157,6 +161,8 @@ func (host *Host) Start() error {
|
||||
if host == nil {
|
||||
return fmt.Errorf("backend host is nil")
|
||||
}
|
||||
host.lifecycleMu.Lock()
|
||||
defer host.lifecycleMu.Unlock()
|
||||
cfg := host.configs.Current()
|
||||
|
||||
host.runMu.Lock()
|
||||
@@ -170,7 +176,7 @@ func (host *Host) Start() error {
|
||||
|
||||
httpServer := &http.Server{
|
||||
Addr: host.listenAddr,
|
||||
Handler: host.mux,
|
||||
Handler: localaccess.Wrap(host.mux),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
listener, err := net.Listen("tcp", host.listenAddr)
|
||||
@@ -199,8 +205,10 @@ func (host *Host) Start() error {
|
||||
|
||||
// 启动 CLI HTTPS+HTTP/2 监听器
|
||||
if err := host.startHTTPSLocked(cfg); err != nil {
|
||||
// HTTPS 失败不阻断 HTTP,仅记录
|
||||
logger.Errorf("启动 CLI HTTPS 监听器失败: %v", err)
|
||||
host.httpServer = nil
|
||||
_ = httpServer.Close()
|
||||
host.lastRunErr = fmt.Errorf("启动 CLI HTTPS 监听器失败: %w", err)
|
||||
return host.lastRunErr
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -230,7 +238,7 @@ func (host *Host) startHTTPSLocked(cfg serverconfig.Config) error {
|
||||
|
||||
httpsServer := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: host.mux,
|
||||
Handler: localaccess.Wrap(host.mux),
|
||||
TLSConfig: tlsConfig,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
@@ -262,28 +270,29 @@ func (host *Host) Stop(ctx context.Context) error {
|
||||
if host == nil {
|
||||
return nil
|
||||
}
|
||||
host.runMu.Lock()
|
||||
httpServer := host.httpServer
|
||||
httpsServer := host.httpsServer
|
||||
host.httpServer = nil
|
||||
host.httpsServer = nil
|
||||
host.runMu.Unlock()
|
||||
host.lifecycleMu.Lock()
|
||||
defer host.lifecycleMu.Unlock()
|
||||
host.runMu.RLock()
|
||||
httpServer, httpsServer := host.httpServer, host.httpsServer
|
||||
host.runMu.RUnlock()
|
||||
var errs []error
|
||||
if httpsServer != nil {
|
||||
if err := httpsServer.Shutdown(ctx); err != nil {
|
||||
errs = append(errs, fmt.Errorf("stop CLI HTTPS: %w", err))
|
||||
for _, srv := range []*http.Server{httpsServer, httpServer} {
|
||||
if srv != nil {
|
||||
if err := srv.Shutdown(ctx); err != nil {
|
||||
_ = srv.Close()
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if serverInstance := httpServer; serverInstance != nil {
|
||||
err := serverInstance.Shutdown(ctx)
|
||||
if err != nil && !errors.Is(err, context.Canceled) {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
host.runMu.Lock()
|
||||
if host.httpServer == httpServer {
|
||||
host.httpServer = nil
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
return errors.Join(errs...)
|
||||
if host.httpsServer == httpsServer {
|
||||
host.httpsServer = nil
|
||||
}
|
||||
return nil
|
||||
host.runMu.Unlock()
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
func (host *Host) HealthCheck(ctx context.Context) error {
|
||||
@@ -928,19 +937,27 @@ func uploadServiceProcedure(pattern string, name string, protocol server.RouteOp
|
||||
func tabServerUpstreamProcedure(pattern string, name string, protocol server.RouteOption, deps upstream.Dependencies, host *Host) server.Option {
|
||||
direct := upstream.DirectAction(deps, upstream.CompatRouteConfig{Name: name})
|
||||
action := func(ctx *server.Context) error {
|
||||
if host == nil || host.tabServerBaseURL == "" {
|
||||
if host == nil || ctx == nil || ctx.Request == nil {
|
||||
return fmt.Errorf("invalid tab request")
|
||||
}
|
||||
cfg := host.configs.Current()
|
||||
if cfg.TabServerBaseURL == "" {
|
||||
http.NotFound(ctx.Writer, ctx.Request)
|
||||
return nil
|
||||
}
|
||||
if ctx != nil && ctx.Request != nil && ctx.Request.URL != nil {
|
||||
baseURL, err := url.Parse(host.tabServerBaseURL)
|
||||
baseURL, err := url.Parse(cfg.TabServerBaseURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("解析 tab server 地址失败: %w", err)
|
||||
}
|
||||
targetURL := *ctx.Request.URL
|
||||
targetURL.Scheme = baseURL.Scheme
|
||||
targetURL.Host = baseURL.Host
|
||||
targetURL.Path = strings.TrimRight(baseURL.Path, "/") + ctx.Request.URL.Path
|
||||
targetURL.RawPath = ""
|
||||
ctx.UpstreamURL = &targetURL
|
||||
ctx.Request = ctx.Request.Clone(ctx.Request.Context())
|
||||
ctx.Request.Header.Set("Authorization", "Bearer "+cfg.TabServerToken)
|
||||
}
|
||||
return direct(ctx)
|
||||
}
|
||||
|
||||
105
internal/backend/host_lifecycle_test.go
Normal file
105
internal/backend/host_lifecycle_test.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"context"
|
||||
serverconfig "cursor/internal/backend/server/config"
|
||||
"cursor/internal/certs"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func freeAddress(t *testing.T) string {
|
||||
t.Helper()
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
addr := l.Addr().String()
|
||||
l.Close()
|
||||
return addr
|
||||
}
|
||||
|
||||
func TestHTTPSBindFailureRollsBackHTTPListener(t *testing.T) {
|
||||
occupied, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer occupied.Close()
|
||||
cfg := serverconfig.DefaultConfig()
|
||||
cfg.BackendListenAddr = freeAddress(t)
|
||||
cfg.CLIHTTPSListenAddr = occupied.Addr().String()
|
||||
store := serverconfig.NewStore(filepath.Join(t.TempDir(), "config.yaml"), "")
|
||||
if _, err = store.Save(nil, cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ca, err := certs.LoadOrCreateLocalCA(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
host, err := NewHost(store, ca)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = host.Start(); err == nil {
|
||||
t.Fatal("HTTPS bind failure ignored")
|
||||
}
|
||||
if host.IsRunning() {
|
||||
t.Fatal("partial service reported running")
|
||||
}
|
||||
listener, err := net.Listen("tcp", cfg.BackendListenAddr)
|
||||
if err != nil {
|
||||
t.Fatal("HTTP listener leaked", err)
|
||||
}
|
||||
listener.Close()
|
||||
}
|
||||
|
||||
func TestShutdownTimeoutForceClosesConnectionAndReleasesPort(t *testing.T) {
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
address := listener.Addr().String()
|
||||
started := make(chan struct{})
|
||||
ended := make(chan struct{})
|
||||
server := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { close(started); <-r.Context().Done(); close(ended) })}
|
||||
go server.Serve(listener)
|
||||
host := &Host{httpServer: server}
|
||||
requestDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(requestDone)
|
||||
client := &http.Client{Timeout: time.Second}
|
||||
resp, _ := client.Get("http://" + address)
|
||||
if resp != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
}()
|
||||
select {
|
||||
case <-started:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("request not started")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
|
||||
defer cancel()
|
||||
err = host.Stop(ctx)
|
||||
if !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatalf("expected timeout, got %v", err)
|
||||
}
|
||||
select {
|
||||
case <-ended:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("handler still active")
|
||||
}
|
||||
<-requestDone
|
||||
if host.IsRunning() {
|
||||
t.Fatal("closed service still running")
|
||||
}
|
||||
rebound, err := net.Listen("tcp", address)
|
||||
if err != nil {
|
||||
t.Fatal("port not released", err)
|
||||
}
|
||||
rebound.Close()
|
||||
}
|
||||
@@ -57,7 +57,7 @@ func (manager *Manager) currentConfig() Config {
|
||||
return DefaultConfig()
|
||||
}
|
||||
if current := manager.current.Load(); current != nil {
|
||||
return *current
|
||||
return cloneConfig(*current)
|
||||
}
|
||||
return DefaultConfig()
|
||||
}
|
||||
@@ -75,7 +75,12 @@ func (manager *Manager) Save(ctx context.Context, cfg Config) (Config, error) {
|
||||
return Config{}, fmt.Errorf("config manager is not initialized")
|
||||
}
|
||||
manager.saveMu.Lock()
|
||||
defer manager.saveMu.Unlock()
|
||||
locked := true
|
||||
defer func() {
|
||||
if locked {
|
||||
manager.saveMu.Unlock()
|
||||
}
|
||||
}()
|
||||
normalized, err := manager.store.Save(ctx, cfg)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
@@ -86,6 +91,8 @@ func (manager *Manager) Save(ctx context.Context, cfg Config) (Config, error) {
|
||||
manager.lastReload = time.Now()
|
||||
manager.reloadError = ""
|
||||
manager.reloadMu.Unlock()
|
||||
manager.saveMu.Unlock()
|
||||
locked = false
|
||||
manager.notify(normalized)
|
||||
return normalized, nil
|
||||
}
|
||||
@@ -104,13 +111,17 @@ func (manager *Manager) SaveLastAgentModelHash(ctx context.Context, value string
|
||||
normalizedValue := strings.TrimSpace(value)
|
||||
// 在 saveMu 保护下完成 read-modify-write,避免与并发 Save 互相覆盖。
|
||||
manager.saveMu.Lock()
|
||||
defer manager.saveMu.Unlock()
|
||||
locked := true
|
||||
defer func() {
|
||||
if locked {
|
||||
manager.saveMu.Unlock()
|
||||
}
|
||||
}()
|
||||
current := manager.currentConfig()
|
||||
if strings.TrimSpace(current.LastAgentModelHash) == normalizedValue {
|
||||
return nil
|
||||
}
|
||||
current.LastAgentModelHash = normalizedValue
|
||||
normalized, err := manager.store.Save(ctx, current)
|
||||
normalized, err := manager.store.updateLastAgentModelHash(normalizedValue)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -120,6 +131,8 @@ func (manager *Manager) SaveLastAgentModelHash(ctx context.Context, value string
|
||||
manager.lastReload = time.Now()
|
||||
manager.reloadError = ""
|
||||
manager.reloadMu.Unlock()
|
||||
manager.saveMu.Unlock()
|
||||
locked = false
|
||||
manager.notify(normalized)
|
||||
return nil
|
||||
}
|
||||
@@ -164,7 +177,9 @@ func (manager *Manager) LegacyRuntimeSnapshot(_ context.Context) (legacyruntime.
|
||||
adapters := make([]legacyruntime.ModelAdapterConfig, 0, len(cfg.ModelAdapters))
|
||||
for _, item := range cfg.ModelAdapters {
|
||||
adapters = append(adapters, legacyruntime.ModelAdapterConfig{
|
||||
ID: item.ID,
|
||||
ID: item.ID,
|
||||
CustomHeadersEnabled: item.CustomHeadersEnabled, CustomHeadersJSON: item.CustomHeadersJSON,
|
||||
AnthropicExtraParamsEnabled: item.AnthropicExtraParamsEnabled, AnthropicExtraParamsJSON: item.AnthropicExtraParamsJSON,
|
||||
DisplayName: item.DisplayName,
|
||||
Type: item.Type,
|
||||
BaseURL: item.BaseURL,
|
||||
@@ -204,7 +219,7 @@ func (manager *Manager) RouteMode(hasUpstreamURL bool) string {
|
||||
}
|
||||
|
||||
func (manager *Manager) setCurrent(cfg Config) {
|
||||
next := cfg
|
||||
next := cloneConfig(cfg)
|
||||
manager.current.Store(&next)
|
||||
}
|
||||
|
||||
@@ -215,6 +230,13 @@ func (manager *Manager) reloadIfChanged(ctx context.Context) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
manager.saveMu.Lock()
|
||||
reloadSaveLocked := true
|
||||
defer func() {
|
||||
if reloadSaveLocked {
|
||||
manager.saveMu.Unlock()
|
||||
}
|
||||
}()
|
||||
now := time.Now()
|
||||
manager.reloadMu.Lock()
|
||||
if !manager.lastReload.IsZero() && now.Sub(manager.lastReload) < configHotReloadMinInterval {
|
||||
@@ -241,6 +263,8 @@ func (manager *Manager) reloadIfChanged(ctx context.Context) {
|
||||
manager.reloadError = ""
|
||||
manager.setCurrent(cfg)
|
||||
manager.reloadMu.Unlock()
|
||||
manager.saveMu.Unlock()
|
||||
reloadSaveLocked = false
|
||||
manager.notify(cfg)
|
||||
}
|
||||
|
||||
@@ -250,7 +274,7 @@ func (manager *Manager) notify(cfg Config) {
|
||||
manager.listenersMu.RUnlock()
|
||||
for _, listener := range listeners {
|
||||
if listener != nil {
|
||||
listener(cfg)
|
||||
listener(cloneConfig(cfg))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,8 +16,8 @@ const (
|
||||
// 默认值越过服务方上限会直接返回 400(exceeds the configured maximum output length)。
|
||||
defaultChannelMaxTokens = 278_000
|
||||
defaultChannelAnthropicMaxTokens = 131_072
|
||||
defaultChannelThinkingBudget = 4_096
|
||||
defaultChannelAnthropicEffort = "xhigh"
|
||||
defaultChannelThinkingBudget = 4_096
|
||||
defaultChannelAnthropicEffort = "xhigh"
|
||||
)
|
||||
|
||||
func (manager *Manager) SelectChannelForModel(_ context.Context, modelID string) (*legacyruntime.ResolvedChannel, error) {
|
||||
|
||||
33
internal/backend/server/config/revision.go
Normal file
33
internal/backend/server/config/revision.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
)
|
||||
|
||||
var ErrConfigConflict = errors.New("CONFIG_CONFLICT: 配置已在其他窗口更改,请重新加载后再保存;当前编辑内容已保留")
|
||||
|
||||
func cloneConfig(cfg Config) Config {
|
||||
cfg.ModelAdapters = append([]ModelAdapterConfig(nil), cfg.ModelAdapters...)
|
||||
if cfg.HomeMetrics.PersistentMode != nil {
|
||||
value := *cfg.HomeMetrics.PersistentMode
|
||||
cfg.HomeMetrics.PersistentMode = &value
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func withRevision(cfg Config) Config {
|
||||
cfg = cloneConfig(cfg)
|
||||
view := cloneConfig(cfg)
|
||||
view.Revision = ""
|
||||
view.LastAgentModelHash = ""
|
||||
for i := range view.ModelAdapters {
|
||||
view.ModelAdapters[i].ID = ""
|
||||
}
|
||||
data, _ := json.Marshal(view)
|
||||
sum := sha256.Sum256(data)
|
||||
cfg.Revision = hex.EncodeToString(sum[:])
|
||||
return cfg
|
||||
}
|
||||
83
internal/backend/server/config/revision_test.go
Normal file
83
internal/backend/server/config/revision_test.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestConfigCASAndMetadataIsolation(t *testing.T) {
|
||||
store := NewStore(filepath.Join(t.TempDir(), "config.yaml"), "")
|
||||
m, err := NewManager(context.Background(), store)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a, _ := m.Load(nil)
|
||||
b, _ := m.Load(nil)
|
||||
if a.Revision == "" {
|
||||
t.Fatal("missing revision")
|
||||
}
|
||||
if err = m.SaveLastAgentModelHash(nil, "model-new"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a.CLIHTTPSListenAddr = "127.0.0.1:39099"
|
||||
first, err := m.Save(nil, a)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first.LastAgentModelHash != "model-new" {
|
||||
t.Fatal("UI overwrote backend metadata")
|
||||
}
|
||||
b.HomeMetrics.RefreshIntervalSeconds = 11
|
||||
if _, err = m.Save(nil, b); !errors.Is(err, ErrConfigConflict) {
|
||||
t.Fatalf("stale save accepted: %v", err)
|
||||
}
|
||||
current, _ := m.Load(nil)
|
||||
if current.CLIHTTPSListenAddr != "127.0.0.1:39099" {
|
||||
t.Fatal("CLI port lost")
|
||||
}
|
||||
data, _ := os.ReadFile(store.Path())
|
||||
if strings.Contains(string(data), "revision:") {
|
||||
t.Fatal("revision persisted in YAML")
|
||||
}
|
||||
if current.Revision == a.Revision {
|
||||
t.Fatal("revision did not change")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentSavesHaveExactlyOneWinner(t *testing.T) {
|
||||
store := NewStore(filepath.Join(t.TempDir(), "config.yaml"), "")
|
||||
m, err := NewManager(context.Background(), store)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a, _ := m.Load(nil)
|
||||
b := cloneConfig(a)
|
||||
a.HomeMetrics.RefreshIntervalSeconds = 11
|
||||
b.HomeMetrics.RefreshIntervalSeconds = 12
|
||||
var wg sync.WaitGroup
|
||||
results := make(chan error, 2)
|
||||
for _, cfg := range []Config{a, b} {
|
||||
wg.Add(1)
|
||||
go func(c Config) { defer wg.Done(); _, err := m.Save(nil, c); results <- err }(cfg)
|
||||
}
|
||||
wg.Wait()
|
||||
close(results)
|
||||
success, conflict := 0, 0
|
||||
for err := range results {
|
||||
if err == nil {
|
||||
success++
|
||||
} else if errors.Is(err, ErrConfigConflict) {
|
||||
conflict++
|
||||
} else {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if success != 1 || conflict != 1 {
|
||||
t.Fatalf("success=%d conflict=%d", success, conflict)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"cursor/internal/securefile"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
@@ -75,7 +76,7 @@ func (store *Store) Load(_ context.Context) (Config, error) {
|
||||
if err := store.saveLocked(defaultConfig); err != nil {
|
||||
return DefaultConfig(), err
|
||||
}
|
||||
return defaultConfig, nil
|
||||
return withRevision(defaultConfig), nil
|
||||
}
|
||||
return DefaultConfig(), fmt.Errorf("读取用户配置失败: %w", err)
|
||||
}
|
||||
@@ -93,7 +94,7 @@ func (store *Store) Load(_ context.Context) (Config, error) {
|
||||
return DefaultConfig(), err
|
||||
}
|
||||
}
|
||||
return normalized, nil
|
||||
return withRevision(normalized), nil
|
||||
}
|
||||
|
||||
func (store *Store) Save(_ context.Context, cfg Config) (Config, error) {
|
||||
@@ -109,10 +110,20 @@ func (store *Store) Save(_ context.Context, cfg Config) (Config, error) {
|
||||
store.mu.Lock()
|
||||
defer store.mu.Unlock()
|
||||
|
||||
if cfg.Revision != "" {
|
||||
existing, err := store.readCurrentLocked()
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if withRevision(existing).Revision != cfg.Revision {
|
||||
return Config{}, ErrConfigConflict
|
||||
}
|
||||
normalized.LastAgentModelHash = existing.LastAgentModelHash
|
||||
}
|
||||
if err := store.saveLocked(normalized); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
return normalized, nil
|
||||
return withRevision(normalized), nil
|
||||
}
|
||||
|
||||
func (store *Store) saveLocked(normalized Config) error {
|
||||
@@ -125,11 +136,7 @@ func (store *Store) saveLocked(normalized Config) error {
|
||||
return fmt.Errorf("序列化用户配置失败: %w", err)
|
||||
}
|
||||
|
||||
tempPath := store.path + ".tmp"
|
||||
if err := writeFileSync(tempPath, data); err != nil {
|
||||
return fmt.Errorf("写入临时配置失败: %w", err)
|
||||
}
|
||||
if err := os.Rename(tempPath, store.path); err != nil {
|
||||
if err := securefile.Write(store.path, data); err != nil {
|
||||
return fmt.Errorf("保存用户配置失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
@@ -181,3 +188,31 @@ func yamlHasKey(raw []byte, key string) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (store *Store) readCurrentLocked() (Config, error) {
|
||||
data, err := os.ReadFile(store.path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return DefaultConfig(), nil
|
||||
}
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
var current Config
|
||||
if err = yaml.Unmarshal(data, ¤t); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
return NormalizeConfig(current)
|
||||
}
|
||||
func (store *Store) updateLastAgentModelHash(value string) (Config, error) {
|
||||
store.mu.Lock()
|
||||
defer store.mu.Unlock()
|
||||
cfg, err := store.readCurrentLocked()
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.LastAgentModelHash = value
|
||||
if err = store.saveLocked(cfg); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
return withRevision(cfg), nil
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"cursor/internal/logger"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -62,6 +63,7 @@ type HomeMetricsConfig struct {
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Revision string `json:"revision" yaml:"-"`
|
||||
Log bool `json:"log" yaml:"log"`
|
||||
ProviderStreamIdleTimeout int `json:"providerStreamIdleTimeout" yaml:"providerStreamIdleTimeout"`
|
||||
BackendListenAddr string `json:"backendListenAddr" yaml:"backendListenAddr"`
|
||||
@@ -139,6 +141,13 @@ func NormalizeConfig(input Config) (Config, error) {
|
||||
}
|
||||
output.TabServerBaseURL = normalizeTabServerURL(input.TabServerBaseURL)
|
||||
output.TabServerToken = strings.TrimSpace(input.TabServerToken)
|
||||
if strings.TrimSpace(input.TabServerBaseURL) != "" && output.TabServerBaseURL == "" {
|
||||
return Config{}, fmt.Errorf("tabServerBaseURL 无效")
|
||||
}
|
||||
if output.TabServerBaseURL != "" && output.TabServerToken == "" {
|
||||
return Config{}, fmt.Errorf("配置 Tab 服务时必须提供独立 tabServerToken")
|
||||
}
|
||||
logger.RegisterSecrets(output.TabServerToken)
|
||||
adapters, err := NormalizeModelAdapterConfigs(input.ModelAdapters)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
@@ -156,7 +165,7 @@ func normalizeTabServerURL(value string) string {
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
||||
if parsed.User != nil || parsed.Hostname() == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
|
||||
return ""
|
||||
}
|
||||
parsed.Path = strings.TrimRight(parsed.Path, "/")
|
||||
@@ -193,6 +202,7 @@ func NormalizeModelAdapterConfigs(input []ModelAdapterConfig) ([]ModelAdapterCon
|
||||
AnthropicMaxTokens: normalizeMaxCompletionTokens(item.AnthropicMaxTokens),
|
||||
ThinkingBudgetTokens: normalizeMaxCompletionTokens(item.ThinkingBudgetTokens),
|
||||
}
|
||||
logger.RegisterSecrets(next.APIKey)
|
||||
if next.Type == "openai" {
|
||||
next.OpenAIExtraParamsEnabled = item.OpenAIExtraParamsEnabled
|
||||
next.OpenAIExtraParamsJSON = strings.TrimSpace(item.OpenAIExtraParamsJSON)
|
||||
@@ -203,6 +213,15 @@ func NormalizeModelAdapterConfigs(input []ModelAdapterConfig) ([]ModelAdapterCon
|
||||
}
|
||||
next.CustomHeadersEnabled = item.CustomHeadersEnabled
|
||||
next.CustomHeadersJSON = strings.TrimSpace(item.CustomHeadersJSON)
|
||||
var headers map[string]string
|
||||
if json.Unmarshal([]byte(next.CustomHeadersJSON), &headers) == nil {
|
||||
for key, value := range headers {
|
||||
lower := strings.ToLower(key)
|
||||
if strings.Contains(lower, "auth") || strings.Contains(lower, "key") || strings.Contains(lower, "token") || strings.Contains(lower, "cookie") {
|
||||
logger.RegisterSecrets(value, strings.TrimPrefix(value, "Bearer "))
|
||||
}
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case next.DisplayName == "":
|
||||
return nil, errors.New("模型适配器 displayName 不能为空")
|
||||
|
||||
65
internal/backend/tab_forward_test.go
Normal file
65
internal/backend/tab_forward_test.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"cursor/internal/backend/server"
|
||||
serverconfig "cursor/internal/backend/server/config"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTabCallerTokenAndBasePathFollowCurrentConfiguration(t *testing.T) {
|
||||
expected := "client-first"
|
||||
calls := 0
|
||||
tab := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
if r.Header.Get("Authorization") != "Bearer "+expected {
|
||||
t.Error("configured caller token not forwarded")
|
||||
}
|
||||
if r.URL.Path != "/base/aiserver.v1.AiService/CppConfig" {
|
||||
t.Error("base path lost", r.URL.Path)
|
||||
}
|
||||
w.WriteHeader(204)
|
||||
}))
|
||||
defer tab.Close()
|
||||
store := serverconfig.NewStore(filepath.Join(t.TempDir(), "config.yaml"), "")
|
||||
cfg := serverconfig.DefaultConfig()
|
||||
cfg.TabServerBaseURL = tab.URL + "/base"
|
||||
cfg.TabServerToken = expected
|
||||
if _, err := store.Save(nil, cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
host, err := NewHost(store, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
local := httptest.NewServer(host.mux)
|
||||
defer local.Close()
|
||||
call := func() {
|
||||
r, _ := http.NewRequest(http.MethodPost, local.URL+"/aiserver.v1.AiService/CppConfig", bytes.NewReader(nil))
|
||||
r.Header.Set("Content-Type", "application/proto")
|
||||
r.Header.Set(server.HeaderServerUpstreamURL, "https://api2.cursor.sh/aiserver.v1.AiService/CppConfig")
|
||||
r.Header.Set("Authorization", "Bearer unrelated-cursor-token")
|
||||
response, err := local.Client().Do(r)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response.Body.Close()
|
||||
if response.StatusCode != 204 {
|
||||
t.Fatal(response.StatusCode)
|
||||
}
|
||||
}
|
||||
call()
|
||||
cfg = host.ConfigManager().Current()
|
||||
expected = "client-second"
|
||||
cfg.TabServerToken = expected
|
||||
if _, err = host.SaveConfig(nil, cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
call()
|
||||
if calls != 2 {
|
||||
t.Fatal("Tab requests did not reach configured server")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user