sap-ai-dev-toolkit 0.6.5 → 0.6.7

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.
@@ -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
- }