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.
Files changed (36) hide show
  1. package/.github/agents/abap-developer.agent.md +3 -1
  2. package/.github/agents/abap-runtime-debugger.agent.md +3 -1
  3. package/.github/agents/hana-cloud-hdi-specialist.agent.md +22 -0
  4. package/.github/agents/rap-service-developer.agent.md +3 -1
  5. package/.github/agents/sap-solution-architect.agent.md +6 -3
  6. package/.github/skills/hana-cloud-inspection/SKILL.md +35 -0
  7. package/.github/skills/hana-cloud-native-development/SKILL.md +26 -0
  8. package/.github/skills/hana-cloud-validation/SKILL.md +26 -0
  9. package/README.md +50 -22
  10. package/package.json +9 -4
  11. package/scripts/postinstall.mjs +3 -3
  12. package/src/bas-destination-relay.mjs +24 -13
  13. package/src/credential-overrides.mjs +75 -0
  14. package/src/credentials-store.mjs +14 -5
  15. package/src/hana-config.mjs +170 -0
  16. package/src/hana-database.mjs +150 -0
  17. package/src/hana-inspector.mjs +114 -0
  18. package/src/hana-tools.mjs +277 -0
  19. package/src/launcher.mjs +10 -32
  20. package/src/mcp-config.mjs +10 -1
  21. package/src/mcp-proxy.mjs +61 -0
  22. package/src/setup.mjs +87 -61
  23. package/test/bas-destination-relay.test.mjs +50 -0
  24. package/test/cf-runtime.test.mjs +27 -5
  25. package/test/copilot-content.test.mjs +63 -0
  26. package/test/credentials-store.test.mjs +31 -0
  27. package/test/hana-config.test.mjs +168 -0
  28. package/test/hana-inspector-stdio.test.mjs +44 -0
  29. package/test/hana-tools.test.mjs +206 -0
  30. package/test/launcher.test.mjs +52 -0
  31. package/test/live-s4h.test.mjs +202 -0
  32. package/test/mcp-config-cf.test.mjs +40 -0
  33. package/test/mcp-proxy.test.mjs +17 -31
  34. package/test/setup-cf.test.mjs +131 -4
  35. package/test/setup.test.mjs +10 -6
  36. 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
- // Internet destinations reach SAP through the BAS .dest proxy, which strips
28
- // Set-Cookie and makes ADT CSRF-protected writes impossible. Ask once for
29
- // direct-connection credentials and store them next to the MCP config; the
30
- // relay then connects straight to the backend host with Basic auth.
31
- async function promptForInternetDestinationCredentials({ destinations, credentialsPath, existing = {}, input = stdin, output = stdout } = {}) {
32
- const stored = {};
33
- const internetDestinations = destinations.filter(destination =>
34
- destination.source !== 'cloud-foundry'
35
- && String(destination.proxyType || '').toLowerCase() === 'internet'
36
- && /^basicauthentication$/i.test(String(destination.authentication || '')));
37
- if (!internetDestinations.length) return stored;
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('Some selected destinations are Internet type with basic authentication.', 'step', output, 'Direct connect'));
41
- print(output, ' The BAS proxy strips SAP session cookies, which blocks ADT writes (CSRF validation).');
42
- print(output, ' Providing a user and password enables a direct connection for those destinations.');
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 internetDestinations) {
47
- const known = existing[destination.name];
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 (user ${known.user}). Press Enter at both prompts to keep them.`, 'info', output, 'Direct connect'));
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(`👤 ${destination.name} — SAP user`, 'cyan', output),
74
+ message: colorText(`👤 ${routeLabel} — SAP user`, 'cyan', output),
53
75
  required: false,
54
- placeholder: known ? `${known.user} (Enter keeps stored user)` : 'SAP user name'
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(`🔒 ${destination.name} — SAP password${known ? ' (Enter keeps stored password)' : ''}`, 'cyan', output),
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
- print(output, formatStatus(`No complete credentials for ${destination.name}; direct connect stays inactive for it.`, 'warning', output, 'Direct connect'));
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
- stored[destination.name] = {
68
- host: destination.backendUrl || known?.host || '',
69
- user,
70
- password
71
- };
72
- print(output, formatStatus(`Stored credentials for ${destination.name}.`, 'success', output, 'Direct connect'));
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 stored;
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, ' Recommended for RAP/Fiori/CAP/UI validation work. Leave empty to only configure ABAP/ADT destination servers.');
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 direct-connect credentials for selected Internet destinations
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
- const internetSelected = selected.filter(destination =>
290
- destination.source !== 'cloud-foundry'
291
- && String(destination.proxyType || '').toLowerCase() === 'internet'
292
- && /^basicauthentication$/i.test(String(destination.authentication || '')));
293
- if (internetSelected.length) {
294
- try {
295
- const existingMap = (await readCredentials(credentialsPath).catch(() => ({ destinations: {} }))).destinations || {};
296
- const storedNow = await promptForInternetDestinationCredentials({
297
- destinations: selected,
298
- credentialsPath,
299
- existing: existingMap,
300
- input,
301
- output
302
- });
303
- for (const [name, entry] of Object.entries(storedNow)) {
304
- await storeDestinationCredentials(credentialsPath, name, entry);
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
+ });
@@ -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('routes a CF Basic OnPremise MCP call through the HTTP Connectivity proxy', async t => {
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('sap-user:sap-password').toString('base64')}` ? 200 : 401, {
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('sap-user:sap-password').toString('base64')}`
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('sap-user:sap-password').toString('base64')}`
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));