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.
- package/.github/agents/abap-developer.agent.md +11 -7
- package/.github/agents/abap-runtime-debugger.agent.md +3 -1
- package/.github/agents/rap-service-developer.agent.md +12 -8
- package/.github/agents/sap-solution-architect.agent.md +5 -3
- package/.github/skills/abap-development/SKILL.md +6 -4
- package/.github/skills/rap-development/SKILL.md +6 -4
- package/.github/skills/rap-service-delivery/SKILL.md +7 -5
- package/.github/skills/sap-sdlc-orchestration/SKILL.md +5 -4
- package/README.md +27 -8
- package/package.json +2 -1
- package/scripts/install-local.mjs +115 -0
- package/src/bas-destination-relay.mjs +282 -0
- package/src/bas-discovery.mjs +4 -0
- package/src/credential-overrides.mjs +75 -0
- package/src/credentials-store.mjs +123 -0
- package/src/launcher.mjs +24 -4
- package/src/mcp-proxy.mjs +120 -32
- package/src/setup.mjs +115 -2
- package/src/terminal-ui.mjs +84 -0
- package/test/bas-destination-relay.test.mjs +238 -0
- package/test/cf-runtime.test.mjs +27 -5
- package/test/copilot-content.test.mjs +56 -0
- package/test/credentials-store.test.mjs +187 -0
- package/test/discovery.test.mjs +1 -1
- package/test/launcher.test.mjs +52 -0
- package/test/mcp-proxy.test.mjs +59 -3
- package/test/setup-cf.test.mjs +131 -4
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import http from 'node:http';
|
|
4
|
+
import net from 'node:net';
|
|
5
|
+
import { request as httpRequest } from 'node:http';
|
|
6
|
+
import { createBasDestinationRelay } from '../src/bas-destination-relay.mjs';
|
|
7
|
+
|
|
8
|
+
const UNSAFE_METHODS = ['POST', 'PUT', 'PATCH', 'DELETE'];
|
|
9
|
+
|
|
10
|
+
// Simulates SAP ADT semantics: a CSRF token is only valid when the unsafe
|
|
11
|
+
// request carries BOTH the token and the session cookies that were set
|
|
12
|
+
// together with it. This reproduces the 403 "CSRF token validation failed"
|
|
13
|
+
// seen on the BAS .dest proxy path before the relay paired sessions.
|
|
14
|
+
function sapLikeBackend({ neverAcceptUnsafe = false, rotateOnFetch = false } = {}) {
|
|
15
|
+
const state = { sessionCounter: 0, requests: [] };
|
|
16
|
+
const server = http.createServer((req, res) => {
|
|
17
|
+
state.requests.push({ method: req.method, path: req.url, headers: { ...req.headers } });
|
|
18
|
+
if (req.method === 'GET' && req.headers['x-csrf-token'] === 'Fetch') {
|
|
19
|
+
state.sessionCounter += 1;
|
|
20
|
+
res.writeHead(200, {
|
|
21
|
+
'x-csrf-token': `token-${state.sessionCounter}`,
|
|
22
|
+
'set-cookie': [`sap-session-${state.sessionCounter}=s${state.sessionCounter}; path=/`, 'sap-usercontext=sap-client=100; path=/']
|
|
23
|
+
});
|
|
24
|
+
res.end('<ok/>');
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
if (UNSAFE_METHODS.includes(req.method)) {
|
|
28
|
+
if (neverAcceptUnsafe) {
|
|
29
|
+
res.writeHead(403, { 'x-csrf-token': 'Required' });
|
|
30
|
+
res.end('CSRF token validation failed');
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
const token = req.headers['x-csrf-token'];
|
|
34
|
+
const cookie = String(req.headers.cookie || '');
|
|
35
|
+
const match = String(token || '').match(/^token-(\d+)$/);
|
|
36
|
+
// Without rotation any still-live token+cookie pair is accepted; with
|
|
37
|
+
// rotation only the newest issued session remains valid, matching SAP
|
|
38
|
+
// server-side session invalidation.
|
|
39
|
+
const valid = match && cookie.includes(`sap-session-${match[1]}=`) && (!rotateOnFetch || Number(match[1]) === state.sessionCounter);
|
|
40
|
+
if (!valid) {
|
|
41
|
+
res.writeHead(403, { 'x-csrf-token': 'Required' });
|
|
42
|
+
res.end('CSRF token validation failed');
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
res.writeHead(201);
|
|
46
|
+
res.end('<created/>');
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
res.writeHead(200);
|
|
50
|
+
res.end('<read/>');
|
|
51
|
+
});
|
|
52
|
+
return { state, server };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function startServer(server) {
|
|
56
|
+
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
|
57
|
+
return server.address().port;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function relayFetch(relayUrl, path, options = {}) {
|
|
61
|
+
const target = new URL(path, relayUrl);
|
|
62
|
+
return new Promise((resolve, reject) => {
|
|
63
|
+
const req = httpRequest(target, { method: options.method || 'GET', headers: options.headers || {} }, response => {
|
|
64
|
+
let body = '';
|
|
65
|
+
response.setEncoding('utf8');
|
|
66
|
+
response.on('data', chunk => { body += chunk; });
|
|
67
|
+
response.on('end', () => resolve({ status: response.statusCode, headers: response.headers, body }));
|
|
68
|
+
});
|
|
69
|
+
req.on('error', reject);
|
|
70
|
+
if (options.body) req.write(options.body);
|
|
71
|
+
req.end();
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function withBackend(options, handler) {
|
|
76
|
+
const { state, server } = sapLikeBackend(options || {});
|
|
77
|
+
const backendPort = await startServer(server);
|
|
78
|
+
const logs = [];
|
|
79
|
+
const relay = createBasDestinationRelay(
|
|
80
|
+
{ name: 'TEST', url: `http://127.0.0.1:${backendPort}`, authentication: 'BasicAuthentication' },
|
|
81
|
+
{
|
|
82
|
+
env: { HTTP_PROXY: '' },
|
|
83
|
+
fetchImpl: options?.fetchImpl || ((url, init) => globalThis.fetch(url, init)),
|
|
84
|
+
log: message => logs.push(message)
|
|
85
|
+
}
|
|
86
|
+
);
|
|
87
|
+
const relayUrl = await relay.ready;
|
|
88
|
+
try {
|
|
89
|
+
return await handler({ state, relay, relayUrl, logs });
|
|
90
|
+
} finally {
|
|
91
|
+
await relay.close();
|
|
92
|
+
await new Promise(resolve => server.close(resolve));
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
test('pairs CSRF token with its session cookies so unsafe requests succeed', async () => {
|
|
97
|
+
await withBackend(null, async ({ relayUrl, state }) => {
|
|
98
|
+
const response = await relayFetch(relayUrl, '/sap/bc/adt/oo/classes', { method: 'POST', headers: { accept: 'application/xml' }, body: '<x/>' });
|
|
99
|
+
assert.equal(response.status, 201, `expected 201, got ${response.status}: ${response.body}`);
|
|
100
|
+
const post = state.requests.find(request => request.method === 'POST');
|
|
101
|
+
assert.match(post.headers['x-csrf-token'], /^token-\d+$/);
|
|
102
|
+
assert.match(String(post.headers.cookie), /sap-session-\d+=/);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('reuses the cached CSRF session for subsequent unsafe requests', async () => {
|
|
107
|
+
await withBackend(null, async ({ relay, relayUrl, state }) => {
|
|
108
|
+
await relayFetch(relayUrl, '/sap/bc/adt/oo/classes', { method: 'POST', body: '<x/>' });
|
|
109
|
+
await relayFetch(relayUrl, '/sap/bc/adt/oo/classes', { method: 'POST', body: '<y/>' });
|
|
110
|
+
const fetches = state.requests.filter(request => request.headers['x-csrf-token'] === 'Fetch');
|
|
111
|
+
assert.equal(fetches.length, 1, 'second POST must reuse the cached session');
|
|
112
|
+
assert.equal(state.requests.filter(request => request.method === 'POST').length, 2);
|
|
113
|
+
assert.equal(relay.stats.csrfFetches, 1);
|
|
114
|
+
assert.equal(relay.stats.csrfRetries, 0);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('self-heals when SAP rotates the session: refetches token and cookies', async () => {
|
|
119
|
+
await withBackend({ rotateOnFetch: true }, async ({ relay, relayUrl, state }) => {
|
|
120
|
+
// A first POST establishes session token-1. SAP then invalidates it
|
|
121
|
+
// server-side (simulated by the rotation rule below).
|
|
122
|
+
await relayFetch(relayUrl, '/sap/bc/adt/oo/classes', { method: 'POST', body: '<x/>' });
|
|
123
|
+
state.sessionCounter += 1;
|
|
124
|
+
const response = await relayFetch(relayUrl, '/sap/bc/adt/oo/classes', { method: 'POST', body: '<y/>' });
|
|
125
|
+
assert.equal(response.status, 201, `rotated session must heal via refetch, got ${response.status}`);
|
|
126
|
+
assert.ok(relay.stats.csrfRetries >= 1, 'a retry must have been counted');
|
|
127
|
+
const fetches = state.requests.filter(request => request.headers['x-csrf-token'] === 'Fetch');
|
|
128
|
+
assert.ok(fetches.length >= 2, 'a fresh token fetch must have occurred');
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('stops retrying after bounded attempts and surfaces the failure', async () => {
|
|
133
|
+
await withBackend({ neverAcceptUnsafe: true }, async ({ relay, relayUrl, state }) => {
|
|
134
|
+
const response = await relayFetch(relayUrl, '/sap/bc/adt/oo/classes', { method: 'POST', body: '<x/>' });
|
|
135
|
+
assert.equal(response.status, 403);
|
|
136
|
+
const attempts = state.requests.filter(request => UNSAFE_METHODS.includes(request.method));
|
|
137
|
+
assert.equal(attempts.length, 1 + 3, 'one initial try plus three bounded retries');
|
|
138
|
+
assert.equal(relay.stats.csrfRetries, 3);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('GET requests pass through without CSRF handling', async () => {
|
|
143
|
+
await withBackend(null, async ({ relayUrl, state, relay }) => {
|
|
144
|
+
const response = await relayFetch(relayUrl, '/sap/bc/adt/discovery', { headers: { accept: 'application/xml' } });
|
|
145
|
+
assert.equal(response.status, 200);
|
|
146
|
+
assert.equal(state.requests.filter(request => request.headers['x-csrf-token']).length, 0);
|
|
147
|
+
assert.equal(relay.stats.csrfFetches, 0);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test('drops POST body headers from the token-fetch GET', async () => {
|
|
152
|
+
await withBackend(null, async ({ relayUrl, state }) => {
|
|
153
|
+
await relayFetch(relayUrl, '/sap/bc/adt/oo/classes', {
|
|
154
|
+
method: 'POST',
|
|
155
|
+
headers: { accept: 'application/xml', 'content-type': 'application/xml', 'content-length': '5' },
|
|
156
|
+
body: '<xy/>'
|
|
157
|
+
});
|
|
158
|
+
const fetchRequest = state.requests.find(request => request.headers['x-csrf-token'] === 'Fetch');
|
|
159
|
+
assert.equal(fetchRequest.headers['content-type'], undefined, 'content-type must not leak into token GET');
|
|
160
|
+
assert.equal(fetchRequest.headers['content-length'], undefined, 'content-length must not leak into token GET');
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test('falls back to a proxy tunnel when the absolute-form request is refused', async () => {
|
|
165
|
+
const fetchCalls = [];
|
|
166
|
+
const fetchImpl = (url, options = {}) => {
|
|
167
|
+
fetchCalls.push({ url: String(url), tunneled: Boolean(options.dispatcher && options.dispatcher.proxyTunnel !== false && options.dispatcher.constructor?.name === 'ProxyAgent' && options.dispatcher.__tunnelMarker !== false) });
|
|
168
|
+
// First call (absolute-form through proxy) is refused by the proxy itself.
|
|
169
|
+
if (fetchCalls.length === 1) return Promise.resolve(new Response('<gateway/>', { status: 502 }));
|
|
170
|
+
return Promise.resolve(new Response('<ok/>', { status: 200, headers: { 'x-csrf-token': 'tok' } }));
|
|
171
|
+
};
|
|
172
|
+
const { state, server } = sapLikeBackend();
|
|
173
|
+
void state;
|
|
174
|
+
const backendPort = await startServer(server);
|
|
175
|
+
const relay = createBasDestinationRelay(
|
|
176
|
+
{ name: 'TEST', url: `http://127.0.0.1:${backendPort}`, authentication: 'BasicAuthentication' },
|
|
177
|
+
{ env: { HTTP_PROXY: 'http://127.0.0.1:1' }, fetchImpl, log: () => {} }
|
|
178
|
+
);
|
|
179
|
+
const relayUrl = await relay.ready;
|
|
180
|
+
try {
|
|
181
|
+
const response = await relayFetch(relayUrl, '/sap/bc/adt/discovery');
|
|
182
|
+
assert.equal(response.status, 200);
|
|
183
|
+
assert.equal(relay.stats.tunnelFallbacks, 1, 'exactly one tunnel fallback must occur');
|
|
184
|
+
assert.equal(fetchCalls.length, 2, 'absolute-form attempt then one tunnel retry');
|
|
185
|
+
} finally {
|
|
186
|
+
await relay.close();
|
|
187
|
+
await new Promise(resolve => server.close(resolve));
|
|
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 => {
|
|
@@ -49,6 +49,12 @@ function assertMentionsLiveToolDiscovery(markdown, path) {
|
|
|
49
49
|
assert.match(markdown, /schema/i, `${path} must tell agents to follow live tool schemas`);
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
function assertDirectMcpUsage(markdown, path) {
|
|
53
|
+
assert.match(markdown, /Direct MCP invocation is mandatory/i, `${path} must require direct MCP calls`);
|
|
54
|
+
assert.match(markdown, /Do not launch `sap-ai-dev`.*MCP operations/i, `${path} must forbid terminal-driven MCP discovery/calls`);
|
|
55
|
+
assert.match(markdown, /host\/session binding issue/i, `${path} must report host tool-binding failures instead of using a CLI fallback`);
|
|
56
|
+
}
|
|
57
|
+
|
|
52
58
|
function assertValidationPolicy(markdown, path) {
|
|
53
59
|
assert.match(markdown, /ABAP Unit|RunUnitTests/i, `${path} must include ABAP Unit or test execution guidance`);
|
|
54
60
|
assert.match(markdown, /runtime validation|Self-validate|executable validation|reproduce/i, `${path} must require executable/runtime validation evidence`);
|
|
@@ -56,6 +62,18 @@ function assertValidationPolicy(markdown, path) {
|
|
|
56
62
|
assert.match(markdown, /report|Record|document/i, `${path} must require observed outcome reporting`);
|
|
57
63
|
}
|
|
58
64
|
|
|
65
|
+
function assertPlanFirstPolicy(markdown, path) {
|
|
66
|
+
assert.match(markdown, /[Pp]lan before implementing|[Pp]lanning phase before implementation/, `${path} must require a plan to be presented before implementation starts`);
|
|
67
|
+
assert.match(markdown, /wait for explicit (?:user )?approval|no SAP state-changing package proceeds before the user approves/i, `${path} must gate SAP state-changing implementation on explicit approval`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function assertLocalFirstPolicy(markdown, path) {
|
|
71
|
+
assert.match(markdown, /abapGit[- ]serialized|abapGit-style/, `${path} must require local abapGit-style source files`);
|
|
72
|
+
assert.match(markdown, /object\.type\.extension/, `${path} must document the object.type.extension file naming`);
|
|
73
|
+
assert.match(markdown, /LintABAP/, `${path} must require local linting with LintABAP`);
|
|
74
|
+
assert.match(markdown, /before (?:they are )?sent to the SAP system|before sending to SAP|check and lint before sending/i, `${path} must require local checks before the SAP transfer`);
|
|
75
|
+
}
|
|
76
|
+
|
|
59
77
|
function assertNoUnsafeTransportClaims(markdown, path) {
|
|
60
78
|
const releaseMentions = markdown.match(/ReleaseTransport|release\/deletion|release and deletion|release is unavailable|release action/gi) || [];
|
|
61
79
|
assert.ok(releaseMentions.length > 0 || !/transport/i.test(markdown), `${path} transport guidance should explicitly handle release limitations when transport is discussed`);
|
|
@@ -73,6 +91,15 @@ test('packaged Copilot agent and skill inventory is complete and canonical', asy
|
|
|
73
91
|
}
|
|
74
92
|
});
|
|
75
93
|
|
|
94
|
+
test('all bundled agents invoke chat-attached MCP tools without terminal JSON-RPC fallbacks', async () => {
|
|
95
|
+
for (const name of expectedAgents) {
|
|
96
|
+
const path = join(agentsRoot, name);
|
|
97
|
+
const markdown = await readMarkdown(path);
|
|
98
|
+
assertDirectMcpUsage(markdown, path);
|
|
99
|
+
assertMentionsLiveToolDiscovery(markdown, path);
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
76
103
|
test('ABAP Developer agent has valid metadata and invokes the packaged skills', async () => {
|
|
77
104
|
const markdown = await readMarkdown(agentPath);
|
|
78
105
|
const metadata = parseFrontmatter(markdown, agentPath);
|
|
@@ -88,6 +115,8 @@ test('ABAP Developer agent has valid metadata and invokes the packaged skills',
|
|
|
88
115
|
}
|
|
89
116
|
assertMentionsLiveToolDiscovery(markdown, agentPath);
|
|
90
117
|
assertValidationPolicy(markdown, agentPath);
|
|
118
|
+
assertPlanFirstPolicy(markdown, agentPath);
|
|
119
|
+
assertLocalFirstPolicy(markdown, agentPath);
|
|
91
120
|
assertNoUnsafeTransportClaims(markdown, agentPath);
|
|
92
121
|
});
|
|
93
122
|
|
|
@@ -108,6 +137,24 @@ test('SAP Solution Architect agent has valid metadata and planning/handoff polic
|
|
|
108
137
|
assert.match(markdown, /RAP Service Developer/i);
|
|
109
138
|
assert.match(markdown, /ABAP Runtime Debugger/i);
|
|
110
139
|
assert.match(markdown, /SDLC|lifecycle/i);
|
|
140
|
+
assertPlanFirstPolicy(markdown, path);
|
|
141
|
+
assertLocalFirstPolicy(markdown, path);
|
|
142
|
+
assertNoUnsafeTransportClaims(markdown, path);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('RAP Service Developer agent has valid metadata and plan-first local-first policy', async () => {
|
|
146
|
+
const path = join(agentsRoot, 'rap-service-developer.agent.md');
|
|
147
|
+
const markdown = await readMarkdown(path);
|
|
148
|
+
const metadata = parseFrontmatter(markdown, path);
|
|
149
|
+
assert.deepEqual(Object.keys(metadata).sort(), ['description', 'name', 'target', 'user-invocable']);
|
|
150
|
+
assert.equal(metadata.name, 'RAP Service Developer');
|
|
151
|
+
assert.equal(metadata.target, 'vscode');
|
|
152
|
+
assert.equal(metadata['user-invocable'], 'true');
|
|
153
|
+
assert.match(metadata.description, /RAP/i);
|
|
154
|
+
assertMentionsLiveToolDiscovery(markdown, path);
|
|
155
|
+
assertValidationPolicy(markdown, path);
|
|
156
|
+
assertPlanFirstPolicy(markdown, path);
|
|
157
|
+
assertLocalFirstPolicy(markdown, path);
|
|
111
158
|
assertNoUnsafeTransportClaims(markdown, path);
|
|
112
159
|
});
|
|
113
160
|
|
|
@@ -134,6 +181,15 @@ test('implementation-oriented skills require tests, runtime evidence, activation
|
|
|
134
181
|
}
|
|
135
182
|
});
|
|
136
183
|
|
|
184
|
+
test('implementation and delivery skills require planning first and local-first authoring', async () => {
|
|
185
|
+
for (const skill of ['abap-development', 'rap-development', 'rap-service-delivery', 'sap-sdlc-orchestration']) {
|
|
186
|
+
const path = join(skillsRoot, skill, 'SKILL.md');
|
|
187
|
+
const markdown = await readMarkdown(path);
|
|
188
|
+
assertPlanFirstPolicy(markdown, path);
|
|
189
|
+
assertLocalFirstPolicy(markdown, path);
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
|
|
137
193
|
test('debugging skill requires evidence before fixes and bounded debugger use', async () => {
|
|
138
194
|
const path = join(skillsRoot, 'abap-debugging', 'SKILL.md');
|
|
139
195
|
const markdown = await readMarkdown(path);
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import http from 'node:http';
|
|
7
|
+
import { request as httpRequest } from 'node:http';
|
|
8
|
+
import { createBasDestinationRelay } from '../src/bas-destination-relay.mjs';
|
|
9
|
+
import { readCredentials, removeDestinationCredentials, resolveCredentialsPath, storeDestinationCredentials } from '../src/credentials-store.mjs';
|
|
10
|
+
import { canPromptForCredentials, credentialKeyForDestination, credentialModeForDestination } from '../src/credential-overrides.mjs';
|
|
11
|
+
|
|
12
|
+
const UNSAFE_METHODS = ['POST', 'PUT', 'PATCH', 'DELETE'];
|
|
13
|
+
|
|
14
|
+
// SAP-like backend that requires Basic auth AND token+cookie pairing —
|
|
15
|
+
// the semantics that only work when the relay connects directly.
|
|
16
|
+
function authedBackend({ expectedUser = 'DEVELOPER', expectedPassword = 'secret123' } = {}) {
|
|
17
|
+
const state = { sessionCounter: 0, requests: [] };
|
|
18
|
+
const server = http.createServer((req, res) => {
|
|
19
|
+
state.requests.push({ method: req.method, headers: { ...req.headers } });
|
|
20
|
+
const authorization = String(req.headers.authorization || '');
|
|
21
|
+
const basic = authorization.replace(/^Basic /i, '');
|
|
22
|
+
const [user, password] = Buffer.from(basic, 'base64').toString().split(':');
|
|
23
|
+
if (user !== expectedUser || password !== expectedPassword) {
|
|
24
|
+
res.writeHead(401, { 'www-authenticate': 'Basic' });
|
|
25
|
+
res.end('credentials required');
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
if (req.method === 'GET' && req.headers['x-csrf-token'] === 'Fetch') {
|
|
29
|
+
state.sessionCounter += 1;
|
|
30
|
+
res.writeHead(200, {
|
|
31
|
+
'x-csrf-token': `token-${state.sessionCounter}`,
|
|
32
|
+
'set-cookie': [`sap-session-${state.sessionCounter}=s${state.sessionCounter}; path=/`]
|
|
33
|
+
});
|
|
34
|
+
res.end('<ok/>');
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
if (UNSAFE_METHODS.includes(req.method)) {
|
|
38
|
+
const match = String(req.headers['x-csrf-token'] || '').match(/^token-(\d+)$/);
|
|
39
|
+
const valid = match && String(req.headers.cookie || '').includes(`sap-session-${match[1]}=`);
|
|
40
|
+
if (!valid) {
|
|
41
|
+
res.writeHead(403, { 'x-csrf-token': 'Required' });
|
|
42
|
+
res.end('CSRF token validation failed');
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
res.writeHead(201);
|
|
46
|
+
res.end('<created/>');
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
res.writeHead(200);
|
|
50
|
+
res.end('<read/>');
|
|
51
|
+
});
|
|
52
|
+
return { state, server };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function relayFetch(relayUrl, path, options = {}) {
|
|
56
|
+
const target = new URL(path, relayUrl);
|
|
57
|
+
return new Promise((resolve, reject) => {
|
|
58
|
+
const req = httpRequest(target, { method: options.method || 'GET', headers: options.headers || {} }, response => {
|
|
59
|
+
let body = '';
|
|
60
|
+
response.setEncoding('utf8');
|
|
61
|
+
response.on('data', chunk => { body += chunk; });
|
|
62
|
+
response.on('end', () => resolve({ status: response.statusCode, body }));
|
|
63
|
+
});
|
|
64
|
+
req.on('error', reject);
|
|
65
|
+
if (options.body) req.write(options.body);
|
|
66
|
+
req.end();
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
test('credentials file resolves next to the MCP config and stores entries with 0600', async t => {
|
|
71
|
+
const directory = await mkdtemp(join(tmpdir(), 'bas-creds-'));
|
|
72
|
+
t.after(async () => { await rm(directory, { recursive: true, force: true }); });
|
|
73
|
+
const mcpConfig = join(directory, 'mcp.json');
|
|
74
|
+
const path = await resolveCredentialsPath({ HOME: '/nonexistent' }, mcpConfig);
|
|
75
|
+
assert.equal(path, join(directory, 'sap-ai-dev-toolkit-credentials.json'));
|
|
76
|
+
|
|
77
|
+
await storeDestinationCredentials(path, 'S4H', { host: 'https://backend.example:44300', user: 'DEVELOPER', password: 'secret123' });
|
|
78
|
+
const stored = await readCredentials(path);
|
|
79
|
+
assert.equal(stored.destinations.S4H.user, 'DEVELOPER');
|
|
80
|
+
assert.equal(stored.destinations.S4H.host, 'https://backend.example:44300');
|
|
81
|
+
const permissions = (await stat(path)).mode & 0o777;
|
|
82
|
+
assert.equal(permissions, 0o600, 'credentials file must be owner-only');
|
|
83
|
+
|
|
84
|
+
const raw = JSON.parse(await readFile(path, 'utf8'));
|
|
85
|
+
assert.equal(raw.version, 1);
|
|
86
|
+
assert.ok(Object.hasOwn(raw.destinations, 'S4H'));
|
|
87
|
+
|
|
88
|
+
const { removed } = await removeDestinationCredentials(path, ['S4H']);
|
|
89
|
+
assert.deepEqual(removed, ['S4H']);
|
|
90
|
+
assert.equal((await readCredentials(path)).destinations.S4H, undefined);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('credentials file with malformed entries keeps only complete records', async t => {
|
|
94
|
+
const directory = await mkdtemp(join(tmpdir(), 'bas-creds-bad-'));
|
|
95
|
+
t.after(async () => { await rm(directory, { recursive: true, force: true }); });
|
|
96
|
+
const path = join(directory, 'sap-ai-dev-toolkit-credentials.json');
|
|
97
|
+
await writeFile(path, JSON.stringify({
|
|
98
|
+
version: 1,
|
|
99
|
+
destinations: {
|
|
100
|
+
GOOD: { host: 'https://good.example', user: 'u', password: 'p', updatedAt: '2026-01-01T00:00:00.000Z' },
|
|
101
|
+
NO_PASSWORD: { host: 'https://bad.example', user: 'u' },
|
|
102
|
+
NOT_OBJECT: 'nope'
|
|
103
|
+
}
|
|
104
|
+
}));
|
|
105
|
+
const stored = await readCredentials(path);
|
|
106
|
+
assert.deepEqual(Object.keys(stored.destinations), ['GOOD']);
|
|
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
|
+
|
|
139
|
+
test('relay direct connect sends Basic auth and CSRF pairing succeeds', async t => {
|
|
140
|
+
const { state, server } = authedBackend();
|
|
141
|
+
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
|
142
|
+
const backendUrl = `http://127.0.0.1:${server.address().port}`;
|
|
143
|
+
const relay = createBasDestinationRelay(
|
|
144
|
+
{
|
|
145
|
+
name: 'S4H',
|
|
146
|
+
url: 'http://S4H.dest',
|
|
147
|
+
authentication: 'BasicAuthentication',
|
|
148
|
+
credentials: { host: backendUrl, user: 'DEVELOPER', password: 'secret123' }
|
|
149
|
+
},
|
|
150
|
+
{ env: { HTTP_PROXY: '' }, log: () => {} }
|
|
151
|
+
);
|
|
152
|
+
const relayUrl = await relay.ready;
|
|
153
|
+
t.after(async () => { await relay.close(); await new Promise(resolve => server.close(resolve)); });
|
|
154
|
+
try {
|
|
155
|
+
const read = await relayFetch(relayUrl, '/sap/bc/adt/discovery');
|
|
156
|
+
assert.equal(read.status, 200, 'authenticated read must succeed');
|
|
157
|
+
const write = await relayFetch(relayUrl, '/sap/bc/adt/datapreview/freestyle', { method: 'POST', body: '<q/>' });
|
|
158
|
+
assert.equal(write.status, 201, `CSRF-protected write must succeed, got ${write.status}: ${write.body}`);
|
|
159
|
+
const post = state.requests.find(request => request.method === 'POST');
|
|
160
|
+
assert.match(post.headers.authorization, /^Basic /);
|
|
161
|
+
assert.match(String(post.headers.cookie), /sap-session-\d+=/);
|
|
162
|
+
assert.equal(relay.stats.direct, true);
|
|
163
|
+
} finally {
|
|
164
|
+
void relayUrl;
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('relay direct connect with wrong credentials surfaces 401 without retries', async t => {
|
|
169
|
+
const { state, server } = authedBackend();
|
|
170
|
+
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
|
171
|
+
const backendUrl = `http://127.0.0.1:${server.address().port}`;
|
|
172
|
+
const relay = createBasDestinationRelay(
|
|
173
|
+
{
|
|
174
|
+
name: 'S4H',
|
|
175
|
+
url: 'http://S4H.dest',
|
|
176
|
+
authentication: 'BasicAuthentication',
|
|
177
|
+
credentials: { host: backendUrl, user: 'DEVELOPER', password: 'wrong' }
|
|
178
|
+
},
|
|
179
|
+
{ env: { HTTP_PROXY: '' }, log: () => {} }
|
|
180
|
+
);
|
|
181
|
+
const relayUrl = await relay.ready;
|
|
182
|
+
t.after(async () => { await relay.close(); await new Promise(resolve => server.close(resolve)); });
|
|
183
|
+
const write = await relayFetch(relayUrl, '/sap/bc/adt/datapreview/freestyle', { method: 'POST', body: '<q/>' });
|
|
184
|
+
assert.equal(write.status, 401);
|
|
185
|
+
assert.equal(relay.stats.csrfRetries, 0, '401 without a session must not trigger CSRF retries');
|
|
186
|
+
void state;
|
|
187
|
+
});
|
package/test/discovery.test.mjs
CHANGED
|
@@ -10,7 +10,7 @@ test('normalizes destination metadata case-insensitively', () => {
|
|
|
10
10
|
});
|
|
11
11
|
assert.deepEqual(destination, {
|
|
12
12
|
name: 'DEV-ABAP', authentication: 'PrincipalPropagation', client: '200',
|
|
13
|
-
url: 'http://DEV-ABAP.dest', rawKeys: ['Name', 'Properties']
|
|
13
|
+
url: 'http://DEV-ABAP.dest', backendUrl: 'https://backend.example', proxyType: null, rawKeys: ['Name', 'Properties']
|
|
14
14
|
});
|
|
15
15
|
});
|
|
16
16
|
|