sap-ai-dev-toolkit 0.7.9 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -272,8 +272,35 @@ The installer handles VSP provisioning automatically:
272
272
 
273
273
  Setup detects where it is running. With `H2O_URL` set, it treats the environment as SAP Business Application Studio and opens a checkbox picker for BAS destinations, with no destinations selected by default. Use **Space** to choose destinations and **Enter** to confirm. Press **a** to toggle all destinations (select all if any are unchecked; otherwise clear the selection). Confirming with none selected removes this add-on's managed MCP entries. When the `cf` CLI 8.18 or newer is authenticated to a targeted space, setup first offers an optional import from that space's Destination service; type **y** then **Enter** to include it, or press **Enter** to skip. Accepted CF and BAS destinations appear together in the picker.
274
274
 
275
- Without `H2O_URL`, interactive setup looks for local SAP GUI system configuration on macOS and Windows (for example SAP GUI landscape XML and `saplogon.ini`). SAP GUI landscape files describe SAP GUI/DIAG connectivity, not the ADT HTTP(S) endpoint, so setup asks you to confirm the ADT URL for each selected system. The generated MCP entry uses VS Code input prompts for the SAP user and password at server start; raw SAP credentials are not stored in `mcp.json`. On Windows, setup can instead write an experimental `SAP_AUTH_MODE=windows-sso` entry without username/password prompts; this requires the ADT endpoint to support HTTP Negotiate/SPNEGO. Runtime uses the bundled Windows Negotiate helper by default, or a trusted custom helper configured with `SAP_AI_DEV_TOOLKIT_NEGOTIATE_HELPER`. npm may run its install hook without an interactive terminal, even when the shell is interactive; in that case, destination selection is skipped without changing MCP config.
275
+ Without `H2O_URL`, interactive setup looks for local SAP GUI system configuration on macOS and Windows (for example SAP GUI landscape XML and `saplogon.ini`). SAP GUI landscape files describe SAP GUI/DIAG connectivity, not the ADT HTTP(S) endpoint or a complete ABAP client catalog, so setup asks for the SAP client or comma-separated clients to configure for each selected system (for example `100,200`), proposes an ADT URL from the SAP GUI host and instance when it can, and asks you to confirm or edit that value. Multiple entered clients create separate isolated MCP entries, one per system/client pair. When the ADT URL uses an IP address, setup automatically inspects the SAP HTTPS certificate, extracts DNS subjectAltName entries, and offers to enable secure TLS self-healing with a simple yes/no confirmation; you do not need to type certificate names. The generated MCP entry uses VS Code input prompts for the SAP user and password at server start; raw SAP credentials are not stored in `mcp.json`. On Windows, setup can instead write an experimental `SAP_AUTH_MODE=windows-sso` entry without username/password prompts; this requires the ADT endpoint to support HTTP Negotiate/SPNEGO. Runtime uses the bundled Windows Negotiate helper by default, or a trusted custom helper configured with `SAP_AI_DEV_TOOLKIT_NEGOTIATE_HELPER`. npm may run its install hook without an interactive terminal, even when the shell is interactive; in that case, destination selection is skipped without changing MCP config.
276
276
 
277
+ ### 🖥️ Local machine SAP GUI support
278
+
279
+ Local setup is intended for developers running the MCP server from their own workstation rather than SAP Business Application Studio.
280
+
281
+ Supported discovery and runtime behavior:
282
+
283
+ - **Discovery sources:** SAP GUI landscape XML and `saplogon.ini` style configuration on macOS and Windows. Setup de-duplicates entries, ignores incomplete DIAG records, and shows the discovered SAP systems in the same checkbox picker used for BAS destinations.
284
+ - **Client selection:** SAP GUI entries may contain one default client but cannot reliably list every ABAP client available through ADT. Setup asks for one or more 3-digit clients per selected system; enter comma-separated values such as `100,200` to create separate MCP servers for each client.
285
+ - **ADT endpoint derivation:** SAP GUI entries usually contain DIAG/router information, not the HTTP(S) ADT endpoint. Setup derives the common ADT port from the SAP instance when possible, for example instance `00` → `https://host:44300`, and asks for confirmation only when configuring the selected entry.
286
+ - **Credential handling:** username/password local entries use VS Code MCP input prompts (`${input:...}`) at server start. Plain SAP credentials are not written to `mcp.json`.
287
+ - **Windows SSO:** local Windows entries can use `SAP_AUTH_MODE=windows-sso` instead of username/password prompts when the ABAP ADT endpoint supports HTTP Negotiate/SPNEGO. The runtime starts a loopback ADT proxy and uses the bundled Windows SSPI helper unless `SAP_AI_DEV_TOOLKIT_NEGOTIATE_HELPER` points to a trusted custom helper. NTLM fallback is rejected.
288
+ - **IP-address HTTPS self-healing:** if the confirmed ADT URL is `https://<ip>:<port>` and the certificate is issued to DNS names, setup reads the certificate DNS SANs automatically and stores them as `SAP_TLS_SERVER_NAMES`. At runtime a loopback proxy dials the IP address but verifies TLS against those discovered DNS names, trying them one by one. Certificate verification stays enabled; there is no `--insecure` mode.
289
+ - **Private CAs:** if the SAP HTTPS certificate chains to an internal CA that is not trusted by the local Node runtime, set `SAP_TLS_CA_FILE` or `SAP_AI_DEV_TOOLKIT_TLS_CA_FILE` to a PEM CA bundle. This adds trust roots for the self-healing proxy without disabling certificate validation.
290
+ - **Redirect and request safety:** local loopback proxies reject cross-origin forward-form requests and rewrite same-origin SAP redirects back through the loopback listener, so VSP stays on the safe route.
291
+
292
+ Typical local setup flow:
293
+
294
+ ```sh
295
+ sap-ai-dev --setup
296
+ ```
297
+
298
+ 1. Select the discovered SAP GUI system with **Space** and confirm with **Enter**.
299
+ 2. Confirm the detected/default SAP client, or enter multiple clients such as `100,200`.
300
+ 3. Accept the proposed ADT URL when it is correct, or edit it if your landscape exposes ADT through a different HTTP(S) host/port.
301
+ 4. If setup discovers certificate DNS names for an IP-based HTTPS URL, accept the yes/no TLS self-healing prompt.
302
+ 5. Choose password or Windows SSO when the prompt is available.
303
+ 6. Start each generated MCP server from VS Code's **MCP: List Servers** command.
277
304
 
278
305
  The same postinstall offers the bundled agents and skills for several AI coding harnesses in a checkbox picker. **GitHub Copilot** and **Claude Code** start pre-checked, so pressing **Enter** alone installs both; **Space** selects or deselects a harness, **a** toggles all seven, and confirming with none selected skips the step without changing files. When destination MCP setup succeeds, the generated managed MCP servers are also mirrored into selected harnesses that have a stable user-level JSON MCP configuration (Claude Code, Cursor, Gemini CLI, and Pi Coding Agent). GitHub Copilot continues to use the BAS/VS Code MCP configuration written during destination setup. With no interactive terminal, the assets are installed for GitHub Copilot only; set `SAP_AI_DEV_TOOLKIT_HARNESSES` to a comma-separated list of harness ids (`github-copilot`, `claude-code`, `codex`, `cursor`, `gemini-cli`, `opencode`, `pi-coding-agent`) to choose non-interactively.
279
306
 
@@ -750,6 +777,9 @@ The response contains a `tools` array. A `RunQuery` entry resembles this excerpt
750
777
  | `SAP_AI_DEV_TOOLKIT_MODE` | VSP child mode (`expert` by default; `focused` omits `ActivateMultiple`, `GetUserTransports`, and `GetTransportInfo`). The proxy exposes its curated tool subset and local workflow tools, including `LintABAP`. |
751
778
  | `SAP_ALLOW_TRANSPORTABLE_EDITS` | Generated MCP entries set this to `true` to permit source edits in transportable packages; VSP safety checks and SAP authorizations still apply. |
752
779
  | `SAP_AUTH_MODE=windows-sso` | Local SAP GUI entry mode for experimental Windows Integrated Authentication. Runtime starts a loopback ADT proxy and does not pass SAP username/password credentials to VSP. |
780
+ | `SAP_TLS_SERVER_NAMES` / `SAP_AI_DEV_TOOLKIT_TLS_SERVER_NAMES` | Comma-separated local SAP GUI TLS self-healing names, normally auto-discovered during setup from the SAP HTTPS certificate when `SAP_URL` uses an IP address. Runtime tries them one by one while keeping certificate validation enabled. |
781
+ | `SAP_TLS_SERVER_NAME` / `SAP_AI_DEV_TOOLKIT_TLS_SERVER_NAME` | Single-name compatibility form of the local SAP GUI TLS self-healing override. Prefer the auto-discovered plural variable written by setup. |
782
+ | `SAP_TLS_CA_FILE` / `SAP_AI_DEV_TOOLKIT_TLS_CA_FILE` | Optional PEM CA bundle for the local TLS self-healing proxy when the SAP HTTPS certificate chains to a private CA that is not in the OS/Node trust store. Certificate verification remains enabled. |
753
783
  | `SAP_AI_DEV_TOOLKIT_NEGOTIATE_HELPER` / `SAP_AI_DEV_TOOLKIT_SSPI_HELPER` | Optional trusted Windows helper executable used by the local Windows SSO proxy to convert an HTTP `WWW-Authenticate: Negotiate` challenge into a Kerberos/SPNEGO token. NTLM fallback is rejected because it is connection-oriented and unsafe for this proxy shape. When unset on Windows, the bundled PowerShell SSPI helper is used. A custom helper receives one JSON line on stdin (`url`, `challenge`, `mechanism`) and prints the base64 token on stdout. |
754
784
  | `SAP_AI_DEV_TOOLKIT_HTTP_PROXY` | Egress proxy for BAS discovery and probe traffic. Takes precedence over `HTTP_PROXY`/`http_proxy`; unset means the default BAS proxy for `.dest` hosts, empty means direct. |
755
785
  | `SAP_AI_DEV_TOOLKIT_REQUEST_TIMEOUT_MS` | Per-request timeout for calls forwarded to a VSP child (default 600000 = 10 minutes; `0` disables). A stalled request fails with a timeout error; the child is left running. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sap-ai-dev-toolkit",
3
- "version": "0.7.9",
3
+ "version": "0.8.1",
4
4
  "description": "SAP AI development toolkit for BAS, ABAP, RAP, CAP, HANA Cloud, Fiori, UI5, and MCP",
5
5
  "author": "Gurkan Yilmaz",
6
6
  "type": "module",
package/src/launcher.mjs CHANGED
@@ -11,6 +11,7 @@ import { installMcpConfig, repairManagedMcpConfig } from './mcp-config.mjs';
11
11
  import { runSetup } from './setup.mjs';
12
12
  import { resolveConfiguredCloudFoundryDestination } from './cf-destination.mjs';
13
13
  import { createWindowsSsoAdtProxy } from './windows-sso-adt-proxy.mjs';
14
+ import { createTlsServerNameAdtProxy } from './tls-adt-proxy.mjs';
14
15
  import { withBrandedEnvironment } from './branding.mjs';
15
16
  import { redactText } from './redact.mjs';
16
17
  const root = dirname(dirname(fileURLToPath(import.meta.url)));
@@ -175,8 +176,18 @@ async function configuredLocalSapGuiDestination(env) {
175
176
  const name = env.SAP_AI_DEV_TOOLKIT_DESTINATION || env.SAP_SYSTEM_ID || 'local-sap';
176
177
  if (!env.SAP_URL) throw new Error('SAP_URL is required for local SAP GUI MCP entries. Rerun sap-ai-dev --setup and enter the ADT URL.');
177
178
  const authMode = String(env.SAP_AUTH_MODE || '').toLowerCase();
179
+ const tlsServerName = String(env.SAP_TLS_SERVER_NAME || env.SAP_AI_DEV_TOOLKIT_TLS_SERVER_NAME || '').trim();
180
+ const tlsServerNames = String(env.SAP_TLS_SERVER_NAMES || env.SAP_AI_DEV_TOOLKIT_TLS_SERVER_NAMES || tlsServerName).split(',').map(value => value.trim()).filter(Boolean);
181
+ const tlsCaFile = String(env.SAP_TLS_CA_FILE || env.SAP_AI_DEV_TOOLKIT_TLS_CA_FILE || '').trim();
182
+ const tlsRoute = tlsServerNames.length
183
+ ? await createTlsServerNameAdtProxy({ destinationUrl: env.SAP_URL, tlsServerNames, caFile: tlsCaFile || undefined })
184
+ : null;
185
+ const adtUrl = tlsRoute?.url || env.SAP_URL;
186
+ const closeRoute = async route => {
187
+ await Promise.all([route?.close?.(), tlsRoute?.close?.()].filter(Boolean).map(close => Promise.resolve().then(close).catch(() => {})));
188
+ };
178
189
  if (authMode === 'windows-sso') {
179
- const route = await createWindowsSsoAdtProxy({ destinationUrl: env.SAP_URL, env });
190
+ const route = await createWindowsSsoAdtProxy({ destinationUrl: adtUrl, env });
180
191
  return {
181
192
  source: 'sap-gui-local',
182
193
  name,
@@ -185,21 +196,23 @@ async function configuredLocalSapGuiDestination(env) {
185
196
  client: env.SAP_CLIENT || '001',
186
197
  authentication: 'WindowsSSO',
187
198
  childEnv: {},
188
- close: route.close,
189
- probe: { status: 'configured', available: true }
199
+ close: () => closeRoute(route),
200
+ probe: { status: tlsRoute ? 'configured-tls-server-name' : 'configured', available: true }
190
201
  };
191
202
  }
192
203
  return {
193
204
  source: 'sap-gui-local',
194
205
  name,
195
- url: env.SAP_URL,
206
+ url: adtUrl,
207
+ backendUrl: tlsRoute ? env.SAP_URL : undefined,
196
208
  client: env.SAP_CLIENT || '001',
197
209
  authentication: 'Basic',
198
210
  childEnv: {
199
211
  SAP_USER: env.SAP_USER || env.SAP_USERNAME || '',
200
212
  SAP_PASSWORD: env.SAP_PASSWORD || env.SAP_PASS || ''
201
213
  },
202
- probe: { status: 'configured', available: true }
214
+ close: tlsRoute?.close,
215
+ probe: { status: tlsRoute ? 'configured-tls-server-name' : 'configured', available: true }
203
216
  };
204
217
  }
205
218
 
@@ -192,6 +192,8 @@ export function buildMcpEntries(destinations, env = process.env) {
192
192
  SAP_URL: String(destination.url),
193
193
  SAP_CLIENT: String(destination.client || '001'),
194
194
  ...(destination.systemId ? { SAP_SYSTEM_ID: String(destination.systemId) } : {}),
195
+ ...(Array.isArray(destination.tlsServerNames) && destination.tlsServerNames.length ? { SAP_TLS_SERVER_NAMES: destination.tlsServerNames.join(',') } : {}),
196
+ ...(!Array.isArray(destination.tlsServerNames) && destination.tlsServerName ? { SAP_TLS_SERVER_NAME: String(destination.tlsServerName) } : {}),
195
197
  ...(destination.childEnv || {})
196
198
  });
197
199
  } else {
package/src/setup.mjs CHANGED
@@ -1,11 +1,13 @@
1
1
  import { createInterface } from 'node:readline/promises';
2
2
  import { stdin, stdout } from 'node:process';
3
3
  import { platform } from 'node:os';
4
+ import { isIP } from 'node:net';
4
5
  import { checkboxPrompt, colorText, formatStatus, textPrompt } from './terminal-ui.mjs';
5
6
  import { discoverDestinations, remediation } from './bas-discovery.mjs';
6
7
  import { defaultAdtUrl, discoverSapGuiSystems } from './local-sap-gui.mjs';
7
8
  import { discoverCloudFoundryDestinations, getCloudFoundryTarget, deleteManagedCloudFoundryServiceKeys, findOrphanedCloudFoundryServiceKeys } from './cf-destination.mjs';
8
9
  import { SAP_DEVELOPMENT_MCP_SERVERS, collectCloudFoundryKeyReferencesFromAllEntries, collectManagedCloudFoundryKeyReferences, installMcpConfig, readMcpConfig, resolveMcpConfigPath } from './mcp-config.mjs';
10
+ import { discoverCertificateDnsNames } from './tls-adt-proxy.mjs';
9
11
 
10
12
  const SETUP_COMMAND = 'sap-ai-dev --setup';
11
13
 
@@ -43,6 +45,51 @@ async function confirmCloudFoundryImport({ input = stdin, output = stdout } = {}
43
45
  }
44
46
  }
45
47
 
48
+ export function parseSapClientList(value, fallback = '001') {
49
+ const supplied = String(value ?? '').trim();
50
+ const raw = supplied || String(fallback || '001').trim() || '001';
51
+ const clients = raw.split(',').map(client => client.trim()).filter(Boolean);
52
+ const unique = [];
53
+ const seen = new Set();
54
+ for (const client of clients) {
55
+ if (!/^\d{3}$/.test(client)) throw new Error('Enter one or more SAP clients as 3 digits, separated by commas, for example 100 or 100,200.');
56
+ if (!seen.has(client)) {
57
+ seen.add(client);
58
+ unique.push(client);
59
+ }
60
+ }
61
+ if (!unique.length) throw new Error('Enter at least one SAP client.');
62
+ return unique;
63
+ }
64
+
65
+ async function promptSapClients(destination, { input = stdin, output = stdout } = {}) {
66
+ const fallback = destination.client || '001';
67
+ const answer = await textPrompt({
68
+ message: `SAP client(s) for ${destination.name}`,
69
+ placeholder: fallback,
70
+ required: false,
71
+ validate: value => {
72
+ try { parseSapClientList(value, fallback); return true; }
73
+ catch (error) { return error.message; }
74
+ }
75
+ }, { input, output });
76
+ return parseSapClientList(answer, fallback);
77
+ }
78
+
79
+ function localSystemDiscriminator(destination) {
80
+ return [destination.systemId, destination.host, destination.instance].map(value => String(value || '').trim()).filter(Boolean).join(' ');
81
+ }
82
+
83
+ function localClientDestinationName(destination, client, { multipleClients = false, duplicateSystemName = false } = {}) {
84
+ const suffix = [];
85
+ if (multipleClients || duplicateSystemName) suffix.push(client);
86
+ if (duplicateSystemName) {
87
+ const discriminator = localSystemDiscriminator(destination);
88
+ if (discriminator) suffix.push(discriminator);
89
+ }
90
+ return suffix.length ? `${destination.name} ${suffix.join(' ')}` : destination.name;
91
+ }
92
+
46
93
 
47
94
  function safeProbe(probe) {
48
95
  const result = {};
@@ -79,6 +126,8 @@ function safeLocalSapGuiDestination(destination) {
79
126
  authentication: destination.authentication || 'Basic',
80
127
  authMode: destination.authMode,
81
128
  proxyType: 'Internet',
129
+ tlsServerName: destination.tlsServerName,
130
+ tlsServerNames: destination.tlsServerNames,
82
131
  probe: safeProbe(destination.probe),
83
132
  childEnv: destination.childEnv || {}
84
133
  };
@@ -289,7 +338,7 @@ export async function runSetup({
289
338
  print(output, ' Space = select/deselect · a = toggle all · Enter = confirm.');
290
339
  print(output, ' Nothing selected removes this add-on’s MCP entries.');
291
340
  print(output, '');
292
- const selected = await checkboxPrompt({
341
+ let selected = await checkboxPrompt({
293
342
  message: colorText('🧭 Select destinations', 'cyan', output),
294
343
  choices,
295
344
  required: false,
@@ -298,12 +347,23 @@ export async function runSetup({
298
347
 
299
348
  if (!isBas && selected.length) {
300
349
  print(output, '');
301
- print(output, formatStatus('SAP GUI landscapes do not contain ADT HTTP(S) endpoints. Confirm the ADT URL and authentication for each selected system.', 'step', output, 'Local SAP GUI'));
350
+ print(output, formatStatus('SAP GUI landscapes do not contain ADT HTTP(S) endpoints or a complete client catalog. Confirm the SAP client(s), ADT URL, and authentication for each selected system.', 'step', output, 'Local SAP GUI'));
351
+ print(output, ' Enter multiple clients as comma-separated 3-digit values, for example 100,200. Setup creates one isolated MCP server per system/client pair.');
302
352
  if (windowsSsoSetupAvailable(env)) print(output, ' Windows SSO is experimental and only applies to ADT systems configured for HTTP Integrated Authentication. Username/password remains the default.');
353
+ const selectedNameCounts = selected.reduce((counts, destination) => {
354
+ const key = String(destination.name).toLowerCase();
355
+ counts.set(key, (counts.get(key) || 0) + 1);
356
+ return counts;
357
+ }, new Map());
358
+ const expanded = [];
303
359
  for (const destination of selected) {
360
+ const duplicateSystemName = (selectedNameCounts.get(String(destination.name).toLowerCase()) || 0) > 1;
361
+ const discriminator = duplicateSystemName ? localSystemDiscriminator(destination) : '';
362
+ const promptLabel = discriminator ? `${destination.name} (${discriminator})` : destination.name;
363
+ const clients = await promptSapClients({ ...destination, name: promptLabel }, { input, output });
304
364
  const fallback = defaultAdtUrl(destination);
305
365
  const url = await textPrompt({
306
- message: `ADT URL for ${destination.name}`,
366
+ message: `ADT URL for ${promptLabel}`,
307
367
  placeholder: fallback || 'https://host:44300',
308
368
  validate: value => {
309
369
  const candidate = value || fallback;
@@ -320,26 +380,54 @@ export async function runSetup({
320
380
  required: false
321
381
  }, { input, output });
322
382
  destination.url = url || fallback;
323
- const authMode = await chooseLocalAuthMode(destination, { input, output, env });
383
+ const parsedAdtUrl = new URL(destination.url);
384
+ if (parsedAdtUrl.protocol === 'https:' && isIP(parsedAdtUrl.hostname)) {
385
+ print(output, formatStatus('The ADT URL uses an IP address. Setup will inspect the SAP HTTPS certificate and can enable TLS self-healing automatically; certificate validation remains enabled.', 'warning', output, 'TLS self-heal'));
386
+ let discoveredNames = [];
387
+ try { discoveredNames = await discoverCertificateDnsNames(destination.url); }
388
+ catch { discoveredNames = []; }
389
+ if (discoveredNames.length) {
390
+ const preview = discoveredNames.slice(0, 5).join(', ');
391
+ const answer = await textPrompt({
392
+ message: `Use discovered certificate DNS name${discoveredNames.length === 1 ? '' : 's'} for ${promptLabel}: ${preview}${discoveredNames.length > 5 ? ', ...' : ''}? (y/Enter=yes, n=no)`,
393
+ placeholder: 'y',
394
+ required: false,
395
+ validate: value => /^(?:|y|yes|n|no)$/i.test(String(value || '').trim()) || 'Enter y or n.'
396
+ }, { input, output });
397
+ if (!/^n(?:o)?$/i.test(String(answer || '').trim())) {
398
+ destination.tlsServerNames = discoveredNames;
399
+ destination.tlsServerName = discoveredNames[0];
400
+ }
401
+ } else {
402
+ print(output, formatStatus('No DNS subjectAltName entries could be read from the SAP HTTPS certificate. TLS self-healing was not configured automatically.', 'warning', output, 'TLS self-heal'));
403
+ }
404
+ }
405
+ const authMode = await chooseLocalAuthMode({ ...destination, name: promptLabel }, { input, output, env });
324
406
  destination.authentication = authMode === 'windows-sso' ? 'WindowsSSO' : 'Basic';
325
407
  destination.authMode = authMode;
326
- if (authMode === 'windows-sso') {
327
- destination.childEnv = { SAP_AUTH_MODE: 'windows-sso' };
328
- destination.inputs = [];
329
- } else {
330
- const userInputId = `sap-ai-dev-${destination.serverName || destination.name}-user`.toLowerCase().replace(/[^a-z0-9_-]+/g, '-');
331
- const passwordInputId = `sap-ai-dev-${destination.serverName || destination.name}-password`.toLowerCase().replace(/[^a-z0-9_-]+/g, '-');
332
- destination.childEnv = {
333
- SAP_AUTH_MODE: 'basic',
334
- SAP_USER: `\${input:${userInputId}}`,
335
- SAP_PASSWORD: `\${input:${passwordInputId}}`
336
- };
337
- destination.inputs = [
338
- { id: userInputId, type: 'promptString', description: `SAP user for ${destination.name}` },
339
- { id: passwordInputId, type: 'promptString', description: `SAP password for ${destination.name}`, password: true }
340
- ];
408
+ for (const client of clients) {
409
+ const name = localClientDestinationName(destination, client, { multipleClients: clients.length > 1, duplicateSystemName });
410
+ const localDestination = { ...destination, name, serverName: name, client };
411
+ if (authMode === 'windows-sso') {
412
+ localDestination.childEnv = { SAP_AUTH_MODE: 'windows-sso' };
413
+ localDestination.inputs = [];
414
+ } else {
415
+ const userInputId = `sap-ai-dev-${localDestination.serverName || localDestination.name}-user`.toLowerCase().replace(/[^a-z0-9_-]+/g, '-');
416
+ const passwordInputId = `sap-ai-dev-${localDestination.serverName || localDestination.name}-password`.toLowerCase().replace(/[^a-z0-9_-]+/g, '-');
417
+ localDestination.childEnv = {
418
+ SAP_AUTH_MODE: 'basic',
419
+ SAP_USER: `\${input:${userInputId}}`,
420
+ SAP_PASSWORD: `\${input:${passwordInputId}}`
421
+ };
422
+ localDestination.inputs = [
423
+ { id: userInputId, type: 'promptString', description: `SAP user for ${localDestination.name}` },
424
+ { id: passwordInputId, type: 'promptString', description: `SAP password for ${localDestination.name}`, password: true }
425
+ ];
426
+ }
427
+ expanded.push(localDestination);
341
428
  }
342
429
  }
430
+ selected = expanded;
343
431
  }
344
432
 
345
433
  let sapDevelopmentServers = [];
@@ -0,0 +1,122 @@
1
+ import http from 'node:http';
2
+ import https from 'node:https';
3
+ import tls from 'node:tls';
4
+ import { once } from 'node:events';
5
+ import { readFile } from 'node:fs/promises';
6
+ import { isIP } from 'node:net';
7
+
8
+ const HOP_BY_HOP = new Set([
9
+ 'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization',
10
+ 'te', 'trailer', 'transfer-encoding', 'upgrade'
11
+ ]);
12
+
13
+ function filteredHeaders(headers) {
14
+ const result = { ...headers };
15
+ for (const name of Object.keys(result)) {
16
+ if (HOP_BY_HOP.has(name.toLowerCase())) delete result[name];
17
+ }
18
+ return result;
19
+ }
20
+
21
+ function requestHeaders(headers, backend) {
22
+ return { ...filteredHeaders(headers), host: backend.host };
23
+ }
24
+
25
+ function parseSubjectAltName(value) {
26
+ return String(value || '')
27
+ .split(/,\s*/)
28
+ .map(item => item.match(/^DNS:(.+)$/i)?.[1]?.trim())
29
+ .filter(Boolean);
30
+ }
31
+
32
+ export async function discoverCertificateDnsNames(destinationUrl) {
33
+ const backend = new URL(destinationUrl);
34
+ if (backend.protocol !== 'https:') return [];
35
+ const socket = tls.connect({
36
+ host: backend.hostname,
37
+ port: Number(backend.port || 443),
38
+ ...(isIP(backend.hostname) ? {} : { servername: backend.hostname }),
39
+ rejectUnauthorized: false
40
+ });
41
+ try {
42
+ await once(socket, 'secureConnect');
43
+ const certificate = socket.getPeerCertificate();
44
+ return [...new Set(parseSubjectAltName(certificate?.subjectaltname))];
45
+ } finally {
46
+ socket.destroy();
47
+ }
48
+ }
49
+
50
+ function responseHeaders(headers, backend, clientReq) {
51
+ const result = filteredHeaders(headers);
52
+ const location = result.location;
53
+ if (typeof location === 'string') {
54
+ try {
55
+ const parsed = new URL(location, backend);
56
+ if (parsed.origin === backend.origin) {
57
+ result.location = `http://${clientReq.headers.host}${parsed.pathname}${parsed.search}${parsed.hash}`;
58
+ }
59
+ } catch {}
60
+ }
61
+ return result;
62
+ }
63
+
64
+ /**
65
+ * Starts a loopback HTTP reverse proxy for ADT HTTPS endpoints whose network
66
+ * address and certificate identity differ. The proxy dials the configured
67
+ * backend URL but verifies TLS with tlsServerName; CA validation stays enabled.
68
+ */
69
+ export async function createTlsServerNameAdtProxy({ destinationUrl, tlsServerName, tlsServerNames, caFile } = {}) {
70
+ const backend = new URL(destinationUrl);
71
+ if (backend.protocol !== 'https:') throw new Error('TLS server-name override requires an https:// SAP URL.');
72
+ const serverNames = [...new Set([...(Array.isArray(tlsServerNames) ? tlsServerNames : []), ...String(tlsServerName || '').split(',')].map(value => String(value || '').trim()).filter(Boolean))];
73
+ if (!serverNames.length) throw new Error('TLS server-name override requires a DNS name.');
74
+
75
+ const ca = caFile ? await readFile(caFile) : undefined;
76
+ const server = http.createServer((clientReq, clientRes) => {
77
+ let target;
78
+ try {
79
+ target = new URL(clientReq.url || '/', backend);
80
+ if (target.origin !== backend.origin) throw new Error('cross-origin request rejected');
81
+ } catch (error) {
82
+ clientRes.writeHead(400, { 'content-type': 'text/plain; charset=utf-8' });
83
+ clientRes.end(`ADT TLS proxy rejected request: ${error.message}`);
84
+ return;
85
+ }
86
+ const chunks = [];
87
+ clientReq.on('data', chunk => chunks.push(chunk));
88
+ clientReq.on('end', () => {
89
+ const body = Buffer.concat(chunks);
90
+ const tryServerName = index => {
91
+ const request = https.request({
92
+ protocol: 'https:',
93
+ hostname: backend.hostname,
94
+ port: backend.port || 443,
95
+ method: clientReq.method,
96
+ path: `${target.pathname}${target.search}`,
97
+ headers: requestHeaders(clientReq.headers, backend),
98
+ servername: serverNames[index],
99
+ rejectUnauthorized: true,
100
+ ...(ca ? { ca } : {})
101
+ }, backendRes => {
102
+ clientRes.writeHead(backendRes.statusCode || 502, backendRes.statusMessage, responseHeaders(backendRes.headers, backend, clientReq));
103
+ backendRes.pipe(clientRes);
104
+ });
105
+ request.on('error', error => {
106
+ if (index + 1 < serverNames.length && /certificate|hostname|altname|x509/i.test(error.message)) return tryServerName(index + 1);
107
+ if (!clientRes.headersSent) clientRes.writeHead(502, { 'content-type': 'text/plain; charset=utf-8' });
108
+ clientRes.end(`ADT TLS proxy failed: ${error.message}`);
109
+ });
110
+ request.end(body);
111
+ };
112
+ tryServerName(0);
113
+ });
114
+ });
115
+ server.listen(0, '127.0.0.1');
116
+ await once(server, 'listening');
117
+ const { port } = server.address();
118
+ return {
119
+ url: `http://127.0.0.1:${port}`,
120
+ close: () => new Promise(resolve => server.close(() => resolve()))
121
+ };
122
+ }