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.
- package/.github/agents/abap-developer.agent.md +1 -1
- package/.github/agents/rap-service-developer.agent.md +1 -1
- package/.github/agents/sap-solution-architect.agent.md +1 -1
- package/.github/skills/abap-development/SKILL.md +1 -1
- package/.github/skills/abap-testing-quality/SKILL.md +1 -1
- package/.github/skills/rap-development/SKILL.md +1 -1
- package/.github/skills/rap-service-delivery/SKILL.md +1 -1
- package/.github/skills/sap-sdlc-orchestration/SKILL.md +1 -1
- package/README.md +26 -20
- package/package.json +1 -2
- package/patches/vsp-bas-proxy-auth.patch +125 -0
- package/scripts/install-local.mjs +2 -1
- package/scripts/install-user-copilot-assets.mjs +4 -112
- package/scripts/postinstall.mjs +64 -28
- package/src/abaplint.mjs +2 -2
- package/src/harnesses.mjs +93 -0
- package/src/install-user-assets.mjs +149 -0
- package/src/launcher.mjs +3 -36
- package/src/mcp-proxy.mjs +5 -51
- package/dist/checksums.txt +0 -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/src/bas-destination-relay.mjs +0 -377
|
@@ -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
|
-
}
|