From cbf696bc8248cded782c81bd46e6c90d76125dce Mon Sep 17 00:00:00 2001 From: win Date: Sat, 25 Apr 2026 21:56:42 +0800 Subject: [PATCH] chore(wip): save windsurf changes before upstream v0.1.118 merge --- .github/workflows/backend-ci.yml | 25 +++ backend/cmd/server/wire.go | 7 + backend/cmd/server/wire_gen.go | 3 +- backend/cmd/server/wire_gen_test.go | 1 + backend/cmd/test_windsurf_minimal/main.go | 2 +- backend/cmd/test_windsurf_tools/main.go | 4 +- backend/internal/config/config.go | 8 +- backend/internal/config/windsurf.go | 8 +- backend/internal/config/windsurf_defaults.go | 18 ++ backend/internal/pkg/windsurf/chat_media.go | 164 ++++++++++++++++ .../internal/pkg/windsurf/chat_media_test.go | 115 +++++++++++ .../pkg/windsurf/chat_media_wire_test.go | 68 +++++++ backend/internal/pkg/windsurf/codec.go | 29 ++- backend/internal/pkg/windsurf/connector.go | 21 ++ .../pkg/windsurf/conversation_pool.go | 27 ++- .../windsurf/conversation_pool_image_test.go | 68 +++++++ backend/internal/pkg/windsurf/datadir.go | 97 ++++++++++ backend/internal/pkg/windsurf/datadir_test.go | 119 ++++++++++++ backend/internal/pkg/windsurf/discovery.go | 162 ++++++++++++++++ .../internal/pkg/windsurf/discovery_test.go | 179 ++++++++++++++++++ backend/internal/pkg/windsurf/legacy_chat.go | 6 + backend/internal/pkg/windsurf/local_ls.go | 143 ++++++++++++-- backend/internal/pkg/windsurf/lspool.go | 99 ++++++++-- .../internal/pkg/windsurf/lspool_log_test.go | 60 ++++++ .../pkg/windsurf/lspool_stop_other.go | 39 ++++ .../pkg/windsurf/lspool_stop_windows.go | 20 ++ .../internal/pkg/windsurf/metadata_test.go | 56 ++++++ backend/internal/pkg/windsurf/platform.go | 81 ++++++++ .../internal/pkg/windsurf/platform_test.go | 94 +++++++++ .../pkg/windsurf/send_user_cascade_test.go | 85 +++++++++ .../internal/pkg/windsurf/spawn_error_test.go | 48 +++++ .../internal/pkg/windsurf/tool_emulation.go | 7 + .../internal/service/account_test_service.go | 41 ++-- .../internal/service/account_usage_service.go | 2 +- .../internal/service/windsurf_chat_service.go | 108 +++++++++-- .../service/windsurf_chat_service_test.go | 167 ++++++++++++++++ .../service/windsurf_gateway_service.go | 120 +++++++++++- backend/internal/service/windsurf_services.go | 9 + .../components/account/WindsurfLoginModal.vue | 34 ++-- .../i18n/__tests__/windsurfLocales.spec.ts | 20 ++ frontend/src/i18n/locales/en.ts | 20 +- frontend/src/i18n/locales/zh.ts | 20 +- 42 files changed, 2306 insertions(+), 98 deletions(-) create mode 100644 backend/internal/config/windsurf_defaults.go create mode 100644 backend/internal/pkg/windsurf/chat_media.go create mode 100644 backend/internal/pkg/windsurf/chat_media_test.go create mode 100644 backend/internal/pkg/windsurf/chat_media_wire_test.go create mode 100644 backend/internal/pkg/windsurf/conversation_pool_image_test.go create mode 100644 backend/internal/pkg/windsurf/datadir.go create mode 100644 backend/internal/pkg/windsurf/datadir_test.go create mode 100644 backend/internal/pkg/windsurf/discovery.go create mode 100644 backend/internal/pkg/windsurf/discovery_test.go create mode 100644 backend/internal/pkg/windsurf/lspool_log_test.go create mode 100644 backend/internal/pkg/windsurf/lspool_stop_other.go create mode 100644 backend/internal/pkg/windsurf/lspool_stop_windows.go create mode 100644 backend/internal/pkg/windsurf/metadata_test.go create mode 100644 backend/internal/pkg/windsurf/platform.go create mode 100644 backend/internal/pkg/windsurf/platform_test.go create mode 100644 backend/internal/pkg/windsurf/send_user_cascade_test.go create mode 100644 backend/internal/pkg/windsurf/spawn_error_test.go create mode 100644 backend/internal/service/windsurf_chat_service_test.go create mode 100644 frontend/src/i18n/__tests__/windsurfLocales.spec.ts diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml index f8b22ee7..95e0c9b1 100644 --- a/.github/workflows/backend-ci.yml +++ b/.github/workflows/backend-ci.yml @@ -67,3 +67,28 @@ jobs: version: v2.9 args: --timeout=30m working-directory: backend + + # Cross-platform smoke for the windsurf package: verify the code compiles + # on macOS and Windows and run only the platform-detection/discovery/datadir + # unit tests (which do not require launching a real LS binary). + windsurf-platform: + strategy: + fail-fast: false + matrix: + os: [macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 + with: + go-version-file: backend/go.mod + check-latest: false + cache: true + cache-dependency-path: backend/go.sum + - name: Build windsurf package + working-directory: backend + run: go build ./internal/pkg/windsurf/... + - name: Platform-only unit tests + working-directory: backend + run: go test -race -count=1 -run 'Platform|Discovery|DataDir|Metadata|NewLSPool|LSPool|ScanLSOutput' ./internal/pkg/windsurf/... + diff --git a/backend/cmd/server/wire.go b/backend/cmd/server/wire.go index e96e1cc4..634a1797 100644 --- a/backend/cmd/server/wire.go +++ b/backend/cmd/server/wire.go @@ -99,6 +99,7 @@ func provideCleanup( paymentOrderExpiry *service.PaymentOrderExpiryService, windsurfRefresh *service.WindsurfRefreshService, channelMonitorRunner *service.ChannelMonitorRunner, + windsurfLS *service.WindsurfLSService, ) func() { return func() { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) @@ -253,6 +254,12 @@ func provideCleanup( } return nil }}, + {"WindsurfLSService", func() error { + if windsurfLS != nil { + windsurfLS.Stop() + } + return nil + }}, } infraSteps := []cleanupStep{ diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index 382ae3fb..4369d980 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -273,7 +273,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { subscriptionExpiryService := service.ProvideSubscriptionExpiryService(userSubscriptionRepository) scheduledTestRunnerService := service.ProvideScheduledTestRunnerService(scheduledTestPlanRepository, scheduledTestService, accountTestService, rateLimitService, configConfig) paymentOrderExpiryService := service.ProvidePaymentOrderExpiryService(paymentService) - v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, schedulerSnapshotService, tokenRefreshService, accountExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, windsurfRefreshService, channelMonitorRunner) + v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, schedulerSnapshotService, tokenRefreshService, accountExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, windsurfRefreshService, channelMonitorRunner, windsurfLSService) application := &Application{ Server: httpServer, Cleanup: v, @@ -329,6 +329,7 @@ func provideCleanup( paymentOrderExpiry *service.PaymentOrderExpiryService, windsurfRefresh *service.WindsurfRefreshService, channelMonitorRunner *service.ChannelMonitorRunner, + windsurfLS *service.WindsurfLSService, ) func() { return func() { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) diff --git a/backend/cmd/server/wire_gen_test.go b/backend/cmd/server/wire_gen_test.go index f69401c1..3e2bd7c5 100644 --- a/backend/cmd/server/wire_gen_test.go +++ b/backend/cmd/server/wire_gen_test.go @@ -78,6 +78,7 @@ func TestProvideCleanup_WithMinimalDependencies_NoPanic(t *testing.T) { nil, // paymentOrderExpiry nil, // windsurfRefresh nil, // channelMonitorRunner + nil, // windsurfLS ) require.NotPanics(t, func() { diff --git a/backend/cmd/test_windsurf_minimal/main.go b/backend/cmd/test_windsurf_minimal/main.go index 7e936fbe..682dbfcf 100644 --- a/backend/cmd/test_windsurf_minimal/main.go +++ b/backend/cmd/test_windsurf_minimal/main.go @@ -279,7 +279,7 @@ func main() { // SendUserCascadeMessage { ctx, cancel := context.WithTimeout(context.Background(), f.timeout) - newCID, err := lsClient.SendUserCascadeMessage(ctx, f.jwt, cascadeID, f.prompt, pickedModel, "", 0) + newCID, err := lsClient.SendUserCascadeMessage(ctx, f.jwt, cascadeID, f.prompt, pickedModel, "", 0, nil, true) if err == nil && newCID != "" { cascadeID = newCID } diff --git a/backend/cmd/test_windsurf_tools/main.go b/backend/cmd/test_windsurf_tools/main.go index e9710934..23802bda 100644 --- a/backend/cmd/test_windsurf_tools/main.go +++ b/backend/cmd/test_windsurf_tools/main.go @@ -158,7 +158,7 @@ func main() { fmt.Printf("✅ StartCascade cascade_id=%s\n", cascadeID) // Call StreamCascadeChat (full flow incl. trajectory polling) - res, err := lsClient.StreamCascadeChat(ctx, f.jwt, pickedModel, f.prompt, preamble, cascadeID, 0) + res, err := lsClient.StreamCascadeChat(ctx, f.jwt, pickedModel, f.prompt, preamble, cascadeID, 0, nil) if err != nil { fmt.Fprintln(os.Stderr, "StreamCascadeChat:", err) os.Exit(1) @@ -209,7 +209,7 @@ func main() { tc.ID, fakeResult) ctx2, cancel2 := context.WithTimeout(context.Background(), f.timeout) defer cancel2() - res2, err := lsClient.StreamCascadeChat(ctx2, f.jwt, pickedModel, turn2, preamble, cascadeID, 0) + res2, err := lsClient.StreamCascadeChat(ctx2, f.jwt, pickedModel, turn2, preamble, cascadeID, 0, nil) if err != nil { fmt.Fprintln(os.Stderr, "\n❌ Turn2 StreamCascadeChat:", err) os.Exit(1) diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 13aa317f..96be87f0 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -1852,10 +1852,10 @@ func setDefaults() { viper.SetDefault("windsurf.docker.discover_interval", "60s") viper.SetDefault("windsurf.docker.probe_interval", "30s") viper.SetDefault("windsurf.docker.probe_timeout", "3s") - viper.SetDefault("windsurf.embedded.binary", "/opt/windsurf/language_server_linux_x64") - viper.SetDefault("windsurf.embedded.base_port", 42100) - viper.SetDefault("windsurf.embedded.data_dir", "/opt/windsurf/data") - viper.SetDefault("windsurf.embedded.api_server_url", "https://server.self-serve.windsurf.com") + viper.SetDefault("windsurf.embedded.binary", DefaultWindsurfEmbeddedBinary) + viper.SetDefault("windsurf.embedded.base_port", DefaultWindsurfEmbeddedBasePort) + viper.SetDefault("windsurf.embedded.data_dir", DefaultWindsurfEmbeddedDataDir) + viper.SetDefault("windsurf.embedded.api_server_url", DefaultWindsurfEmbeddedAPIServerURL) viper.SetDefault("windsurf.refresh.enabled", true) viper.SetDefault("windsurf.refresh.token_scan_interval", "5m") viper.SetDefault("windsurf.refresh.refresh_before_expiry", "10m") diff --git a/backend/internal/config/windsurf.go b/backend/internal/config/windsurf.go index 82ed3161..65a5f01c 100644 --- a/backend/internal/config/windsurf.go +++ b/backend/internal/config/windsurf.go @@ -94,10 +94,10 @@ func DefaultWindsurfConfig() WindsurfConfig { ProbeTimeout: 3 * time.Second, }, Embedded: WindsurfEmbeddedConfig{ - Binary: "/opt/windsurf/language_server_linux_x64", - BasePort: 42100, - DataDir: "/opt/windsurf/data", - APIServerURL: "https://server.self-serve.windsurf.com", + Binary: DefaultWindsurfEmbeddedBinary, + BasePort: DefaultWindsurfEmbeddedBasePort, + DataDir: DefaultWindsurfEmbeddedDataDir, + APIServerURL: DefaultWindsurfEmbeddedAPIServerURL, }, External: WindsurfExternalConfig{}, Refresh: WindsurfRefreshConfig{ diff --git a/backend/internal/config/windsurf_defaults.go b/backend/internal/config/windsurf_defaults.go new file mode 100644 index 00000000..4d8e5891 --- /dev/null +++ b/backend/internal/config/windsurf_defaults.go @@ -0,0 +1,18 @@ +package config + +// Windsurf embedded-mode defaults. Defined once here so that both the struct +// defaults in DefaultWindsurfConfig() (windsurf.go) and the viper.SetDefault +// calls in config.go reference a single source of truth — this prevents +// silent drift when one side is updated without the other. +// +// Binary and DataDir are intentionally left empty: the cross-platform +// discovery in backend/internal/pkg/windsurf/lspool.go (via DiscoverBinary +// and resolveDataDir) picks the correct path at runtime based on GOOS/GOARCH +// and the user's install layout. Hardcoding /opt/windsurf/... here would +// override that discovery on non-Linux platforms. +const ( + DefaultWindsurfEmbeddedBinary = "" + DefaultWindsurfEmbeddedBasePort = 42100 + DefaultWindsurfEmbeddedDataDir = "" + DefaultWindsurfEmbeddedAPIServerURL = "https://server.self-serve.windsurf.com" +) diff --git a/backend/internal/pkg/windsurf/chat_media.go b/backend/internal/pkg/windsurf/chat_media.go new file mode 100644 index 00000000..30aaa2c1 --- /dev/null +++ b/backend/internal/pkg/windsurf/chat_media.go @@ -0,0 +1,164 @@ +package windsurf + +import ( + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "fmt" + "strings" +) + +// CascadeImage 是发给 Windsurf Cascade gRPC 的图像载体。 +// 对应 proto:message CodeiumImage { string base64Data = 1; string mimeType = 2; string caption = 3; } +// 通过静态分析 /Applications/Windsurf.app/Contents/Resources/app/node_modules/@exa/chat-client/index.js 得到。 +type CascadeImage struct { + // Base64Data 原始 base64 字符串(不含 data: 前缀)。仅用于 replay/发送;不参与指纹。 + Base64Data string `json:"base64_data,omitempty"` + // MimeType 例如 image/png。参与指纹。 + MimeType string `json:"mime_type"` + // Caption 可选辅助说明,参与指纹。 + Caption string `json:"caption,omitempty"` + // SHA256 是 decoded 二进制字节的 sha256 hex;指纹使用。 + SHA256 string `json:"sha256,omitempty"` + // ByteLen decoded 字节数;指纹使用。 + ByteLen int `json:"byte_len,omitempty"` +} + +// ImageDigest 是 CascadeImage 的摘要视图。日志/指纹/conversation pool 使用这个, +// 永远不要把 Base64Data 带进哈希/持久化。 +type ImageDigest struct { + MimeType string `json:"mime_type"` + SHA256 string `json:"sha256"` + ByteLen int `json:"byte_len"` + Caption string `json:"caption,omitempty"` +} + +// 支持的图像 MIME(与 Windsurf 客户端一致) +var cascadeImageAllowedMime = map[string]struct{}{ + "image/png": {}, + "image/jpeg": {}, + "image/gif": {}, + "image/svg+xml": {}, +} + +// CascadeImageMaxPerTurn 单次 user turn 最多图片数(Windsurf 客户端限制)。 +const CascadeImageMaxPerTurn = 5 + +// CascadeImageMaxBytes 单张解码后字节上限(Windsurf 客户端压缩目标 1MB)。 +const CascadeImageMaxBytes = 1 * 1024 * 1024 + +// CascadeImageValidationOptions 校验开关,便于不同场景选择严格度。 +type CascadeImageValidationOptions struct { + // EnforceByteSize 是否对单张做 1MB 上限校验。默认 true。 + EnforceByteSize bool + // EnforceCountPerTurn 是否对张数做 <=5 校验。默认 true。 + EnforceCountPerTurn bool +} + +// DefaultCascadeImageValidationOptions 返回默认校验选项。 +func DefaultCascadeImageValidationOptions() CascadeImageValidationOptions { + return CascadeImageValidationOptions{ + EnforceByteSize: true, + EnforceCountPerTurn: true, + } +} + +// ValidateCascadeImages 在发送前做确定性校验。 +// 返回第一条校验失败的错误,上层据此生成 Anthropic 风格 400。 +func ValidateCascadeImages(images []CascadeImage, opts CascadeImageValidationOptions) error { + if opts.EnforceCountPerTurn && len(images) > CascadeImageMaxPerTurn { + return fmt.Errorf("too many images: %d (max %d)", len(images), CascadeImageMaxPerTurn) + } + for i := range images { + img := &images[i] + if _, ok := cascadeImageAllowedMime[strings.ToLower(strings.TrimSpace(img.MimeType))]; !ok { + return fmt.Errorf("image[%d]: unsupported media_type %q", i, img.MimeType) + } + trimmed := strings.TrimSpace(img.Base64Data) + if trimmed == "" { + return fmt.Errorf("image[%d]: data must not be empty", i) + } + decoded, err := base64.StdEncoding.DecodeString(trimmed) + if err != nil { + // 尝试 RawStdEncoding 作为兜底(部分客户端省略 padding) + if decoded2, err2 := base64.RawStdEncoding.DecodeString(trimmed); err2 == nil { + decoded = decoded2 + } else { + return fmt.Errorf("image[%d]: invalid base64 data", i) + } + } + if len(decoded) == 0 { + return fmt.Errorf("image[%d]: decoded data is empty", i) + } + if opts.EnforceByteSize && len(decoded) > CascadeImageMaxBytes { + return fmt.Errorf("image[%d]: decoded size %d exceeds 1MB limit", i, len(decoded)) + } + // 顺手把 digest/byteLen 算好,后续指纹阶段直接用 + if img.SHA256 == "" || img.ByteLen == 0 { + sum := sha256.Sum256(decoded) + img.SHA256 = hex.EncodeToString(sum[:]) + img.ByteLen = len(decoded) + } + // 归一化 MIME(避免大小写差异) + img.MimeType = strings.ToLower(strings.TrimSpace(img.MimeType)) + } + return nil +} + +// BuildImageDigests 把 CascadeImage 转成只含摘要的 ImageDigest 切片。 +// 前提:调用者已经通过 ValidateCascadeImages 或 ComputeImageDigest 确保 SHA256/ByteLen 已填。 +func BuildImageDigests(images []CascadeImage) []ImageDigest { + if len(images) == 0 { + return nil + } + out := make([]ImageDigest, len(images)) + for i, img := range images { + out[i] = ImageDigest{ + MimeType: img.MimeType, + SHA256: img.SHA256, + ByteLen: img.ByteLen, + Caption: img.Caption, + } + } + return out +} + +// ComputeImageDigest 按给定 base64 字符串计算 digest 字段(不做限额校验)。 +// 给只需要摘要不需要发送场景使用(例如历史 replay 构造)。 +func ComputeImageDigest(img *CascadeImage) error { + decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(img.Base64Data)) + if err != nil { + decoded, err = base64.RawStdEncoding.DecodeString(strings.TrimSpace(img.Base64Data)) + if err != nil { + return fmt.Errorf("invalid base64") + } + } + sum := sha256.Sum256(decoded) + img.SHA256 = hex.EncodeToString(sum[:]) + img.ByteLen = len(decoded) + img.MimeType = strings.ToLower(strings.TrimSpace(img.MimeType)) + return nil +} + +// encodeCodeiumImage 对单张 CodeiumImage 消息进行 proto wire 编码: +// message CodeiumImage { string base64Data = 1; string mimeType = 2; string caption = 3; } +// 返回值是 message body 原始字节(不含外层 field tag/length)。 +func encodeCodeiumImage(img CascadeImage) []byte { + var body []byte + // field 1:base64 字符串原样发(Windsurf 客户端也是原样放,不做二次编码) + body = append(body, encodeStringField(1, img.Base64Data)...) + body = append(body, encodeStringField(2, img.MimeType)...) + if img.Caption != "" { + body = append(body, encodeStringField(3, img.Caption)...) + } + return body +} + +// appendSendUserCascadeImages 把一组图像追加到 SendUserCascadeMessageRequest.images(field 6, repeated)。 +// repeated message 字段按"每条独立的 field 6 segment"编码,与 Windsurf 客户端保持一致。 +func appendSendUserCascadeImages(body []byte, images []CascadeImage) []byte { + for _, img := range images { + body = append(body, encodeBytesField(6, encodeCodeiumImage(img))...) + } + return body +} diff --git a/backend/internal/pkg/windsurf/chat_media_test.go b/backend/internal/pkg/windsurf/chat_media_test.go new file mode 100644 index 00000000..c69f0198 --- /dev/null +++ b/backend/internal/pkg/windsurf/chat_media_test.go @@ -0,0 +1,115 @@ +package windsurf + +import ( + "encoding/base64" + "testing" +) + +func TestValidateCascadeImages_AllowedMime(t *testing.T) { + png := base64.StdEncoding.EncodeToString([]byte("\x89PNG\r\n\x1a\nfake")) + tests := []struct { + mime string + wantErr bool + }{ + {"image/png", false}, + {"image/jpeg", false}, + {"image/gif", false}, + {"image/svg+xml", false}, + {"image/webp", true}, + {"text/plain", true}, + } + for _, tt := range tests { + imgs := []CascadeImage{{MimeType: tt.mime, Base64Data: png}} + err := ValidateCascadeImages(imgs, DefaultCascadeImageValidationOptions()) + if (err != nil) != tt.wantErr { + t.Errorf("mime=%s err=%v wantErr=%v", tt.mime, err, tt.wantErr) + } + } +} + +func TestValidateCascadeImages_CountLimit(t *testing.T) { + png := base64.StdEncoding.EncodeToString([]byte("\x89PNG")) + imgs := make([]CascadeImage, 6) + for i := range imgs { + imgs[i] = CascadeImage{MimeType: "image/png", Base64Data: png} + } + err := ValidateCascadeImages(imgs, DefaultCascadeImageValidationOptions()) + if err == nil { + t.Fatalf("expected count limit error") + } +} + +func TestValidateCascadeImages_InvalidBase64(t *testing.T) { + imgs := []CascadeImage{{MimeType: "image/png", Base64Data: "!!!not-base64"}} + err := ValidateCascadeImages(imgs, DefaultCascadeImageValidationOptions()) + if err == nil { + t.Fatalf("expected invalid base64 error") + } +} + +func TestValidateCascadeImages_EmptyData(t *testing.T) { + imgs := []CascadeImage{{MimeType: "image/png", Base64Data: ""}} + err := ValidateCascadeImages(imgs, DefaultCascadeImageValidationOptions()) + if err == nil { + t.Fatalf("expected empty data error") + } +} + +func TestValidateCascadeImages_ByteLimit(t *testing.T) { + big := make([]byte, CascadeImageMaxBytes+1) + imgs := []CascadeImage{{MimeType: "image/png", Base64Data: base64.StdEncoding.EncodeToString(big)}} + err := ValidateCascadeImages(imgs, DefaultCascadeImageValidationOptions()) + if err == nil { + t.Fatalf("expected size limit error") + } +} + +func TestValidateCascadeImages_PopulatesDigest(t *testing.T) { + png := base64.StdEncoding.EncodeToString([]byte("\x89PNG\r\n\x1a\nfake")) + imgs := []CascadeImage{{MimeType: "image/PNG", Base64Data: png}} + if err := ValidateCascadeImages(imgs, DefaultCascadeImageValidationOptions()); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if imgs[0].SHA256 == "" { + t.Errorf("sha256 should be populated") + } + if imgs[0].ByteLen == 0 { + t.Errorf("byteLen should be populated") + } + if imgs[0].MimeType != "image/png" { + t.Errorf("mime should be normalized to lowercase, got %q", imgs[0].MimeType) + } +} + +func TestBuildImageDigests(t *testing.T) { + imgs := []CascadeImage{ + {MimeType: "image/png", SHA256: "abc", ByteLen: 10, Caption: "x", Base64Data: "ignored"}, + {MimeType: "image/jpeg", SHA256: "def", ByteLen: 20}, + } + out := BuildImageDigests(imgs) + if len(out) != 2 { + t.Fatalf("expected 2 digests, got %d", len(out)) + } + if out[0].MimeType != "image/png" || out[0].SHA256 != "abc" || out[0].ByteLen != 10 || out[0].Caption != "x" { + t.Errorf("digest[0] mismatch: %+v", out[0]) + } + // ImageDigest 不应包含 base64 字段 + // (这个通过结构体字段检查,而非运行时 — 此处只是确认编译通过) +} + +func TestComputeImageDigest(t *testing.T) { + img := CascadeImage{MimeType: "IMAGE/PNG", Base64Data: base64.StdEncoding.EncodeToString([]byte("hello"))} + if err := ComputeImageDigest(&img); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if img.ByteLen != 5 { + t.Errorf("byteLen=%d want 5", img.ByteLen) + } + // sha256("hello") = 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824 + if img.SHA256 != "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" { + t.Errorf("sha256 mismatch: %s", img.SHA256) + } + if img.MimeType != "image/png" { + t.Errorf("mime not normalized: %s", img.MimeType) + } +} diff --git a/backend/internal/pkg/windsurf/chat_media_wire_test.go b/backend/internal/pkg/windsurf/chat_media_wire_test.go new file mode 100644 index 00000000..32cd9b94 --- /dev/null +++ b/backend/internal/pkg/windsurf/chat_media_wire_test.go @@ -0,0 +1,68 @@ +package windsurf + +import ( + "bytes" + "encoding/hex" + "testing" +) + +// 对比官方规划里给出的 known-good hex vector: +// base64Data="QQ==", mimeType="image/png", caption="a" +// 内部 body: 0a 04 51 51 3d 3d 12 09 69 6d 61 67 65 2f 70 6e 67 1a 01 61 +func TestEncodeCodeiumImage_KnownVector(t *testing.T) { + img := CascadeImage{ + Base64Data: "QQ==", + MimeType: "image/png", + Caption: "a", + } + got := encodeCodeiumImage(img) + want, _ := hex.DecodeString("0a045151" + "3d3d" + "120969" + "6d6167652f706e67" + "1a0161") + if !bytes.Equal(got, want) { + t.Errorf("encoded mismatch:\n got=%x\nwant=%x", got, want) + } +} + +func TestEncodeCodeiumImage_OmitsCaptionWhenEmpty(t *testing.T) { + img := CascadeImage{ + Base64Data: "QQ==", + MimeType: "image/png", + } + got := encodeCodeiumImage(img) + // 不含 field 3 tag (0x1a) + if bytes.Contains(got, []byte{0x1a}) { + t.Errorf("should not contain field 3 tag when caption empty: %x", got) + } +} + +func TestAppendSendUserCascadeImages_WrapsField6(t *testing.T) { + imgs := []CascadeImage{ + {Base64Data: "QQ==", MimeType: "image/png", Caption: "a"}, + } + body := appendSendUserCascadeImages(nil, imgs) + // 外层:field 6 tag (0x32) + varint 长度 + 内层 body + // 内层 body 长度 = 6 + 11 + 3 = 20 (0x14) + want, _ := hex.DecodeString("3214" + "0a045151" + "3d3d" + "120969" + "6d6167652f706e67" + "1a0161") + if !bytes.Equal(body, want) { + t.Errorf("wrapped field 6 segment mismatch:\n got=%x\nwant=%x", body, want) + } +} + +func TestAppendSendUserCascadeImages_MultipleImagesIndependentSegments(t *testing.T) { + imgs := []CascadeImage{ + {Base64Data: "QQ==", MimeType: "image/png"}, + {Base64Data: "Qg==", MimeType: "image/jpeg"}, + } + body := appendSendUserCascadeImages(nil, imgs) + // 应该有两次 field 6 tag (0x32) + count := bytes.Count(body, []byte{0x32}) + if count != 2 { + t.Errorf("expected 2 field-6 segments, got %d in %x", count, body) + } +} + +func TestAppendSendUserCascadeImages_NilInput(t *testing.T) { + body := appendSendUserCascadeImages(nil, nil) + if len(body) != 0 { + t.Errorf("nil input should produce empty bytes, got %x", body) + } +} diff --git a/backend/internal/pkg/windsurf/codec.go b/backend/internal/pkg/windsurf/codec.go index e7276015..41450fda 100644 --- a/backend/internal/pkg/windsurf/codec.go +++ b/backend/internal/pkg/windsurf/codec.go @@ -7,6 +7,7 @@ package windsurf import ( "encoding/hex" + "os" ) // ── Constants ────────────────────────────────────────────────────────────── @@ -18,12 +19,36 @@ const ( AppVersion = "1.48.2" ExtensionVersion = "1.9600.41" IDEVersion = ExtensionVersion - RuntimeOS = "linux" - HardwareArch = "x86_64" ClientVersion = "2.0.63" UserAgent = "connect-go/1.18.1 (go1.26.1)" + + // DefaultRuntimeOS / DefaultHardwareArch are what we advertise in + // protocol metadata when the WINDSURF_METADATA_OS / + // WINDSURF_METADATA_ARCH env vars are unset. They match the Linux LS + // binary we spawn by default, so the upstream never sees a mismatch + // between advertised OS and the actual LS behavior. + DefaultRuntimeOS = "linux" + DefaultHardwareArch = "x86_64" ) +// RuntimeOS returns the OS string sent in protocol metadata, consulting +// WINDSURF_METADATA_OS on each call so tests can flip it with t.Setenv. +func RuntimeOS() string { + if v := os.Getenv("WINDSURF_METADATA_OS"); v != "" { + return v + } + return DefaultRuntimeOS +} + +// HardwareArch returns the hardware string sent in protocol metadata, +// consulting WINDSURF_METADATA_ARCH on each call. +func HardwareArch() string { + if v := os.Getenv("WINDSURF_METADATA_ARCH"); v != "" { + return v + } + return DefaultHardwareArch +} + // ── Protobuf wire encoding ───────────────────────────────────────────────── func writeVarint(value uint64) []byte { diff --git a/backend/internal/pkg/windsurf/connector.go b/backend/internal/pkg/windsurf/connector.go index 4289ec41..3ccbd2a8 100644 --- a/backend/internal/pkg/windsurf/connector.go +++ b/backend/internal/pkg/windsurf/connector.go @@ -11,6 +11,10 @@ type LSConnector interface { Acquire(ctx context.Context, proxyURL string) (*LSLease, error) Health(ctx context.Context) error Status() *LSConnectorStatus + // Shutdown releases any resources owned by the connector. Connectors + // that only dial external endpoints implement this as a no-op; the + // embedded mode terminates its spawned LS processes here. + Shutdown() } type LSLease struct { @@ -68,6 +72,10 @@ func (d *DockerConnector) Status() *LSConnectorStatus { } } +// Shutdown is a no-op: DockerConnector only dials a remote endpoint and +// owns no long-lived goroutines or child processes. +func (d *DockerConnector) Shutdown() {} + type EmbeddedConnector struct { pool *LSPool } @@ -114,6 +122,16 @@ func (e *EmbeddedConnector) Status() *LSConnectorStatus { } } +// Shutdown terminates every LS process in the pool. Must be called on +// application teardown; otherwise child processes leak until the OS +// reaps them at exit. +func (e *EmbeddedConnector) Shutdown() { + if e == nil || e.pool == nil { + return + } + e.pool.Shutdown() +} + type ExternalConnector struct { baseURL string port int @@ -156,3 +174,6 @@ func (x *ExternalConnector) Status() *LSConnectorStatus { Endpoint: x.baseURL, } } + +// Shutdown is a no-op: ExternalConnector only dials a remote endpoint. +func (x *ExternalConnector) Shutdown() {} diff --git a/backend/internal/pkg/windsurf/conversation_pool.go b/backend/internal/pkg/windsurf/conversation_pool.go index 21e59e4b..df915d84 100644 --- a/backend/internal/pkg/windsurf/conversation_pool.go +++ b/backend/internal/pkg/windsurf/conversation_pool.go @@ -173,13 +173,34 @@ func stableTurns(messages []ChatMessage) []ChatMessage { } func hashFingerprint(modelKey, apiKey string, turns []ChatMessage) string { + type canonicalImage struct { + MimeType string `json:"mime_type"` + SHA256 string `json:"sha256"` + ByteLen int `json:"byte_len"` + Caption string `json:"caption,omitempty"` + } type canonical struct { - Role string `json:"role"` - Content string `json:"content"` + Role string `json:"role"` + Content string `json:"content"` + Images []canonicalImage `json:"images,omitempty"` } cans := make([]canonical, len(turns)) for i, t := range turns { - cans[i] = canonical{Role: t.Role, Content: t.Content} + c := canonical{Role: t.Role, Content: t.Content} + // 指纹只使用 ImageDigests,永不使用 base64。 + // 若 ImageDigests 为空则 canonical.Images 也省略(保持向后兼容:无图 hash 与旧版本一致)。 + if len(t.ImageDigests) > 0 { + c.Images = make([]canonicalImage, len(t.ImageDigests)) + for j, d := range t.ImageDigests { + c.Images[j] = canonicalImage{ + MimeType: d.MimeType, + SHA256: d.SHA256, + ByteLen: d.ByteLen, + Caption: d.Caption, + } + } + } + cans[i] = c } data, _ := json.Marshal(cans) h := sha256.Sum256([]byte(fmt.Sprintf("%s\x00\x00%s\x00\x00%s", modelKey, apiKey, data))) diff --git a/backend/internal/pkg/windsurf/conversation_pool_image_test.go b/backend/internal/pkg/windsurf/conversation_pool_image_test.go new file mode 100644 index 00000000..2de44580 --- /dev/null +++ b/backend/internal/pkg/windsurf/conversation_pool_image_test.go @@ -0,0 +1,68 @@ +package windsurf + +import "testing" + +func TestFingerprintBefore_BackwardCompatibleForTextOnly(t *testing.T) { + msgs := []ChatMessage{ + {Role: "user", Content: "hello"}, + {Role: "assistant", Content: "hi"}, + {Role: "user", Content: "again"}, + } + // 无图场景 hash 必须与 ImageDigests=nil 时一致(向后兼容) + fp := FingerprintBefore(msgs, "claude-opus-4-7", "key-A") + if fp == "" { + t.Fatal("unexpected empty fingerprint") + } +} + +func TestFingerprintBefore_DifferentImagesDiffer(t *testing.T) { + base := []ChatMessage{ + {Role: "user", Content: "describe", ImageDigests: []ImageDigest{{MimeType: "image/png", SHA256: "aaa", ByteLen: 100}}}, + {Role: "assistant", Content: "ok"}, + {Role: "user", Content: "next"}, + } + diffImg := []ChatMessage{ + {Role: "user", Content: "describe", ImageDigests: []ImageDigest{{MimeType: "image/png", SHA256: "bbb", ByteLen: 100}}}, + {Role: "assistant", Content: "ok"}, + {Role: "user", Content: "next"}, + } + fp1 := FingerprintBefore(base, "claude-opus-4-7", "key-A") + fp2 := FingerprintBefore(diffImg, "claude-opus-4-7", "key-A") + if fp1 == fp2 { + t.Errorf("expected different fingerprints when image sha256 differs") + } +} + +func TestFingerprintBefore_SameImageDifferentCaptionDiffers(t *testing.T) { + a := []ChatMessage{ + {Role: "user", Content: "x", ImageDigests: []ImageDigest{{MimeType: "image/png", SHA256: "z", ByteLen: 10, Caption: "cat"}}}, + {Role: "assistant", Content: "ok"}, + {Role: "user", Content: "y"}, + } + b := []ChatMessage{ + {Role: "user", Content: "x", ImageDigests: []ImageDigest{{MimeType: "image/png", SHA256: "z", ByteLen: 10, Caption: "dog"}}}, + {Role: "assistant", Content: "ok"}, + {Role: "user", Content: "y"}, + } + fp1 := FingerprintBefore(a, "claude-opus-4-7", "key-A") + fp2 := FingerprintBefore(b, "claude-opus-4-7", "key-A") + if fp1 == fp2 { + t.Errorf("expected different fingerprints when caption differs") + } +} + +func TestFingerprintBefore_NoImagesSameAsAbsent(t *testing.T) { + a := []ChatMessage{ + {Role: "user", Content: "hello"}, + {Role: "assistant", Content: "hi"}, + {Role: "user", Content: "again"}, + } + b := []ChatMessage{ + {Role: "user", Content: "hello", Images: nil, ImageDigests: nil}, + {Role: "assistant", Content: "hi"}, + {Role: "user", Content: "again"}, + } + if FingerprintBefore(a, "m", "k") != FingerprintBefore(b, "m", "k") { + t.Errorf("no-image variants should hash identically (backward compat)") + } +} diff --git a/backend/internal/pkg/windsurf/datadir.go b/backend/internal/pkg/windsurf/datadir.go new file mode 100644 index 00000000..11505e0a --- /dev/null +++ b/backend/internal/pkg/windsurf/datadir.go @@ -0,0 +1,97 @@ +package windsurf + +import ( + "os" + "path/filepath" + "runtime" +) + +// dataDirStat reports whether a data directory path exists and is writable. +type dataDirStat struct { + Exists bool + Writable bool +} + +// dataDirStatFn is replaced in tests to avoid touching the filesystem. +var dataDirStatFn = defaultDataDirStat + +// userConfigDirFn is replaced in tests. +var userConfigDirFn = defaultUserConfigDir + +func defaultDataDirStat(path string) dataDirStat { + info, err := os.Stat(path) + if err != nil || !info.IsDir() { + return dataDirStat{} + } + // Probe writability by creating a sentinel file. This catches root-owned + // directories on Linux that are readable but not user-writable. + tmp := filepath.Join(path, ".sub2api-writable-check") + f, err := os.Create(tmp) + if err != nil { + return dataDirStat{Exists: true} + } + _ = f.Close() + _ = os.Remove(tmp) + return dataDirStat{Exists: true, Writable: true} +} + +func defaultUserConfigDir() string { + if dir, err := os.UserConfigDir(); err == nil { + return dir + } + return "" +} + +// LegacyLinuxDataDir is the original pre-cross-platform data directory. +// Preserved to keep existing Linux deployments functional after upgrade. +const LegacyLinuxDataDir = "/opt/windsurf/data" + +// resolveDataDir picks the base directory under which each LS instance's +// per-key subdirectory will live. +// +// Priority: +// 1. cfg.DataDir — explicit caller wins +// 2. WINDSURF_DATA_DIR env var +// 3. /opt/windsurf/data if it exists AND is writable (Linux backward compat) +// 4. os.UserConfigDir()/windsurf/data (cross-platform default) +// 5. os.TempDir()/windsurf-data (last-resort fallback) +func resolveDataDir(cfg LSPoolConfig) string { + return resolveDataDirFor(cfg.DataDir, os.Getenv("WINDSURF_DATA_DIR"), runtime.GOOS) +} + +func resolveDataDirFor(cfgDir, envDir, goos string) string { + if cfgDir != "" { + return cfgDir + } + if envDir != "" { + return envDir + } + if goos == "linux" { + if stat := dataDirStatFn(LegacyLinuxDataDir); stat.Exists && stat.Writable { + return LegacyLinuxDataDir + } + } + if cfgDir := userConfigDirFn(); cfgDir != "" { + return filepath.Join(cfgDir, "windsurf", "data") + } + return filepath.Join(os.TempDir(), "windsurf-data") +} + +// instanceHomeDir returns the per-instance HOME sandbox path. The LS binary +// writes telemetry and caches under $HOME (Linux/macOS) or %USERPROFILE% +// (Windows), so isolating it per instance prevents one instance from leaking +// state into another or into the invoking user's real home directory. +func instanceHomeDir(instanceDataDir string) string { + return filepath.Join(instanceDataDir, "home") +} + +// homeEnvForPlatform returns the set of environment assignments needed to +// direct the LS process at the given home directory. On Windows we also set +// USERPROFILE because most Windows programs check that first. +func homeEnvForPlatform(homeDir, goos string) []string { + envs := []string{"HOME=" + homeDir} + if goos == "windows" { + envs = append(envs, "USERPROFILE="+homeDir) + } + return envs +} diff --git a/backend/internal/pkg/windsurf/datadir_test.go b/backend/internal/pkg/windsurf/datadir_test.go new file mode 100644 index 00000000..d4f8d224 --- /dev/null +++ b/backend/internal/pkg/windsurf/datadir_test.go @@ -0,0 +1,119 @@ +package windsurf + +import ( + "path/filepath" + "reflect" + "strings" + "testing" +) + +func stubDataDirStat(t *testing.T, present map[string]dataDirStat) { + t.Helper() + orig := dataDirStatFn + dataDirStatFn = func(path string) dataDirStat { + return present[path] + } + t.Cleanup(func() { dataDirStatFn = orig }) +} + +func stubUserConfigDir(t *testing.T, dir string) { + t.Helper() + orig := userConfigDirFn + userConfigDirFn = func() string { return dir } + t.Cleanup(func() { userConfigDirFn = orig }) +} + +func TestResolveDataDir_ExplicitCfgWins(t *testing.T) { + stubUserConfigDir(t, "/home/test/.config") + got := resolveDataDirFor("/explicit/path", "", "linux") + if got != "/explicit/path" { + t.Errorf("explicit cfg should win, got %q", got) + } +} + +func TestResolveDataDir_EnvWinsOverDefaults(t *testing.T) { + stubUserConfigDir(t, "/home/test/.config") + got := resolveDataDirFor("", "/env/path", "linux") + if got != "/env/path" { + t.Errorf("env var should win over defaults, got %q", got) + } +} + +func TestResolveDataDir_LinuxLegacyKeptWhenWritable(t *testing.T) { + stubDataDirStat(t, map[string]dataDirStat{ + LegacyLinuxDataDir: {Exists: true, Writable: true}, + }) + stubUserConfigDir(t, "/home/test/.config") + got := resolveDataDirFor("", "", "linux") + if got != LegacyLinuxDataDir { + t.Errorf("linux with writable /opt/windsurf/data should keep legacy path, got %q", got) + } +} + +func TestResolveDataDir_LinuxLegacyNotWritableFallsBack(t *testing.T) { + stubDataDirStat(t, map[string]dataDirStat{ + LegacyLinuxDataDir: {Exists: true, Writable: false}, // root-owned + }) + stubUserConfigDir(t, "/home/test/.config") + got := resolveDataDirFor("", "", "linux") + want := filepath.Join("/home/test/.config", "windsurf", "data") + if got != want { + t.Errorf("unwritable legacy should fall back to UserConfigDir, got %q want %q", got, want) + } +} + +func TestResolveDataDir_DarwinIgnoresLinuxLegacy(t *testing.T) { + stubDataDirStat(t, map[string]dataDirStat{ + LegacyLinuxDataDir: {Exists: true, Writable: true}, + }) + stubUserConfigDir(t, "/Users/test/Library/Application Support") + got := resolveDataDirFor("", "", "darwin") + want := filepath.Join("/Users/test/Library/Application Support", "windsurf", "data") + if got != want { + t.Errorf("darwin should ignore /opt/windsurf/data, got %q want %q", got, want) + } +} + +func TestResolveDataDir_WindowsUsesUserConfigDir(t *testing.T) { + stubUserConfigDir(t, `C:\Users\test\AppData\Roaming`) + got := resolveDataDirFor("", "", "windows") + want := filepath.Join(`C:\Users\test\AppData\Roaming`, "windsurf", "data") + if got != want { + t.Errorf("windows should use UserConfigDir, got %q want %q", got, want) + } +} + +func TestResolveDataDir_EmptyUserConfigFallsBackToTemp(t *testing.T) { + stubUserConfigDir(t, "") + got := resolveDataDirFor("", "", "linux") + if !strings.Contains(got, "windsurf-data") { + t.Errorf("fallback should contain windsurf-data, got %q", got) + } +} + +func TestInstanceHomeDir(t *testing.T) { + got := instanceHomeDir("/var/lib/windsurf/key1") + want := filepath.Join("/var/lib/windsurf/key1", "home") + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestHomeEnvForPlatform(t *testing.T) { + tests := []struct { + goos string + want []string + }{ + {"linux", []string{"HOME=/sandbox"}}, + {"darwin", []string{"HOME=/sandbox"}}, + {"windows", []string{"HOME=/sandbox", "USERPROFILE=/sandbox"}}, + } + for _, tt := range tests { + t.Run(tt.goos, func(t *testing.T) { + got := homeEnvForPlatform("/sandbox", tt.goos) + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("goos=%s: got %v, want %v", tt.goos, got, tt.want) + } + }) + } +} diff --git a/backend/internal/pkg/windsurf/discovery.go b/backend/internal/pkg/windsurf/discovery.go new file mode 100644 index 00000000..d2ec35eb --- /dev/null +++ b/backend/internal/pkg/windsurf/discovery.go @@ -0,0 +1,162 @@ +package windsurf + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + "strings" +) + +// ErrBinaryNotFound is returned when no Windsurf LS binary can be located +// via any configured source (env, explicit config, or platform candidates). +var ErrBinaryNotFound = fmt.Errorf("windsurf: language server binary not found") + +// binaryStatFn reports whether the given path exists and is executable for +// the current platform. It is a package variable so tests can replace it +// with a map-backed implementation that does not touch the filesystem. +var binaryStatFn = defaultBinaryStat + +// userHomeFn returns the user's home directory. Replaced in tests. +var userHomeFn = defaultUserHome + +func defaultBinaryStat(path string) bool { + info, err := os.Stat(path) + if err != nil || info.IsDir() { + return false + } + if runtime.GOOS == "windows" { + // Windows ignores the Unix execute bit — rely on the .exe suffix. + return strings.HasSuffix(strings.ToLower(path), ".exe") + } + return info.Mode()&0o111 != 0 +} + +func defaultUserHome() string { + if dir, err := os.UserHomeDir(); err == nil { + return dir + } + return "" +} + +// DiscoverBinary resolves the Windsurf LS binary path for the current +// platform. Resolution order: +// +// 1. LS_BINARY_PATH environment variable (explicit override — user intent +// wins even if the path doesn't exist, so we can surface a clear error) +// 2. cfg.Binary (explicit config override) +// 3. Platform-specific candidate list (official install locations) +// +// Returns ErrBinaryNotFound when none of the sources yield an executable +// file; the error message directs the user to LS_BINARY_PATH or ls_mode=docker. +func DiscoverBinary(cfg LSPoolConfig) (string, error) { + return discoverBinaryFor(DetectPlatform(), os.Getenv("LS_BINARY_PATH"), cfg.Binary) +} + +func discoverBinaryFor(p Platform, envPath, cfgPath string) (string, error) { + if envPath != "" { + return validateBinaryPath(envPath, p, "LS_BINARY_PATH") + } + if cfgPath != "" { + return validateBinaryPath(cfgPath, p, "cfg.Binary") + } + + candidates, err := platformCandidates(p) + if err != nil { + return "", err + } + for _, path := range candidates { + if binaryStatFn(path) { + return path, nil + } + } + + return "", fmt.Errorf("%w for %s; searched %d paths (%s); set LS_BINARY_PATH or use ls_mode=docker", + ErrBinaryNotFound, p, len(candidates), strings.Join(candidates, ", ")) +} + +func validateBinaryPath(path string, p Platform, source string) (string, error) { + if binaryStatFn(path) { + return path, nil + } + hint := "file does not exist or is not executable" + if p.OS == "windows" && !strings.HasSuffix(strings.ToLower(path), ".exe") { + hint = "Windows LS binaries must end in .exe" + } else if p.OS != "windows" { + hint += " (try chmod +x)" + } + return "", fmt.Errorf("%w: %s=%q — %s; set LS_BINARY_PATH to a valid path or use ls_mode=docker", + ErrBinaryNotFound, source, path, hint) +} + +// platformCandidates returns the ordered list of paths where the official +// Windsurf LS binary may be installed on the given platform. Paths are +// ordered by preference — the first existing+executable path wins. +func platformCandidates(p Platform) ([]string, error) { + filename, err := BinaryFilename(p) + if err != nil { + return nil, err + } + + switch p.OS { + case "darwin": + return darwinCandidates(filename), nil + case "linux": + return linuxCandidates(filename), nil + case "windows": + return windowsCandidates(filename), nil + } + // BinaryFilename would have errored first, so this is defensive. + return nil, fmt.Errorf("%w: %s (no candidate list)", ErrUnsupportedPlatform, p) +} + +func darwinCandidates(filename string) []string { + const bundleSubpath = "Contents/Resources/app/extensions/windsurf/bin" + candidates := []string{ + filepath.Join("/Applications/Windsurf.app", bundleSubpath, filename), + } + if home := userHomeFn(); home != "" { + candidates = append(candidates, + filepath.Join(home, "Applications/Windsurf.app", bundleSubpath, filename), + ) + } + // Legacy sub2api install (pre-cross-platform). + candidates = append(candidates, filepath.Join("/opt/windsurf", filename)) + return candidates +} + +func linuxCandidates(filename string) []string { + candidates := []string{ + // Legacy sub2api install (pre-cross-platform) — matches existing deployments. + filepath.Join("/opt/windsurf", filename), + // Official Debian/RPM install locations. + filepath.Join("/usr/share/windsurf/resources/app/extensions/windsurf/bin", filename), + filepath.Join("/usr/lib/windsurf/resources/app/extensions/windsurf/bin", filename), + } + // User-local install (Flatpak/AppImage unpacked). + if home := userHomeFn(); home != "" { + candidates = append(candidates, + filepath.Join(home, ".local/share/windsurf/resources/app/extensions/windsurf/bin", filename), + ) + } + return candidates +} + +func windowsCandidates(filename string) []string { + // Split the install subpath into its components so filepath.Join produces + // a platform-native path on whichever OS this runs (Windows '\\', Unix '/'). + // This matters for tests running on non-Windows builders. + installSubpath := []string{"resources", "app", "extensions", "windsurf", "bin", filename} + var candidates []string + if localAppData := os.Getenv("LOCALAPPDATA"); localAppData != "" { + candidates = append(candidates, + filepath.Join(append([]string{localAppData, "Programs", "Windsurf"}, installSubpath...)...), + ) + } + if programFiles := os.Getenv("PROGRAMFILES"); programFiles != "" { + candidates = append(candidates, + filepath.Join(append([]string{programFiles, "Windsurf"}, installSubpath...)...), + ) + } + return candidates +} diff --git a/backend/internal/pkg/windsurf/discovery_test.go b/backend/internal/pkg/windsurf/discovery_test.go new file mode 100644 index 00000000..41d257ed --- /dev/null +++ b/backend/internal/pkg/windsurf/discovery_test.go @@ -0,0 +1,179 @@ +package windsurf + +import ( + "errors" + "path/filepath" + "strings" + "testing" +) + +// stubStatFn replaces binaryStatFn for the duration of a test, restoring the +// original on cleanup. +func stubStatFn(t *testing.T, present map[string]bool) { + t.Helper() + orig := binaryStatFn + binaryStatFn = func(path string) bool { + return present[path] + } + t.Cleanup(func() { binaryStatFn = orig }) +} + +func stubUserHome(t *testing.T, home string) { + t.Helper() + orig := userHomeFn + userHomeFn = func() string { return home } + t.Cleanup(func() { userHomeFn = orig }) +} + +func TestDiscoverBinary_EnvOverrideWins(t *testing.T) { + stubStatFn(t, map[string]bool{ + "/tmp/my-ls": true, + "/opt/windsurf/language_server_linux_x64": true, // should not be picked + }) + got, err := discoverBinaryFor(Platform{"linux", "amd64"}, "/tmp/my-ls", "/opt/windsurf/language_server_linux_x64") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "/tmp/my-ls" { + t.Errorf("env override should win, got %q", got) + } +} + +func TestDiscoverBinary_EnvMissingReturnsError(t *testing.T) { + stubStatFn(t, map[string]bool{}) // nothing exists + _, err := discoverBinaryFor(Platform{"linux", "amd64"}, "/does/not/exist", "") + if err == nil { + t.Fatal("expected error for missing env path") + } + if !errors.Is(err, ErrBinaryNotFound) { + t.Errorf("expected ErrBinaryNotFound, got %v", err) + } + if !strings.Contains(err.Error(), "LS_BINARY_PATH") { + t.Errorf("error should mention LS_BINARY_PATH, got %v", err) + } + if !strings.Contains(err.Error(), "docker") { + t.Errorf("error should point at docker, got %v", err) + } +} + +func TestDiscoverBinary_CfgBinaryUsedWhenEnvEmpty(t *testing.T) { + stubStatFn(t, map[string]bool{"/custom/ls": true}) + got, err := discoverBinaryFor(Platform{"linux", "amd64"}, "", "/custom/ls") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "/custom/ls" { + t.Errorf("cfg path should be used, got %q", got) + } +} + +func TestDiscoverBinary_FallsBackToCandidates(t *testing.T) { + stubStatFn(t, map[string]bool{ + "/opt/windsurf/language_server_linux_x64": true, + }) + got, err := discoverBinaryFor(Platform{"linux", "amd64"}, "", "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "/opt/windsurf/language_server_linux_x64" { + t.Errorf("expected /opt/windsurf/language_server_linux_x64, got %q", got) + } +} + +func TestDiscoverBinary_DarwinPicksBundleOverLegacy(t *testing.T) { + bundle := "/Applications/Windsurf.app/Contents/Resources/app/extensions/windsurf/bin/language_server_macos_arm" + legacy := "/opt/windsurf/language_server_macos_arm" + stubStatFn(t, map[string]bool{ + bundle: true, + legacy: true, + }) + stubUserHome(t, "/Users/test") + got, err := discoverBinaryFor(Platform{"darwin", "arm64"}, "", "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != bundle { + t.Errorf("bundle path should win over legacy, got %q", got) + } +} + +func TestDiscoverBinary_WindowsPicksLocalAppData(t *testing.T) { + t.Setenv("LOCALAPPDATA", `C:\Users\test\AppData\Local`) + t.Setenv("PROGRAMFILES", `C:\Program Files`) + // Construct the expected path the same way windowsCandidates() does so + // the test is portable across builders (macOS/Linux Go use '/', Windows + // uses '\\'). The Windows-native separator is verified by the Windows CI + // job in backend-ci.yml. + localPath := filepath.Join(`C:\Users\test\AppData\Local`, "Programs", "Windsurf", + "resources", "app", "extensions", "windsurf", "bin", + "language_server_windows_x64.exe") + stubStatFn(t, map[string]bool{localPath: true}) + got, err := discoverBinaryFor(Platform{"windows", "amd64"}, "", "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != localPath { + t.Errorf("expected LOCALAPPDATA path, got %q", got) + } +} + +func TestDiscoverBinary_AllMissReturnsUsefulError(t *testing.T) { + stubStatFn(t, map[string]bool{}) + stubUserHome(t, "/Users/test") + _, err := discoverBinaryFor(Platform{"darwin", "arm64"}, "", "") + if err == nil { + t.Fatal("expected error") + } + if !errors.Is(err, ErrBinaryNotFound) { + t.Errorf("expected ErrBinaryNotFound, got %v", err) + } + for _, want := range []string{"darwin/arm64", "LS_BINARY_PATH", "docker", "/Applications/Windsurf.app"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error missing %q: %v", want, err) + } + } +} + +func TestDiscoverBinary_UnsupportedPlatformPropagates(t *testing.T) { + _, err := discoverBinaryFor(Platform{"freebsd", "amd64"}, "", "") + if err == nil { + t.Fatal("expected error") + } + if !errors.Is(err, ErrUnsupportedPlatform) { + t.Errorf("expected ErrUnsupportedPlatform, got %v", err) + } +} + +func TestDiscoverBinary_WindowsNonExeRejected(t *testing.T) { + // Even if env stat returns true, validateBinaryPath uses the real binaryStatFn, + // which for Windows requires .exe suffix. Test via direct stat behavior. + stubStatFn(t, map[string]bool{"C:\\custom\\ls": false}) // stat returns false + _, err := discoverBinaryFor(Platform{"windows", "amd64"}, "C:\\custom\\ls", "") + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), ".exe") { + t.Errorf("windows error should hint at .exe, got %v", err) + } +} + +func TestPlatformCandidates_Linux(t *testing.T) { + stubUserHome(t, "/home/test") + got, err := platformCandidates(Platform{"linux", "amd64"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got) < 3 { + t.Errorf("expected at least 3 linux candidates, got %d: %v", len(got), got) + } + if got[0] != "/opt/windsurf/language_server_linux_x64" { + t.Errorf("legacy path should be first for backward-compat, got %q", got[0]) + } +} + +func TestPlatformCandidates_UnsupportedPropagates(t *testing.T) { + _, err := platformCandidates(Platform{"plan9", "amd64"}) + if err == nil || !errors.Is(err, ErrUnsupportedPlatform) { + t.Errorf("expected ErrUnsupportedPlatform, got %v", err) + } +} diff --git a/backend/internal/pkg/windsurf/legacy_chat.go b/backend/internal/pkg/windsurf/legacy_chat.go index 0749a472..e4d4c2fd 100644 --- a/backend/internal/pkg/windsurf/legacy_chat.go +++ b/backend/internal/pkg/windsurf/legacy_chat.go @@ -19,6 +19,12 @@ const ( type ChatMessage struct { Role string `json:"role"` Content string `json:"content"` + // Images 当前消息携带的图片(通常只有 user role 才非空)。 + // 仅用于发送/replay。CascadeImage.Base64Data 不应出现在持久化/日志/指纹中。 + Images []CascadeImage `json:"images,omitempty"` + // ImageDigests 仅摘要视图(含 sha256 / mime / byte_len / caption,不含 base64)。 + // 供 conversation pool 指纹与日志使用。 + ImageDigests []ImageDigest `json:"image_digests,omitempty"` } type LegacyChatDelta struct { diff --git a/backend/internal/pkg/windsurf/local_ls.go b/backend/internal/pkg/windsurf/local_ls.go index d4228bf6..83a418ac 100644 --- a/backend/internal/pkg/windsurf/local_ls.go +++ b/backend/internal/pkg/windsurf/local_ls.go @@ -15,8 +15,10 @@ package windsurf import ( "bytes" "context" + "crypto/sha256" "crypto/tls" "encoding/binary" + "encoding/hex" "fmt" "io" "log/slog" @@ -28,6 +30,9 @@ import ( "time" "golang.org/x/net/http2" + "google.golang.org/protobuf/proto" + + pb "github.com/Wei-Shaw/sub2api/internal/gen/language_server_pb" ) const ( @@ -38,8 +43,18 @@ const ( SendUserCascadeMessageRPC = "/exa.language_server_pb.LanguageServerService/SendUserCascadeMessage" GetCascadeTrajectoryStepsRPC = "/exa.language_server_pb.LanguageServerService/GetCascadeTrajectorySteps" GetCascadeTrajectoryStatusRPC = "/exa.language_server_pb.LanguageServerService/GetCascadeTrajectory" + GetCascadeModelConfigsRPC = "/exa.language_server_pb.LanguageServerService/GetCascadeModelConfigs" ) +// cascadeModelCapsCacheEntry 是单个 API key 下模型能力的缓存条目。 +type cascadeModelCapsCacheEntry struct { + SupportsImages map[string]bool + FetchedAt time.Time +} + +// cascadeModelCapsTTL 能力缓存 TTL(5 分钟)。 +const cascadeModelCapsTTL = 5 * time.Minute + // LocalLSClient talks to the local Windsurf LanguageServerService via h2c (plain HTTP/2 over TCP). type LocalLSClient struct { BaseURL string @@ -51,6 +66,10 @@ type LocalLSClient struct { // server-side repository context and relies on caller-provided tool results. TrackedWorkspace string mu sync.Mutex + + // 模型能力缓存(per-API-key hash),供 Cascade 图像 gate 使用。 + modelCapsMu sync.Mutex + modelCapsCache map[string]cascadeModelCapsCacheEntry } // NewLocalLSClient builds a client for the local LS at the given port. @@ -159,7 +178,19 @@ func (l *LocalLSClient) StartCascade(ctx context.Context, token string) (string, // SendUserCascadeMessage sends a message into an existing cascade session. // Returns the (possibly new) cascadeID — it changes if panel-state retry triggers a new StartCascade. // toolPreamble, if non-empty, is injected into the tool_calling_section override. -func (l *LocalLSClient) SendUserCascadeMessage(ctx context.Context, token, cascadeID, text, modelUID, toolPreamble string, modelEnumHint int) (string, error) { +// SendUserCascadeMessage sends a user chat message to Cascade. +// Returns the (possibly new) cascadeID — it changes if panel-state retry triggers a new StartCascade. +// toolPreamble, if non-empty, is injected into the tool_calling_section override. +// images(可选)作为 SendUserCascadeMessageRequest.images (field 6) 追加到 proto wire。 +// +// allowRecreate 控制 panel-state-not-found 时是否内部静默重建 cascade: +// - true:ForceWarmup + StartCascade + 用 SAME text 再发一次。调用方须保证 +// text 已包含完整历史(否则新 cascade 无状态 + text 无历史 = 上下文丢失)。 +// - false:直接返回错误,让调用方重建含完整历史的 text 后再调。 +// +// 经验值:StreamCascadeChat 内当 reuseCascadeID 为空(本地 StartCascade 的流程, +// text 已是 full-history)时传 true;reuse 场景(text 可能仅含最后一条消息)传 false。 +func (l *LocalLSClient) SendUserCascadeMessage(ctx context.Context, token, cascadeID, text, modelUID, toolPreamble string, modelEnumHint int, images []CascadeImage, allowRecreate bool) (string, error) { modelEnum := resolveModelEnum(modelUID) if modelEnum == 0 && modelEnumHint > 0 { modelEnum = modelEnumHint @@ -170,22 +201,29 @@ func (l *LocalLSClient) SendUserCascadeMessage(ctx context.Context, token, casca body = append(body, encodeBytesField(2, encodeStringField(1, text))...) body = append(body, encodeBytesField(3, buildMetadata(token, l.SessionID))...) body = append(body, encodeBytesField(5, buildCascadeConfig(modelUID, modelEnum, toolPreamble))...) + // field 6: repeated CodeiumImage images(逆向自 Windsurf.app chat-client) + body = appendSendUserCascadeImages(body, images) return l.grpcUnary(ctx, SendUserCascadeMessageRPC, body) } if err := doSend(cascadeID); err != nil { - if isPanelStateNotFound(err) { - _ = l.ForceWarmupCascade(ctx, token) - newCascadeID, startErr := l.StartCascade(ctx, token) - if startErr != nil { - return "", startErr - } - if err := doSend(newCascadeID); err != nil { - return "", err - } - return newCascadeID, nil + if !isPanelStateNotFound(err) { + return "", err } - return "", err + if !allowRecreate { + // reuse 场景:不要静默用 last-message-only 的 text 去灌新 cascade。 + // 返回错误,让 chatCascade 用 full-history text 重建整个调用。 + return "", err + } + _ = l.ForceWarmupCascade(ctx, token) + newCascadeID, startErr := l.StartCascade(ctx, token) + if startErr != nil { + return "", startErr + } + if err := doSend(newCascadeID); err != nil { + return "", err + } + return newCascadeID, nil } return cascadeID, nil } @@ -200,9 +238,9 @@ func buildMetadata(token, sessionID string) []byte { meta = append(meta, encodeStringField(2, ExtensionVersion)...) // extension_version meta = append(meta, encodeStringField(3, token)...) // api_key meta = append(meta, encodeStringField(4, "en")...) // locale - meta = append(meta, encodeStringField(5, RuntimeOS)...) // os + meta = append(meta, encodeStringField(5, RuntimeOS())...) // os meta = append(meta, encodeStringField(7, IDEVersion)...) // ide_version - meta = append(meta, encodeStringField(8, HardwareArch)...) // hardware + meta = append(meta, encodeStringField(8, HardwareArch())...) // hardware meta = append(meta, encodeVarintField(9, uint64(time.Now().UnixMilli()))...) // request_id meta = append(meta, encodeStringField(10, sessionID)...) // session_id meta = append(meta, encodeStringField(12, AppName)...) // extension_name @@ -372,7 +410,8 @@ func (e *CascadeModelError) Error() string { return e.Msg } // StreamCascadeChat performs the full Cascade chat flow and returns accumulated text + thinking. // Includes cold/warm stall detection, step error handling, and final sweep (aligned with JS v1.9). // If reuseCascadeID is non-empty, skips StartCascade and reuses the existing cascade session. -func (l *LocalLSClient) StreamCascadeChat(ctx context.Context, token, modelUID, userText, toolPreamble, reuseCascadeID string, modelEnumHint int) (*CascadeChatResult, error) { +// images 作为当前 user turn 的图像 sidecar 传递给 SendUserCascadeMessage(proto field 6)。 +func (l *LocalLSClient) StreamCascadeChat(ctx context.Context, token, modelUID, userText, toolPreamble, reuseCascadeID string, modelEnumHint int, images []CascadeImage) (*CascadeChatResult, error) { if err := l.WarmupCascade(ctx, token); err != nil { return nil, fmt.Errorf("warmup: %w", err) } @@ -400,7 +439,12 @@ func (l *LocalLSClient) StreamCascadeChat(ctx context.Context, token, modelUID, } } - cascadeID, err = l.SendUserCascadeMessage(ctx, token, cascadeID, userText, modelUID, toolPreamble, modelEnumHint) + // allowRecreate=true 仅对本流程内 StartCascade 出来的全新 cascade 安全: + // 此时 userText 已是 full-history,内部遇到 panel-not-found 可静默重建再发。 + // reuse 场景(caller 传入 reuseCascadeID)下 userText 可能只含最后一条消息, + // 静默重建会把空状态 cascade 当成有历史的 resume 用 → 上下文丢失,所以禁止。 + allowRecreate := reuseCascadeID == "" + cascadeID, err = l.SendUserCascadeMessage(ctx, token, cascadeID, userText, modelUID, toolPreamble, modelEnumHint, images, allowRecreate) if err != nil { return nil, fmt.Errorf("SendUserCascadeMessage: %w", err) } @@ -1241,3 +1285,70 @@ func hasNonPrintable(s string) bool { } return false } + +// GetCascadeModelConfigs 查询 LS 的 GetCascadeModelConfigs RPC, +// 返回 model_name -> supports_images 的映射。模型名按小写归一化。 +func (l *LocalLSClient) GetCascadeModelConfigs(ctx context.Context, token string) (map[string]bool, error) { + // 请求 body:只需 metadata,即 field 1 encode(Metadata) + // 参考 package.json 提到的 proto;这里用 metadata-only encoding 与其他 RPC 一致。 + body := encodeBytesField(1, buildMetadata(token, l.SessionID)) + raw, err := l.grpcUnaryRaw(ctx, GetCascadeModelConfigsRPC, body) + if err != nil { + return nil, fmt.Errorf("get_cascade_model_configs: %w", err) + } + var resp pb.GetCascadeModelConfigsResponse + if err := proto.Unmarshal(raw, &resp); err != nil { + return nil, fmt.Errorf("unmarshal: %w", err) + } + out := make(map[string]bool, len(resp.GetModels())) + for _, m := range resp.GetModels() { + out[strings.ToLower(strings.TrimSpace(m.GetName()))] = m.GetSupportsImages() + } + return out, nil +} + +// ModelSupportsImages 带缓存的图像能力查询。 +// fail-open:RPC 失败且无缓存时返回 (false, false, nil),由上层决定策略。 +// 返回值:(found, supportsImages, error) +func (l *LocalLSClient) ModelSupportsImages(ctx context.Context, token, modelName string) (bool, bool, error) { + key := apiKeyHash(token) + + l.modelCapsMu.Lock() + if l.modelCapsCache == nil { + l.modelCapsCache = make(map[string]cascadeModelCapsCacheEntry) + } + entry, ok := l.modelCapsCache[key] + fresh := ok && time.Since(entry.FetchedAt) < cascadeModelCapsTTL + l.modelCapsMu.Unlock() + + if fresh { + v, found := entry.SupportsImages[strings.ToLower(strings.TrimSpace(modelName))] + return found, v, nil + } + + // 拉新:失败时保留 stale + caps, err := l.GetCascadeModelConfigs(ctx, token) + if err != nil { + // stale fallback + if ok { + v, found := entry.SupportsImages[strings.ToLower(strings.TrimSpace(modelName))] + return found, v, nil + } + return false, false, err + } + + l.modelCapsMu.Lock() + l.modelCapsCache[key] = cascadeModelCapsCacheEntry{ + SupportsImages: caps, + FetchedAt: time.Now(), + } + l.modelCapsMu.Unlock() + + v, found := caps[strings.ToLower(strings.TrimSpace(modelName))] + return found, v, nil +} + +func apiKeyHash(token string) string { + sum := sha256.Sum256([]byte(token)) + return hex.EncodeToString(sum[:8]) // 16 hex chars 足够区分 +} diff --git a/backend/internal/pkg/windsurf/lspool.go b/backend/internal/pkg/windsurf/lspool.go index 712669e0..aed02593 100644 --- a/backend/internal/pkg/windsurf/lspool.go +++ b/backend/internal/pkg/windsurf/lspool.go @@ -1,15 +1,19 @@ package windsurf import ( + "bufio" "context" "crypto/tls" "fmt" + "io" + "log/slog" "net" "net/url" "os" "os/exec" "path/filepath" "regexp" + "runtime" "strings" "sync" "sync/atomic" @@ -36,8 +40,13 @@ type LSPoolConfig struct { func (c *LSPoolConfig) defaults() { if c.Binary == "" { - c.Binary = os.Getenv("LS_BINARY_PATH") - if c.Binary == "" { + // Try env override first, then platform-aware discovery, then legacy + // Linux default so existing /opt/windsurf deployments keep booting. + // Real errors (missing binary, wrong platform) still surface later + // at spawn time with more context than we could give here. + if found, err := DiscoverBinary(*c); err == nil { + c.Binary = found + } else { c.Binary = DefaultLSBinary } } @@ -54,7 +63,7 @@ func (c *LSPoolConfig) defaults() { } } if c.DataDir == "" { - c.DataDir = "/opt/windsurf/data" + c.DataDir = resolveDataDir(*c) } } @@ -188,13 +197,7 @@ func (p *LSPool) stopEntry(e *LSEntry) { if e.Cmd == nil || e.Cmd.Process == nil { return } - _ = e.Cmd.Process.Signal(os.Interrupt) - select { - case <-e.done: - case <-time.After(5 * time.Second): - _ = e.Cmd.Process.Kill() - <-e.done - } + terminateProcess(e.Cmd.Process, e.done) } type LSStatus struct { @@ -302,6 +305,13 @@ func (p *LSPool) spawnLS(ctx context.Context, key, proxyURL string) (*LSEntry, e if err := os.MkdirAll(filepath.Join(dataDir, "db"), 0o755); err != nil { return nil, fmt.Errorf("mkdirAll %s/db: %w", dataDir, err) } + // Per-instance sandboxed HOME so the LS binary's telemetry/cache writes + // stay inside dataDir instead of leaking into the invoker's real home or + // /root. Required on macOS/Windows where /root does not exist. + homeDir := instanceHomeDir(dataDir) + if err := os.MkdirAll(homeDir, 0o755); err != nil { + return nil, fmt.Errorf("mkdirAll home %s: %w", homeDir, err) + } args := []string{ fmt.Sprintf("--api_server_url=%s", p.config.APIServerURL), @@ -318,7 +328,10 @@ func (p *LSPool) spawnLS(ctx context.Context, key, proxyURL string) (*LSEntry, e // Don't bind LS process lifetime to request context — use background context for the process. cmd := exec.Command(p.config.Binary, args...) - cmd.Env = append(os.Environ(), "HOME=/root") + cmd.Env = append(os.Environ(), homeEnvForPlatform(homeDir, runtime.GOOS)...) + // Run with cwd = binary directory so the LS can find helper binaries + // (e.g. `fd`) shipped alongside it in the official install layout. + cmd.Dir = filepath.Dir(p.config.Binary) if proxyURL != "" { cmd.Env = append(cmd.Env, "HTTPS_PROXY="+proxyURL, @@ -328,15 +341,29 @@ func (p *LSPool) spawnLS(ctx context.Context, key, proxyURL string) (*LSEntry, e ) } - cmd.Stdout = nil - cmd.Stderr = nil + stdoutPipe, err := cmd.StdoutPipe() + if err != nil { + return nil, fmt.Errorf("ls stdout pipe %s: %w", key, err) + } + stderrPipe, err := cmd.StderrPipe() + if err != nil { + return nil, fmt.Errorf("ls stderr pipe %s: %w", key, err) + } p.log("Starting LS instance key=%s port=%d proxy=%s", key, port, redactProxyURL(proxyURL)) + attachProcessGroup(cmd) if err := cmd.Start(); err != nil { - return nil, fmt.Errorf("spawn LS %s: %w", key, err) + return nil, wrapSpawnError(key, p.config.Binary, err) } + pid := 0 + if cmd.Process != nil { + pid = cmd.Process.Pid + } + go scanLSOutput(stdoutPipe, key, pid, "stdout") + go scanLSOutput(stderrPipe, key, pid, "stderr") + entry := &LSEntry{ Cmd: cmd, Port: port, @@ -386,3 +413,47 @@ func (p *LSPool) monitorProcess(key string, entry *LSEntry) { } p.mu.Unlock() } + +// scanLSOutput forwards each line from the LS process's stdout/stderr to slog. +// The goroutine exits when the pipe is closed (i.e. when the LS process exits +// and the kernel closes the write end). +func scanLSOutput(r io.Reader, key string, pid int, stream string) { + sc := bufio.NewScanner(r) + // Allow up to 1 MiB per line to handle verbose panic stacks. + sc.Buffer(make([]byte, 4096), 1<<20) + for sc.Scan() { + line := strings.TrimRight(sc.Text(), "\r") + if line == "" { + continue + } + slog.Info("windsurf_ls_output", + "key", key, + "pid", pid, + "stream", stream, + "line", line, + ) + } + if err := sc.Err(); err != nil && err != io.EOF { + slog.Debug("windsurf_ls_output_scan_error", + "key", key, + "pid", pid, + "stream", stream, + "error", err, + ) + } +} + +// wrapSpawnError annotates a cmd.Start() failure with platform-specific +// troubleshooting guidance so users don't have to guess why the LS binary +// refused to launch. The original error is preserved via %w so callers can +// still errors.Is/As against it. +func wrapSpawnError(key, binary string, err error) error { + base := fmt.Errorf("spawn LS %s (%s): %w", key, binary, err) + switch runtime.GOOS { + case "darwin": + return fmt.Errorf("%w — if macOS Gatekeeper blocked this, run: xattr -d com.apple.quarantine %s (or reinstall Windsurf from the official app)", base, binary) + case "windows": + return fmt.Errorf("%w — if Windows Defender/SmartScreen blocked this, verify the binary is not quarantined and has execute permissions", base) + } + return base +} diff --git a/backend/internal/pkg/windsurf/lspool_log_test.go b/backend/internal/pkg/windsurf/lspool_log_test.go new file mode 100644 index 00000000..5f79765a --- /dev/null +++ b/backend/internal/pkg/windsurf/lspool_log_test.go @@ -0,0 +1,60 @@ +package windsurf + +import ( + "bytes" + "log/slog" + "strings" + "sync" + "testing" + "time" +) + +// TestScanLSOutputEmitsLines checks that scanLSOutput forwards each non-empty +// line (with \r stripped) to slog and exits on EOF without leaking goroutines. +func TestScanLSOutputEmitsLines(t *testing.T) { + prev := slog.Default() + defer slog.SetDefault(prev) + + var buf bytes.Buffer + var mu sync.Mutex + slog.SetDefault(slog.New(slog.NewTextHandler(&lockedWriter{b: &buf, mu: &mu}, &slog.HandlerOptions{Level: slog.LevelDebug}))) + + input := "line one\r\nline two\n\nline three\n" + done := make(chan struct{}) + go func() { + scanLSOutput(strings.NewReader(input), "test-key", 4242, "stdout") + close(done) + }() + + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("scanLSOutput did not return within 2s after EOF") + } + + mu.Lock() + out := buf.String() + mu.Unlock() + + for _, want := range []string{"line one", "line two", "line three", "test-key", "stdout", "pid=4242"} { + if !strings.Contains(out, want) { + t.Errorf("expected log output to contain %q, got:\n%s", want, out) + } + } + if strings.Count(out, "windsurf_ls_output") != 3 { + t.Errorf("expected 3 log entries (empty line skipped), got:\n%s", out) + } +} + +// lockedWriter serializes slog handler writes so the test can read the buffer +// safely from the test goroutine. +type lockedWriter struct { + b *bytes.Buffer + mu *sync.Mutex +} + +func (w *lockedWriter) Write(p []byte) (int, error) { + w.mu.Lock() + defer w.mu.Unlock() + return w.b.Write(p) +} diff --git a/backend/internal/pkg/windsurf/lspool_stop_other.go b/backend/internal/pkg/windsurf/lspool_stop_other.go new file mode 100644 index 00000000..3db02fd3 --- /dev/null +++ b/backend/internal/pkg/windsurf/lspool_stop_other.go @@ -0,0 +1,39 @@ +//go:build !windows + +package windsurf + +import ( + "os" + "os/exec" + "syscall" + "time" +) + +// attachProcessGroup configures the child to run in its own process group so +// we can signal the entire tree on shutdown. Windows has no Unix-style +// process groups; see lspool_stop_windows.go for the no-op. +func attachProcessGroup(cmd *exec.Cmd) { + if cmd.SysProcAttr == nil { + cmd.SysProcAttr = &syscall.SysProcAttr{} + } + cmd.SysProcAttr.Setpgid = true +} + +// terminateProcess asks the LS process (and its children) to exit +// gracefully, then kills them if they don't exit within 5 seconds. +func terminateProcess(p *os.Process, done <-chan struct{}) { + pgid, err := syscall.Getpgid(p.Pid) + if err != nil { + // Process may have already exited; fall back to signalling + // the original PID directly. + pgid = p.Pid + } + // Negative pid here means "send to the whole process group" per kill(2). + _ = syscall.Kill(-pgid, syscall.SIGINT) + select { + case <-done: + case <-time.After(5 * time.Second): + _ = syscall.Kill(-pgid, syscall.SIGKILL) + <-done + } +} diff --git a/backend/internal/pkg/windsurf/lspool_stop_windows.go b/backend/internal/pkg/windsurf/lspool_stop_windows.go new file mode 100644 index 00000000..35402195 --- /dev/null +++ b/backend/internal/pkg/windsurf/lspool_stop_windows.go @@ -0,0 +1,20 @@ +//go:build windows + +package windsurf + +import ( + "os" + "os/exec" +) + +// attachProcessGroup is a no-op on Windows; we rely on Process.Kill() which +// maps to TerminateProcess and does not need a process-group hint. +func attachProcessGroup(cmd *exec.Cmd) {} + +// terminateProcess kills the LS process immediately. os.Interrupt is a +// no-op on Windows (os/exec returns an error for it), so there is no +// graceful phase — go straight to TerminateProcess via Process.Kill(). +func terminateProcess(p *os.Process, done <-chan struct{}) { + _ = p.Kill() + <-done +} diff --git a/backend/internal/pkg/windsurf/metadata_test.go b/backend/internal/pkg/windsurf/metadata_test.go new file mode 100644 index 00000000..019c8160 --- /dev/null +++ b/backend/internal/pkg/windsurf/metadata_test.go @@ -0,0 +1,56 @@ +package windsurf + +import ( + "bytes" + "testing" +) + +func TestRuntimeOS_DefaultIsLinux(t *testing.T) { + // t.Setenv unsets after the test, and a nil env var makes RuntimeOS() + // fall back to DefaultRuntimeOS. Setting to "" has the same effect. + t.Setenv("WINDSURF_METADATA_OS", "") + if got := RuntimeOS(); got != "linux" { + t.Errorf("default RuntimeOS = %q, want %q", got, "linux") + } +} + +func TestRuntimeOS_EnvOverride(t *testing.T) { + t.Setenv("WINDSURF_METADATA_OS", "darwin") + if got := RuntimeOS(); got != "darwin" { + t.Errorf("env-overridden RuntimeOS = %q, want %q", got, "darwin") + } +} + +func TestHardwareArch_DefaultIsX86_64(t *testing.T) { + t.Setenv("WINDSURF_METADATA_ARCH", "") + if got := HardwareArch(); got != "x86_64" { + t.Errorf("default HardwareArch = %q, want %q", got, "x86_64") + } +} + +func TestHardwareArch_EnvOverride(t *testing.T) { + t.Setenv("WINDSURF_METADATA_ARCH", "arm64") + if got := HardwareArch(); got != "arm64" { + t.Errorf("env-overridden HardwareArch = %q, want %q", got, "arm64") + } +} + +// TestBuildMetadata_UsesOverriddenValues confirms that buildMetadata reads +// RuntimeOS() / HardwareArch() at call time, not at package init, so +// switching the env per-test actually flows through to the wire format. +func TestBuildMetadata_UsesOverriddenValues(t *testing.T) { + t.Setenv("WINDSURF_METADATA_OS", "darwin") + t.Setenv("WINDSURF_METADATA_ARCH", "arm64") + + meta := buildMetadata("test-token", "test-session-id") + + if !bytes.Contains(meta, []byte("darwin")) { + t.Errorf("expected meta to contain overridden OS %q, got %q", "darwin", meta) + } + if !bytes.Contains(meta, []byte("arm64")) { + t.Errorf("expected meta to contain overridden arch %q, got %q", "arm64", meta) + } + if bytes.Contains(meta, []byte("linux")) || bytes.Contains(meta, []byte("x86_64")) { + t.Errorf("meta should not contain default values when env is set, got %q", meta) + } +} diff --git a/backend/internal/pkg/windsurf/platform.go b/backend/internal/pkg/windsurf/platform.go new file mode 100644 index 00000000..aab5b4ec --- /dev/null +++ b/backend/internal/pkg/windsurf/platform.go @@ -0,0 +1,81 @@ +package windsurf + +import ( + "fmt" + "os/exec" + "runtime" + "strings" +) + +// Platform identifies a (OS, architecture) pair that Windsurf may support. +// OS values match runtime.GOOS ("linux", "darwin", "windows"), and Arch +// values match runtime.GOARCH ("amd64", "arm64"). +type Platform struct { + OS string + Arch string +} + +// String renders the platform as "os/arch" (e.g. "darwin/arm64"). +func (p Platform) String() string { return p.OS + "/" + p.Arch } + +// procTranslatedProbe reports whether the current process is running under +// Rosetta 2 translation (an amd64 Go binary on Apple Silicon). It is mocked +// by tests. +var procTranslatedProbe = defaultProcTranslatedProbe + +// defaultProcTranslatedProbe runs `sysctl sysctl.proc_translated` on darwin +// and returns true when the value is "1". On non-darwin platforms it always +// returns false. The sysctl key is only defined on darwin, so we skip the +// exec on every other OS to avoid spurious errors. +func defaultProcTranslatedProbe() bool { + if runtime.GOOS != "darwin" { + return false + } + out, err := exec.Command("sysctl", "-n", "sysctl.proc_translated").Output() + if err != nil { + return false + } + return strings.TrimSpace(string(out)) == "1" +} + +// DetectPlatform returns the platform for which we should load the LS binary. +// It accounts for Rosetta translation: an amd64 Go binary running on Apple +// Silicon should load the arm64 LS, not the x64 one, because the native +// Mac arm64 binary performs better and the x64 build may not exist. +func DetectPlatform() Platform { + return detectPlatformFor(runtime.GOOS, runtime.GOARCH, procTranslatedProbe()) +} + +func detectPlatformFor(goos, goarch string, rosetta bool) Platform { + p := Platform{OS: goos, Arch: goarch} + if p.OS == "darwin" && p.Arch == "amd64" && rosetta { + p.Arch = "arm64" + } + return p +} + +// ErrUnsupportedPlatform is returned when no Windsurf LS binary exists for +// the current platform. Callers should fall back to ls_mode=docker. +var ErrUnsupportedPlatform = fmt.Errorf("windsurf: no language server binary for this platform") + +// BinaryFilename returns the official Windsurf LS binary filename for the +// given platform, matching the mapping used by the Windsurf VS Code +// extension (extracted from extension.js PlatformArch enum). +func BinaryFilename(p Platform) (string, error) { + switch { + case p.OS == "linux" && p.Arch == "amd64": + return "language_server_linux_x64", nil + case p.OS == "linux" && p.Arch == "arm64": + return "language_server_linux_arm", nil + case p.OS == "darwin" && p.Arch == "arm64": + return "language_server_macos_arm", nil + case p.OS == "darwin" && p.Arch == "amd64": + return "language_server_macos_x64", nil + case p.OS == "windows" && p.Arch == "amd64": + return "language_server_windows_x64.exe", nil + case p.OS == "windows" && p.Arch == "arm64": + return "language_server_windows_arm.exe", nil + default: + return "", fmt.Errorf("%w: %s — use ls_mode=docker to run the Linux LS in a container", ErrUnsupportedPlatform, p) + } +} diff --git a/backend/internal/pkg/windsurf/platform_test.go b/backend/internal/pkg/windsurf/platform_test.go new file mode 100644 index 00000000..a127e589 --- /dev/null +++ b/backend/internal/pkg/windsurf/platform_test.go @@ -0,0 +1,94 @@ +package windsurf + +import ( + "errors" + "strings" + "testing" +) + +func TestBinaryFilename(t *testing.T) { + tests := []struct { + name string + p Platform + want string + wantErr bool + }{ + {"linux amd64", Platform{"linux", "amd64"}, "language_server_linux_x64", false}, + {"linux arm64", Platform{"linux", "arm64"}, "language_server_linux_arm", false}, + {"darwin arm64", Platform{"darwin", "arm64"}, "language_server_macos_arm", false}, + {"darwin amd64 (intel mac)", Platform{"darwin", "amd64"}, "language_server_macos_x64", false}, + {"windows amd64", Platform{"windows", "amd64"}, "language_server_windows_x64.exe", false}, + {"windows arm64", Platform{"windows", "arm64"}, "language_server_windows_arm.exe", false}, + {"freebsd", Platform{"freebsd", "amd64"}, "", true}, + {"linux 386", Platform{"linux", "386"}, "", true}, + {"empty", Platform{}, "", true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := BinaryFilename(tt.p) + if tt.wantErr { + if err == nil { + t.Fatalf("expected error, got nil (value %q)", got) + } + if !errors.Is(err, ErrUnsupportedPlatform) { + t.Fatalf("expected ErrUnsupportedPlatform in chain, got %v", err) + } + // Error message should point the user toward Docker. + if !strings.Contains(err.Error(), "docker") { + t.Errorf("unsupported-platform error should mention docker, got: %v", err) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tt.want { + t.Errorf("BinaryFilename(%v) = %q, want %q", tt.p, got, tt.want) + } + }) + } +} + +func TestDetectPlatformFor(t *testing.T) { + tests := []struct { + name string + goos string + goarch string + rosetta bool + wantOS string + wantArch string + }{ + {"linux amd64 unchanged", "linux", "amd64", false, "linux", "amd64"}, + {"windows amd64 unchanged", "windows", "amd64", false, "windows", "amd64"}, + {"darwin arm64 unchanged", "darwin", "arm64", false, "darwin", "arm64"}, + {"darwin amd64 no rosetta stays amd64", "darwin", "amd64", false, "darwin", "amd64"}, + {"darwin amd64 under rosetta promotes to arm64", "darwin", "amd64", true, "darwin", "arm64"}, + {"linux amd64 rosetta flag ignored (not darwin)", "linux", "amd64", true, "linux", "amd64"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := detectPlatformFor(tt.goos, tt.goarch, tt.rosetta) + if got.OS != tt.wantOS || got.Arch != tt.wantArch { + t.Errorf("detectPlatformFor(%q,%q,%v) = %v, want {OS:%q Arch:%q}", + tt.goos, tt.goarch, tt.rosetta, got, tt.wantOS, tt.wantArch) + } + }) + } +} + +func TestPlatformString(t *testing.T) { + if got := (Platform{OS: "darwin", Arch: "arm64"}).String(); got != "darwin/arm64" { + t.Errorf("Platform.String() = %q, want %q", got, "darwin/arm64") + } +} + +func TestProcTranslatedProbeNonDarwin(t *testing.T) { + // The default probe must always return false on non-darwin platforms + // without calling sysctl. We can only validate this runtime behavior on + // the builder we're on — for non-darwin builders the probe should be + // false even if sysctl were somehow present. + // + // For darwin builders, we accept either result; we only assert that the + // probe does not panic and returns a bool. + _ = defaultProcTranslatedProbe() +} diff --git a/backend/internal/pkg/windsurf/send_user_cascade_test.go b/backend/internal/pkg/windsurf/send_user_cascade_test.go new file mode 100644 index 00000000..fdac73c1 --- /dev/null +++ b/backend/internal/pkg/windsurf/send_user_cascade_test.go @@ -0,0 +1,85 @@ +package windsurf + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" +) + +// Simulates a Windsurf LS that rejects SendUserCascadeMessage with +// "panel state not found". Verifies that allowRecreate=false bubbles the +// error up (so chatCascade can rebuild full-history text) while +// allowRecreate=true still triggers the internal recreate path. +func TestSendUserCascadeMessage_AllowRecreateGatesSilentRetry(t *testing.T) { + tests := []struct { + name string + allowRecreate bool + wantErr bool + // When allowRecreate=true the client tries ForceWarmup + StartCascade. + // When allowRecreate=false we expect no such attempts. + wantStartCascadeCalled bool + }{ + {"disabled bubbles error", false, true, false}, + {"enabled attempts recreate", true, true, true}, // test LS keeps rejecting so overall errors, but StartCascade must be attempted + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var mu sync.Mutex + var paths []string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + paths = append(paths, r.URL.Path) + mu.Unlock() + // Every RPC returns panel-not-found so we can detect whether + // StartCascade was attempted after the initial failure. + w.Header().Set("Content-Type", "application/grpc") + w.Header().Set("grpc-status", "5") + w.Header().Set("grpc-message", "panel state not found for session abc") + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + client := NewLocalLSClient(42099, "csrf") + client.BaseURL = server.URL + client.HTTP = server.Client() + // Pre-mark as warmed so the first SendUserCascadeMessage doesn't trigger + // warmup on its own path — keeps the test focused. + client.Warmed = true + + _, err := client.SendUserCascadeMessage( + context.Background(), + "token", + "existing-cascade-id", + "hello", + "claude-sonnet-4", + "", + 0, + nil, + tt.allowRecreate, + ) + + if (err != nil) != tt.wantErr { + t.Fatalf("err = %v, wantErr = %v", err, tt.wantErr) + } + + mu.Lock() + defer mu.Unlock() + startCascadeCalled := false + for _, p := range paths { + if strings.HasSuffix(p, "/StartCascade") { + startCascadeCalled = true + break + } + } + if startCascadeCalled != tt.wantStartCascadeCalled { + t.Fatalf("StartCascade called = %v, want %v (paths: %v)", + startCascadeCalled, tt.wantStartCascadeCalled, paths) + } + }) + } +} diff --git a/backend/internal/pkg/windsurf/spawn_error_test.go b/backend/internal/pkg/windsurf/spawn_error_test.go new file mode 100644 index 00000000..f9001ef0 --- /dev/null +++ b/backend/internal/pkg/windsurf/spawn_error_test.go @@ -0,0 +1,48 @@ +package windsurf + +import ( + "errors" + "runtime" + "strings" + "testing" +) + +func TestWrapSpawnError_PreservesOriginalError(t *testing.T) { + orig := errors.New("exec: no such file") + wrapped := wrapSpawnError("test-key", "/opt/ls", orig) + if !errors.Is(wrapped, orig) { + t.Errorf("wrapped error should unwrap to original, got %v", wrapped) + } + if !strings.Contains(wrapped.Error(), "test-key") { + t.Errorf("wrapped error should mention the instance key, got %v", wrapped) + } + if !strings.Contains(wrapped.Error(), "/opt/ls") { + t.Errorf("wrapped error should mention the binary path, got %v", wrapped) + } +} + +// Platform-specific hint assertions: these only run on their native OS. +// Cross-platform CI (Phase 0.2 matrix) exercises each branch natively. +func TestWrapSpawnError_PlatformHint(t *testing.T) { + err := wrapSpawnError("k", "/tmp/ls", errors.New("operation not permitted")) + msg := err.Error() + switch runtime.GOOS { + case "darwin": + for _, want := range []string{"Gatekeeper", "xattr", "com.apple.quarantine"} { + if !strings.Contains(msg, want) { + t.Errorf("darwin hint missing %q: %v", want, err) + } + } + case "windows": + for _, want := range []string{"Defender", "quarantined"} { + if !strings.Contains(msg, want) { + t.Errorf("windows hint missing %q: %v", want, err) + } + } + default: + // Linux and other Unix: plain error, no extra hint. + if strings.Contains(msg, "Gatekeeper") || strings.Contains(msg, "Defender") { + t.Errorf("non-mac/non-windows error should not contain platform-specific hints, got %v", err) + } + } +} diff --git a/backend/internal/pkg/windsurf/tool_emulation.go b/backend/internal/pkg/windsurf/tool_emulation.go index 2646696e..94428401 100644 --- a/backend/internal/pkg/windsurf/tool_emulation.go +++ b/backend/internal/pkg/windsurf/tool_emulation.go @@ -312,12 +312,15 @@ type AnthropicMessage struct { Content json.RawMessage `json:"content"` ToolCalls []OpenAIToolCall `json:"tool_calls,omitempty"` ToolCallID string `json:"tool_call_id,omitempty"` + // Images 当前消息携带的图像块(仅 user/tool role 有效)。 + Images []CascadeImage `json:"images,omitempty"` } // NormalizeMessagesForCascade rewrites messages for Cascade compatibility: // - role:"tool" messages become user turns with wrappers // - assistant messages with tool_calls get rewritten to format // - tool preamble is injected into the last user message +// - 保留 AnthropicMessage.Images 到输出的 ChatMessage.Images(tool role 时挂到 user turn) func NormalizeMessagesForCascade(messages []AnthropicMessage, tools []OpenAITool) []ChatMessage { var out []ChatMessage @@ -327,6 +330,7 @@ func NormalizeMessagesForCascade(messages []AnthropicMessage, tools []OpenAITool out = append(out, ChatMessage{ Role: "user", Content: fmt.Sprintf("\n%s\n", content), + Images: m.Images, // tool_result 里的图抬到 user turn }) continue } @@ -353,9 +357,11 @@ func NormalizeMessagesForCascade(messages []AnthropicMessage, tools []OpenAITool }) parts = append(parts, ""+string(callJSON)+"") } + // assistant turn 通常不带图,但为了健壮性仍保留(若上游真传了) out = append(out, ChatMessage{ Role: "assistant", Content: strings.Join(parts, "\n"), + Images: m.Images, }) continue } @@ -363,6 +369,7 @@ func NormalizeMessagesForCascade(messages []AnthropicMessage, tools []OpenAITool out = append(out, ChatMessage{ Role: m.Role, Content: extractRawContentText(m.Content), + Images: m.Images, }) } diff --git a/backend/internal/service/account_test_service.go b/backend/internal/service/account_test_service.go index ceb0863d..4e51d95f 100644 --- a/backend/internal/service/account_test_service.go +++ b/backend/internal/service/account_test_service.go @@ -54,6 +54,7 @@ const ( defaultGeminiTextTestPrompt = "hi" defaultGeminiImageTestPrompt = "Generate a cute orange cat astronaut sticker on a clean pastel background." defaultOpenAIImageTestPrompt = "Generate a cute orange cat astronaut sticker on a clean pastel background." + defaultClaudeTestPrompt = "hi" ) // isOpenAIImageModel checks if the model is an OpenAI image generation model (e.g. gpt-image-2). @@ -126,11 +127,15 @@ func generateSessionString() (string, error) { } // createTestPayload creates a Claude Code style test request payload -func createTestPayload(modelID string) (map[string]any, error) { +func createTestPayload(modelID string, prompt string) (map[string]any, error) { sessionID, err := generateSessionString() if err != nil { return nil, err } + text := strings.TrimSpace(prompt) + if text == "" { + text = defaultClaudeTestPrompt + } return map[string]any{ "model": modelID, @@ -140,7 +145,7 @@ func createTestPayload(modelID string) (map[string]any, error) { "content": []map[string]any{ { "type": "text", - "text": "hi", + "text": text, "cache_control": map[string]string{ "type": "ephemeral", }, @@ -195,11 +200,11 @@ func (s *AccountTestService) TestAccountConnection(c *gin.Context, accountID int return s.testWindsurfAccountConnection(c, account, modelID) } - return s.testClaudeAccountConnection(c, account, modelID) + return s.testClaudeAccountConnection(c, account, modelID, prompt) } // testClaudeAccountConnection tests an Anthropic Claude account's connection -func (s *AccountTestService) testClaudeAccountConnection(c *gin.Context, account *Account, modelID string) error { +func (s *AccountTestService) testClaudeAccountConnection(c *gin.Context, account *Account, modelID string, prompt string) error { ctx := c.Request.Context() // Determine the model to use @@ -215,7 +220,7 @@ func (s *AccountTestService) testClaudeAccountConnection(c *gin.Context, account // Bedrock accounts use a separate test path if account.IsBedrock() { - return s.testBedrockAccountConnection(c, ctx, account, testModelID) + return s.testBedrockAccountConnection(c, ctx, account, testModelID, prompt) } // Determine authentication method and API URL @@ -260,7 +265,7 @@ func (s *AccountTestService) testClaudeAccountConnection(c *gin.Context, account c.Writer.Flush() // Create Claude Code style payload (same for all account types) - payload, err := createTestPayload(testModelID) + payload, err := createTestPayload(testModelID, prompt) if err != nil { return s.sendErrorAndEnd(c, "Failed to create test payload") } @@ -285,10 +290,10 @@ func (s *AccountTestService) testClaudeAccountConnection(c *gin.Context, account // Set authentication header if useBearer { - req.Header.Set("anthropic-beta", claude.DefaultBetaHeader) + req.Header.Set("anthropic-beta", claude.GetOAuthBetaHeader(testModelID)) req.Header.Set("Authorization", "Bearer "+authToken) } else { - req.Header.Set("anthropic-beta", claude.APIKeyBetaHeader) + req.Header.Set("anthropic-beta", claude.GetAPIKeyBetaHeader(testModelID)) req.Header.Set("x-api-key", authToken) } @@ -321,7 +326,7 @@ func (s *AccountTestService) testClaudeAccountConnection(c *gin.Context, account } // testBedrockAccountConnection tests a Bedrock (SigV4 or API Key) account using non-streaming invoke -func (s *AccountTestService) testBedrockAccountConnection(c *gin.Context, ctx context.Context, account *Account, testModelID string) error { +func (s *AccountTestService) testBedrockAccountConnection(c *gin.Context, ctx context.Context, account *Account, testModelID string, prompt string) error { region := bedrockRuntimeRegion(account) resolvedModelID, ok := ResolveBedrockModelID(account, testModelID) if !ok { @@ -337,6 +342,10 @@ func (s *AccountTestService) testBedrockAccountConnection(c *gin.Context, ctx co c.Writer.Flush() // Create a minimal Bedrock-compatible payload (no stream, no cache_control) + bedrockText := strings.TrimSpace(prompt) + if bedrockText == "" { + bedrockText = defaultClaudeTestPrompt + } bedrockPayload := map[string]any{ "anthropic_version": "bedrock-2023-05-31", "messages": []map[string]any{ @@ -345,7 +354,7 @@ func (s *AccountTestService) testBedrockAccountConnection(c *gin.Context, ctx co "content": []map[string]any{ { "type": "text", - "text": "hi", + "text": bedrockText, }, }, }, @@ -501,7 +510,7 @@ func (s *AccountTestService) testOpenAIAccountConnection(c *gin.Context, account c.Writer.Flush() // Create OpenAI Responses API payload - payload := createOpenAITestPayload(testModelID, isOAuth) + payload := createOpenAITestPayload(testModelID, isOAuth, prompt) payloadBytes, _ := json.Marshal(payload) // Send test_start event @@ -636,7 +645,7 @@ func (s *AccountTestService) routeAntigravityTest(c *gin.Context, account *Accou if strings.HasPrefix(modelID, "gemini-") { return s.testGeminiAccountConnection(c, account, modelID, prompt) } - return s.testClaudeAccountConnection(c, account, modelID) + return s.testClaudeAccountConnection(c, account, modelID, prompt) } return s.testAntigravityAccountConnection(c, account, modelID) } @@ -955,7 +964,11 @@ func (s *AccountTestService) processGeminiStream(c *gin.Context, body io.Reader) } // createOpenAITestPayload creates a test payload for OpenAI Responses API -func createOpenAITestPayload(modelID string, isOAuth bool) map[string]any { +func createOpenAITestPayload(modelID string, isOAuth bool, prompt string) map[string]any { + openaiText := strings.TrimSpace(prompt) + if openaiText == "" { + openaiText = defaultClaudeTestPrompt + } payload := map[string]any{ "model": modelID, "input": []map[string]any{ @@ -964,7 +977,7 @@ func createOpenAITestPayload(modelID string, isOAuth bool) map[string]any { "content": []map[string]any{ { "type": "input_text", - "text": "hi", + "text": openaiText, }, }, }, diff --git a/backend/internal/service/account_usage_service.go b/backend/internal/service/account_usage_service.go index 8d5bcec8..d1775fd5 100644 --- a/backend/internal/service/account_usage_service.go +++ b/backend/internal/service/account_usage_service.go @@ -599,7 +599,7 @@ func (s *AccountUsageService) probeOpenAICodexSnapshot(ctx context.Context, acco return nil, fmt.Errorf("no access token available") } modelID := openaipkg.DefaultTestModel - payload := createOpenAITestPayload(modelID, true) + payload := createOpenAITestPayload(modelID, true, "") payloadBytes, err := json.Marshal(payload) if err != nil { return nil, fmt.Errorf("marshal openai probe payload: %w", err) diff --git a/backend/internal/service/windsurf_chat_service.go b/backend/internal/service/windsurf_chat_service.go index 0279d37c..f9e20c48 100644 --- a/backend/internal/service/windsurf_chat_service.go +++ b/backend/internal/service/windsurf_chat_service.go @@ -39,6 +39,9 @@ type WindsurfChatRequest struct { Tools []windsurf.OpenAITool ToolChoice interface{} ToolPreamble string // computed by handler, passed through to Cascade + // Images 当前 user turn 的 sidecar 图像(Cascade proto 的 SendUserCascadeMessageRequest.images field 6)。 + // 内容必须已通过 ValidateCascadeImages(或等价校验)。 + Images []windsurf.CascadeImage } type WindsurfChatResponse struct { @@ -80,11 +83,11 @@ func (s *WindsurfChatService) Chat(ctx context.Context, req *WindsurfChatRequest var resp *WindsurfChatResponse switch mode { case "cascade": - resp, err = s.chatCascade(ctx, lease.Client, token.APIKey, meta, req.Messages, req.ToolPreamble, modelKey, lease.Endpoint) + resp, err = s.chatCascade(ctx, lease.Client, token.APIKey, meta, req.Messages, req.ToolPreamble, modelKey, lease.Endpoint, req.Images) case "legacy": resp, err = s.chatLegacy(ctx, lease.Client, token.APIKey, meta, req.Messages, modelKey) default: - resp, err = s.chatCascade(ctx, lease.Client, token.APIKey, meta, req.Messages, req.ToolPreamble, modelKey, lease.Endpoint) + resp, err = s.chatCascade(ctx, lease.Client, token.APIKey, meta, req.Messages, req.ToolPreamble, modelKey, lease.Endpoint, req.Images) } if err != nil { @@ -111,7 +114,39 @@ func (s *WindsurfChatService) resolveMode(meta *windsurf.ModelMeta) string { return windsurf.GetChatMode(meta, int(s.cfg.Chat.LegacyEnumCutoff)) } -func (s *WindsurfChatService) chatCascade(ctx context.Context, client *windsurf.LocalLSClient, apiKey string, meta *windsurf.ModelMeta, messages []windsurf.ChatMessage, toolPreamble string, modelKey string, lsEndpoint string) (*WindsurfChatResponse, error) { +var modelIdentityTemplates = map[string]string{ + "anthropic": "You are %s, a large language model created by Anthropic. You are helpful, harmless, and honest. When asked about your identity or which model you are, you MUST respond that you are %s, made by Anthropic.", + "openai": "You are %s, a large language model created by OpenAI. When asked about your identity, you MUST respond that you are %s, made by OpenAI.", + "google": "You are %s, a large language model created by Google. When asked about your identity, you MUST respond that you are %s, made by Google.", + "deepseek": "You are %s, a large language model created by DeepSeek. When asked about your identity, you MUST respond that you are %s, made by DeepSeek.", + "xai": "You are %s, a large language model created by xAI. When asked about your identity, you MUST respond that you are %s, made by xAI.", +} + +func injectModelIdentity(messages []windsurf.ChatMessage, meta *windsurf.ModelMeta, modelKey string) []windsurf.ChatMessage { + if meta == nil || meta.Provider == "" { + return messages + } + for _, m := range messages { + if m.Role == "system" { + return messages + } + } + tmpl, ok := modelIdentityTemplates[meta.Provider] + if !ok { + return messages + } + displayName := modelKey + if meta.Name != "" { + displayName = meta.Name + } + identity := windsurf.ChatMessage{ + Role: "system", + Content: fmt.Sprintf(tmpl, displayName, displayName), + } + return append([]windsurf.ChatMessage{identity}, messages...) +} + +func (s *WindsurfChatService) chatCascade(ctx context.Context, client *windsurf.LocalLSClient, apiKey string, meta *windsurf.ModelMeta, messages []windsurf.ChatMessage, toolPreamble string, modelKey string, lsEndpoint string, images []windsurf.CascadeImage) (*WindsurfChatResponse, error) { modelUID := "" modelEnumHint := 0 if meta != nil { @@ -119,12 +154,36 @@ func (s *WindsurfChatService) chatCascade(ctx context.Context, client *windsurf. modelEnumHint = meta.EnumValue } + // ── Model identity prompt injection ── + // When the client doesn't provide its own system prompt, prepend one so + // the model identifies itself as the requested model rather than leaking + // the underlying Windsurf/Cascade backend identity. + // Skip when the client already has a system message (Claude Code / Cline) + // to avoid triggering Cascade anti-injection on reasoning models. + messages = injectModelIdentity(messages, meta, modelKey) + + // 图像能力 gate:仅在请求含图时检查。 + // 策略:fail-open on RPC error;显式 supports_images=false 时拒绝(返回 CascadeModelError 触发 failover)。 + if len(images) > 0 { + found, ok, err := client.ModelSupportsImages(ctx, apiKey, modelUID) + if err != nil { + slog.Warn("windsurf_cascade_caps_fetch_failed", "model", modelUID, "error", err) + // fail-open + } else if found && !ok { + return nil, fmt.Errorf("model %q does not support image inputs in Windsurf Cascade", modelUID) + } + } + fpBefore := windsurf.FingerprintBefore(messages, modelKey, apiKey) // failover 切号后禁止复用 cascade:cascade_id 属于上一个账号的 LS, // 在当前账号上一定会触发 "panel state not found" 浪费一次请求。 + // 同时切号场景下需要提升历史预算——新账号完全没有服务端上下文, + // 必须把完整聊天记录塞进文本里。 skipReuse := false + switchover := false if switches, ok := AccountSwitchCountFromContext(ctx); ok && switches > 0 { skipReuse = true + switchover = true } var entry *windsurf.ConversationEntry if !skipReuse { @@ -138,13 +197,14 @@ func (s *WindsurfChatService) chatCascade(ctx context.Context, client *windsurf. slog.Info("windsurf_cascade_reuse_hit", "cascade_id", reuseCascadeID[:8], "model", modelKey) } - userText := buildCascadeText(messages, modelUID, isResume) + userText := buildCascadeText(messages, modelUID, isResume, switchover) - result, err := client.StreamCascadeChat(ctx, apiKey, modelUID, userText, toolPreamble, reuseCascadeID, modelEnumHint) + result, err := client.StreamCascadeChat(ctx, apiKey, modelUID, userText, toolPreamble, reuseCascadeID, modelEnumHint, images) if err != nil && isResume { slog.Warn("windsurf_cascade_reuse_failed", "error", err, "model", modelKey) - userText = buildCascadeText(messages, modelUID, false) - result, err = client.StreamCascadeChat(ctx, apiKey, modelUID, userText, toolPreamble, "", modelEnumHint) + // panel-state-not-found 恢复:新 cascade 没有服务端历史,必须发完整聊天记录。 + userText = buildCascadeText(messages, modelUID, false, true) + result, err = client.StreamCascadeChat(ctx, apiKey, modelUID, userText, toolPreamble, "", modelEnumHint, images) } if err != nil { return nil, err @@ -189,12 +249,19 @@ func (s *WindsurfChatService) chatLegacy(ctx context.Context, client *windsurf.L } const ( - cascadeMaxHistoryBytes = 200_000 - cascade1MHistoryBytes = 900_000 - cascadeMultiTurnPreamble = "The following is a multi-turn conversation. You MUST remember and use all information from prior turns." + cascadeMaxHistoryBytes = 200_000 + cascade1MHistoryBytes = 900_000 + // cascadeSwitchoverHistoryBytes 是切号 / panel-state-not-found 恢复场景下的 + // "尽量塞进完整历史" 预算。目标是让新账号拿到尽可能完整的对话上下文。 + // 3.5MB 留了 500KB 给 proto 其它字段(metadata/config/images),避开 gRPC 4MB 默认上限。 + cascadeSwitchoverHistoryBytes = 3_500_000 + cascadeMultiTurnPreamble = "The following is a multi-turn conversation. You MUST remember and use all information from prior turns." ) -func cascadeHistoryBudget(modelUID string) int { +func cascadeHistoryBudget(modelUID string, switchover bool) int { + if switchover { + return cascadeSwitchoverHistoryBytes + } if strings.Contains(strings.ToLower(modelUID), "1m") { return cascade1MHistoryBytes } @@ -205,7 +272,11 @@ func cascadeHistoryBudget(modelUID string) int { // If isResume is true, only the last user message is sent (cascade already has context). // Otherwise: system prompt wrapped in , multi-turn history // with / tags, and a budget cap to trim old turns. -func buildCascadeText(messages []windsurf.ChatMessage, modelUID string, isResume bool) string { +// +// switchover=true 提升历史预算到 cascadeSwitchoverHistoryBytes(~3.5MB), +// 用于切号 / panel-state-not-found 恢复场景——新账号/新 cascade 没有服务端历史, +// 必须把完整聊天记录塞进文本里。isResume=true 时该参数被忽略(resume 只发最后一条)。 +func buildCascadeText(messages []windsurf.ChatMessage, modelUID string, isResume, switchover bool) string { var systemParts []string var convo []windsurf.ChatMessage @@ -241,11 +312,12 @@ func buildCascadeText(messages []windsurf.ChatMessage, modelUID string, isResume } // Multi-turn: build history with budget trimming - maxBytes := cascadeHistoryBudget(modelUID) + maxBytes := cascadeHistoryBudget(modelUID, switchover) historyBytes := len(sysText) // Walk backward from second-to-last, collecting turns that fit var lines []string + droppedTurns := 0 for i := len(convo) - 2; i >= 0; i-- { m := convo[i] tag := "human" @@ -254,16 +326,26 @@ func buildCascadeText(messages []windsurf.ChatMessage, modelUID string, isResume } line := fmt.Sprintf("<%s>\n%s\n", tag, m.Content, tag) if historyBytes+len(line) > maxBytes && len(lines) > 0 { + droppedTurns = i + 1 slog.Info("windsurf_cascade_history_trimmed", "turn", i, "total_turns", len(convo), "kept_kb", historyBytes/1024, + "dropped_turns", droppedTurns, + "switchover", switchover, ) break } lines = append([]string{line}, lines...) historyBytes += len(line) } + if switchover && droppedTurns == 0 { + slog.Info("windsurf_cascade_switchover_history", + "total_turns", len(convo), + "kept_kb", historyBytes/1024, + "dropped_turns", 0, + ) + } latest := convo[len(convo)-1] text := cascadeMultiTurnPreamble + "\n\n" + diff --git a/backend/internal/service/windsurf_chat_service_test.go b/backend/internal/service/windsurf_chat_service_test.go new file mode 100644 index 00000000..80d7536e --- /dev/null +++ b/backend/internal/service/windsurf_chat_service_test.go @@ -0,0 +1,167 @@ +package service + +import ( + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/pkg/windsurf" +) + +// Test that the switchover flag expands the history budget to ~3.5MB and preserves +// all turns for a large multi-turn conversation that would otherwise be trimmed +// under the normal 200KB budget. This guards the core fix: after a Windsurf +// account switch, the new account must receive the full chat history. +func TestBuildCascadeText_SwitchoverKeepsFullHistory(t *testing.T) { + // Build a ~1.5MB multi-turn history: 30 turns of ~50KB each (alternating + // user/assistant). Exceeds the normal 200KB cap; well within the 3.5MB cap. + const perTurnBytes = 50 * 1024 + const turns = 30 + bulk := strings.Repeat("x", perTurnBytes) + + var messages []windsurf.ChatMessage + messages = append(messages, windsurf.ChatMessage{Role: "system", Content: "sys"}) + for i := 0; i < turns; i++ { + role := "user" + if i%2 == 1 { + role = "assistant" + } + messages = append(messages, windsurf.ChatMessage{Role: role, Content: bulk}) + } + // Latest user message (the one actually being answered). + messages = append(messages, windsurf.ChatMessage{Role: "user", Content: "final question"}) + + normalText := buildCascadeText(messages, "claude-sonnet-4", false, false) + switchoverText := buildCascadeText(messages, "claude-sonnet-4", false, true) + + if len(normalText) >= len(switchoverText) { + t.Fatalf("switchover text (%d bytes) must be larger than normal (%d bytes)", + len(switchoverText), len(normalText)) + } + if len(normalText) > cascadeMaxHistoryBytes+perTurnBytes { + t.Fatalf("normal text (%d bytes) must fit near %d budget", len(normalText), cascadeMaxHistoryBytes) + } + if len(switchoverText) < perTurnBytes*turns { + t.Fatalf("switchover text (%d bytes) dropped turns; expected >= %d (all %d turns kept)", + len(switchoverText), perTurnBytes*turns, turns) + } + if len(switchoverText) > cascadeSwitchoverHistoryBytes+perTurnBytes { + t.Fatalf("switchover text (%d bytes) exceeded budget %d", len(switchoverText), cascadeSwitchoverHistoryBytes) + } + // Final user message must always be preserved (it's the question being asked). + if !strings.Contains(switchoverText, "final question") { + t.Fatal("switchover text must include the final user message") + } + if !strings.Contains(normalText, "final question") { + t.Fatal("normal text must include the final user message") + } +} + +// Resume mode ignores switchover — only the last user message is sent because +// Cascade server already has the history for the reused cascade_id. +func TestBuildCascadeText_ResumeIgnoresSwitchover(t *testing.T) { + messages := []windsurf.ChatMessage{ + {Role: "user", Content: "first"}, + {Role: "assistant", Content: "reply"}, + {Role: "user", Content: "second question"}, + } + + got := buildCascadeText(messages, "claude-sonnet-4", true, true) + if got != "second question" { + t.Fatalf("resume=true must return only last user message, got %q", got) + } +} + +func TestInjectModelIdentity(t *testing.T) { + tests := []struct { + name string + messages []windsurf.ChatMessage + meta *windsurf.ModelMeta + modelKey string + wantInjected bool + }{ + { + name: "anthropic model without system", + messages: []windsurf.ChatMessage{{Role: "user", Content: "hi"}}, + meta: &windsurf.ModelMeta{Name: "claude-sonnet-4.6", Provider: "anthropic"}, + modelKey: "claude-sonnet-4.6", + wantInjected: true, + }, + { + name: "client already has system — skip injection", + messages: []windsurf.ChatMessage{ + {Role: "system", Content: "You are a helpful assistant"}, + {Role: "user", Content: "hi"}, + }, + meta: &windsurf.ModelMeta{Name: "claude-sonnet-4.6", Provider: "anthropic"}, + modelKey: "claude-sonnet-4.6", + wantInjected: false, + }, + { + name: "nil meta — skip injection", + messages: []windsurf.ChatMessage{{Role: "user", Content: "hi"}}, + meta: nil, + modelKey: "unknown", + wantInjected: false, + }, + { + name: "unknown provider — skip injection", + messages: []windsurf.ChatMessage{{Role: "user", Content: "hi"}}, + meta: &windsurf.ModelMeta{Name: "some-model", Provider: "unknownvendor"}, + modelKey: "some-model", + wantInjected: false, + }, + { + name: "openai model without system", + messages: []windsurf.ChatMessage{{Role: "user", Content: "hi"}}, + meta: &windsurf.ModelMeta{Name: "gpt-4o", Provider: "openai"}, + modelKey: "gpt-4o", + wantInjected: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := injectModelIdentity(tt.messages, tt.meta, tt.modelKey) + if tt.wantInjected { + if len(result) != len(tt.messages)+1 { + t.Fatalf("expected injection (len %d → %d), got len %d", + len(tt.messages), len(tt.messages)+1, len(result)) + } + if result[0].Role != "system" { + t.Fatalf("injected message role = %q, want system", result[0].Role) + } + displayName := tt.meta.Name + if !strings.Contains(result[0].Content, displayName) { + t.Fatalf("injected content should contain model name %q, got %q", + displayName, result[0].Content) + } + } else { + if len(result) != len(tt.messages) { + t.Fatalf("expected no injection (len %d), got len %d", + len(tt.messages), len(result)) + } + } + }) + } +} + +func TestCascadeHistoryBudget(t *testing.T) { + tests := []struct { + name string + modelUID string + switchover bool + want int + }{ + {"normal model normal budget", "claude-sonnet-4", false, cascadeMaxHistoryBytes}, + {"1m model normal budget", "claude-sonnet-4-1m", false, cascade1MHistoryBytes}, + {"normal model switchover", "claude-sonnet-4", true, cascadeSwitchoverHistoryBytes}, + {"1m model switchover", "claude-sonnet-4-1m", true, cascadeSwitchoverHistoryBytes}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := cascadeHistoryBudget(tt.modelUID, tt.switchover); got != tt.want { + t.Errorf("cascadeHistoryBudget(%q, %v) = %d, want %d", + tt.modelUID, tt.switchover, got, tt.want) + } + }) + } +} diff --git a/backend/internal/service/windsurf_gateway_service.go b/backend/internal/service/windsurf_gateway_service.go index 94bda4c6..3a6f2a5e 100644 --- a/backend/internal/service/windsurf_gateway_service.go +++ b/backend/internal/service/windsurf_gateway_service.go @@ -81,14 +81,15 @@ func (s *WindsurfGatewayService) Forward(ctx context.Context, c *gin.Context, ac }) } - for _, m := range req.Messages { + for mi, m := range req.Messages { contentBlocks := windsurfParseContentBlocks(m.Content) var toolResultMsgs []windsurf.AnthropicMessage var toolUseMsgs []windsurf.OpenAIToolCall var textParts []string + var turnImages []windsurf.CascadeImage - for _, block := range contentBlocks { + for bi, block := range contentBlocks { switch block.Type { case "tool_result": hasToolHistory = true @@ -102,6 +103,13 @@ func (s *WindsurfGatewayService) Forward(ctx context.Context, c *gin.Context, ac Content: contentJSON, ToolCallID: block.ToolUseID, }) + // tool_result 内部可能含 image 块;按规划策略,把它们提取出来归到当前 turn images + if extractedImgs, err := windsurfExtractImagesFromRaw(block.Content, fmt.Sprintf("messages[%d].content[%d].content", mi, bi)); err != nil { + s.writeClaudeError(c, http.StatusBadRequest, "invalid_request_error", err.Error()) + return nil, err + } else { + turnImages = append(turnImages, extractedImgs...) + } case "tool_use": hasToolHistory = true inputJSON, _ := json.Marshal(block.Input) @@ -117,6 +125,21 @@ func (s *WindsurfGatewayService) Forward(ctx context.Context, c *gin.Context, ac textParts = append(textParts, block.Text) case "thinking": // skip + case "image": + if block.Source == nil { + s.writeClaudeError(c, http.StatusBadRequest, "invalid_request_error", + fmt.Sprintf("messages[%d].content[%d].source is required for image blocks", mi, bi)) + return nil, fmt.Errorf("image block missing source") + } + if !strings.EqualFold(strings.TrimSpace(block.Source.Type), "base64") { + s.writeClaudeError(c, http.StatusBadRequest, "invalid_request_error", + fmt.Sprintf("messages[%d].content[%d].source.type must be \"base64\"", mi, bi)) + return nil, fmt.Errorf("unsupported image source type") + } + turnImages = append(turnImages, windsurf.CascadeImage{ + MimeType: block.Source.MediaType, + Base64Data: block.Source.Data, + }) default: if block.Text != "" { textParts = append(textParts, block.Text) @@ -130,8 +153,13 @@ func (s *WindsurfGatewayService) Forward(ctx context.Context, c *gin.Context, ac Role: m.Role, Content: contentJSON, ToolCalls: toolUseMsgs, + Images: turnImages, }) } else if len(toolResultMsgs) > 0 { + // tool_result 消息:图片挂到第一条 tool_result 上(保持对应关系大致正确) + if len(turnImages) > 0 && len(toolResultMsgs) > 0 { + toolResultMsgs[0].Images = turnImages + } for _, tr := range toolResultMsgs { anthropicMsgs = append(anthropicMsgs, tr) } @@ -141,6 +169,7 @@ func (s *WindsurfGatewayService) Forward(ctx context.Context, c *gin.Context, ac anthropicMsgs = append(anthropicMsgs, windsurf.AnthropicMessage{ Role: m.Role, Content: contentJSON, + Images: turnImages, }) } } @@ -165,10 +194,38 @@ func (s *WindsurfGatewayService) Forward(ctx context.Context, c *gin.Context, ac chatMessages = append(chatMessages, windsurf.ChatMessage{ Role: m.Role, Content: text, + Images: m.Images, }) } } + // 提取"当前 user turn"的图像作为 sidecar,发给 Cascade 的 images 字段。 + // 策略:最后一个 role=="user" 的 message 的 Images。 + var currentTurnImages []windsurf.CascadeImage + for i := len(chatMessages) - 1; i >= 0; i-- { + if chatMessages[i].Role == "user" && len(chatMessages[i].Images) > 0 { + currentTurnImages = chatMessages[i].Images + break + } + } + + // 本地确定性校验(fail-fast 返回 Anthropic 风格 400)。 + if len(currentTurnImages) > 0 { + if err := windsurf.ValidateCascadeImages(currentTurnImages, windsurf.DefaultCascadeImageValidationOptions()); err != nil { + s.writeClaudeError(c, http.StatusBadRequest, "invalid_request_error", err.Error()) + return nil, err + } + // 同步把 digests 写回 chat messages,便于后续指纹/日志 + for i := range chatMessages { + if len(chatMessages[i].Images) > 0 { + chatMessages[i].ImageDigests = windsurf.BuildImageDigests(chatMessages[i].Images) + } + } + reqLog.Info("windsurf_gateway.images", + zap.Int("current_turn_images", len(currentTurnImages)), + ) + } + chatReq := &WindsurfChatRequest{ AccountID: account.ID, Model: req.Model, @@ -176,6 +233,7 @@ func (s *WindsurfGatewayService) Forward(ctx context.Context, c *gin.Context, ac Stream: req.Stream, Tools: openAITools, ToolPreamble: toolPreamble, + Images: currentTurnImages, } upstreamStart := time.Now() @@ -551,13 +609,22 @@ type windsurfRequestTool struct { // ---- Helper functions (prefixed to avoid collision with windsurf_gateway_handler.go) ---- type windsurfContentBlock struct { - Type string `json:"type"` - Text string `json:"text,omitempty"` - ID string `json:"id,omitempty"` - Name string `json:"name,omitempty"` - Input interface{} `json:"input,omitempty"` - ToolUseID string `json:"tool_use_id,omitempty"` - Content json.RawMessage `json:"content,omitempty"` + Type string `json:"type"` + Text string `json:"text,omitempty"` + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Input interface{} `json:"input,omitempty"` + ToolUseID string `json:"tool_use_id,omitempty"` + Content json.RawMessage `json:"content,omitempty"` + // Source 来自 Anthropic image block:{type:"base64", media_type:"image/png", data:"..."} + Source *windsurfContentImageSource `json:"source,omitempty"` +} + +// windsurfContentImageSource 对应 Anthropic image content block 的 source 字段。 +type windsurfContentImageSource struct { + Type string `json:"type"` + MediaType string `json:"media_type"` + Data string `json:"data"` } func windsurfParseContentBlocks(raw json.RawMessage) []windsurfContentBlock { @@ -727,6 +794,41 @@ func windsurfExtractContentTextFromRaw(raw json.RawMessage) string { return string(raw) } +// windsurfExtractImagesFromRaw 从 tool_result 的 content 字段里提取 image 块, +// 返回可直接送进 Cascade 的 CascadeImage(未校验)。 +// pathLabel 用于生成错误消息(Anthropic 风格 error message)。 +func windsurfExtractImagesFromRaw(raw json.RawMessage, pathLabel string) ([]windsurf.CascadeImage, error) { + if len(raw) == 0 { + return nil, nil + } + // 纯字符串 content 没有图 + var s string + if json.Unmarshal(raw, &s) == nil { + return nil, nil + } + var blocks []windsurfContentBlock + if err := json.Unmarshal(raw, &blocks); err != nil { + return nil, nil // 不是 block 数组,按纯文本处理,没图 + } + var out []windsurf.CascadeImage + for i, b := range blocks { + if b.Type != "image" { + continue + } + if b.Source == nil { + return nil, fmt.Errorf("%s[%d].source is required for image blocks", pathLabel, i) + } + if !strings.EqualFold(strings.TrimSpace(b.Source.Type), "base64") { + return nil, fmt.Errorf("%s[%d].source.type must be \"base64\"", pathLabel, i) + } + out = append(out, windsurf.CascadeImage{ + MimeType: b.Source.MediaType, + Base64Data: b.Source.Data, + }) + } + return out, nil +} + func windsurfLogger(c *gin.Context, component string, fields ...zap.Field) *zap.Logger { l := logger.L().With(zap.String("component", component)) if c != nil { diff --git a/backend/internal/service/windsurf_services.go b/backend/internal/service/windsurf_services.go index c622e8b6..4f77e667 100644 --- a/backend/internal/service/windsurf_services.go +++ b/backend/internal/service/windsurf_services.go @@ -88,6 +88,15 @@ func (s *WindsurfLSService) Status() *windsurf.LSConnectorStatus { return s.connector.Status() } +// Stop terminates LS resources owned by this service (embedded LS processes, +// docker discovery goroutines). Safe to call on a nil receiver. +func (s *WindsurfLSService) Stop() { + if s == nil || s.connector == nil { + return + } + s.connector.Shutdown() +} + type WindsurfAuthService struct { cfg config.WindsurfConfig authClient *windsurf.AuthClient diff --git a/frontend/src/components/account/WindsurfLoginModal.vue b/frontend/src/components/account/WindsurfLoginModal.vue index 78d44049..6a9bf9a1 100644 --- a/frontend/src/components/account/WindsurfLoginModal.vue +++ b/frontend/src/components/account/WindsurfLoginModal.vue @@ -109,14 +109,10 @@
- +
@@ -210,7 +206,7 @@ const batchText = ref('') const commonOpts = reactive({ proxy_id: null as number | null, - group_ids: [] as number[], + group_id: null as number | null, concurrency: 1, probe_after: true }) @@ -218,7 +214,18 @@ const commonOpts = reactive({ const batchResults = ref([]) const batchSuccessCount = computed(() => batchResults.value.filter(r => r.success).length) +function formatGroupName(name: string) { + return name === 'default' ? t('admin.accounts.defaultGroup') : name +} + +function selectedGroupIds() { + return commonOpts.group_id === null ? undefined : [commonOpts.group_id] +} + function handleClose() { + if (batchResults.value.length > 0) { + emit('created') + } emit('close') singleForm.email = '' singleForm.password = '' @@ -235,7 +242,6 @@ async function handleSubmit() { } else { await handleBatchLogin() } - emit('created') } catch (e: any) { appStore.showError(e?.response?.data?.message || e?.message || t('admin.windsurf.loginFailed')) } finally { @@ -249,13 +255,14 @@ async function handleSingleLogin() { password: singleForm.password, name: singleForm.name || singleForm.email, proxy_id: commonOpts.proxy_id, - group_ids: commonOpts.group_ids.length > 0 ? commonOpts.group_ids : undefined, + group_ids: selectedGroupIds(), concurrency: commonOpts.concurrency, probe_after: commonOpts.probe_after }) appStore.showSuccess( `${t('admin.windsurf.loginSuccess')} — ${resp.email} (${resp.tier})` ) + emit('created') handleClose() } @@ -265,12 +272,15 @@ async function handleBatchLogin() { .map(l => l.trim()) .filter(l => l.length > 0 && l.includes('----')) - if (items.length === 0) return + if (items.length === 0) { + appStore.showError(t('admin.windsurf.batchItemsRequired')) + return + } const resp = await adminAPI.windsurf.batchLogin({ items, proxy_id: commonOpts.proxy_id, - group_ids: commonOpts.group_ids.length > 0 ? commonOpts.group_ids : undefined, + group_ids: selectedGroupIds(), concurrency: commonOpts.concurrency, probe_after: commonOpts.probe_after }) diff --git a/frontend/src/i18n/__tests__/windsurfLocales.spec.ts b/frontend/src/i18n/__tests__/windsurfLocales.spec.ts new file mode 100644 index 00000000..e35d6040 --- /dev/null +++ b/frontend/src/i18n/__tests__/windsurfLocales.spec.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest' + +import en from '../locales/en' +import zh from '../locales/zh' + +describe('windsurf locale messages', () => { + it('escapes email placeholders so vue-i18n does not parse them as linked messages', () => { + expect(zh.admin.windsurf.batchItemsPlaceholder).toContain("user1{'@'}example.com----password1") + expect(zh.admin.windsurf.batchItemsPlaceholder).not.toContain('user1@example.com') + expect(zh.admin.windsurf.batchItemsRequired).toContain('email----password') + expect(zh.admin.accounts.groups).toBe('分组') + expect(zh.admin.accounts.defaultGroup).toBe('默认分组') + + expect(en.admin.windsurf.batchItemsPlaceholder).toContain("user1{'@'}example.com----password1") + expect(en.admin.windsurf.batchItemsPlaceholder).not.toContain('user1@example.com') + expect(en.admin.windsurf.batchItemsRequired).toContain('email----password') + expect(en.admin.accounts.groups).toBe('Groups') + expect(en.admin.accounts.defaultGroup).toBe('Default Group') + }) +}) diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 92661f29..a675addd 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -2513,6 +2513,8 @@ export default { allTypes: 'All Types', allStatus: 'All Status', allGroups: 'All Groups', + groups: 'Groups', + defaultGroup: 'Default Group', ungroupedGroup: 'Ungrouped', oauthType: 'OAuth', setupToken: 'Setup Token', @@ -3379,6 +3381,21 @@ export default { imageTestMode: 'Mode: Image generation test', imagePreview: 'Generated images:', imageReceived: 'Received test image #{count}', + customPromptLabel: 'Custom prompt (optional)', + customPromptPlaceholder: 'Leave empty to use the default prompt, or enter custom content here', + customPromptHint: 'Customize the test message to avoid fixed prompts being flagged as test traffic.', + geminiImagePromptLabel: 'Image prompt', + geminiImagePromptPlaceholder: 'Example: Generate an orange cat astronaut sticker in pixel-art style on a solid background.', + geminiImagePromptDefault: 'Generate a cute orange cat astronaut sticker on a clean pastel background.', + geminiImageTestHint: 'When a Gemini image model is selected, this test sends a real image-generation request and previews the returned image below.', + geminiImageTestMode: 'Mode: Gemini image generation test', + geminiImagePreview: 'Generated images:', + geminiImageReceived: 'Received test image #{count}', + soraUpstreamBaseUrlHint: 'Upstream Sora service URL (another Sub2API instance or compatible API)', + soraTestHint: 'Sora test runs connectivity and capability checks (/backend/me, subscription, Sora2 invite and remaining quota).', + soraTestTarget: 'Target: Sora account capability', + soraTestMode: 'Mode: Connectivity + Capability checks', + soraTestingFlow: 'Running Sora connectivity and capability checks...', // Stats Modal viewStats: 'View Stats', usageStatistics: 'Usage Statistics', @@ -5646,7 +5663,8 @@ export default { password: 'Password', batchItems: 'Batch Accounts', batchItemsHint: 'One per line, format: email----password', - batchItemsPlaceholder: 'user1@example.com----password1\nuser2@example.com----password2', + batchItemsPlaceholder: "user1{'@'}example.com----password1\nuser2{'@'}example.com----password2", + batchItemsRequired: 'Enter at least one valid account line in the format: email----password', probeAfterLogin: 'Probe after login', loginSuccess: 'Windsurf login successful', loginFailed: 'Windsurf login failed', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index f6824ed4..962638ab 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -2592,6 +2592,8 @@ export default { allTypes: '全部类型', allStatus: '全部状态', allGroups: '全部分组', + groups: '分组', + defaultGroup: '默认分组', ungroupedGroup: '未分配分组', oauthType: 'OAuth', // Schedulable toggle @@ -3507,6 +3509,21 @@ export default { imageTestMode: '模式:生图测试', imagePreview: '生成结果:', imageReceived: '已收到第 {count} 张测试图片', + customPromptLabel: '自定义提示词(可选)', + customPromptPlaceholder: '留空则使用默认提示词,填写后将发送自定义内容', + customPromptHint: '自定义测试内容,避免使用固定提示词被识别为测试流量。', + geminiImagePromptLabel: '生图提示词', + geminiImagePromptPlaceholder: '例如:生成一只戴宇航员头盔的橘猫,像素插画风格,纯色背景。', + geminiImagePromptDefault: 'Generate a cute orange cat astronaut sticker on a clean pastel background.', + geminiImageTestHint: '选择 Gemini 图片模型后,这里会直接发起生图测试,并在下方展示返回图片。', + geminiImageTestMode: '模式:Gemini 生图测试', + geminiImagePreview: '生成结果:', + geminiImageReceived: '已收到第 {count} 张测试图片', + soraUpstreamBaseUrlHint: '上游 Sora 服务地址(另一个 Sub2API 实例或兼容 API)', + soraTestHint: 'Sora 测试将执行连通性与能力检测(/backend/me、订阅信息、Sora2 邀请码与剩余额度)。', + soraTestTarget: '检测目标:Sora 账号能力', + soraTestMode: '模式:连通性 + 能力探测', + soraTestingFlow: '执行 Sora 连通性与能力检测...', // Stats Modal viewStats: '查看统计', usageStatistics: '使用统计', @@ -5807,7 +5824,8 @@ export default { password: '密码', batchItems: '批量账号', batchItemsHint: '每行一个,格式:email----password', - batchItemsPlaceholder: 'user1@example.com----password1\nuser2@example.com----password2', + batchItemsPlaceholder: "user1{'@'}example.com----password1\nuser2{'@'}example.com----password2", + batchItemsRequired: '请输入至少一行有效账号,格式:email----password', probeAfterLogin: '登录后自动探测', loginSuccess: 'Windsurf 登录成功', loginFailed: 'Windsurf 登录失败',