sap-ai-dev-toolkit 0.7.9 → 0.8.0

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
@@ -750,6 +750,9 @@ The response contains a `tools` array. A `RunQuery` entry resembles this excerpt
750
750
  | `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
751
  | `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
752
  | `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. |
753
+ | `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. |
754
+ | `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. |
755
+ | `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
756
  | `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
757
  | `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
758
  | `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.0",
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
 
@@ -79,6 +81,8 @@ function safeLocalSapGuiDestination(destination) {
79
81
  authentication: destination.authentication || 'Basic',
80
82
  authMode: destination.authMode,
81
83
  proxyType: 'Internet',
84
+ tlsServerName: destination.tlsServerName,
85
+ tlsServerNames: destination.tlsServerNames,
82
86
  probe: safeProbe(destination.probe),
83
87
  childEnv: destination.childEnv || {}
84
88
  };
@@ -320,6 +324,28 @@ export async function runSetup({
320
324
  required: false
321
325
  }, { input, output });
322
326
  destination.url = url || fallback;
327
+ const parsedAdtUrl = new URL(destination.url);
328
+ if (parsedAdtUrl.protocol === 'https:' && isIP(parsedAdtUrl.hostname)) {
329
+ 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'));
330
+ let discoveredNames = [];
331
+ try { discoveredNames = await discoverCertificateDnsNames(destination.url); }
332
+ catch { discoveredNames = []; }
333
+ if (discoveredNames.length) {
334
+ const preview = discoveredNames.slice(0, 5).join(', ');
335
+ const answer = await textPrompt({
336
+ message: `Use discovered certificate DNS name${discoveredNames.length === 1 ? '' : 's'} for ${destination.name}: ${preview}${discoveredNames.length > 5 ? ', ...' : ''}? (y/Enter=yes, n=no)`,
337
+ placeholder: 'y',
338
+ required: false,
339
+ validate: value => /^(?:|y|yes|n|no)$/i.test(String(value || '').trim()) || 'Enter y or n.'
340
+ }, { input, output });
341
+ if (!/^n(?:o)?$/i.test(String(answer || '').trim())) {
342
+ destination.tlsServerNames = discoveredNames;
343
+ destination.tlsServerName = discoveredNames[0];
344
+ }
345
+ } else {
346
+ 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'));
347
+ }
348
+ }
323
349
  const authMode = await chooseLocalAuthMode(destination, { input, output, env });
324
350
  destination.authentication = authMode === 'windows-sso' ? 'WindowsSSO' : 'Basic';
325
351
  destination.authMode = authMode;
@@ -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
+ }