sap-ai-dev-toolkit 0.5.0 → 0.5.6
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 -3
- package/.github/agents/abap-runtime-debugger.agent.md +3 -3
- package/.github/agents/hana-cloud-hdi-specialist.agent.md +1 -1
- package/.github/agents/rap-service-developer.agent.md +1 -1
- package/.github/agents/sap-solution-architect.agent.md +1 -1
- package/.github/skills/abap-debugging/SKILL.md +3 -3
- package/.github/skills/abap-development/SKILL.md +1 -1
- package/.github/skills/abap-runtime-analysis/SKILL.md +3 -3
- package/.github/skills/abap-testing-quality/SKILL.md +1 -1
- package/.github/skills/cds-development/SKILL.md +1 -1
- package/.github/skills/clean-core-extensibility/SKILL.md +1 -1
- package/.github/skills/hana-cloud-inspection/SKILL.md +1 -1
- package/.github/skills/hana-cloud-native-development/SKILL.md +2 -2
- package/.github/skills/hana-cloud-validation/SKILL.md +1 -1
- package/.github/skills/rap-development/SKILL.md +1 -1
- package/.github/skills/rap-service-delivery/SKILL.md +1 -1
- package/.github/skills/sap-sdlc-orchestration/SKILL.md +1 -1
- package/.github/skills/sap-standard-api-analysis/SKILL.md +1 -1
- package/.github/skills/sap-transport-release/SKILL.md +1 -1
- package/README.md +17 -11
- package/inventory.md +179 -0
- package/package.json +2 -2
- package/scripts/build-vsp.mjs +2 -1
- package/scripts/postinstall.mjs +17 -18
- package/src/bas-destination-relay.mjs +58 -22
- package/src/bas-discovery.mjs +24 -8
- package/src/binary.mjs +101 -17
- package/src/cf-destination.mjs +55 -1
- package/src/credentials-store.mjs +0 -10
- package/src/engineering-tools.mjs +32 -10
- package/src/hana-tools.mjs +9 -2
- package/src/launcher.mjs +22 -19
- package/src/mcp-config.mjs +41 -7
- package/src/mcp-proxy.mjs +392 -104
- package/src/redact.mjs +28 -0
- package/src/setup.mjs +33 -3
- package/tools.md +4 -2
- package/test/bas-destination-relay.test.mjs +0 -374
- package/test/cf-connectivity.test.mjs +0 -287
- package/test/cf-destination.test.mjs +0 -307
- package/test/cf-runtime.test.mjs +0 -527
- package/test/copilot-assets.test.mjs +0 -63
- package/test/copilot-content.test.mjs +0 -249
- package/test/credentials-store.test.mjs +0 -187
- package/test/discovery.test.mjs +0 -257
- package/test/fixtures/fake-vsp.mjs +0 -263
- package/test/hana-config.test.mjs +0 -168
- package/test/hana-inspector-stdio.test.mjs +0 -44
- package/test/hana-tools.test.mjs +0 -206
- package/test/launcher.test.mjs +0 -399
- package/test/live-s4h.test.mjs +0 -202
- package/test/mcp-config-cf.test.mjs +0 -407
- package/test/mcp-proxy.test.mjs +0 -409
- package/test/pty.mjs +0 -8
- package/test/setup-cf.test.mjs +0 -489
- package/test/setup.test.mjs +0 -417
- package/test/terminal-ui.test.mjs +0 -47
package/test/discovery.test.mjs
DELETED
|
@@ -1,257 +0,0 @@
|
|
|
1
|
-
import test from 'node:test';
|
|
2
|
-
import assert from 'node:assert/strict';
|
|
3
|
-
import { createServer } from 'node:http';
|
|
4
|
-
import { normalizeDestination, destinationUrl, discoverDestinations, fetchDestinationList, redact, sanitizeChildEnv, slugifyDestination } from '../src/bas-discovery.mjs';
|
|
5
|
-
|
|
6
|
-
test('normalizes destination metadata case-insensitively', () => {
|
|
7
|
-
const destination = normalizeDestination({
|
|
8
|
-
Name: 'DEV-ABAP',
|
|
9
|
-
Properties: { 'WebIDEEnabled': 'true', 'HTML5.DynamicDestination': true, 'WebIDEUsage': 'dev_abap', 'SAP-Client': '200', Authentication: 'PrincipalPropagation', URL: 'https://backend.example' }
|
|
10
|
-
});
|
|
11
|
-
assert.deepEqual(destination, {
|
|
12
|
-
name: 'DEV-ABAP', authentication: 'PrincipalPropagation', client: '200',
|
|
13
|
-
url: 'http://DEV-ABAP.dest', backendUrl: 'https://backend.example', proxyType: null, rawKeys: ['Name', 'Properties']
|
|
14
|
-
});
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
test('returns every named destination regardless of ADT probe and preserves allowlists', async () => {
|
|
18
|
-
const body = { DESTINATIONS: [
|
|
19
|
-
{ Name: 'z-system', WebIDEEnabled: true, 'HTML5.DynamicDestination': false, WebIDEUsage: 'ui5' },
|
|
20
|
-
{ Name: 'b-system', WebIDEEnabled: false, 'HTML5.DynamicDestination': false, WebIDEUsage: 'ui5' },
|
|
21
|
-
{ Name: 'a-system', WebIDEEnabled: false, 'HTML5.DynamicDestination': false, WebIDEUsage: 'ui5' }
|
|
22
|
-
] };
|
|
23
|
-
const probeImpl = async url => ({ status: url.includes('a-system') ? 403 : url.includes('b-system') ? 200 : 404 });
|
|
24
|
-
const allowlisted = await discoverDestinations({
|
|
25
|
-
body,
|
|
26
|
-
env: { SAP_AI_DEV_TOOLKIT_DESTINATION: 'a-system,b-system' },
|
|
27
|
-
probeImpl
|
|
28
|
-
});
|
|
29
|
-
assert.deepEqual(allowlisted.map(item => [item.name, item.client, item.probe.status]), [
|
|
30
|
-
['a-system', '001', 'auth-required'],
|
|
31
|
-
['b-system', '001', 'available']
|
|
32
|
-
]);
|
|
33
|
-
|
|
34
|
-
const all = await discoverDestinations({ body, env: {}, probeImpl });
|
|
35
|
-
assert.deepEqual(all.map(item => [item.name, item.probe.status]), [
|
|
36
|
-
['a-system', 'auth-required'],
|
|
37
|
-
['b-system', 'available'],
|
|
38
|
-
['z-system', 'not-found']
|
|
39
|
-
]);
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
test('accepts the previous destination environment variable during upgrades', async () => {
|
|
43
|
-
const destinations = await discoverDestinations({
|
|
44
|
-
body: [{ Name: 'legacy-system' }, { Name: 'other-system' }],
|
|
45
|
-
env: { BAS_VSP_DESTINATION: 'legacy-system' },
|
|
46
|
-
skipProbe: true
|
|
47
|
-
});
|
|
48
|
-
assert.deepEqual(destinations.map(destination => destination.name), ['legacy-system']);
|
|
49
|
-
});
|
|
50
|
-
test('parses sample-style destination records and enables every ADT heartbeat response', async () => {
|
|
51
|
-
const body = [
|
|
52
|
-
{
|
|
53
|
-
Type: 'HTTP',
|
|
54
|
-
'HTML5.DynamicDestination': 'true',
|
|
55
|
-
Authentication: 'PrincipalPropagation',
|
|
56
|
-
'sap-client': '200',
|
|
57
|
-
Name: 'DGT_200',
|
|
58
|
-
WebIDEUsage: 'odata_abap,ui5_execute_abap,dev_abap',
|
|
59
|
-
WebIDEEnabled: 'true'
|
|
60
|
-
},
|
|
61
|
-
{
|
|
62
|
-
Name: 'S4D_100',
|
|
63
|
-
Type: 'HTTP',
|
|
64
|
-
Authentication: 'PrincipalPropagation',
|
|
65
|
-
'HTML5.DynamicDestination': 'true',
|
|
66
|
-
'sap-client': '100',
|
|
67
|
-
WebIDEUsage: 'odata_abap',
|
|
68
|
-
WebIDEEnabled: 'true'
|
|
69
|
-
},
|
|
70
|
-
{
|
|
71
|
-
Name: 'SUP_BACKEND',
|
|
72
|
-
Type: 'HTTP',
|
|
73
|
-
Authentication: 'OAuth2ClientCredentials',
|
|
74
|
-
'HTML5.DynamicDestination': 'true',
|
|
75
|
-
WebIDEUsage: 'true',
|
|
76
|
-
WebIDEEnabled: 'true'
|
|
77
|
-
}
|
|
78
|
-
];
|
|
79
|
-
const probed = [];
|
|
80
|
-
const destinations = await discoverDestinations({
|
|
81
|
-
body,
|
|
82
|
-
env: {},
|
|
83
|
-
probeImpl: async url => {
|
|
84
|
-
probed.push(url);
|
|
85
|
-
if (url.includes('DGT_200.dest')) return { status: 200 };
|
|
86
|
-
if (url.includes('S4D_100.dest')) return { status: 403 };
|
|
87
|
-
return { status: 404 };
|
|
88
|
-
}
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
assert.deepEqual(probed, [
|
|
92
|
-
'http://DGT_200.dest/sap/bc/adt/discovery',
|
|
93
|
-
'http://S4D_100.dest/sap/bc/adt/discovery',
|
|
94
|
-
'http://SUP_BACKEND.dest/sap/bc/adt/discovery'
|
|
95
|
-
]);
|
|
96
|
-
assert.deepEqual(destinations.map(({ name, client, authentication, probe }) => [
|
|
97
|
-
name, client, authentication, probe.status, probe.available
|
|
98
|
-
]), [
|
|
99
|
-
['DGT_200', '200', 'PrincipalPropagation', 'available', true],
|
|
100
|
-
['S4D_100', '100', 'PrincipalPropagation', 'auth-required', true],
|
|
101
|
-
['SUP_BACKEND', '001', 'OAuth2ClientCredentials', 'not-found', false]
|
|
102
|
-
]);
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
test('keeps T4D destinations when their ADT probes fail', async () => {
|
|
106
|
-
const destinations = await discoverDestinations({
|
|
107
|
-
body: [
|
|
108
|
-
{ Name: 'T4D_100', 'sap-client': '100' },
|
|
109
|
-
{ Name: 'T4D_200', 'sap-client': '200' }
|
|
110
|
-
],
|
|
111
|
-
env: {},
|
|
112
|
-
probeImpl: async url => ({ status: url.includes('T4D_100') ? 503 : 404 })
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
assert.deepEqual(destinations.map(({ name, client, probe }) => [
|
|
116
|
-
name, client, probe.status, probe.httpStatus, probe.available
|
|
117
|
-
]), [
|
|
118
|
-
['T4D_100', '100', 'unavailable', 503, false],
|
|
119
|
-
['T4D_200', '200', 'not-found', 404, false]
|
|
120
|
-
]);
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
test('rejects invalid destination URL names and creates deterministic collision slugs', () => {
|
|
125
|
-
assert.equal(destinationUrl('A-1_2'), 'http://A-1_2.dest');
|
|
126
|
-
assert.throws(() => destinationUrl('backend.example/path'), /Invalid BAS destination name/);
|
|
127
|
-
const used = new Map();
|
|
128
|
-
assert.deepEqual(['Dev System', 'dev-system', 'DEV_SYSTEM'].map(name => slugifyDestination(name, used)), ['dev-system', 'dev-system-2', 'dev-system-3']);
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
test('sanitizes child identity material and preserves caller proxy safety settings', () => {
|
|
132
|
-
const env = sanitizeChildEnv({ HTTP_PROXY: 'http://proxy:8887', NO_PROXY: 'localhost,.dest,127.0.0.1', Authorization: 'Bearer secret', Cookie: 'SAP_SESSION=secret', SAP_USER: 'alice', SAP_PASSWORD: 'secret', SAP_READ_ONLY: 'true' });
|
|
133
|
-
assert.equal(env.SAP_PROXY_CONTEXTID_GUARD, 'true');
|
|
134
|
-
assert.equal(env.HTTP_PROXY, 'http://proxy:8887');
|
|
135
|
-
assert.equal(env.NO_PROXY, 'localhost,127.0.0.1');
|
|
136
|
-
assert.equal(env.SAP_READ_ONLY, 'true');
|
|
137
|
-
assert.equal(env.Authorization, undefined);
|
|
138
|
-
assert.equal(env.Cookie, undefined);
|
|
139
|
-
assert.equal(env.SAP_USER, undefined);
|
|
140
|
-
assert.equal(env.SAP_PASSWORD, undefined);
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
test('probes .dest only through configured HTTP proxy and accepts proxy-auth responses', async () => {
|
|
144
|
-
const requests = [];
|
|
145
|
-
const proxy = createServer((request, response) => {
|
|
146
|
-
requests.push({ host: request.headers.host, path: request.url });
|
|
147
|
-
response.writeHead(401);
|
|
148
|
-
response.end();
|
|
149
|
-
});
|
|
150
|
-
await new Promise(resolve => proxy.listen(0, '127.0.0.1', resolve));
|
|
151
|
-
const port = proxy.address().port;
|
|
152
|
-
const found = await discoverDestinations({
|
|
153
|
-
body: [{ Name: 'proxy-system' }],
|
|
154
|
-
env: { HTTP_PROXY: `http://127.0.0.1:${port}` }
|
|
155
|
-
});
|
|
156
|
-
await new Promise(resolve => proxy.close(resolve));
|
|
157
|
-
assert.equal(found[0].probe.status, 'auth-required');
|
|
158
|
-
assert.deepEqual(requests, [{ host: 'proxy-system.dest', path: 'http://proxy-system.dest/sap/bc/adt/discovery' }]);
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
test('uses the documented branded HTTP proxy for destination probes and respects explicit direct mode', async t => {
|
|
162
|
-
const requests = [];
|
|
163
|
-
const proxy = createServer((request, response) => {
|
|
164
|
-
requests.push({ host: request.headers.host, path: request.url });
|
|
165
|
-
response.writeHead(200);
|
|
166
|
-
response.end('<ok/>');
|
|
167
|
-
});
|
|
168
|
-
await new Promise(resolve => proxy.listen(0, '127.0.0.1', resolve));
|
|
169
|
-
t.after(() => new Promise(resolve => proxy.close(resolve)));
|
|
170
|
-
const proxyUrl = `http://127.0.0.1:${proxy.address().port}`;
|
|
171
|
-
const found = await discoverDestinations({
|
|
172
|
-
body: [{ Name: 'branded-proxy-system' }],
|
|
173
|
-
env: { HTTP_PROXY: 'http://127.0.0.1:1', SAP_AI_DEV_TOOLKIT_HTTP_PROXY: proxyUrl }
|
|
174
|
-
});
|
|
175
|
-
assert.equal(found[0].probe.status, 'available');
|
|
176
|
-
assert.deepEqual(requests, [{ host: 'branded-proxy-system.dest', path: 'http://branded-proxy-system.dest/sap/bc/adt/discovery' }]);
|
|
177
|
-
|
|
178
|
-
const direct = await discoverDestinations({
|
|
179
|
-
body: [{ Name: 'direct-system' }],
|
|
180
|
-
env: { HTTP_PROXY: proxyUrl, SAP_AI_DEV_TOOLKIT_HTTP_PROXY: '' },
|
|
181
|
-
probeImpl: async (_url, options) => {
|
|
182
|
-
assert.equal(options.proxyUrl, '', 'explicit empty branded proxy must bypass the configured proxy');
|
|
183
|
-
return { status: 200 };
|
|
184
|
-
}
|
|
185
|
-
});
|
|
186
|
-
assert.equal(direct[0].probe.status, 'available');
|
|
187
|
-
assert.equal(requests.length, 1);
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
test('fetches BAS destination lists through configured HTTP and HTTPS proxies', async t => {
|
|
191
|
-
const proxyRequests = [];
|
|
192
|
-
const directRequests = [];
|
|
193
|
-
const target = createServer((request, response) => {
|
|
194
|
-
directRequests.push({ method: request.method, host: request.headers.host, path: request.url });
|
|
195
|
-
response.writeHead(200, { 'content-type': 'application/json' });
|
|
196
|
-
response.end(JSON.stringify([{ Name: 'direct-system' }]));
|
|
197
|
-
});
|
|
198
|
-
await new Promise(resolve => target.listen(0, '127.0.0.1', resolve));
|
|
199
|
-
const proxy = createServer((request, response) => {
|
|
200
|
-
proxyRequests.push({ method: request.method, host: request.headers.host, path: request.url });
|
|
201
|
-
response.writeHead(200, { 'content-type': 'application/json' });
|
|
202
|
-
response.end(JSON.stringify([{ Name: 'proxied-system' }]));
|
|
203
|
-
});
|
|
204
|
-
proxy.on('connect', (request, socket) => {
|
|
205
|
-
proxyRequests.push({ method: request.method, host: request.url });
|
|
206
|
-
socket.end('HTTP/1.1 502 Bad Gateway\r\n\r\n');
|
|
207
|
-
});
|
|
208
|
-
await new Promise(resolve => proxy.listen(0, '127.0.0.1', resolve));
|
|
209
|
-
t.after(async () => {
|
|
210
|
-
await Promise.all([target, proxy].map(server => new Promise(resolve => server.close(resolve))));
|
|
211
|
-
});
|
|
212
|
-
const proxyUrl = `http://127.0.0.1:${proxy.address().port}`;
|
|
213
|
-
const env = { http_proxy: proxyUrl, https_proxy: proxyUrl, no_proxy: '' };
|
|
214
|
-
const body = await fetchDestinationList('http://bas.example', { env, timeoutMs: 1000 });
|
|
215
|
-
assert.deepEqual(body, [{ Name: 'proxied-system' }]);
|
|
216
|
-
await assert.rejects(fetchDestinationList('https://bas.example', { env, timeoutMs: 1000 }));
|
|
217
|
-
assert.deepEqual(proxyRequests, [
|
|
218
|
-
{ method: 'GET', host: 'bas.example', path: 'http://bas.example/api/listDestinations' },
|
|
219
|
-
{ method: 'CONNECT', host: 'bas.example:443' }
|
|
220
|
-
]);
|
|
221
|
-
const directBody = await fetchDestinationList(`http://127.0.0.1:${target.address().port}`, {
|
|
222
|
-
env: { HTTP_PROXY: proxyUrl, NO_PROXY: '127.0.0.1' },
|
|
223
|
-
timeoutMs: 1000
|
|
224
|
-
});
|
|
225
|
-
assert.deepEqual(directBody, [{ Name: 'direct-system' }]);
|
|
226
|
-
assert.deepEqual(directRequests, [{
|
|
227
|
-
method: 'GET',
|
|
228
|
-
host: `127.0.0.1:${target.address().port}`,
|
|
229
|
-
path: '/api/listDestinations'
|
|
230
|
-
}]);
|
|
231
|
-
assert.equal(proxyRequests.length, 2);
|
|
232
|
-
});
|
|
233
|
-
|
|
234
|
-
test('branded HTTP proxy takes precedence over HTTPS_PROXY for secure BAS discovery', async () => {
|
|
235
|
-
let capturedDispatcher = 'not-called';
|
|
236
|
-
const body = await fetchDestinationList('https://bas.example', {
|
|
237
|
-
env: {
|
|
238
|
-
SAP_AI_DEV_TOOLKIT_HTTP_PROXY: '',
|
|
239
|
-
HTTPS_PROXY: 'http://127.0.0.1:1',
|
|
240
|
-
https_proxy: 'http://127.0.0.1:1'
|
|
241
|
-
},
|
|
242
|
-
fetchImpl: async (_url, options) => {
|
|
243
|
-
capturedDispatcher = options.dispatcher;
|
|
244
|
-
return new Response(JSON.stringify([{ Name: 'secure-system' }]), {
|
|
245
|
-
status: 200,
|
|
246
|
-
headers: { 'content-type': 'application/json' }
|
|
247
|
-
});
|
|
248
|
-
}
|
|
249
|
-
});
|
|
250
|
-
assert.deepEqual(body, [{ Name: 'secure-system' }]);
|
|
251
|
-
assert.equal(capturedDispatcher, undefined, 'explicit branded empty proxy must disable inherited HTTPS_PROXY too');
|
|
252
|
-
});
|
|
253
|
-
|
|
254
|
-
test('redacts credential-bearing diagnostics without exposing payloads', () => {
|
|
255
|
-
const value = redact({ name: 'dev', password: 'secret', authorization: 'Bearer abc', nested: { cookie: 'x', ok: 'visible' } });
|
|
256
|
-
assert.deepEqual(value, { name: 'dev', password: '[redacted]', authorization: '[redacted]', nested: { cookie: '[redacted]', ok: 'visible' } });
|
|
257
|
-
});
|
|
@@ -1,263 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import { appendFileSync } from 'node:fs';
|
|
3
|
-
import { request as httpRequest } from 'node:http';
|
|
4
|
-
|
|
5
|
-
const args = process.argv.slice(2);
|
|
6
|
-
const mode = args[args.indexOf('--mode') + 1] || 'focused';
|
|
7
|
-
const url = args[args.indexOf('--url') + 1] || 'unknown';
|
|
8
|
-
const destination = url.replace(/^https?:\/\//, '').replace(/\.dest$/, '');
|
|
9
|
-
const logPath = process.env.FAKE_LOG;
|
|
10
|
-
function log(entry) { if (logPath) appendFileSync(logPath, `${JSON.stringify({ destination, ...entry })}\n`); }
|
|
11
|
-
function reply(id, result) { process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id, result })}\n`); }
|
|
12
|
-
function requestADTThroughProxy(targetUrl, proxyUrl, { method = 'GET', body = '', bypassProxyForLoopback = false } = {}) {
|
|
13
|
-
const target = new URL(targetUrl);
|
|
14
|
-
const proxy = new URL(proxyUrl);
|
|
15
|
-
if (target.protocol !== 'http:') throw new Error('fixture ADT request supports HTTP only');
|
|
16
|
-
const headers = { host: target.host, accept: 'application/xml,text/xml,*/*' };
|
|
17
|
-
if (body) headers['content-type'] = 'application/xml';
|
|
18
|
-
if (process.env.SAP_USER && process.env.SAP_PASSWORD) {
|
|
19
|
-
headers.authorization = `Basic ${Buffer.from(`${process.env.SAP_USER}:${process.env.SAP_PASSWORD}`).toString('base64')}`;
|
|
20
|
-
}
|
|
21
|
-
const loopback = bypassProxyForLoopback && ['127.0.0.1', 'localhost'].includes(target.hostname);
|
|
22
|
-
return new Promise((resolve, reject) => {
|
|
23
|
-
const requestOptions = loopback ? {
|
|
24
|
-
hostname: target.hostname,
|
|
25
|
-
port: Number(target.port),
|
|
26
|
-
method,
|
|
27
|
-
path: `${target.pathname}${target.search}`,
|
|
28
|
-
headers
|
|
29
|
-
} : {
|
|
30
|
-
hostname: proxy.hostname,
|
|
31
|
-
port: Number(proxy.port),
|
|
32
|
-
method,
|
|
33
|
-
path: target.href,
|
|
34
|
-
headers
|
|
35
|
-
};
|
|
36
|
-
const request = httpRequest(requestOptions, response => {
|
|
37
|
-
let body = '';
|
|
38
|
-
response.setEncoding('utf8');
|
|
39
|
-
response.on('data', chunk => { body += chunk; });
|
|
40
|
-
response.on('end', () => resolve({ status: response.statusCode || 0, body }));
|
|
41
|
-
});
|
|
42
|
-
request.setTimeout(5_000, () => request.destroy(new Error('fixture ADT request timed out')));
|
|
43
|
-
request.on('error', reject);
|
|
44
|
-
if (body) request.write(body);
|
|
45
|
-
request.end();
|
|
46
|
-
});
|
|
47
|
-
}
|
|
48
|
-
const toolNames = [
|
|
49
|
-
'GetSource',
|
|
50
|
-
'WriteSource',
|
|
51
|
-
'EditSource',
|
|
52
|
-
'SearchObject',
|
|
53
|
-
'GrepObjects',
|
|
54
|
-
'GrepPackages',
|
|
55
|
-
'FindDefinition',
|
|
56
|
-
'FindReferences',
|
|
57
|
-
'GetContext',
|
|
58
|
-
'SyntaxCheck',
|
|
59
|
-
'Activate',
|
|
60
|
-
'ActivatePackage',
|
|
61
|
-
'CompareSource',
|
|
62
|
-
'GetClassInfo',
|
|
63
|
-
'CreatePackage',
|
|
64
|
-
'CreateTable',
|
|
65
|
-
'GetTable',
|
|
66
|
-
'GetTableContents',
|
|
67
|
-
'GetTransport',
|
|
68
|
-
'ListTransports',
|
|
69
|
-
'GetTransportInfo',
|
|
70
|
-
'GetUserTransports',
|
|
71
|
-
'RunQuery',
|
|
72
|
-
'GetPackage',
|
|
73
|
-
'GetFunctionGroup',
|
|
74
|
-
'GetCDSDependencies',
|
|
75
|
-
'GetCDSImpactAnalysis',
|
|
76
|
-
'GetAPIReleaseState',
|
|
77
|
-
'GetCDSElementInfo',
|
|
78
|
-
'GetMessages',
|
|
79
|
-
'GetFeatures',
|
|
80
|
-
'PrettyPrint',
|
|
81
|
-
'GetSystemInfo',
|
|
82
|
-
'GetInstalledComponents',
|
|
83
|
-
'RunUnitTests',
|
|
84
|
-
'RunATCCheck',
|
|
85
|
-
'GetInactiveObjects',
|
|
86
|
-
'DeleteObject',
|
|
87
|
-
'DebuggerAttach',
|
|
88
|
-
'ListSQLTraces',
|
|
89
|
-
'CreateTransport',
|
|
90
|
-
'ReleaseTransport',
|
|
91
|
-
'DeleteTransport',
|
|
92
|
-
'AnalyzeABAPCode',
|
|
93
|
-
'AnalyzeCallGraph',
|
|
94
|
-
'CodeCompletion',
|
|
95
|
-
'GetAbapHelp',
|
|
96
|
-
'GetCallGraph',
|
|
97
|
-
'GetCalleesOf',
|
|
98
|
-
'GetCallersOf',
|
|
99
|
-
'GetCodeCoverage',
|
|
100
|
-
'GetConnectionInfo',
|
|
101
|
-
'GetObjectStructure',
|
|
102
|
-
'GetTypeHierarchy',
|
|
103
|
-
'GetTypeInfo',
|
|
104
|
-
'GrepObject',
|
|
105
|
-
'GrepPackage',
|
|
106
|
-
'CallRFC',
|
|
107
|
-
'DebuggerDetach',
|
|
108
|
-
'DebuggerGetStack',
|
|
109
|
-
'DebuggerGetVariables',
|
|
110
|
-
'DebuggerListen',
|
|
111
|
-
'DebuggerStep',
|
|
112
|
-
'DeleteBreakpoint',
|
|
113
|
-
'GetBreakpoints',
|
|
114
|
-
'GetDump',
|
|
115
|
-
'GetSQLTraceState',
|
|
116
|
-
'GetTrace',
|
|
117
|
-
'ListDumps',
|
|
118
|
-
'SetBreakpoint',
|
|
119
|
-
'CloneObject',
|
|
120
|
-
'CreateAndActivateProgram',
|
|
121
|
-
'CreateClassWithTests',
|
|
122
|
-
'CreateObject',
|
|
123
|
-
'CreateTestInclude',
|
|
124
|
-
'ExecuteABAP',
|
|
125
|
-
'GetClass',
|
|
126
|
-
'GetClassComponents',
|
|
127
|
-
'GetClassInclude',
|
|
128
|
-
'GetFunction',
|
|
129
|
-
'GetInclude',
|
|
130
|
-
'GetInterface',
|
|
131
|
-
'GetProgram',
|
|
132
|
-
'GetStructure',
|
|
133
|
-
'GetTransaction',
|
|
134
|
-
'LockObject',
|
|
135
|
-
'MoveObject',
|
|
136
|
-
'RecoverFailedCreate',
|
|
137
|
-
'RenameObject',
|
|
138
|
-
'SaveToFile',
|
|
139
|
-
'UnlockObject',
|
|
140
|
-
'UpdateClassInclude',
|
|
141
|
-
'UpdateSource',
|
|
142
|
-
'WriteClass',
|
|
143
|
-
'WriteProgram',
|
|
144
|
-
'ListDependencies',
|
|
145
|
-
'PublishServiceBinding',
|
|
146
|
-
'UnpublishServiceBinding'
|
|
147
|
-
];
|
|
148
|
-
if (mode === 'expert') toolNames.push('ActivateMultiple', 'SAP');
|
|
149
|
-
|
|
150
|
-
function toolDefinition(name) {
|
|
151
|
-
const schemas = {
|
|
152
|
-
RunQuery: {
|
|
153
|
-
type: 'object',
|
|
154
|
-
properties: {
|
|
155
|
-
sql_query: { type: 'string' },
|
|
156
|
-
max_rows: { type: 'number' },
|
|
157
|
-
all_rows: { type: 'boolean' }
|
|
158
|
-
},
|
|
159
|
-
required: ['sql_query']
|
|
160
|
-
},
|
|
161
|
-
GetAPIReleaseState: { type: 'object', properties: { object_uri: { type: 'string' } }, required: ['object_uri'] },
|
|
162
|
-
PrettyPrint: { type: 'object', properties: { source: { type: 'string' } }, required: ['source'] },
|
|
163
|
-
GetTransport: { type: 'object', properties: { transport: { type: 'string' } }, required: ['transport'] },
|
|
164
|
-
GetTransportInfo: {
|
|
165
|
-
type: 'object',
|
|
166
|
-
properties: { object_url: { type: 'string' }, dev_class: { type: 'string' } },
|
|
167
|
-
required: ['object_url', 'dev_class']
|
|
168
|
-
},
|
|
169
|
-
GetUserTransports: {
|
|
170
|
-
type: 'object',
|
|
171
|
-
properties: { user_name: { type: 'string' } },
|
|
172
|
-
required: []
|
|
173
|
-
},
|
|
174
|
-
ActivateMultiple: {
|
|
175
|
-
type: 'object',
|
|
176
|
-
properties: {
|
|
177
|
-
objects: {
|
|
178
|
-
type: 'array',
|
|
179
|
-
items: {
|
|
180
|
-
anyOf: [
|
|
181
|
-
{
|
|
182
|
-
type: 'object',
|
|
183
|
-
properties: { url: { type: 'string' }, name: { type: 'string' } },
|
|
184
|
-
required: ['url', 'name']
|
|
185
|
-
},
|
|
186
|
-
{ type: 'string' }
|
|
187
|
-
]
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
},
|
|
191
|
-
required: ['objects']
|
|
192
|
-
}
|
|
193
|
-
};
|
|
194
|
-
return { name, description: name, inputSchema: schemas[name] || { type: 'object', properties: {}, required: [] } };
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
let buffer = '';
|
|
198
|
-
process.stdin.setEncoding('utf8');
|
|
199
|
-
process.stdin.on('data', async chunk => {
|
|
200
|
-
buffer += chunk;
|
|
201
|
-
let end;
|
|
202
|
-
while ((end = buffer.indexOf('\n')) >= 0) {
|
|
203
|
-
const line = buffer.slice(0, end).trim();
|
|
204
|
-
buffer = buffer.slice(end + 1);
|
|
205
|
-
if (!line) continue;
|
|
206
|
-
const message = JSON.parse(line);
|
|
207
|
-
if (message.method === 'initialize') {
|
|
208
|
-
log({ event: 'initialize', argv: args, env: { guard: process.env.SAP_PROXY_CONTEXTID_GUARD, authorization: process.env.Authorization, cookie: process.env.Cookie, user: process.env.SAP_USER, password: process.env.SAP_PASSWORD, verbose: process.env.SAP_VERBOSE, httpProxy: process.env.HTTP_PROXY, httpsProxy: process.env.HTTPS_PROXY, noProxy: process.env.NO_PROXY, allowTransportableEdits: process.env.SAP_ALLOW_TRANSPORTABLE_EDITS } });
|
|
209
|
-
if (destination === 'broken') process.exit(2);
|
|
210
|
-
reply(message.id, { protocolVersion: '2024-11-05', capabilities: { tools: {} }, serverInfo: { name: destination, version: 'fixture' } });
|
|
211
|
-
} else if (message.method === 'tools/list') {
|
|
212
|
-
reply(message.id, message.params?.cursor
|
|
213
|
-
? { tools: toolNames.slice(15).map(toolDefinition) }
|
|
214
|
-
: { tools: toolNames.slice(0, 15).map(toolDefinition), nextCursor: 'page-2' });
|
|
215
|
-
} else if (message.method === 'tools/call') {
|
|
216
|
-
const name = message.params?.name;
|
|
217
|
-
log({ event: 'call', name, arguments: message.params?.arguments });
|
|
218
|
-
if (message.params?.arguments?.emitNotification) {
|
|
219
|
-
process.stdout.write(`${JSON.stringify({
|
|
220
|
-
jsonrpc: '2.0',
|
|
221
|
-
method: 'notifications/message',
|
|
222
|
-
params: { level: 'info', logger: 'fake-vsp', data: 'fixture log notification' }
|
|
223
|
-
})}\n`);
|
|
224
|
-
}
|
|
225
|
-
const transportTools = [
|
|
226
|
-
'ListTransports', 'GetTransport', 'GetUserTransports', 'GetTransportInfo',
|
|
227
|
-
'CreateTransport', 'ReleaseTransport', 'DeleteTransport'
|
|
228
|
-
];
|
|
229
|
-
const disabledTransportOperation = transportTools.includes(name)
|
|
230
|
-
&& (!args.includes('--enable-transports')
|
|
231
|
-
|| (['CreateTransport', 'ReleaseTransport', 'DeleteTransport'].includes(name) && args.includes('--transport-read-only')));
|
|
232
|
-
if (disabledTransportOperation) {
|
|
233
|
-
reply(message.id, { content: [{ type: 'text', text: 'transport operation is not enabled' }], isError: true });
|
|
234
|
-
} else if (message.params?.arguments?.object === 'RPC_ERROR') {
|
|
235
|
-
process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id: message.id, error: { code: -32042, message: 'backend connection refused; password=must-not-log' } })}\n`);
|
|
236
|
-
} else if (message.params?.arguments?.object === 'TOOL_ERROR') {
|
|
237
|
-
reply(message.id, { content: [{ type: 'text', text: 'backend timeout; token=must-not-log' }], isError: true });
|
|
238
|
-
} else if (process.env.FAKE_CF_ADT_TEST === 'true' && name === 'GetSystemInfo') {
|
|
239
|
-
try {
|
|
240
|
-
const target = new URL('/sap/bc/adt/discovery', url);
|
|
241
|
-
const proxy = process.env.HTTP_PROXY || process.env.http_proxy;
|
|
242
|
-
const adt = await requestADTThroughProxy(target.href, proxy);
|
|
243
|
-
reply(message.id, { content: [{ type: 'text', text: adt.body }], isError: false });
|
|
244
|
-
} catch {
|
|
245
|
-
reply(message.id, { content: [{ type: 'text', text: 'fixture ADT request failed' }], isError: true });
|
|
246
|
-
}
|
|
247
|
-
} else if (process.env.FAKE_RELAY_CSRF_TEST === 'true' && name === 'GetSystemInfo') {
|
|
248
|
-
try {
|
|
249
|
-
const target = new URL('/sap/bc/adt/datapreview/freestyle', url);
|
|
250
|
-
const proxy = process.env.HTTP_PROXY || process.env.http_proxy;
|
|
251
|
-
const adt = await requestADTThroughProxy(target.href, proxy, { method: 'POST', body: '<probe/>', bypassProxyForLoopback: true });
|
|
252
|
-
reply(message.id, { content: [{ type: 'text', text: `fixture ADT HTTP ${adt.status}: ${adt.body}` }], isError: adt.status < 200 || adt.status >= 300 });
|
|
253
|
-
} catch {
|
|
254
|
-
reply(message.id, { content: [{ type: 'text', text: 'fixture ADT write failed' }], isError: true });
|
|
255
|
-
}
|
|
256
|
-
} else {
|
|
257
|
-
reply(message.id, { content: [{ type: 'text', text: `${destination}:${name}` }], isError: false });
|
|
258
|
-
}
|
|
259
|
-
} else if (message.id !== undefined) reply(message.id, {});
|
|
260
|
-
}
|
|
261
|
-
});
|
|
262
|
-
process.stdin.on('end', () => { log({ event: 'end' }); });
|
|
263
|
-
process.on('SIGTERM', () => { log({ event: 'term' }); process.exit(0); });
|
|
@@ -1,168 +0,0 @@
|
|
|
1
|
-
import test from 'node:test';
|
|
2
|
-
import assert from 'node:assert/strict';
|
|
3
|
-
import { quoteHanaIdentifier, resolveHanaReadOnlyConfig } from '../src/hana-config.mjs';
|
|
4
|
-
import { hanaConnectionOptions, HanaDatabase } from '../src/hana-database.mjs';
|
|
5
|
-
|
|
6
|
-
const explicitEnv = {
|
|
7
|
-
HANA_RO_HOST: 'hana.example.test',
|
|
8
|
-
HANA_RO_PORT: '443',
|
|
9
|
-
HANA_RO_USER: 'INSPECTOR',
|
|
10
|
-
HANA_RO_PASSWORD: 'do-not-print-this',
|
|
11
|
-
HANA_RO_SCHEMA: 'HDI_CONTAINER_SCHEMA'
|
|
12
|
-
};
|
|
13
|
-
|
|
14
|
-
function vcapBinding(name, user = 'INSPECTOR') {
|
|
15
|
-
return {
|
|
16
|
-
name,
|
|
17
|
-
binding_name: name,
|
|
18
|
-
credentials: {
|
|
19
|
-
host: 'hana.example.test',
|
|
20
|
-
port: 443,
|
|
21
|
-
user,
|
|
22
|
-
password: 'vcap-secret-not-for-output',
|
|
23
|
-
schema: 'HDI_CONTAINER_SCHEMA',
|
|
24
|
-
hdi_user: 'HDI_DEPLOYER',
|
|
25
|
-
hdi_password: 'never-use-this'
|
|
26
|
-
}
|
|
27
|
-
};
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
test('resolves explicit read-only configuration and never disables TLS verification', () => {
|
|
31
|
-
const config = resolveHanaReadOnlyConfig(explicitEnv);
|
|
32
|
-
assert.equal(config.source, 'environment');
|
|
33
|
-
assert.equal(config.user, 'INSPECTOR');
|
|
34
|
-
assert.equal(config.password, 'do-not-print-this');
|
|
35
|
-
assert.equal(config.schema, 'HDI_CONTAINER_SCHEMA');
|
|
36
|
-
assert.equal(Object.isFrozen(config), true);
|
|
37
|
-
const options = hanaConnectionOptions(config);
|
|
38
|
-
assert.equal(options.encrypt, true);
|
|
39
|
-
assert.equal(options.sslValidateCertificate, true);
|
|
40
|
-
assert.equal(options.currentSchema, 'HDI_CONTAINER_SCHEMA');
|
|
41
|
-
assert.equal(options.pwd, 'do-not-print-this');
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
test('rejects incomplete explicit credentials instead of falling back to VCAP', () => {
|
|
45
|
-
assert.throws(() => resolveHanaReadOnlyConfig({
|
|
46
|
-
...explicitEnv,
|
|
47
|
-
HANA_RO_PASSWORD: '',
|
|
48
|
-
VCAP_SERVICES: JSON.stringify({ hana: [vcapBinding('fallback')] })
|
|
49
|
-
}), /Explicit HANA read-only configuration is incomplete/);
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
test('resolves one selected HANA VCAP binding without using HDI deployment credentials', () => {
|
|
53
|
-
const config = resolveHanaReadOnlyConfig({
|
|
54
|
-
VCAP_SERVICES: JSON.stringify({ hana: [vcapBinding('inspection-binding')] }),
|
|
55
|
-
HANA_RO_BINDING: 'inspection-binding'
|
|
56
|
-
});
|
|
57
|
-
assert.equal(config.source, 'vcap-services');
|
|
58
|
-
assert.equal(config.bindingName, 'inspection-binding');
|
|
59
|
-
assert.equal(config.serviceName, 'hana');
|
|
60
|
-
assert.equal(config.user, 'INSPECTOR');
|
|
61
|
-
assert.equal(config.schema, 'HDI_CONTAINER_SCHEMA');
|
|
62
|
-
assert.equal(config.password, 'vcap-secret-not-for-output');
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
test('fails closed when VCAP contains multiple HANA bindings', () => {
|
|
66
|
-
const env = {
|
|
67
|
-
VCAP_SERVICES: JSON.stringify({ hana: [vcapBinding('first'), vcapBinding('second')] })
|
|
68
|
-
};
|
|
69
|
-
assert.throws(() => resolveHanaReadOnlyConfig(env), /Multiple HANA VCAP bindings/);
|
|
70
|
-
assert.equal(resolveHanaReadOnlyConfig({ ...env, HANA_RO_BINDING: 'second' }).bindingName, 'second');
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
test('rejects an HDI-deployer-only binding and does not disclose its credentials', () => {
|
|
74
|
-
const deployerOnly = {
|
|
75
|
-
name: 'deployer-only',
|
|
76
|
-
credentials: {
|
|
77
|
-
host: 'hana.example.test',
|
|
78
|
-
port: 443,
|
|
79
|
-
schema: 'HDI_CONTAINER_SCHEMA',
|
|
80
|
-
hdi_user: 'HDI_DEPLOYER',
|
|
81
|
-
hdi_password: 'never-use-this'
|
|
82
|
-
}
|
|
83
|
-
};
|
|
84
|
-
assert.throws(() => resolveHanaReadOnlyConfig({
|
|
85
|
-
VCAP_SERVICES: JSON.stringify({ hana: [deployerOnly] })
|
|
86
|
-
}), error => {
|
|
87
|
-
assert.match(error.message, /separate read-only binding/);
|
|
88
|
-
assert.doesNotMatch(error.message, /never-use-this/);
|
|
89
|
-
return true;
|
|
90
|
-
});
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
test('rejects a VCAP binding that aliases its read-only and deployment usernames', () => {
|
|
94
|
-
const binding = vcapBinding('aliased-binding', 'HDI_DEPLOYER');
|
|
95
|
-
assert.throws(() => resolveHanaReadOnlyConfig({
|
|
96
|
-
VCAP_SERVICES: JSON.stringify({ hana: [binding] })
|
|
97
|
-
}), /aliases its read-only and HDI deployment users/);
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
test('rejects bad host, port, missing schema, and missing VCAP configuration', () => {
|
|
101
|
-
assert.throws(() => resolveHanaReadOnlyConfig({ ...explicitEnv, HANA_RO_HOST: 'https://hana.example.test' }), /hostname/);
|
|
102
|
-
assert.throws(() => resolveHanaReadOnlyConfig({ ...explicitEnv, HANA_RO_PORT: '70000' }), /between 1 and 65535/);
|
|
103
|
-
assert.throws(() => resolveHanaReadOnlyConfig({ ...explicitEnv, HANA_RO_SCHEMA: '' }), /incomplete/);
|
|
104
|
-
assert.throws(() => resolveHanaReadOnlyConfig({}), /Set HANA_RO_\*/);
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
test('quotes identifiers by doubling embedded quotes and rejects control characters', () => {
|
|
108
|
-
assert.equal(quoteHanaIdentifier('A"B'), '"A""B"');
|
|
109
|
-
assert.throws(() => quoteHanaIdentifier('bad\nname'), /identifier is invalid/);
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
test('serializes database calls, passes bind values, and redacts native driver errors', async () => {
|
|
113
|
-
const calls = [];
|
|
114
|
-
let disconnects = 0;
|
|
115
|
-
const connection = {
|
|
116
|
-
connect(options, callback) {
|
|
117
|
-
calls.push({ type: 'connect', options });
|
|
118
|
-
callback(null);
|
|
119
|
-
},
|
|
120
|
-
exec(sql, params, options, callback) {
|
|
121
|
-
calls.push({ type: 'exec', sql, params, options });
|
|
122
|
-
if (sql === 'FAIL') callback(Object.assign(new Error('password=do-not-print-this'), { code: 'HY000' }));
|
|
123
|
-
else callback(null, [{ OK: true }]);
|
|
124
|
-
},
|
|
125
|
-
disconnect(callback) {
|
|
126
|
-
disconnects += 1;
|
|
127
|
-
callback(null);
|
|
128
|
-
}
|
|
129
|
-
};
|
|
130
|
-
const db = new HanaDatabase(connection, resolveHanaReadOnlyConfig(explicitEnv));
|
|
131
|
-
await db.connect();
|
|
132
|
-
assert.deepEqual(await db.query('SELECT ? FROM DUMMY', ['value']), [{ OK: true }]);
|
|
133
|
-
await assert.rejects(db.query('FAIL'), error => {
|
|
134
|
-
assert.equal(error.name, 'HanaDatabaseError');
|
|
135
|
-
assert.match(error.message, /HANA error HY000/);
|
|
136
|
-
assert.doesNotMatch(error.message, /do-not-print-this/);
|
|
137
|
-
return true;
|
|
138
|
-
});
|
|
139
|
-
assert.equal(calls[0].options.sslValidateCertificate, true);
|
|
140
|
-
assert.equal(calls[1].params[0], 'value');
|
|
141
|
-
assert.deepEqual(calls[1].options, {});
|
|
142
|
-
await db.close();
|
|
143
|
-
await db.close();
|
|
144
|
-
assert.equal(disconnects, 1);
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
test('times out a hung read, requests cancellation, and retires its connection', async () => {
|
|
148
|
-
let cancels = 0;
|
|
149
|
-
let disconnects = 0;
|
|
150
|
-
const connection = {
|
|
151
|
-
connect(_options, callback) { callback(null); },
|
|
152
|
-
exec() {},
|
|
153
|
-
cancel() { cancels += 1; },
|
|
154
|
-
disconnect(callback) { disconnects += 1; callback(null); }
|
|
155
|
-
};
|
|
156
|
-
const db = new HanaDatabase(connection, resolveHanaReadOnlyConfig(explicitEnv), { queryTimeoutMs: 5 });
|
|
157
|
-
await db.connect();
|
|
158
|
-
await assert.rejects(db.query('SELECT HANG FROM DUMMY'), error => {
|
|
159
|
-
assert.equal(error.name, 'HanaQueryTimeoutError');
|
|
160
|
-
assert.match(error.message, /time limit/);
|
|
161
|
-
assert.doesNotMatch(error.message, /do-not-print-this/);
|
|
162
|
-
return true;
|
|
163
|
-
});
|
|
164
|
-
assert.equal(cancels, 1);
|
|
165
|
-
assert.equal(disconnects, 1);
|
|
166
|
-
assert.equal(db.unusable, true);
|
|
167
|
-
await assert.rejects(db.query('SELECT 1 FROM DUMMY'), /not available/);
|
|
168
|
-
});
|