sap-ai-dev-toolkit 0.5.0 → 0.5.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/agents/abap-developer.agent.md +3 -3
- package/.github/agents/abap-runtime-debugger.agent.md +3 -3
- package/.github/agents/hana-cloud-hdi-specialist.agent.md +1 -1
- package/.github/agents/rap-service-developer.agent.md +1 -1
- package/.github/agents/sap-solution-architect.agent.md +1 -1
- package/.github/skills/abap-debugging/SKILL.md +3 -3
- package/.github/skills/abap-development/SKILL.md +1 -1
- package/.github/skills/abap-runtime-analysis/SKILL.md +3 -3
- package/.github/skills/abap-testing-quality/SKILL.md +1 -1
- package/.github/skills/cds-development/SKILL.md +1 -1
- package/.github/skills/clean-core-extensibility/SKILL.md +1 -1
- package/.github/skills/hana-cloud-inspection/SKILL.md +1 -1
- package/.github/skills/hana-cloud-native-development/SKILL.md +2 -2
- package/.github/skills/hana-cloud-validation/SKILL.md +1 -1
- package/.github/skills/rap-development/SKILL.md +1 -1
- package/.github/skills/rap-service-delivery/SKILL.md +1 -1
- package/.github/skills/sap-sdlc-orchestration/SKILL.md +1 -1
- package/.github/skills/sap-standard-api-analysis/SKILL.md +1 -1
- package/.github/skills/sap-transport-release/SKILL.md +1 -1
- package/README.md +17 -11
- package/inventory.md +179 -0
- package/package.json +2 -2
- package/scripts/build-vsp.mjs +2 -1
- package/scripts/postinstall.mjs +17 -18
- package/src/bas-destination-relay.mjs +58 -22
- package/src/bas-discovery.mjs +24 -8
- package/src/binary.mjs +101 -17
- package/src/cf-destination.mjs +55 -1
- package/src/credentials-store.mjs +0 -10
- package/src/engineering-tools.mjs +32 -10
- package/src/hana-tools.mjs +9 -2
- package/src/launcher.mjs +22 -19
- package/src/mcp-config.mjs +41 -7
- package/src/mcp-proxy.mjs +392 -104
- package/src/redact.mjs +28 -0
- package/src/setup.mjs +33 -3
- package/tools.md +4 -2
- package/test/bas-destination-relay.test.mjs +0 -374
- package/test/cf-connectivity.test.mjs +0 -287
- package/test/cf-destination.test.mjs +0 -307
- package/test/cf-runtime.test.mjs +0 -527
- package/test/copilot-assets.test.mjs +0 -63
- package/test/copilot-content.test.mjs +0 -249
- package/test/credentials-store.test.mjs +0 -187
- package/test/discovery.test.mjs +0 -257
- package/test/fixtures/fake-vsp.mjs +0 -263
- package/test/hana-config.test.mjs +0 -168
- package/test/hana-inspector-stdio.test.mjs +0 -44
- package/test/hana-tools.test.mjs +0 -206
- package/test/launcher.test.mjs +0 -399
- package/test/live-s4h.test.mjs +0 -202
- package/test/mcp-config-cf.test.mjs +0 -407
- package/test/mcp-proxy.test.mjs +0 -409
- package/test/pty.mjs +0 -8
- package/test/setup-cf.test.mjs +0 -489
- package/test/setup.test.mjs +0 -417
- package/test/terminal-ui.test.mjs +0 -47
|
@@ -1,249 +0,0 @@
|
|
|
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
|
-
'hana-cloud-hdi-specialist.agent.md',
|
|
14
|
-
'rap-service-developer.agent.md',
|
|
15
|
-
'sap-solution-architect.agent.md'
|
|
16
|
-
];
|
|
17
|
-
const expectedSkills = [
|
|
18
|
-
'abap-debugging',
|
|
19
|
-
'abap-development',
|
|
20
|
-
'abap-runtime-analysis',
|
|
21
|
-
'abap-testing-quality',
|
|
22
|
-
'cds-development',
|
|
23
|
-
'clean-core-extensibility',
|
|
24
|
-
'hana-cloud-inspection',
|
|
25
|
-
'hana-cloud-native-development',
|
|
26
|
-
'hana-cloud-validation',
|
|
27
|
-
'rap-development',
|
|
28
|
-
'rap-service-delivery',
|
|
29
|
-
'sap-sdlc-orchestration',
|
|
30
|
-
'sap-standard-api-analysis',
|
|
31
|
-
'sap-transport-release'
|
|
32
|
-
];
|
|
33
|
-
|
|
34
|
-
async function readMarkdown(path) {
|
|
35
|
-
return readFile(path, 'utf8');
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function parseFrontmatter(markdown, path) {
|
|
39
|
-
const match = /^---\n([\s\S]*?)\n---\n/.exec(markdown);
|
|
40
|
-
assert.ok(match, `${path} must start with YAML frontmatter`);
|
|
41
|
-
const metadata = {};
|
|
42
|
-
for (const line of match[1].split('\n')) {
|
|
43
|
-
const separator = line.indexOf(':');
|
|
44
|
-
assert.ok(separator > 0, `${path} has invalid frontmatter line: ${line}`);
|
|
45
|
-
metadata[line.slice(0, separator).trim()] = line.slice(separator + 1).trim();
|
|
46
|
-
}
|
|
47
|
-
return metadata;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
function assertMentionsLiveToolDiscovery(markdown, path) {
|
|
51
|
-
assert.match(markdown, /tools\/list/i, `${path} must require live MCP tool discovery`);
|
|
52
|
-
assert.match(markdown, /destination-prefixed|destination-prefixed names/i, `${path} must tell agents to use actual destination-prefixed tool names`);
|
|
53
|
-
assert.match(markdown, /schema/i, `${path} must tell agents to follow live tool schemas`);
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
function assertDirectMcpUsage(markdown, path) {
|
|
57
|
-
assert.match(markdown, /Direct MCP invocation is mandatory/i, `${path} must require direct MCP calls`);
|
|
58
|
-
assert.match(markdown, /Do not launch `sap-ai-dev`.*MCP operations/i, `${path} must forbid terminal-driven MCP discovery/calls`);
|
|
59
|
-
assert.match(markdown, /host\/session binding issue/i, `${path} must report host tool-binding failures instead of using a CLI fallback`);
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function assertValidationPolicy(markdown, path) {
|
|
63
|
-
assert.match(markdown, /ABAP Unit|RunUnitTests/i, `${path} must include ABAP Unit or test execution guidance`);
|
|
64
|
-
assert.match(markdown, /runtime validation|Self-validate|executable validation|reproduce/i, `${path} must require executable/runtime validation evidence`);
|
|
65
|
-
assert.match(markdown, /activate|activation/i, `${path} must handle activation state or blockers`);
|
|
66
|
-
assert.match(markdown, /report|Record|document/i, `${path} must require observed outcome reporting`);
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
function assertPlanFirstPolicy(markdown, path) {
|
|
70
|
-
assert.match(markdown, /[Pp]lan before implementing|[Pp]lanning phase before implementation/, `${path} must require a plan to be presented before implementation starts`);
|
|
71
|
-
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`);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
function assertLocalFirstPolicy(markdown, path) {
|
|
75
|
-
assert.match(markdown, /abapGit[- ]serialized|abapGit-style/, `${path} must require local abapGit-style source files`);
|
|
76
|
-
assert.match(markdown, /object\.type\.extension/, `${path} must document the object.type.extension file naming`);
|
|
77
|
-
assert.match(markdown, /LintABAP/, `${path} must require local linting with LintABAP`);
|
|
78
|
-
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`);
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
function assertNoUnsafeTransportClaims(markdown, path) {
|
|
82
|
-
const releaseMentions = markdown.match(/ReleaseTransport|release\/deletion|release and deletion|release is unavailable|release action/gi) || [];
|
|
83
|
-
assert.ok(releaseMentions.length > 0 || !/transport/i.test(markdown), `${path} transport guidance should explicitly handle release limitations when transport is discussed`);
|
|
84
|
-
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`);
|
|
85
|
-
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`);
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
test('packaged Copilot agent and skill inventory is complete and canonical', async () => {
|
|
89
|
-
assert.equal((await stat(agentPath)).isFile(), true);
|
|
90
|
-
assert.deepEqual((await readdir(agentsRoot)).sort(), expectedAgents);
|
|
91
|
-
const actualSkills = (await readdir(skillsRoot)).sort();
|
|
92
|
-
assert.deepEqual(actualSkills, expectedSkills);
|
|
93
|
-
for (const skill of expectedSkills) {
|
|
94
|
-
assert.equal((await stat(join(skillsRoot, skill, 'SKILL.md'))).isFile(), true, `${skill} must have SKILL.md`);
|
|
95
|
-
}
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
test('all bundled agents invoke chat-attached MCP tools without terminal JSON-RPC fallbacks', async () => {
|
|
99
|
-
for (const name of expectedAgents) {
|
|
100
|
-
const path = join(agentsRoot, name);
|
|
101
|
-
const markdown = await readMarkdown(path);
|
|
102
|
-
assertDirectMcpUsage(markdown, path);
|
|
103
|
-
assertMentionsLiveToolDiscovery(markdown, path);
|
|
104
|
-
}
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
test('ABAP Developer agent has valid metadata and invokes the packaged skills', async () => {
|
|
108
|
-
const markdown = await readMarkdown(agentPath);
|
|
109
|
-
const metadata = parseFrontmatter(markdown, agentPath);
|
|
110
|
-
assert.deepEqual(Object.keys(metadata).sort(), ['description', 'name', 'target', 'user-invocable']);
|
|
111
|
-
assert.equal(metadata.name, 'ABAP Developer');
|
|
112
|
-
assert.equal(metadata.target, 'vscode');
|
|
113
|
-
assert.equal(metadata['user-invocable'], 'true');
|
|
114
|
-
assert.match(metadata.description, /ABAP/i);
|
|
115
|
-
assert.match(metadata.description, /BAS|SAP/i);
|
|
116
|
-
|
|
117
|
-
for (const skill of ['ABAP implementation', 'RAP', 'CDS', 'debugging', 'transport']) {
|
|
118
|
-
assert.match(markdown, new RegExp(skill, 'i'), `agent should route to ${skill} skill guidance`);
|
|
119
|
-
}
|
|
120
|
-
assertMentionsLiveToolDiscovery(markdown, agentPath);
|
|
121
|
-
assertValidationPolicy(markdown, agentPath);
|
|
122
|
-
assertPlanFirstPolicy(markdown, agentPath);
|
|
123
|
-
assertLocalFirstPolicy(markdown, agentPath);
|
|
124
|
-
assertNoUnsafeTransportClaims(markdown, agentPath);
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
test('SAP Solution Architect agent has valid metadata and planning/handoff policy', async () => {
|
|
128
|
-
const path = join(agentsRoot, 'sap-solution-architect.agent.md');
|
|
129
|
-
const markdown = await readMarkdown(path);
|
|
130
|
-
const metadata = parseFrontmatter(markdown, path);
|
|
131
|
-
assert.deepEqual(Object.keys(metadata).sort(), ['description', 'name', 'target', 'user-invocable']);
|
|
132
|
-
assert.equal(metadata.name, 'SAP Solution Architect');
|
|
133
|
-
assert.equal(metadata.target, 'vscode');
|
|
134
|
-
assert.equal(metadata['user-invocable'], 'true');
|
|
135
|
-
assert.match(metadata.description, /standard|Clean Core|side-by-side|SDLC/i);
|
|
136
|
-
assertMentionsLiveToolDiscovery(markdown, path);
|
|
137
|
-
assert.match(markdown, /GetAPIReleaseState/i);
|
|
138
|
-
assert.match(markdown, /Clean Core/i);
|
|
139
|
-
assert.match(markdown, /side-by-side/i);
|
|
140
|
-
assert.match(markdown, /ABAP Developer/i);
|
|
141
|
-
assert.match(markdown, /RAP Service Developer/i);
|
|
142
|
-
assert.match(markdown, /ABAP Runtime Debugger/i);
|
|
143
|
-
assert.match(markdown, /HANA Cloud\/HDI Specialist/i);
|
|
144
|
-
assert.match(markdown, /SDLC|lifecycle/i);
|
|
145
|
-
assertPlanFirstPolicy(markdown, path);
|
|
146
|
-
assertLocalFirstPolicy(markdown, path);
|
|
147
|
-
assertNoUnsafeTransportClaims(markdown, path);
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
test('RAP Service Developer agent has valid metadata and plan-first local-first policy', async () => {
|
|
151
|
-
const path = join(agentsRoot, 'rap-service-developer.agent.md');
|
|
152
|
-
const markdown = await readMarkdown(path);
|
|
153
|
-
const metadata = parseFrontmatter(markdown, path);
|
|
154
|
-
assert.deepEqual(Object.keys(metadata).sort(), ['description', 'name', 'target', 'user-invocable']);
|
|
155
|
-
assert.equal(metadata.name, 'RAP Service Developer');
|
|
156
|
-
assert.equal(metadata.target, 'vscode');
|
|
157
|
-
assert.equal(metadata['user-invocable'], 'true');
|
|
158
|
-
assert.match(metadata.description, /RAP/i);
|
|
159
|
-
assertMentionsLiveToolDiscovery(markdown, path);
|
|
160
|
-
assertValidationPolicy(markdown, path);
|
|
161
|
-
assertPlanFirstPolicy(markdown, path);
|
|
162
|
-
assertLocalFirstPolicy(markdown, path);
|
|
163
|
-
assertNoUnsafeTransportClaims(markdown, path);
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
test('HANA Cloud/HDI Specialist agent has a read-only target-bound workflow', async () => {
|
|
167
|
-
const path = join(agentsRoot, 'hana-cloud-hdi-specialist.agent.md');
|
|
168
|
-
const markdown = await readMarkdown(path);
|
|
169
|
-
const metadata = parseFrontmatter(markdown, path);
|
|
170
|
-
assert.deepEqual(Object.keys(metadata).sort(), ['description', 'name', 'target', 'user-invocable']);
|
|
171
|
-
assert.equal(metadata.name, 'HANA Cloud/HDI Specialist');
|
|
172
|
-
assert.equal(metadata.target, 'vscode');
|
|
173
|
-
assert.equal(metadata['user-invocable'], 'true');
|
|
174
|
-
assert.match(metadata.description, /HANA Cloud/i);
|
|
175
|
-
assertDirectMcpUsage(markdown, path);
|
|
176
|
-
assertMentionsLiveToolDiscovery(markdown, path);
|
|
177
|
-
for (const tool of ['hana_connection_info', 'hana_list_objects', 'hana_describe_object', 'hana_read_rows']) {
|
|
178
|
-
assert.match(markdown, new RegExp(tool));
|
|
179
|
-
}
|
|
180
|
-
assert.match(markdown, /read-only/i);
|
|
181
|
-
assert.match(markdown, /HANA_RO_\*/);
|
|
182
|
-
assert.match(markdown, /cds build --for hana/);
|
|
183
|
-
assert.match(markdown, /user-run deployment|deployment is performed by the user/i);
|
|
184
|
-
assert.match(markdown, /never execute HANA DDL\/DML|Never execute HANA DDL\/DML/i);
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
test('each skill has valid metadata, matching name, and enforceable live-tool validation policy', async () => {
|
|
188
|
-
for (const skill of expectedSkills) {
|
|
189
|
-
const path = join(skillsRoot, skill, 'SKILL.md');
|
|
190
|
-
const markdown = await readMarkdown(path);
|
|
191
|
-
const metadata = parseFrontmatter(markdown, path);
|
|
192
|
-
assert.deepEqual(Object.keys(metadata).sort(), ['description', 'name']);
|
|
193
|
-
assert.equal(metadata.name, skill, `${skill} frontmatter name must match its directory`);
|
|
194
|
-
assert.ok(metadata.description.length >= 40, `${skill} needs a useful description`);
|
|
195
|
-
assert.match(markdown, /^#\s+\S.+$/m, `${skill} must have a top-level heading`);
|
|
196
|
-
assertMentionsLiveToolDiscovery(markdown, path);
|
|
197
|
-
assertNoUnsafeTransportClaims(markdown, path);
|
|
198
|
-
}
|
|
199
|
-
});
|
|
200
|
-
|
|
201
|
-
test('implementation-oriented skills require tests, runtime evidence, activation, and separate reporting', async () => {
|
|
202
|
-
for (const skill of ['abap-development', 'abap-testing-quality', 'cds-development', 'rap-development', 'sap-transport-release']) {
|
|
203
|
-
const path = join(skillsRoot, skill, 'SKILL.md');
|
|
204
|
-
const markdown = await readMarkdown(path);
|
|
205
|
-
assertValidationPolicy(markdown, path);
|
|
206
|
-
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`);
|
|
207
|
-
}
|
|
208
|
-
});
|
|
209
|
-
|
|
210
|
-
test('implementation and delivery skills require planning first and local-first authoring', async () => {
|
|
211
|
-
for (const skill of ['abap-development', 'rap-development', 'rap-service-delivery', 'sap-sdlc-orchestration']) {
|
|
212
|
-
const path = join(skillsRoot, skill, 'SKILL.md');
|
|
213
|
-
const markdown = await readMarkdown(path);
|
|
214
|
-
assertPlanFirstPolicy(markdown, path);
|
|
215
|
-
assertLocalFirstPolicy(markdown, path);
|
|
216
|
-
}
|
|
217
|
-
});
|
|
218
|
-
|
|
219
|
-
test('debugging skill requires evidence before fixes and bounded debugger use', async () => {
|
|
220
|
-
const path = join(skillsRoot, 'abap-debugging', 'SKILL.md');
|
|
221
|
-
const markdown = await readMarkdown(path);
|
|
222
|
-
assert.match(markdown, /reproduce/i);
|
|
223
|
-
assert.match(markdown, /dumps?|ListDumps/i);
|
|
224
|
-
assert.match(markdown, /DebuggerDetach/i);
|
|
225
|
-
assert.match(markdown, /Avoid attaching to another user's session|do not.*another user's session/i);
|
|
226
|
-
assert.match(markdown, /before changing code|before proposing a cause/i);
|
|
227
|
-
});
|
|
228
|
-
|
|
229
|
-
test('HANA skills describe the read-only tool surface and safe CAP/HDI generation', async () => {
|
|
230
|
-
const inspection = await readMarkdown(join(skillsRoot, 'hana-cloud-inspection', 'SKILL.md'));
|
|
231
|
-
for (const tool of ['hana_connection_info', 'hana_list_objects', 'hana_describe_object', 'hana_read_rows']) {
|
|
232
|
-
assert.match(inspection, new RegExp(tool));
|
|
233
|
-
}
|
|
234
|
-
assert.match(inspection, /200-row limit/);
|
|
235
|
-
assert.match(inspection, /Do not launch `sap-ai-dev` or `sap-ai-hana` for MCP operations/);
|
|
236
|
-
assert.match(inspection, /schema-bound/);
|
|
237
|
-
assert.match(inspection, /credential-like columns/);
|
|
238
|
-
|
|
239
|
-
const development = await readMarkdown(join(skillsRoot, 'hana-cloud-native-development', 'SKILL.md'));
|
|
240
|
-
assert.match(development, /cds build --for hana/);
|
|
241
|
-
assert.match(development, /CAP-owned database models/);
|
|
242
|
-
assert.match(development, /Never execute `cds deploy` against HANA/);
|
|
243
|
-
assert.match(development, /HANA DDL\/DML/);
|
|
244
|
-
|
|
245
|
-
const validation = await readMarkdown(join(skillsRoot, 'hana-cloud-validation', 'SKILL.md'));
|
|
246
|
-
assert.match(validation, /deployment handoff/);
|
|
247
|
-
assert.match(validation, /HANA tools are metadata and bounded read-only inspection only/);
|
|
248
|
-
assert.match(validation, /Never claim HANA activation\/deployment/);
|
|
249
|
-
});
|
|
@@ -1,187 +0,0 @@
|
|
|
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
|
-
});
|