fix(devin): align wire protocol, harden streaming, and resolve multi-turn tool/signature parity

- Wire parity: align Connect-RPC Sentry-Trace, User-Agent suppression, float32 double pattern, and dynamic 732-char hex device fingerprint
- Session ordinal & cache: implement process-scoped Field 15.2 with bounded LRU (5000 entries) and Field 15.4=14 user boundary; prioritize stable session_id over previous_interaction_id to preserve prompt caching
- Streaming robustness: unblock hung TCP reads on client cancellation via context watcher; accurately propagate stream read errors and trailer errors instead of swallowing truncated frames
- Thought signature & reasoning: emit raw delta signatures directly in active thought steps; eliminate redundant tail base64 re-encoding; ensure 1:1 assistant signature and thinking alignment across multi-turn history
- Tool call de-multiplexing: route parallel tool calls by tc.Index in both streaming step events and non-streaming aggregations
- Security & transport: escape OAuth callback error HTML against reflected XSS, enforce strict state validation, and isolate Devin HTTP transport with tr.Clone()
This commit is contained in:
sususu
2026-09-13 16:09:13 +08:00
committed by sususu98
parent ca159b303d
commit f1f5506c0b
12 changed files with 496 additions and 119 deletions

View File

@@ -6,6 +6,7 @@ import (
"encoding/json"
"errors"
"fmt"
"html"
"io"
"net"
"net/http"
@@ -289,7 +290,7 @@ func (s *OAuthServer) handleCallback(w http.ResponseWriter, r *http.Request) {
errMsg = "missing authorization code"
}
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(fmt.Sprintf(loginFailureHTML, errMsg)))
_, _ = w.Write([]byte(fmt.Sprintf(loginFailureHTML, html.EscapeString(errMsg))))
select {
case s.resultChan <- &OAuthResult{Error: errMsg}:
default:

View File

@@ -2,6 +2,7 @@ package devin
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"errors"
@@ -40,9 +41,15 @@ type DevinUserStatus struct {
PlanEnd time.Time `json:"plan_end,omitempty"`
}
// GenerateDeviceFingerprint generates a stable 732-character hex device fingerprint.
// GenerateDeviceFingerprint generates a 732-character hex device fingerprint.
// When seed is empty, it generates a cryptographically random 732-character hex string per request.
// When seed is provided, it derives a deterministic 732-character hex fingerprint.
func GenerateDeviceFingerprint(seed string) string {
if seed == "" {
var b [devinFingerprintHexLen / 2]byte
if _, err := rand.Read(b[:]); err == nil {
return hex.EncodeToString(b[:])
}
seed = uuid.New().String()
}
var sb strings.Builder
@@ -346,6 +353,8 @@ func (s *DevinAuthService) FetchUserStatus(ctx context.Context, sessionToken, de
req.Header.Set("Authorization", fmt.Sprintf("Basic %s-%s", sessionToken, sessionToken))
req.Header.Set("Connect-Protocol-Version", "1")
req.Header.Set("Content-Type", "application/proto")
req.Header.Set("Accept", "*/*")
req.Header["User-Agent"] = []string{""}
resp, errDo := s.client.Do(req)
if errDo != nil {

View File

@@ -193,6 +193,9 @@ func TestFetchUserStatusLiveMock(t *testing.T) {
}
func TestLiveDevinUserStatus(t *testing.T) {
if os.Getenv("CPA_LIVE_TEST") != "true" {
t.Skip("skipping live test; set CPA_LIVE_TEST=true to run")
}
authPath := "../../../auths/devin-cli.json"
data, err := os.ReadFile(authPath)
if err != nil {

View File

@@ -76,6 +76,24 @@ func (cache *BoundedLRU[K, V]) Get(key K) (V, bool) {
return zero, false
}
func (cache *BoundedLRU[K, V]) Delete(key K) bool {
cache.mu.Lock()
element, ok := cache.entries[key]
if !ok {
cache.mu.Unlock()
return false
}
entry := element.Value.(boundedLRUEntry[K, V])
delete(cache.entries, key)
cache.order.Remove(element)
cache.mu.Unlock()
if cache.onEvict != nil {
cache.onEvict(entry.key, entry.value)
}
return true
}
func (cache *BoundedLRU[K, V]) Len() int {
cache.mu.Lock()
defer cache.mu.Unlock()

View File

@@ -7,7 +7,7 @@
"owned_by": "cognition",
"display_name": "SWE-2",
"context_length": 262000,
"max_completion_tokens": 64000,
"max_completion_tokens": 128000,
"supportedInputModalities": [
"text",
"image"
@@ -30,7 +30,7 @@
"owned_by": "cognition",
"display_name": "SWE-1.7",
"context_length": 262000,
"max_completion_tokens": 64000,
"max_completion_tokens": 128000,
"supportedInputModalities": [
"text",
"image"
@@ -52,7 +52,7 @@
"owned_by": "cognition",
"display_name": "SWE-1.6",
"context_length": 200000,
"max_completion_tokens": 64000,
"max_completion_tokens": 128000,
"supportedInputModalities": [
"text"
],
@@ -67,7 +67,7 @@
"owned_by": "zhipu",
"display_name": "GLM-5.2",
"context_length": 200000,
"max_completion_tokens": 64000,
"max_completion_tokens": 128000,
"supportedInputModalities": [
"text"
],
@@ -88,7 +88,7 @@
"owned_by": "zhipu",
"display_name": "GLM-5.3",
"context_length": 1048576,
"max_completion_tokens": 64000,
"max_completion_tokens": 128000,
"supportedInputModalities": [
"text"
],
@@ -110,7 +110,7 @@
"owned_by": "zhipu",
"display_name": "GLM-5.3 Flash",
"context_length": 1000000,
"max_completion_tokens": 64000,
"max_completion_tokens": 128000,
"supportedInputModalities": [
"text"
],
@@ -132,7 +132,7 @@
"owned_by": "deepseek",
"display_name": "DeepSeek V4 Flash",
"context_length": 1048576,
"max_completion_tokens": 64000,
"max_completion_tokens": 128000,
"supportedInputModalities": [
"text"
],
@@ -153,7 +153,7 @@
"owned_by": "deepseek",
"display_name": "DeepSeek V4.1 Flash",
"context_length": 1048576,
"max_completion_tokens": 64000,
"max_completion_tokens": 128000,
"supportedInputModalities": [
"text"
],
@@ -174,7 +174,7 @@
"owned_by": "deepseek",
"display_name": "DeepSeek V4 Pro",
"context_length": 1048576,
"max_completion_tokens": 64000,
"max_completion_tokens": 128000,
"supportedInputModalities": [
"text"
],
@@ -195,7 +195,7 @@
"owned_by": "google",
"display_name": "Gemini 3.8 Flash",
"context_length": 1048576,
"max_completion_tokens": 65536,
"max_completion_tokens": 128000,
"supportedInputModalities": [
"text",
"image"
@@ -218,7 +218,7 @@
"owned_by": "google",
"display_name": "Gemini 3.7 Flash",
"context_length": 1048576,
"max_completion_tokens": 65536,
"max_completion_tokens": 128000,
"supportedInputModalities": [
"text",
"image"
@@ -240,7 +240,7 @@
"owned_by": "google",
"display_name": "Gemini 3.6 Flash",
"context_length": 1048576,
"max_completion_tokens": 65536,
"max_completion_tokens": 128000,
"supportedInputModalities": [
"text",
"image"
@@ -264,7 +264,7 @@
"owned_by": "google",
"display_name": "Gemini 3.5 Flash",
"context_length": 1048576,
"max_completion_tokens": 65536,
"max_completion_tokens": 128000,
"supportedInputModalities": [
"text",
"image"
@@ -288,7 +288,7 @@
"owned_by": "google",
"display_name": "Gemini 3 Flash",
"context_length": 1048576,
"max_completion_tokens": 65536,
"max_completion_tokens": 128000,
"supportedInputModalities": [
"text",
"image"
@@ -312,7 +312,7 @@
"owned_by": "anthropic",
"display_name": "Claude Fable 5.1",
"context_length": 1000000,
"max_completion_tokens": 64000,
"max_completion_tokens": 128000,
"supportedInputModalities": [
"text",
"image"
@@ -337,7 +337,7 @@
"owned_by": "anthropic",
"display_name": "Claude 5 Fable",
"context_length": 1000000,
"max_completion_tokens": 64000,
"max_completion_tokens": 128000,
"supportedInputModalities": [
"text",
"image"
@@ -362,7 +362,7 @@
"owned_by": "anthropic",
"display_name": "Claude Sonnet 5",
"context_length": 1000000,
"max_completion_tokens": 64000,
"max_completion_tokens": 128000,
"supportedInputModalities": [
"text",
"image"
@@ -387,7 +387,7 @@
"owned_by": "anthropic",
"display_name": "Claude Opus 5",
"context_length": 1000000,
"max_completion_tokens": 64000,
"max_completion_tokens": 128000,
"supportedInputModalities": [
"text",
"image"
@@ -412,7 +412,7 @@
"owned_by": "anthropic",
"display_name": "Claude Opus 4.8",
"context_length": 1000000,
"max_completion_tokens": 64000,
"max_completion_tokens": 128000,
"supportedInputModalities": [
"text",
"image"
@@ -437,7 +437,7 @@
"owned_by": "anthropic",
"display_name": "Claude Opus 4.7",
"context_length": 1000000,
"max_completion_tokens": 64000,
"max_completion_tokens": 128000,
"supportedInputModalities": [
"text",
"image"
@@ -462,7 +462,7 @@
"owned_by": "anthropic",
"display_name": "Claude Opus 4.6",
"context_length": 200000,
"max_completion_tokens": 64000,
"max_completion_tokens": 128000,
"supportedInputModalities": [
"text",
"image"
@@ -478,7 +478,7 @@
"owned_by": "anthropic",
"display_name": "Claude Sonnet 4.6",
"context_length": 200000,
"max_completion_tokens": 64000,
"max_completion_tokens": 128000,
"supportedInputModalities": [
"text",
"image"
@@ -494,7 +494,7 @@
"owned_by": "anthropic",
"display_name": "Claude Haiku 4.5",
"context_length": 200000,
"max_completion_tokens": 64000,
"max_completion_tokens": 128000,
"supportedInputModalities": [
"text",
"image"
@@ -510,7 +510,7 @@
"owned_by": "anthropic",
"display_name": "Claude Sonnet 4.5",
"context_length": 200000,
"max_completion_tokens": 64000,
"max_completion_tokens": 128000,
"supportedInputModalities": [
"text",
"image"
@@ -526,7 +526,7 @@
"owned_by": "openai",
"display_name": "GPT-6 Astra",
"context_length": 1000000,
"max_completion_tokens": 64000,
"max_completion_tokens": 128000,
"supportedInputModalities": [
"text",
"image"
@@ -551,7 +551,7 @@
"owned_by": "openai",
"display_name": "GPT-5.6 Sol",
"context_length": 1000000,
"max_completion_tokens": 64000,
"max_completion_tokens": 128000,
"supportedInputModalities": [
"text",
"image"
@@ -577,7 +577,7 @@
"owned_by": "openai",
"display_name": "GPT-5.6 Terra",
"context_length": 1000000,
"max_completion_tokens": 64000,
"max_completion_tokens": 128000,
"supportedInputModalities": [
"text",
"image"
@@ -603,7 +603,7 @@
"owned_by": "openai",
"display_name": "GPT-5.6 Luna",
"context_length": 1000000,
"max_completion_tokens": 64000,
"max_completion_tokens": 128000,
"supportedInputModalities": [
"text",
"image"
@@ -629,7 +629,7 @@
"owned_by": "openai",
"display_name": "GPT-5.5",
"context_length": 272000,
"max_completion_tokens": 64000,
"max_completion_tokens": 128000,
"supportedInputModalities": [
"text",
"image"
@@ -654,7 +654,7 @@
"owned_by": "openai",
"display_name": "GPT-5.4",
"context_length": 272000,
"max_completion_tokens": 64000,
"max_completion_tokens": 128000,
"supportedInputModalities": [
"text",
"image"
@@ -679,7 +679,7 @@
"owned_by": "openai",
"display_name": "GPT-5.4 Mini",
"context_length": 400000,
"max_completion_tokens": 64000,
"max_completion_tokens": 128000,
"supportedInputModalities": [
"text",
"image"
@@ -703,7 +703,7 @@
"owned_by": "openai",
"display_name": "GPT-5.3-Codex",
"context_length": 400000,
"max_completion_tokens": 64000,
"max_completion_tokens": 128000,
"supportedInputModalities": [
"text",
"image"
@@ -727,7 +727,7 @@
"owned_by": "openai",
"display_name": "GPT-4.1",
"context_length": 1047576,
"max_completion_tokens": 64000,
"max_completion_tokens": 128000,
"supportedInputModalities": [
"text",
"image"
@@ -790,7 +790,7 @@
"owned_by": "moonshot",
"display_name": "Kimi K3",
"context_length": 1048576,
"max_completion_tokens": 64000,
"max_completion_tokens": 128000,
"supportedInputModalities": [
"text"
],
@@ -812,7 +812,7 @@
"owned_by": "moonshot",
"display_name": "Kimi K2.7",
"context_length": 262144,
"max_completion_tokens": 64000,
"max_completion_tokens": 128000,
"supportedInputModalities": [
"text"
],
@@ -827,7 +827,7 @@
"owned_by": "moonshot",
"display_name": "Kimi K2.6",
"context_length": 262144,
"max_completion_tokens": 64000,
"max_completion_tokens": 128000,
"supportedInputModalities": [
"text"
],
@@ -842,7 +842,7 @@
"owned_by": "nvidia",
"display_name": "Nemotron 3 Ultra",
"context_length": 1000000,
"max_completion_tokens": 64000,
"max_completion_tokens": 128000,
"supportedInputModalities": [
"text"
],

View File

@@ -8,6 +8,7 @@ import (
"errors"
"fmt"
"io"
"net"
"net/http"
"strconv"
"strings"
@@ -97,7 +98,15 @@ func (e *DevinExecutor) PrepareRequest(req *http.Request, auth *cliproxyauth.Aut
}
req.Header.Set("Content-Type", "application/connect+proto")
req.Header.Set("Connect-Protocol-Version", "1")
req.Header.Set("User-Agent", "connect-go/1.19.1 (go1.25.0)")
req.Header.Set("Accept", "*/*")
// Native devin-cli attaches Sentry-Trace only to chat streaming, omitting it on unary status/catalog calls.
isUnary := req.URL != nil && (strings.Contains(req.URL.Path, "GetUserStatus") || strings.Contains(req.URL.Path, "GetCliModelConfigs") || strings.Contains(req.URL.Path, "SeatManagementService"))
if !isUnary && req.Header.Get("Sentry-Trace") == "" {
req.Header.Set("Sentry-Trace", helps.GenerateDevinSentryTrace())
}
// Native devin-cli suppresses User-Agent header entirely on the wire.
// In Go net/http, setting the header slice to empty string suppresses default Go-http-client injection.
req.Header["User-Agent"] = []string{""}
var attrs map[string]string
if auth != nil {
@@ -119,7 +128,7 @@ func (e *DevinExecutor) HttpRequest(ctx context.Context, auth *cliproxyauth.Auth
if err := e.PrepareRequest(httpReq, auth); err != nil {
return nil, err
}
httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
httpClient := helps.NewDevinHTTPClient(ctx, e.cfg, auth, 0)
return httpClient.Do(httpReq)
}
@@ -147,7 +156,7 @@ func (e *DevinExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (*
return auth, nil
}
httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
httpClient := helps.NewDevinHTTPClient(ctx, e.cfg, auth, 0)
authService := devinauth.NewDevinAuthService(httpClient)
if baseURL := strings.TrimSpace(auth.Attributes["base_url"]); baseURL != "" {
authService.SetServerBaseURL(baseURL)
@@ -276,7 +285,7 @@ func (e *DevinExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re
AuthValue: authValue,
})
httpClient := reporter.TrackHTTPClient(helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0))
httpClient := reporter.TrackHTTPClient(helps.NewDevinHTTPClient(ctx, e.cfg, auth, 0))
httpResp, errDo := httpClient.Do(httpReq)
if errDo != nil {
helps.RecordAPIResponseError(ctx, e.cfg, errDo)
@@ -288,7 +297,7 @@ func (e *DevinExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re
helps.RecordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone())
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
errData, _ := io.ReadAll(httpResp.Body)
errData, _ := io.ReadAll(io.LimitReader(httpResp.Body, 1<<20))
helps.AppendAPIResponseChunk(ctx, e.cfg, errData)
return resp, newDevinStatusError(httpResp.StatusCode, httpResp.Header, errData)
}
@@ -340,7 +349,7 @@ func (e *DevinExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au
AuthValue: authValue,
})
httpClient := reporter.TrackHTTPClient(helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0))
httpClient := reporter.TrackHTTPClient(helps.NewDevinHTTPClient(ctx, e.cfg, auth, 0))
httpResp, errDo := httpClient.Do(httpReq)
if errDo != nil {
helps.RecordAPIResponseError(ctx, e.cfg, errDo)
@@ -348,10 +357,10 @@ func (e *DevinExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au
}
helps.RecordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone())
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
errData, _ := io.ReadAll(httpResp.Body)
errData, _ := io.ReadAll(io.LimitReader(httpResp.Body, 1<<20))
_ = httpResp.Body.Close()
helps.AppendAPIResponseChunk(ctx, e.cfg, errData)
return nil, statusErr{code: httpResp.StatusCode, msg: string(errData)}
return nil, newDevinStatusError(httpResp.StatusCode, httpResp.Header, errData)
}
out := make(chan cliproxyexecutor.StreamChunk)
@@ -359,6 +368,11 @@ func (e *DevinExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au
streamCtx, cancelStream := context.WithCancel(ctx)
go func() {
<-streamCtx.Done()
_ = httpResp.Body.Close()
}()
go func() {
defer close(out)
defer cancelStream()
@@ -382,9 +396,6 @@ func (e *DevinExecutor) prepareDevinHTTPRequest(ctx context.Context, auth *clipr
if apiKey == "" {
return nil, "", nil, fmt.Errorf("devin credentials missing: api_key or session_token required")
}
if deviceSeed == "" {
deviceSeed = apiKey
}
payload := req.Payload
isInteractionsSource := opts.SourceFormat == "" || opts.SourceFormat == sdktranslator.FormatInteractions
@@ -394,7 +405,8 @@ func (e *DevinExecutor) prepareDevinHTTPRequest(ctx context.Context, auth *clipr
systemPrompt, prompts, tools, temp, maxTokens, sessionID, cascadeID, thinkingLevel, budgetTokens := parseInteractionsPayload(payload, opts.OriginalRequest)
sessionID, cascadeID = resolveDevinSessionAndCascadeIDs(ctx, sessionID, cascadeID, opts)
if modelInfo := registry.LookupModelInfo(req.Model, "devin"); modelInfo != nil && modelInfo.MaxCompletionTokens > 0 {
baseModel := thinking.ParseSuffix(req.Model).ModelName
if modelInfo := registry.LookupModelInfo(baseModel, "devin"); modelInfo != nil && modelInfo.MaxCompletionTokens > 0 {
if maxTokens > modelInfo.MaxCompletionTokens || maxTokens <= 0 {
maxTokens = modelInfo.MaxCompletionTokens
}
@@ -469,7 +481,7 @@ func (e *DevinExecutor) streamDevinFrames(
stepIndex := 0
thoughtStarted := false
contentStarted := false
currentToolCallActive := false
toolCallSteps := make(map[int]int) // maps tc.Index -> stepIndex
thinkingBuf := &helps.UTF8SplitBuffer{}
contentBuf := &helps.UTF8SplitBuffer{}
var accumulatedThinking strings.Builder
@@ -533,15 +545,17 @@ func (e *DevinExecutor) streamDevinFrames(
}
thoughtStepIndex := -1
var streamErr error
// 2. Consume streaming Connect-proto frames
for {
flag, payload, errRead := helps.ReadConnectFrame(body)
if errRead != nil {
if errors.Is(errRead, io.EOF) || errors.Is(errRead, io.ErrUnexpectedEOF) {
if errors.Is(errRead, io.EOF) || errors.Is(errRead, net.ErrClosed) || ctx.Err() != nil {
break
}
log.Debugf("devin executor: read connect frame error: %v", errRead)
streamErr = errRead
log.Warnf("devin executor: stream read error: %v", errRead)
break
}
streamFrameCount++
@@ -551,6 +565,7 @@ func (e *DevinExecutor) streamDevinFrames(
code, errTrailer := helps.ParseDevinTrailerError(payload)
if errTrailer != nil {
log.Warnf("devin executor: trailer error (%d): %v", code, errTrailer)
helps.RecordAPIResponseError(ctx, e.cfg, errTrailer)
failedEvent, _ := sjson.SetBytes([]byte(`{"event_type":"response.failed","error":{"message":"","code":""}}`), "error.message", errTrailer.Error())
failedEvent, _ = sjson.SetBytes(failedEvent, "error.code", fmt.Sprintf("%d", code))
_ = emitInteractionsEvent(failedEvent)
@@ -600,14 +615,23 @@ func (e *DevinExecutor) streamDevinFrames(
// Emit thinking signature delta targeting the thought step
if len(frameRes.DeltaSignature) > 0 {
targetIdx := thoughtStepIndex
if targetIdx < 0 {
targetIdx = 0
if !thoughtStarted {
thoughtStepIndex = stepIndex
startEvent, _ := sjson.SetBytes([]byte(`{"event_type":"step.start","index":0,"step":{"type":"thought"}}`), "index", stepIndex)
if !emitInteractionsEvent(startEvent) {
return
}
thoughtStarted = true
}
sigEvent := []byte(`{"event_type":"step.delta","index":0,"delta":{"type":"thought_signature","signature":""}}`)
sigEvent, _ = sjson.SetBytes(sigEvent, "index", targetIdx)
sigEvent, _ = sjson.SetBytes(sigEvent, "index", thoughtStepIndex)
sigEvent, _ = sjson.SetBytes(sigEvent, "delta.signature", string(frameRes.DeltaSignature))
_ = emitInteractionsEvent(sigEvent)
if frameRes.DeltaSignatureType != "" {
sigEvent, _ = sjson.SetBytes(sigEvent, "delta.signature_type", frameRes.DeltaSignatureType)
}
if !emitInteractionsEvent(sigEvent) {
return
}
}
// Emit content text delta
@@ -653,23 +677,22 @@ func (e *DevinExecutor) streamDevinFrames(
stepIndex++
}
if tc.Name != "" || tc.ID != "" {
if currentToolCallActive {
stopEvent, _ := sjson.SetBytes([]byte(`{"event_type":"step.stop","index":0}`), "index", stepIndex)
_ = emitInteractionsEvent(stopEvent)
stepIndex++
}
startEvent, _ := sjson.SetBytes([]byte(`{"event_type":"step.start","index":0,"step":{"type":"function_call","name":"","id":"","call_id":"","arguments":{}}}`), "index", stepIndex)
sIdx, exists := toolCallSteps[tc.Index]
if !exists {
sIdx = stepIndex
stepIndex++
toolCallSteps[tc.Index] = sIdx
startEvent, _ := sjson.SetBytes([]byte(`{"event_type":"step.start","index":0,"step":{"type":"function_call","name":"","id":"","call_id":"","arguments":{}}}`), "index", sIdx)
startEvent, _ = sjson.SetBytes(startEvent, "step.name", tc.Name)
startEvent, _ = sjson.SetBytes(startEvent, "step.id", tc.ID)
startEvent, _ = sjson.SetBytes(startEvent, "step.call_id", tc.ID)
if !emitInteractionsEvent(startEvent) {
return
}
currentToolCallActive = true
}
if tc.Arguments != "" {
deltaEvent, _ := sjson.SetBytes([]byte(`{"event_type":"step.delta","index":0,"delta":{"type":"arguments_delta","arguments":""}}`), "index", stepIndex)
deltaEvent, _ := sjson.SetBytes([]byte(`{"event_type":"step.delta","index":0,"delta":{"type":"arguments_delta","arguments":""}}`), "index", sIdx)
deltaEvent, _ = sjson.SetBytes(deltaEvent, "delta.arguments", tc.Arguments)
if !emitInteractionsEvent(deltaEvent) {
return
@@ -678,26 +701,23 @@ func (e *DevinExecutor) streamDevinFrames(
}
}
// 3. Emit accumulated signature if present
if len(accumulatedSignature) > 0 {
targetIdx := thoughtStepIndex
if targetIdx < 0 {
targetIdx = 0
}
sigBase64 := base64.StdEncoding.EncodeToString(accumulatedSignature)
sigEvent, _ := sjson.SetBytes([]byte(`{"event_type":"step.delta","index":0,"delta":{"type":"thought_signature","signature":""}}`), "index", targetIdx)
sigEvent, _ = sjson.SetBytes(sigEvent, "delta.signature", sigBase64)
if signatureType != "" {
sigEvent, _ = sjson.SetBytes(sigEvent, "delta.signature_type", signatureType)
}
_ = emitInteractionsEvent(sigEvent)
}
// 4. Close open steps
if thoughtStarted || contentStarted || currentToolCallActive {
// 3. Close open steps
if thoughtStarted || contentStarted {
stopEvent, _ := sjson.SetBytes([]byte(`{"event_type":"step.stop","index":0}`), "index", stepIndex)
_ = emitInteractionsEvent(stopEvent)
}
for _, sIdx := range toolCallSteps {
stopEvent, _ := sjson.SetBytes([]byte(`{"event_type":"step.stop","index":0}`), "index", sIdx)
_ = emitInteractionsEvent(stopEvent)
}
// If stream encountered an abnormal read error mid-flight, record failure and emit response.failed
if streamErr != nil && ctx.Err() == nil {
helps.RecordAPIResponseError(ctx, e.cfg, streamErr)
failedEvent, _ := sjson.SetBytes([]byte(`{"event_type":"response.failed","error":{"message":"","code":"stream_read_error"}}`), "error.message", streamErr.Error())
_ = emitInteractionsEvent(failedEvent)
return
}
// 5. Emit interaction.completed with final usage
completedEvent := []byte(`{"event_type":"interaction.completed","interaction":{"id":"","model":"","status":"completed","usage":{"total_input_tokens":0,"total_output_tokens":0,"total_cached_tokens":0}}}`)
@@ -775,7 +795,7 @@ func consumeDevinFramesToInteractions(body io.Reader, model, chatModelUID string
for {
flag, payload, errRead := helps.ReadConnectFrame(body)
if errRead != nil {
if errors.Is(errRead, io.EOF) || errors.Is(errRead, io.ErrUnexpectedEOF) {
if errors.Is(errRead, io.EOF) {
break
}
respLog := &helps.DevinUpstreamResponseLog{
@@ -840,14 +860,18 @@ func consumeDevinFramesToInteractions(body io.Reader, model, chatModelUID string
textParts = append(textParts, frameRes.ContentText)
}
for _, tc := range frameRes.ToolCallDeltas {
if tc.Name != "" || tc.ID != "" {
toolCalls = append(toolCalls, helps.DevinToolCall{
ID: tc.ID,
Name: tc.Name,
Arguments: tc.Arguments,
})
} else if len(toolCalls) > 0 {
toolCalls[len(toolCalls)-1].Arguments += tc.Arguments
idx := tc.Index
for len(toolCalls) <= idx {
toolCalls = append(toolCalls, helps.DevinToolCall{})
}
if tc.ID != "" {
toolCalls[idx].ID = tc.ID
}
if tc.Name != "" {
toolCalls[idx].Name = tc.Name
}
if tc.Arguments != "" {
toolCalls[idx].Arguments += tc.Arguments
}
}
}
@@ -858,9 +882,13 @@ func consumeDevinFramesToInteractions(body io.Reader, model, chatModelUID string
var steps [][]byte
if len(thinkingParts) > 0 {
if len(thinkingParts) > 0 || len(accumulatedSignature) > 0 {
thoughtStep := []byte(`{"type":"thought","content":[{"type":"text","text":""}]}`)
thoughtStep, _ = sjson.SetBytes(thoughtStep, "content.0.text", strings.Join(thinkingParts, ""))
if len(thinkingParts) > 0 {
thoughtStep, _ = sjson.SetBytes(thoughtStep, "content.0.text", strings.Join(thinkingParts, ""))
} else {
thoughtStep, _ = sjson.DeleteBytes(thoughtStep, "content")
}
if len(accumulatedSignature) > 0 {
sigStr := string(accumulatedSignature)
thoughtStep, _ = sjson.SetBytes(thoughtStep, "signature", sigStr)
@@ -972,19 +1000,22 @@ func parseInteractionsPayload(payload, originalRequest []byte) (
}
// 3. Session and Cascade ID
// Prioritize stable session identifiers across turns (session_id, sessionId, conversation_id)
// to ensure upstream session ID and cascade ID remain stable, preserving prompt caching.
// Fall back to previous_interaction_id only when no stable session identifier exists.
sessionID = strings.TrimSpace(firstNonEmpty(
root.Get("previous_interaction_id").String(),
root.Get("session_id").String(),
root.Get("sessionId").String(),
root.Get("conversation_id").String(),
root.Get("previous_interaction_id").String(),
))
if sessionID == "" && len(originalRequest) > 0 {
origRoot := gjson.ParseBytes(originalRequest)
sessionID = strings.TrimSpace(firstNonEmpty(
origRoot.Get("previous_interaction_id").String(),
origRoot.Get("session_id").String(),
origRoot.Get("sessionId").String(),
origRoot.Get("conversation_id").String(),
origRoot.Get("previous_interaction_id").String(),
))
}
cascadeID = sessionID
@@ -1403,16 +1434,22 @@ func detectSignatureType(sig string) string {
return "sealed"
}
type originalAssistantMeta struct {
signature []byte
signatureType string
thinking string
}
func supplementSignaturesFromOriginal(original []byte, prompts []helps.DevinPrompt) {
origRoot := gjson.ParseBytes(original)
messages := origRoot.Get("messages")
if !messages.IsArray() {
return
}
var assistantSigs [][]byte
var assistantSigTypes []string
var originalAssistants []originalAssistantMeta
for _, m := range messages.Array() {
if strings.EqualFold(m.Get("role").String(), "assistant") {
var meta originalAssistantMeta
content := m.Get("content")
if content.IsArray() {
for _, part := range content.Array() {
@@ -1420,22 +1457,34 @@ func supplementSignaturesFromOriginal(original []byte, prompts []helps.DevinProm
if sig := part.Get("signature").String(); sig != "" {
bytes, sType := parseSignatureBytes(sig)
if len(bytes) > 0 {
assistantSigs = append(assistantSigs, bytes)
assistantSigTypes = append(assistantSigTypes, sType)
meta.signature = bytes
meta.signatureType = sType
}
}
if t := part.Get("thinking").String(); t != "" {
meta.thinking = t
}
}
}
}
originalAssistants = append(originalAssistants, meta)
}
}
sigIdx := 0
asstIdx := 0
for i := range prompts {
if prompts[i].Source == 2 && len(prompts[i].Signature) == 0 && sigIdx < len(assistantSigs) {
prompts[i].Signature = assistantSigs[sigIdx]
prompts[i].SignatureType = assistantSigTypes[sigIdx]
sigIdx++
if prompts[i].Source == 2 {
if asstIdx < len(originalAssistants) {
orig := originalAssistants[asstIdx]
if len(prompts[i].Signature) == 0 && len(orig.signature) > 0 {
prompts[i].Signature = orig.signature
prompts[i].SignatureType = orig.signatureType
}
if prompts[i].Thinking == "" && orig.thinking != "" {
prompts[i].Thinking = orig.thinking
}
asstIdx++
}
}
}
}
@@ -1497,6 +1546,9 @@ func devinAuthCredentials(auth *cliproxyauth.Auth) (apiKey string, baseURL strin
if v, ok := auth.Metadata["base_url"].(string); ok && strings.TrimSpace(v) != "" && baseURL == helps.DevinDefaultBaseURL {
baseURL = strings.TrimSpace(v)
}
if v, ok := auth.Metadata["device_seed"].(string); ok && strings.TrimSpace(v) != "" && deviceSeed == "" {
deviceSeed = strings.TrimSpace(v)
}
}
return
}

View File

@@ -61,6 +61,41 @@ func TestDevinExecutorPrepareRequest(t *testing.T) {
if req.Header.Get("Connect-Protocol-Version") != "1" {
t.Fatalf("Connect-Protocol-Version = %q, want 1", req.Header.Get("Connect-Protocol-Version"))
}
if req.Header.Get("Accept") != "*/*" {
t.Fatalf("Accept = %q, want */*", req.Header.Get("Accept"))
}
sentryTrace := req.Header.Get("Sentry-Trace")
if sentryTrace == "" {
t.Fatalf("Sentry-Trace header missing")
}
parts := strings.Split(sentryTrace, "-")
if len(parts) != 3 || len(parts[0]) != 32 || len(parts[1]) != 16 || parts[2] != "1" {
t.Fatalf("invalid Sentry-Trace format: %q", sentryTrace)
}
// Verify User-Agent suppression on the wire
var receivedUA []string
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
receivedUA = r.Header["User-Agent"]
}))
defer ts.Close()
wireReq, err := http.NewRequest(http.MethodPost, ts.URL, nil)
if err != nil {
t.Fatalf("NewRequest failed: %v", err)
}
if err := exec.PrepareRequest(wireReq, auth); err != nil {
t.Fatalf("PrepareRequest failed: %v", err)
}
resp, err := ts.Client().Do(wireReq)
if err != nil {
t.Fatalf("Do request failed: %v", err)
}
_ = resp.Body.Close()
if len(receivedUA) != 0 {
t.Errorf("expected User-Agent to be completely omitted on wire, got: %v", receivedUA)
}
}
func TestDevinAuthCredentials(t *testing.T) {

View File

@@ -20,6 +20,7 @@ var (
// knownDevinSuffixes lists recognized model uid suffixes.
var knownDevinSuffixes = []string{
"-none",
"-low",
"-medium",
"-high",

View File

@@ -3,6 +3,7 @@ package helps
import (
"bytes"
"compress/gzip"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/binary"
@@ -14,9 +15,11 @@ import (
"net/http"
"runtime"
"strings"
"sync/atomic"
"unicode/utf8"
"github.com/google/uuid"
"github.com/router-for-me/CLIProxyAPI/v7/internal/cache"
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
"google.golang.org/protobuf/encoding/protowire"
)
@@ -117,9 +120,15 @@ type DevinFrameResult struct {
UnknownFieldNumbers []int
}
// GenerateDevinDeviceFingerprint generates a stable 732-character hex device fingerprint.
// GenerateDevinDeviceFingerprint generates a 732-character hex device fingerprint.
// When seed is empty, it generates a cryptographically random 732-character hex string per request (matching native devin-cli).
// When seed is provided, it derives a deterministic 732-character hex fingerprint.
func GenerateDevinDeviceFingerprint(seed string) string {
if seed == "" {
var b [DevinFingerprintHexLen / 2]byte
if _, err := rand.Read(b[:]); err == nil {
return hex.EncodeToString(b[:])
}
seed = uuid.New().String()
}
var sb strings.Builder
@@ -133,6 +142,45 @@ func GenerateDevinDeviceFingerprint(seed string) string {
return sb.String()[:DevinFingerprintHexLen]
}
// GenerateDevinSentryTrace generates a Sentry distributed tracing header in the format:
// "<32-hex-trace-id>-<16-hex-span-id>-1"
func GenerateDevinSentryTrace() string {
var b [24]byte
if _, err := rand.Read(b[:]); err != nil {
u1 := strings.ReplaceAll(uuid.New().String(), "-", "")
u2 := strings.ReplaceAll(uuid.New().String(), "-", "")[:16]
return u1 + "-" + u2 + "-1"
}
traceID := hex.EncodeToString(b[:16])
spanID := hex.EncodeToString(b[16:24])
return traceID + "-" + spanID + "-1"
}
const defaultMaxSessionTurnCounters = 5000
var sessionTurnLRU = cache.NewBoundedLRU[string, *atomic.Uint64](defaultMaxSessionTurnCounters, nil)
// NextDevinSessionTurnIndex returns the next 0-based request ordinal for a session (Field 15.2).
// In native devin-cli, the counter is process-scoped per session:
// First request in a session returns 0 (which is omitted on the wire).
// Subsequent requests return 1, 2, 3... monotonically.
func NextDevinSessionTurnIndex(sessionID string) int {
cleanID := strings.TrimSpace(sessionID)
if cleanID == "" {
return 0
}
counter := sessionTurnLRU.GetOrAdd(cleanID, func() *atomic.Uint64 {
return &atomic.Uint64{}
})
return int(counter.Add(1) - 1)
}
// ResetDevinSessionTurnIndex clears the session counter (used for testing or explicit session reset).
func ResetDevinSessionTurnIndex(sessionID string) {
sessionTurnLRU.Delete(strings.TrimSpace(sessionID))
}
// WrapConnectEnvelope wraps raw payload bytes into a standard 5-byte Connect envelope:
// [1 byte flag: 0x00] + [4 byte big-endian length] + [payload].
func WrapConnectEnvelope(protoBytes []byte) []byte {
@@ -373,7 +421,7 @@ func BuildDevinGetChatMessageRequest(
f8Bytes = protowire.AppendVarint(f8Bytes, 40)
f8Bytes = protowire.AppendTag(f8Bytes, 8, protowire.Fixed64Type)
f8Bytes = protowire.AppendFixed64(f8Bytes, math.Float64bits(0.95))
f8Bytes = protowire.AppendFixed64(f8Bytes, math.Float64bits(float64(float32(0.95))))
reqBytes = protowire.AppendTag(reqBytes, 8, protowire.BytesType)
reqBytes = protowire.AppendBytes(reqBytes, f8Bytes)
@@ -402,18 +450,32 @@ func BuildDevinGetChatMessageRequest(
}
// 7. Thread session metadata (Field 15)
// In native devin-cli:
// Field 1: sessionID (UUID string)
// Field 2: turnIndex (per-session request ordinal, omitted when 0)
// Field 3: 4 (varint)
// Field 4: 14 (emitted conditionally on user-turn boundaries)
turnIndex := NextDevinSessionTurnIndex(sessionID)
var f15Bytes []byte
f15Bytes = protowire.AppendTag(f15Bytes, 1, protowire.BytesType)
f15Bytes = protowire.AppendString(f15Bytes, sessionID)
f15Bytes = protowire.AppendTag(f15Bytes, 2, protowire.VarintType)
f15Bytes = protowire.AppendVarint(f15Bytes, 53)
if turnIndex > 0 {
f15Bytes = protowire.AppendTag(f15Bytes, 2, protowire.VarintType)
f15Bytes = protowire.AppendVarint(f15Bytes, uint64(turnIndex))
}
f15Bytes = protowire.AppendTag(f15Bytes, 3, protowire.VarintType)
f15Bytes = protowire.AppendVarint(f15Bytes, 4)
f15Bytes = protowire.AppendTag(f15Bytes, 4, protowire.VarintType)
f15Bytes = protowire.AppendVarint(f15Bytes, 14)
// In native devin-cli, Field 15.4=14 is emitted on user-turn boundaries
if len(prompts) > 0 && prompts[len(prompts)-1].Source == 1 {
if turnIndex == 0 || len(prompts) < 2 || prompts[len(prompts)-2].Source != 1 {
f15Bytes = protowire.AppendTag(f15Bytes, 4, protowire.VarintType)
f15Bytes = protowire.AppendVarint(f15Bytes, 14)
}
}
reqBytes = protowire.AppendTag(reqBytes, 15, protowire.BytesType)
reqBytes = protowire.AppendBytes(reqBytes, f15Bytes)

View File

@@ -4,6 +4,9 @@ import (
"bytes"
"strings"
"testing"
"github.com/google/uuid"
"google.golang.org/protobuf/encoding/protowire"
)
func TestConnectEnvelopeFraming(t *testing.T) {
@@ -255,10 +258,10 @@ func TestParseDevinTrailerError(t *testing.T) {
func TestUTF8SplitBuffer(t *testing.T) {
buf := &UTF8SplitBuffer{}
// "你好" in UTF-8: \xe4\xbd\xa0 \xe5\xa5\xbd (3 bytes each)
chunk1 := []byte{0xe4, 0xbd} // first 2 bytes of 你
chunk2 := []byte{0xa0, 0xe5, 0xa5} // last 1 byte of 你, first 2 bytes of 好
chunk3 := []byte{0xbd} // last 1 byte of 好
// Multi-byte UTF-8 test: \xe4\xbd\xa0 \xe5\xa5\xbd (3 bytes each)
chunk1 := []byte{0xe4, 0xbd} // first 2 bytes of char 1
chunk2 := []byte{0xa0, 0xe5, 0xa5} // last 1 byte of char 1, first 2 bytes of char 2
chunk3 := []byte{0xbd} // last 1 byte of char 2
s1 := buf.Feed(chunk1)
if s1 != "" {
@@ -374,3 +377,178 @@ func TestBuildDevinUpstreamLogBody(t *testing.T) {
t.Errorf("expected direct body to contain model UID")
}
}
func TestGenerateDevinSentryTrace(t *testing.T) {
st1 := GenerateDevinSentryTrace()
st2 := GenerateDevinSentryTrace()
if st1 == st2 {
t.Fatalf("traces should be randomly generated: %s == %s", st1, st2)
}
parts := strings.Split(st1, "-")
if len(parts) != 3 {
t.Fatalf("sentry-trace should have 3 parts separated by hyphen, got %q", st1)
}
if len(parts[0]) != 32 {
t.Errorf("traceID len = %d, want 32", len(parts[0]))
}
if len(parts[1]) != 16 {
t.Errorf("spanID len = %d, want 16", len(parts[1]))
}
if parts[2] != "1" {
t.Errorf("sampled = %q, want 1", parts[2])
}
}
func TestBuildDevinGetChatMessageRequest_Field15TurnIndex(t *testing.T) {
sessID0 := "sess-turn0-" + uuid.New().String()
defer ResetDevinSessionTurnIndex(sessID0)
// 1. Turn 0 with user prompt: turnIndex=0 (omitted), 15.4=14 emitted on user boundary
promptsTurn0 := []DevinPrompt{
{MessageID: "u1", Source: 1, Content: "hello"},
}
req0 := BuildDevinGetChatMessageRequest("tok", "seed", "swe-2-high", "", promptsTurn0, nil, nil, 1000, sessID0, "casc-turn0", nil)
gotSess0, f15Sub0 := extractField15Subfields(t, req0)
if gotSess0 != sessID0 {
t.Errorf("Field 1 sessionID = %q, want %q", gotSess0, sessID0)
}
if _, hasF2 := f15Sub0[2]; hasF2 {
t.Errorf("turn 0 should omit Field 2, got %v", f15Sub0[2])
}
if f15Sub0[3] != 4 {
t.Errorf("Field 3 = %d, want 4", f15Sub0[3])
}
if f15Sub0[4] != 14 {
t.Errorf("Field 4 = %d, want 14 on user turn boundary", f15Sub0[4])
}
sessIDTool := "sess-tool-" + uuid.New().String()
defer ResetDevinSessionTurnIndex(sessIDTool)
// 2. Tool-result continuation (source=4): 15.4 should be omitted
promptsTool := []DevinPrompt{
{MessageID: "u1", Source: 1, Content: "read file"},
{MessageID: "a1", Source: 2, Content: "calling tool"},
{MessageID: "t1", Source: 4, Content: "file content", ToolCallID: "call_1"},
}
reqTool := BuildDevinGetChatMessageRequest("tok", "seed", "swe-2-high", "", promptsTool, nil, nil, 1000, sessIDTool, "casc-tool", nil)
_, f15SubTool := extractField15Subfields(t, reqTool)
if _, hasF4 := f15SubTool[4]; hasF4 {
t.Errorf("Field 4 should be omitted on tool result continuation, got %v", f15SubTool[4])
}
}
func TestBuildDevinGetChatMessageRequest_Field15SequentialCounter(t *testing.T) {
sessionID := "sess-sequential-test-" + uuid.New().String()
defer ResetDevinSessionTurnIndex(sessionID)
prompts := []DevinPrompt{
{MessageID: "u1", Source: 1, Content: "hi"},
}
// Request 1: fresh session -> turnIndex 0 (omitted from wire)
req1 := BuildDevinGetChatMessageRequest("tok", "seed", "swe-2-high", "", prompts, nil, nil, 1000, sessionID, "casc-1", nil)
_, sub1 := extractField15Subfields(t, req1)
if _, hasF2 := sub1[2]; hasF2 {
t.Errorf("Request 1 in fresh session should omit 15.2, got %v", sub1[2])
}
// Request 2: turnIndex 1
req2 := BuildDevinGetChatMessageRequest("tok", "seed", "swe-2-high", "", prompts, nil, nil, 1000, sessionID, "casc-1", nil)
_, sub2 := extractField15Subfields(t, req2)
if sub2[2] != 1 {
t.Errorf("Request 2 should have 15.2 = 1, got %v", sub2[2])
}
// Request 3: turnIndex 2
req3 := BuildDevinGetChatMessageRequest("tok", "seed", "swe-2-high", "", prompts, nil, nil, 1000, sessionID, "casc-1", nil)
_, sub3 := extractField15Subfields(t, req3)
if sub3[2] != 2 {
t.Errorf("Request 3 should have 15.2 = 2, got %v", sub3[2])
}
}
func TestGenerateDevinDeviceFingerprint_RandomWhenEmptySeed(t *testing.T) {
fp1 := GenerateDevinDeviceFingerprint("")
fp2 := GenerateDevinDeviceFingerprint("")
if len(fp1) != DevinFingerprintHexLen {
t.Fatalf("fp1 len = %d, want %d", len(fp1), DevinFingerprintHexLen)
}
if len(fp2) != DevinFingerprintHexLen {
t.Fatalf("fp2 len = %d, want %d", len(fp2), DevinFingerprintHexLen)
}
if fp1 == fp2 {
t.Fatalf("fingerprints without explicit seed must be unique per call: %q == %q", fp1, fp2)
}
// With explicit seed, it must be deterministic
seeded1 := GenerateDevinDeviceFingerprint("my-stable-seed")
seeded2 := GenerateDevinDeviceFingerprint("my-stable-seed")
if seeded1 != seeded2 {
t.Fatalf("seeded fingerprints must be identical: %q != %q", seeded1, seeded2)
}
}
func extractField15Subfields(t *testing.T, reqBytes []byte) (string, map[int]uint64) {
t.Helper()
b := reqBytes
var f15Bytes []byte
for len(b) > 0 {
num, typ, n := protowire.ConsumeTag(b)
if n < 0 {
break
}
b = b[n:]
if num == 15 && typ == protowire.BytesType {
sub, m := protowire.ConsumeBytes(b)
if m < 0 {
t.Fatalf("failed to consume field 15 bytes")
}
f15Bytes = sub
break
}
m := protowire.ConsumeFieldValue(num, typ, b)
if m < 0 {
break
}
b = b[m:]
}
if len(f15Bytes) == 0 {
t.Fatalf("field 15 not found in request")
}
var sessionID string
subfields := make(map[int]uint64)
sb := f15Bytes
for len(sb) > 0 {
num, typ, n := protowire.ConsumeTag(sb)
if n < 0 {
break
}
sb = sb[n:]
if typ == protowire.VarintType {
val, m := protowire.ConsumeVarint(sb)
if m < 0 {
break
}
subfields[int(num)] = val
sb = sb[m:]
} else if typ == protowire.BytesType {
val, m := protowire.ConsumeBytes(sb)
if m < 0 {
break
}
if num == 1 {
sessionID = string(val)
}
sb = sb[m:]
} else {
m := protowire.ConsumeFieldValue(num, typ, sb)
if m < 0 {
break
}
sb = sb[m:]
}
}
return sessionID, subfields
}

View File

@@ -61,6 +61,24 @@ func NewProxyAwareHTTPClient(ctx context.Context, cfg *config.Config, auth *clip
return httpClient
}
// NewDevinHTTPClient creates an HTTP client customized for Devin Connect-RPC upstream.
// Suppresses automatic Accept-Encoding: gzip while preserving robust HTTP/2 streaming.
func NewDevinHTTPClient(ctx context.Context, cfg *config.Config, auth *cliproxyauth.Auth, timeout time.Duration) *http.Client {
httpClient := NewProxyAwareHTTPClient(ctx, cfg, auth, timeout)
if httpClient.Transport == nil {
if dt, ok := http.DefaultTransport.(*http.Transport); ok {
httpClient.Transport = dt.Clone()
}
} else if tr, ok := httpClient.Transport.(*http.Transport); ok {
// Clone transport to avoid mutating a shared or cached roundtripper
httpClient.Transport = tr.Clone()
}
if tr, ok := httpClient.Transport.(*http.Transport); ok {
tr.DisableCompression = true
}
return httpClient
}
// buildProxyTransport creates an HTTP transport configured for the given proxy URL.
// It supports SOCKS5, HTTP, and HTTPS proxy protocols.
//

View File

@@ -132,7 +132,7 @@ waitForResult:
if res.Error != "" {
return nil, fmt.Errorf("devin oauth error: %s", res.Error)
}
if state != "" && res.State != "" && res.State != state {
if state != "" && res.State != state {
return nil, fmt.Errorf("devin oauth state mismatch (possible CSRF)")
}
authCode = res.Code
@@ -180,7 +180,7 @@ waitForResult:
// 2. Full callback redirect URL
parsed, errParse := misc.ParseOAuthCallback(trimmed)
if errParse == nil && parsed != nil && parsed.Code != "" {
if state != "" && parsed.State != "" && parsed.State != state {
if state != "" && parsed.State != state {
return nil, fmt.Errorf("devin oauth state mismatch (possible CSRF)")
}
authCode = parsed.Code