sap-ai-dev-toolkit 0.5.7 → 0.6.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 -5
- package/package.json +1 -1
- package/src/bas-destination-relay.mjs +13 -41
- package/src/launcher.mjs +5 -15
- package/src/setup.mjs +2 -110
- package/src/terminal-ui.mjs +3 -1
- package/src/credential-overrides.mjs +0 -75
- package/src/credentials-store.mjs +0 -113
package/README.md
CHANGED
|
@@ -400,7 +400,7 @@ The sample values sketch an on-premise ABAP backend routed through SAP Cloud Con
|
|
|
400
400
|
| `WebIDEUsage` | `dev_abap,odata_abap` | Include `dev_abap` for ABAP development; add other usages required by your BAS scenario. |
|
|
401
401
|
| `CloudConnectorLocationId` | `DEMO-LOCATION` | Optional; set only when the Cloud Connector uses a location ID. |
|
|
402
402
|
|
|
403
|
-
For on-premise systems, configure Cloud Connector access to the backend host and port first.
|
|
403
|
+
For on-premise systems, configure Cloud Connector access to the backend host and port first. The add-on uses the authentication already configured in BAS or the Cloud Foundry Destination service; manage SAP credentials there.
|
|
404
404
|
|
|
405
405
|
Think of `H2O_URL` as BAS's front door: it must point to the endpoint serving `/api/listDestinations`, not to the SAP backend.
|
|
406
406
|
|
|
@@ -734,14 +734,13 @@ The response contains a `tools` array. A `RunQuery` entry resembles this excerpt
|
|
|
734
734
|
| `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`. |
|
|
735
735
|
| `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. |
|
|
736
736
|
| `SAP_AI_DEV_TOOLKIT_DISABLE_BAS_RELAY=true` | Disable the built-in BAS destination relay. By default the add-on self-heals `.dest` destinations through a local relay that keeps all access destination-based while handling ADT CSRF fetch/retry behavior before VSP calls SAP. |
|
|
737
|
-
| `SAP_AI_DEV_TOOLKIT_HTTP_PROXY` | Egress proxy for relay and discovery traffic. Takes precedence over `HTTP_PROXY`/`http_proxy`; unset means the default BAS proxy for `.dest` hosts, empty means direct
|
|
737
|
+
| `SAP_AI_DEV_TOOLKIT_HTTP_PROXY` | Egress proxy for relay and discovery traffic. Takes precedence over `HTTP_PROXY`/`http_proxy`; unset means the default BAS proxy for `.dest` hosts, empty means direct. |
|
|
738
738
|
| `SAP_AI_DEV_TOOLKIT_MAX_CSRF_RETRIES` | Bounded retries after explicit CSRF-session rejection per unsafe request (default 3, maximum 10). Invalid values use the default. |
|
|
739
739
|
| `SAP_AI_DEV_TOOLKIT_READ_ONLY=true` | Read-only mode: hides `WriteSource`, `EditSource`, `Activate`, `ActivateMultiple`, `CreateTransport`, `SetBreakpoint`, and the change-set workflow tools, and starts VSP with `--transport-read-only`. |
|
|
740
740
|
| `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. |
|
|
741
741
|
| `SAP_AI_DEV_MCP_CONFIG` | Explicit MCP configuration path; highest precedence. |
|
|
742
742
|
| `SAP_AI_DEV_TOOLKIT_MCP_CONFIG` | Branded compatibility alias for the MCP configuration path. |
|
|
743
743
|
| `BAS_VSP_MCP_CONFIG` | Backward-compatible alias for the MCP configuration path. |
|
|
744
|
-
| `SAP_AI_DEV_TOOLKIT_CREDENTIALS_FILE` | Explicit path for the optional SAP backend credential-override file (default: beside `mcp.json`). |
|
|
745
744
|
| `SAP_AI_DEV_TOOLKIT_BINARY` | Trusted prebuilt VSP executable; skips binary provisioning. Legacy `BAS_VSP_BINARY` still works. |
|
|
746
745
|
| `SAP_AI_DEV_TOOLKIT_BINARY_URL` | Alternate VSP binary download URL (checksum-verified). Legacy `BAS_VSP_BINARY_URL` still works. |
|
|
747
746
|
| `SAP_AI_DEV_TOOLKIT_RELEASE_BASE_URL` | Override the base URL that VSP release binaries are downloaded from. |
|
|
@@ -760,8 +759,7 @@ The relay between the VSP child and each BAS destination recovers from the failu
|
|
|
760
759
|
2. **Session refresh** — when SAP explicitly rejects a CSRF token, the cached session is dropped, a fresh token+cookie pair is fetched, and the request is retried within the configured bound. Generic authorization 401/403 responses are returned without being mislabeled or retried as CSRF failures.
|
|
761
760
|
3. **Proxy tunnel fallback** — when the BAS proxy refuses absolute-form requests (502/504 or transport errors), the relay switches to a CONNECT tunnel through the same proxy and keeps going.
|
|
762
761
|
4. **Child crash recovery** — a crashed VSP child is restarted transparently, re-initialized, tools re-registered, and the interrupted `tools/call` retried once before any error reaches the client.
|
|
763
|
-
5. **
|
|
764
|
-
6. **Cloud Connector credential overrides** — setup also offers per-destination overrides for BasicAuthentication OnPremise destinations. BAS OnPremise credentials travel through an HTTP CONNECT tunnel to the BAS `.dest` endpoint, retaining its Cloud Connector mapping; setup saves the override only after a read-only probe returns both a CSRF token and a session cookie. If that route check fails, the override is not saved. Cloud Foundry OnPremise overrides replace only the SAP backend user/password; Connectivity service-key authentication and its proxy route remain unchanged. PrincipalPropagation is never overridden. Rerun `sap-ai-dev --setup` to change credentials or answer No to disable a stored override. Deselecting a destination (or selecting none) removes stale overrides.
|
|
762
|
+
5. **Configured destination authentication** — the add-on always uses the URL, authentication mode, and credentials already configured for the selected destination. Setup does not prompt for or store SAP usernames/passwords, and the relay does not connect directly to a backend host as a credential fallback. Correct the credentials in BAS or the Cloud Foundry Destination service if SAP rejects them.
|
|
765
763
|
Existing installs that still set the previous `BAS_VSP_*` environment variables remain supported. The setup wizard writes new MCP entries with the `SAP_AI_DEV_TOOLKIT_*` names.
|
|
766
764
|
|
|
767
765
|
#### Server logging
|
package/package.json
CHANGED
|
@@ -133,14 +133,7 @@ async function readRequestBody(req) {
|
|
|
133
133
|
export function createBasDestinationRelay(destination, { env = process.env, fetchImpl = undiciFetch, log = () => {} } = {}) {
|
|
134
134
|
if (!destination?.url) throw new Error('Destination relay requires a BAS destination URL');
|
|
135
135
|
const base = new URL(destination.url);
|
|
136
|
-
const
|
|
137
|
-
const tunnelMode = credentials?.mode === 'bas-tunnel';
|
|
138
|
-
const directHost = tunnelMode ? '' : credentials?.host;
|
|
139
|
-
// Internet overrides retain the direct-backend path. OnPremise overrides
|
|
140
|
-
// must keep the BAS .dest route (and its Cloud Connector mapping), so they
|
|
141
|
-
// use HTTP CONNECT through the BAS proxy instead of credentials.host.
|
|
142
|
-
const directBase = directHost ? new URL(String(directHost)) : null;
|
|
143
|
-
const cookieBase = directBase || (destination.backendUrl
|
|
136
|
+
const cookieBase = destination.backendUrl
|
|
144
137
|
? new URL(String(destination.backendUrl))
|
|
145
138
|
: (() => {
|
|
146
139
|
const target = new URL(base);
|
|
@@ -149,9 +142,8 @@ export function createBasDestinationRelay(destination, { env = process.env, fetc
|
|
|
149
142
|
// omits the backend URL so Secure session cookies are not discarded.
|
|
150
143
|
if (target.protocol === 'http:' && /\.dest$/i.test(target.hostname)) target.protocol = 'https:';
|
|
151
144
|
return target;
|
|
152
|
-
})()
|
|
153
|
-
|
|
154
|
-
if (tunnelMode) log(`[${destination.name}] BAS relay using a BAS proxy tunnel for the OnPremise destination`);
|
|
145
|
+
})();
|
|
146
|
+
// BAS destinations must keep their configured .dest route and authentication.
|
|
155
147
|
// Proxy resolution: an explicitly configured proxy wins (an explicit empty
|
|
156
148
|
// value means "go direct"). Without configuration, BAS virtual .dest hosts
|
|
157
149
|
// imply the default BAS proxy; any other host (tests, direct URLs) is
|
|
@@ -159,16 +151,15 @@ export function createBasDestinationRelay(destination, { env = process.env, fetc
|
|
|
159
151
|
// foreign proxy.
|
|
160
152
|
const brandedProxy = brandedEnvValue(env, 'HTTP_PROXY');
|
|
161
153
|
const explicitProxy = brandedProxy !== undefined ? brandedProxy : (env.HTTP_PROXY ?? env.http_proxy);
|
|
162
|
-
const proxyUrl =
|
|
163
|
-
if (tunnelMode && !proxyUrl) throw new Error('OnPremise credential overrides require the BAS destination proxy to preserve the Cloud Connector route');
|
|
154
|
+
const proxyUrl = explicitProxy !== undefined ? String(explicitProxy) : (/\.dest$/i.test(base.hostname) ? DEFAULT_PROXY : '');
|
|
164
155
|
const dispatcher = proxyUrl ? new ProxyAgent({ uri: proxyUrl, proxyTunnel: false }) : undefined;
|
|
165
156
|
// Self-healing mode 2: if the BAS proxy refuses absolute-form proxied
|
|
166
157
|
// requests (its own 502/504), retry through a CONNECT tunnel so the relay
|
|
167
158
|
// keeps working through the same egress.
|
|
168
159
|
const tunnelDispatcher = proxyUrl ? new ProxyAgent({ uri: proxyUrl, proxyTunnel: true }) : undefined;
|
|
169
|
-
let tunneled =
|
|
160
|
+
let tunneled = false;
|
|
170
161
|
// Observable self-healing counters, surfaced by --doctor and tests.
|
|
171
|
-
const stats = { requests: 0, csrfFetches: 0, csrfRetries: 0, csrfSessionFailures: 0, tunnelFallbacks: 0, direct:
|
|
162
|
+
const stats = { requests: 0, csrfFetches: 0, csrfRetries: 0, csrfSessionFailures: 0, tunnelFallbacks: 0, direct: false, proxyTunnel: false };
|
|
172
163
|
const maxFailures = maxTokenFailures(env, log, destination.name);
|
|
173
164
|
// The shared jar captures SAP cookies from safe reads as well as CSRF fetches.
|
|
174
165
|
// Each cached token gets its own snapshot so later responses cannot silently
|
|
@@ -210,16 +201,16 @@ export function createBasDestinationRelay(destination, { env = process.env, fetc
|
|
|
210
201
|
async function fetchTokenFresh(requestHeaders) {
|
|
211
202
|
const key = CSRF_FETCH_PATH;
|
|
212
203
|
stats.csrfFetches += 1;
|
|
213
|
-
const target =
|
|
204
|
+
const target = new URL(CSRF_FETCH_PATH, base);
|
|
214
205
|
const cookieTarget = resolveCookieTarget(target);
|
|
215
206
|
const jar = copyCookieJar(sharedCookieJar);
|
|
216
207
|
await seedRequestCookies(jar, requestHeaders.cookie, cookieTarget);
|
|
217
208
|
const outgoingCookie = await cookieHeader(jar, cookieTarget);
|
|
218
|
-
const response = await sendWithFallback(target, 'GET',
|
|
209
|
+
const response = await sendWithFallback(target, 'GET', fetchHeaders(requestHeaders, {
|
|
219
210
|
'x-csrf-token': 'Fetch',
|
|
220
211
|
accept: requestHeaders.accept || 'application/xml,*/*',
|
|
221
212
|
...(outgoingCookie ? { cookie: outgoingCookie } : {})
|
|
222
|
-
}))
|
|
213
|
+
}));
|
|
223
214
|
await response.arrayBuffer().catch(() => undefined);
|
|
224
215
|
const token = response.headers.get('x-csrf-token');
|
|
225
216
|
await recordCookies(jar, response.headers, cookieTarget, log, destination.name);
|
|
@@ -265,36 +256,17 @@ export function createBasDestinationRelay(destination, { env = process.env, fetc
|
|
|
265
256
|
};
|
|
266
257
|
}
|
|
267
258
|
|
|
268
|
-
// Rewrite an incoming .dest target onto the direct backend host when
|
|
269
|
-
// credentials are configured, keeping path and query intact.
|
|
270
|
-
function resolveTarget(target) {
|
|
271
|
-
if (!directBase) return target;
|
|
272
|
-
return new URL(`${target.pathname}${target.search}`, directBase);
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
function authHeaders(extra = {}) {
|
|
276
|
-
if (!credentials?.user || !credentials?.password) return extra;
|
|
277
|
-
const basic = Buffer.from(`${credentials.user}:${credentials.password}`).toString('base64');
|
|
278
|
-
// The stored credential override wins over any inbound authorization
|
|
279
|
-
// header: the override exists precisely to pair Basic auth with the
|
|
280
|
-
// relay's CSRF session, and silently forwarding a caller-supplied header
|
|
281
|
-
// would disable that pairing without any diagnostic.
|
|
282
|
-
const headers = { ...extra };
|
|
283
|
-
delete headers.authorization;
|
|
284
|
-
delete headers.Authorization;
|
|
285
|
-
return { ...headers, authorization: `Basic ${basic}` };
|
|
286
|
-
}
|
|
287
259
|
|
|
288
260
|
async function forward(method, requestUrl, requestHeaders, body, session) {
|
|
289
|
-
const target =
|
|
261
|
+
const target = requestUrl;
|
|
290
262
|
const cookieTarget = resolveCookieTarget(target);
|
|
291
263
|
const cookieJar = session?.jar || sharedCookieJar;
|
|
292
264
|
if (!session) await seedRequestCookies(cookieJar, requestHeaders.cookie, cookieTarget);
|
|
293
265
|
const sessionCookie = await cookieHeader(cookieJar, cookieTarget);
|
|
294
|
-
const headers =
|
|
266
|
+
const headers = headersFrom(requestHeaders, {
|
|
295
267
|
...(session?.token ? { 'x-csrf-token': session.token } : {}),
|
|
296
268
|
...(sessionCookie ? { cookie: sessionCookie } : {})
|
|
297
|
-
})
|
|
269
|
+
});
|
|
298
270
|
const response = await sendWithFallback(target, method, headers, body);
|
|
299
271
|
const responseBody = Buffer.from(await response.arrayBuffer());
|
|
300
272
|
if (session) await recordCookies(session.jar, response.headers, cookieTarget, log, destination.name);
|
|
@@ -332,7 +304,7 @@ export function createBasDestinationRelay(destination, { env = process.env, fetc
|
|
|
332
304
|
return forwardWithRetry(method, requestUrl, requestHeaders, body, failures + 1);
|
|
333
305
|
}
|
|
334
306
|
if (unsafe && rejected && tokenOnly) {
|
|
335
|
-
log(`[${destination.name}] BAS relay CSRF token rejected without a session cookie for ${requestUrl.pathname}; not retried (route
|
|
307
|
+
log(`[${destination.name}] BAS relay CSRF token rejected without a session cookie for ${requestUrl.pathname}; not retried (the configured destination route delivered no cookies)`);
|
|
336
308
|
} else if (unsafe && rejected && failures >= maxFailures) {
|
|
337
309
|
log(`[${destination.name}] BAS relay stopped after ${maxFailures} CSRF session retries for ${requestUrl.pathname}`);
|
|
338
310
|
}
|
package/src/launcher.mjs
CHANGED
|
@@ -10,7 +10,6 @@ import { installMcpConfig, repairManagedMcpConfig } from './mcp-config.mjs';
|
|
|
10
10
|
import { runSetup } from './setup.mjs';
|
|
11
11
|
import { resolveConfiguredCloudFoundryDestination } from './cf-destination.mjs';
|
|
12
12
|
import { withBrandedEnvironment } from './branding.mjs';
|
|
13
|
-
import { enrichWithStoredCredentials } from './credential-overrides.mjs';
|
|
14
13
|
import { redactText } from './redact.mjs';
|
|
15
14
|
const root = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
16
15
|
const pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'));
|
|
@@ -100,11 +99,6 @@ async function runDoctor(destinations) {
|
|
|
100
99
|
} else {
|
|
101
100
|
checks.push(doctorRow(destination.name, 'BAS destination relay', 'skipped', 'Not applicable for this destination.'));
|
|
102
101
|
}
|
|
103
|
-
if (relayedDestination?.credentials?.mode === 'bas-tunnel') {
|
|
104
|
-
checks.push(doctorRow(destination.name, 'OnPremise credential override', 'passed', 'Basic auth is routed through a BAS proxy tunnel; Cloud Connector routing is retained'));
|
|
105
|
-
} else if (relayedDestination?.credentials?.host) {
|
|
106
|
-
checks.push(doctorRow(destination.name, 'Direct connect', 'passed', `active (${new URL(relayedDestination.credentials.host).origin}); ADT writes use stored credentials`));
|
|
107
|
-
}
|
|
108
102
|
const listed = await proxy.handle({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} });
|
|
109
103
|
if (listed?.error) throw new Error(listed.error.message || 'MCP tools/list failed');
|
|
110
104
|
const tools = listed.result?.tools || [];
|
|
@@ -137,7 +131,7 @@ async function runDoctor(destinations) {
|
|
|
137
131
|
: `HTTP ${session?.httpStatus || 0}; token=${session?.tokenReceived ? 'received' : 'missing'}; cookies=${session?.cookieCount || 0}; usableSession=${sessionReady}; csrfFetches=${stats.csrfFetches || 0}; csrfRetries=${stats.csrfRetries || 0}; sessionFailures=${stats.csrfSessionFailures || 0}; tunnelFallbacks=${stats.tunnelFallbacks || 0}`;
|
|
138
132
|
checks.push(doctorRow(destination.name, 'BAS relay CSRF preflight', status, detail));
|
|
139
133
|
if (!sessionReady) {
|
|
140
|
-
const remediation = 'GetSystemInfo was not called because the relay could not establish a paired CSRF token/session.
|
|
134
|
+
const remediation = 'GetSystemInfo was not called because the relay could not establish a paired CSRF token/session. Confirm the configured destination authentication and BAS proxy route.';
|
|
141
135
|
checks.push(doctorRow(destination.name, 'SAP system check', 'skipped', preflightError ? `${remediation} Relay error: ${preflightError}` : remediation));
|
|
142
136
|
}
|
|
143
137
|
}
|
|
@@ -222,8 +216,8 @@ async function main() {
|
|
|
222
216
|
let report;
|
|
223
217
|
try {
|
|
224
218
|
const destinations = runtimeEnv.SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE === 'cloud-foundry'
|
|
225
|
-
?
|
|
226
|
-
: await
|
|
219
|
+
? [await resolveConfiguredCloudFoundryDestination({ env: runtimeEnv })]
|
|
220
|
+
: await discoverForCommand();
|
|
227
221
|
report = await runDoctor(destinations);
|
|
228
222
|
} catch (error) {
|
|
229
223
|
report = { ok: false, destinations: 0, checks: [doctorRow('-', 'destination discovery', 'failed', redactText(error.message || error).slice(0, 300))] };
|
|
@@ -259,7 +253,7 @@ async function main() {
|
|
|
259
253
|
}
|
|
260
254
|
|
|
261
255
|
if (runtimeEnv.SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE === 'cloud-foundry') {
|
|
262
|
-
const destination =
|
|
256
|
+
const destination = await resolveConfiguredCloudFoundryDestination({ env: runtimeEnv });
|
|
263
257
|
let proxy;
|
|
264
258
|
try {
|
|
265
259
|
const binary = await binaryOrError();
|
|
@@ -289,7 +283,7 @@ async function main() {
|
|
|
289
283
|
|
|
290
284
|
const discovered = await discoverDestinations({ env: runtimeEnv });
|
|
291
285
|
for (const destination of discovered) logLine(probeDiagnostic(destination));
|
|
292
|
-
const destinations =
|
|
286
|
+
const destinations = discovered;
|
|
293
287
|
if (!destinations.length) {
|
|
294
288
|
logLine('[sap-ai-dev] destination discovery returned no named BAS destinations');
|
|
295
289
|
throw new Error(remediation);
|
|
@@ -297,10 +291,6 @@ async function main() {
|
|
|
297
291
|
const binary = await binaryOrError();
|
|
298
292
|
logLine(`[sap-ai-dev] sap-ai-dev-toolkit v${pkg.version} (node ${process.version}, pid ${process.pid})`);
|
|
299
293
|
logLine(`[sap-ai-dev] VSP binary: ${binary}`);
|
|
300
|
-
for (const destination of destinations) {
|
|
301
|
-
if (destination.credentials?.mode === 'bas-tunnel') logLine(`[sap-ai-dev] ${destination.name}: Basic credentials enabled through the BAS proxy tunnel`);
|
|
302
|
-
else if (destination.credentials?.host) logLine(`[sap-ai-dev] ${destination.name}: direct connect enabled through stored credentials`);
|
|
303
|
-
}
|
|
304
294
|
logLine(`[sap-ai-dev] starting MCP proxy for ${destinations.map(destination => `${destination.name} (client=${destination.client})`).join(', ')}`);
|
|
305
295
|
const proxy = new MCPProxy({ binary, destinations, env: process.env, log: logLine, version: pkg.version });
|
|
306
296
|
const shutdown = signal => { void proxy.close().finally(() => process.exit(signal === 'SIGINT' ? 130 : 143)); };
|
package/src/setup.mjs
CHANGED
|
@@ -1,12 +1,9 @@
|
|
|
1
1
|
import { createInterface } from 'node:readline/promises';
|
|
2
2
|
import { stdin, stdout } from 'node:process';
|
|
3
|
-
import { checkboxPrompt, colorText, formatStatus
|
|
3
|
+
import { checkboxPrompt, colorText, formatStatus } from './terminal-ui.mjs';
|
|
4
4
|
import { discoverDestinations, remediation } from './bas-discovery.mjs';
|
|
5
5
|
import { discoverCloudFoundryDestinations, getCloudFoundryTarget, deleteManagedCloudFoundryServiceKeys, findOrphanedCloudFoundryServiceKeys } 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';
|
|
8
|
-
import { canPromptForCredentials, credentialKeyForDestination, credentialModeForDestination } from './credential-overrides.mjs';
|
|
9
|
-
import { createBasDestinationRelay } from './bas-destination-relay.mjs';
|
|
10
7
|
|
|
11
8
|
const SETUP_COMMAND = 'sap-ai-dev --setup';
|
|
12
9
|
|
|
@@ -26,89 +23,6 @@ async function confirmCloudFoundryImport({ input = stdin, output = stdout } = {}
|
|
|
26
23
|
}
|
|
27
24
|
}
|
|
28
25
|
|
|
29
|
-
async function probeBasOnPremCredentialRoute({ destination, user, password, env }) {
|
|
30
|
-
const relay = createBasDestinationRelay({
|
|
31
|
-
...destination,
|
|
32
|
-
credentials: { user, password, mode: 'bas-tunnel' }
|
|
33
|
-
}, { env, log: () => {} });
|
|
34
|
-
try {
|
|
35
|
-
await relay.ready;
|
|
36
|
-
return await relay.probeCsrfSession();
|
|
37
|
-
} finally {
|
|
38
|
-
await relay.close();
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
async function promptForDestinationCredentials({ destinations, credentialsPath, existing = {}, input = stdin, output = stdout, env = process.env, verifyOnPremCredentialRoute = probeBasOnPremCredentialRoute } = {}) {
|
|
43
|
-
const eligible = destinations.filter(canPromptForCredentials);
|
|
44
|
-
const retained = new Set();
|
|
45
|
-
if (!eligible.length) return retained;
|
|
46
|
-
|
|
47
|
-
print(output, '');
|
|
48
|
-
print(output, formatStatus('Credential overrides are optional and apply only to selected BasicAuthentication destinations.', 'step', output, 'Credentials'));
|
|
49
|
-
print(output, ` Overrides are stored beside your MCP config in ${credentialsPath} with owner-only permissions (never inside mcp.json).`);
|
|
50
|
-
print(output, ' For OnPremise destinations, BAS/Cloud Foundry proxy credentials remain separate from the SAP backend credentials.');
|
|
51
|
-
print(output, '');
|
|
52
|
-
|
|
53
|
-
for (const destination of eligible) {
|
|
54
|
-
const key = credentialKeyForDestination(destination);
|
|
55
|
-
if (!key) continue;
|
|
56
|
-
const mode = credentialModeForDestination(destination);
|
|
57
|
-
const known = existing[key];
|
|
58
|
-
const routeLabel = `${destination.source === 'cloud-foundry' ? 'Cloud Foundry' : 'BAS'} ${destination.proxyType} — ${destination.name}`;
|
|
59
|
-
const answer = await textPrompt({
|
|
60
|
-
message: colorText(`🔐 ${routeLabel} — override SAP credentials? (y/n)`, 'cyan', output),
|
|
61
|
-
required: true,
|
|
62
|
-
validate: value => /^(?:y|yes|n|no)$/i.test(value.trim()) || 'Enter y or n.'
|
|
63
|
-
}, { input, output });
|
|
64
|
-
if (/^n/i.test(answer.trim())) {
|
|
65
|
-
await removeDestinationCredentials(credentialsPath, [key]);
|
|
66
|
-
print(output, formatStatus(`Credential override disabled for ${destination.name}.`, 'info', output, 'Credentials'));
|
|
67
|
-
continue;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
if (known?.user && known?.password) {
|
|
71
|
-
print(output, formatStatus(`Credentials for ${destination.name} are already stored. Press Enter at both prompts to keep them.`, 'info', output, 'Credentials'));
|
|
72
|
-
}
|
|
73
|
-
const userAnswer = await textPrompt({
|
|
74
|
-
message: colorText(`👤 ${routeLabel} — SAP user`, 'cyan', output),
|
|
75
|
-
required: false,
|
|
76
|
-
placeholder: known ? 'Enter keeps the stored user' : 'SAP user name'
|
|
77
|
-
}, { input, output });
|
|
78
|
-
const user = userAnswer.trim() || known?.user || '';
|
|
79
|
-
const passwordAnswer = await textPrompt({
|
|
80
|
-
message: colorText(`🔒 ${routeLabel} — SAP password${known ? ' (Enter keeps stored password)' : ''}`, 'cyan', output),
|
|
81
|
-
secret: true,
|
|
82
|
-
required: false
|
|
83
|
-
}, { input, output });
|
|
84
|
-
const password = passwordAnswer || known?.password || '';
|
|
85
|
-
if (!user || !password) {
|
|
86
|
-
await removeDestinationCredentials(credentialsPath, [key]);
|
|
87
|
-
print(output, formatStatus(`No complete credentials for ${destination.name}; no override was stored.`, 'warning', output, 'Credentials'));
|
|
88
|
-
continue;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
if (mode === 'bas-tunnel') {
|
|
92
|
-
let route;
|
|
93
|
-
try {
|
|
94
|
-
route = await verifyOnPremCredentialRoute({ destination, user, password, env });
|
|
95
|
-
} catch {
|
|
96
|
-
route = null;
|
|
97
|
-
}
|
|
98
|
-
if (!(route?.httpStatus >= 200 && route.httpStatus < 300) || !route.tokenReceived || !(route.cookieCount > 0)) {
|
|
99
|
-
await removeDestinationCredentials(credentialsPath, [key]);
|
|
100
|
-
print(output, formatStatus(`No override stored for ${destination.name}: the BAS/Cloud Connector route did not return both a CSRF token and session cookie.`, 'warning', output, 'Credentials'));
|
|
101
|
-
continue;
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
const host = mode === 'direct' ? (destination.backendUrl || known?.host || '') : '';
|
|
106
|
-
await storeDestinationCredentials(credentialsPath, key, { host, user, password, mode });
|
|
107
|
-
retained.add(key);
|
|
108
|
-
print(output, formatStatus(`Stored credential override for ${destination.name}.`, 'success', output, 'Credentials'));
|
|
109
|
-
}
|
|
110
|
-
return retained;
|
|
111
|
-
}
|
|
112
26
|
|
|
113
27
|
function safeProbe(probe) {
|
|
114
28
|
const result = {};
|
|
@@ -187,7 +101,6 @@ export async function runSetup({
|
|
|
187
101
|
discoverCf = discoverCloudFoundryDestinations,
|
|
188
102
|
confirmCfImport = confirmCloudFoundryImport,
|
|
189
103
|
install = installMcpConfig,
|
|
190
|
-
verifyOnPremCredentialRoute = probeBasOnPremCredentialRoute,
|
|
191
104
|
includeSapDevelopmentToolsPrompt = false
|
|
192
105
|
} = {}) {
|
|
193
106
|
if (!env.H2O_URL) {
|
|
@@ -209,7 +122,6 @@ export async function runSetup({
|
|
|
209
122
|
}
|
|
210
123
|
const destinations = basDestinations.map(safeBasDestination);
|
|
211
124
|
const warnings = [];
|
|
212
|
-
const credentialWarnings = [];
|
|
213
125
|
let createdKeys = [];
|
|
214
126
|
let configPath;
|
|
215
127
|
let managedKeys = [];
|
|
@@ -352,25 +264,6 @@ export async function runSetup({
|
|
|
352
264
|
}, { input, output });
|
|
353
265
|
}
|
|
354
266
|
|
|
355
|
-
// Ask for an explicit credential override choice for each selected supported destination.
|
|
356
|
-
const credentialsPath = await resolveCredentialsPath(env, configPath);
|
|
357
|
-
try {
|
|
358
|
-
const existingMap = (await readCredentials(credentialsPath).catch(() => ({ destinations: {} }))).destinations || {};
|
|
359
|
-
const retained = await promptForDestinationCredentials({
|
|
360
|
-
destinations: selected,
|
|
361
|
-
credentialsPath,
|
|
362
|
-
existing: existingMap,
|
|
363
|
-
input,
|
|
364
|
-
output,
|
|
365
|
-
env,
|
|
366
|
-
verifyOnPremCredentialRoute
|
|
367
|
-
});
|
|
368
|
-
const stale = Object.keys(existingMap).filter(name => !retained.has(name));
|
|
369
|
-
if (stale.length) await removeDestinationCredentials(credentialsPath, stale);
|
|
370
|
-
} catch (error) {
|
|
371
|
-
if (/Prompt interrupted/.test(error.message)) throw error;
|
|
372
|
-
credentialWarnings.push(`Credential overrides could not be stored: ${error.message}`);
|
|
373
|
-
}
|
|
374
267
|
try {
|
|
375
268
|
const result = await install(selected, { env, sapDevelopmentServers });
|
|
376
269
|
const location = result?.path ? ` in ${result.path}` : '';
|
|
@@ -391,9 +284,8 @@ export async function runSetup({
|
|
|
391
284
|
}
|
|
392
285
|
}
|
|
393
286
|
await reportWarnings(warnings);
|
|
394
|
-
await reportWarnings(credentialWarnings);
|
|
395
287
|
await reportWarnings(cleanupWarnings);
|
|
396
|
-
return { selected, ...(result || {}), warnings: [...warnings, ...
|
|
288
|
+
return { selected, ...(result || {}), warnings: [...warnings, ...cleanupWarnings] };
|
|
397
289
|
} catch (error) {
|
|
398
290
|
const cleanupWarnings = [];
|
|
399
291
|
if (createdKeys.length) {
|
package/src/terminal-ui.mjs
CHANGED
|
@@ -139,7 +139,9 @@ export async function checkboxPrompt({ message, choices, required = false, short
|
|
|
139
139
|
for (let index = top; index < end; index += 1) {
|
|
140
140
|
const choice = choices[index];
|
|
141
141
|
const pointer = index === cursor ? '❯' : ' ';
|
|
142
|
-
const marker = selected.has(index)
|
|
142
|
+
const marker = selected.has(index)
|
|
143
|
+
? colorText('✓', 'green', output)
|
|
144
|
+
: colorText('✗', 'red', output);
|
|
143
145
|
const disabled = choice.disabled ? ` — ${choice.disabled}` : '';
|
|
144
146
|
const label = `${pointer}${marker} ${choice.name}${disabled}`;
|
|
145
147
|
lines.push(truncateToColumns(label, columns));
|
|
@@ -1,75 +0,0 @@
|
|
|
1
|
-
import { readCredentials, resolveCredentialsPath } from './credentials-store.mjs';
|
|
2
|
-
|
|
3
|
-
const CLOUD_FOUNDRY_KEY_PREFIX = 'cloud-foundry:';
|
|
4
|
-
|
|
5
|
-
function isBasicAuthentication(value) {
|
|
6
|
-
return /^basic(?:authentication)?$/i.test(String(value || '').replace(/\s+/g, ''));
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
export function credentialKeyForDestination(destination = {}) {
|
|
10
|
-
if (destination.source === 'cloud-foundry') {
|
|
11
|
-
const serverName = String(destination.serverName || '').trim();
|
|
12
|
-
return serverName ? `${CLOUD_FOUNDRY_KEY_PREFIX}${serverName}` : '';
|
|
13
|
-
}
|
|
14
|
-
return String(destination.name || '').trim();
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export function credentialModeForDestination(destination = {}) {
|
|
18
|
-
if (!isBasicAuthentication(destination.authentication)) return null;
|
|
19
|
-
const proxyType = String(destination.proxyType || '').trim().toLowerCase();
|
|
20
|
-
if (destination.source === 'cloud-foundry') {
|
|
21
|
-
return proxyType === 'onpremise' ? 'cf-connectivity' : null;
|
|
22
|
-
}
|
|
23
|
-
if (proxyType === 'internet') return 'direct';
|
|
24
|
-
if (proxyType === 'onpremise') return 'bas-tunnel';
|
|
25
|
-
return null;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export function canPromptForCredentials(destination = {}) {
|
|
29
|
-
const mode = credentialModeForDestination(destination);
|
|
30
|
-
return Boolean(mode && (mode !== 'direct' || destination.backendUrl));
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export async function enrichWithStoredCredentials(destinations, env = process.env, log = message => console.error(message)) {
|
|
34
|
-
const needingCredentials = destinations.filter(destination => {
|
|
35
|
-
const mode = credentialModeForDestination(destination);
|
|
36
|
-
if (!mode || !canPromptForCredentials(destination)) return false;
|
|
37
|
-
if (mode === 'cf-connectivity') return Boolean(destination.serverName);
|
|
38
|
-
return Boolean(destination.url && String(destination.url).includes('.dest'));
|
|
39
|
-
});
|
|
40
|
-
if (!needingCredentials.length) return destinations;
|
|
41
|
-
let stored;
|
|
42
|
-
try {
|
|
43
|
-
const path = await resolveCredentialsPath(env);
|
|
44
|
-
stored = await readCredentials(path);
|
|
45
|
-
} catch (error) {
|
|
46
|
-
log(`[sap-ai-dev] stored credentials could not be read: ${String(error.message).slice(0, 200)}`);
|
|
47
|
-
return destinations;
|
|
48
|
-
}
|
|
49
|
-
return destinations.map(destination => {
|
|
50
|
-
const mode = credentialModeForDestination(destination);
|
|
51
|
-
const key = credentialKeyForDestination(destination);
|
|
52
|
-
const entry = key ? stored.destinations?.[key] : undefined;
|
|
53
|
-
if (!mode || !entry?.user || !entry?.password) return destination;
|
|
54
|
-
if (mode === 'cf-connectivity') {
|
|
55
|
-
if (entry.mode !== mode || destination.source !== 'cloud-foundry') return destination;
|
|
56
|
-
return {
|
|
57
|
-
...destination,
|
|
58
|
-
childEnv: {
|
|
59
|
-
...(destination.childEnv || {}),
|
|
60
|
-
SAP_USER: entry.user,
|
|
61
|
-
SAP_PASSWORD: entry.password,
|
|
62
|
-
SAP_VERBOSE: 'false'
|
|
63
|
-
}
|
|
64
|
-
};
|
|
65
|
-
}
|
|
66
|
-
if (mode === 'bas-tunnel') {
|
|
67
|
-
if (entry.mode !== mode) return destination;
|
|
68
|
-
return { ...destination, credentials: { user: entry.user, password: entry.password, mode } };
|
|
69
|
-
}
|
|
70
|
-
if (!destination.backendUrl || !destination.url || !String(destination.url).includes('.dest')) return destination;
|
|
71
|
-
if (entry.mode && entry.mode !== 'direct') return destination;
|
|
72
|
-
const host = entry.host || destination.backendUrl;
|
|
73
|
-
return { ...destination, credentials: { host, user: entry.user, password: entry.password, mode: 'direct' } };
|
|
74
|
-
});
|
|
75
|
-
}
|
|
@@ -1,113 +0,0 @@
|
|
|
1
|
-
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import { chmod, mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
3
|
-
import { homedir } from 'node:os';
|
|
4
|
-
import { basename, dirname, join } from 'node:path';
|
|
5
|
-
import { brandedEnvValue } from './branding.mjs';
|
|
6
|
-
|
|
7
|
-
// Optional SAP backend credential overrides live next to the MCP config
|
|
8
|
-
// (never inside it), with 0600 permissions and no tokens or cookies. Entries
|
|
9
|
-
// are keyed by a stable destination identity and may include a route mode.
|
|
10
|
-
const CREDENTIALS_FILENAME = 'sap-ai-dev-toolkit-credentials.json';
|
|
11
|
-
const CREDENTIAL_MODES = new Set(['direct', 'bas-tunnel', 'cf-connectivity']);
|
|
12
|
-
|
|
13
|
-
async function exists(path) {
|
|
14
|
-
try {
|
|
15
|
-
await stat(path);
|
|
16
|
-
return true;
|
|
17
|
-
} catch (error) {
|
|
18
|
-
if (error?.code === 'ENOENT') return false;
|
|
19
|
-
throw error;
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export async function resolveCredentialsPath(env = process.env, mcpConfigPath) {
|
|
24
|
-
const configured = brandedEnvValue(env, 'CREDENTIALS_FILE');
|
|
25
|
-
if (configured) return configured;
|
|
26
|
-
if (mcpConfigPath) return join(dirname(mcpConfigPath), CREDENTIALS_FILENAME);
|
|
27
|
-
const home = env.HOME || homedir();
|
|
28
|
-
const candidates = [
|
|
29
|
-
join(home, '.vscode', 'data', 'User'),
|
|
30
|
-
join(home, '.vscode-server', 'data', 'User'),
|
|
31
|
-
join(home, '.code-server', 'data', 'User')
|
|
32
|
-
];
|
|
33
|
-
for (const candidate of candidates) if (await exists(candidate)) return join(candidate, CREDENTIALS_FILENAME);
|
|
34
|
-
return join(candidates[0], CREDENTIALS_FILENAME);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function sanitizeEntry(entry) {
|
|
38
|
-
const result = {};
|
|
39
|
-
if (typeof entry?.host === 'string' && entry.host) result.host = entry.host;
|
|
40
|
-
if (typeof entry?.user === 'string' && entry.user) result.user = entry.user;
|
|
41
|
-
if (typeof entry?.password === 'string' && entry.password) result.password = entry.password;
|
|
42
|
-
if (CREDENTIAL_MODES.has(entry?.mode)) result.mode = entry.mode;
|
|
43
|
-
if (typeof entry?.updatedAt === 'string') result.updatedAt = entry.updatedAt;
|
|
44
|
-
return result;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
export async function readCredentials(path) {
|
|
48
|
-
let raw;
|
|
49
|
-
try {
|
|
50
|
-
raw = await readFile(path, 'utf8');
|
|
51
|
-
} catch (error) {
|
|
52
|
-
if (error?.code === 'ENOENT') return {};
|
|
53
|
-
throw new Error(`Credentials file ${path} could not be read: ${error.message}`);
|
|
54
|
-
}
|
|
55
|
-
let parsed;
|
|
56
|
-
try {
|
|
57
|
-
parsed = JSON.parse(raw);
|
|
58
|
-
} catch (error) {
|
|
59
|
-
throw new Error(`Credentials file ${path} contains invalid JSON: ${error.message}`);
|
|
60
|
-
}
|
|
61
|
-
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
62
|
-
throw new Error(`Credentials file ${path} must contain a top-level JSON object`);
|
|
63
|
-
}
|
|
64
|
-
const destinations = {};
|
|
65
|
-
for (const [name, entry] of Object.entries(parsed.destinations || {})) {
|
|
66
|
-
const clean = sanitizeEntry(entry);
|
|
67
|
-
if (clean.user && clean.password) destinations[name] = clean;
|
|
68
|
-
}
|
|
69
|
-
return { destinations };
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
async function writeCredentials(path, destinations) {
|
|
73
|
-
const directory = dirname(path);
|
|
74
|
-
await mkdir(directory, { recursive: true });
|
|
75
|
-
const temporary = join(directory, `.${basename(path)}.${process.pid}.${randomUUID()}.tmp`);
|
|
76
|
-
try {
|
|
77
|
-
await writeFile(temporary, `${JSON.stringify({ version: 1, destinations }, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
78
|
-
await rename(temporary, path);
|
|
79
|
-
await chmod(path, 0o600).catch(() => {});
|
|
80
|
-
} catch (error) {
|
|
81
|
-
await rm(temporary, { force: true }).catch(() => {});
|
|
82
|
-
throw new Error(`Credentials file ${path} could not be written: ${error.message}`);
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
export async function storeDestinationCredentials(path, name, { host, user, password, mode }) {
|
|
87
|
-
if (mode != null && !CREDENTIAL_MODES.has(mode)) throw new Error('Credential override route mode is unsupported');
|
|
88
|
-
const current = await readCredentials(path).catch(error => { throw error; });
|
|
89
|
-
const destinations = { ...current.destinations };
|
|
90
|
-
destinations[String(name)] = {
|
|
91
|
-
host: String(host || ''),
|
|
92
|
-
user: String(user || ''),
|
|
93
|
-
password: String(password || ''),
|
|
94
|
-
...(mode ? { mode } : {}),
|
|
95
|
-
updatedAt: new Date().toISOString()
|
|
96
|
-
};
|
|
97
|
-
await writeCredentials(path, destinations);
|
|
98
|
-
return { path, name };
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
export async function removeDestinationCredentials(path, names) {
|
|
102
|
-
const current = await readCredentials(path);
|
|
103
|
-
const destinations = { ...current.destinations };
|
|
104
|
-
const removed = [];
|
|
105
|
-
for (const name of names) {
|
|
106
|
-
if (Object.hasOwn(destinations, String(name))) {
|
|
107
|
-
delete destinations[String(name)];
|
|
108
|
-
removed.push(String(name));
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
if (removed.length) await writeCredentials(path, destinations);
|
|
112
|
-
return { path, removed };
|
|
113
|
-
}
|