sap-ai-dev-toolkit 0.3.1 → 0.3.4

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.
@@ -0,0 +1,282 @@
1
+ import http from 'node:http';
2
+ import { fetch as undiciFetch, ProxyAgent } from 'undici';
3
+
4
+ const DEFAULT_PROXY = 'http://127.0.0.1:8887';
5
+ const MAX_BODY_BYTES = 64 * 1024 * 1024;
6
+ const HOP_BY_HOP = new Set([
7
+ 'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization',
8
+ 'proxy-connection', 'te', 'trailer', 'transfer-encoding', 'upgrade', 'host'
9
+ ]);
10
+ const UNSAFE = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
11
+ // SAP ties CSRF tokens to the session that fetched them. The BAS .dest proxy
12
+ // authenticates each hop, so the relay pairs every token with the exact
13
+ // Set-Cookie state SAP returned alongside it and replays both on the unsafe
14
+ // request. Without this pairing SAP sees a token from a foreign session and
15
+ // answers "CSRF token validation failed" with HTTP 403.
16
+ const TOKEN_TTL_MS = 15 * 60 * 1000;
17
+ const MAX_TOKEN_FAILURES = 3;
18
+ const MAX_TUNNEL_FALLBACKS = 2;
19
+ // Carrying a POST's content-length on the token-fetch GET would make the
20
+ // proxy wait for a body that never arrives, so the relay drops body headers.
21
+ const BODY_HEADERS = new Set(['content-length', 'content-type']);
22
+ // The forwarded request's content-length must describe the body undici is
23
+ // about to send. Forwarding the inbound value alongside a re-buffered body
24
+ // makes undici reject the request (UND_ERR_INVALID_ARG), so it is always
25
+ // recomputed from the actual payload.
26
+ const LENGTH_HEADERS = new Set(['content-length', 'transfer-encoding']);
27
+ // undici transparently decompresses gzip/deflate/br responses but keeps the
28
+ // content-encoding header, so forwarding the client's accept-encoding would
29
+ // hand VSP a "gzip" body that is actually plain bytes. Dropping the header
30
+ // lets undici request identity encoding and keeps bodies consistent.
31
+ const ENCODING_HEADERS = new Set(['accept-encoding', 'content-encoding']);
32
+
33
+ function headersFrom(requestHeaders, extra = {}) {
34
+ const headers = {};
35
+ for (const [name, value] of Object.entries(requestHeaders || {})) {
36
+ const lower = name.toLowerCase();
37
+ if (HOP_BY_HOP.has(lower) || LENGTH_HEADERS.has(lower) || ENCODING_HEADERS.has(lower)) continue;
38
+ if (value !== undefined) headers[name] = Array.isArray(value) ? value.join(', ') : String(value);
39
+ }
40
+ return { ...headers, ...extra };
41
+ }
42
+
43
+ function fetchHeaders(requestHeaders, extra = {}) {
44
+ const headers = {};
45
+ for (const [name, value] of Object.entries(requestHeaders || {})) {
46
+ const lower = name.toLowerCase();
47
+ if (HOP_BY_HOP.has(lower) || BODY_HEADERS.has(lower) || ENCODING_HEADERS.has(lower)) continue;
48
+ if (value !== undefined) headers[name] = Array.isArray(value) ? value.join(', ') : String(value);
49
+ }
50
+ return { ...headers, ...extra };
51
+ }
52
+
53
+ function tunnelFailure(status) {
54
+ // 502/504 from the proxy itself means the absolute-form .dest request is
55
+ // being refused; worth retrying through a CONNECT tunnel.
56
+ return status === 502 || status === 504;
57
+ }
58
+
59
+ function csrfFailure(status, headers, body) {
60
+ return status === 403 && (
61
+ String(headers.get?.('x-csrf-token') || '').toLowerCase() === 'required' ||
62
+ String(body || '').toLowerCase().includes('csrf token validation failed')
63
+ );
64
+ }
65
+
66
+ function authFailure(status) {
67
+ return status === 401 || status === 403;
68
+ }
69
+
70
+ function cookieHeader(jar) {
71
+ return jar.size ? [...jar].map(([name, value]) => `${name}=${value}`).join('; ') : undefined;
72
+ }
73
+
74
+ function recordCookies(jar, headers) {
75
+ for (const value of headers.getSetCookie?.() || []) {
76
+ const [pair] = String(value).split(';');
77
+ const separator = pair.indexOf('=');
78
+ if (separator <= 0) continue;
79
+ const name = pair.slice(0, separator).trim();
80
+ if (!name) continue;
81
+ if (/expires=thu, 01 jan 1970/i.test(String(value))) jar.delete(name);
82
+ else jar.set(name, pair.slice(separator + 1).trim());
83
+ }
84
+ }
85
+
86
+ async function readRequestBody(req) {
87
+ const chunks = [];
88
+ let size = 0;
89
+ for await (const chunk of req) {
90
+ size += chunk.length;
91
+ if (size > MAX_BODY_BYTES) throw new Error('request body is too large for BAS destination relay');
92
+ chunks.push(chunk);
93
+ }
94
+ return Buffer.concat(chunks);
95
+ }
96
+
97
+ export function createBasDestinationRelay(destination, { env = process.env, fetchImpl = undiciFetch, log = () => {} } = {}) {
98
+ if (!destination?.url) throw new Error('Destination relay requires a BAS destination URL');
99
+ const base = new URL(destination.url);
100
+ const credentials = destination.credentials;
101
+ const tunnelMode = credentials?.mode === 'bas-tunnel';
102
+ const directHost = tunnelMode ? '' : credentials?.host;
103
+ // Internet overrides retain the direct-backend path. OnPremise overrides
104
+ // must keep the BAS .dest route (and its Cloud Connector mapping), so they
105
+ // use HTTP CONNECT through the BAS proxy instead of credentials.host.
106
+ const directBase = directHost ? new URL(String(directHost)) : null;
107
+ if (directBase) log(`[${destination.name}] BAS relay direct connect active (${directBase.origin}); BAS proxy cookie stripping bypassed`);
108
+ if (tunnelMode) log(`[${destination.name}] BAS relay using a BAS proxy tunnel for the OnPremise destination`);
109
+ // Proxy resolution: an explicitly configured proxy wins (an explicit empty
110
+ // value means "go direct"). Without configuration, BAS virtual .dest hosts
111
+ // imply the default BAS proxy; any other host (tests, direct URLs) is
112
+ // reached directly so the relay never routes loopback traffic through a
113
+ // foreign proxy.
114
+ const explicitProxy = env.HTTP_PROXY ?? env.http_proxy;
115
+ const proxyUrl = directBase ? '' : (explicitProxy !== undefined ? String(explicitProxy) : (/\.dest$/i.test(base.hostname) ? DEFAULT_PROXY : ''));
116
+ if (tunnelMode && !proxyUrl) throw new Error('OnPremise credential overrides require the BAS destination proxy to preserve the Cloud Connector route');
117
+ const dispatcher = proxyUrl ? new ProxyAgent({ uri: proxyUrl, proxyTunnel: false }) : undefined;
118
+ // Self-healing mode 2: if the BAS proxy refuses absolute-form proxied
119
+ // requests (its own 502/504), retry through a CONNECT tunnel so the relay
120
+ // keeps working through the same egress.
121
+ const tunnelDispatcher = proxyUrl ? new ProxyAgent({ uri: proxyUrl, proxyTunnel: true }) : undefined;
122
+ let tunneled = tunnelMode;
123
+ // Observable self-healing counters, surfaced by --doctor and tests.
124
+ const stats = { requests: 0, csrfFetches: 0, csrfRetries: 0, tunnelFallbacks: 0, direct: Boolean(directBase), proxyTunnel: tunnelMode };
125
+ // Per-path CSRF sessions: { token, jar, fetchedAt } where jar holds the
126
+ // Set-Cookie state SAP returned together with the token.
127
+ const tokenCache = new Map();
128
+
129
+ function rememberSession(key, token, headers, httpStatus) {
130
+ const jar = new Map();
131
+ recordCookies(jar, headers);
132
+ tokenCache.set(key, { token, jar, fetchedAt: Date.now(), httpStatus });
133
+ }
134
+
135
+ async function send(target, method, headers, body) {
136
+ const useTunnel = tunneled && tunnelDispatcher;
137
+ return fetchImpl(target, {
138
+ method,
139
+ headers,
140
+ body: body?.length ? body : undefined,
141
+ ...(useTunnel ? { dispatcher: tunnelDispatcher } : dispatcher ? { dispatcher } : {})
142
+ });
143
+ }
144
+
145
+ // Try absolute-form first; on transport errors or proxy-level failures,
146
+ // flip to tunneled mode once and retry (sticky for later requests).
147
+ async function sendWithFallback(target, method, headers, body) {
148
+ if (tunneled) return send(target, method, headers, body);
149
+ try {
150
+ const response = await send(target, method, headers, body);
151
+ if (!tunnelFailure(response.status)) return response;
152
+ log(`[${destination.name}] BAS relay proxy returned HTTP ${response.status} for ${target.pathname}; switching to proxy tunnel`);
153
+ } catch (error) {
154
+ log(`[${destination.name}] BAS relay absolute-form request failed for ${target.pathname} (${error.message}); switching to proxy tunnel`);
155
+ }
156
+ tunneled = true;
157
+ stats.tunnelFallbacks += 1;
158
+ return send(target, method, headers, body);
159
+ }
160
+
161
+ async function fetchToken(requestUrl, requestHeaders) {
162
+ const key = requestUrl.pathname;
163
+ const cached = tokenCache.get(key);
164
+ if (cached && Date.now() - cached.fetchedAt < TOKEN_TTL_MS) return cached;
165
+ stats.csrfFetches += 1;
166
+ const response = await sendWithFallback(resolveTarget(requestUrl), 'GET', authHeaders(fetchHeaders(requestHeaders, {
167
+ 'x-csrf-token': 'Fetch',
168
+ accept: requestHeaders.accept || 'application/xml,*/*',
169
+ ...(cached?.jar?.size ? { cookie: cookieHeader(cached.jar) } : {})
170
+ })));
171
+ await response.arrayBuffer().catch(() => undefined);
172
+ const token = response.headers.get('x-csrf-token');
173
+ if (token && token.toLowerCase() !== 'required') {
174
+ rememberSession(key, token, response.headers, response.status);
175
+ return tokenCache.get(key);
176
+ }
177
+ return null;
178
+ }
179
+
180
+ async function probeCsrfSession(path = '/sap/bc/adt/discovery') {
181
+ const requestUrl = new URL(path, base);
182
+ const session = await fetchToken(requestUrl, { accept: 'application/xml,text/xml,*/*' });
183
+ return {
184
+ httpStatus: session?.httpStatus || 0,
185
+ tokenReceived: Boolean(session?.token),
186
+ cookieCount: session?.jar?.size || 0
187
+ };
188
+ }
189
+
190
+ // Rewrite an incoming .dest target onto the direct backend host when
191
+ // credentials are configured, keeping path and query intact.
192
+ function resolveTarget(target) {
193
+ if (!directBase) return target;
194
+ return new URL(`${target.pathname}${target.search}`, directBase);
195
+ }
196
+
197
+ function authHeaders(extra = {}) {
198
+ if (!credentials?.user || !credentials?.password) return extra;
199
+ const basic = Buffer.from(`${credentials.user}:${credentials.password}`).toString('base64');
200
+ return { authorization: `Basic ${basic}`, ...extra };
201
+ }
202
+
203
+ async function forward(method, requestUrl, requestHeaders, body, session) {
204
+ const headers = authHeaders(headersFrom(requestHeaders, {
205
+ ...(session?.token ? { 'x-csrf-token': session.token } : {}),
206
+ ...(session?.jar?.size ? { cookie: cookieHeader(session.jar) } : {})
207
+ }));
208
+ const response = await sendWithFallback(resolveTarget(requestUrl), method, headers, body);
209
+ const responseBody = Buffer.from(await response.arrayBuffer());
210
+ if (session) recordCookies(session.jar, response.headers);
211
+ return { response, body: responseBody };
212
+ }
213
+
214
+ // Self-healing mode 1: when SAP rejects a token (expired, rotated, or
215
+ // invalidated server-side), drop the cached session and retry with a
216
+ // freshly fetched token paired with its own cookies. Bounded retries keep
217
+ // a failing destination from looping forever.
218
+ async function forwardWithRetry(method, requestUrl, requestHeaders, body, failures = 0) {
219
+ const unsafe = UNSAFE.has(method);
220
+ const session = unsafe ? await fetchToken(requestUrl, requestHeaders) : null;
221
+ if (unsafe && !session) log(`[${destination.name}] BAS relay could not obtain CSRF token for ${requestUrl.pathname}`);
222
+ const { response, body: responseBody } = await forward(method, requestUrl, requestHeaders, body, session);
223
+ const rejected = csrfFailure(response.status, response.headers, responseBody) || (authFailure(response.status) && session);
224
+ if (unsafe && rejected && failures < MAX_TOKEN_FAILURES) {
225
+ log(`[${destination.name}] BAS relay CSRF session rejected for ${requestUrl.pathname}; re-establishing token and session (attempt ${failures + 1}/${MAX_TOKEN_FAILURES})`);
226
+ stats.csrfRetries += 1;
227
+ tokenCache.delete(requestUrl.pathname);
228
+ return forwardWithRetry(method, requestUrl, requestHeaders, body, failures + 1);
229
+ }
230
+ return { response, body: responseBody };
231
+ }
232
+
233
+ const server = http.createServer((req, res) => {
234
+ void (async () => {
235
+ const method = String(req.method || 'GET').toUpperCase();
236
+ const requestUrl = new URL(req.url || '/', base);
237
+ stats.requests += 1;
238
+ const body = UNSAFE.has(method) ? await readRequestBody(req) : Buffer.alloc(0);
239
+ const { response, body: responseBody } = await forwardWithRetry(method, requestUrl, req.headers, body);
240
+ const responseHeaders = {};
241
+ for (const [name, value] of response.headers) {
242
+ const lower = name.toLowerCase();
243
+ // undici decompresses response bodies; the stale content-encoding and
244
+ // content-length would misdescribe the plain bytes handed to VSP.
245
+ if (HOP_BY_HOP.has(lower) || lower === 'content-encoding' || lower === 'content-length') continue;
246
+ responseHeaders[name] = value;
247
+ }
248
+ responseHeaders['x-sap-ai-dev-toolkit-relay'] = 'bas-destination';
249
+ res.writeHead(response.status, response.statusText, responseHeaders);
250
+ res.end(responseBody);
251
+ })().catch(error => {
252
+ if (!res.headersSent) res.writeHead(502, { 'content-type': 'text/plain; charset=utf-8', 'connection': 'close' });
253
+ res.end(`BAS destination relay failed: ${error.message}\n`);
254
+ });
255
+ });
256
+
257
+ // Bind eagerly and report the real port through `ready`. Callers must
258
+ // await `ready` before handing the URL to a VSP child; binding failures
259
+ // surface as a rejected promise instead of a silently wrong guessed port.
260
+ const listening = new Promise((resolve, reject) => {
261
+ server.once('error', reject);
262
+ server.listen(0, '127.0.0.1', () => {
263
+ server.removeListener('error', reject);
264
+ resolve(server.address().port);
265
+ });
266
+ });
267
+ const ready = listening.then(port => `http://127.0.0.1:${port}`).catch(error => {
268
+ log(`[${destination.name}] BAS relay failed to start: ${error.message}`);
269
+ throw error;
270
+ });
271
+ return {
272
+ ready,
273
+ stats,
274
+ probeCsrfSession,
275
+ close: async () => {
276
+ await dispatcher?.close?.();
277
+ await tunnelDispatcher?.close?.();
278
+ server.closeAllConnections?.();
279
+ await new Promise(resolve => server.close(() => resolve()));
280
+ }
281
+ };
282
+ }
@@ -52,11 +52,15 @@ export function normalizeDestination(item) {
52
52
  const name = text(values.name || values.destinationname || values.destname);
53
53
  const authentication = text(values.authentication || values.authtype || values.auth);
54
54
  const client = text(values.sapclient || values.client) || '001';
55
+ const backendUrl = text(values.url || values.host || values.webideexposedhost);
56
+ const proxyType = text(values.proxytype || values.proxy);
55
57
  return {
56
58
  name,
57
59
  authentication: authentication || 'Unknown',
58
60
  client,
59
61
  url: name ? destinationUrl(name) : null,
62
+ backendUrl: backendUrl || null,
63
+ proxyType: proxyType || null,
60
64
  rawKeys: Object.keys(item || {}).map(String)
61
65
  };
62
66
  }
@@ -0,0 +1,75 @@
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
+ }
@@ -0,0 +1,123 @@
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
+ const SECRET_PATTERN = /(authorization|cookie|password|secret|token|bearer|credential)/i;
14
+
15
+ async function exists(path) {
16
+ try {
17
+ await stat(path);
18
+ return true;
19
+ } catch (error) {
20
+ if (error?.code === 'ENOENT') return false;
21
+ throw error;
22
+ }
23
+ }
24
+
25
+ export function redactCredentialValue(value) {
26
+ return SECRET_PATTERN.test(String(value)) ? '[redacted]' : value;
27
+ }
28
+
29
+ export async function resolveCredentialsPath(env = process.env, mcpConfigPath) {
30
+ const configured = brandedEnvValue(env, 'CREDENTIALS_FILE');
31
+ if (configured) return configured;
32
+ if (mcpConfigPath) return join(dirname(mcpConfigPath), CREDENTIALS_FILENAME);
33
+ const home = env.HOME || homedir();
34
+ const candidates = [
35
+ join(home, '.vscode', 'data', 'User'),
36
+ join(home, '.vscode-server', 'data', 'User'),
37
+ join(home, '.code-server', 'data', 'User')
38
+ ];
39
+ for (const candidate of candidates) if (await exists(candidate)) return join(candidate, CREDENTIALS_FILENAME);
40
+ return join(candidates[0], CREDENTIALS_FILENAME);
41
+ }
42
+
43
+ function sanitizeEntry(entry) {
44
+ const result = {};
45
+ if (typeof entry?.host === 'string' && entry.host) result.host = entry.host;
46
+ if (typeof entry?.user === 'string' && entry.user) result.user = entry.user;
47
+ if (typeof entry?.password === 'string' && entry.password) result.password = entry.password;
48
+ if (CREDENTIAL_MODES.has(entry?.mode)) result.mode = entry.mode;
49
+ if (typeof entry?.updatedAt === 'string') result.updatedAt = entry.updatedAt;
50
+ return result;
51
+ }
52
+
53
+ export async function readCredentials(path) {
54
+ let raw;
55
+ try {
56
+ raw = await readFile(path, 'utf8');
57
+ } catch (error) {
58
+ if (error?.code === 'ENOENT') return {};
59
+ throw new Error(`Credentials file ${path} could not be read: ${error.message}`);
60
+ }
61
+ let parsed;
62
+ try {
63
+ parsed = JSON.parse(raw);
64
+ } catch (error) {
65
+ throw new Error(`Credentials file ${path} contains invalid JSON: ${error.message}`);
66
+ }
67
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
68
+ throw new Error(`Credentials file ${path} must contain a top-level JSON object`);
69
+ }
70
+ const destinations = {};
71
+ for (const [name, entry] of Object.entries(parsed.destinations || {})) {
72
+ const clean = sanitizeEntry(entry);
73
+ if (clean.user && clean.password) destinations[name] = clean;
74
+ }
75
+ return { destinations };
76
+ }
77
+
78
+ async function writeCredentials(path, destinations) {
79
+ const directory = dirname(path);
80
+ await mkdir(directory, { recursive: true });
81
+ const temporary = join(directory, `.${basename(path)}.${process.pid}.${randomUUID()}.tmp`);
82
+ try {
83
+ await writeFile(temporary, `${JSON.stringify({ version: 1, destinations }, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
84
+ await rename(temporary, path);
85
+ await chmod(path, 0o600).catch(() => {});
86
+ } catch (error) {
87
+ await rm(temporary, { force: true }).catch(() => {});
88
+ throw new Error(`Credentials file ${path} could not be written: ${error.message}`);
89
+ }
90
+ }
91
+
92
+ export async function storeDestinationCredentials(path, name, { host, user, password, mode }) {
93
+ if (mode != null && !CREDENTIAL_MODES.has(mode)) throw new Error('Credential override route mode is unsupported');
94
+ const current = await readCredentials(path).catch(error => { throw error; });
95
+ const destinations = { ...current.destinations };
96
+ destinations[String(name)] = {
97
+ host: String(host || ''),
98
+ user: String(user || ''),
99
+ password: String(password || ''),
100
+ ...(mode ? { mode } : {}),
101
+ updatedAt: new Date().toISOString()
102
+ };
103
+ await writeCredentials(path, destinations);
104
+ return { path, name };
105
+ }
106
+
107
+ export async function removeDestinationCredentials(path, names) {
108
+ const current = await readCredentials(path);
109
+ const destinations = { ...current.destinations };
110
+ const removed = [];
111
+ for (const name of names) {
112
+ if (Object.hasOwn(destinations, String(name))) {
113
+ delete destinations[String(name)];
114
+ removed.push(String(name));
115
+ }
116
+ }
117
+ if (removed.length) await writeCredentials(path, destinations);
118
+ return { path, removed };
119
+ }
120
+
121
+ export function listCredentialDestinations(credentials) {
122
+ return Object.entries(credentials?.destinations || {}).map(([name, entry]) => ({ name, host: entry.host, user: entry.user, updatedAt: entry.updatedAt }));
123
+ }
package/src/launcher.mjs CHANGED
@@ -10,6 +10,7 @@ import { installMcpConfig } 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';
13
14
  const root = dirname(dirname(fileURLToPath(import.meta.url)));
14
15
  const pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'));
15
16
  const runtimeEnv = withBrandedEnvironment(process.env);
@@ -75,6 +76,21 @@ async function runDoctor(destinations) {
75
76
  const initialized = await proxy.handle({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05' } });
76
77
  if (initialized?.error) throw new Error(initialized.error.message || 'MCP initialization failed');
77
78
  checks.push(doctorRow(destination.name, 'VSP MCP startup', 'passed', 'initialized'));
79
+ const relayedDestination = proxy.children[0]?.destination;
80
+ if (relayedDestination?.relay) {
81
+ checks.push(doctorRow(destination.name, 'BAS destination relay', 'passed', `enabled (${relayedDestination.url}); CSRF token/session pairing active`));
82
+ } else if (destination.source === 'cloud-foundry') {
83
+ checks.push(doctorRow(destination.name, 'BAS destination relay', 'skipped', 'Cloud Foundry destinations connect through the connectivity proxy directly.'));
84
+ } else if (String(runtimeEnv.SAP_AI_DEV_TOOLKIT_DISABLE_BAS_RELAY || '').toLowerCase() === 'true') {
85
+ checks.push(doctorRow(destination.name, 'BAS destination relay', 'skipped', 'Disabled through SAP_AI_DEV_TOOLKIT_DISABLE_BAS_RELAY.'));
86
+ } else {
87
+ checks.push(doctorRow(destination.name, 'BAS destination relay', 'skipped', 'Not applicable for this destination.'));
88
+ }
89
+ if (relayedDestination?.credentials?.mode === 'bas-tunnel') {
90
+ checks.push(doctorRow(destination.name, 'OnPremise credential override', 'passed', 'Basic auth is routed through a BAS proxy tunnel; Cloud Connector routing is retained'));
91
+ } else if (relayedDestination?.credentials?.host) {
92
+ checks.push(doctorRow(destination.name, 'Direct connect', 'passed', `active (${new URL(relayedDestination.credentials.host).origin}); ADT writes use stored credentials`));
93
+ }
78
94
  const listed = await proxy.handle({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} });
79
95
  if (listed?.error) throw new Error(listed.error.message || 'MCP tools/list failed');
80
96
  const tools = listed.result?.tools || [];
@@ -164,8 +180,8 @@ async function main() {
164
180
  let report;
165
181
  try {
166
182
  const destinations = runtimeEnv.SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE === 'cloud-foundry'
167
- ? [await resolveConfiguredCloudFoundryDestination({ env: runtimeEnv })]
168
- : await discoverForCommand();
183
+ ? await enrichWithStoredCredentials([await resolveConfiguredCloudFoundryDestination({ env: runtimeEnv })], runtimeEnv)
184
+ : await enrichWithStoredCredentials(await discoverForCommand(), runtimeEnv);
169
185
  report = await runDoctor(destinations);
170
186
  } catch (error) {
171
187
  report = { ok: false, destinations: 0, checks: [doctorRow('-', 'destination discovery', 'failed', redactText(error.message || error).slice(0, 300))] };
@@ -201,7 +217,7 @@ async function main() {
201
217
  }
202
218
 
203
219
  if (runtimeEnv.SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE === 'cloud-foundry') {
204
- const destination = await resolveConfiguredCloudFoundryDestination({ env: runtimeEnv });
220
+ const destination = (await enrichWithStoredCredentials([await resolveConfiguredCloudFoundryDestination({ env: runtimeEnv })], runtimeEnv))[0];
205
221
  let proxy;
206
222
  try {
207
223
  const binary = await binaryOrError();
@@ -231,12 +247,16 @@ async function main() {
231
247
 
232
248
  const discovered = await discoverDestinations({ env: runtimeEnv });
233
249
  for (const destination of discovered) console.error(probeDiagnostic(destination));
234
- const destinations = discovered;
250
+ const destinations = await enrichWithStoredCredentials(discovered, runtimeEnv);
235
251
  if (!destinations.length) {
236
252
  console.error('[sap-ai-dev] destination discovery returned no named BAS destinations');
237
253
  throw new Error(remediation);
238
254
  }
239
255
  const binary = await binaryOrError();
256
+ for (const destination of destinations) {
257
+ if (destination.credentials?.mode === 'bas-tunnel') console.error(`[sap-ai-dev] ${destination.name}: Basic credentials enabled through the BAS proxy tunnel`);
258
+ else if (destination.credentials?.host) console.error(`[sap-ai-dev] ${destination.name}: direct connect enabled through stored credentials`);
259
+ }
240
260
  console.error(`[sap-ai-dev] starting MCP proxy for ${destinations.map(destination => `${destination.name} (client=${destination.client})`).join(', ')}`);
241
261
  const proxy = new MCPProxy({ binary, destinations, env: process.env, log: message => console.error(message) });
242
262
  const shutdown = signal => { void proxy.close().finally(() => process.exit(signal === 'SIGINT' ? 130 : 143)); };