sap-ai-dev-toolkit 0.6.2 → 0.6.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -12
- package/dist/checksums.txt +5 -5
- package/dist/vsp-darwin-arm64 +0 -0
- package/dist/vsp-darwin-x64 +0 -0
- package/dist/vsp-linux-arm64 +0 -0
- package/dist/vsp-linux-x64 +0 -0
- package/dist/vsp-win32-x64.exe +0 -0
- package/package.json +1 -2
- package/patches/vsp-bas-proxy-auth.patch +125 -0
- package/src/launcher.mjs +3 -36
- package/src/mcp-proxy.mjs +18 -50
- package/src/bas-destination-relay.mjs +0 -377
package/README.md
CHANGED
|
@@ -544,7 +544,7 @@ For a transport report, include exactly one `GetTransport` check whose `transpor
|
|
|
544
544
|
|
|
545
545
|
For an ABAP Cloud assessment, get object URIs from `SearchObject` and pass them to `PlanABAPCloudMigration`. It reports release evidence; it does not guess replacement APIs. Save the JSON from `GenerateRAPRegressionSuite` and pass it to `RunRAPRegressionSuite` after relevant service changes.
|
|
546
546
|
|
|
547
|
-
`sap-ai-dev --doctor` probes each discovered destination, checks that each managed MCP entry still matches its selected BAS destination, starts VSP, lists MCP tools, and
|
|
547
|
+
`sap-ai-dev --doctor` probes each discovered destination, checks that each managed MCP entry still matches its selected BAS destination, starts VSP, lists MCP tools, and calls `GetSystemInfo` directly when that tool is exposed. It repairs only tagged toolkit-owned BAS entries that can be verified against complete destination discovery; it does not add destinations, remove stale or third-party entries, or overwrite malformed configuration. `--doctor --json` reports redacted diagnostics and exits unsuccessfully when a required check fails.
|
|
548
548
|
|
|
549
549
|
`tools/list` confirms what this MCP server exposes; it does not prove that VS Code attached the server to the active chat. Start the selected destination in **MCP: List Servers**, enable it in the Chat tools picker, and reload/reselect the agent if the host binding is stale. This add-on cannot inspect or repair another MCP server's registration or a host-wide tool budget.
|
|
550
550
|
|
|
@@ -738,9 +738,7 @@ The response contains a `tools` array. A `RunQuery` entry resembles this excerpt
|
|
|
738
738
|
| `SAP_AI_DEV_TOOLKIT_DESTINATION` | Comma-separated destination allowlist for normal runtime discovery. Setup clears this temporarily so it can display all eligible systems. |
|
|
739
739
|
| `SAP_AI_DEV_TOOLKIT_MODE` | VSP child mode (`expert` by default; `focused` omits `ActivateMultiple`, `GetUserTransports`, and `GetTransportInfo`). The proxy exposes its curated tool subset and local workflow tools, including `LintABAP`. |
|
|
740
740
|
| `SAP_ALLOW_TRANSPORTABLE_EDITS` | Generated MCP entries set this to `true` to permit source edits in transportable packages; VSP safety checks and SAP authorizations still apply. |
|
|
741
|
-
| `
|
|
742
|
-
| `SAP_AI_DEV_TOOLKIT_HTTP_PROXY` | Egress proxy for relay and discovery traffic. Takes precedence over `HTTP_PROXY`/`http_proxy`; unset means the default BAS proxy for `.dest` hosts, empty means direct. |
|
|
743
|
-
| `SAP_AI_DEV_TOOLKIT_MAX_CSRF_RETRIES` | Bounded retries after explicit CSRF-session rejection per unsafe request (default 3, maximum 10). Invalid values use the default. |
|
|
741
|
+
| `SAP_AI_DEV_TOOLKIT_HTTP_PROXY` | Egress proxy for BAS discovery and probe traffic. Takes precedence over `HTTP_PROXY`/`http_proxy`; unset means the default BAS proxy for `.dest` hosts, empty means direct. |
|
|
744
742
|
| `SAP_AI_DEV_TOOLKIT_REQUEST_TIMEOUT_MS` | Per-request timeout for calls forwarded to a VSP child (default 600000 = 10 minutes; `0` disables). A stalled request fails with a timeout error; the child is left running. |
|
|
745
743
|
| `SAP_AI_DEV_MCP_CONFIG` | Explicit MCP configuration path; highest precedence. |
|
|
746
744
|
| `SAP_AI_DEV_TOOLKIT_MCP_CONFIG` | Branded compatibility alias for the MCP configuration path. |
|
|
@@ -757,18 +755,13 @@ The response contains a `tools` array. A `RunQuery` entry resembles this excerpt
|
|
|
757
755
|
|
|
758
756
|
#### Self-healing modes
|
|
759
757
|
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
1. **CSRF session pairing** — a scoped cookie jar retains session cookies received during safe reads and token fetches, applies updates and expirations, and replays the matching token/cookie pair on POST/PUT/PATCH/DELETE. Tokens are session-scoped and always fetched from the GET-capable `/sap/bc/adt/discovery` endpoint, so unsafe requests to POST-only services (ADT data preview) are covered too. If no token can be obtained at all, the relay fails closed and does not send the unsafe request. A token that arrives without session cookies is still used — BAS proxy routes strip `Set-Cookie`, yet the backend accepts the token on the proxy-established Basic-auth session — and a CSRF rejection on such a cookie-less route is surfaced without pointless retries.
|
|
763
|
-
2. **Session refresh** — when SAP explicitly rejects a CSRF token, the cached session is dropped, a fresh token+cookie pair is fetched, and the request is retried within the configured bound. Generic authorization 401/403 responses are returned without being mislabeled or retried as CSRF failures.
|
|
764
|
-
3. **Proxy tunnel fallback** — when the BAS proxy refuses absolute-form requests (502/504 or transport errors), the relay switches to a CONNECT tunnel through the same proxy and keeps going.
|
|
765
|
-
4. **Child crash recovery** — a crashed VSP child is restarted transparently, re-initialized, tools re-registered, and the interrupted `tools/call` retried once before any error reaches the client.
|
|
766
|
-
5. **Configured destination authentication** — the add-on always uses the URL, authentication mode, and credentials already configured for the selected destination. Setup does not prompt for or store SAP usernames/passwords, and the relay does not connect directly to a backend host as a credential fallback. Correct the credentials in BAS or the Cloud Foundry Destination service if SAP rejects them.
|
|
758
|
+
VSP children connect to each configured destination URL directly. BAS destination authentication remains selected through VSP's `--proxy-auth` path; Cloud Foundry on-premise destinations continue through the separate Connectivity proxy.
|
|
759
|
+
Child crash recovery restarts a failed VSP child, re-initializes it, re-registers tools, and retries the interrupted `tools/call` once before surfacing an error. State-changing tools are not retried.
|
|
767
760
|
Existing installs that still set the previous `BAS_VSP_*` environment variables remain supported. The setup wizard writes new MCP entries with the `SAP_AI_DEV_TOOLKIT_*` names.
|
|
768
761
|
|
|
769
762
|
#### Server logging
|
|
770
763
|
|
|
771
|
-
Every observable event — startup banner (version, PID, VSP binary), destination discovery,
|
|
764
|
+
Every observable event — startup banner (version, PID, VSP binary), destination discovery, VSP session initialization, each JSON-RPC request with its id, every tool call with duration and outcome, self-healing restarts, and errors — is logged twice: to **stderr** with ISO timestamps (visible when the server runs in a terminal or in hosts that surface stderr), and to the host's **MCP server output channel** as standard `notifications/message` log notifications (VS Code/BAS and Claude Code render these under the server's output). Log notifications start at `info`; send `logging/setLevel` with `debug` (or higher) from the client to adjust what reaches the channel — stderr always receives everything. Credentials, cookies, and tokens are redacted.
|
|
772
765
|
|
|
773
766
|
<a id="troubleshooting"></a>
|
|
774
767
|
|
|
@@ -785,6 +778,8 @@ Next, check that the backend answers at `/sap/bc/adt`.
|
|
|
785
778
|
|
|
786
779
|
Runtime diagnostics take the stderr lane. In the MCP server's Output view, check per-destination ADT probe status, VSP child stderr, and failed tool-call details; stdout is reserved for MCP protocol messages.
|
|
787
780
|
|
|
781
|
+
If `GetSystemInfo` or `RunQuery` reports a CSRF-token fetch failure, VSP tries `/sap/bc/adt/core/discovery` first and falls back to `/sap/bc/adt/discovery` when the core endpoint returns HTTP 404 or 5xx without a token. If both endpoints fail, the error includes their HTTP statuses; check BAS destination routing and the SAP ADT service. `GetInstalledComponents` is a separate GET request and a failure there is not evidence of a CSRF problem.
|
|
782
|
+
|
|
788
783
|
If automatic Go or VSP provisioning fails, check network access and the package's supported platform. You can install Go manually or set `GO_BINARY` as an explicit fallback. A trusted prebuilt VSP can be supplied with `SAP_AI_DEV_TOOLKIT_BINARY`.
|
|
789
784
|
|
|
790
785
|
## 🧑💻 Development
|
package/dist/checksums.txt
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
8ab555658815db7de670b956dafe0f12d6a249e4f1e1cbbb058deb658a89a37d vsp-linux-x64
|
|
2
|
+
7011b716e295ce2cecc40b9a56e57d9a1ec24c2b9d6aa25ab46e5cde0ec23998 vsp-linux-arm64
|
|
3
|
+
5c240a6b53e15ebaae817612aed1c5b95624a99adfc38e6abb9afa377de30d1a vsp-darwin-x64
|
|
4
|
+
28032c4d331253808ec9318e6db570420c6c9308935964de665e34374465d89f vsp-darwin-arm64
|
|
5
|
+
5bb45857489bbe5d57587d9abda0b768d3a1b352d89a2a6bf13b23af8d2fd45e vsp-win32-x64.exe
|
package/dist/vsp-darwin-arm64
CHANGED
|
Binary file
|
package/dist/vsp-darwin-x64
CHANGED
|
Binary file
|
package/dist/vsp-linux-arm64
CHANGED
|
Binary file
|
package/dist/vsp-linux-x64
CHANGED
|
Binary file
|
package/dist/vsp-win32-x64.exe
CHANGED
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sap-ai-dev-toolkit",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.6",
|
|
4
4
|
"description": "SAP AI development toolkit for BAS, ABAP, RAP, CAP, HANA Cloud, Fiori, UI5, and MCP",
|
|
5
5
|
"author": "Gurkan Yilmaz",
|
|
6
6
|
"type": "module",
|
|
@@ -12,7 +12,6 @@
|
|
|
12
12
|
"@modelcontextprotocol/sdk": "1.30.1",
|
|
13
13
|
"@sap/hana-client": "2.30.27",
|
|
14
14
|
"terminal-kit": "^3.1.4",
|
|
15
|
-
"tough-cookie": "^5.1.2",
|
|
16
15
|
"undici": "^6.21.3",
|
|
17
16
|
"zod": "4.6.5"
|
|
18
17
|
},
|
|
@@ -308,3 +308,128 @@ diff --git a/internal/mcp/tools_register.go b/internal/mcp/tools_register.go
|
|
|
308
308
|
+ }),
|
|
309
309
|
),
|
|
310
310
|
), s.handleActivateMultiple)
|
|
311
|
+
}
|
|
312
|
+
diff --git a/pkg/adt/http.go b/pkg/adt/http.go
|
|
313
|
+
--- a/pkg/adt/http.go
|
|
314
|
+
+++ b/pkg/adt/http.go
|
|
315
|
+
@@ -499,6 +499,7 @@ func (t *Transport) fetchCSRFTokenFor(ctx context.Context, stateful bool) error
|
|
316
|
+
// allowReauth exists to break a cycle: re-authenticating ends with a token
|
|
317
|
+
// fetch of its own, and that fetch must not start another re-authentication.
|
|
318
|
+
func (t *Transport) fetchCSRFTokenWithReauth(ctx context.Context, allowReauth bool, stateful bool) error {
|
|
319
|
+
+ var alternateFailure string
|
|
320
|
+
token, status, redirected, err := t.probeCSRFToken(ctx, http.MethodHead, stateful)
|
|
321
|
+
if err != nil {
|
|
322
|
+
return err
|
|
323
|
+
@@ -513,6 +514,29 @@ func (t *Transport) fetchCSRFTokenWithReauth(ctx context.Context, allowReauth bo
|
|
324
|
+
if err != nil {
|
|
325
|
+
return err
|
|
326
|
+
}
|
|
327
|
+
+ // Some ADT installations return 500 from /core/discovery even though
|
|
328
|
+
+ // the standard /discovery endpoint can issue a token. Keep the
|
|
329
|
+
+ // existing fast path, but do not make every POST depend on the
|
|
330
|
+
+ // failing endpoint.
|
|
331
|
+
+ if !isCSRFToken(token) && (getStatus >= 500 || getStatus == http.StatusNotFound) {
|
|
332
|
+
+ coreStatus := getStatus
|
|
333
|
+
+ token, status, redirected, err = t.probeCSRFTokenAt(ctx, http.MethodHead, stateful, "/sap/bc/adt/discovery")
|
|
334
|
+
+ if err != nil {
|
|
335
|
+
+ return err
|
|
336
|
+
+ }
|
|
337
|
+
+ if !isCSRFToken(token) {
|
|
338
|
+
+ if status == http.StatusUnauthorized && !t.canReauth() {
|
|
339
|
+
+ return fmt.Errorf("authentication failed (401): check username/password")
|
|
340
|
+
+ }
|
|
341
|
+
+ token, getStatus, getRedirected, err = t.probeCSRFTokenAt(ctx, http.MethodGet, stateful, "/sap/bc/adt/discovery")
|
|
342
|
+
+ if err != nil {
|
|
343
|
+
+ return err
|
|
344
|
+
+ }
|
|
345
|
+
+ if !isCSRFToken(token) {
|
|
346
|
+
+ alternateFailure = fmt.Sprintf("no CSRF token from /sap/bc/adt/core/discovery (GET %d) or /sap/bc/adt/discovery (HEAD %d, GET %d)", coreStatus, status, getStatus)
|
|
347
|
+
+ }
|
|
348
|
+
+ }
|
|
349
|
+
+ }
|
|
350
|
+
if !isCSRFToken(token) {
|
|
351
|
+
// An expired SSO session rarely announces itself as a 401. ICF sends
|
|
352
|
+
// the request on to the identity provider, the redirect chain is
|
|
353
|
+
@@ -537,6 +561,9 @@ func (t *Transport) fetchCSRFTokenWithReauth(ctx context.Context, allowReauth bo
|
|
354
|
+
}
|
|
355
|
+
return t.fetchCSRFTokenWithReauth(ctx, false, stateful)
|
|
356
|
+
}
|
|
357
|
+
+ if alternateFailure != "" {
|
|
358
|
+
+ return errors.New(alternateFailure)
|
|
359
|
+
+ }
|
|
360
|
+
switch getStatus {
|
|
361
|
+
case http.StatusUnauthorized:
|
|
362
|
+
return fmt.Errorf("authentication failed (401): check username/password")
|
|
363
|
+
@@ -556,7 +583,11 @@ func (t *Transport) fetchCSRFTokenWithReauth(ctx context.Context, allowReauth bo
|
|
364
|
+
// returns the token (empty when the server did not supply one), the status, and
|
|
365
|
+
// whether the answer came from somewhere other than the SAP host.
|
|
366
|
+
func (t *Transport) probeCSRFToken(ctx context.Context, method string, stateful bool) (token string, status int, redirected bool, err error) {
|
|
367
|
+
- reqURL, err := t.buildURL("/sap/bc/adt/core/discovery", nil)
|
|
368
|
+
+ return t.probeCSRFTokenAt(ctx, method, stateful, "/sap/bc/adt/core/discovery")
|
|
369
|
+
+}
|
|
370
|
+
+
|
|
371
|
+
+func (t *Transport) probeCSRFTokenAt(ctx context.Context, method string, stateful bool, path string) (token string, status int, redirected bool, err error) {
|
|
372
|
+
+ reqURL, err := t.buildURL(path, nil)
|
|
373
|
+
if err != nil {
|
|
374
|
+
return "", 0, false, fmt.Errorf("building URL: %w", err)
|
|
375
|
+
}
|
|
376
|
+
diff --git a/pkg/adt/http_csrf_discovery_test.go b/pkg/adt/http_csrf_discovery_test.go
|
|
377
|
+
new file mode 100644
|
|
378
|
+
--- /dev/null
|
|
379
|
+
+++ b/pkg/adt/http_csrf_discovery_test.go
|
|
380
|
+
@@ -0,0 +1,55 @@
|
|
381
|
+
+package adt
|
|
382
|
+
+
|
|
383
|
+
+import (
|
|
384
|
+
+ "context"
|
|
385
|
+
+ "net/http"
|
|
386
|
+
+ "net/http/httptest"
|
|
387
|
+
+ "strings"
|
|
388
|
+
+ "testing"
|
|
389
|
+
+)
|
|
390
|
+
+
|
|
391
|
+
+func TestCSRFAlternateDiscoveryOnCoreFailure(t *testing.T) {
|
|
392
|
+
+ var requests []string
|
|
393
|
+
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
394
|
+
+ requests = append(requests, r.Method+" "+r.URL.Path)
|
|
395
|
+
+ switch r.URL.Path {
|
|
396
|
+
+ case "/sap/bc/adt/core/discovery":
|
|
397
|
+
+ w.WriteHeader(http.StatusInternalServerError)
|
|
398
|
+
+ case "/sap/bc/adt/discovery":
|
|
399
|
+
+ w.Header().Set("X-CSRF-Token", "valid-token")
|
|
400
|
+
+ case "/sap/bc/adt/datapreview/freestyle":
|
|
401
|
+
+ if r.Header.Get("X-CSRF-Token") != "valid-token" {
|
|
402
|
+
+ t.Errorf("POST missing fetched token")
|
|
403
|
+
+ w.WriteHeader(http.StatusForbidden)
|
|
404
|
+
+ }
|
|
405
|
+
+ default:
|
|
406
|
+
+ t.Errorf("unexpected request %s", r.URL.Path)
|
|
407
|
+
+ }
|
|
408
|
+
+ }))
|
|
409
|
+
+ defer srv.Close()
|
|
410
|
+
+ tr := newCSRFTestTransport(t, srv)
|
|
411
|
+
+ _, err := tr.Request(context.Background(), "/sap/bc/adt/datapreview/freestyle", &RequestOptions{Method: http.MethodPost, Body: []byte("SELECT * FROM T000")})
|
|
412
|
+
+ if err != nil {
|
|
413
|
+
+ t.Fatalf("read-only POST failed: %v", err)
|
|
414
|
+
+ }
|
|
415
|
+
+ want := []string{"HEAD /sap/bc/adt/core/discovery", "GET /sap/bc/adt/core/discovery", "HEAD /sap/bc/adt/discovery", "POST /sap/bc/adt/datapreview/freestyle"}
|
|
416
|
+
+ if strings.Join(requests, "|") != strings.Join(want, "|") {
|
|
417
|
+
+ t.Errorf("requests = %v, want %v", requests, want)
|
|
418
|
+
+ }
|
|
419
|
+
+}
|
|
420
|
+
+
|
|
421
|
+
+func TestCSRFAlternateDiscoveryFailureIncludesStatuses(t *testing.T) {
|
|
422
|
+
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
423
|
+
+ if r.URL.Path == "/sap/bc/adt/core/discovery" {
|
|
424
|
+
+ w.WriteHeader(http.StatusInternalServerError)
|
|
425
|
+
+ } else {
|
|
426
|
+
+ w.WriteHeader(http.StatusForbidden)
|
|
427
|
+
+ }
|
|
428
|
+
+ }))
|
|
429
|
+
+ defer srv.Close()
|
|
430
|
+
+ tr := newCSRFTestTransport(t, srv)
|
|
431
|
+
+ err := tr.fetchCSRFToken(context.Background())
|
|
432
|
+
+ if err == nil || !strings.Contains(err.Error(), "500") || !strings.Contains(err.Error(), "403") {
|
|
433
|
+
+ t.Fatalf("expected both endpoint failures, got %v", err)
|
|
434
|
+
+ }
|
|
435
|
+
+}
|
package/src/launcher.mjs
CHANGED
|
@@ -89,16 +89,6 @@ async function runDoctor(destinations) {
|
|
|
89
89
|
const initialized = await proxy.handle({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05' } });
|
|
90
90
|
if (initialized?.error) throw new Error(initialized.error.message || 'MCP initialization failed');
|
|
91
91
|
checks.push(doctorRow(destination.name, 'VSP MCP startup', 'passed', 'initialized'));
|
|
92
|
-
const relayedDestination = proxy.children[0]?.destination;
|
|
93
|
-
if (relayedDestination?.relay) {
|
|
94
|
-
checks.push(doctorRow(destination.name, 'BAS destination relay', 'passed', `enabled (${relayedDestination.url}); runtime session preflight pending`));
|
|
95
|
-
} else if (destination.source === 'cloud-foundry') {
|
|
96
|
-
checks.push(doctorRow(destination.name, 'BAS destination relay', 'skipped', 'Cloud Foundry destinations connect through the connectivity proxy directly.'));
|
|
97
|
-
} else if (String(runtimeEnv.SAP_AI_DEV_TOOLKIT_DISABLE_BAS_RELAY || '').toLowerCase() === 'true') {
|
|
98
|
-
checks.push(doctorRow(destination.name, 'BAS destination relay', 'skipped', 'Disabled through SAP_AI_DEV_TOOLKIT_DISABLE_BAS_RELAY.'));
|
|
99
|
-
} else {
|
|
100
|
-
checks.push(doctorRow(destination.name, 'BAS destination relay', 'skipped', 'Not applicable for this destination.'));
|
|
101
|
-
}
|
|
102
92
|
const listed = await proxy.handle({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} });
|
|
103
93
|
if (listed?.error) throw new Error(listed.error.message || 'MCP tools/list failed');
|
|
104
94
|
const tools = listed.result?.tools || [];
|
|
@@ -114,32 +104,9 @@ async function runDoctor(destinations) {
|
|
|
114
104
|
if (!systemInfo) {
|
|
115
105
|
checks.push(doctorRow(destination.name, 'SAP system check', 'skipped', 'GetSystemInfo is not exposed by this VSP mode.'));
|
|
116
106
|
} else {
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
let preflightError;
|
|
121
|
-
try {
|
|
122
|
-
session = await relayedDestination.relay.probeCsrfSession();
|
|
123
|
-
} catch (error) {
|
|
124
|
-
preflightError = redactText(error.message || error).slice(0, 250);
|
|
125
|
-
}
|
|
126
|
-
sessionReady = session?.sessionUsable === true;
|
|
127
|
-
const status = sessionReady ? 'passed' : 'failed';
|
|
128
|
-
const stats = relayedDestination.relay.stats || {};
|
|
129
|
-
const detail = preflightError
|
|
130
|
-
? `preflight request failed: ${preflightError}`
|
|
131
|
-
: `HTTP ${session?.httpStatus || 0}; token=${session?.tokenReceived ? 'received' : 'missing'}; cookies=${session?.cookieCount || 0}; usableSession=${sessionReady}; csrfFetches=${stats.csrfFetches || 0}; csrfRetries=${stats.csrfRetries || 0}; sessionFailures=${stats.csrfSessionFailures || 0}; tunnelFallbacks=${stats.tunnelFallbacks || 0}`;
|
|
132
|
-
checks.push(doctorRow(destination.name, 'BAS relay CSRF preflight', status, detail));
|
|
133
|
-
if (!sessionReady) {
|
|
134
|
-
const remediation = 'GetSystemInfo was not called because the relay could not establish a paired CSRF token/session. Confirm the configured destination authentication and BAS proxy route.';
|
|
135
|
-
checks.push(doctorRow(destination.name, 'SAP system check', 'skipped', preflightError ? `${remediation} Relay error: ${preflightError}` : remediation));
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
if (sessionReady) {
|
|
139
|
-
const inspected = await proxy.handle({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: systemInfo.name, arguments: {} } });
|
|
140
|
-
const failed = inspected?.error || inspected?.result?.isError;
|
|
141
|
-
checks.push(doctorRow(destination.name, 'SAP system check', failed ? 'failed' : 'passed', failed ? redactText(inspected?.error?.message || inspected?.result?.content?.[0]?.text || 'GetSystemInfo failed') : 'GetSystemInfo returned successfully'));
|
|
142
|
-
}
|
|
107
|
+
const inspected = await proxy.handle({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: systemInfo.name, arguments: {} } });
|
|
108
|
+
const failed = inspected?.error || inspected?.result?.isError;
|
|
109
|
+
checks.push(doctorRow(destination.name, 'SAP system check', failed ? 'failed' : 'passed', failed ? redactText(inspected?.error?.message || inspected?.result?.content?.[0]?.text || 'GetSystemInfo failed') : 'GetSystemInfo returned successfully'));
|
|
143
110
|
}
|
|
144
111
|
} catch (error) {
|
|
145
112
|
const detail = redactText(error.message || error).slice(0, 300);
|
package/src/mcp-proxy.mjs
CHANGED
|
@@ -4,7 +4,6 @@ import { sanitizeChildEnv, slugifyDestination } from './bas-discovery.mjs';
|
|
|
4
4
|
import { ABAP_LINT_TOOL, runABAPLint } from './abaplint.mjs';
|
|
5
5
|
import { createEngineeringTools } from './engineering-tools.mjs';
|
|
6
6
|
import { brandedEnvValue } from './branding.mjs';
|
|
7
|
-
import { createBasDestinationRelay } from './bas-destination-relay.mjs';
|
|
8
7
|
import { diagnosticText, redactText } from './redact.mjs';
|
|
9
8
|
|
|
10
9
|
const JSONRPC = '2.0';
|
|
@@ -167,25 +166,22 @@ function closeDestinationRoute(destination) {
|
|
|
167
166
|
return closedRoutes.get(destination);
|
|
168
167
|
}
|
|
169
168
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
if (!destination.authentication) return false;
|
|
174
|
-
return Boolean(destination.url && String(destination.url).includes('.dest'));
|
|
169
|
+
|
|
170
|
+
function useProxyAuthentication(destination) {
|
|
171
|
+
return destination.source !== 'cloud-foundry' || destination.authentication === 'PrincipalPropagation';
|
|
175
172
|
}
|
|
176
173
|
|
|
177
174
|
function childEnvironment(destination, env) {
|
|
178
175
|
const childEnv = { ...sanitizeChildEnv(env), ...(destination.childEnv || {}) };
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
const
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
}
|
|
176
|
+
if (useProxyAuthentication(destination)) {
|
|
177
|
+
// VSP loads SAP_* values from local .env files. Set every supported local
|
|
178
|
+
// auth variable explicitly empty so dotenv cannot supply a fallback
|
|
179
|
+
// identity or session instead of the selected BAS destination identity.
|
|
180
|
+
for (const key of [
|
|
181
|
+
'SAP_USER', 'SAP_USERNAME', 'SAP_PASSWORD', 'SAP_PASS',
|
|
182
|
+
'SAP_COOKIE_FILE', 'SAP_COOKIE_STRING', 'SAP_BROWSER_AUTH', 'SAP_SSO',
|
|
183
|
+
'SAP_SAML_AUTH', 'SAP_SAML_USER', 'SAP_SAML_PASSWORD', 'SAP_CREDENTIAL_CMD'
|
|
184
|
+
]) childEnv[key] = '';
|
|
189
185
|
}
|
|
190
186
|
return childEnv;
|
|
191
187
|
}
|
|
@@ -196,7 +192,7 @@ export function childArguments(destination, env = process.env) {
|
|
|
196
192
|
// controlled by SAP_ALLOW_TRANSPORTABLE_EDITS and SAP authorizations.
|
|
197
193
|
const mode = brandedEnvValue(env, 'MODE') || 'expert';
|
|
198
194
|
const args = ['--url', destination.url, '--client', destination.client || '001', '--mode', mode];
|
|
199
|
-
if (destination
|
|
195
|
+
if (useProxyAuthentication(destination)) args.push('--proxy-auth');
|
|
200
196
|
args.push('--enable-transports');
|
|
201
197
|
return args;
|
|
202
198
|
}
|
|
@@ -231,14 +227,12 @@ class Child {
|
|
|
231
227
|
this.process.on('error', error => {
|
|
232
228
|
if (!this.closing && !this.pending.size && this.options.log) this.options.log(`[${destination.name}] VSP child process error: ${diagnosticText(error.message)}`);
|
|
233
229
|
this.fail(error);
|
|
234
|
-
|
|
235
|
-
// them; they are closed by the proxy shutdown path instead.
|
|
236
|
-
if (!destination.relay) void closeDestinationRoute(destination);
|
|
230
|
+
void closeDestinationRoute(destination);
|
|
237
231
|
});
|
|
238
232
|
this.process.on('exit', (code, signal) => {
|
|
239
233
|
if (!this.closing && !this.pending.size && this.options.log) this.options.log(`[${destination.name}] VSP child exited (${code ?? signal})`);
|
|
240
234
|
this.fail(new Error(`child exited (${code ?? signal})`));
|
|
241
|
-
|
|
235
|
+
void closeDestinationRoute(destination);
|
|
242
236
|
});
|
|
243
237
|
}
|
|
244
238
|
|
|
@@ -428,34 +422,10 @@ export class MCPProxy {
|
|
|
428
422
|
if (this.started) return this;
|
|
429
423
|
this.started = true;
|
|
430
424
|
this.starting = (async () => {
|
|
431
|
-
// Destinations start concurrently
|
|
432
|
-
//
|
|
433
|
-
|
|
434
|
-
const started = await Promise.all(this.destinations.map(originalDestination => (async () => {
|
|
435
|
-
let destination = originalDestination;
|
|
436
|
-
let relay;
|
|
425
|
+
// Destinations start concurrently and are assembled in destination order,
|
|
426
|
+
// keeping serverInfo's children[0] fallback and merged slug numbering deterministic.
|
|
427
|
+
const started = await Promise.all(this.destinations.map(destination => (async () => {
|
|
437
428
|
try {
|
|
438
|
-
if (useBasDestinationRelay(originalDestination, this.env)) {
|
|
439
|
-
relay = createBasDestinationRelay(originalDestination, { env: this.env, log: message => this.eventSink(message) });
|
|
440
|
-
const originalClose = originalDestination.close;
|
|
441
|
-
const relayUrl = await relay.ready;
|
|
442
|
-
destination = {
|
|
443
|
-
...originalDestination,
|
|
444
|
-
url: relayUrl,
|
|
445
|
-
relay: {
|
|
446
|
-
probeCsrfSession: relay.probeCsrfSession,
|
|
447
|
-
stats: relay.stats
|
|
448
|
-
},
|
|
449
|
-
async close() {
|
|
450
|
-
try {
|
|
451
|
-
await originalClose?.();
|
|
452
|
-
} finally {
|
|
453
|
-
await relay.close();
|
|
454
|
-
}
|
|
455
|
-
}
|
|
456
|
-
};
|
|
457
|
-
this.eventSink(`[${originalDestination.name}] BAS destination relay enabled (${relayUrl})`);
|
|
458
|
-
}
|
|
459
429
|
this.eventSink(`[${destination.name}] starting VSP child (client=${destination.client || '001'})`);
|
|
460
430
|
return {
|
|
461
431
|
destination,
|
|
@@ -463,8 +433,6 @@ export class MCPProxy {
|
|
|
463
433
|
};
|
|
464
434
|
} catch (error) {
|
|
465
435
|
this.eventSink(`[${destination.name}] failed to start child: ${diagnosticText(error.message)}`, 'error');
|
|
466
|
-
// A relay whose ready promise rejected still owns dispatchers.
|
|
467
|
-
if (relay && destination === originalDestination) await relay.close().catch(() => {});
|
|
468
436
|
void closeDestinationRoute(destination);
|
|
469
437
|
return null;
|
|
470
438
|
}
|
|
@@ -1,377 +0,0 @@
|
|
|
1
|
-
import http from 'node:http';
|
|
2
|
-
import { fetch as undiciFetch, ProxyAgent } from 'undici';
|
|
3
|
-
import { CookieJar } from 'tough-cookie';
|
|
4
|
-
import { brandedEnvValue } from './branding.mjs';
|
|
5
|
-
import { redactText } from './redact.mjs';
|
|
6
|
-
|
|
7
|
-
const DEFAULT_PROXY = 'http://127.0.0.1:8887';
|
|
8
|
-
const MAX_BODY_BYTES = 64 * 1024 * 1024;
|
|
9
|
-
const HOP_BY_HOP = new Set([
|
|
10
|
-
'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization',
|
|
11
|
-
'proxy-connection', 'te', 'trailer', 'transfer-encoding', 'upgrade', 'host'
|
|
12
|
-
]);
|
|
13
|
-
const UNSAFE = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
|
14
|
-
// SAP ties CSRF tokens to the session that fetched them. The BAS .dest proxy
|
|
15
|
-
// authenticates each hop, so the relay pairs every token with the exact
|
|
16
|
-
// Set-Cookie state SAP returned alongside it and replays both on the unsafe
|
|
17
|
-
// request. Without this pairing SAP sees a token from a foreign session and
|
|
18
|
-
// answers "CSRF token validation failed" with HTTP 403.
|
|
19
|
-
// Tokens are session-scoped, not path-scoped, but SAP only issues them from
|
|
20
|
-
// GET-capable endpoints. POST-only services (ADT data preview among them)
|
|
21
|
-
// answer a same-path token fetch with 4xx, so the relay always fetches from
|
|
22
|
-
// the GET-friendly discovery endpoint and accepts that session's token for
|
|
23
|
-
// unsafe requests to any path.
|
|
24
|
-
const CSRF_FETCH_PATH = '/sap/bc/adt/discovery';
|
|
25
|
-
const TOKEN_TTL_MS = 15 * 60 * 1000;
|
|
26
|
-
const DEFAULT_MAX_TOKEN_FAILURES = 3;
|
|
27
|
-
const MAX_CONFIGURED_TOKEN_FAILURES = 10;
|
|
28
|
-
const MAX_TUNNEL_FALLBACKS = 2;
|
|
29
|
-
// Carrying a POST's content-length on the token-fetch GET would make the
|
|
30
|
-
// proxy wait for a body that never arrives, so the relay drops body headers.
|
|
31
|
-
const BODY_HEADERS = new Set(['content-length', 'content-type']);
|
|
32
|
-
// The forwarded request's content-length must describe the body undici is
|
|
33
|
-
// about to send. Forwarding the inbound value alongside a re-buffered body
|
|
34
|
-
// makes undici reject the request (UND_ERR_INVALID_ARG), so it is always
|
|
35
|
-
// recomputed from the actual payload.
|
|
36
|
-
const LENGTH_HEADERS = new Set(['content-length', 'transfer-encoding']);
|
|
37
|
-
// undici transparently decompresses gzip/deflate/br responses but keeps the
|
|
38
|
-
// content-encoding header, so forwarding the client's accept-encoding would
|
|
39
|
-
// hand VSP a "gzip" body that is actually plain bytes. Dropping the header
|
|
40
|
-
// lets undici request identity encoding and keeps bodies consistent.
|
|
41
|
-
const ENCODING_HEADERS = new Set(['accept-encoding', 'content-encoding']);
|
|
42
|
-
|
|
43
|
-
function headersFrom(requestHeaders, extra = {}) {
|
|
44
|
-
const headers = {};
|
|
45
|
-
for (const [name, value] of Object.entries(requestHeaders || {})) {
|
|
46
|
-
const lower = name.toLowerCase();
|
|
47
|
-
if (HOP_BY_HOP.has(lower) || LENGTH_HEADERS.has(lower) || ENCODING_HEADERS.has(lower)) continue;
|
|
48
|
-
if (value !== undefined) headers[name] = Array.isArray(value) ? value.join(', ') : String(value);
|
|
49
|
-
}
|
|
50
|
-
return { ...headers, ...extra };
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
function fetchHeaders(requestHeaders, extra = {}) {
|
|
54
|
-
const headers = {};
|
|
55
|
-
for (const [name, value] of Object.entries(requestHeaders || {})) {
|
|
56
|
-
const lower = name.toLowerCase();
|
|
57
|
-
if (HOP_BY_HOP.has(lower) || BODY_HEADERS.has(lower) || ENCODING_HEADERS.has(lower)) continue;
|
|
58
|
-
if (value !== undefined) headers[name] = Array.isArray(value) ? value.join(', ') : String(value);
|
|
59
|
-
}
|
|
60
|
-
return { ...headers, ...extra };
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function tunnelFailure(status) {
|
|
64
|
-
// 502/504 from the proxy itself means the absolute-form .dest request is
|
|
65
|
-
// being refused; worth retrying through a CONNECT tunnel.
|
|
66
|
-
return status === 502 || status === 504;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
function csrfFailure(status, headers, body) {
|
|
70
|
-
return status === 403 && (
|
|
71
|
-
String(headers.get?.('x-csrf-token') || '').toLowerCase() === 'required' ||
|
|
72
|
-
String(body || '').toLowerCase().includes('csrf token validation failed')
|
|
73
|
-
);
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
function maxTokenFailures(env, log, destinationName) {
|
|
77
|
-
const configured = brandedEnvValue(env, 'MAX_CSRF_RETRIES');
|
|
78
|
-
if (configured === undefined || configured === '') return DEFAULT_MAX_TOKEN_FAILURES;
|
|
79
|
-
const parsed = Number(configured);
|
|
80
|
-
if (!Number.isSafeInteger(parsed) || parsed < 0) {
|
|
81
|
-
log(`[${destinationName}] invalid CSRF retry setting; using the default of ${DEFAULT_MAX_TOKEN_FAILURES}`);
|
|
82
|
-
return DEFAULT_MAX_TOKEN_FAILURES;
|
|
83
|
-
}
|
|
84
|
-
return Math.min(parsed, MAX_CONFIGURED_TOKEN_FAILURES);
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
function copyCookieJar(jar) {
|
|
88
|
-
return CookieJar.deserializeSync(jar.serializeSync());
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
async function cookieHeader(jar, target) {
|
|
92
|
-
return await jar.getCookieString(target.href) || undefined;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
async function seedRequestCookies(jar, requestCookie, target) {
|
|
96
|
-
for (const pair of String(requestCookie || '').split(';')) {
|
|
97
|
-
const separator = pair.indexOf('=');
|
|
98
|
-
if (separator <= 0) continue;
|
|
99
|
-
const name = pair.slice(0, separator).trim();
|
|
100
|
-
const value = pair.slice(separator + 1).trim();
|
|
101
|
-
if (!name) continue;
|
|
102
|
-
try {
|
|
103
|
-
await jar.setCookie(`${name}=${value}; Path=/`, target.href, { ignoreError: true });
|
|
104
|
-
} catch {
|
|
105
|
-
// Invalid caller-provided cookie fragments are ignored; SAP response
|
|
106
|
-
// cookies remain authoritative for the destination session.
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
async function recordCookies(jar, headers, target, log, destinationName) {
|
|
112
|
-
const values = headers.getSetCookie?.() || (headers.get('set-cookie') ? [headers.get('set-cookie')] : []);
|
|
113
|
-
for (const value of values) {
|
|
114
|
-
try {
|
|
115
|
-
await jar.setCookie(String(value), target.href, { ignoreError: true });
|
|
116
|
-
} catch {
|
|
117
|
-
log(`[${destinationName}] BAS relay ignored an invalid Set-Cookie header`);
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
async function readRequestBody(req) {
|
|
123
|
-
const chunks = [];
|
|
124
|
-
let size = 0;
|
|
125
|
-
for await (const chunk of req) {
|
|
126
|
-
size += chunk.length;
|
|
127
|
-
if (size > MAX_BODY_BYTES) throw new Error('request body is too large for BAS destination relay');
|
|
128
|
-
chunks.push(chunk);
|
|
129
|
-
}
|
|
130
|
-
return Buffer.concat(chunks);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
export function createBasDestinationRelay(destination, { env = process.env, fetchImpl = undiciFetch, log = () => {} } = {}) {
|
|
134
|
-
if (!destination?.url) throw new Error('Destination relay requires a BAS destination URL');
|
|
135
|
-
const base = new URL(destination.url);
|
|
136
|
-
const cookieBase = destination.backendUrl
|
|
137
|
-
? new URL(String(destination.backendUrl))
|
|
138
|
-
: (() => {
|
|
139
|
-
const target = new URL(base);
|
|
140
|
-
// BAS .dest URLs are local HTTP routing aliases for SAP backends that
|
|
141
|
-
// are commonly HTTPS. Use a secure cookie context when BAS metadata
|
|
142
|
-
// omits the backend URL so Secure session cookies are not discarded.
|
|
143
|
-
if (target.protocol === 'http:' && /\.dest$/i.test(target.hostname)) target.protocol = 'https:';
|
|
144
|
-
return target;
|
|
145
|
-
})();
|
|
146
|
-
// BAS destinations must keep their configured .dest route and authentication.
|
|
147
|
-
// Proxy resolution: an explicitly configured proxy wins (an explicit empty
|
|
148
|
-
// value means "go direct"). Without configuration, BAS virtual .dest hosts
|
|
149
|
-
// imply the default BAS proxy; any other host (tests, direct URLs) is
|
|
150
|
-
// reached directly so the relay never routes loopback traffic through a
|
|
151
|
-
// foreign proxy.
|
|
152
|
-
const brandedProxy = brandedEnvValue(env, 'HTTP_PROXY');
|
|
153
|
-
const explicitProxy = brandedProxy !== undefined ? brandedProxy : (env.HTTP_PROXY ?? env.http_proxy);
|
|
154
|
-
const proxyUrl = explicitProxy !== undefined ? String(explicitProxy) : (/\.dest$/i.test(base.hostname) ? DEFAULT_PROXY : '');
|
|
155
|
-
const dispatcher = proxyUrl ? new ProxyAgent({ uri: proxyUrl, proxyTunnel: false }) : undefined;
|
|
156
|
-
// Self-healing mode 2: if the BAS proxy refuses absolute-form proxied
|
|
157
|
-
// requests (its own 502/504), retry through a CONNECT tunnel so the relay
|
|
158
|
-
// keeps working through the same egress.
|
|
159
|
-
const tunnelDispatcher = proxyUrl ? new ProxyAgent({ uri: proxyUrl, proxyTunnel: true }) : undefined;
|
|
160
|
-
let tunneled = false;
|
|
161
|
-
// Observable self-healing counters, surfaced by --doctor and tests.
|
|
162
|
-
const stats = { requests: 0, csrfFetches: 0, csrfRetries: 0, csrfSessionFailures: 0, tunnelFallbacks: 0, direct: false, proxyTunnel: false };
|
|
163
|
-
const maxFailures = maxTokenFailures(env, log, destination.name);
|
|
164
|
-
// The shared jar captures SAP cookies from safe reads as well as CSRF fetches.
|
|
165
|
-
// Each cached token gets its own snapshot so later responses cannot silently
|
|
166
|
-
// pair an old token with a different session.
|
|
167
|
-
const sharedCookieJar = new CookieJar();
|
|
168
|
-
const tokenCache = new Map();
|
|
169
|
-
const tokenFetches = new Map();
|
|
170
|
-
|
|
171
|
-
function resolveCookieTarget(target) {
|
|
172
|
-
return new URL(`${target.pathname}${target.search}`, cookieBase);
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
async function send(target, method, headers, body) {
|
|
176
|
-
const useTunnel = tunneled && tunnelDispatcher;
|
|
177
|
-
return fetchImpl(target, {
|
|
178
|
-
method,
|
|
179
|
-
headers,
|
|
180
|
-
body: body?.length ? body : undefined,
|
|
181
|
-
...(useTunnel ? { dispatcher: tunnelDispatcher } : dispatcher ? { dispatcher } : {})
|
|
182
|
-
});
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
// Try absolute-form first; on transport errors or proxy-level failures,
|
|
186
|
-
// flip to tunneled mode once and retry (sticky for later requests).
|
|
187
|
-
async function sendWithFallback(target, method, headers, body) {
|
|
188
|
-
if (tunneled) return send(target, method, headers, body);
|
|
189
|
-
try {
|
|
190
|
-
const response = await send(target, method, headers, body);
|
|
191
|
-
if (!tunnelFailure(response.status)) return response;
|
|
192
|
-
log(`[${destination.name}] BAS relay proxy returned HTTP ${response.status} for ${target.pathname}; switching to proxy tunnel`);
|
|
193
|
-
} catch (error) {
|
|
194
|
-
log(`[${destination.name}] BAS relay absolute-form request failed for ${target.pathname} (${error.message}); switching to proxy tunnel`);
|
|
195
|
-
}
|
|
196
|
-
tunneled = true;
|
|
197
|
-
stats.tunnelFallbacks += 1;
|
|
198
|
-
return send(target, method, headers, body);
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
async function fetchTokenFresh(requestHeaders) {
|
|
202
|
-
const key = CSRF_FETCH_PATH;
|
|
203
|
-
stats.csrfFetches += 1;
|
|
204
|
-
const target = new URL(CSRF_FETCH_PATH, base);
|
|
205
|
-
const cookieTarget = resolveCookieTarget(target);
|
|
206
|
-
const jar = copyCookieJar(sharedCookieJar);
|
|
207
|
-
await seedRequestCookies(jar, requestHeaders.cookie, cookieTarget);
|
|
208
|
-
const outgoingCookie = await cookieHeader(jar, cookieTarget);
|
|
209
|
-
const response = await sendWithFallback(target, 'GET', fetchHeaders(requestHeaders, {
|
|
210
|
-
'x-csrf-token': 'Fetch',
|
|
211
|
-
accept: requestHeaders.accept || 'application/xml,*/*',
|
|
212
|
-
...(outgoingCookie ? { cookie: outgoingCookie } : {})
|
|
213
|
-
}));
|
|
214
|
-
await response.arrayBuffer().catch(() => undefined);
|
|
215
|
-
const token = response.headers.get('x-csrf-token');
|
|
216
|
-
await recordCookies(jar, response.headers, cookieTarget, log, destination.name);
|
|
217
|
-
await recordCookies(sharedCookieJar, response.headers, cookieTarget, log, destination.name);
|
|
218
|
-
const cookies = await jar.getCookies(cookieTarget.href);
|
|
219
|
-
const session = {
|
|
220
|
-
token: response.ok && token && token.toLowerCase() !== 'required' ? token : null,
|
|
221
|
-
jar,
|
|
222
|
-
fetchedAt: Date.now(),
|
|
223
|
-
httpStatus: response.status,
|
|
224
|
-
cookieCount: cookies.length
|
|
225
|
-
};
|
|
226
|
-
if (session.token) tokenCache.set(key, session);
|
|
227
|
-
return session;
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
async function fetchToken(requestHeaders = {}) {
|
|
231
|
-
const key = CSRF_FETCH_PATH;
|
|
232
|
-
const cached = tokenCache.get(key);
|
|
233
|
-
if (cached && Date.now() - cached.fetchedAt < TOKEN_TTL_MS) return cached;
|
|
234
|
-
const pending = tokenFetches.get(key);
|
|
235
|
-
if (pending) return pending;
|
|
236
|
-
const fetching = fetchTokenFresh(requestHeaders);
|
|
237
|
-
tokenFetches.set(key, fetching);
|
|
238
|
-
try {
|
|
239
|
-
return await fetching;
|
|
240
|
-
} finally {
|
|
241
|
-
if (tokenFetches.get(key) === fetching) tokenFetches.delete(key);
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
async function probeCsrfSession() {
|
|
246
|
-
const session = await fetchToken({ accept: 'application/xml,text/xml,*/*' });
|
|
247
|
-
return {
|
|
248
|
-
httpStatus: session?.httpStatus || 0,
|
|
249
|
-
tokenReceived: Boolean(session?.token),
|
|
250
|
-
cookieCount: session?.cookieCount || 0,
|
|
251
|
-
// A token alone makes unsafe requests viable: BAS proxy routes deliver
|
|
252
|
-
// tokens but strip cookies, and the backend accepts the token on the
|
|
253
|
-
// proxy-established Basic-auth session. cookieCount stays reported for
|
|
254
|
-
// diagnosis; setup's override validation requires cookies separately.
|
|
255
|
-
sessionUsable: Boolean(session?.token)
|
|
256
|
-
};
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
async function forward(method, requestUrl, requestHeaders, body, session) {
|
|
261
|
-
const target = requestUrl;
|
|
262
|
-
const cookieTarget = resolveCookieTarget(target);
|
|
263
|
-
const cookieJar = session?.jar || sharedCookieJar;
|
|
264
|
-
if (!session) await seedRequestCookies(cookieJar, requestHeaders.cookie, cookieTarget);
|
|
265
|
-
const sessionCookie = await cookieHeader(cookieJar, cookieTarget);
|
|
266
|
-
const headers = headersFrom(requestHeaders, {
|
|
267
|
-
...(session?.token ? { 'x-csrf-token': session.token } : {}),
|
|
268
|
-
...(sessionCookie ? { cookie: sessionCookie } : {})
|
|
269
|
-
});
|
|
270
|
-
const response = await sendWithFallback(target, method, headers, body);
|
|
271
|
-
const responseBody = Buffer.from(await response.arrayBuffer());
|
|
272
|
-
if (session) await recordCookies(session.jar, response.headers, cookieTarget, log, destination.name);
|
|
273
|
-
await recordCookies(sharedCookieJar, response.headers, cookieTarget, log, destination.name);
|
|
274
|
-
return { response, body: responseBody };
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
// Self-healing mode 1: when SAP rejects a token (expired, rotated, or
|
|
278
|
-
// invalidated server-side), drop the cached session and retry with a
|
|
279
|
-
// freshly fetched token paired with its own cookies. Bounded retries keep
|
|
280
|
-
// a failing destination from looping forever.
|
|
281
|
-
async function forwardWithRetry(method, requestUrl, requestHeaders, body, failures = 0) {
|
|
282
|
-
const unsafe = UNSAFE.has(method);
|
|
283
|
-
const session = unsafe ? await fetchToken(requestHeaders) : null;
|
|
284
|
-
if (unsafe && !session?.token) {
|
|
285
|
-
stats.csrfSessionFailures += 1;
|
|
286
|
-
const message = `BAS destination relay could not obtain a CSRF token for ${requestUrl.pathname}; no unsafe request was sent`;
|
|
287
|
-
log(`[${destination.name}] ${message}`);
|
|
288
|
-
const status = session?.httpStatus >= 400 ? session.httpStatus : 503;
|
|
289
|
-
const response = new Response(message, { status, headers: { 'content-type': 'text/plain; charset=utf-8' } });
|
|
290
|
-
return { response, body: Buffer.from(message) };
|
|
291
|
-
}
|
|
292
|
-
// A token without cookies is still sent: BAS proxy routes deliver tokens
|
|
293
|
-
// but strip Set-Cookie, and the backend accepts the token on the Basic-
|
|
294
|
-
// auth session the proxy establishes per hop. A retry cannot help there
|
|
295
|
-
// (a fresh token still arrives cookie-less), so a CSRF rejection on a
|
|
296
|
-
// cookie-less session is surfaced instead of retried.
|
|
297
|
-
const tokenOnly = unsafe && !session.cookieCount;
|
|
298
|
-
const { response, body: responseBody } = await forward(method, requestUrl, requestHeaders, body, session);
|
|
299
|
-
const rejected = csrfFailure(response.status, response.headers, responseBody);
|
|
300
|
-
if (unsafe && rejected && !tokenOnly && failures < maxFailures) {
|
|
301
|
-
log(`[${destination.name}] BAS relay CSRF session rejected for ${requestUrl.pathname}; re-establishing token and session (attempt ${failures + 1}/${maxFailures})`);
|
|
302
|
-
stats.csrfRetries += 1;
|
|
303
|
-
tokenCache.delete(CSRF_FETCH_PATH);
|
|
304
|
-
return forwardWithRetry(method, requestUrl, requestHeaders, body, failures + 1);
|
|
305
|
-
}
|
|
306
|
-
if (unsafe && rejected && tokenOnly) {
|
|
307
|
-
log(`[${destination.name}] BAS relay CSRF token rejected without a session cookie for ${requestUrl.pathname}; not retried (the configured destination route delivered no cookies)`);
|
|
308
|
-
} else if (unsafe && rejected && failures >= maxFailures) {
|
|
309
|
-
log(`[${destination.name}] BAS relay stopped after ${maxFailures} CSRF session retries for ${requestUrl.pathname}`);
|
|
310
|
-
}
|
|
311
|
-
return { response, body: responseBody };
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
const server = http.createServer((req, res) => {
|
|
315
|
-
void (async () => {
|
|
316
|
-
const method = String(req.method || 'GET').toUpperCase();
|
|
317
|
-
const requestUrl = new URL(req.url || '/', base);
|
|
318
|
-
stats.requests += 1;
|
|
319
|
-
const body = UNSAFE.has(method) ? await readRequestBody(req) : Buffer.alloc(0);
|
|
320
|
-
const { response, body: responseBody } = await forwardWithRetry(method, requestUrl, req.headers, body);
|
|
321
|
-
const responseHeaders = {};
|
|
322
|
-
// SAP sessions commonly set several cookies at once; a plain object
|
|
323
|
-
// assignment would collapse them to the last value. undici exposes the
|
|
324
|
-
// full list via getSetCookie, and Node's http server serializes an
|
|
325
|
-
// array as repeated headers.
|
|
326
|
-
const setCookies = response.headers.getSetCookie?.() || [];
|
|
327
|
-
if (setCookies.length) responseHeaders['set-cookie'] = setCookies;
|
|
328
|
-
for (const [name, value] of response.headers) {
|
|
329
|
-
const lower = name.toLowerCase();
|
|
330
|
-
// undici decompresses response bodies; the stale content-encoding and
|
|
331
|
-
// content-length would misdescribe the plain bytes handed to VSP.
|
|
332
|
-
if (lower === 'set-cookie' || HOP_BY_HOP.has(lower) || lower === 'content-encoding' || lower === 'content-length') continue;
|
|
333
|
-
responseHeaders[name] = value;
|
|
334
|
-
}
|
|
335
|
-
responseHeaders['x-sap-ai-dev-toolkit-relay'] = 'bas-destination';
|
|
336
|
-
res.writeHead(response.status, response.statusText, responseHeaders);
|
|
337
|
-
res.end(responseBody);
|
|
338
|
-
})().catch(error => {
|
|
339
|
-
if (!res.headersSent) res.writeHead(502, { 'content-type': 'text/plain; charset=utf-8', 'connection': 'close' });
|
|
340
|
-
// Proxy and transport errors can embed the full proxy URL including
|
|
341
|
-
// userinfo; redact before the body reaches the child (and the tool result).
|
|
342
|
-
res.end(`BAS destination relay failed: ${redactText(error.message)}\n`);
|
|
343
|
-
});
|
|
344
|
-
});
|
|
345
|
-
|
|
346
|
-
// Keep an error listener for the server's whole lifetime: after listen()
|
|
347
|
-
// succeeds, a later 'error' event (port collision cleanup, socket reset)
|
|
348
|
-
// with no listener would be an uncaught exception that kills the process.
|
|
349
|
-
server.on('error', error => {
|
|
350
|
-
log(`[${destination.name}] BAS relay server error: ${redactText(error.message)}`);
|
|
351
|
-
});
|
|
352
|
-
// Bind eagerly and report the real port through `ready`. Callers must
|
|
353
|
-
// await `ready` before handing the URL to a VSP child; binding failures
|
|
354
|
-
// surface as a rejected promise instead of a silently wrong guessed port.
|
|
355
|
-
const listening = new Promise((resolve, reject) => {
|
|
356
|
-
server.once('error', reject);
|
|
357
|
-
server.listen(0, '127.0.0.1', () => {
|
|
358
|
-
server.removeListener('error', reject);
|
|
359
|
-
resolve(server.address().port);
|
|
360
|
-
});
|
|
361
|
-
});
|
|
362
|
-
const ready = listening.then(port => `http://127.0.0.1:${port}`).catch(error => {
|
|
363
|
-
log(`[${destination.name}] BAS relay failed to start: ${error.message}`);
|
|
364
|
-
throw error;
|
|
365
|
-
});
|
|
366
|
-
return {
|
|
367
|
-
ready,
|
|
368
|
-
stats,
|
|
369
|
-
probeCsrfSession,
|
|
370
|
-
close: async () => {
|
|
371
|
-
await dispatcher?.close?.();
|
|
372
|
-
await tunnelDispatcher?.close?.();
|
|
373
|
-
server.closeAllConnections?.();
|
|
374
|
-
await new Promise(resolve => server.close(() => resolve()));
|
|
375
|
-
}
|
|
376
|
-
};
|
|
377
|
-
}
|