sap-ai-dev-toolkit 0.3.1 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,6 +7,8 @@ import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises
7
7
  import { tmpdir } from 'node:os';
8
8
  import { join } from 'node:path';
9
9
  import { spawnWithPty } from './pty.mjs';
10
+ import { storeDestinationCredentials } from '../src/credentials-store.mjs';
11
+ import { credentialKeyForDestination, enrichWithStoredCredentials } from '../src/credential-overrides.mjs';
10
12
 
11
13
  const launcher = fileURLToPath(new URL('../src/launcher.mjs', import.meta.url));
12
14
  const fakeVsp = fileURLToPath(new URL('./fixtures/fake-vsp.mjs', import.meta.url));
@@ -43,6 +45,56 @@ function runLauncherTty(env, input, args = ['--setup']) {
43
45
  });
44
46
  }
45
47
 
48
+ test('enriches stored credentials without switching BAS OnPremise or CF Connectivity routes', async t => {
49
+ const directory = await mkdtemp(join(tmpdir(), 'bas-launcher-credentials-'));
50
+ const credentialsPath = join(directory, 'credentials.json');
51
+ t.after(async () => { await rm(directory, { recursive: true, force: true }); });
52
+
53
+ const internet = {
54
+ name: 'internet-system', url: 'http://internet-system.dest', backendUrl: 'https://internet.example:44300',
55
+ authentication: 'BasicAuthentication', proxyType: 'Internet'
56
+ };
57
+ const basOnPremise = {
58
+ name: 'shared-system', url: 'http://shared-system.dest', backendUrl: 'https://internal.example:44300',
59
+ authentication: 'BasicAuthentication', proxyType: 'OnPremise'
60
+ };
61
+ const cfOnPremise = {
62
+ source: 'cloud-foundry', name: 'shared-system', serverName: 'cf:space-one:instance-one:shared-system',
63
+ url: 'https://sap.internal:44300', authentication: 'BasicAuthentication', proxyType: 'OnPremise',
64
+ childEnv: { HTTP_PROXY: 'http://127.0.0.1:3333', SAP_USER: 'configured-user', SAP_PASSWORD: 'configured-password' }
65
+ };
66
+ const principalPropagation = { ...cfOnPremise, name: 'principal-system', serverName: 'cf:space-one:instance-one:principal-system', authentication: 'PrincipalPropagation' };
67
+
68
+ await storeDestinationCredentials(credentialsPath, 'internet-system', {
69
+ host: internet.backendUrl, user: 'INTERNET_USER', password: 'internet-secret'
70
+ });
71
+ await storeDestinationCredentials(credentialsPath, 'shared-system', {
72
+ user: 'BAS_USER', password: 'bas-secret', mode: 'bas-tunnel'
73
+ });
74
+ await storeDestinationCredentials(credentialsPath, credentialKeyForDestination(cfOnPremise), {
75
+ user: 'CF_USER', password: 'cf-secret', mode: 'cf-connectivity'
76
+ });
77
+ await storeDestinationCredentials(credentialsPath, credentialKeyForDestination(principalPropagation), {
78
+ user: 'SHOULD_NOT_APPLY', password: 'pp-secret', mode: 'cf-connectivity'
79
+ });
80
+
81
+ const result = await enrichWithStoredCredentials(
82
+ [internet, basOnPremise, cfOnPremise, principalPropagation],
83
+ { SAP_AI_DEV_TOOLKIT_CREDENTIALS_FILE: credentialsPath },
84
+ () => {}
85
+ );
86
+ assert.deepEqual(result[0].credentials, {
87
+ host: internet.backendUrl, user: 'INTERNET_USER', password: 'internet-secret', mode: 'direct'
88
+ });
89
+ assert.deepEqual(result[1].credentials, { user: 'BAS_USER', password: 'bas-secret', mode: 'bas-tunnel' });
90
+ assert.equal(result[1].credentials.host, undefined, 'OnPremise must not bypass the BAS destination route');
91
+ assert.deepEqual(result[2].childEnv, {
92
+ HTTP_PROXY: 'http://127.0.0.1:3333', SAP_USER: 'CF_USER', SAP_PASSWORD: 'cf-secret', SAP_VERBOSE: 'false'
93
+ });
94
+ assert.equal(result[3].childEnv.SAP_USER, 'configured-user', 'PrincipalPropagation must not receive a Basic override');
95
+ assert.equal(result[3].childEnv.SAP_PASSWORD, 'configured-password');
96
+ });
97
+
46
98
  test('help exits before BAS destination discovery', async t => {
47
99
  let requests = 0;
48
100
  const server = createServer((_request, response) => {
@@ -47,6 +47,57 @@ test('uses authentication-specific arguments for Cloud Foundry children', () =>
47
47
  assert.equal(childArguments(principal, {}).includes('--proxy-auth'), true);
48
48
  });
49
49
 
50
+ test('self-heals BAS destinations through a local destination relay without credentials', async t => {
51
+ const destination = { name: 'S4H', url: 'http://S4H.dest', client: '100', authentication: 'BasicAuthentication', proxyType: 'Internet' };
52
+ const directory = await mkdtemp(join(tmpdir(), 'bas-relay-'));
53
+ const log = join(directory, 'children.log');
54
+ const logs = [];
55
+ const proxy = new MCPProxy({ binary: fixture, destinations: [destination], env: { ...process.env, FAKE_LOG: log }, log: message => logs.push(message) });
56
+ t.after(async () => { await proxy.close(); await rm(directory, { recursive: true, force: true }); });
57
+ proxy.start();
58
+ await proxy.handle({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} });
59
+ const event = (await readFile(log, 'utf8')).trim().split('\n').map(line => JSON.parse(line)).find(row => row.event === 'initialize');
60
+ assert.equal(event.argv.includes('--proxy-auth'), true);
61
+ assert.match(event.argv[event.argv.indexOf('--url') + 1], /^http:\/\/127\.0\.0\.1:\d+$/);
62
+ assert.equal(event.env.user, undefined);
63
+ assert.equal(event.env.password, undefined);
64
+ assert.ok(logs.some(message => message.includes('BAS destination relay enabled')));
65
+ });
66
+
67
+ test('self-heals BAS relay children with loopback NO_PROXY and keeps --proxy-auth', async t => {
68
+ const destination = { name: 'S4H', url: 'http://S4H.dest', client: '100', authentication: 'BasicAuthentication', proxyType: 'Internet' };
69
+ const directory = await mkdtemp(join(tmpdir(), 'bas-relay-env-'));
70
+ const log = join(directory, 'children.log');
71
+ const proxy = new MCPProxy({ binary: fixture, destinations: [destination], env: { ...process.env, FAKE_LOG: log }, log: () => {} });
72
+ t.after(async () => { await proxy.close(); await rm(directory, { recursive: true, force: true }); });
73
+ proxy.start();
74
+ await proxy.handle({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} });
75
+ const event = (await readFile(log, 'utf8')).trim().split('\n').map(line => JSON.parse(line)).find(row => row.event === 'initialize');
76
+ assert.equal(event.argv.includes('--proxy-auth'), true);
77
+ const noProxy = String(event.env.noProxy || '').split(',').map(value => value.trim().toLowerCase());
78
+ assert.ok(noProxy.includes('127.0.0.1'), `NO_PROXY must exempt the relay loopback: ${event.env.noProxy}`);
79
+ assert.ok(noProxy.includes('localhost'), `NO_PROXY must exempt relay localhost: ${event.env.noProxy}`);
80
+ });
81
+
82
+ test('restarts a crashed VSP child and retries the tool call once', async t => {
83
+ const { directory, log, proxy } = await fixtureProxy();
84
+ t.after(async () => { await proxy.close(); await rm(directory, { recursive: true, force: true }); });
85
+ await proxy.handle({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05' } });
86
+ await proxy.handle({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} });
87
+ const entry = proxy.children[0];
88
+ const originalChild = entry.child;
89
+ // Kill the child behind the proxy's back to simulate a crash.
90
+ const exit = once(originalChild.process, 'exit');
91
+ originalChild.process.kill('SIGKILL');
92
+ await exit;
93
+ const response = await proxy.handle({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'alpha__GetSystemInfo', arguments: {} } });
94
+ assert.equal(response?.error, undefined, `self-healing must recover the call: ${JSON.stringify(response?.error)}`);
95
+ assert.notEqual(proxy.children[0].child, originalChild, 'a replacement child must have been spawned');
96
+ const events = (await readFile(log, 'utf8')).trim().split('\n').map(line => JSON.parse(line));
97
+ assert.ok(events.some(row => row.event === 'initialize' && row.destination === 'alpha'), 'replacement child must re-initialize');
98
+ assert.ok(events.some(row => row.event === 'call' && row.name === 'GetSystemInfo'), 'the retried call must reach the child');
99
+ });
100
+
50
101
  test('closes each Cloud Foundry route after child exit and on startup failure', async t => {
51
102
  const directory = await mkdtemp(join(tmpdir(), 'bas-cf-child-lifecycle-'));
52
103
  const log = join(directory, 'children.log');
@@ -89,7 +140,8 @@ test('closes each Cloud Foundry route after child exit and on startup failure',
89
140
  spawn: () => { throw new Error('spawn denied'); },
90
141
  log: () => {}
91
142
  });
92
- assert.throws(() => failedProxy.start(), /No destination child could be started/);
143
+ failedProxy.start();
144
+ await assert.rejects(() => failedProxy.starting, /No destination child could be started/);
93
145
  await new Promise(resolve => setImmediate(resolve));
94
146
  assert.equal(failedStartCloseCount, 1);
95
147
  });
@@ -302,9 +354,13 @@ test('returns JSON-RPC errors for unknown namespaces and logs child failures', a
302
354
  const child = proxy.children.find(entry => entry.destination.name === 'alpha').child;
303
355
  child.process.kill('SIGKILL');
304
356
  await new Promise(resolve => setTimeout(resolve, 25));
305
- const failed = await proxy.handle({ jsonrpc: '2.0', id: 7, method: 'tools/call', params: { name: 'alpha__GetSource' } });
306
- assert.equal(failed.error.code, -32001);
357
+ // A crashed child no longer fails the call: self-healing restarts it and
358
+ // retries once, so the request goes through to a fresh child.
359
+ const healed = await proxy.handle({ jsonrpc: '2.0', id: 7, method: 'tools/call', params: { name: 'alpha__GetSource' } });
360
+ assert.equal(healed?.error, undefined, `call must heal after child crash: ${JSON.stringify(healed?.error)}`);
361
+ assert.equal(healed.result.content[0].text, 'alpha:GetSource');
307
362
  assert.ok(logs.some(message => message.includes('alpha') && message.includes('tools/call GetSource failed')));
363
+ assert.ok(logs.some(message => message.includes('self-healing restart complete')), 'restart must be logged');
308
364
  });
309
365
  test('stdin EOF shuts down every child process and forwards child logs', async t => {
310
366
  const { directory, log, proxy } = await fixtureProxy();
@@ -6,6 +6,8 @@ import { join } from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { installMcpConfig } from '../src/mcp-config.mjs';
8
8
  import { destinationTable } from '../scripts/postinstall.mjs';
9
+ import { readCredentials, resolveCredentialsPath, storeDestinationCredentials } from '../src/credentials-store.mjs';
10
+ import { credentialKeyForDestination } from '../src/credential-overrides.mjs';
9
11
  import { spawnWithPty } from './pty.mjs';
10
12
 
11
13
  const setupModule = fileURLToPath(new URL('../src/setup.mjs', import.meta.url));
@@ -28,6 +30,22 @@ function cloudFoundryDestination({ name, serverName, instanceGuid, instanceName,
28
30
  };
29
31
  }
30
32
 
33
+ function cloudFoundryOnPremiseDestination() {
34
+ const destination = cloudFoundryDestination({
35
+ name: 'shared-name',
36
+ serverName: 'cf:space-one:cf-destination:shared-name',
37
+ instanceGuid: 'cf-destination',
38
+ instanceName: 'destination-service',
39
+ keyName: 'destination-key'
40
+ });
41
+ destination.authentication = 'BasicAuthentication';
42
+ destination.proxyType = 'OnPremise';
43
+ destination.cf.connectivityInstanceGuid = 'cf-connectivity';
44
+ destination.cf.connectivityInstanceName = 'connectivity-service';
45
+ destination.cf.connectivityKeyName = 'connectivity-key';
46
+ return destination;
47
+ }
48
+
31
49
  async function makeFixture(t, { version = 'cf version 8.18.0', space = 'space-one', deleteMode = 'success' } = {}) {
32
50
  const directory = await mkdtemp(join(tmpdir(), 'bas-cf-setup-'));
33
51
  const bin = join(directory, 'bin');
@@ -63,6 +81,7 @@ await runSetup({
63
81
  await writeFile(process.env.CF_DISCOVERY_LOG, JSON.stringify({ spaceGuid, managedKeys }));
64
82
  return JSON.parse(process.env.TEST_CF_RESULT);
65
83
  },
84
+ verifyOnPremCredentialRoute: async () => JSON.parse(process.env.TEST_ONPREM_CSRF_RESULT),
66
85
  ...(process.env.TEST_INSTALL_FAIL === 'true' ? {
67
86
  install: async () => { throw new Error('simulated config write failure'); }
68
87
  } : {})
@@ -81,13 +100,14 @@ await runSetup({
81
100
  TEST_CF_DELETE_MODE: deleteMode,
82
101
  TEST_INSTALL_FAIL: 'false',
83
102
  TEST_BAS_DESTINATIONS: JSON.stringify([]),
84
- TEST_CF_RESULT: JSON.stringify({ destinations: [], createdKeys: [], warnings: [] })
103
+ TEST_CF_RESULT: JSON.stringify({ destinations: [], createdKeys: [], warnings: [] }),
104
+ TEST_ONPREM_CSRF_RESULT: JSON.stringify({ httpStatus: 200, tokenReceived: true, cookieCount: 1 })
85
105
  };
86
106
  t.after(() => rm(directory, { recursive: true, force: true }));
87
107
  return { directory, configPath, callLog, discoveryLog, harness, env };
88
108
  }
89
109
 
90
- function runSetupInPty(fixture, { importAnswer = 'y\r', selection = ' \r' } = {}) {
110
+ function runSetupInPty(fixture, { importAnswer = 'y\r', selection = ' \r', promptAnswers = [] } = {}) {
91
111
  return new Promise((resolve, reject) => {
92
112
  const command = `${JSON.stringify(process.execPath)} ${JSON.stringify(fixture.harness)}`;
93
113
  const child = spawnWithPty(command, { env: fixture.env, stdio: ['pipe', 'pipe', 'pipe'] });
@@ -95,6 +115,7 @@ function runSetupInPty(fixture, { importAnswer = 'y\r', selection = ' \r' } = {}
95
115
  let stderr = '';
96
116
  let importSent = false;
97
117
  let selectionSent = false;
118
+ let promptAnswerIndex = 0;
98
119
  const timeout = setTimeout(() => child.kill('SIGKILL'), 15000);
99
120
  child.stdout.on('data', chunk => {
100
121
  stdout += chunk.toString();
@@ -104,7 +125,16 @@ function runSetupInPty(fixture, { importAnswer = 'y\r', selection = ' \r' } = {}
104
125
  }
105
126
  if (!selectionSent && stdout.includes('Select destinations')) {
106
127
  selectionSent = true;
107
- child.stdin.end(selection);
128
+ if (promptAnswers.length) child.stdin.write(selection);
129
+ else child.stdin.end(selection);
130
+ }
131
+ if (selectionSent && promptAnswerIndex < promptAnswers.length) {
132
+ const answer = promptAnswers[promptAnswerIndex];
133
+ if (stdout.includes(answer.when)) {
134
+ promptAnswerIndex++;
135
+ child.stdin.write(answer.value);
136
+ if (promptAnswerIndex === promptAnswers.length) child.stdin.end();
137
+ }
108
138
  }
109
139
  });
110
140
  child.stderr.on('data', chunk => { stderr += chunk.toString(); });
@@ -114,7 +144,7 @@ function runSetupInPty(fixture, { importAnswer = 'y\r', selection = ' \r' } = {}
114
144
  });
115
145
  child.on('exit', (code, signal) => {
116
146
  clearTimeout(timeout);
117
- resolve({ code, signal, stdout, stderr, importSent, selectionSent });
147
+ resolve({ code, signal, stdout, stderr, importSent, selectionSent, promptAnswersSent: promptAnswerIndex });
118
148
  });
119
149
  });
120
150
  }
@@ -145,6 +175,7 @@ test('imports selected CF destinations, passes managed key references, and remov
145
175
  assert.equal(result.selectionSent, true, result.stdout);
146
176
  assert.match(result.stdout, /CF new-destination-service/);
147
177
  assert.match(result.stdout, /Configured 1 MCP server/);
178
+ assert.match(result.stdout, /GitHub Copilot Chat.*Chat tools picker.*enable each selected destination server/s);
148
179
  assert.doesNotMatch(`${result.stdout}\n${result.stderr}`, /SECRET|clientSecret|Password/);
149
180
 
150
181
  const snapshot = JSON.parse(await readFile(fixture.discoveryLog, 'utf8'));
@@ -163,6 +194,102 @@ test('imports selected CF destinations, passes managed key references, and remov
163
194
  assert.equal(calls.some(args => args.at(-1) === 'new-key'), false);
164
195
  });
165
196
 
197
+ test('prompts for BAS and CF OnPremise overrides separately and keeps their routes distinct', async t => {
198
+ const fixture = await makeFixture(t);
199
+ const cfDestination = cloudFoundryOnPremiseDestination();
200
+ fixture.env.TEST_BAS_DESTINATIONS = JSON.stringify([
201
+ {
202
+ name: 'internet-system', url: 'http://internet-system.dest', backendUrl: 'https://internet.example:44300',
203
+ client: '100', authentication: 'BasicAuthentication', proxyType: 'Internet',
204
+ probe: { status: 'available', available: true }
205
+ },
206
+ {
207
+ name: 'shared-name', url: 'http://shared-name.dest', backendUrl: 'https://internal.example:44300',
208
+ client: '101', authentication: 'BasicAuthentication', proxyType: 'OnPremise',
209
+ probe: { status: 'available', available: true }
210
+ },
211
+ {
212
+ name: 'principal-system', url: 'http://principal-system.dest',
213
+ client: '102', authentication: 'PrincipalPropagation', proxyType: 'OnPremise',
214
+ probe: { status: 'available', available: true }
215
+ }
216
+ ]);
217
+ fixture.env.TEST_CF_RESULT = JSON.stringify({ destinations: [cfDestination], createdKeys: [], warnings: [] });
218
+
219
+ const result = await runSetupInPty(fixture, {
220
+ selection: 'a\r',
221
+ promptAnswers: [
222
+ { when: 'BAS Internet — internet-system — override SAP credentials?', value: 'y\r' },
223
+ { when: 'BAS Internet — internet-system — SAP user', value: 'INTERNET_USER\r' },
224
+ { when: 'BAS Internet — internet-system — SAP password', value: 'internet-password-secret\r' },
225
+ { when: 'BAS OnPremise — shared-name — override SAP credentials?', value: 'y\r' },
226
+ { when: 'BAS OnPremise — shared-name — SAP user', value: 'BAS_CC_USER\r' },
227
+ { when: 'BAS OnPremise — shared-name — SAP password', value: 'bas-cc-password-secret\r' },
228
+ { when: 'Cloud Foundry OnPremise — shared-name — override SAP credentials?', value: 'y\r' },
229
+ { when: 'Cloud Foundry OnPremise — shared-name — SAP user', value: 'CF_CC_USER\r' },
230
+ { when: 'Cloud Foundry OnPremise — shared-name — SAP password', value: 'cf-cc-password-secret\r' }
231
+ ]
232
+ });
233
+ assert.equal(result.code, 0, `${result.stdout}\n${result.stderr}`);
234
+ assert.equal(result.promptAnswersSent, 9, result.stdout);
235
+ assert.doesNotMatch(result.stdout, /PrincipalPropagation — principal-system — override SAP credentials/);
236
+ assert.doesNotMatch(`${result.stdout}\n${result.stderr}`, /internet-password-secret|bas-cc-password-secret|cf-cc-password-secret/);
237
+
238
+ const credentialsPath = await resolveCredentialsPath(fixture.env, fixture.configPath);
239
+ const stored = (await readCredentials(credentialsPath)).destinations;
240
+ assert.equal(stored['internet-system'].mode, 'direct');
241
+ assert.equal(stored['internet-system'].host, 'https://internet.example:44300');
242
+ assert.equal(stored['shared-name'].mode, 'bas-tunnel');
243
+ assert.equal(stored['shared-name'].host, undefined, 'BAS OnPremise must not store or use the backend URL');
244
+ const cfKey = credentialKeyForDestination(cfDestination);
245
+ assert.equal(stored[cfKey].mode, 'cf-connectivity');
246
+ assert.equal(stored[cfKey].host, undefined);
247
+ assert.deepEqual(
248
+ [stored['shared-name'].user, stored[cfKey].user],
249
+ ['BAS_CC_USER', 'CF_CC_USER'],
250
+ 'same destination names from BAS and CF must remain distinct'
251
+ );
252
+
253
+ const mcpConfig = JSON.parse(await readFile(fixture.configPath, 'utf8'));
254
+ assert.equal(JSON.stringify(mcpConfig).includes('bas-cc-password-secret'), false);
255
+ assert.equal(JSON.stringify(mcpConfig).includes('cf-cc-password-secret'), false);
256
+ });
257
+
258
+ test('No removes a stored BAS OnPremise override and failed CSRF preflight refuses a new one', async t => {
259
+ const fixture = await makeFixture(t);
260
+ fixture.env.TEST_BAS_DESTINATIONS = JSON.stringify([{
261
+ name: 'shared-name', url: 'http://shared-name.dest', backendUrl: 'https://internal.example:44300',
262
+ client: '100', authentication: 'BasicAuthentication', proxyType: 'OnPremise',
263
+ probe: { status: 'available', available: true }
264
+ }]);
265
+ fixture.env.TEST_CF_RESULT = JSON.stringify({ destinations: [], createdKeys: [], warnings: [] });
266
+ const credentialsPath = await resolveCredentialsPath(fixture.env, fixture.configPath);
267
+ await storeDestinationCredentials(credentialsPath, 'shared-name', {
268
+ user: 'OLD_USER', password: 'old-password-secret', mode: 'bas-tunnel'
269
+ });
270
+
271
+ const declined = await runSetupInPty(fixture, {
272
+ selection: ' \r',
273
+ promptAnswers: [{ when: 'BAS OnPremise — shared-name — override SAP credentials?', value: 'n\r' }]
274
+ });
275
+ assert.equal(declined.code, 0, `${declined.stdout}\n${declined.stderr}`);
276
+ assert.equal((await readCredentials(credentialsPath)).destinations['shared-name'], undefined);
277
+
278
+ fixture.env.TEST_ONPREM_CSRF_RESULT = JSON.stringify({ httpStatus: 200, tokenReceived: true, cookieCount: 0 });
279
+ const rejected = await runSetupInPty(fixture, {
280
+ selection: ' \r',
281
+ promptAnswers: [
282
+ { when: 'BAS OnPremise — shared-name — override SAP credentials?', value: 'y\r' },
283
+ { when: 'BAS OnPremise — shared-name — SAP user', value: 'NEW_USER\r' },
284
+ { when: 'BAS OnPremise — shared-name — SAP password', value: 'new-password-secret\r' }
285
+ ]
286
+ });
287
+ assert.equal(rejected.code, 0, `${rejected.stdout}\n${rejected.stderr}`);
288
+ assert.match(rejected.stdout, /did not return both a CSRF token and session cookie/);
289
+ assert.equal((await readCredentials(credentialsPath)).destinations['shared-name'], undefined);
290
+ assert.doesNotMatch(`${rejected.stdout}\n${rejected.stderr}`, /new-password-secret/);
291
+ });
292
+
166
293
  test('declining CF import keeps BAS setup and does not create CF keys', async t => {
167
294
  const fixture = await makeFixture(t);
168
295
  fixture.env.TEST_BAS_DESTINATIONS = JSON.stringify([{