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 +3 -0
- package/package.json +1 -1
- package/src/launcher.mjs +18 -5
- package/src/mcp-config.mjs +2 -0
- package/src/setup.mjs +26 -0
- package/src/tls-adt-proxy.mjs +122 -0
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
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:
|
|
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
|
|
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:
|
|
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
|
-
|
|
214
|
+
close: tlsRoute?.close,
|
|
215
|
+
probe: { status: tlsRoute ? 'configured-tls-server-name' : 'configured', available: true }
|
|
203
216
|
};
|
|
204
217
|
}
|
|
205
218
|
|
package/src/mcp-config.mjs
CHANGED
|
@@ -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
|
+
}
|