fix(adt): deploy session ordering + correct MODIFICATION_SUPPORT handling#108
fix(adt): deploy session ordering + correct MODIFICATION_SUPPORT handling#108dme007 wants to merge 4 commits into
Conversation
CreateFromFile and UpdateFromFile ran SyntaxCheck *after* LockObject.
SyntaxCheck has no Stateful flag on its RequestOptions, so it rode the
transport's stateless default. The outbound sequence therefore alternated
session-type mid-stream:
POST …/oo/classes/<name>?_action=LOCK X-sap-adt-sessiontype: stateful
POST …/checkruns?reporters=abapCheckRun X-sap-adt-sessiontype: stateless ← kills stateful session
PUT …/oo/classes/<name>/source/main X-sap-adt-sessiontype: stateful
SAP's ICM retired the stateful session after the stateless hop
(Sap-Err-Id: ICMENOSESSION on the PUT, HTTP 400 "Session Timed Out"),
after which the CSRF-refresh path failed because the session that held
the token was already gone — producing the "no CSRF token in response
(HTTP 400)" error path users hit on live deploys.
Running SyntaxCheck before LockObject keeps the Lock → UpdateSource →
Unlock → Activate block as a single uninterrupted stateful sequence.
The Stateful flags 22517d4 added on individual calls are necessary but
not sufficient; call ordering matters too.
This completes the oisee#88 / oisee#92 / oisee#98 lock-handle class on Cloud-Connector
destinations with a single-pod Envoy proxy, where the stateless hop
would otherwise migrate the connection.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…only 22517d4 hard-failed LockObject when a successful LOCK response came back with MODIFICATION_SUPPORT="NoModification", interpreting that value as "the object is read-only via ADT for this user/system". That reading is wrong. SAP's standard interface IF_ADT_LOCK_RESULT defines four values for this field: "ModifcationAssistant" CO_MOD_SUPPORT_MODASS — SAP/partner object, Modification Assistant on "ModificationsLoggedOnly" CO_MOD_SUPPORT_LOGGED_ONLY — SAP/partner object, changes only logged "NoModification" CO_MOD_SUPPORT_NOT_NEEDED — "Modification support is not needed because object is not in SAP/partner namespace in a customer system" (customer Z*/Y* objects — tracking not needed, edit is fine) "" CO_MOD_SUPPORT_NOT_SPECIFIED — not specified The constant is NOT_NEEDED, not NOT_PERMITTED. Customer-namespace edits normally return this value, and the PUT that follows succeeds — confirmed live with VSP_HTTP_TRACE on on-prem S/4HANA deployments of ZCL_* classes to both $TMP and transportable customer packages: <LOCK_HANDLE>…</LOCK_HANDLE> <CORRNR>WSDK920768</CORRNR> <CORRUSER>X60000469</CORRUSER> <CORRTEXT>… CR description …</CORRTEXT> <IS_LOCAL/> <MODIFICATION_SUPPORT>NoModification</MODIFICATION_SUPPORT> ← metadata, not reject <SCOPE_MESSAGES/> → subsequent PUT …/source/main returns HTTP 200, class activates CORRNR / CORRUSER populated on the LOCK response means SAP evaluated the user's edit rights and accepted the lock; the field is advisory metadata about change tracking, not an authorisation verdict. The "confusing HTTP 423 InvalidLockHandle a few seconds later" that the guard was introduced to pre-empt is fully explained by the session- affinity fixes in 27f4d7c plus 22517d4's class-include completion and the call-ordering fix in the preceding commit of this series. The NoModification guard is orthogonal to that bug class and only ever fires as a false positive on normal customer code. LockObject now returns the parsed result verbatim. The regression-test pair TestLockObject_RejectsNoModification / TestLockObject_AllowsNoModificationOnReadLock is replaced with TestLockObject_PassesThroughModificationSupport — a table-driven test that pins pass-through across all four documented SAP values — plus a retained read-lock variant to keep the READ access-mode path explicitly covered. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two related defensive improvements uncovered while root-causing the MODIFICATION_SUPPORT issue on Cloud-Connector destinations. 1. CheckRedirect now explicitly re-sets X-CSRF-Token and X-sap-adt-sessiontype from the initial request (via[0]) alongside the existing Authorization restoration. Go's http.Client strips Authorization / WWW-Authenticate / Cookie / Cookie2 on cross-origin redirects per RFC 7235 §4.2; custom headers are forwarded by default. We re-set the two ADT-critical custom headers explicitly anyway — partly as defence against any Go version or middleware that decides to strip them on its own, partly as documentation-in-code that they are load-bearing for the lock → write → unlock sequence. Without X-sap-adt-sessiontype the second hop lands stateless and SAP rejects the lock handle with HTTP 423; without X-CSRF-Token the mutation is rejected as CSRF- violating. Two regression tests pin the behaviour. 2. VSP_HTTP_TRACE env var gates raw HTTP request/response dumps to stderr from Transport.Request, Transport.retryRequest, and Transport.fetchCSRFToken. Method, URL, selected headers, and body (truncated at 4 KiB) are written; Authorization and individual Cookie values are redacted so the dump is safe to paste into a bug report. Default-off, opt-in per invocation. This was the tool that made the preceding NoModification root-cause analysis tractable and that captured the "Session Timed Out" signature behind the deploy- workflow ordering fix in the first commit of this series. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Long-running vsp MCP-server processes hit HTTP 400 "Session Timed Out"
(Sap-Err-Id: ICMENOSESSION) on every request after the first successful
Lock → Write → Unlock → Activate sequence on SAP BAS destinations with
Cloud Connector + Principal Propagation.
Root cause: the stateless POST /sap/bc/adt/activation at the end of the
deploy closes SAP's stateful context server-side, but the sap-contextid
cookies the server had set during the stateful phase remain in Go's
cookie jar — sometimes on several paths (/, /sap/, /sap/bc/,
/sap/bc/adt/). Every subsequent outgoing request re-sends a matching
dead identifier, and SAP's ICM replies ICMENOSESSION — including on
the HEAD /core/discovery refetch that the IsSessionExpired recovery
path issues to open a fresh session. The recovery therefore loops:
same stale cookie mix, same ICMENOSESSION, no forward progress.
Trace captured via VSP_HTTP_TRACE=1 during an MCP session that
deployed an interface followed by a second tool call:
POST …/oo/interfaces/ZIF_…?_action=LOCK → 200
PUT …/oo/interfaces/ZIF_…/source/main → 200
POST …/oo/interfaces/ZIF_…?_action=UNLOCK → 200
POST /sap/bc/adt/activation?method=activate → 200 (stateless; ends context)
POST /sap/bc/adt/repository/nodestructure → 400 ICMENOSESSION
+ 6× Set-Cookie: sap-contextid= …
HEAD /sap/bc/adt/core/discovery → 400 ICMENOSESSION
+ 6× Set-Cookie: sap-contextid= …
… (stuck)
Short-lived CLI subcommands don't see this — each spawns a fresh
process with a brand-new cookie jar.
Fix: in the IsSessionExpired branch of Transport.Request, replace the
entire cookie jar with a fresh cookiejar.New before the CSRF refetch.
Targeted expire with SetCookies(url, {MaxAge: -1}) was the first
approach attempted but proved insufficient: Go's http.CookieJar keys
entries by (name, domain, path) and does not expose the stored path
through its public interface. A delete cookie for Path="/" leaves
entries on /sap/, /sap/bc/, or /sap/bc/adt/ untouched, and that is
exactly where SAP's ICM stores sap-contextid. Live re-capture after
the targeted approach landed still showed the same ICMENOSESSION
loop. Jar replacement drops every variant in one step regardless of
the path scope SAP used.
User-provided cookies (config.Cookies — populated by basic auth with
explicit cookie injection, browser-auth SSO, cookie-file, or
cookie-string flows) are attached per request via addCookies() on
every outbound Request, so swapping the jar does not drop the
caller's SSO / cookie-file state. Only cookies that the server had
dynamically deposited during the dead session are removed, which is
exactly the desired behaviour.
Transport now carries a jar http.CookieJar cached reference extracted
from the underlying *http.Client; the helper type-asserts
t.httpClient.(*http.Client) to swap both the client's Jar field and
the cached reference in lock-step. NewTransportWithClient fed a mock
HTTPDoer leaves both nil, and the helper no-ops there.
Regression coverage:
TestClearSAPSessionCookies_ReplacesJar — seeds the jar with
sap-contextid / SAP_SESSIONID_* on /, /sap/, /sap/bc/adt/;
asserts every SAP variant is gone post-swap and that
*http.Client.Jar was updated in lock-step with Transport.jar.
TestClearSAPSessionCookies_NonHTTPClientIsSafe — mock HTTPDoer path
stays a no-op (cannot swap what we don't own).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- fix(adt): drop stale sap-contextid on ICMENOSESSION recovery - feat(adt): preserve ADT headers on redirect + opt-in HTTP trace - fix(adt): drop MODIFICATION_SUPPORT="NoModification" misread as read-only - fix(adt): move SyntaxCheck before Lock in deploy workflows Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Independent confirmation of the analysis from a different on-premise system. Environment: S/4HANA on-prem (release D18, stack Symptom (current Side-effect we hit before reading your PR: because the existing guard returns the error before the What we did locally before finding #108: built a configurable opt-out ( Verified after applying #108 logic locally: A merge of this PR (along with #125's mutation-gate ordering work) would unblock all on-prem |
Summary
Four independent but related fixes to the ADT layer. All surfaced while
verifying
vsp deployon SAP Business Application Studio destinations(Cloud Connector + Principal Propagation). Live-tested against an on-prem
S/4HANA (DEV 2021 FPS05) for CLAS and INTF, both
$TMPand transportablecustomer packages. Full
pkg/adtsuite stays green.CreateFromFile/UpdateFromFile.MODIFICATION_SUPPORT="NoModification"hard-fail at LOCK.X-CSRF-TokenandX-sap-adt-sessiontypeon redirects.ICMENOSESSIONrecovery.Plus one opt-in diagnostic:
VSP_HTTP_TRACE=1dumps raw HTTP requestsand responses to stderr with Authorization / Cookie values redacted. It
made the root-cause analysis of (2) and (4) possible.
1. SyntaxCheck before Lock
SyntaxCheckcarried the default stateless session-type; running itafter
LockObjectinterleaves a stateless hop between the statefullock and write. SAP's ICM retires the stateful context on that hop
(
Sap-Err-Id: ICMENOSESSIONon the PUT), and the ensuing CSRF refreshfails because the session that held the token is already gone.
Moving
SyntaxCheckbeforeLockObjectkeepsLock → UpdateSource → Unlock → Activateas a single uninterrupted stateful block. Theper-request
Stateful: trueflags from 22517d4 are necessary but notsufficient on their own — call ordering matters too.
2.
MODIFICATION_SUPPORT="NoModification"is not "read-only"Commit 22517d4 hard-failed
LockObjectwhen the response carriedMODIFICATION_SUPPORT=NoModification, reading it as "object isread-only via ADT". That is a misreading of the SAP-standard interface
IF_ADT_LOCK_RESULT, which defines four values:ModifcationAssistant(sic)CO_MOD_SUPPORT_MODASSModificationsLoggedOnlyCO_MOD_SUPPORT_LOGGED_ONLYNoModificationCO_MOD_SUPPORT_NOT_NEEDEDCO_MOD_SUPPORT_NOT_SPECIFIEDThe constant is
NOT_NEEDED, notNOT_PERMITTED. Customer Z*/Y*objects routinely return this value and the subsequent PUT succeeds —
confirmed live with
VSP_HTTP_TRACE=1:CORRNR/CORRUSERpopulated on the LOCK response means SAP acceptedthe lock. The field is tracking metadata, not an authorisation verdict.
The "confusing HTTP 423 a few seconds later" the guard was meant to
pre-empt is fully explained by the session-affinity work in 27f4d7c +
the class-include completion in 22517d4 itself. The guard only ever
fires as a false positive on destinations whose SAP system populates
the flag — blocking legitimate customer edits.
LockObjectnow returns the parsed result verbatim for any value.Tests move from the asymmetric
_RejectsNoModificationpair to atable-driven
_PassesThroughModificationSupport, plus the retainedread-lock variant.
3.
CheckRedirectpreserves ADT headersGo's
http.Clientstrips Authorization / WWW-Authenticate / Cookie oncross-origin redirects (RFC 7235). The existing
CheckRedirectrestores
Authorizationfor #90.X-CSRF-TokenandX-sap-adt-sessiontypeare not on Go's sensitive-header list and aretherefore forwarded by default — but if either silently vanishes
across a redirect (proxy, future library change, middleware), the
second hop lands stateless with a missing CSRF token and the in-flight
lock is invalidated. Re-set both explicitly from
via[0], mirroringthe existing
Authorizationlogic. Documented as defensive in thesource comment.
4. Replace the cookie jar on
ICMENOSESSIONrecoveryLong-running processes (MCP server) hit HTTP 400 ICMENOSESSION on
every request after the first successful
Lock → Write → Unlock → Activate cycle. Captured trace:
Root cause: the stateless
Activatecloses SAP's stateful contextserver-side, but
sap-contextidcookies from the stateful phase stayin Go's cookie jar — sometimes on multiple paths (
/,/sap/,/sap/bc/,/sap/bc/adt/). Every retry re-sends a matching deadidentifier, including the HEAD
/core/discoveryrefetch thatIsSessionExpiredrecovery relies on. Short-lived CLI subcommandsescape because each spawns a fresh jar.
A first iteration of this fix expired cookies with
SetCookies(baseURL, {MaxAge: -1, Path: "/"}). It did not work inpractice:
http.CookieJarkeys entries by(name, domain, path)anddoes not expose the stored path — an expire for
Path="/"leavesentries on deeper paths untouched, and that is exactly where SAP
stores the contextid.
Final fix replaces the entire cookie jar with a fresh one before the
CSRF refetch. User-provided cookies (
config.Cookies, populated bybrowser-auth / SAML / cookie-file flows) are attached per request via
addCookies()and survive untouched; only dynamicallyserver-deposited entries from the dead session are dropped —
the desired outcome.
Transportgains a cachedjar http.CookieJarreference from the underlying
*http.Client; a type-assertion onHTTPDoerleaves the field nil for test mocks and the helper no-opsthere.
Files changed
Related issues
of 27f4d7c + 22517d4's class-include completion + the ordering fix in
this PR's commit 1.
read-only case on BTP, if it exists, will now surface with SAP's
authoritative error at the write step rather than spuriously at LOCK.