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.
@@ -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
+ });
@@ -6,6 +6,7 @@ import { spawn } from 'node:child_process';
6
6
  import { tmpdir } from 'node:os';
7
7
  import { join } from 'node:path';
8
8
  import { fileURLToPath } from 'node:url';
9
+ import { storeDestinationCredentials } from '../src/credentials-store.mjs';
9
10
 
10
11
  const launcher = fileURLToPath(new URL('../src/launcher.mjs', import.meta.url));
11
12
  const fakeVsp = fileURLToPath(new URL('./fixtures/fake-vsp.mjs', import.meta.url));
@@ -257,6 +258,12 @@ test('routes a PrincipalPropagation MCP call through CF Connectivity to the SAP
257
258
  connectivityProxyPort,
258
259
  connectivityTokenUrl: `${serviceOrigin}/connectivity/oauth/token`
259
260
  });
261
+ const credentialsPath = join(directory, 'sap-ai-dev-toolkit-credentials.json');
262
+ await storeDestinationCredentials(credentialsPath, `cloud-foundry:cf:${SPACE_GUID}:${INSTANCE_GUID}:pp-target`, {
263
+ user: 'must-not-override',
264
+ password: 'must-not-override-secret',
265
+ mode: 'cf-connectivity'
266
+ });
260
267
  const childLog = join(directory, 'vsp-children.jsonl');
261
268
  t.after(async () => {
262
269
  await new Promise(resolve => service.close(resolve));
@@ -271,6 +278,7 @@ test('routes a PrincipalPropagation MCP call through CF Connectivity to the SAP
271
278
  SAP_AI_DEV_TOOLKIT_BINARY: fakeVsp,
272
279
  SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE: 'cloud-foundry',
273
280
  SAP_AI_DEV_TOOLKIT_DESTINATION: 'pp-target',
281
+ SAP_AI_DEV_TOOLKIT_CREDENTIALS_FILE: credentialsPath,
274
282
  BAS_CF_SPACE_GUID: SPACE_GUID,
275
283
  BAS_CF_DESTINATION_INSTANCE_GUID: INSTANCE_GUID,
276
284
  BAS_CF_DESTINATION_INSTANCE: INSTANCE_NAME,
@@ -313,17 +321,17 @@ test('routes a PrincipalPropagation MCP call through CF Connectivity to the SAP
313
321
  assert.equal(initialized.argv.includes('--proxy-auth'), true);
314
322
  assert.equal(initialized.env.user, undefined);
315
323
  assert.equal(initialized.env.password, undefined);
316
- for (const secret of ['destination-key-secret', 'destination-api-token', 'connectivity-key-secret', 'user-exchange-token', 'current-cf-user-jwt', 'parent-user-secret', 'parent-password-secret']) {
324
+ for (const secret of ['must-not-override', 'must-not-override-secret', 'destination-key-secret', 'destination-api-token', 'connectivity-key-secret', 'user-exchange-token', 'current-cf-user-jwt', 'parent-user-secret', 'parent-password-secret']) {
317
325
  assert.equal(`${result.stdout}\\n${result.stderr}\\n${JSON.stringify(initialized.argv)}`.includes(secret), false);
318
326
  }
319
327
  });
320
328
 
321
- test('routes a CF Basic OnPremise MCP call through the HTTP Connectivity proxy', async t => {
329
+ test('overrides CF Basic OnPremise backend auth while retaining the HTTP Connectivity proxy', async t => {
322
330
  const directory = await mkdtemp(join(tmpdir(), 'bas-cf-basic-onprem-'));
323
331
  const observed = { destinationToken: [], destinationList: [], connectivityToken: [], proxy: [], connect: [], target: [] };
324
332
  const target = createServer((request, response) => {
325
333
  observed.target.push({ path: request.url, authorization: request.headers.authorization });
326
- response.writeHead(request.headers.authorization === `Basic ${Buffer.from('sap-user:sap-password').toString('base64')}` ? 200 : 401, {
334
+ response.writeHead(request.headers.authorization === `Basic ${Buffer.from('override-user:override-password').toString('base64')}` ? 200 : 401, {
327
335
  'content-type': 'text/plain'
328
336
  });
329
337
  response.end('SAP system information: client=100');
@@ -409,6 +417,12 @@ test('routes a CF Basic OnPremise MCP call through the HTTP Connectivity proxy',
409
417
  connectivityProxyPort,
410
418
  connectivityTokenUrl: `${serviceOrigin}/connectivity/oauth/token`
411
419
  });
420
+ const credentialsPath = join(directory, 'sap-ai-dev-toolkit-credentials.json');
421
+ await storeDestinationCredentials(credentialsPath, `cloud-foundry:cf:${SPACE_GUID}:${INSTANCE_GUID}:basic-onprem`, {
422
+ user: 'override-user',
423
+ password: 'override-password',
424
+ mode: 'cf-connectivity'
425
+ });
412
426
  const childLog = join(directory, 'vsp-children.jsonl');
413
427
  const servers = [service, target, connectivityProxy];
414
428
  t.after(async () => {
@@ -423,6 +437,7 @@ test('routes a CF Basic OnPremise MCP call through the HTTP Connectivity proxy',
423
437
  SAP_AI_DEV_TOOLKIT_BINARY: fakeVsp,
424
438
  SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE: 'cloud-foundry',
425
439
  SAP_AI_DEV_TOOLKIT_DESTINATION: 'basic-onprem',
440
+ SAP_AI_DEV_TOOLKIT_CREDENTIALS_FILE: credentialsPath,
426
441
  BAS_CF_SPACE_GUID: SPACE_GUID,
427
442
  BAS_CF_DESTINATION_INSTANCE_GUID: INSTANCE_GUID,
428
443
  BAS_CF_DESTINATION_INSTANCE: INSTANCE_NAME,
@@ -461,13 +476,20 @@ test('routes a CF Basic OnPremise MCP call through the HTTP Connectivity proxy',
461
476
  'GET',
462
477
  `${targetOrigin}/sap/bc/adt/discovery`,
463
478
  'Bearer application-proxy-token',
464
- `Basic ${Buffer.from('sap-user:sap-password').toString('base64')}`
479
+ `Basic ${Buffer.from('override-user:override-password').toString('base64')}`
465
480
  ]]);
466
481
  assert.deepEqual(observed.connect, []);
467
482
  assert.deepEqual(observed.target, [{
468
483
  path: '/sap/bc/adt/discovery',
469
- authorization: `Basic ${Buffer.from('sap-user:sap-password').toString('base64')}`
484
+ authorization: `Basic ${Buffer.from('override-user:override-password').toString('base64')}`
470
485
  }]);
486
+ const events = (await readFile(childLog, 'utf8')).trim().split('\n').map(line => JSON.parse(line));
487
+ const initialized = events.find(event => event.event === 'initialize');
488
+ assert.deepEqual([initialized.env.user, initialized.env.password], ['override-user', 'override-password']);
489
+ assert.equal(initialized.argv.includes('--proxy-auth'), false);
490
+ for (const secret of ['override-user', 'override-password', 'sap-password', 'connectivity-key-secret', 'application-proxy-token']) {
491
+ assert.equal(`${result.stdout}\n${result.stderr}\n${JSON.stringify(initialized.argv)}`.includes(secret), false, `${secret} must not appear in output or argv`);
492
+ }
471
493
  });
472
494
 
473
495
  test('CF list-destinations clears the CF name filter and remains BAS-only', async t => {
@@ -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
+ });
@@ -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