sap-ai-dev-toolkit 0.3.0 → 0.3.3

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/src/mcp-proxy.mjs CHANGED
@@ -4,6 +4,7 @@ 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';
7
8
 
8
9
  const JSONRPC = '2.0';
9
10
  const FORWARDED_METHODS = new Set(['ping', 'resources/list', 'resources/read', 'resources/templates/list', 'prompts/list', 'completion/complete', 'logging/setLevel']);
@@ -58,8 +59,27 @@ function closeDestinationRoute(destination) {
58
59
  return closedRoutes.get(destination);
59
60
  }
60
61
 
62
+ function useBasDestinationRelay(destination, env = process.env) {
63
+ if (String(brandedEnvValue(env, 'DISABLE_BAS_RELAY') || '').toLowerCase() === 'true') return false;
64
+ if (destination.source === 'cloud-foundry') return false;
65
+ if (!destination.authentication) return false;
66
+ return Boolean(destination.url && String(destination.url).includes('.dest'));
67
+ }
68
+
61
69
  function childEnvironment(destination, env) {
62
- return { ...sanitizeChildEnv(env), ...(destination.childEnv || {}) };
70
+ const childEnv = { ...sanitizeChildEnv(env), ...(destination.childEnv || {}) };
71
+ // The VSP child must reach the local BAS relay directly, never through the
72
+ // BAS .dest proxy. Without a loopback exception every child request would
73
+ // be double-proxied and the relay's session pairing could never apply.
74
+ if (destination.relay) {
75
+ const loopback = '127.0.0.1,localhost';
76
+ for (const key of ['NO_PROXY', 'no_proxy']) {
77
+ const entries = String(childEnv[key] || '').split(',').map(value => value.trim()).filter(Boolean);
78
+ for (const host of loopback.split(',')) if (!entries.some(entry => entry.toLowerCase() === host)) entries.push(host);
79
+ childEnv[key] = entries.join(',');
80
+ }
81
+ }
82
+ return childEnv;
63
83
  }
64
84
 
65
85
 
@@ -95,12 +115,14 @@ class Child {
95
115
  this.process.on('error', error => {
96
116
  if (!this.closing && !this.pending.size && this.options.log) this.options.log(`[${destination.name}] VSP child process error: ${diagnosticText(error.message)}`);
97
117
  this.fail(error);
98
- void closeDestinationRoute(destination);
118
+ // Relay routes survive child crashes so self-healing restarts can reuse
119
+ // them; they are closed by the proxy shutdown path instead.
120
+ if (!destination.relay) void closeDestinationRoute(destination);
99
121
  });
100
122
  this.process.on('exit', (code, signal) => {
101
123
  if (!this.closing && !this.pending.size && this.options.log) this.options.log(`[${destination.name}] VSP child exited (${code ?? signal})`);
102
124
  this.fail(new Error(`child exited (${code ?? signal})`));
103
- void closeDestinationRoute(destination);
125
+ if (!destination.relay) void closeDestinationRoute(destination);
104
126
  });
105
127
  }
106
128
 
@@ -209,36 +231,87 @@ export class MCPProxy {
209
231
  start() {
210
232
  if (this.started) return this;
211
233
  this.started = true;
212
- for (const destination of this.destinations) {
213
- try {
214
- this.log(`[${destination.name}] starting VSP child (client=${destination.client || '001'})`);
215
- this.children.push({
216
- destination,
217
- child: new Child(this.binary, destination, {
218
- env: this.env,
219
- spawn: this.spawn,
220
- args: this.childArgs?.(destination),
221
- log: this.log,
222
- onNotification: notification => {
223
- if (this.clientInitialized) this.output(JSON.stringify(notification));
224
- else this.pendingNotifications.push(notification);
225
- }
226
- })
227
- });
228
- } catch (error) {
229
- this.log(`[${destination.name}] failed to start child: ${diagnosticText(error.message)}`);
230
- void closeDestinationRoute(destination);
234
+ this.starting = (async () => {
235
+ for (const originalDestination of this.destinations) {
236
+ let destination = originalDestination;
237
+ try {
238
+ if (useBasDestinationRelay(originalDestination, this.env)) {
239
+ const relay = createBasDestinationRelay(originalDestination, { env: this.env, log: this.log });
240
+ const originalClose = originalDestination.close;
241
+ const relayUrl = await relay.ready;
242
+ destination = {
243
+ ...originalDestination,
244
+ url: relayUrl,
245
+ relay: true,
246
+ async close() {
247
+ await originalClose?.();
248
+ await relay.close();
249
+ }
250
+ };
251
+ this.log(`[${originalDestination.name}] BAS destination relay enabled (${relayUrl})`);
252
+ }
253
+ this.log(`[${destination.name}] starting VSP child (client=${destination.client || '001'})`);
254
+ this.children.push({
255
+ destination,
256
+ child: new Child(this.binary, destination, {
257
+ env: this.env,
258
+ spawn: this.spawn,
259
+ args: this.childArgs?.(destination),
260
+ log: this.log,
261
+ onNotification: notification => {
262
+ if (this.clientInitialized) this.output(JSON.stringify(notification));
263
+ else this.pendingNotifications.push(notification);
264
+ }
265
+ })
266
+ });
267
+ } catch (error) {
268
+ this.log(`[${destination.name}] failed to start child: ${diagnosticText(error.message)}`);
269
+ void closeDestinationRoute(destination);
270
+ }
231
271
  }
232
- }
233
- if (!this.children.length) {
234
- this.started = false;
235
- for (const destination of this.destinations) void closeDestinationRoute(destination);
236
- throw new Error('No destination child could be started');
237
- }
272
+ if (!this.children.length) {
273
+ this.started = false;
274
+ for (const destination of this.destinations) void closeDestinationRoute(destination);
275
+ throw new Error('No destination child could be started');
276
+ }
277
+ return this;
278
+ })();
279
+ // Mark the startup promise as handled here; initializeChildren re-awaits
280
+ // it so startup failures still surface as MCP initialization errors
281
+ // without triggering an unhandled rejection in between.
282
+ this.starting.catch(() => {});
238
283
  return this;
239
284
  }
240
285
 
286
+ // Self-healing mode 3: transparently restart a crashed VSP child so an
287
+ // MCP tools/call does not permanently fail after a single crash. The
288
+ // restarted child re-initializes and re-registers tools before the call
289
+ // is retried once.
290
+ async restartChild(entry) {
291
+ if (this.shuttingDown) throw new Error('MCP proxy is shutting down');
292
+ const name = entry.destination.name;
293
+ this.log(`[${name}] VSP child crashed; self-healing restart in progress`);
294
+ await entry.child.close().catch(() => {});
295
+ const child = new Child(this.binary, entry.destination, {
296
+ env: this.env,
297
+ spawn: this.spawn,
298
+ args: this.childArgs?.(entry.destination),
299
+ log: this.log,
300
+ onNotification: notification => {
301
+ if (this.clientInitialized) this.output(JSON.stringify(notification));
302
+ else this.pendingNotifications.push(notification);
303
+ }
304
+ });
305
+ entry.child = child;
306
+ entry.server = await child.initialize(this.lastInitializeParams || {});
307
+ await child.listTools().catch(() => {});
308
+ this.log(`[${name}] VSP child self-healing restart complete`);
309
+ return entry;
310
+ }
311
+
241
312
  async initializeChildren(params) {
313
+ await this.starting;
314
+ this.lastInitializeParams = params;
242
315
  const healthy = [];
243
316
  for (const entry of this.children) {
244
317
  this.log(`[${entry.destination.name}] initializing VSP MCP session`);
@@ -342,6 +415,19 @@ export class MCPProxy {
342
415
  return response.error ? { ...response, id: message.id } : rpcResult(message.id, response.result);
343
416
  } catch (error) {
344
417
  this.log(`[${mapped.entry.destination.name}] tools/call ${toolName} failed after ${Date.now() - startedAt}ms: ${diagnosticText(error.rpcError?.message || error.message)}`);
418
+ // Self-healing: a dead child (crash, OOM, transient pipe break) is
419
+ // restarted and the call retried once before surfacing the error.
420
+ const childBroken = mapped.entry.child.exited || !mapped.entry.child.process.stdin.writable;
421
+ if (childBroken && !this.shuttingDown) {
422
+ try {
423
+ await this.restartChild(mapped.entry);
424
+ this.log(`[${mapped.entry.destination.name}] tools/call ${toolName} retried after self-healing restart`);
425
+ const retried = await mapped.entry.child.request('tools/call', upstreamParams);
426
+ return retried.error ? { ...retried, id: message.id } : rpcResult(message.id, retried.result);
427
+ } catch (restartError) {
428
+ this.log(`[${mapped.entry.destination.name}] tools/call ${toolName} self-healing restart failed: ${diagnosticText(restartError.message)}`);
429
+ }
430
+ }
345
431
  return error.rpcError ? rpcError(message.id, error.rpcError.code || -32001, error.rpcError.message || error.message, error.rpcError.data) : rpcError(message.id, -32001, `Destination ${mapped.entry.destination.name} failed: ${error.message}`);
346
432
  }
347
433
  }
@@ -393,10 +479,12 @@ export class MCPProxy {
393
479
  async close() {
394
480
  if (this.shuttingDown) return;
395
481
  this.shuttingDown = true;
396
- await Promise.all(this.destinations.map(async destination => {
397
- const entry = this.children.find(candidate => candidate.destination === destination);
398
- await entry?.child.close();
399
- await closeDestinationRoute(destination);
482
+ const closed = new Set();
483
+ await Promise.all(this.children.map(async entry => {
484
+ await entry.child.close();
485
+ await closeDestinationRoute(entry.destination);
486
+ closed.add(entry.destination);
400
487
  }));
488
+ await Promise.all(this.destinations.filter(destination => !closed.has(destination)).map(destination => closeDestinationRoute(destination)));
401
489
  }
402
490
  }
package/src/setup.mjs CHANGED
@@ -1,9 +1,10 @@
1
1
  import { createInterface } from 'node:readline/promises';
2
2
  import { stdin, stdout } from 'node:process';
3
- import { checkboxPrompt, colorText, formatStatus } from './terminal-ui.mjs';
3
+ import { checkboxPrompt, colorText, formatStatus, textPrompt } from './terminal-ui.mjs';
4
4
  import { discoverDestinations, remediation } from './bas-discovery.mjs';
5
5
  import { discoverCloudFoundryDestinations, getCloudFoundryTarget, deleteManagedCloudFoundryServiceKeys } from './cf-destination.mjs';
6
6
  import { SAP_DEVELOPMENT_MCP_SERVERS, collectCloudFoundryKeyReferencesFromAllEntries, collectManagedCloudFoundryKeyReferences, installMcpConfig, readMcpConfig, resolveMcpConfigPath } from './mcp-config.mjs';
7
+ import { readCredentials, removeDestinationCredentials, resolveCredentialsPath, storeDestinationCredentials } from './credentials-store.mjs';
7
8
 
8
9
  const SETUP_COMMAND = 'sap-ai-dev --setup';
9
10
 
@@ -23,6 +24,56 @@ async function confirmCloudFoundryImport({ input = stdin, output = stdout } = {}
23
24
  }
24
25
  }
25
26
 
27
+ // Internet destinations reach SAP through the BAS .dest proxy, which strips
28
+ // Set-Cookie and makes ADT CSRF-protected writes impossible. Ask once for
29
+ // direct-connection credentials and store them next to the MCP config; the
30
+ // relay then connects straight to the backend host with Basic auth.
31
+ async function promptForInternetDestinationCredentials({ destinations, credentialsPath, existing = {}, input = stdin, output = stdout } = {}) {
32
+ const stored = {};
33
+ const internetDestinations = destinations.filter(destination =>
34
+ destination.source !== 'cloud-foundry'
35
+ && String(destination.proxyType || '').toLowerCase() === 'internet'
36
+ && /^basicauthentication$/i.test(String(destination.authentication || '')));
37
+ if (!internetDestinations.length) return stored;
38
+
39
+ print(output, '');
40
+ print(output, formatStatus('Some selected destinations are Internet type with basic authentication.', 'step', output, 'Direct connect'));
41
+ print(output, ' The BAS proxy strips SAP session cookies, which blocks ADT writes (CSRF validation).');
42
+ print(output, ' Providing a user and password enables a direct connection for those destinations.');
43
+ print(output, ` Credentials are stored beside your MCP config in ${credentialsPath} (never inside mcp.json).`);
44
+ print(output, '');
45
+
46
+ for (const destination of internetDestinations) {
47
+ const known = existing[destination.name];
48
+ if (known?.user && known?.password) {
49
+ print(output, formatStatus(`Credentials for ${destination.name} are already stored (user ${known.user}). Press Enter at both prompts to keep them.`, 'info', output, 'Direct connect'));
50
+ }
51
+ const userAnswer = await textPrompt({
52
+ message: colorText(`👤 ${destination.name} — SAP user`, 'cyan', output),
53
+ required: false,
54
+ placeholder: known ? `${known.user} (Enter keeps stored user)` : 'SAP user name'
55
+ }, { input, output });
56
+ const user = userAnswer.trim() || known?.user || '';
57
+ const passwordAnswer = await textPrompt({
58
+ message: colorText(`🔒 ${destination.name} — SAP password${known ? ' (Enter keeps stored password)' : ''}`, 'cyan', output),
59
+ secret: true,
60
+ required: false
61
+ }, { input, output });
62
+ const password = passwordAnswer || known?.password || '';
63
+ if (!user || !password) {
64
+ print(output, formatStatus(`No complete credentials for ${destination.name}; direct connect stays inactive for it.`, 'warning', output, 'Direct connect'));
65
+ continue;
66
+ }
67
+ stored[destination.name] = {
68
+ host: destination.backendUrl || known?.host || '',
69
+ user,
70
+ password
71
+ };
72
+ print(output, formatStatus(`Stored credentials for ${destination.name}.`, 'success', output, 'Direct connect'));
73
+ }
74
+ return stored;
75
+ }
76
+
26
77
  function safeProbe(probe) {
27
78
  const result = {};
28
79
  if (typeof probe?.status === 'string') result.status = probe.status;
@@ -39,6 +90,8 @@ function safeBasDestination(destination) {
39
90
  url: destination.url,
40
91
  client: destination.client,
41
92
  authentication: destination.authentication,
93
+ proxyType: destination.proxyType,
94
+ backendUrl: destination.backendUrl || null,
42
95
  probe: safeProbe(destination.probe)
43
96
  };
44
97
  }
@@ -117,6 +170,7 @@ export async function runSetup({
117
170
  }
118
171
  const destinations = basDestinations.map(safeBasDestination);
119
172
  const warnings = [];
173
+ const credentialWarnings = [];
120
174
  let createdKeys = [];
121
175
  let configPath;
122
176
  let managedKeys = [];
@@ -228,6 +282,39 @@ export async function runSetup({
228
282
  shortcuts: { all: 'a' }
229
283
  }, { input, output });
230
284
  }
285
+
286
+ // Ask for direct-connect credentials for selected Internet destinations
287
+ // so ADT writes work despite the BAS proxy stripping session cookies.
288
+ const credentialsPath = await resolveCredentialsPath(env, configPath);
289
+ const internetSelected = selected.filter(destination =>
290
+ destination.source !== 'cloud-foundry'
291
+ && String(destination.proxyType || '').toLowerCase() === 'internet'
292
+ && /^basicauthentication$/i.test(String(destination.authentication || '')));
293
+ if (internetSelected.length) {
294
+ try {
295
+ const existingMap = (await readCredentials(credentialsPath).catch(() => ({ destinations: {} }))).destinations || {};
296
+ const storedNow = await promptForInternetDestinationCredentials({
297
+ destinations: selected,
298
+ credentialsPath,
299
+ existing: existingMap,
300
+ input,
301
+ output
302
+ });
303
+ for (const [name, entry] of Object.entries(storedNow)) {
304
+ await storeDestinationCredentials(credentialsPath, name, entry);
305
+ }
306
+ // Remove stored credentials for Internet destinations that are no
307
+ // longer selected, so the file only tracks active servers.
308
+ const stillSelected = new Set(selected.map(destination => destination.name));
309
+ const stale = Object.keys(existingMap)
310
+ .filter(name => !stillSelected.has(name))
311
+ .filter(name => internetSelected.every(destination => destination.name !== name));
312
+ if (stale.length) await removeDestinationCredentials(credentialsPath, stale);
313
+ } catch (error) {
314
+ if (/Prompt interrupted/.test(error.message)) throw error;
315
+ credentialWarnings.push(`Direct-connect credentials could not be stored: ${error.message}`);
316
+ }
317
+ }
231
318
  try {
232
319
  const result = await install(selected, { env, sapDevelopmentServers });
233
320
  const location = result?.path ? ` in ${result.path}` : '';
@@ -248,8 +335,9 @@ export async function runSetup({
248
335
  }
249
336
  }
250
337
  await reportWarnings(warnings);
338
+ await reportWarnings(credentialWarnings);
251
339
  await reportWarnings(cleanupWarnings);
252
- return { selected, ...(result || {}), warnings: [...warnings, ...cleanupWarnings] };
340
+ return { selected, ...(result || {}), warnings: [...warnings, ...credentialWarnings, ...cleanupWarnings] };
253
341
  } catch (error) {
254
342
  const cleanupWarnings = [];
255
343
  if (createdKeys.length) {
@@ -210,3 +210,87 @@ export async function checkboxPrompt({ message, choices, required = false, short
210
210
  }
211
211
  });
212
212
  }
213
+
214
+ // Single-line text/secret prompt matching the checkbox prompt's interaction
215
+ // style. Secrets are masked with ●, support backspace, and can be re-prompted
216
+ // through `validate`. Ctrl+C rejects with 'Prompt interrupted'.
217
+ export async function textPrompt({ message, secret = false, required = true, placeholder = '', validate } = {}, { input = process.stdin, output = process.stdout } = {}) {
218
+ let value = '';
219
+ let renderedLines = 0;
220
+ let done = false;
221
+ let previousRawMode;
222
+
223
+ const write = text => output.write(text);
224
+ const masked = () => '●'.repeat(value.length);
225
+ const clearRendered = () => {
226
+ if (!renderedLines) return;
227
+ write(`\u001b[${renderedLines}A\u001b[J`);
228
+ renderedLines = 0;
229
+ };
230
+ const render = (error = '') => {
231
+ const columns = Number.isFinite(output?.columns) ? output.columns : 80;
232
+ const lines = [];
233
+ const hint = secret ? ' (input is hidden)' : '';
234
+ lines.push(truncateToColumns(`${message}${hint}`, columns));
235
+ const shown = secret ? masked() : value;
236
+ const display = shown || colorText(placeholder, 'cyan', output);
237
+ lines.push(truncateToColumns(`❯ ${display}`, columns));
238
+ if (error) lines.push(colorText(` ${error}`, 'red', output));
239
+ clearRendered();
240
+ write(`${lines.join('\n')}\n`);
241
+ renderedLines = lines.length;
242
+ };
243
+
244
+ return new Promise((resolve, reject) => {
245
+ const cleanup = () => {
246
+ input.off('keypress', onKeypress);
247
+ if (typeof input.setRawMode === 'function' && previousRawMode !== undefined) input.setRawMode(previousRawMode);
248
+ if (typeof input.pause === 'function') input.pause();
249
+ write('\u001b[?25h');
250
+ };
251
+ const finish = result => {
252
+ if (done) return;
253
+ done = true;
254
+ clearRendered();
255
+ const summary = secret ? '✓ saved' : (result || '(empty)');
256
+ write(`${stripAnsi(message)} ${summary}\n`);
257
+ cleanup();
258
+ resolve(result);
259
+ };
260
+ const fail = error => {
261
+ if (done) return;
262
+ done = true;
263
+ cleanup();
264
+ reject(error);
265
+ };
266
+ const onKeypress = (chunk, key = {}) => {
267
+ if (key.ctrl && key.name === 'c') return fail(new Error('Prompt interrupted'));
268
+ if (key.name === 'return' || key.name === 'enter') {
269
+ const validation = typeof validate === 'function' ? validate(value) : true;
270
+ if (validation !== true) return render(String(validation));
271
+ if (required && !value.length) return render('Enter a value (or press Ctrl+C to skip).');
272
+ return finish(value);
273
+ }
274
+ if (key.name === 'backspace' || key.name === 'delete') {
275
+ value = value.slice(0, -1);
276
+ } else if (key.sequence && !key.ctrl && !key.meta && /^.$/u.test(key.sequence)) {
277
+ value += key.sequence;
278
+ } else {
279
+ return;
280
+ }
281
+ render();
282
+ };
283
+
284
+ try {
285
+ readline.emitKeypressEvents(input);
286
+ previousRawMode = input.isRaw;
287
+ if (typeof input.setRawMode === 'function') input.setRawMode(true);
288
+ if (typeof input.resume === 'function') input.resume();
289
+ write('\u001b[?25l');
290
+ input.on('keypress', onKeypress);
291
+ render();
292
+ } catch (error) {
293
+ fail(error);
294
+ }
295
+ });
296
+ }
@@ -0,0 +1,188 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import http from 'node:http';
4
+ import { request as httpRequest } from 'node:http';
5
+ import { createBasDestinationRelay } from '../src/bas-destination-relay.mjs';
6
+
7
+ const UNSAFE_METHODS = ['POST', 'PUT', 'PATCH', 'DELETE'];
8
+
9
+ // Simulates SAP ADT semantics: a CSRF token is only valid when the unsafe
10
+ // request carries BOTH the token and the session cookies that were set
11
+ // together with it. This reproduces the 403 "CSRF token validation failed"
12
+ // seen on the BAS .dest proxy path before the relay paired sessions.
13
+ function sapLikeBackend({ neverAcceptUnsafe = false, rotateOnFetch = false } = {}) {
14
+ const state = { sessionCounter: 0, requests: [] };
15
+ const server = http.createServer((req, res) => {
16
+ state.requests.push({ method: req.method, path: req.url, headers: { ...req.headers } });
17
+ if (req.method === 'GET' && req.headers['x-csrf-token'] === 'Fetch') {
18
+ state.sessionCounter += 1;
19
+ res.writeHead(200, {
20
+ 'x-csrf-token': `token-${state.sessionCounter}`,
21
+ 'set-cookie': [`sap-session-${state.sessionCounter}=s${state.sessionCounter}; path=/`, 'sap-usercontext=sap-client=100; path=/']
22
+ });
23
+ res.end('<ok/>');
24
+ return;
25
+ }
26
+ if (UNSAFE_METHODS.includes(req.method)) {
27
+ if (neverAcceptUnsafe) {
28
+ res.writeHead(403, { 'x-csrf-token': 'Required' });
29
+ res.end('CSRF token validation failed');
30
+ return;
31
+ }
32
+ const token = req.headers['x-csrf-token'];
33
+ const cookie = String(req.headers.cookie || '');
34
+ const match = String(token || '').match(/^token-(\d+)$/);
35
+ // Without rotation any still-live token+cookie pair is accepted; with
36
+ // rotation only the newest issued session remains valid, matching SAP
37
+ // server-side session invalidation.
38
+ const valid = match && cookie.includes(`sap-session-${match[1]}=`) && (!rotateOnFetch || Number(match[1]) === state.sessionCounter);
39
+ if (!valid) {
40
+ res.writeHead(403, { 'x-csrf-token': 'Required' });
41
+ res.end('CSRF token validation failed');
42
+ return;
43
+ }
44
+ res.writeHead(201);
45
+ res.end('<created/>');
46
+ return;
47
+ }
48
+ res.writeHead(200);
49
+ res.end('<read/>');
50
+ });
51
+ return { state, server };
52
+ }
53
+
54
+ async function startServer(server) {
55
+ await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
56
+ return server.address().port;
57
+ }
58
+
59
+ function relayFetch(relayUrl, path, options = {}) {
60
+ const target = new URL(path, relayUrl);
61
+ return new Promise((resolve, reject) => {
62
+ const req = httpRequest(target, { method: options.method || 'GET', headers: options.headers || {} }, response => {
63
+ let body = '';
64
+ response.setEncoding('utf8');
65
+ response.on('data', chunk => { body += chunk; });
66
+ response.on('end', () => resolve({ status: response.statusCode, headers: response.headers, body }));
67
+ });
68
+ req.on('error', reject);
69
+ if (options.body) req.write(options.body);
70
+ req.end();
71
+ });
72
+ }
73
+
74
+ async function withBackend(options, handler) {
75
+ const { state, server } = sapLikeBackend(options || {});
76
+ const backendPort = await startServer(server);
77
+ const logs = [];
78
+ const relay = createBasDestinationRelay(
79
+ { name: 'TEST', url: `http://127.0.0.1:${backendPort}`, authentication: 'BasicAuthentication' },
80
+ {
81
+ env: { HTTP_PROXY: '' },
82
+ fetchImpl: options?.fetchImpl || ((url, init) => globalThis.fetch(url, init)),
83
+ log: message => logs.push(message)
84
+ }
85
+ );
86
+ const relayUrl = await relay.ready;
87
+ try {
88
+ return await handler({ state, relay, relayUrl, logs });
89
+ } finally {
90
+ await relay.close();
91
+ await new Promise(resolve => server.close(resolve));
92
+ }
93
+ }
94
+
95
+ test('pairs CSRF token with its session cookies so unsafe requests succeed', async () => {
96
+ await withBackend(null, async ({ relayUrl, state }) => {
97
+ const response = await relayFetch(relayUrl, '/sap/bc/adt/oo/classes', { method: 'POST', headers: { accept: 'application/xml' }, body: '<x/>' });
98
+ assert.equal(response.status, 201, `expected 201, got ${response.status}: ${response.body}`);
99
+ const post = state.requests.find(request => request.method === 'POST');
100
+ assert.match(post.headers['x-csrf-token'], /^token-\d+$/);
101
+ assert.match(String(post.headers.cookie), /sap-session-\d+=/);
102
+ });
103
+ });
104
+
105
+ test('reuses the cached CSRF session for subsequent unsafe requests', async () => {
106
+ await withBackend(null, async ({ relay, relayUrl, state }) => {
107
+ await relayFetch(relayUrl, '/sap/bc/adt/oo/classes', { method: 'POST', body: '<x/>' });
108
+ await relayFetch(relayUrl, '/sap/bc/adt/oo/classes', { method: 'POST', body: '<y/>' });
109
+ const fetches = state.requests.filter(request => request.headers['x-csrf-token'] === 'Fetch');
110
+ assert.equal(fetches.length, 1, 'second POST must reuse the cached session');
111
+ assert.equal(state.requests.filter(request => request.method === 'POST').length, 2);
112
+ assert.equal(relay.stats.csrfFetches, 1);
113
+ assert.equal(relay.stats.csrfRetries, 0);
114
+ });
115
+ });
116
+
117
+ test('self-heals when SAP rotates the session: refetches token and cookies', async () => {
118
+ await withBackend({ rotateOnFetch: true }, async ({ relay, relayUrl, state }) => {
119
+ // A first POST establishes session token-1. SAP then invalidates it
120
+ // server-side (simulated by the rotation rule below).
121
+ await relayFetch(relayUrl, '/sap/bc/adt/oo/classes', { method: 'POST', body: '<x/>' });
122
+ state.sessionCounter += 1;
123
+ const response = await relayFetch(relayUrl, '/sap/bc/adt/oo/classes', { method: 'POST', body: '<y/>' });
124
+ assert.equal(response.status, 201, `rotated session must heal via refetch, got ${response.status}`);
125
+ assert.ok(relay.stats.csrfRetries >= 1, 'a retry must have been counted');
126
+ const fetches = state.requests.filter(request => request.headers['x-csrf-token'] === 'Fetch');
127
+ assert.ok(fetches.length >= 2, 'a fresh token fetch must have occurred');
128
+ });
129
+ });
130
+
131
+ test('stops retrying after bounded attempts and surfaces the failure', async () => {
132
+ await withBackend({ neverAcceptUnsafe: true }, async ({ relay, relayUrl, state }) => {
133
+ const response = await relayFetch(relayUrl, '/sap/bc/adt/oo/classes', { method: 'POST', body: '<x/>' });
134
+ assert.equal(response.status, 403);
135
+ const attempts = state.requests.filter(request => UNSAFE_METHODS.includes(request.method));
136
+ assert.equal(attempts.length, 1 + 3, 'one initial try plus three bounded retries');
137
+ assert.equal(relay.stats.csrfRetries, 3);
138
+ });
139
+ });
140
+
141
+ test('GET requests pass through without CSRF handling', async () => {
142
+ await withBackend(null, async ({ relayUrl, state, relay }) => {
143
+ const response = await relayFetch(relayUrl, '/sap/bc/adt/discovery', { headers: { accept: 'application/xml' } });
144
+ assert.equal(response.status, 200);
145
+ assert.equal(state.requests.filter(request => request.headers['x-csrf-token']).length, 0);
146
+ assert.equal(relay.stats.csrfFetches, 0);
147
+ });
148
+ });
149
+
150
+ test('drops POST body headers from the token-fetch GET', async () => {
151
+ await withBackend(null, async ({ relayUrl, state }) => {
152
+ await relayFetch(relayUrl, '/sap/bc/adt/oo/classes', {
153
+ method: 'POST',
154
+ headers: { accept: 'application/xml', 'content-type': 'application/xml', 'content-length': '5' },
155
+ body: '<xy/>'
156
+ });
157
+ const fetchRequest = state.requests.find(request => request.headers['x-csrf-token'] === 'Fetch');
158
+ assert.equal(fetchRequest.headers['content-type'], undefined, 'content-type must not leak into token GET');
159
+ assert.equal(fetchRequest.headers['content-length'], undefined, 'content-length must not leak into token GET');
160
+ });
161
+ });
162
+
163
+ test('falls back to a proxy tunnel when the absolute-form request is refused', async () => {
164
+ const fetchCalls = [];
165
+ const fetchImpl = (url, options = {}) => {
166
+ fetchCalls.push({ url: String(url), tunneled: Boolean(options.dispatcher && options.dispatcher.proxyTunnel !== false && options.dispatcher.constructor?.name === 'ProxyAgent' && options.dispatcher.__tunnelMarker !== false) });
167
+ // First call (absolute-form through proxy) is refused by the proxy itself.
168
+ if (fetchCalls.length === 1) return Promise.resolve(new Response('<gateway/>', { status: 502 }));
169
+ return Promise.resolve(new Response('<ok/>', { status: 200, headers: { 'x-csrf-token': 'tok' } }));
170
+ };
171
+ const { state, server } = sapLikeBackend();
172
+ void state;
173
+ const backendPort = await startServer(server);
174
+ const relay = createBasDestinationRelay(
175
+ { name: 'TEST', url: `http://127.0.0.1:${backendPort}`, authentication: 'BasicAuthentication' },
176
+ { env: { HTTP_PROXY: 'http://127.0.0.1:1' }, fetchImpl, log: () => {} }
177
+ );
178
+ const relayUrl = await relay.ready;
179
+ try {
180
+ const response = await relayFetch(relayUrl, '/sap/bc/adt/discovery');
181
+ assert.equal(response.status, 200);
182
+ assert.equal(relay.stats.tunnelFallbacks, 1, 'exactly one tunnel fallback must occur');
183
+ assert.equal(fetchCalls.length, 2, 'absolute-form attempt then one tunnel retry');
184
+ } finally {
185
+ await relay.close();
186
+ await new Promise(resolve => server.close(resolve));
187
+ }
188
+ });