sap-ai-dev-toolkit 0.3.3 → 0.3.7
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 +3 -1
- package/.github/agents/abap-runtime-debugger.agent.md +3 -1
- package/.github/agents/hana-cloud-hdi-specialist.agent.md +22 -0
- package/.github/agents/rap-service-developer.agent.md +3 -1
- package/.github/agents/sap-solution-architect.agent.md +6 -3
- package/.github/skills/hana-cloud-inspection/SKILL.md +35 -0
- package/.github/skills/hana-cloud-native-development/SKILL.md +26 -0
- package/.github/skills/hana-cloud-validation/SKILL.md +26 -0
- package/README.md +50 -22
- package/package.json +9 -4
- package/scripts/postinstall.mjs +3 -3
- package/src/bas-destination-relay.mjs +24 -13
- package/src/credential-overrides.mjs +75 -0
- package/src/credentials-store.mjs +14 -5
- package/src/hana-config.mjs +170 -0
- package/src/hana-database.mjs +150 -0
- package/src/hana-inspector.mjs +114 -0
- package/src/hana-tools.mjs +277 -0
- package/src/launcher.mjs +10 -32
- package/src/mcp-config.mjs +10 -1
- package/src/mcp-proxy.mjs +61 -0
- package/src/setup.mjs +87 -61
- package/test/bas-destination-relay.test.mjs +50 -0
- package/test/cf-runtime.test.mjs +27 -5
- package/test/copilot-content.test.mjs +63 -0
- package/test/credentials-store.test.mjs +31 -0
- package/test/hana-config.test.mjs +168 -0
- package/test/hana-inspector-stdio.test.mjs +44 -0
- package/test/hana-tools.test.mjs +206 -0
- package/test/launcher.test.mjs +52 -0
- package/test/live-s4h.test.mjs +202 -0
- package/test/mcp-config-cf.test.mjs +40 -0
- package/test/mcp-proxy.test.mjs +17 -31
- package/test/setup-cf.test.mjs +131 -4
- package/test/setup.test.mjs +10 -6
- package/tools.md +58 -50
package/src/setup.mjs
CHANGED
|
@@ -5,6 +5,8 @@ import { discoverDestinations, remediation } from './bas-discovery.mjs';
|
|
|
5
5
|
import { discoverCloudFoundryDestinations, getCloudFoundryTarget, deleteManagedCloudFoundryServiceKeys } from './cf-destination.mjs';
|
|
6
6
|
import { SAP_DEVELOPMENT_MCP_SERVERS, collectCloudFoundryKeyReferencesFromAllEntries, collectManagedCloudFoundryKeyReferences, installMcpConfig, readMcpConfig, resolveMcpConfigPath } from './mcp-config.mjs';
|
|
7
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';
|
|
8
10
|
|
|
9
11
|
const SETUP_COMMAND = 'sap-ai-dev --setup';
|
|
10
12
|
|
|
@@ -24,54 +26,88 @@ async function confirmCloudFoundryImport({ input = stdin, output = stdout } = {}
|
|
|
24
26
|
}
|
|
25
27
|
}
|
|
26
28
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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;
|
|
38
46
|
|
|
39
47
|
print(output, '');
|
|
40
|
-
print(output, formatStatus('
|
|
41
|
-
print(output,
|
|
42
|
-
print(output, '
|
|
43
|
-
print(output, ` Credentials are stored beside your MCP config in ${credentialsPath} (never inside mcp.json).`);
|
|
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.');
|
|
44
51
|
print(output, '');
|
|
45
52
|
|
|
46
|
-
for (const destination of
|
|
47
|
-
const
|
|
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
|
+
|
|
48
70
|
if (known?.user && known?.password) {
|
|
49
|
-
print(output, formatStatus(`Credentials for ${destination.name} are already stored
|
|
71
|
+
print(output, formatStatus(`Credentials for ${destination.name} are already stored. Press Enter at both prompts to keep them.`, 'info', output, 'Credentials'));
|
|
50
72
|
}
|
|
51
73
|
const userAnswer = await textPrompt({
|
|
52
|
-
message: colorText(`👤 ${
|
|
74
|
+
message: colorText(`👤 ${routeLabel} — SAP user`, 'cyan', output),
|
|
53
75
|
required: false,
|
|
54
|
-
placeholder: known ?
|
|
76
|
+
placeholder: known ? 'Enter keeps the stored user' : 'SAP user name'
|
|
55
77
|
}, { input, output });
|
|
56
78
|
const user = userAnswer.trim() || known?.user || '';
|
|
57
79
|
const passwordAnswer = await textPrompt({
|
|
58
|
-
message: colorText(`🔒 ${
|
|
80
|
+
message: colorText(`🔒 ${routeLabel} — SAP password${known ? ' (Enter keeps stored password)' : ''}`, 'cyan', output),
|
|
59
81
|
secret: true,
|
|
60
82
|
required: false
|
|
61
83
|
}, { input, output });
|
|
62
84
|
const password = passwordAnswer || known?.password || '';
|
|
63
85
|
if (!user || !password) {
|
|
64
|
-
|
|
86
|
+
await removeDestinationCredentials(credentialsPath, [key]);
|
|
87
|
+
print(output, formatStatus(`No complete credentials for ${destination.name}; no override was stored.`, 'warning', output, 'Credentials'));
|
|
65
88
|
continue;
|
|
66
89
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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'));
|
|
73
109
|
}
|
|
74
|
-
return
|
|
110
|
+
return retained;
|
|
75
111
|
}
|
|
76
112
|
|
|
77
113
|
function safeProbe(probe) {
|
|
@@ -137,6 +173,7 @@ function printConnectionInstructions(output, result) {
|
|
|
137
173
|
print(output, ` • ${name} — ${detail}`);
|
|
138
174
|
}
|
|
139
175
|
print(output, formatStatus('Open the Command Palette → “MCP: List Servers”, select a generated server, and choose “Start Server”.', 'step', output, 'Next'));
|
|
176
|
+
print(output, formatStatus('In GitHub Copilot Chat, select an agent, open the Chat tools picker, and enable each selected destination server for this chat.', 'step', output, 'Copilot Chat'));
|
|
140
177
|
print(output, formatStatus('Inspect or edit entries with “MCP: Open User Configuration”.', 'info', output, 'Config'));
|
|
141
178
|
}
|
|
142
179
|
|
|
@@ -149,6 +186,7 @@ export async function runSetup({
|
|
|
149
186
|
discoverCf = discoverCloudFoundryDestinations,
|
|
150
187
|
confirmCfImport = confirmCloudFoundryImport,
|
|
151
188
|
install = installMcpConfig,
|
|
189
|
+
verifyOnPremCredentialRoute = probeBasOnPremCredentialRoute,
|
|
152
190
|
includeSapDevelopmentToolsPrompt = false
|
|
153
191
|
} = {}) {
|
|
154
192
|
if (!env.H2O_URL) {
|
|
@@ -268,8 +306,9 @@ export async function runSetup({
|
|
|
268
306
|
let sapDevelopmentServers = [];
|
|
269
307
|
if (includeSapDevelopmentToolsPrompt) {
|
|
270
308
|
print(output, '');
|
|
271
|
-
print(output, formatStatus('Optionally add companion MCP servers for full-stack SAP development.', 'step', output, 'Tools'));
|
|
272
|
-
print(output, '
|
|
309
|
+
print(output, formatStatus('Optionally add companion MCP servers for full-stack SAP development and HANA inspection.', 'step', output, 'Tools'));
|
|
310
|
+
print(output, ' HANA Cloud inspector is read-only and uses HANA_RO_* or VCAP_SERVICES already present in the MCP host environment; credentials are not written to mcp.json.');
|
|
311
|
+
print(output, ' Leave empty to only configure ABAP/ADT destination servers.');
|
|
273
312
|
print(output, '');
|
|
274
313
|
sapDevelopmentServers = await checkboxPrompt({
|
|
275
314
|
message: colorText('🧰 Select companion tools', 'cyan', output),
|
|
@@ -283,37 +322,24 @@ export async function runSetup({
|
|
|
283
322
|
}, { input, output });
|
|
284
323
|
}
|
|
285
324
|
|
|
286
|
-
// Ask for
|
|
287
|
-
// so ADT writes work despite the BAS proxy stripping session cookies.
|
|
325
|
+
// Ask for an explicit credential override choice for each selected supported destination.
|
|
288
326
|
const credentialsPath = await resolveCredentialsPath(env, configPath);
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
}
|
|
306
|
-
// Remove stored credentials for Internet destinations that are no
|
|
307
|
-
// longer selected, so the file only tracks active servers.
|
|
308
|
-
const stillSelected = new Set(selected.map(destination => destination.name));
|
|
309
|
-
const stale = Object.keys(existingMap)
|
|
310
|
-
.filter(name => !stillSelected.has(name))
|
|
311
|
-
.filter(name => internetSelected.every(destination => destination.name !== name));
|
|
312
|
-
if (stale.length) await removeDestinationCredentials(credentialsPath, stale);
|
|
313
|
-
} catch (error) {
|
|
314
|
-
if (/Prompt interrupted/.test(error.message)) throw error;
|
|
315
|
-
credentialWarnings.push(`Direct-connect credentials could not be stored: ${error.message}`);
|
|
316
|
-
}
|
|
327
|
+
try {
|
|
328
|
+
const existingMap = (await readCredentials(credentialsPath).catch(() => ({ destinations: {} }))).destinations || {};
|
|
329
|
+
const retained = await promptForDestinationCredentials({
|
|
330
|
+
destinations: selected,
|
|
331
|
+
credentialsPath,
|
|
332
|
+
existing: existingMap,
|
|
333
|
+
input,
|
|
334
|
+
output,
|
|
335
|
+
env,
|
|
336
|
+
verifyOnPremCredentialRoute
|
|
337
|
+
});
|
|
338
|
+
const stale = Object.keys(existingMap).filter(name => !retained.has(name));
|
|
339
|
+
if (stale.length) await removeDestinationCredentials(credentialsPath, stale);
|
|
340
|
+
} catch (error) {
|
|
341
|
+
if (/Prompt interrupted/.test(error.message)) throw error;
|
|
342
|
+
credentialWarnings.push(`Credential overrides could not be stored: ${error.message}`);
|
|
317
343
|
}
|
|
318
344
|
try {
|
|
319
345
|
const result = await install(selected, { env, sapDevelopmentServers });
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import test from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
3
|
import http from 'node:http';
|
|
4
|
+
import net from 'node:net';
|
|
4
5
|
import { request as httpRequest } from 'node:http';
|
|
5
6
|
import { createBasDestinationRelay } from '../src/bas-destination-relay.mjs';
|
|
6
7
|
|
|
@@ -186,3 +187,52 @@ test('falls back to a proxy tunnel when the absolute-form request is refused', a
|
|
|
186
187
|
await new Promise(resolve => server.close(resolve));
|
|
187
188
|
}
|
|
188
189
|
});
|
|
190
|
+
|
|
191
|
+
test('keeps OnPremise Basic auth on a BAS proxy tunnel and verifies the CSRF session', async t => {
|
|
192
|
+
const { state, server } = sapLikeBackend();
|
|
193
|
+
const backendPort = await startServer(server);
|
|
194
|
+
const connectTargets = [];
|
|
195
|
+
const proxy = http.createServer();
|
|
196
|
+
proxy.on('connect', (request, clientSocket, head) => {
|
|
197
|
+
connectTargets.push(request.url);
|
|
198
|
+
const upstream = net.connect(backendPort, '127.0.0.1', () => {
|
|
199
|
+
clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
|
|
200
|
+
if (head.length) upstream.write(head);
|
|
201
|
+
clientSocket.pipe(upstream);
|
|
202
|
+
upstream.pipe(clientSocket);
|
|
203
|
+
});
|
|
204
|
+
clientSocket.on('error', () => upstream.destroy());
|
|
205
|
+
upstream.on('error', () => clientSocket.destroy());
|
|
206
|
+
});
|
|
207
|
+
const proxyPort = await startServer(proxy);
|
|
208
|
+
const logs = [];
|
|
209
|
+
const relay = createBasDestinationRelay({
|
|
210
|
+
name: 'CC-SYSTEM',
|
|
211
|
+
url: 'http://cc-system.dest',
|
|
212
|
+
authentication: 'BasicAuthentication',
|
|
213
|
+
credentials: { user: 'DEVELOPER', password: 'secret123', mode: 'bas-tunnel' }
|
|
214
|
+
}, {
|
|
215
|
+
env: { HTTP_PROXY: `http://127.0.0.1:${proxyPort}` },
|
|
216
|
+
log: message => logs.push(message)
|
|
217
|
+
});
|
|
218
|
+
const relayUrl = await relay.ready;
|
|
219
|
+
t.after(async () => {
|
|
220
|
+
await relay.close();
|
|
221
|
+
proxy.closeAllConnections?.();
|
|
222
|
+
await new Promise(resolve => proxy.close(resolve));
|
|
223
|
+
await new Promise(resolve => server.close(resolve));
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
const probe = await relay.probeCsrfSession();
|
|
227
|
+
assert.deepEqual(probe, { httpStatus: 200, tokenReceived: true, cookieCount: 2 });
|
|
228
|
+
const response = await relayFetch(relayUrl, '/sap/bc/adt/oo/classes', { method: 'POST', body: '<x/>' });
|
|
229
|
+
assert.equal(response.status, 201, `expected a paired CSRF session, got ${response.status}: ${response.body}`);
|
|
230
|
+
const post = state.requests.find(request => request.method === 'POST');
|
|
231
|
+
assert.match(post.headers.authorization, /^Basic /);
|
|
232
|
+
assert.match(String(post.headers.cookie), /sap-session-\d+=/);
|
|
233
|
+
assert.ok(connectTargets.length >= 2);
|
|
234
|
+
assert.ok(connectTargets.every(target => target === 'cc-system.dest:80'));
|
|
235
|
+
assert.equal(relay.stats.direct, false);
|
|
236
|
+
assert.equal(relay.stats.proxyTunnel, true);
|
|
237
|
+
assert.ok(logs.some(message => message.includes('BAS proxy tunnel')));
|
|
238
|
+
});
|
package/test/cf-runtime.test.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import { spawn } from 'node:child_process';
|
|
|
6
6
|
import { tmpdir } from 'node:os';
|
|
7
7
|
import { join } from 'node:path';
|
|
8
8
|
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { storeDestinationCredentials } from '../src/credentials-store.mjs';
|
|
9
10
|
|
|
10
11
|
const launcher = fileURLToPath(new URL('../src/launcher.mjs', import.meta.url));
|
|
11
12
|
const fakeVsp = fileURLToPath(new URL('./fixtures/fake-vsp.mjs', import.meta.url));
|
|
@@ -257,6 +258,12 @@ test('routes a PrincipalPropagation MCP call through CF Connectivity to the SAP
|
|
|
257
258
|
connectivityProxyPort,
|
|
258
259
|
connectivityTokenUrl: `${serviceOrigin}/connectivity/oauth/token`
|
|
259
260
|
});
|
|
261
|
+
const credentialsPath = join(directory, 'sap-ai-dev-toolkit-credentials.json');
|
|
262
|
+
await storeDestinationCredentials(credentialsPath, `cloud-foundry:cf:${SPACE_GUID}:${INSTANCE_GUID}:pp-target`, {
|
|
263
|
+
user: 'must-not-override',
|
|
264
|
+
password: 'must-not-override-secret',
|
|
265
|
+
mode: 'cf-connectivity'
|
|
266
|
+
});
|
|
260
267
|
const childLog = join(directory, 'vsp-children.jsonl');
|
|
261
268
|
t.after(async () => {
|
|
262
269
|
await new Promise(resolve => service.close(resolve));
|
|
@@ -271,6 +278,7 @@ test('routes a PrincipalPropagation MCP call through CF Connectivity to the SAP
|
|
|
271
278
|
SAP_AI_DEV_TOOLKIT_BINARY: fakeVsp,
|
|
272
279
|
SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE: 'cloud-foundry',
|
|
273
280
|
SAP_AI_DEV_TOOLKIT_DESTINATION: 'pp-target',
|
|
281
|
+
SAP_AI_DEV_TOOLKIT_CREDENTIALS_FILE: credentialsPath,
|
|
274
282
|
BAS_CF_SPACE_GUID: SPACE_GUID,
|
|
275
283
|
BAS_CF_DESTINATION_INSTANCE_GUID: INSTANCE_GUID,
|
|
276
284
|
BAS_CF_DESTINATION_INSTANCE: INSTANCE_NAME,
|
|
@@ -313,17 +321,17 @@ test('routes a PrincipalPropagation MCP call through CF Connectivity to the SAP
|
|
|
313
321
|
assert.equal(initialized.argv.includes('--proxy-auth'), true);
|
|
314
322
|
assert.equal(initialized.env.user, undefined);
|
|
315
323
|
assert.equal(initialized.env.password, undefined);
|
|
316
|
-
for (const secret of ['destination-key-secret', 'destination-api-token', 'connectivity-key-secret', 'user-exchange-token', 'current-cf-user-jwt', 'parent-user-secret', 'parent-password-secret']) {
|
|
324
|
+
for (const secret of ['must-not-override', 'must-not-override-secret', 'destination-key-secret', 'destination-api-token', 'connectivity-key-secret', 'user-exchange-token', 'current-cf-user-jwt', 'parent-user-secret', 'parent-password-secret']) {
|
|
317
325
|
assert.equal(`${result.stdout}\\n${result.stderr}\\n${JSON.stringify(initialized.argv)}`.includes(secret), false);
|
|
318
326
|
}
|
|
319
327
|
});
|
|
320
328
|
|
|
321
|
-
test('
|
|
329
|
+
test('overrides CF Basic OnPremise backend auth while retaining the HTTP Connectivity proxy', async t => {
|
|
322
330
|
const directory = await mkdtemp(join(tmpdir(), 'bas-cf-basic-onprem-'));
|
|
323
331
|
const observed = { destinationToken: [], destinationList: [], connectivityToken: [], proxy: [], connect: [], target: [] };
|
|
324
332
|
const target = createServer((request, response) => {
|
|
325
333
|
observed.target.push({ path: request.url, authorization: request.headers.authorization });
|
|
326
|
-
response.writeHead(request.headers.authorization === `Basic ${Buffer.from('
|
|
334
|
+
response.writeHead(request.headers.authorization === `Basic ${Buffer.from('override-user:override-password').toString('base64')}` ? 200 : 401, {
|
|
327
335
|
'content-type': 'text/plain'
|
|
328
336
|
});
|
|
329
337
|
response.end('SAP system information: client=100');
|
|
@@ -409,6 +417,12 @@ test('routes a CF Basic OnPremise MCP call through the HTTP Connectivity proxy',
|
|
|
409
417
|
connectivityProxyPort,
|
|
410
418
|
connectivityTokenUrl: `${serviceOrigin}/connectivity/oauth/token`
|
|
411
419
|
});
|
|
420
|
+
const credentialsPath = join(directory, 'sap-ai-dev-toolkit-credentials.json');
|
|
421
|
+
await storeDestinationCredentials(credentialsPath, `cloud-foundry:cf:${SPACE_GUID}:${INSTANCE_GUID}:basic-onprem`, {
|
|
422
|
+
user: 'override-user',
|
|
423
|
+
password: 'override-password',
|
|
424
|
+
mode: 'cf-connectivity'
|
|
425
|
+
});
|
|
412
426
|
const childLog = join(directory, 'vsp-children.jsonl');
|
|
413
427
|
const servers = [service, target, connectivityProxy];
|
|
414
428
|
t.after(async () => {
|
|
@@ -423,6 +437,7 @@ test('routes a CF Basic OnPremise MCP call through the HTTP Connectivity proxy',
|
|
|
423
437
|
SAP_AI_DEV_TOOLKIT_BINARY: fakeVsp,
|
|
424
438
|
SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE: 'cloud-foundry',
|
|
425
439
|
SAP_AI_DEV_TOOLKIT_DESTINATION: 'basic-onprem',
|
|
440
|
+
SAP_AI_DEV_TOOLKIT_CREDENTIALS_FILE: credentialsPath,
|
|
426
441
|
BAS_CF_SPACE_GUID: SPACE_GUID,
|
|
427
442
|
BAS_CF_DESTINATION_INSTANCE_GUID: INSTANCE_GUID,
|
|
428
443
|
BAS_CF_DESTINATION_INSTANCE: INSTANCE_NAME,
|
|
@@ -461,13 +476,20 @@ test('routes a CF Basic OnPremise MCP call through the HTTP Connectivity proxy',
|
|
|
461
476
|
'GET',
|
|
462
477
|
`${targetOrigin}/sap/bc/adt/discovery`,
|
|
463
478
|
'Bearer application-proxy-token',
|
|
464
|
-
`Basic ${Buffer.from('
|
|
479
|
+
`Basic ${Buffer.from('override-user:override-password').toString('base64')}`
|
|
465
480
|
]]);
|
|
466
481
|
assert.deepEqual(observed.connect, []);
|
|
467
482
|
assert.deepEqual(observed.target, [{
|
|
468
483
|
path: '/sap/bc/adt/discovery',
|
|
469
|
-
authorization: `Basic ${Buffer.from('
|
|
484
|
+
authorization: `Basic ${Buffer.from('override-user:override-password').toString('base64')}`
|
|
470
485
|
}]);
|
|
486
|
+
const events = (await readFile(childLog, 'utf8')).trim().split('\n').map(line => JSON.parse(line));
|
|
487
|
+
const initialized = events.find(event => event.event === 'initialize');
|
|
488
|
+
assert.deepEqual([initialized.env.user, initialized.env.password], ['override-user', 'override-password']);
|
|
489
|
+
assert.equal(initialized.argv.includes('--proxy-auth'), false);
|
|
490
|
+
for (const secret of ['override-user', 'override-password', 'sap-password', 'connectivity-key-secret', 'application-proxy-token']) {
|
|
491
|
+
assert.equal(`${result.stdout}\n${result.stderr}\n${JSON.stringify(initialized.argv)}`.includes(secret), false, `${secret} must not appear in output or argv`);
|
|
492
|
+
}
|
|
471
493
|
});
|
|
472
494
|
|
|
473
495
|
test('CF list-destinations clears the CF name filter and remains BAS-only', async t => {
|
|
@@ -10,6 +10,7 @@ const skillsRoot = join(root, '.github', 'skills');
|
|
|
10
10
|
const expectedAgents = [
|
|
11
11
|
'abap-developer.agent.md',
|
|
12
12
|
'abap-runtime-debugger.agent.md',
|
|
13
|
+
'hana-cloud-hdi-specialist.agent.md',
|
|
13
14
|
'rap-service-developer.agent.md',
|
|
14
15
|
'sap-solution-architect.agent.md'
|
|
15
16
|
];
|
|
@@ -20,6 +21,9 @@ const expectedSkills = [
|
|
|
20
21
|
'abap-testing-quality',
|
|
21
22
|
'cds-development',
|
|
22
23
|
'clean-core-extensibility',
|
|
24
|
+
'hana-cloud-inspection',
|
|
25
|
+
'hana-cloud-native-development',
|
|
26
|
+
'hana-cloud-validation',
|
|
23
27
|
'rap-development',
|
|
24
28
|
'rap-service-delivery',
|
|
25
29
|
'sap-sdlc-orchestration',
|
|
@@ -49,6 +53,12 @@ function assertMentionsLiveToolDiscovery(markdown, path) {
|
|
|
49
53
|
assert.match(markdown, /schema/i, `${path} must tell agents to follow live tool schemas`);
|
|
50
54
|
}
|
|
51
55
|
|
|
56
|
+
function assertDirectMcpUsage(markdown, path) {
|
|
57
|
+
assert.match(markdown, /Direct MCP invocation is mandatory/i, `${path} must require direct MCP calls`);
|
|
58
|
+
assert.match(markdown, /Do not launch `sap-ai-dev`.*MCP operations/i, `${path} must forbid terminal-driven MCP discovery/calls`);
|
|
59
|
+
assert.match(markdown, /host\/session binding issue/i, `${path} must report host tool-binding failures instead of using a CLI fallback`);
|
|
60
|
+
}
|
|
61
|
+
|
|
52
62
|
function assertValidationPolicy(markdown, path) {
|
|
53
63
|
assert.match(markdown, /ABAP Unit|RunUnitTests/i, `${path} must include ABAP Unit or test execution guidance`);
|
|
54
64
|
assert.match(markdown, /runtime validation|Self-validate|executable validation|reproduce/i, `${path} must require executable/runtime validation evidence`);
|
|
@@ -85,6 +95,15 @@ test('packaged Copilot agent and skill inventory is complete and canonical', asy
|
|
|
85
95
|
}
|
|
86
96
|
});
|
|
87
97
|
|
|
98
|
+
test('all bundled agents invoke chat-attached MCP tools without terminal JSON-RPC fallbacks', async () => {
|
|
99
|
+
for (const name of expectedAgents) {
|
|
100
|
+
const path = join(agentsRoot, name);
|
|
101
|
+
const markdown = await readMarkdown(path);
|
|
102
|
+
assertDirectMcpUsage(markdown, path);
|
|
103
|
+
assertMentionsLiveToolDiscovery(markdown, path);
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
88
107
|
test('ABAP Developer agent has valid metadata and invokes the packaged skills', async () => {
|
|
89
108
|
const markdown = await readMarkdown(agentPath);
|
|
90
109
|
const metadata = parseFrontmatter(markdown, agentPath);
|
|
@@ -121,6 +140,7 @@ test('SAP Solution Architect agent has valid metadata and planning/handoff polic
|
|
|
121
140
|
assert.match(markdown, /ABAP Developer/i);
|
|
122
141
|
assert.match(markdown, /RAP Service Developer/i);
|
|
123
142
|
assert.match(markdown, /ABAP Runtime Debugger/i);
|
|
143
|
+
assert.match(markdown, /HANA Cloud\/HDI Specialist/i);
|
|
124
144
|
assert.match(markdown, /SDLC|lifecycle/i);
|
|
125
145
|
assertPlanFirstPolicy(markdown, path);
|
|
126
146
|
assertLocalFirstPolicy(markdown, path);
|
|
@@ -143,6 +163,27 @@ test('RAP Service Developer agent has valid metadata and plan-first local-first
|
|
|
143
163
|
assertNoUnsafeTransportClaims(markdown, path);
|
|
144
164
|
});
|
|
145
165
|
|
|
166
|
+
test('HANA Cloud/HDI Specialist agent has a read-only target-bound workflow', async () => {
|
|
167
|
+
const path = join(agentsRoot, 'hana-cloud-hdi-specialist.agent.md');
|
|
168
|
+
const markdown = await readMarkdown(path);
|
|
169
|
+
const metadata = parseFrontmatter(markdown, path);
|
|
170
|
+
assert.deepEqual(Object.keys(metadata).sort(), ['description', 'name', 'target', 'user-invocable']);
|
|
171
|
+
assert.equal(metadata.name, 'HANA Cloud/HDI Specialist');
|
|
172
|
+
assert.equal(metadata.target, 'vscode');
|
|
173
|
+
assert.equal(metadata['user-invocable'], 'true');
|
|
174
|
+
assert.match(metadata.description, /HANA Cloud/i);
|
|
175
|
+
assertDirectMcpUsage(markdown, path);
|
|
176
|
+
assertMentionsLiveToolDiscovery(markdown, path);
|
|
177
|
+
for (const tool of ['hana_connection_info', 'hana_list_objects', 'hana_describe_object', 'hana_read_rows']) {
|
|
178
|
+
assert.match(markdown, new RegExp(tool));
|
|
179
|
+
}
|
|
180
|
+
assert.match(markdown, /read-only/i);
|
|
181
|
+
assert.match(markdown, /HANA_RO_\*/);
|
|
182
|
+
assert.match(markdown, /cds build --for hana/);
|
|
183
|
+
assert.match(markdown, /user-run deployment|deployment is performed by the user/i);
|
|
184
|
+
assert.match(markdown, /never execute HANA DDL\/DML|Never execute HANA DDL\/DML/i);
|
|
185
|
+
});
|
|
186
|
+
|
|
146
187
|
test('each skill has valid metadata, matching name, and enforceable live-tool validation policy', async () => {
|
|
147
188
|
for (const skill of expectedSkills) {
|
|
148
189
|
const path = join(skillsRoot, skill, 'SKILL.md');
|
|
@@ -184,3 +225,25 @@ test('debugging skill requires evidence before fixes and bounded debugger use',
|
|
|
184
225
|
assert.match(markdown, /Avoid attaching to another user's session|do not.*another user's session/i);
|
|
185
226
|
assert.match(markdown, /before changing code|before proposing a cause/i);
|
|
186
227
|
});
|
|
228
|
+
|
|
229
|
+
test('HANA skills describe the read-only tool surface and safe CAP/HDI generation', async () => {
|
|
230
|
+
const inspection = await readMarkdown(join(skillsRoot, 'hana-cloud-inspection', 'SKILL.md'));
|
|
231
|
+
for (const tool of ['hana_connection_info', 'hana_list_objects', 'hana_describe_object', 'hana_read_rows']) {
|
|
232
|
+
assert.match(inspection, new RegExp(tool));
|
|
233
|
+
}
|
|
234
|
+
assert.match(inspection, /200-row limit/);
|
|
235
|
+
assert.match(inspection, /Do not launch `sap-ai-dev` or `sap-ai-hana` for MCP operations/);
|
|
236
|
+
assert.match(inspection, /schema-bound/);
|
|
237
|
+
assert.match(inspection, /credential-like columns/);
|
|
238
|
+
|
|
239
|
+
const development = await readMarkdown(join(skillsRoot, 'hana-cloud-native-development', 'SKILL.md'));
|
|
240
|
+
assert.match(development, /cds build --for hana/);
|
|
241
|
+
assert.match(development, /CAP-owned database models/);
|
|
242
|
+
assert.match(development, /Never execute `cds deploy` against HANA/);
|
|
243
|
+
assert.match(development, /HANA DDL\/DML/);
|
|
244
|
+
|
|
245
|
+
const validation = await readMarkdown(join(skillsRoot, 'hana-cloud-validation', 'SKILL.md'));
|
|
246
|
+
assert.match(validation, /deployment handoff/);
|
|
247
|
+
assert.match(validation, /HANA tools are metadata and bounded read-only inspection only/);
|
|
248
|
+
assert.match(validation, /Never claim HANA activation\/deployment/);
|
|
249
|
+
});
|
|
@@ -7,6 +7,7 @@ import http from 'node:http';
|
|
|
7
7
|
import { request as httpRequest } from 'node:http';
|
|
8
8
|
import { createBasDestinationRelay } from '../src/bas-destination-relay.mjs';
|
|
9
9
|
import { readCredentials, removeDestinationCredentials, resolveCredentialsPath, storeDestinationCredentials } from '../src/credentials-store.mjs';
|
|
10
|
+
import { canPromptForCredentials, credentialKeyForDestination, credentialModeForDestination } from '../src/credential-overrides.mjs';
|
|
10
11
|
|
|
11
12
|
const UNSAFE_METHODS = ['POST', 'PUT', 'PATCH', 'DELETE'];
|
|
12
13
|
|
|
@@ -105,6 +106,36 @@ test('credentials file with malformed entries keeps only complete records', asyn
|
|
|
105
106
|
assert.deepEqual(Object.keys(stored.destinations), ['GOOD']);
|
|
106
107
|
});
|
|
107
108
|
|
|
109
|
+
test('credential overrides use route-aware modes and distinct CF destination keys', async t => {
|
|
110
|
+
const directory = await mkdtemp(join(tmpdir(), 'bas-creds-modes-'));
|
|
111
|
+
const path = join(directory, 'sap-ai-dev-toolkit-credentials.json');
|
|
112
|
+
t.after(async () => { await rm(directory, { recursive: true, force: true }); });
|
|
113
|
+
|
|
114
|
+
const basDestination = { name: 'shared-name', source: 'bas', proxyType: 'Internet', authentication: 'BasicAuthentication', backendUrl: 'https://internet.example' };
|
|
115
|
+
const basOnPremise = { name: 'shared-name', source: 'bas', proxyType: 'OnPremise', authentication: 'BasicAuthentication' };
|
|
116
|
+
const cfOnPremise = { name: 'shared-name', serverName: 'cf:space-one:instance-one:shared-name', source: 'cloud-foundry', proxyType: 'OnPremise', authentication: 'BasicAuthentication' };
|
|
117
|
+
const principalPropagation = { ...cfOnPremise, authentication: 'PrincipalPropagation' };
|
|
118
|
+
|
|
119
|
+
assert.equal(canPromptForCredentials(basDestination), true);
|
|
120
|
+
assert.equal(credentialModeForDestination(basDestination), 'direct');
|
|
121
|
+
assert.equal(credentialModeForDestination(basOnPremise), 'bas-tunnel');
|
|
122
|
+
assert.equal(credentialModeForDestination(cfOnPremise), 'cf-connectivity');
|
|
123
|
+
assert.equal(canPromptForCredentials(principalPropagation), false);
|
|
124
|
+
assert.equal(credentialModeForDestination({ ...cfOnPremise, proxyType: 'Internet' }), null);
|
|
125
|
+
assert.equal(canPromptForCredentials({ ...basDestination, backendUrl: null }), false);
|
|
126
|
+
assert.notEqual(credentialKeyForDestination(basOnPremise), credentialKeyForDestination(cfOnPremise));
|
|
127
|
+
|
|
128
|
+
const cfKey = credentialKeyForDestination(cfOnPremise);
|
|
129
|
+
await storeDestinationCredentials(path, cfKey, { user: 'OVERRIDE', password: 'secret', mode: 'cf-connectivity' });
|
|
130
|
+
const stored = await readCredentials(path);
|
|
131
|
+
assert.equal(stored.destinations[cfKey].mode, 'cf-connectivity');
|
|
132
|
+
assert.equal(stored.destinations[cfKey].host, undefined);
|
|
133
|
+
await assert.rejects(
|
|
134
|
+
() => storeDestinationCredentials(path, 'INVALID', { user: 'U', password: 'P', mode: 'bypass-cloud-connector' }),
|
|
135
|
+
/route mode is unsupported/
|
|
136
|
+
);
|
|
137
|
+
});
|
|
138
|
+
|
|
108
139
|
test('relay direct connect sends Basic auth and CSRF pairing succeeds', async t => {
|
|
109
140
|
const { state, server } = authedBackend();
|
|
110
141
|
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|