sap-ai-dev-toolkit 0.3.0 → 0.3.3
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/.github/agents/abap-developer.agent.md +9 -7
- package/.github/agents/rap-service-developer.agent.md +10 -8
- package/.github/agents/sap-solution-architect.agent.md +2 -2
- package/.github/skills/abap-development/SKILL.md +6 -4
- package/.github/skills/rap-development/SKILL.md +6 -4
- package/.github/skills/rap-service-delivery/SKILL.md +7 -5
- package/.github/skills/sap-sdlc-orchestration/SKILL.md +5 -4
- package/README.md +19 -5
- package/package.json +2 -1
- package/scripts/install-local.mjs +115 -0
- package/src/bas-destination-relay.mjs +271 -0
- package/src/bas-discovery.mjs +4 -0
- package/src/credentials-store.mjs +114 -0
- package/src/launcher.mjs +44 -2
- package/src/mcp-proxy.mjs +120 -32
- package/src/setup.mjs +90 -2
- package/src/terminal-ui.mjs +84 -0
- package/test/bas-destination-relay.test.mjs +188 -0
- package/test/copilot-content.test.mjs +41 -0
- package/test/credentials-store.test.mjs +156 -0
- package/test/discovery.test.mjs +1 -1
- package/test/mcp-proxy.test.mjs +59 -3
|
@@ -0,0 +1,271 @@
|
|
|
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
|
+
// Self-healing mode 5 (direct connect): the BAS .dest proxy strips
|
|
101
|
+
// Set-Cookie, which makes SAP CSRF session binding impossible. When the
|
|
102
|
+
// operator stored credentials for this destination (setup prompts for
|
|
103
|
+
// Internet destinations), the relay connects straight to the backend host
|
|
104
|
+
// with Basic auth — cookies survive, CSRF pairing works, and writes go
|
|
105
|
+
// through exactly like they do from Eclipse/ADT.
|
|
106
|
+
const credentials = destination.credentials;
|
|
107
|
+
const directBase = credentials?.host ? new URL(String(credentials.host)) : null;
|
|
108
|
+
const effectiveBase = directBase || base;
|
|
109
|
+
if (directBase) log(`[${destination.name}] BAS relay direct connect active (${directBase.origin}); BAS proxy cookie stripping bypassed`);
|
|
110
|
+
// Proxy resolution: an explicitly configured proxy wins (an explicit empty
|
|
111
|
+
// value means "go direct"). Without configuration, BAS virtual .dest hosts
|
|
112
|
+
// imply the default BAS proxy; any other host (tests, direct URLs) is
|
|
113
|
+
// reached directly so the relay never routes loopback traffic through a
|
|
114
|
+
// foreign proxy.
|
|
115
|
+
const explicitProxy = env.HTTP_PROXY ?? env.http_proxy;
|
|
116
|
+
const proxyUrl = directBase ? '' : (explicitProxy !== undefined ? String(explicitProxy) : (/\.dest$/i.test(base.hostname) ? DEFAULT_PROXY : ''));
|
|
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 = false;
|
|
123
|
+
// Observable self-healing counters, surfaced by --doctor and tests.
|
|
124
|
+
const stats = { requests: 0, csrfFetches: 0, csrfRetries: 0, tunnelFallbacks: 0, direct: Boolean(directBase) };
|
|
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) {
|
|
130
|
+
const jar = new Map();
|
|
131
|
+
recordCookies(jar, headers);
|
|
132
|
+
tokenCache.set(key, { token, jar, fetchedAt: Date.now() });
|
|
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);
|
|
175
|
+
return tokenCache.get(key);
|
|
176
|
+
}
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Rewrite an incoming .dest target onto the direct backend host when
|
|
181
|
+
// credentials are configured, keeping path and query intact.
|
|
182
|
+
function resolveTarget(target) {
|
|
183
|
+
if (!directBase) return target;
|
|
184
|
+
return new URL(`${target.pathname}${target.search}`, directBase);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function authHeaders(extra = {}) {
|
|
188
|
+
if (!credentials?.user || !credentials?.password) return extra;
|
|
189
|
+
const basic = Buffer.from(`${credentials.user}:${credentials.password}`).toString('base64');
|
|
190
|
+
return { authorization: `Basic ${basic}`, ...extra };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function forward(method, requestUrl, requestHeaders, body, session) {
|
|
194
|
+
const headers = authHeaders(headersFrom(requestHeaders, {
|
|
195
|
+
...(session?.token ? { 'x-csrf-token': session.token } : {}),
|
|
196
|
+
...(session?.jar?.size ? { cookie: cookieHeader(session.jar) } : {})
|
|
197
|
+
}));
|
|
198
|
+
const response = await sendWithFallback(resolveTarget(requestUrl), method, headers, body);
|
|
199
|
+
const responseBody = Buffer.from(await response.arrayBuffer());
|
|
200
|
+
if (session) recordCookies(session.jar, response.headers);
|
|
201
|
+
return { response, body: responseBody };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Self-healing mode 1: when SAP rejects a token (expired, rotated, or
|
|
205
|
+
// invalidated server-side), drop the cached session and retry with a
|
|
206
|
+
// freshly fetched token paired with its own cookies. Bounded retries keep
|
|
207
|
+
// a failing destination from looping forever.
|
|
208
|
+
async function forwardWithRetry(method, requestUrl, requestHeaders, body, failures = 0) {
|
|
209
|
+
const unsafe = UNSAFE.has(method);
|
|
210
|
+
const session = unsafe ? await fetchToken(requestUrl, requestHeaders) : null;
|
|
211
|
+
if (unsafe && !session) log(`[${destination.name}] BAS relay could not obtain CSRF token for ${requestUrl.pathname}`);
|
|
212
|
+
const { response, body: responseBody } = await forward(method, requestUrl, requestHeaders, body, session);
|
|
213
|
+
const rejected = csrfFailure(response.status, response.headers, responseBody) || (authFailure(response.status) && session);
|
|
214
|
+
if (unsafe && rejected && failures < MAX_TOKEN_FAILURES) {
|
|
215
|
+
log(`[${destination.name}] BAS relay CSRF session rejected for ${requestUrl.pathname}; re-establishing token and session (attempt ${failures + 1}/${MAX_TOKEN_FAILURES})`);
|
|
216
|
+
stats.csrfRetries += 1;
|
|
217
|
+
tokenCache.delete(requestUrl.pathname);
|
|
218
|
+
return forwardWithRetry(method, requestUrl, requestHeaders, body, failures + 1);
|
|
219
|
+
}
|
|
220
|
+
return { response, body: responseBody };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const server = http.createServer((req, res) => {
|
|
224
|
+
void (async () => {
|
|
225
|
+
const method = String(req.method || 'GET').toUpperCase();
|
|
226
|
+
const requestUrl = new URL(req.url || '/', base);
|
|
227
|
+
stats.requests += 1;
|
|
228
|
+
const body = UNSAFE.has(method) ? await readRequestBody(req) : Buffer.alloc(0);
|
|
229
|
+
const { response, body: responseBody } = await forwardWithRetry(method, requestUrl, req.headers, body);
|
|
230
|
+
const responseHeaders = {};
|
|
231
|
+
for (const [name, value] of response.headers) {
|
|
232
|
+
const lower = name.toLowerCase();
|
|
233
|
+
// undici decompresses response bodies; the stale content-encoding and
|
|
234
|
+
// content-length would misdescribe the plain bytes handed to VSP.
|
|
235
|
+
if (HOP_BY_HOP.has(lower) || lower === 'content-encoding' || lower === 'content-length') continue;
|
|
236
|
+
responseHeaders[name] = value;
|
|
237
|
+
}
|
|
238
|
+
responseHeaders['x-sap-ai-dev-toolkit-relay'] = 'bas-destination';
|
|
239
|
+
res.writeHead(response.status, response.statusText, responseHeaders);
|
|
240
|
+
res.end(responseBody);
|
|
241
|
+
})().catch(error => {
|
|
242
|
+
if (!res.headersSent) res.writeHead(502, { 'content-type': 'text/plain; charset=utf-8', 'connection': 'close' });
|
|
243
|
+
res.end(`BAS destination relay failed: ${error.message}\n`);
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
// Bind eagerly and report the real port through `ready`. Callers must
|
|
248
|
+
// await `ready` before handing the URL to a VSP child; binding failures
|
|
249
|
+
// surface as a rejected promise instead of a silently wrong guessed port.
|
|
250
|
+
const listening = new Promise((resolve, reject) => {
|
|
251
|
+
server.once('error', reject);
|
|
252
|
+
server.listen(0, '127.0.0.1', () => {
|
|
253
|
+
server.removeListener('error', reject);
|
|
254
|
+
resolve(server.address().port);
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
const ready = listening.then(port => `http://127.0.0.1:${port}`).catch(error => {
|
|
258
|
+
log(`[${destination.name}] BAS relay failed to start: ${error.message}`);
|
|
259
|
+
throw error;
|
|
260
|
+
});
|
|
261
|
+
return {
|
|
262
|
+
ready,
|
|
263
|
+
stats,
|
|
264
|
+
close: async () => {
|
|
265
|
+
await dispatcher?.close?.();
|
|
266
|
+
await tunnelDispatcher?.close?.();
|
|
267
|
+
server.closeAllConnections?.();
|
|
268
|
+
await new Promise(resolve => server.close(() => resolve()));
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
}
|
package/src/bas-discovery.mjs
CHANGED
|
@@ -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,114 @@
|
|
|
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
|
+
// Credentials for direct-connect destinations live next to the MCP config
|
|
8
|
+
// (never inside it), with 0600 permissions and no tokens or cookies. The
|
|
9
|
+
// file maps destination names to { host, user, password, updatedAt }.
|
|
10
|
+
const CREDENTIALS_FILENAME = 'sap-ai-dev-toolkit-credentials.json';
|
|
11
|
+
|
|
12
|
+
const SECRET_PATTERN = /(authorization|cookie|password|secret|token|bearer|credential)/i;
|
|
13
|
+
|
|
14
|
+
async function exists(path) {
|
|
15
|
+
try {
|
|
16
|
+
await stat(path);
|
|
17
|
+
return true;
|
|
18
|
+
} catch (error) {
|
|
19
|
+
if (error?.code === 'ENOENT') return false;
|
|
20
|
+
throw error;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function redactCredentialValue(value) {
|
|
25
|
+
return SECRET_PATTERN.test(String(value)) ? '[redacted]' : value;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function resolveCredentialsPath(env = process.env, mcpConfigPath) {
|
|
29
|
+
const configured = brandedEnvValue(env, 'CREDENTIALS_FILE');
|
|
30
|
+
if (configured) return configured;
|
|
31
|
+
if (mcpConfigPath) return join(dirname(mcpConfigPath), CREDENTIALS_FILENAME);
|
|
32
|
+
const home = env.HOME || homedir();
|
|
33
|
+
const candidates = [
|
|
34
|
+
join(home, '.vscode', 'data', 'User'),
|
|
35
|
+
join(home, '.vscode-server', 'data', 'User'),
|
|
36
|
+
join(home, '.code-server', 'data', 'User')
|
|
37
|
+
];
|
|
38
|
+
for (const candidate of candidates) if (await exists(candidate)) return join(candidate, CREDENTIALS_FILENAME);
|
|
39
|
+
return join(candidates[0], CREDENTIALS_FILENAME);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function sanitizeEntry(entry) {
|
|
43
|
+
const result = {};
|
|
44
|
+
if (typeof entry?.host === 'string' && entry.host) result.host = entry.host;
|
|
45
|
+
if (typeof entry?.user === 'string' && entry.user) result.user = entry.user;
|
|
46
|
+
if (typeof entry?.password === 'string' && entry.password) result.password = entry.password;
|
|
47
|
+
if (typeof entry?.updatedAt === 'string') result.updatedAt = entry.updatedAt;
|
|
48
|
+
return result;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function readCredentials(path) {
|
|
52
|
+
let raw;
|
|
53
|
+
try {
|
|
54
|
+
raw = await readFile(path, 'utf8');
|
|
55
|
+
} catch (error) {
|
|
56
|
+
if (error?.code === 'ENOENT') return {};
|
|
57
|
+
throw new Error(`Credentials file ${path} could not be read: ${error.message}`);
|
|
58
|
+
}
|
|
59
|
+
let parsed;
|
|
60
|
+
try {
|
|
61
|
+
parsed = JSON.parse(raw);
|
|
62
|
+
} catch (error) {
|
|
63
|
+
throw new Error(`Credentials file ${path} contains invalid JSON: ${error.message}`);
|
|
64
|
+
}
|
|
65
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
66
|
+
throw new Error(`Credentials file ${path} must contain a top-level JSON object`);
|
|
67
|
+
}
|
|
68
|
+
const destinations = {};
|
|
69
|
+
for (const [name, entry] of Object.entries(parsed.destinations || {})) {
|
|
70
|
+
const clean = sanitizeEntry(entry);
|
|
71
|
+
if (clean.user && clean.password) destinations[name] = clean;
|
|
72
|
+
}
|
|
73
|
+
return { destinations };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function writeCredentials(path, destinations) {
|
|
77
|
+
const directory = dirname(path);
|
|
78
|
+
await mkdir(directory, { recursive: true });
|
|
79
|
+
const temporary = join(directory, `.${basename(path)}.${process.pid}.${randomUUID()}.tmp`);
|
|
80
|
+
try {
|
|
81
|
+
await writeFile(temporary, `${JSON.stringify({ version: 1, destinations }, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
82
|
+
await rename(temporary, path);
|
|
83
|
+
await chmod(path, 0o600).catch(() => {});
|
|
84
|
+
} catch (error) {
|
|
85
|
+
await rm(temporary, { force: true }).catch(() => {});
|
|
86
|
+
throw new Error(`Credentials file ${path} could not be written: ${error.message}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function storeDestinationCredentials(path, name, { host, user, password }) {
|
|
91
|
+
const current = await readCredentials(path).catch(error => { throw error; });
|
|
92
|
+
const destinations = { ...current.destinations };
|
|
93
|
+
destinations[String(name)] = { host: String(host || ''), user: String(user || ''), password: String(password || ''), updatedAt: new Date().toISOString() };
|
|
94
|
+
await writeCredentials(path, destinations);
|
|
95
|
+
return { path, name };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function removeDestinationCredentials(path, names) {
|
|
99
|
+
const current = await readCredentials(path);
|
|
100
|
+
const destinations = { ...current.destinations };
|
|
101
|
+
const removed = [];
|
|
102
|
+
for (const name of names) {
|
|
103
|
+
if (Object.hasOwn(destinations, String(name))) {
|
|
104
|
+
delete destinations[String(name)];
|
|
105
|
+
removed.push(String(name));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (removed.length) await writeCredentials(path, destinations);
|
|
109
|
+
return { path, removed };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function listCredentialDestinations(credentials) {
|
|
113
|
+
return Object.entries(credentials?.destinations || {}).map(([name, entry]) => ({ name, host: entry.host, user: entry.user, updatedAt: entry.updatedAt }));
|
|
114
|
+
}
|
package/src/launcher.mjs
CHANGED
|
@@ -10,10 +10,36 @@ 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 { readCredentials, resolveCredentialsPath } from './credentials-store.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);
|
|
16
17
|
|
|
18
|
+
// Attach stored direct-connect credentials (setup prompts for Internet
|
|
19
|
+
// destinations) to matching discovered destinations. Credentials stay in
|
|
20
|
+
// memory only; nothing is logged or written to the MCP config.
|
|
21
|
+
async function enrichWithStoredCredentials(destinations, env = runtimeEnv) {
|
|
22
|
+
const needingCredentials = destinations.filter(destination =>
|
|
23
|
+
destination.source !== 'cloud-foundry'
|
|
24
|
+
&& destination.url
|
|
25
|
+
&& String(destination.url).includes('.dest'));
|
|
26
|
+
if (!needingCredentials.length) return destinations;
|
|
27
|
+
let stored;
|
|
28
|
+
try {
|
|
29
|
+
const path = await resolveCredentialsPath(env);
|
|
30
|
+
stored = await readCredentials(path);
|
|
31
|
+
} catch (error) {
|
|
32
|
+
console.error(`[sap-ai-dev] stored credentials could not be read: ${String(error.message).slice(0, 200)}`);
|
|
33
|
+
return destinations;
|
|
34
|
+
}
|
|
35
|
+
return destinations.map(destination => {
|
|
36
|
+
const entry = stored.destinations?.[destination.name];
|
|
37
|
+
if (!entry?.host || !entry.user || !entry.password) return destination;
|
|
38
|
+
if (!destination.backendUrl) return destination;
|
|
39
|
+
return { ...destination, credentials: { host: entry.host, user: entry.user, password: entry.password } };
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
17
43
|
function hasFlag(name) { return process.argv.slice(2).includes(name); }
|
|
18
44
|
function json(value) { process.stdout.write(`${JSON.stringify(value)}\n`); }
|
|
19
45
|
|
|
@@ -75,6 +101,19 @@ async function runDoctor(destinations) {
|
|
|
75
101
|
const initialized = await proxy.handle({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05' } });
|
|
76
102
|
if (initialized?.error) throw new Error(initialized.error.message || 'MCP initialization failed');
|
|
77
103
|
checks.push(doctorRow(destination.name, 'VSP MCP startup', 'passed', 'initialized'));
|
|
104
|
+
const relayedDestination = proxy.children[0]?.destination;
|
|
105
|
+
if (relayedDestination?.relay) {
|
|
106
|
+
checks.push(doctorRow(destination.name, 'BAS destination relay', 'passed', `enabled (${relayedDestination.url}); CSRF token/session pairing active`));
|
|
107
|
+
} else if (destination.source === 'cloud-foundry') {
|
|
108
|
+
checks.push(doctorRow(destination.name, 'BAS destination relay', 'skipped', 'Cloud Foundry destinations connect through the connectivity proxy directly.'));
|
|
109
|
+
} else if (String(runtimeEnv.SAP_AI_DEV_TOOLKIT_DISABLE_BAS_RELAY || '').toLowerCase() === 'true') {
|
|
110
|
+
checks.push(doctorRow(destination.name, 'BAS destination relay', 'skipped', 'Disabled through SAP_AI_DEV_TOOLKIT_DISABLE_BAS_RELAY.'));
|
|
111
|
+
} else {
|
|
112
|
+
checks.push(doctorRow(destination.name, 'BAS destination relay', 'skipped', 'Not applicable for this destination.'));
|
|
113
|
+
}
|
|
114
|
+
if (relayedDestination?.credentials) {
|
|
115
|
+
checks.push(doctorRow(destination.name, 'Direct connect', 'passed', `active (${new URL(relayedDestination.credentials.host).origin}); ADT writes use stored credentials`));
|
|
116
|
+
}
|
|
78
117
|
const listed = await proxy.handle({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} });
|
|
79
118
|
if (listed?.error) throw new Error(listed.error.message || 'MCP tools/list failed');
|
|
80
119
|
const tools = listed.result?.tools || [];
|
|
@@ -165,7 +204,7 @@ async function main() {
|
|
|
165
204
|
try {
|
|
166
205
|
const destinations = runtimeEnv.SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE === 'cloud-foundry'
|
|
167
206
|
? [await resolveConfiguredCloudFoundryDestination({ env: runtimeEnv })]
|
|
168
|
-
: await discoverForCommand();
|
|
207
|
+
: await enrichWithStoredCredentials(await discoverForCommand());
|
|
169
208
|
report = await runDoctor(destinations);
|
|
170
209
|
} catch (error) {
|
|
171
210
|
report = { ok: false, destinations: 0, checks: [doctorRow('-', 'destination discovery', 'failed', redactText(error.message || error).slice(0, 300))] };
|
|
@@ -231,12 +270,15 @@ async function main() {
|
|
|
231
270
|
|
|
232
271
|
const discovered = await discoverDestinations({ env: runtimeEnv });
|
|
233
272
|
for (const destination of discovered) console.error(probeDiagnostic(destination));
|
|
234
|
-
const destinations = discovered;
|
|
273
|
+
const destinations = await enrichWithStoredCredentials(discovered);
|
|
235
274
|
if (!destinations.length) {
|
|
236
275
|
console.error('[sap-ai-dev] destination discovery returned no named BAS destinations');
|
|
237
276
|
throw new Error(remediation);
|
|
238
277
|
}
|
|
239
278
|
const binary = await binaryOrError();
|
|
279
|
+
for (const destination of destinations) {
|
|
280
|
+
if (destination.credentials) console.error(`[sap-ai-dev] ${destination.name}: direct connect enabled through stored credentials`);
|
|
281
|
+
}
|
|
240
282
|
console.error(`[sap-ai-dev] starting MCP proxy for ${destinations.map(destination => `${destination.name} (client=${destination.client})`).join(', ')}`);
|
|
241
283
|
const proxy = new MCPProxy({ binary, destinations, env: process.env, log: message => console.error(message) });
|
|
242
284
|
const shutdown = signal => { void proxy.close().finally(() => process.exit(signal === 'SIGINT' ? 130 : 143)); };
|