Files
CLIProxyAPI/internal/auth/devin/devin_auth_test.go
sususu f94752762b feat(devin): add Devin/Cognition provider integration and CLI OAuth
Implement the full Devin/Cognition Connect-RPC provider support across all CPA endpoints (/v1/chat/completions, /v1/messages, /v1/responses), complete with binary protobuf wire framing, streaming tools/arguments delta handling, thinking/reasoning replay, and CLI OAuth authentication.

Key highlights:
- Wire Protocol & Streaming:
  * Implemented Connect-RPC uncompressed 5-byte framing (0x00 + 4-byte length + protobuf) for ApiServerService/GetChatMessage.
  * Implemented Devin protobuf encoder/decoder in internal/runtime/executor/helps/devin_wire.go, including ClientMetadata, prompts, tools, completion_config, and multimodal image handling (Prompt Field #10).
  * Stream frame consumption via interactions protocol, correctly mapping arguments_delta and tracking multiple sequential tool calls (currentToolCallActive).
  * Streaming thought summary and sealed.v1 signature deltas targeting the thinking step.

- Model Registration & Thinking Clamping:
  * Registered static fallback models in model_definitions.go (swe-2, claude-fable-5-1, gpt-6-astra, swe-1-7-lightning, glm-5-2, glm-5-3).
  * Configured ThinkingSupport with discrete levels per model family.
  * Implemented CPA-standard nearest-neighbor clamping for thinking levels (minimal/low -> medium, xhigh -> max for swe-2).
  * Mapped thinking effort to Devin upstream model UID (e.g. swe-2-medium, swe-2-high, swe-2-max).

- Sensitive Words & System Prompt Sanitization:
  * Added devin.sensitive-words configuration in internal/config/config_types.go and config.go, matching Antigravity conventions.
  * Supported zero-width space (\u200b) obfuscation in prompts, tools, and system instructions via SensitiveWordMatcher.
  * Stripped Claude Code billing headers (x-anthropic-billing-header:) and CLI identity signatures from system instructions and tool descriptions to avoid upstream content filter rejections.

- Signature Compatibility:
  * Added SignatureProviderSWE = "swe" recognizing sealed.v1.* reasoning signatures in internal/signature/provider_compatibility.go.
  * Propagated reasoning.encrypted_content on Responses API and thinking.signature on Messages API.

- Authentication:
  * Implemented Devin PKCE OAuth flow with loopback callback server and headless manual token/code paste (--no-browser).
  * Registered Devin authenticator in SDK and CLI (-devin-login flag).
  * Integrated with management OAuth session endpoints and credentials manager.
2026-09-13 23:34:43 +08:00

196 lines
5.0 KiB
Go

package devin
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
)
func TestPKCEGeneration(t *testing.T) {
pkce, err := GeneratePKCECodes()
if err != nil {
t.Fatalf("GeneratePKCECodes failed: %v", err)
}
if len(pkce.CodeVerifier) < 43 {
t.Errorf("code_verifier too short: %d", len(pkce.CodeVerifier))
}
if len(pkce.CodeChallenge) == 0 {
t.Errorf("code_challenge is empty")
}
// Re-generation produces unique values
pkce2, _ := GeneratePKCECodes()
if pkce.CodeVerifier == pkce2.CodeVerifier {
t.Error("expected randomly unique code verifiers")
}
}
func TestFormatSessionToken(t *testing.T) {
tests := []struct {
input string
want string
}{
{
input: "devin-session-token$eyJ123",
want: "devin-session-token$eyJ123",
},
{
input: "eyJ123.456.789",
want: "devin-session-token$eyJ123.456.789",
},
{
input: "custom-token-xyz",
want: "custom-token-xyz",
},
}
for _, tt := range tests {
got := FormatSessionToken(tt.input)
if got != tt.want {
t.Errorf("FormatSessionToken(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
func TestBuildAuthorizationURL(t *testing.T) {
svc := NewDevinAuthService(nil)
u := svc.BuildAuthorizationURL("http://127.0.0.1:1234/callback", "test-challenge", "state-abc")
if !strings.HasPrefix(u, "https://app.devin.ai/auth/cli/continue?") {
t.Fatalf("unexpected url prefix: %s", u)
}
parsed, err := url.Parse(u)
if err != nil {
t.Fatalf("failed to parse auth url: %v", err)
}
q := parsed.Query()
if q.Get("redirect_uri") != "http://127.0.0.1:1234/callback" {
t.Errorf("redirect_uri = %q", q.Get("redirect_uri"))
}
if q.Get("code_challenge") != "test-challenge" {
t.Errorf("code_challenge = %q", q.Get("code_challenge"))
}
if q.Get("code_challenge_method") != "S256" {
t.Errorf("code_challenge_method = %q", q.Get("code_challenge_method"))
}
if q.Get("state") != "state-abc" {
t.Errorf("state = %q", q.Get("state"))
}
}
func TestExchangeCodeForTokenAndFetchSelf(t *testing.T) {
// Mock devin API backend
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/auth/cli/token":
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
var body map[string]string
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
if body["code"] != "valid-code" || body["code_verifier"] != "valid-verifier" {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(`{"error":"invalid_grant"}`))
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"token":"eyJtest-jwt-token"}`))
case "/v3/self":
auth := r.Header.Get("Authorization")
if !strings.HasPrefix(auth, "Bearer ") {
w.WriteHeader(http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{
"principal_type": "windsurf_session",
"user_id": "user-test-123",
"user_name": "devin-user-abc",
"org_id": "org-test-456"
}`))
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer mockServer.Close()
svc := NewDevinAuthService(mockServer.Client())
svc.apiBaseURL = mockServer.URL
ctx := context.Background()
// 1. Exchange code
token, err := svc.ExchangeCodeForToken(ctx, "valid-code", "valid-verifier")
if err != nil {
t.Fatalf("ExchangeCodeForToken failed: %v", err)
}
if token != "eyJtest-jwt-token" {
t.Errorf("token = %q, want eyJtest-jwt-token", token)
}
// 2. Fetch self profile
userName, userID, orgID, errSelf := svc.FetchSelfProfile(ctx, "devin-session-token$"+token)
if errSelf != nil {
t.Fatalf("FetchSelfProfile failed: %v", errSelf)
}
if userName != "devin-user-abc" {
t.Errorf("userName = %q, want devin-user-abc", userName)
}
if userID != "user-test-123" {
t.Errorf("userID = %q, want user-test-123", userID)
}
if orgID != "org-test-456" {
t.Errorf("orgID = %q, want org-test-456", orgID)
}
}
func TestOAuthServerCallback(t *testing.T) {
server := NewOAuthServer(0)
port, err := server.Start()
if err != nil {
t.Fatalf("OAuthServer.Start() failed: %v", err)
}
defer func() {
_ = server.Stop(context.Background())
}()
// Simulate browser redirect to /callback?code=mock-code&state=mock-state
client := &http.Client{}
callbackURL := fmt.Sprintf("http://127.0.0.1:%d/callback?code=mock-code&state=mock-state", port)
resp, errGet := client.Get(callbackURL)
if errGet != nil {
t.Fatalf("GET /callback failed: %v", errGet)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("status = %d, want 200", resp.StatusCode)
}
res, errWait := server.WaitForCallback(2 * time.Second)
if errWait != nil {
t.Fatalf("WaitForCallback failed: %v", errWait)
}
if res.Code != "mock-code" {
t.Errorf("res.Code = %q, want mock-code", res.Code)
}
if res.State != "mock-state" {
t.Errorf("res.State = %q, want mock-state", res.State)
}
}