diff --git a/cmd/vsp/main.go b/cmd/vsp/main.go index 92acdb48..162cabc0 100755 --- a/cmd/vsp/main.go +++ b/cmd/vsp/main.go @@ -469,6 +469,11 @@ func resolveConfig(cmd *cobra.Command) { } } + // Session type: SAP_SESSION_TYPE env (stateful | stateless | keep) + if v := viper.GetString("SESSION_TYPE"); v != "" { + cfg.SessionType = v + } + // Keep-alive interval: flag > SAP_KEEPALIVE env if !cmd.Flags().Changed("keepalive") { if v := viper.GetString("KEEPALIVE"); v != "" { diff --git a/internal/mcp/server.go b/internal/mcp/server.go index b0cd7c75..8eda7f21 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -4,6 +4,7 @@ package mcp import ( "context" "fmt" + "os" "strings" "sync" "time" @@ -87,6 +88,9 @@ type Config struct { // Graph / co-change configuration TransportAttribute string // E070A attribute name for CR-level co-change aggregation + // Session type: "stateful" keeps SAP session across requests (required for lock/write flows) + SessionType string + // Debugger configuration TerminalID string // SAP GUI terminal ID for cross-tool breakpoint sharing @@ -126,6 +130,15 @@ func NewServer(cfg *Config) *Server { if cfg.Verbose { opts = append(opts, adt.WithVerbose()) } + if cfg.SessionType != "" { + st := adt.SessionType(cfg.SessionType) + switch st { + case adt.SessionStateful, adt.SessionStateless, adt.SessionKeep: + opts = append(opts, adt.WithSessionType(st)) + default: + fmt.Fprintf(os.Stderr, "[vsp] warning: unknown SAP_SESSION_TYPE %q, using default (stateless)\n", cfg.SessionType) + } + } if cfg.ReauthFunc != nil { opts = append(opts, adt.WithReauthFunc(cfg.ReauthFunc)) } diff --git a/pkg/adt/config.go b/pkg/adt/config.go index 719d6e3e..1aa0e8c4 100755 --- a/pkg/adt/config.go +++ b/pkg/adt/config.go @@ -7,6 +7,7 @@ import ( "fmt" "net/http" "net/http/cookiejar" + "net/url" "time" ) @@ -227,9 +228,32 @@ func WithTerminalID(terminalID string) Option { } } +// httpCookieJar wraps cookiejar.Jar and strips the Secure flag when storing cookies +// received over plain HTTP. This is required for SAP systems accessed via HTTP reverse +// proxies that still set Secure cookies (e.g. nginx in front of SAP ICM). Go's standard +// jar won't send Secure cookies on HTTP requests, causing CSRF tokens to appear expired +// because the session cookie never reaches the server on subsequent requests. +type httpCookieJar struct { + *cookiejar.Jar +} + +func (j *httpCookieJar) SetCookies(u *url.URL, cookies []*http.Cookie) { + if u.Scheme == "http" { + stripped := make([]*http.Cookie, len(cookies)) + for i, c := range cookies { + copy := *c + copy.Secure = false + stripped[i] = © + } + cookies = stripped + } + j.Jar.SetCookies(u, cookies) +} + // NewHTTPClient creates an http.Client configured for the given Config. func (c *Config) NewHTTPClient() *http.Client { - jar, _ := cookiejar.New(nil) + base, _ := cookiejar.New(nil) + jar := &httpCookieJar{base} transport := &http.Transport{ Proxy: http.ProxyFromEnvironment, // Honor HTTP_PROXY/HTTPS_PROXY env vars diff --git a/pkg/adt/http.go b/pkg/adt/http.go index d3b30227..a7dd7394 100755 --- a/pkg/adt/http.go +++ b/pkg/adt/http.go @@ -308,17 +308,50 @@ func (t *Transport) fetchCSRFToken(ctx context.Context) error { req.Header.Set("X-sap-adt-sessiontype", "stateful") } - resp, err := t.httpClient.Do(req) + headResp, err := t.httpClient.Do(req) if err != nil { return fmt.Errorf("executing request: %w", err) } - defer resp.Body.Close() - - // Drain body to allow connection reuse - _, _ = io.Copy(io.Discard, resp.Body) + _, _ = io.Copy(io.Discard, headResp.Body) + headResp.Body.Close() + + // Use the HEAD response by default; fall back to GET when HEAD returns no usable token. + // Some SAP systems (on-premise, S/4HANA cloud) reject HEAD or return no token — + // CL_ADT_WB_RES_APP may not implement HEAD. GET is what Eclipse ADT uses. + // + // But skip the GET fallback on 401/403: there the token is absent because the + // credentials/authorizations are invalid, not because HEAD is unsupported — a GET + // retry can't help and only wastes a round-trip (and would swallow a mock slot in + // the re-auth-failure path). + resp := headResp + headToken := headResp.Header.Get("X-CSRF-Token") + headIsAuthFailure := headResp.StatusCode == http.StatusUnauthorized || + headResp.StatusCode == http.StatusForbidden + if (headToken == "" || headToken == "Required") && !headIsAuthFailure { + reqGet, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) + if err != nil { + return fmt.Errorf("creating GET fallback request: %w", err) + } + if t.config.HasBasicAuth() { + reqGet.SetBasicAuth(t.config.Username, t.config.Password) + } + t.addCookies(reqGet) + reqGet.Header.Set("X-CSRF-Token", "fetch") + reqGet.Header.Set("Accept", "*/*") + if t.config.SessionType == SessionStateful { + reqGet.Header.Set("X-sap-adt-sessiontype", "stateful") + } + getResp, err := t.httpClient.Do(reqGet) + if err != nil { + return fmt.Errorf("executing GET fallback: %w", err) + } + _, _ = io.Copy(io.Discard, getResp.Body) + getResp.Body.Close() + resp = getResp + } // Note: HEAD may return 400 but still provides CSRF token in headers - // But 401/403 indicates auth failure and won't have a valid token + // But 401 indicates auth failure and won't have a valid token token := resp.Header.Get("X-CSRF-Token") if token == "" || token == "Required" {