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 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. BAS Destination and Connectivity service credentials remain in their respective services; optional SAP backend overrides are stored in the owner-only credentials file beside `mcp.json`, never in `mcp.json`. The BAS OnPremise override is saved only when its tunnel route returns a CSRF token and session cookie.
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 except for OnPremise credential overrides, which require a BAS proxy tunnel. |
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. **Direct connect for Internet destinations** — the BAS `.dest` proxy strips SAP `Set-Cookie` headers, which makes CSRF token/session binding impossible for ADT writes (`403 CSRF token validation failed`). When you select an Internet destination with BasicAuthentication, setup asks whether to override its credentials. A Yes prompts for SAP user/password and stores them in `sap-ai-dev-toolkit-credentials.json` **next to** `mcp.json` (never inside it, permissions `0600`). The relay connects straight to the backend host so cookies survive and ADT writes can use the paired CSRF session.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sap-ai-dev-toolkit",
3
- "version": "0.5.7",
3
+ "version": "0.6.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",
@@ -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 credentials = destination.credentials;
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
- if (directBase) log(`[${destination.name}] BAS relay direct connect active (${directBase.origin}); BAS proxy cookie stripping bypassed`);
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 = directBase ? '' : (explicitProxy !== undefined ? String(explicitProxy) : (/\.dest$/i.test(base.hostname) ? DEFAULT_PROXY : ''));
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 = tunnelMode;
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: Boolean(directBase), proxyTunnel: tunnelMode };
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 = resolveTarget(new URL(CSRF_FETCH_PATH, base));
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', authHeaders(fetchHeaders(requestHeaders, {
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 = resolveTarget(requestUrl);
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 = authHeaders(headersFrom(requestHeaders, {
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 delivers no cookies; a credential override enables a paired session)`);
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. For OnPremise BasicAuth, run sap-ai-dev --setup and use a validated BAS proxy tunnel credential override.';
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
- ? await enrichWithStoredCredentials([await resolveConfiguredCloudFoundryDestination({ env: runtimeEnv })], runtimeEnv)
226
- : await enrichWithStoredCredentials(await discoverForCommand(), runtimeEnv);
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 = (await enrichWithStoredCredentials([await resolveConfiguredCloudFoundryDestination({ env: runtimeEnv })], runtimeEnv))[0];
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 = await enrichWithStoredCredentials(discovered, runtimeEnv);
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, textPrompt } from './terminal-ui.mjs';
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, ...credentialWarnings, ...cleanupWarnings] };
288
+ return { selected, ...(result || {}), warnings: [...warnings, ...cleanupWarnings] };
397
289
  } catch (error) {
398
290
  const cleanupWarnings = [];
399
291
  if (createdKeys.length) {
@@ -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
- }