sap-ai-dev-toolkit 0.2.5
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 +25 -0
- package/.github/agents/abap-runtime-debugger.agent.md +27 -0
- package/.github/agents/rap-service-developer.agent.md +27 -0
- package/.github/agents/sap-solution-architect.agent.md +31 -0
- package/.github/skills/abap-debugging/SKILL.md +15 -0
- package/.github/skills/abap-development/SKILL.md +16 -0
- package/.github/skills/abap-runtime-analysis/SKILL.md +17 -0
- package/.github/skills/abap-testing-quality/SKILL.md +19 -0
- package/.github/skills/cds-development/SKILL.md +16 -0
- package/.github/skills/clean-core-extensibility/SKILL.md +16 -0
- package/.github/skills/rap-development/SKILL.md +16 -0
- package/.github/skills/rap-service-delivery/SKILL.md +17 -0
- package/.github/skills/sap-sdlc-orchestration/SKILL.md +16 -0
- package/.github/skills/sap-standard-api-analysis/SKILL.md +16 -0
- package/.github/skills/sap-transport-release/SKILL.md +14 -0
- package/LICENSE +22 -0
- package/LICENSE-APACHE-2.0.txt +201 -0
- package/NOTICE +33 -0
- package/README.md +723 -0
- package/dist/checksums.txt +5 -0
- package/dist/vsp-darwin-arm64 +0 -0
- package/dist/vsp-darwin-x64 +0 -0
- package/dist/vsp-linux-arm64 +0 -0
- package/dist/vsp-linux-x64 +0 -0
- package/dist/vsp-win32-x64.exe +0 -0
- package/package.json +52 -0
- package/patches/vsp-bas-proxy-auth.patch +310 -0
- package/scripts/build-vsp.mjs +52 -0
- package/scripts/ensure-go.mjs +59 -0
- package/scripts/install-user-copilot-assets.mjs +110 -0
- package/scripts/mutation-terminal-ui.mjs +69 -0
- package/scripts/postinstall.mjs +269 -0
- package/scripts/publish-npm.mjs +97 -0
- package/src/abaplint.mjs +105 -0
- package/src/bas-discovery.mjs +210 -0
- package/src/binary.mjs +99 -0
- package/src/cf-connectivity.mjs +317 -0
- package/src/cf-destination.mjs +793 -0
- package/src/launcher.mjs +144 -0
- package/src/mcp-config.mjs +253 -0
- package/src/mcp-proxy.mjs +388 -0
- package/src/setup.mjs +275 -0
- package/src/terminal-ui.mjs +67 -0
- package/test/cf-connectivity.test.mjs +287 -0
- package/test/cf-destination.test.mjs +307 -0
- package/test/cf-runtime.test.mjs +505 -0
- package/test/copilot-assets.test.mjs +37 -0
- package/test/copilot-content.test.mjs +145 -0
- package/test/discovery.test.mjs +199 -0
- package/test/fixtures/fake-vsp.mjs +244 -0
- package/test/launcher.test.mjs +164 -0
- package/test/mcp-config-cf.test.mjs +149 -0
- package/test/mcp-proxy.test.mjs +337 -0
- package/test/setup-cf.test.mjs +362 -0
- package/test/setup.test.mjs +376 -0
- package/test/terminal-ui.test.mjs +47 -0
- package/tools.md +68 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { readFile, readdir, stat } from 'node:fs/promises';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
|
|
6
|
+
const root = process.cwd();
|
|
7
|
+
const agentsRoot = join(root, '.github', 'agents');
|
|
8
|
+
const agentPath = join(agentsRoot, 'abap-developer.agent.md');
|
|
9
|
+
const skillsRoot = join(root, '.github', 'skills');
|
|
10
|
+
const expectedAgents = [
|
|
11
|
+
'abap-developer.agent.md',
|
|
12
|
+
'abap-runtime-debugger.agent.md',
|
|
13
|
+
'rap-service-developer.agent.md',
|
|
14
|
+
'sap-solution-architect.agent.md'
|
|
15
|
+
];
|
|
16
|
+
const expectedSkills = [
|
|
17
|
+
'abap-debugging',
|
|
18
|
+
'abap-development',
|
|
19
|
+
'abap-runtime-analysis',
|
|
20
|
+
'abap-testing-quality',
|
|
21
|
+
'cds-development',
|
|
22
|
+
'clean-core-extensibility',
|
|
23
|
+
'rap-development',
|
|
24
|
+
'rap-service-delivery',
|
|
25
|
+
'sap-sdlc-orchestration',
|
|
26
|
+
'sap-standard-api-analysis',
|
|
27
|
+
'sap-transport-release'
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
async function readMarkdown(path) {
|
|
31
|
+
return readFile(path, 'utf8');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function parseFrontmatter(markdown, path) {
|
|
35
|
+
const match = /^---\n([\s\S]*?)\n---\n/.exec(markdown);
|
|
36
|
+
assert.ok(match, `${path} must start with YAML frontmatter`);
|
|
37
|
+
const metadata = {};
|
|
38
|
+
for (const line of match[1].split('\n')) {
|
|
39
|
+
const separator = line.indexOf(':');
|
|
40
|
+
assert.ok(separator > 0, `${path} has invalid frontmatter line: ${line}`);
|
|
41
|
+
metadata[line.slice(0, separator).trim()] = line.slice(separator + 1).trim();
|
|
42
|
+
}
|
|
43
|
+
return metadata;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function assertMentionsLiveToolDiscovery(markdown, path) {
|
|
47
|
+
assert.match(markdown, /tools\/list/i, `${path} must require live MCP tool discovery`);
|
|
48
|
+
assert.match(markdown, /destination-prefixed|destination-prefixed names/i, `${path} must tell agents to use actual destination-prefixed tool names`);
|
|
49
|
+
assert.match(markdown, /schema/i, `${path} must tell agents to follow live tool schemas`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function assertValidationPolicy(markdown, path) {
|
|
53
|
+
assert.match(markdown, /ABAP Unit|RunUnitTests/i, `${path} must include ABAP Unit or test execution guidance`);
|
|
54
|
+
assert.match(markdown, /runtime validation|Self-validate|executable validation|reproduce/i, `${path} must require executable/runtime validation evidence`);
|
|
55
|
+
assert.match(markdown, /activate|activation/i, `${path} must handle activation state or blockers`);
|
|
56
|
+
assert.match(markdown, /report|Record|document/i, `${path} must require observed outcome reporting`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function assertNoUnsafeTransportClaims(markdown, path) {
|
|
60
|
+
const releaseMentions = markdown.match(/ReleaseTransport|release\/deletion|release and deletion|release is unavailable|release action/gi) || [];
|
|
61
|
+
assert.ok(releaseMentions.length > 0 || !/transport/i.test(markdown), `${path} transport guidance should explicitly handle release limitations when transport is discussed`);
|
|
62
|
+
assert.doesNotMatch(markdown, /use\s+`?ReleaseTransport`?|call\s+`?ReleaseTransport`?|with\s+`?ReleaseTransport`?/i, `${path} must not instruct the agent to release transports through this addon`);
|
|
63
|
+
assert.doesNotMatch(markdown, /use\s+`?DeleteTransport`?|call\s+`?DeleteTransport`?|with\s+`?DeleteTransport`?/i, `${path} must not instruct the agent to delete transports through this addon`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
test('packaged Copilot agent and skill inventory is complete and canonical', async () => {
|
|
67
|
+
assert.equal((await stat(agentPath)).isFile(), true);
|
|
68
|
+
assert.deepEqual((await readdir(agentsRoot)).sort(), expectedAgents);
|
|
69
|
+
const actualSkills = (await readdir(skillsRoot)).sort();
|
|
70
|
+
assert.deepEqual(actualSkills, expectedSkills);
|
|
71
|
+
for (const skill of expectedSkills) {
|
|
72
|
+
assert.equal((await stat(join(skillsRoot, skill, 'SKILL.md'))).isFile(), true, `${skill} must have SKILL.md`);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('ABAP Developer agent has valid metadata and invokes the packaged skills', async () => {
|
|
77
|
+
const markdown = await readMarkdown(agentPath);
|
|
78
|
+
const metadata = parseFrontmatter(markdown, agentPath);
|
|
79
|
+
assert.deepEqual(Object.keys(metadata).sort(), ['description', 'name', 'target', 'user-invocable']);
|
|
80
|
+
assert.equal(metadata.name, 'ABAP Developer');
|
|
81
|
+
assert.equal(metadata.target, 'vscode');
|
|
82
|
+
assert.equal(metadata['user-invocable'], 'true');
|
|
83
|
+
assert.match(metadata.description, /ABAP/i);
|
|
84
|
+
assert.match(metadata.description, /BAS|SAP/i);
|
|
85
|
+
|
|
86
|
+
for (const skill of ['ABAP implementation', 'RAP', 'CDS', 'debugging', 'transport']) {
|
|
87
|
+
assert.match(markdown, new RegExp(skill, 'i'), `agent should route to ${skill} skill guidance`);
|
|
88
|
+
}
|
|
89
|
+
assertMentionsLiveToolDiscovery(markdown, agentPath);
|
|
90
|
+
assertValidationPolicy(markdown, agentPath);
|
|
91
|
+
assertNoUnsafeTransportClaims(markdown, agentPath);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('SAP Solution Architect agent has valid metadata and planning/handoff policy', async () => {
|
|
95
|
+
const path = join(agentsRoot, 'sap-solution-architect.agent.md');
|
|
96
|
+
const markdown = await readMarkdown(path);
|
|
97
|
+
const metadata = parseFrontmatter(markdown, path);
|
|
98
|
+
assert.deepEqual(Object.keys(metadata).sort(), ['description', 'name', 'target', 'user-invocable']);
|
|
99
|
+
assert.equal(metadata.name, 'SAP Solution Architect');
|
|
100
|
+
assert.equal(metadata.target, 'vscode');
|
|
101
|
+
assert.equal(metadata['user-invocable'], 'true');
|
|
102
|
+
assert.match(metadata.description, /standard|Clean Core|side-by-side|SDLC/i);
|
|
103
|
+
assertMentionsLiveToolDiscovery(markdown, path);
|
|
104
|
+
assert.match(markdown, /GetAPIReleaseState/i);
|
|
105
|
+
assert.match(markdown, /Clean Core/i);
|
|
106
|
+
assert.match(markdown, /side-by-side/i);
|
|
107
|
+
assert.match(markdown, /ABAP Developer/i);
|
|
108
|
+
assert.match(markdown, /RAP Service Developer/i);
|
|
109
|
+
assert.match(markdown, /ABAP Runtime Debugger/i);
|
|
110
|
+
assert.match(markdown, /SDLC|lifecycle/i);
|
|
111
|
+
assertNoUnsafeTransportClaims(markdown, path);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('each skill has valid metadata, matching name, and enforceable live-tool validation policy', async () => {
|
|
115
|
+
for (const skill of expectedSkills) {
|
|
116
|
+
const path = join(skillsRoot, skill, 'SKILL.md');
|
|
117
|
+
const markdown = await readMarkdown(path);
|
|
118
|
+
const metadata = parseFrontmatter(markdown, path);
|
|
119
|
+
assert.deepEqual(Object.keys(metadata).sort(), ['description', 'name']);
|
|
120
|
+
assert.equal(metadata.name, skill, `${skill} frontmatter name must match its directory`);
|
|
121
|
+
assert.ok(metadata.description.length >= 40, `${skill} needs a useful description`);
|
|
122
|
+
assert.match(markdown, /^#\s+\S.+$/m, `${skill} must have a top-level heading`);
|
|
123
|
+
assertMentionsLiveToolDiscovery(markdown, path);
|
|
124
|
+
assertNoUnsafeTransportClaims(markdown, path);
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('implementation-oriented skills require tests, runtime evidence, activation, and separate reporting', async () => {
|
|
129
|
+
for (const skill of ['abap-development', 'abap-testing-quality', 'cds-development', 'rap-development', 'sap-transport-release']) {
|
|
130
|
+
const path = join(skillsRoot, skill, 'SKILL.md');
|
|
131
|
+
const markdown = await readMarkdown(path);
|
|
132
|
+
assertValidationPolicy(markdown, path);
|
|
133
|
+
assert.match(markdown, /do not (?:treat|present|claim)|never report|not replace|not .*substitutes?/i, `${skill} must explicitly prevent substituting static checks for behavior evidence`);
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('debugging skill requires evidence before fixes and bounded debugger use', async () => {
|
|
138
|
+
const path = join(skillsRoot, 'abap-debugging', 'SKILL.md');
|
|
139
|
+
const markdown = await readMarkdown(path);
|
|
140
|
+
assert.match(markdown, /reproduce/i);
|
|
141
|
+
assert.match(markdown, /dumps?|ListDumps/i);
|
|
142
|
+
assert.match(markdown, /DebuggerDetach/i);
|
|
143
|
+
assert.match(markdown, /Avoid attaching to another user's session|do not.*another user's session/i);
|
|
144
|
+
assert.match(markdown, /before changing code|before proposing a cause/i);
|
|
145
|
+
});
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { createServer } from 'node:http';
|
|
4
|
+
import { normalizeDestination, destinationUrl, discoverDestinations, fetchDestinationList, redact, sanitizeChildEnv, slugifyDestination } from '../src/bas-discovery.mjs';
|
|
5
|
+
|
|
6
|
+
test('normalizes destination metadata case-insensitively', () => {
|
|
7
|
+
const destination = normalizeDestination({
|
|
8
|
+
Name: 'DEV-ABAP',
|
|
9
|
+
Properties: { 'WebIDEEnabled': 'true', 'HTML5.DynamicDestination': true, 'WebIDEUsage': 'dev_abap', 'SAP-Client': '200', Authentication: 'PrincipalPropagation', URL: 'https://backend.example' }
|
|
10
|
+
});
|
|
11
|
+
assert.deepEqual(destination, {
|
|
12
|
+
name: 'DEV-ABAP', authentication: 'PrincipalPropagation', client: '200',
|
|
13
|
+
url: 'http://DEV-ABAP.dest', rawKeys: ['Name', 'Properties']
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test('returns every named destination regardless of ADT probe and preserves allowlists', async () => {
|
|
18
|
+
const body = { DESTINATIONS: [
|
|
19
|
+
{ Name: 'z-system', WebIDEEnabled: true, 'HTML5.DynamicDestination': false, WebIDEUsage: 'ui5' },
|
|
20
|
+
{ Name: 'b-system', WebIDEEnabled: false, 'HTML5.DynamicDestination': false, WebIDEUsage: 'ui5' },
|
|
21
|
+
{ Name: 'a-system', WebIDEEnabled: false, 'HTML5.DynamicDestination': false, WebIDEUsage: 'ui5' }
|
|
22
|
+
] };
|
|
23
|
+
const probeImpl = async url => ({ status: url.includes('a-system') ? 403 : url.includes('b-system') ? 200 : 404 });
|
|
24
|
+
const allowlisted = await discoverDestinations({
|
|
25
|
+
body,
|
|
26
|
+
env: { BAS_VSP_DESTINATION: 'a-system,b-system' },
|
|
27
|
+
probeImpl
|
|
28
|
+
});
|
|
29
|
+
assert.deepEqual(allowlisted.map(item => [item.name, item.client, item.probe.status]), [
|
|
30
|
+
['a-system', '001', 'auth-required'],
|
|
31
|
+
['b-system', '001', 'available']
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
const all = await discoverDestinations({ body, env: {}, probeImpl });
|
|
35
|
+
assert.deepEqual(all.map(item => [item.name, item.probe.status]), [
|
|
36
|
+
['a-system', 'auth-required'],
|
|
37
|
+
['b-system', 'available'],
|
|
38
|
+
['z-system', 'not-found']
|
|
39
|
+
]);
|
|
40
|
+
});
|
|
41
|
+
test('parses sample-style destination records and enables every ADT heartbeat response', async () => {
|
|
42
|
+
const body = [
|
|
43
|
+
{
|
|
44
|
+
Type: 'HTTP',
|
|
45
|
+
'HTML5.DynamicDestination': 'true',
|
|
46
|
+
Authentication: 'PrincipalPropagation',
|
|
47
|
+
'sap-client': '200',
|
|
48
|
+
Name: 'DGT_200',
|
|
49
|
+
WebIDEUsage: 'odata_abap,ui5_execute_abap,dev_abap',
|
|
50
|
+
WebIDEEnabled: 'true'
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
Name: 'S4D_100',
|
|
54
|
+
Type: 'HTTP',
|
|
55
|
+
Authentication: 'PrincipalPropagation',
|
|
56
|
+
'HTML5.DynamicDestination': 'true',
|
|
57
|
+
'sap-client': '100',
|
|
58
|
+
WebIDEUsage: 'odata_abap',
|
|
59
|
+
WebIDEEnabled: 'true'
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
Name: 'SUP_BACKEND',
|
|
63
|
+
Type: 'HTTP',
|
|
64
|
+
Authentication: 'OAuth2ClientCredentials',
|
|
65
|
+
'HTML5.DynamicDestination': 'true',
|
|
66
|
+
WebIDEUsage: 'true',
|
|
67
|
+
WebIDEEnabled: 'true'
|
|
68
|
+
}
|
|
69
|
+
];
|
|
70
|
+
const probed = [];
|
|
71
|
+
const destinations = await discoverDestinations({
|
|
72
|
+
body,
|
|
73
|
+
env: {},
|
|
74
|
+
probeImpl: async url => {
|
|
75
|
+
probed.push(url);
|
|
76
|
+
if (url.includes('DGT_200.dest')) return { status: 200 };
|
|
77
|
+
if (url.includes('S4D_100.dest')) return { status: 403 };
|
|
78
|
+
return { status: 404 };
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
assert.deepEqual(probed, [
|
|
83
|
+
'http://DGT_200.dest/sap/bc/adt/discovery',
|
|
84
|
+
'http://S4D_100.dest/sap/bc/adt/discovery',
|
|
85
|
+
'http://SUP_BACKEND.dest/sap/bc/adt/discovery'
|
|
86
|
+
]);
|
|
87
|
+
assert.deepEqual(destinations.map(({ name, client, authentication, probe }) => [
|
|
88
|
+
name, client, authentication, probe.status, probe.available
|
|
89
|
+
]), [
|
|
90
|
+
['DGT_200', '200', 'PrincipalPropagation', 'available', true],
|
|
91
|
+
['S4D_100', '100', 'PrincipalPropagation', 'auth-required', true],
|
|
92
|
+
['SUP_BACKEND', '001', 'OAuth2ClientCredentials', 'not-found', false]
|
|
93
|
+
]);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('keeps T4D destinations when their ADT probes fail', async () => {
|
|
97
|
+
const destinations = await discoverDestinations({
|
|
98
|
+
body: [
|
|
99
|
+
{ Name: 'T4D_100', 'sap-client': '100' },
|
|
100
|
+
{ Name: 'T4D_200', 'sap-client': '200' }
|
|
101
|
+
],
|
|
102
|
+
env: {},
|
|
103
|
+
probeImpl: async url => ({ status: url.includes('T4D_100') ? 503 : 404 })
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
assert.deepEqual(destinations.map(({ name, client, probe }) => [
|
|
107
|
+
name, client, probe.status, probe.httpStatus, probe.available
|
|
108
|
+
]), [
|
|
109
|
+
['T4D_100', '100', 'unavailable', 503, false],
|
|
110
|
+
['T4D_200', '200', 'not-found', 404, false]
|
|
111
|
+
]);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
test('rejects invalid destination URL names and creates deterministic collision slugs', () => {
|
|
116
|
+
assert.equal(destinationUrl('A-1_2'), 'http://A-1_2.dest');
|
|
117
|
+
assert.throws(() => destinationUrl('backend.example/path'), /Invalid BAS destination name/);
|
|
118
|
+
const used = new Map();
|
|
119
|
+
assert.deepEqual(['Dev System', 'dev-system', 'DEV_SYSTEM'].map(name => slugifyDestination(name, used)), ['dev-system', 'dev-system-2', 'dev-system-3']);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('sanitizes child identity material and preserves caller proxy safety settings', () => {
|
|
123
|
+
const env = sanitizeChildEnv({ HTTP_PROXY: 'http://proxy:8887', NO_PROXY: 'localhost,.dest,127.0.0.1', Authorization: 'Bearer secret', Cookie: 'SAP_SESSION=secret', SAP_USER: 'alice', SAP_PASSWORD: 'secret', SAP_READ_ONLY: 'true' });
|
|
124
|
+
assert.equal(env.SAP_PROXY_CONTEXTID_GUARD, 'true');
|
|
125
|
+
assert.equal(env.HTTP_PROXY, 'http://proxy:8887');
|
|
126
|
+
assert.equal(env.NO_PROXY, 'localhost,127.0.0.1');
|
|
127
|
+
assert.equal(env.SAP_READ_ONLY, 'true');
|
|
128
|
+
assert.equal(env.Authorization, undefined);
|
|
129
|
+
assert.equal(env.Cookie, undefined);
|
|
130
|
+
assert.equal(env.SAP_USER, undefined);
|
|
131
|
+
assert.equal(env.SAP_PASSWORD, undefined);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('probes .dest only through configured HTTP proxy and accepts proxy-auth responses', async () => {
|
|
135
|
+
const requests = [];
|
|
136
|
+
const proxy = createServer((request, response) => {
|
|
137
|
+
requests.push({ host: request.headers.host, path: request.url });
|
|
138
|
+
response.writeHead(401);
|
|
139
|
+
response.end();
|
|
140
|
+
});
|
|
141
|
+
await new Promise(resolve => proxy.listen(0, '127.0.0.1', resolve));
|
|
142
|
+
const port = proxy.address().port;
|
|
143
|
+
const found = await discoverDestinations({
|
|
144
|
+
body: [{ Name: 'proxy-system' }],
|
|
145
|
+
env: { HTTP_PROXY: `http://127.0.0.1:${port}` }
|
|
146
|
+
});
|
|
147
|
+
await new Promise(resolve => proxy.close(resolve));
|
|
148
|
+
assert.equal(found[0].probe.status, 'auth-required');
|
|
149
|
+
assert.deepEqual(requests, [{ host: 'proxy-system.dest', path: 'http://proxy-system.dest/sap/bc/adt/discovery' }]);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('fetches BAS destination lists through configured HTTP and HTTPS proxies', async t => {
|
|
153
|
+
const proxyRequests = [];
|
|
154
|
+
const directRequests = [];
|
|
155
|
+
const target = createServer((request, response) => {
|
|
156
|
+
directRequests.push({ method: request.method, host: request.headers.host, path: request.url });
|
|
157
|
+
response.writeHead(200, { 'content-type': 'application/json' });
|
|
158
|
+
response.end(JSON.stringify([{ Name: 'direct-system' }]));
|
|
159
|
+
});
|
|
160
|
+
await new Promise(resolve => target.listen(0, '127.0.0.1', resolve));
|
|
161
|
+
const proxy = createServer((request, response) => {
|
|
162
|
+
proxyRequests.push({ method: request.method, host: request.headers.host, path: request.url });
|
|
163
|
+
response.writeHead(200, { 'content-type': 'application/json' });
|
|
164
|
+
response.end(JSON.stringify([{ Name: 'proxied-system' }]));
|
|
165
|
+
});
|
|
166
|
+
proxy.on('connect', (request, socket) => {
|
|
167
|
+
proxyRequests.push({ method: request.method, host: request.url });
|
|
168
|
+
socket.end('HTTP/1.1 502 Bad Gateway\r\n\r\n');
|
|
169
|
+
});
|
|
170
|
+
await new Promise(resolve => proxy.listen(0, '127.0.0.1', resolve));
|
|
171
|
+
t.after(async () => {
|
|
172
|
+
await Promise.all([target, proxy].map(server => new Promise(resolve => server.close(resolve))));
|
|
173
|
+
});
|
|
174
|
+
const proxyUrl = `http://127.0.0.1:${proxy.address().port}`;
|
|
175
|
+
const env = { http_proxy: proxyUrl, https_proxy: proxyUrl, no_proxy: '' };
|
|
176
|
+
const body = await fetchDestinationList('http://bas.example', { env, timeoutMs: 1000 });
|
|
177
|
+
assert.deepEqual(body, [{ Name: 'proxied-system' }]);
|
|
178
|
+
await assert.rejects(fetchDestinationList('https://bas.example', { env, timeoutMs: 1000 }));
|
|
179
|
+
assert.deepEqual(proxyRequests, [
|
|
180
|
+
{ method: 'GET', host: 'bas.example', path: 'http://bas.example/api/listDestinations' },
|
|
181
|
+
{ method: 'CONNECT', host: 'bas.example:443' }
|
|
182
|
+
]);
|
|
183
|
+
const directBody = await fetchDestinationList(`http://127.0.0.1:${target.address().port}`, {
|
|
184
|
+
env: { HTTP_PROXY: proxyUrl, NO_PROXY: '127.0.0.1' },
|
|
185
|
+
timeoutMs: 1000
|
|
186
|
+
});
|
|
187
|
+
assert.deepEqual(directBody, [{ Name: 'direct-system' }]);
|
|
188
|
+
assert.deepEqual(directRequests, [{
|
|
189
|
+
method: 'GET',
|
|
190
|
+
host: `127.0.0.1:${target.address().port}`,
|
|
191
|
+
path: '/api/listDestinations'
|
|
192
|
+
}]);
|
|
193
|
+
assert.equal(proxyRequests.length, 2);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test('redacts credential-bearing diagnostics without exposing payloads', () => {
|
|
197
|
+
const value = redact({ name: 'dev', password: 'secret', authorization: 'Bearer abc', nested: { cookie: 'x', ok: 'visible' } });
|
|
198
|
+
assert.deepEqual(value, { name: 'dev', password: '[redacted]', authorization: '[redacted]', nested: { cookie: '[redacted]', ok: 'visible' } });
|
|
199
|
+
});
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { appendFileSync } from 'node:fs';
|
|
3
|
+
import { request as httpRequest } from 'node:http';
|
|
4
|
+
|
|
5
|
+
const args = process.argv.slice(2);
|
|
6
|
+
const mode = args[args.indexOf('--mode') + 1] || 'focused';
|
|
7
|
+
const url = args[args.indexOf('--url') + 1] || 'unknown';
|
|
8
|
+
const destination = url.replace(/^https?:\/\//, '').replace(/\.dest$/, '');
|
|
9
|
+
const logPath = process.env.FAKE_LOG;
|
|
10
|
+
function log(entry) { if (logPath) appendFileSync(logPath, `${JSON.stringify({ destination, ...entry })}\n`); }
|
|
11
|
+
function reply(id, result) { process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id, result })}\n`); }
|
|
12
|
+
function requestADTThroughProxy(targetUrl, proxyUrl) {
|
|
13
|
+
const target = new URL(targetUrl);
|
|
14
|
+
const proxy = new URL(proxyUrl);
|
|
15
|
+
if (target.protocol !== 'http:') throw new Error('fixture ADT request supports HTTP only');
|
|
16
|
+
const headers = { host: target.host, accept: 'application/xml,text/xml,*/*' };
|
|
17
|
+
if (process.env.SAP_USER && process.env.SAP_PASSWORD) {
|
|
18
|
+
headers.authorization = `Basic ${Buffer.from(`${process.env.SAP_USER}:${process.env.SAP_PASSWORD}`).toString('base64')}`;
|
|
19
|
+
}
|
|
20
|
+
return new Promise((resolve, reject) => {
|
|
21
|
+
const request = httpRequest({
|
|
22
|
+
hostname: proxy.hostname,
|
|
23
|
+
port: Number(proxy.port),
|
|
24
|
+
method: 'GET',
|
|
25
|
+
path: target.href,
|
|
26
|
+
headers
|
|
27
|
+
}, response => {
|
|
28
|
+
let body = '';
|
|
29
|
+
response.setEncoding('utf8');
|
|
30
|
+
response.on('data', chunk => { body += chunk; });
|
|
31
|
+
response.on('end', () => resolve({ status: response.statusCode || 0, body }));
|
|
32
|
+
});
|
|
33
|
+
request.setTimeout(5_000, () => request.destroy(new Error('fixture ADT request timed out')));
|
|
34
|
+
request.on('error', reject);
|
|
35
|
+
request.end();
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
const toolNames = [
|
|
39
|
+
'GetSource',
|
|
40
|
+
'WriteSource',
|
|
41
|
+
'EditSource',
|
|
42
|
+
'SearchObject',
|
|
43
|
+
'GrepObjects',
|
|
44
|
+
'GrepPackages',
|
|
45
|
+
'FindDefinition',
|
|
46
|
+
'FindReferences',
|
|
47
|
+
'GetContext',
|
|
48
|
+
'SyntaxCheck',
|
|
49
|
+
'Activate',
|
|
50
|
+
'ActivatePackage',
|
|
51
|
+
'CompareSource',
|
|
52
|
+
'GetClassInfo',
|
|
53
|
+
'CreatePackage',
|
|
54
|
+
'CreateTable',
|
|
55
|
+
'GetTable',
|
|
56
|
+
'GetTableContents',
|
|
57
|
+
'GetTransport',
|
|
58
|
+
'ListTransports',
|
|
59
|
+
'GetTransportInfo',
|
|
60
|
+
'GetUserTransports',
|
|
61
|
+
'RunQuery',
|
|
62
|
+
'GetPackage',
|
|
63
|
+
'GetFunctionGroup',
|
|
64
|
+
'GetCDSDependencies',
|
|
65
|
+
'GetCDSImpactAnalysis',
|
|
66
|
+
'GetAPIReleaseState',
|
|
67
|
+
'GetCDSElementInfo',
|
|
68
|
+
'GetMessages',
|
|
69
|
+
'GetFeatures',
|
|
70
|
+
'PrettyPrint',
|
|
71
|
+
'GetSystemInfo',
|
|
72
|
+
'GetInstalledComponents',
|
|
73
|
+
'RunUnitTests',
|
|
74
|
+
'RunATCCheck',
|
|
75
|
+
'GetInactiveObjects',
|
|
76
|
+
'DeleteObject',
|
|
77
|
+
'DebuggerAttach',
|
|
78
|
+
'ListSQLTraces',
|
|
79
|
+
'CreateTransport',
|
|
80
|
+
'ReleaseTransport',
|
|
81
|
+
'DeleteTransport',
|
|
82
|
+
'AnalyzeABAPCode',
|
|
83
|
+
'AnalyzeCallGraph',
|
|
84
|
+
'CodeCompletion',
|
|
85
|
+
'GetAbapHelp',
|
|
86
|
+
'GetCallGraph',
|
|
87
|
+
'GetCalleesOf',
|
|
88
|
+
'GetCallersOf',
|
|
89
|
+
'GetCodeCoverage',
|
|
90
|
+
'GetConnectionInfo',
|
|
91
|
+
'GetObjectStructure',
|
|
92
|
+
'GetTypeHierarchy',
|
|
93
|
+
'GetTypeInfo',
|
|
94
|
+
'GrepObject',
|
|
95
|
+
'GrepPackage',
|
|
96
|
+
'CallRFC',
|
|
97
|
+
'DebuggerDetach',
|
|
98
|
+
'DebuggerGetStack',
|
|
99
|
+
'DebuggerGetVariables',
|
|
100
|
+
'DebuggerListen',
|
|
101
|
+
'DebuggerStep',
|
|
102
|
+
'DeleteBreakpoint',
|
|
103
|
+
'GetBreakpoints',
|
|
104
|
+
'GetDump',
|
|
105
|
+
'GetSQLTraceState',
|
|
106
|
+
'GetTrace',
|
|
107
|
+
'ListDumps',
|
|
108
|
+
'SetBreakpoint',
|
|
109
|
+
'CloneObject',
|
|
110
|
+
'CreateAndActivateProgram',
|
|
111
|
+
'CreateClassWithTests',
|
|
112
|
+
'CreateObject',
|
|
113
|
+
'CreateTestInclude',
|
|
114
|
+
'ExecuteABAP',
|
|
115
|
+
'GetClass',
|
|
116
|
+
'GetClassComponents',
|
|
117
|
+
'GetClassInclude',
|
|
118
|
+
'GetFunction',
|
|
119
|
+
'GetInclude',
|
|
120
|
+
'GetInterface',
|
|
121
|
+
'GetProgram',
|
|
122
|
+
'GetStructure',
|
|
123
|
+
'GetTransaction',
|
|
124
|
+
'LockObject',
|
|
125
|
+
'MoveObject',
|
|
126
|
+
'RecoverFailedCreate',
|
|
127
|
+
'RenameObject',
|
|
128
|
+
'SaveToFile',
|
|
129
|
+
'UnlockObject',
|
|
130
|
+
'UpdateClassInclude',
|
|
131
|
+
'UpdateSource',
|
|
132
|
+
'WriteClass',
|
|
133
|
+
'WriteProgram',
|
|
134
|
+
'ListDependencies',
|
|
135
|
+
'PublishServiceBinding',
|
|
136
|
+
'UnpublishServiceBinding'
|
|
137
|
+
];
|
|
138
|
+
if (mode === 'expert') toolNames.push('ActivateMultiple', 'SAP');
|
|
139
|
+
|
|
140
|
+
function toolDefinition(name) {
|
|
141
|
+
const schemas = {
|
|
142
|
+
RunQuery: {
|
|
143
|
+
type: 'object',
|
|
144
|
+
properties: {
|
|
145
|
+
sql_query: { type: 'string' },
|
|
146
|
+
max_rows: { type: 'number' },
|
|
147
|
+
all_rows: { type: 'boolean' }
|
|
148
|
+
},
|
|
149
|
+
required: ['sql_query']
|
|
150
|
+
},
|
|
151
|
+
GetAPIReleaseState: { type: 'object', properties: { object_uri: { type: 'string' } }, required: ['object_uri'] },
|
|
152
|
+
PrettyPrint: { type: 'object', properties: { source: { type: 'string' } }, required: ['source'] },
|
|
153
|
+
GetTransport: { type: 'object', properties: { transport: { type: 'string' } }, required: ['transport'] },
|
|
154
|
+
GetTransportInfo: {
|
|
155
|
+
type: 'object',
|
|
156
|
+
properties: { object_url: { type: 'string' }, dev_class: { type: 'string' } },
|
|
157
|
+
required: ['object_url', 'dev_class']
|
|
158
|
+
},
|
|
159
|
+
GetUserTransports: {
|
|
160
|
+
type: 'object',
|
|
161
|
+
properties: { user_name: { type: 'string' } },
|
|
162
|
+
required: []
|
|
163
|
+
},
|
|
164
|
+
ActivateMultiple: {
|
|
165
|
+
type: 'object',
|
|
166
|
+
properties: {
|
|
167
|
+
objects: {
|
|
168
|
+
type: 'array',
|
|
169
|
+
items: {
|
|
170
|
+
anyOf: [
|
|
171
|
+
{
|
|
172
|
+
type: 'object',
|
|
173
|
+
properties: { url: { type: 'string' }, name: { type: 'string' } },
|
|
174
|
+
required: ['url', 'name']
|
|
175
|
+
},
|
|
176
|
+
{ type: 'string' }
|
|
177
|
+
]
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
},
|
|
181
|
+
required: ['objects']
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
return { name, description: name, inputSchema: schemas[name] || { type: 'object', properties: {}, required: [] } };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
let buffer = '';
|
|
188
|
+
process.stdin.setEncoding('utf8');
|
|
189
|
+
process.stdin.on('data', async chunk => {
|
|
190
|
+
buffer += chunk;
|
|
191
|
+
let end;
|
|
192
|
+
while ((end = buffer.indexOf('\n')) >= 0) {
|
|
193
|
+
const line = buffer.slice(0, end).trim();
|
|
194
|
+
buffer = buffer.slice(end + 1);
|
|
195
|
+
if (!line) continue;
|
|
196
|
+
const message = JSON.parse(line);
|
|
197
|
+
if (message.method === 'initialize') {
|
|
198
|
+
log({ event: 'initialize', argv: args, env: { guard: process.env.SAP_PROXY_CONTEXTID_GUARD, authorization: process.env.Authorization, cookie: process.env.Cookie, user: process.env.SAP_USER, password: process.env.SAP_PASSWORD, verbose: process.env.SAP_VERBOSE, httpProxy: process.env.HTTP_PROXY, httpsProxy: process.env.HTTPS_PROXY, noProxy: process.env.NO_PROXY, allowTransportableEdits: process.env.SAP_ALLOW_TRANSPORTABLE_EDITS } });
|
|
199
|
+
if (destination === 'broken') process.exit(2);
|
|
200
|
+
reply(message.id, { protocolVersion: '2024-11-05', capabilities: { tools: {} }, serverInfo: { name: destination, version: 'fixture' } });
|
|
201
|
+
} else if (message.method === 'tools/list') {
|
|
202
|
+
reply(message.id, message.params?.cursor
|
|
203
|
+
? { tools: toolNames.slice(15).map(toolDefinition) }
|
|
204
|
+
: { tools: toolNames.slice(0, 15).map(toolDefinition), nextCursor: 'page-2' });
|
|
205
|
+
} else if (message.method === 'tools/call') {
|
|
206
|
+
const name = message.params?.name;
|
|
207
|
+
log({ event: 'call', name, arguments: message.params?.arguments });
|
|
208
|
+
if (message.params?.arguments?.emitNotification) {
|
|
209
|
+
process.stdout.write(`${JSON.stringify({
|
|
210
|
+
jsonrpc: '2.0',
|
|
211
|
+
method: 'notifications/message',
|
|
212
|
+
params: { level: 'info', logger: 'fake-vsp', data: 'fixture log notification' }
|
|
213
|
+
})}\n`);
|
|
214
|
+
}
|
|
215
|
+
const transportTools = [
|
|
216
|
+
'ListTransports', 'GetTransport', 'GetUserTransports', 'GetTransportInfo',
|
|
217
|
+
'CreateTransport', 'ReleaseTransport', 'DeleteTransport'
|
|
218
|
+
];
|
|
219
|
+
const disabledTransportOperation = transportTools.includes(name)
|
|
220
|
+
&& (!args.includes('--enable-transports')
|
|
221
|
+
|| (['CreateTransport', 'ReleaseTransport', 'DeleteTransport'].includes(name) && args.includes('--transport-read-only')));
|
|
222
|
+
if (disabledTransportOperation) {
|
|
223
|
+
reply(message.id, { content: [{ type: 'text', text: 'transport operation is not enabled' }], isError: true });
|
|
224
|
+
} else if (message.params?.arguments?.object === 'RPC_ERROR') {
|
|
225
|
+
process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id: message.id, error: { code: -32042, message: 'backend connection refused; password=must-not-log' } })}\n`);
|
|
226
|
+
} else if (message.params?.arguments?.object === 'TOOL_ERROR') {
|
|
227
|
+
reply(message.id, { content: [{ type: 'text', text: 'backend timeout; token=must-not-log' }], isError: true });
|
|
228
|
+
} else if (process.env.FAKE_CF_ADT_TEST === 'true' && name === 'GetSystemInfo') {
|
|
229
|
+
try {
|
|
230
|
+
const target = new URL('/sap/bc/adt/discovery', url);
|
|
231
|
+
const proxy = process.env.HTTP_PROXY || process.env.http_proxy;
|
|
232
|
+
const adt = await requestADTThroughProxy(target.href, proxy);
|
|
233
|
+
reply(message.id, { content: [{ type: 'text', text: adt.body }], isError: false });
|
|
234
|
+
} catch {
|
|
235
|
+
reply(message.id, { content: [{ type: 'text', text: 'fixture ADT request failed' }], isError: true });
|
|
236
|
+
}
|
|
237
|
+
} else {
|
|
238
|
+
reply(message.id, { content: [{ type: 'text', text: `${destination}:${name}` }], isError: false });
|
|
239
|
+
}
|
|
240
|
+
} else if (message.id !== undefined) reply(message.id, {});
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
process.stdin.on('end', () => { log({ event: 'end' }); });
|
|
244
|
+
process.on('SIGTERM', () => { log({ event: 'term' }); process.exit(0); });
|