sap-ai-dev-toolkit 0.3.0 → 0.3.3

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.
@@ -56,6 +56,18 @@ function assertValidationPolicy(markdown, path) {
56
56
  assert.match(markdown, /report|Record|document/i, `${path} must require observed outcome reporting`);
57
57
  }
58
58
 
59
+ function assertPlanFirstPolicy(markdown, path) {
60
+ assert.match(markdown, /[Pp]lan before implementing|[Pp]lanning phase before implementation/, `${path} must require a plan to be presented before implementation starts`);
61
+ 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`);
62
+ }
63
+
64
+ function assertLocalFirstPolicy(markdown, path) {
65
+ assert.match(markdown, /abapGit[- ]serialized|abapGit-style/, `${path} must require local abapGit-style source files`);
66
+ assert.match(markdown, /object\.type\.extension/, `${path} must document the object.type.extension file naming`);
67
+ assert.match(markdown, /LintABAP/, `${path} must require local linting with LintABAP`);
68
+ 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`);
69
+ }
70
+
59
71
  function assertNoUnsafeTransportClaims(markdown, path) {
60
72
  const releaseMentions = markdown.match(/ReleaseTransport|release\/deletion|release and deletion|release is unavailable|release action/gi) || [];
61
73
  assert.ok(releaseMentions.length > 0 || !/transport/i.test(markdown), `${path} transport guidance should explicitly handle release limitations when transport is discussed`);
@@ -88,6 +100,8 @@ test('ABAP Developer agent has valid metadata and invokes the packaged skills',
88
100
  }
89
101
  assertMentionsLiveToolDiscovery(markdown, agentPath);
90
102
  assertValidationPolicy(markdown, agentPath);
103
+ assertPlanFirstPolicy(markdown, agentPath);
104
+ assertLocalFirstPolicy(markdown, agentPath);
91
105
  assertNoUnsafeTransportClaims(markdown, agentPath);
92
106
  });
93
107
 
@@ -108,6 +122,24 @@ test('SAP Solution Architect agent has valid metadata and planning/handoff polic
108
122
  assert.match(markdown, /RAP Service Developer/i);
109
123
  assert.match(markdown, /ABAP Runtime Debugger/i);
110
124
  assert.match(markdown, /SDLC|lifecycle/i);
125
+ assertPlanFirstPolicy(markdown, path);
126
+ assertLocalFirstPolicy(markdown, path);
127
+ assertNoUnsafeTransportClaims(markdown, path);
128
+ });
129
+
130
+ test('RAP Service Developer agent has valid metadata and plan-first local-first policy', async () => {
131
+ const path = join(agentsRoot, 'rap-service-developer.agent.md');
132
+ const markdown = await readMarkdown(path);
133
+ const metadata = parseFrontmatter(markdown, path);
134
+ assert.deepEqual(Object.keys(metadata).sort(), ['description', 'name', 'target', 'user-invocable']);
135
+ assert.equal(metadata.name, 'RAP Service Developer');
136
+ assert.equal(metadata.target, 'vscode');
137
+ assert.equal(metadata['user-invocable'], 'true');
138
+ assert.match(metadata.description, /RAP/i);
139
+ assertMentionsLiveToolDiscovery(markdown, path);
140
+ assertValidationPolicy(markdown, path);
141
+ assertPlanFirstPolicy(markdown, path);
142
+ assertLocalFirstPolicy(markdown, path);
111
143
  assertNoUnsafeTransportClaims(markdown, path);
112
144
  });
113
145
 
@@ -134,6 +166,15 @@ test('implementation-oriented skills require tests, runtime evidence, activation
134
166
  }
135
167
  });
136
168
 
169
+ test('implementation and delivery skills require planning first and local-first authoring', async () => {
170
+ for (const skill of ['abap-development', 'rap-development', 'rap-service-delivery', 'sap-sdlc-orchestration']) {
171
+ const path = join(skillsRoot, skill, 'SKILL.md');
172
+ const markdown = await readMarkdown(path);
173
+ assertPlanFirstPolicy(markdown, path);
174
+ assertLocalFirstPolicy(markdown, path);
175
+ }
176
+ });
177
+
137
178
  test('debugging skill requires evidence before fixes and bounded debugger use', async () => {
138
179
  const path = join(skillsRoot, 'abap-debugging', 'SKILL.md');
139
180
  const markdown = await readMarkdown(path);
@@ -0,0 +1,156 @@
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
+
11
+ const UNSAFE_METHODS = ['POST', 'PUT', 'PATCH', 'DELETE'];
12
+
13
+ // SAP-like backend that requires Basic auth AND token+cookie pairing —
14
+ // the semantics that only work when the relay connects directly.
15
+ function authedBackend({ expectedUser = 'DEVELOPER', expectedPassword = 'secret123' } = {}) {
16
+ const state = { sessionCounter: 0, requests: [] };
17
+ const server = http.createServer((req, res) => {
18
+ state.requests.push({ method: req.method, headers: { ...req.headers } });
19
+ const authorization = String(req.headers.authorization || '');
20
+ const basic = authorization.replace(/^Basic /i, '');
21
+ const [user, password] = Buffer.from(basic, 'base64').toString().split(':');
22
+ if (user !== expectedUser || password !== expectedPassword) {
23
+ res.writeHead(401, { 'www-authenticate': 'Basic' });
24
+ res.end('credentials required');
25
+ return;
26
+ }
27
+ if (req.method === 'GET' && req.headers['x-csrf-token'] === 'Fetch') {
28
+ state.sessionCounter += 1;
29
+ res.writeHead(200, {
30
+ 'x-csrf-token': `token-${state.sessionCounter}`,
31
+ 'set-cookie': [`sap-session-${state.sessionCounter}=s${state.sessionCounter}; path=/`]
32
+ });
33
+ res.end('<ok/>');
34
+ return;
35
+ }
36
+ if (UNSAFE_METHODS.includes(req.method)) {
37
+ const match = String(req.headers['x-csrf-token'] || '').match(/^token-(\d+)$/);
38
+ const valid = match && String(req.headers.cookie || '').includes(`sap-session-${match[1]}=`);
39
+ if (!valid) {
40
+ res.writeHead(403, { 'x-csrf-token': 'Required' });
41
+ res.end('CSRF token validation failed');
42
+ return;
43
+ }
44
+ res.writeHead(201);
45
+ res.end('<created/>');
46
+ return;
47
+ }
48
+ res.writeHead(200);
49
+ res.end('<read/>');
50
+ });
51
+ return { state, server };
52
+ }
53
+
54
+ function relayFetch(relayUrl, path, options = {}) {
55
+ const target = new URL(path, relayUrl);
56
+ return new Promise((resolve, reject) => {
57
+ const req = httpRequest(target, { method: options.method || 'GET', headers: options.headers || {} }, response => {
58
+ let body = '';
59
+ response.setEncoding('utf8');
60
+ response.on('data', chunk => { body += chunk; });
61
+ response.on('end', () => resolve({ status: response.statusCode, body }));
62
+ });
63
+ req.on('error', reject);
64
+ if (options.body) req.write(options.body);
65
+ req.end();
66
+ });
67
+ }
68
+
69
+ test('credentials file resolves next to the MCP config and stores entries with 0600', async t => {
70
+ const directory = await mkdtemp(join(tmpdir(), 'bas-creds-'));
71
+ t.after(async () => { await rm(directory, { recursive: true, force: true }); });
72
+ const mcpConfig = join(directory, 'mcp.json');
73
+ const path = await resolveCredentialsPath({ HOME: '/nonexistent' }, mcpConfig);
74
+ assert.equal(path, join(directory, 'sap-ai-dev-toolkit-credentials.json'));
75
+
76
+ await storeDestinationCredentials(path, 'S4H', { host: 'https://backend.example:44300', user: 'DEVELOPER', password: 'secret123' });
77
+ const stored = await readCredentials(path);
78
+ assert.equal(stored.destinations.S4H.user, 'DEVELOPER');
79
+ assert.equal(stored.destinations.S4H.host, 'https://backend.example:44300');
80
+ const permissions = (await stat(path)).mode & 0o777;
81
+ assert.equal(permissions, 0o600, 'credentials file must be owner-only');
82
+
83
+ const raw = JSON.parse(await readFile(path, 'utf8'));
84
+ assert.equal(raw.version, 1);
85
+ assert.ok(Object.hasOwn(raw.destinations, 'S4H'));
86
+
87
+ const { removed } = await removeDestinationCredentials(path, ['S4H']);
88
+ assert.deepEqual(removed, ['S4H']);
89
+ assert.equal((await readCredentials(path)).destinations.S4H, undefined);
90
+ });
91
+
92
+ test('credentials file with malformed entries keeps only complete records', async t => {
93
+ const directory = await mkdtemp(join(tmpdir(), 'bas-creds-bad-'));
94
+ t.after(async () => { await rm(directory, { recursive: true, force: true }); });
95
+ const path = join(directory, 'sap-ai-dev-toolkit-credentials.json');
96
+ await writeFile(path, JSON.stringify({
97
+ version: 1,
98
+ destinations: {
99
+ GOOD: { host: 'https://good.example', user: 'u', password: 'p', updatedAt: '2026-01-01T00:00:00.000Z' },
100
+ NO_PASSWORD: { host: 'https://bad.example', user: 'u' },
101
+ NOT_OBJECT: 'nope'
102
+ }
103
+ }));
104
+ const stored = await readCredentials(path);
105
+ assert.deepEqual(Object.keys(stored.destinations), ['GOOD']);
106
+ });
107
+
108
+ test('relay direct connect sends Basic auth and CSRF pairing succeeds', async t => {
109
+ const { state, server } = authedBackend();
110
+ await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
111
+ const backendUrl = `http://127.0.0.1:${server.address().port}`;
112
+ const relay = createBasDestinationRelay(
113
+ {
114
+ name: 'S4H',
115
+ url: 'http://S4H.dest',
116
+ authentication: 'BasicAuthentication',
117
+ credentials: { host: backendUrl, user: 'DEVELOPER', password: 'secret123' }
118
+ },
119
+ { env: { HTTP_PROXY: '' }, log: () => {} }
120
+ );
121
+ const relayUrl = await relay.ready;
122
+ t.after(async () => { await relay.close(); await new Promise(resolve => server.close(resolve)); });
123
+ try {
124
+ const read = await relayFetch(relayUrl, '/sap/bc/adt/discovery');
125
+ assert.equal(read.status, 200, 'authenticated read must succeed');
126
+ const write = await relayFetch(relayUrl, '/sap/bc/adt/datapreview/freestyle', { method: 'POST', body: '<q/>' });
127
+ assert.equal(write.status, 201, `CSRF-protected write must succeed, got ${write.status}: ${write.body}`);
128
+ const post = state.requests.find(request => request.method === 'POST');
129
+ assert.match(post.headers.authorization, /^Basic /);
130
+ assert.match(String(post.headers.cookie), /sap-session-\d+=/);
131
+ assert.equal(relay.stats.direct, true);
132
+ } finally {
133
+ void relayUrl;
134
+ }
135
+ });
136
+
137
+ test('relay direct connect with wrong credentials surfaces 401 without retries', async t => {
138
+ const { state, server } = authedBackend();
139
+ await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
140
+ const backendUrl = `http://127.0.0.1:${server.address().port}`;
141
+ const relay = createBasDestinationRelay(
142
+ {
143
+ name: 'S4H',
144
+ url: 'http://S4H.dest',
145
+ authentication: 'BasicAuthentication',
146
+ credentials: { host: backendUrl, user: 'DEVELOPER', password: 'wrong' }
147
+ },
148
+ { env: { HTTP_PROXY: '' }, log: () => {} }
149
+ );
150
+ const relayUrl = await relay.ready;
151
+ t.after(async () => { await relay.close(); await new Promise(resolve => server.close(resolve)); });
152
+ const write = await relayFetch(relayUrl, '/sap/bc/adt/datapreview/freestyle', { method: 'POST', body: '<q/>' });
153
+ assert.equal(write.status, 401);
154
+ assert.equal(relay.stats.csrfRetries, 0, '401 without a session must not trigger CSRF retries');
155
+ void state;
156
+ });
@@ -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
 
@@ -47,6 +47,57 @@ test('uses authentication-specific arguments for Cloud Foundry children', () =>
47
47
  assert.equal(childArguments(principal, {}).includes('--proxy-auth'), true);
48
48
  });
49
49
 
50
+ test('self-heals BAS destinations through a local destination relay without credentials', async t => {
51
+ const destination = { name: 'S4H', url: 'http://S4H.dest', client: '100', authentication: 'BasicAuthentication', proxyType: 'Internet' };
52
+ const directory = await mkdtemp(join(tmpdir(), 'bas-relay-'));
53
+ const log = join(directory, 'children.log');
54
+ const logs = [];
55
+ const proxy = new MCPProxy({ binary: fixture, destinations: [destination], env: { ...process.env, FAKE_LOG: log }, log: message => logs.push(message) });
56
+ t.after(async () => { await proxy.close(); await rm(directory, { recursive: true, force: true }); });
57
+ proxy.start();
58
+ await proxy.handle({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} });
59
+ const event = (await readFile(log, 'utf8')).trim().split('\n').map(line => JSON.parse(line)).find(row => row.event === 'initialize');
60
+ assert.equal(event.argv.includes('--proxy-auth'), true);
61
+ assert.match(event.argv[event.argv.indexOf('--url') + 1], /^http:\/\/127\.0\.0\.1:\d+$/);
62
+ assert.equal(event.env.user, undefined);
63
+ assert.equal(event.env.password, undefined);
64
+ assert.ok(logs.some(message => message.includes('BAS destination relay enabled')));
65
+ });
66
+
67
+ test('self-heals BAS relay children with loopback NO_PROXY and keeps --proxy-auth', async t => {
68
+ const destination = { name: 'S4H', url: 'http://S4H.dest', client: '100', authentication: 'BasicAuthentication', proxyType: 'Internet' };
69
+ const directory = await mkdtemp(join(tmpdir(), 'bas-relay-env-'));
70
+ const log = join(directory, 'children.log');
71
+ const proxy = new MCPProxy({ binary: fixture, destinations: [destination], env: { ...process.env, FAKE_LOG: log }, log: () => {} });
72
+ t.after(async () => { await proxy.close(); await rm(directory, { recursive: true, force: true }); });
73
+ proxy.start();
74
+ await proxy.handle({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} });
75
+ const event = (await readFile(log, 'utf8')).trim().split('\n').map(line => JSON.parse(line)).find(row => row.event === 'initialize');
76
+ assert.equal(event.argv.includes('--proxy-auth'), true);
77
+ const noProxy = String(event.env.noProxy || '').split(',').map(value => value.trim().toLowerCase());
78
+ assert.ok(noProxy.includes('127.0.0.1'), `NO_PROXY must exempt the relay loopback: ${event.env.noProxy}`);
79
+ assert.ok(noProxy.includes('localhost'), `NO_PROXY must exempt relay localhost: ${event.env.noProxy}`);
80
+ });
81
+
82
+ test('restarts a crashed VSP child and retries the tool call once', async t => {
83
+ const { directory, log, proxy } = await fixtureProxy();
84
+ t.after(async () => { await proxy.close(); await rm(directory, { recursive: true, force: true }); });
85
+ await proxy.handle({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05' } });
86
+ await proxy.handle({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} });
87
+ const entry = proxy.children[0];
88
+ const originalChild = entry.child;
89
+ // Kill the child behind the proxy's back to simulate a crash.
90
+ const exit = once(originalChild.process, 'exit');
91
+ originalChild.process.kill('SIGKILL');
92
+ await exit;
93
+ const response = await proxy.handle({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'alpha__GetSystemInfo', arguments: {} } });
94
+ assert.equal(response?.error, undefined, `self-healing must recover the call: ${JSON.stringify(response?.error)}`);
95
+ assert.notEqual(proxy.children[0].child, originalChild, 'a replacement child must have been spawned');
96
+ const events = (await readFile(log, 'utf8')).trim().split('\n').map(line => JSON.parse(line));
97
+ assert.ok(events.some(row => row.event === 'initialize' && row.destination === 'alpha'), 'replacement child must re-initialize');
98
+ assert.ok(events.some(row => row.event === 'call' && row.name === 'GetSystemInfo'), 'the retried call must reach the child');
99
+ });
100
+
50
101
  test('closes each Cloud Foundry route after child exit and on startup failure', async t => {
51
102
  const directory = await mkdtemp(join(tmpdir(), 'bas-cf-child-lifecycle-'));
52
103
  const log = join(directory, 'children.log');
@@ -89,7 +140,8 @@ test('closes each Cloud Foundry route after child exit and on startup failure',
89
140
  spawn: () => { throw new Error('spawn denied'); },
90
141
  log: () => {}
91
142
  });
92
- assert.throws(() => failedProxy.start(), /No destination child could be started/);
143
+ failedProxy.start();
144
+ await assert.rejects(() => failedProxy.starting, /No destination child could be started/);
93
145
  await new Promise(resolve => setImmediate(resolve));
94
146
  assert.equal(failedStartCloseCount, 1);
95
147
  });
@@ -302,9 +354,13 @@ test('returns JSON-RPC errors for unknown namespaces and logs child failures', a
302
354
  const child = proxy.children.find(entry => entry.destination.name === 'alpha').child;
303
355
  child.process.kill('SIGKILL');
304
356
  await new Promise(resolve => setTimeout(resolve, 25));
305
- const failed = await proxy.handle({ jsonrpc: '2.0', id: 7, method: 'tools/call', params: { name: 'alpha__GetSource' } });
306
- assert.equal(failed.error.code, -32001);
357
+ // A crashed child no longer fails the call: self-healing restarts it and
358
+ // retries once, so the request goes through to a fresh child.
359
+ const healed = await proxy.handle({ jsonrpc: '2.0', id: 7, method: 'tools/call', params: { name: 'alpha__GetSource' } });
360
+ assert.equal(healed?.error, undefined, `call must heal after child crash: ${JSON.stringify(healed?.error)}`);
361
+ assert.equal(healed.result.content[0].text, 'alpha:GetSource');
307
362
  assert.ok(logs.some(message => message.includes('alpha') && message.includes('tools/call GetSource failed')));
363
+ assert.ok(logs.some(message => message.includes('self-healing restart complete')), 'restart must be logged');
308
364
  });
309
365
  test('stdin EOF shuts down every child process and forwards child logs', async t => {
310
366
  const { directory, log, proxy } = await fixtureProxy();