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/.github/agents/abap-developer.agent.md +9 -7
- package/.github/agents/rap-service-developer.agent.md +10 -8
- package/.github/agents/sap-solution-architect.agent.md +2 -2
- package/.github/skills/abap-development/SKILL.md +6 -4
- package/.github/skills/rap-development/SKILL.md +6 -4
- package/.github/skills/rap-service-delivery/SKILL.md +7 -5
- package/.github/skills/sap-sdlc-orchestration/SKILL.md +5 -4
- package/README.md +19 -5
- package/package.json +2 -1
- package/scripts/install-local.mjs +115 -0
- package/src/bas-destination-relay.mjs +271 -0
- package/src/bas-discovery.mjs +4 -0
- package/src/credentials-store.mjs +114 -0
- package/src/launcher.mjs +44 -2
- package/src/mcp-proxy.mjs +120 -32
- package/src/setup.mjs +90 -2
- package/src/terminal-ui.mjs +84 -0
- package/test/bas-destination-relay.test.mjs +188 -0
- package/test/copilot-content.test.mjs +41 -0
- package/test/credentials-store.test.mjs +156 -0
- package/test/discovery.test.mjs +1 -1
- package/test/mcp-proxy.test.mjs +59 -3
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
397
|
-
|
|
398
|
-
await entry
|
|
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) {
|
package/src/terminal-ui.mjs
CHANGED
|
@@ -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
|
+
});
|