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
package/inventory.md
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# SAP AI Dev Toolkit Tool Inventory
|
|
2
|
+
|
|
3
|
+
This inventory reflects the current SAP AI Dev Toolkit proxy behavior in `src/mcp-proxy.mjs`. The proxy exposes a curated VSP allowlist plus destination-scoped local workflows; it does not expose every tool registered by VSP. The live MCP `tools/list` response remains authoritative because capabilities vary by VSP mode and SAP system.
|
|
4
|
+
|
|
5
|
+
## Summary
|
|
6
|
+
|
|
7
|
+
| Status | Count | Notes |
|
|
8
|
+
| --- | ---: | --- |
|
|
9
|
+
| VSP tools | Up to 51 | Curated from the child VSP tool list and exposed with a destination prefix in lowercase snake_case, for example `<destination>_get_source`. |
|
|
10
|
+
| Local lint tool | 1 per destination | `LintABAP` analyzes caller-supplied ABAP source in memory; it is exposed publicly as `<destination>_lint_abap`. |
|
|
11
|
+
| Workflow tools | Up to 6 per destination | Review/apply change sets, transport evidence, Clean Core release assessment, and read-only RAP regression suites. Some are exposed only when their upstream VSP tools are registered. |
|
|
12
|
+
| Convenience mapping | Dynamic | `GetApplicationLog` maps to VSP `SAP(action="analyze", type="application_log")` when the SAP router is registered. |
|
|
13
|
+
| Intentionally filtered VSP tools | Dynamic | Destructive, broad-router, trace, and unsupported VSP operations are hidden from direct calls. |
|
|
14
|
+
| Mode-dependent | Dynamic | Focused/expert mode and backend capabilities determine what VSP registers. |
|
|
15
|
+
|
|
16
|
+
The maximum fixture surface is 59 tools per destination when expert-mode capabilities and all local workflows are available. Multiple selected destinations create separate MCP servers; this add-on cannot inspect VS Code's aggregate tool budget or per-chat tool binding.
|
|
17
|
+
|
|
18
|
+
## Known upstream VSP tools
|
|
19
|
+
|
|
20
|
+
These names are known from fixtures, README, and historical upstream VSP inventories. They are not a statement that the proxy exposes them; only the curated list in [tools.md](tools.md) is public through the proxy. Names absent from that allowlist are filtered even when VSP registers them.
|
|
21
|
+
|
|
22
|
+
### Baseline/source inspection
|
|
23
|
+
|
|
24
|
+
- `GetSource`
|
|
25
|
+
- `SearchObject`
|
|
26
|
+
- `GrepObjects`
|
|
27
|
+
- `GrepPackages`
|
|
28
|
+
- `FindDefinition`
|
|
29
|
+
- `FindReferences`
|
|
30
|
+
- `GetContext`
|
|
31
|
+
- `CompareSource`
|
|
32
|
+
- `GetClassInfo`
|
|
33
|
+
- `GetPackage`
|
|
34
|
+
- `GetFunctionGroup`
|
|
35
|
+
- `GetMessages`
|
|
36
|
+
- `GetInactiveObjects`
|
|
37
|
+
- `GetAPIReleaseState`
|
|
38
|
+
|
|
39
|
+
### Data, CDS, and metadata
|
|
40
|
+
|
|
41
|
+
- `GetTable`
|
|
42
|
+
- `GetTableContents`
|
|
43
|
+
- `RunQuery`
|
|
44
|
+
- `GetCDSDependencies`
|
|
45
|
+
- `GetCDSImpactAnalysis`
|
|
46
|
+
- `GetCDSElementInfo`
|
|
47
|
+
- `GetFeatures`
|
|
48
|
+
- `GetSystemInfo`
|
|
49
|
+
- `GetInstalledComponents`
|
|
50
|
+
|
|
51
|
+
### Create, update, activate, and quality
|
|
52
|
+
|
|
53
|
+
- `WriteSource`
|
|
54
|
+
- `EditSource`
|
|
55
|
+
- `CreatePackage`
|
|
56
|
+
- `CreateTable`
|
|
57
|
+
- `SyntaxCheck`
|
|
58
|
+
- `Activate`
|
|
59
|
+
- `ActivateMultiple`
|
|
60
|
+
- `ActivatePackage`
|
|
61
|
+
- `PrettyPrint`
|
|
62
|
+
- `RunUnitTests`
|
|
63
|
+
- `RunATCCheck`
|
|
64
|
+
|
|
65
|
+
### Transport tools
|
|
66
|
+
|
|
67
|
+
- `GetTransport`
|
|
68
|
+
- `GetTransportInfo`
|
|
69
|
+
- `GetUserTransports`
|
|
70
|
+
- `ListTransports`
|
|
71
|
+
- `CreateTransport`
|
|
72
|
+
- `ReleaseTransport`
|
|
73
|
+
- `DeleteTransport`
|
|
74
|
+
|
|
75
|
+
### Additional analysis, help, and call graph tools
|
|
76
|
+
|
|
77
|
+
- `AnalyzeABAPCode`
|
|
78
|
+
- `AnalyzeCallGraph`
|
|
79
|
+
- `CodeCompletion`
|
|
80
|
+
- `GetAbapHelp`
|
|
81
|
+
- `GetCallGraph`
|
|
82
|
+
- `GetCalleesOf`
|
|
83
|
+
- `GetCallersOf`
|
|
84
|
+
- `GetCodeCoverage`
|
|
85
|
+
- `GetConnectionInfo`
|
|
86
|
+
- `GetObjectStructure`
|
|
87
|
+
- `GetTypeHierarchy`
|
|
88
|
+
- `GetTypeInfo`
|
|
89
|
+
- `GrepObject`
|
|
90
|
+
- `GrepPackage`
|
|
91
|
+
- `CallRFC`
|
|
92
|
+
|
|
93
|
+
### Debugging, dumps, breakpoints, and trace
|
|
94
|
+
|
|
95
|
+
- `DebuggerAttach`
|
|
96
|
+
- `DebuggerDetach`
|
|
97
|
+
- `DebuggerGetStack`
|
|
98
|
+
- `DebuggerGetVariables`
|
|
99
|
+
- `DebuggerListen`
|
|
100
|
+
- `DebuggerStep`
|
|
101
|
+
- `DeleteBreakpoint`
|
|
102
|
+
- `GetBreakpoints`
|
|
103
|
+
- `GetDump`
|
|
104
|
+
- `GetSQLTraceState`
|
|
105
|
+
- `GetTrace`
|
|
106
|
+
- `ListDumps`
|
|
107
|
+
- `SetBreakpoint`
|
|
108
|
+
- `ListSQLTraces`
|
|
109
|
+
|
|
110
|
+
### Object, source, dependency, and service operations
|
|
111
|
+
|
|
112
|
+
- `CloneObject`
|
|
113
|
+
- `CreateAndActivateProgram`
|
|
114
|
+
- `CreateClassWithTests`
|
|
115
|
+
- `CreateObject`
|
|
116
|
+
- `CreateTestInclude`
|
|
117
|
+
- `DeleteObject`
|
|
118
|
+
- `ExecuteABAP`
|
|
119
|
+
- `GetClass`
|
|
120
|
+
- `GetClassComponents`
|
|
121
|
+
- `GetClassInclude`
|
|
122
|
+
- `GetFunction`
|
|
123
|
+
- `GetInclude`
|
|
124
|
+
- `GetInterface`
|
|
125
|
+
- `GetProgram`
|
|
126
|
+
- `GetStructure`
|
|
127
|
+
- `GetTransaction`
|
|
128
|
+
- `LockObject`
|
|
129
|
+
- `MoveObject`
|
|
130
|
+
- `RecoverFailedCreate`
|
|
131
|
+
- `RenameObject`
|
|
132
|
+
- `SaveToFile`
|
|
133
|
+
- `UnlockObject`
|
|
134
|
+
- `UpdateClassInclude`
|
|
135
|
+
- `UpdateSource`
|
|
136
|
+
- `WriteClass`
|
|
137
|
+
- `WriteProgram`
|
|
138
|
+
- `ListDependencies`
|
|
139
|
+
- `PublishServiceBinding`
|
|
140
|
+
- `UnpublishServiceBinding`
|
|
141
|
+
|
|
142
|
+
### General VSP router
|
|
143
|
+
|
|
144
|
+
- `SAP`
|
|
145
|
+
|
|
146
|
+
## Enabled add-on/local tools
|
|
147
|
+
|
|
148
|
+
- `LintABAP` — local add-on tool; lints caller-supplied ABAP source without contacting SAP.
|
|
149
|
+
- `GetApplicationLog` — add-on convenience tool mapped to VSP `SAP` with `action="analyze"` and `type="application_log"`.
|
|
150
|
+
|
|
151
|
+
These logical names are exposed through MCP in lowercase snake_case (`lint_abap`, `get_application_log`) because BAS/VS Code chat tool references bind only lowercase identifiers.
|
|
152
|
+
|
|
153
|
+
## Destination workflow tools
|
|
154
|
+
|
|
155
|
+
- `PrepareABAPChangeSet` — stage full-source `WriteSource` changes and return review diffs and source fingerprints.
|
|
156
|
+
- `ApplyABAPChangeSet` — re-read staged objects before writing and report conflicts or partial application.
|
|
157
|
+
- `CheckTransportReadiness` — collect selected transport, dependency, inactive-object, ABAP Unit, and ATC evidence.
|
|
158
|
+
- `PlanABAPCloudMigration` — batch-check API release state for supplied ADT object URIs and prioritize recognized unreleased APIs.
|
|
159
|
+
- `GenerateRAPRegressionSuite` — read service metadata and emit reusable GET-only smoke cases for entity sets.
|
|
160
|
+
- `RunRAPRegressionSuite` — execute saved cases with status, content-type, and JSON-path assertions over the connected destination.
|
|
161
|
+
|
|
162
|
+
## Notes
|
|
163
|
+
|
|
164
|
+
- Runtime tool names are `<destination-slug>_<tool>`, for example `demo-abap_run_query`; `tools/call` accepts the exact name returned by `tools/list`.
|
|
165
|
+
- The proxy starts VSP with `--enable-transports`; generated MCP entries set `SAP_ALLOW_TRANSPORTABLE_EDITS=true`.
|
|
166
|
+
- Transport release/deletion and the general-purpose `SAP` router are intentionally hidden from direct proxy calls. `GetApplicationLog` is the bounded convenience mapping for the SAP application-log route.
|
|
167
|
+
|
|
168
|
+
## Separate optional HANA Cloud inspector
|
|
169
|
+
|
|
170
|
+
The HANA inspector is a standalone companion MCP process launched as `sap-ai-hana`; it is not a VSP child and is not destination-prefixed. It uses a read-only identity selected from `HANA_RO_*` variables or one unambiguous `VCAP_SERVICES` binding. Its host/schema target is fixed by process configuration; tool arguments cannot change it.
|
|
171
|
+
|
|
172
|
+
| Tool | Behavior |
|
|
173
|
+
| --- | --- |
|
|
174
|
+
| `hana_connection_info` | Report selected endpoint, service/binding name, configured and current schema, database user, and TLS validation state. Does not return credentials. |
|
|
175
|
+
| `hana_list_objects` | List tables and/or views in the configured schema, capped at 200 objects. |
|
|
176
|
+
| `hana_describe_object` | Return columns for a table or view verified in the configured schema. |
|
|
177
|
+
| `hana_read_rows` | Read selected catalog-verified columns with bound filter values and a hard 200-row maximum; credential-like columns are blocked. No free-form SQL. |
|
|
178
|
+
|
|
179
|
+
The process exposes no DDL, DML, arbitrary SQL, procedure-call, deployment, undeploy, grant, or service-key management tools. The separate HANA identity should also be granted only the reads required for the intended HDI container.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sap-ai-dev-toolkit",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.6",
|
|
4
4
|
"description": "SAP AI development toolkit for BAS, ABAP, RAP, CAP, HANA Cloud, Fiori, UI5, and MCP",
|
|
5
5
|
"author": "Gurkan Yilmaz",
|
|
6
6
|
"type": "module",
|
|
@@ -45,11 +45,11 @@
|
|
|
45
45
|
"scripts/",
|
|
46
46
|
"patches/",
|
|
47
47
|
"dist/",
|
|
48
|
-
"test/",
|
|
49
48
|
"LICENSE",
|
|
50
49
|
"NOTICE",
|
|
51
50
|
"LICENSE-APACHE-2.0.txt",
|
|
52
51
|
"tools.md",
|
|
52
|
+
"inventory.md",
|
|
53
53
|
".github/agents/",
|
|
54
54
|
".github/skills/"
|
|
55
55
|
],
|
package/scripts/build-vsp.mjs
CHANGED
|
@@ -5,12 +5,13 @@ import { dirname, join } from 'node:path';
|
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
6
|
import { createHash } from 'node:crypto';
|
|
7
7
|
import { ensureGo } from './ensure-go.mjs';
|
|
8
|
+
import { UPSTREAM_COMMIT } from '../src/binary.mjs';
|
|
8
9
|
|
|
9
10
|
const exec = promisify(execFile);
|
|
10
11
|
const root = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
11
12
|
const source = join(root, '.build', 'vibing-steampunk');
|
|
12
13
|
const dist = join(root, 'dist');
|
|
13
|
-
const commit =
|
|
14
|
+
const commit = UPSTREAM_COMMIT;
|
|
14
15
|
const upstream = 'https://github.com/oisee/vibing-steampunk.git';
|
|
15
16
|
const targets = [
|
|
16
17
|
['linux', 'amd64', 'linux', 'x64', ''],
|
package/scripts/postinstall.mjs
CHANGED
|
@@ -2,7 +2,6 @@ import { open, readFile } from 'node:fs/promises';
|
|
|
2
2
|
import { closeSync, openSync, realpathSync } from 'node:fs';
|
|
3
3
|
import { dirname, join, resolve } from 'node:path';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
|
-
import { ensureGo } from './ensure-go.mjs';
|
|
6
5
|
import { installBinary } from '../src/binary.mjs';
|
|
7
6
|
import { runSetup } from '../src/setup.mjs';
|
|
8
7
|
import { installUserCopilotAssets } from './install-user-copilot-assets.mjs';
|
|
@@ -11,6 +10,8 @@ import { ReadStream as TTYReadStream, WriteStream as TTYWriteStream } from 'node
|
|
|
11
10
|
import { homedir } from 'node:os';
|
|
12
11
|
import { colorText, formatStatus } from '../src/terminal-ui.mjs';
|
|
13
12
|
import { brandedEnvValue, withBrandedEnvironment } from '../src/branding.mjs';
|
|
13
|
+
import { generatedServerName } from '../src/mcp-config.mjs';
|
|
14
|
+
import { redactText } from '../src/redact.mjs';
|
|
14
15
|
|
|
15
16
|
const root = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
16
17
|
const pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'));
|
|
@@ -76,10 +77,10 @@ async function runCopilotAssetInstall() {
|
|
|
76
77
|
await announce([
|
|
77
78
|
'Optional Copilot setup is waiting for your choice.',
|
|
78
79
|
'',
|
|
79
|
-
'
|
|
80
|
+
'Type y then press Enter to install the bundled agents and all skills in the path shown below; press Enter alone to skip.',
|
|
80
81
|
'',
|
|
81
|
-
`${colorText('✅
|
|
82
|
-
`${colorText('⏭️
|
|
82
|
+
`${colorText('✅ Type y then Enter', 'green', true)} to install the bundled agents and all skills.`,
|
|
83
|
+
`${colorText('⏭️ Press Enter', 'yellow', true)} to skip this optional step (default).`,
|
|
83
84
|
'',
|
|
84
85
|
`${colorText('📁 Target folder:', 'cyan', true)}`,
|
|
85
86
|
` ${copilotRoot}`,
|
|
@@ -88,11 +89,13 @@ async function runCopilotAssetInstall() {
|
|
|
88
89
|
const prompt = createInterface({ input: terminal.input, output: terminal.output });
|
|
89
90
|
let answer;
|
|
90
91
|
try {
|
|
91
|
-
answer = await prompt.question(`${colorText('🤖 Install the bundled agents and all skills?', 'magenta', terminal.output)} ${colorText('[
|
|
92
|
+
answer = await prompt.question(`${colorText('🤖 Install the bundled agents and all skills?', 'magenta', terminal.output)} ${colorText('[y/N]', 'yellow', terminal.output)} `);
|
|
92
93
|
} finally {
|
|
93
94
|
prompt.close();
|
|
94
95
|
}
|
|
95
|
-
|
|
96
|
+
// Default is skip: writing instruction files into ~/.copilot affects
|
|
97
|
+
// every Copilot session on the machine, so it needs an explicit yes.
|
|
98
|
+
if (!/^y(?:es)?$/i.test(answer.trim())) {
|
|
96
99
|
await announce('Copilot agent and skills were skipped. Your files were not changed.', 'info');
|
|
97
100
|
return;
|
|
98
101
|
}
|
|
@@ -110,8 +113,7 @@ async function runCopilotAssetInstall() {
|
|
|
110
113
|
function probeCell(destination) {
|
|
111
114
|
const probe = destination.probe || {};
|
|
112
115
|
if (probe.status === 'skipped') return { text: 'SKIPPED (probe disabled)', color: 'yellow' };
|
|
113
|
-
const error = destination.source === 'cloud-foundry' ? '' :
|
|
114
|
-
.replace(/(authorization|cookie|password|secret|token)\s*[:=]\s*[^\s,;]+/gi, '$1=[redacted]')
|
|
116
|
+
const error = destination.source === 'cloud-foundry' ? '' : redactText(probe.error || '')
|
|
115
117
|
.replace(/[\u0000-\u001f\u007f]/g, ' ')
|
|
116
118
|
.replace(/\s+/g, ' ')
|
|
117
119
|
.slice(0, 100);
|
|
@@ -129,7 +131,7 @@ export function destinationTable(destinations, registeredNames) {
|
|
|
129
131
|
const headings = ['Destination', 'Source', 'Client', 'Authentication', 'ADT probe', 'MCP server'];
|
|
130
132
|
const rows = destinations.map(destination => {
|
|
131
133
|
const probe = probeCell(destination);
|
|
132
|
-
const registered = registeredNames.has(destination.serverName || destination.name);
|
|
134
|
+
const registered = registeredNames.has(generatedServerName(destination.serverName || destination.name));
|
|
133
135
|
const source = destination.source === 'cloud-foundry'
|
|
134
136
|
? `CF ${destination.cf?.destinationInstanceName || 'unknown instance'}`
|
|
135
137
|
: 'BAS';
|
|
@@ -178,7 +180,7 @@ async function announceSetup(result) {
|
|
|
178
180
|
|
|
179
181
|
const selected = result.selected || [];
|
|
180
182
|
const destinationsByServer = new Map(selected.map(destination => [
|
|
181
|
-
destination.serverName || destination.name,
|
|
183
|
+
generatedServerName(destination.serverName || destination.name),
|
|
182
184
|
destination
|
|
183
185
|
]));
|
|
184
186
|
const registeredNames = new Set(servers.map(([name]) => name));
|
|
@@ -218,17 +220,14 @@ async function announceSetup(result) {
|
|
|
218
220
|
async function main() {
|
|
219
221
|
if (process.env.npm_config_ignore_scripts === 'true') return;
|
|
220
222
|
|
|
223
|
+
// Go is intentionally NOT provisioned here: the installed package never
|
|
224
|
+
// runs `go` (only the repository-only build:vsp script does), so a toolchain
|
|
225
|
+
// download at install time was pure cost. The pinned, checksum-verified VSP
|
|
226
|
+
// binary ships with the package; the download below is the fallback.
|
|
221
227
|
try {
|
|
222
228
|
if (brandedEnvValue(runtimeEnv, 'BINARY')) {
|
|
223
229
|
await announce('Using the SAP_AI_DEV_TOOLKIT_BINARY override.', 'info');
|
|
224
230
|
} else {
|
|
225
|
-
await announce('Checking for Go. The supported version installs automatically if needed; this may take a few minutes.', 'progress');
|
|
226
|
-
try {
|
|
227
|
-
await ensureGo();
|
|
228
|
-
await announce('Go is ready.', 'success');
|
|
229
|
-
} catch (error) {
|
|
230
|
-
throw new Error(`Go provisioning failed: ${error.message}`);
|
|
231
|
-
}
|
|
232
231
|
await announce('Preparing the pinned VSP runtime for this platform.', 'progress');
|
|
233
232
|
try {
|
|
234
233
|
await installBinary(pkg, { env: runtimeEnv });
|
|
@@ -239,7 +238,7 @@ async function main() {
|
|
|
239
238
|
}
|
|
240
239
|
} catch (error) {
|
|
241
240
|
await announce(error.message, 'error');
|
|
242
|
-
await announce('
|
|
241
|
+
await announce('Set SAP_AI_DEV_TOOLKIT_BINARY only when supplying a trusted prebuilt VSP executable.', 'info');
|
|
243
242
|
process.exitCode = 1;
|
|
244
243
|
return;
|
|
245
244
|
}
|
|
@@ -2,6 +2,7 @@ import http from 'node:http';
|
|
|
2
2
|
import { fetch as undiciFetch, ProxyAgent } from 'undici';
|
|
3
3
|
import { CookieJar } from 'tough-cookie';
|
|
4
4
|
import { brandedEnvValue } from './branding.mjs';
|
|
5
|
+
import { redactText } from './redact.mjs';
|
|
5
6
|
|
|
6
7
|
const DEFAULT_PROXY = 'http://127.0.0.1:8887';
|
|
7
8
|
const MAX_BODY_BYTES = 64 * 1024 * 1024;
|
|
@@ -15,6 +16,12 @@ const UNSAFE = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
|
|
15
16
|
// Set-Cookie state SAP returned alongside it and replays both on the unsafe
|
|
16
17
|
// request. Without this pairing SAP sees a token from a foreign session and
|
|
17
18
|
// answers "CSRF token validation failed" with HTTP 403.
|
|
19
|
+
// Tokens are session-scoped, not path-scoped, but SAP only issues them from
|
|
20
|
+
// GET-capable endpoints. POST-only services (ADT data preview among them)
|
|
21
|
+
// answer a same-path token fetch with 4xx, so the relay always fetches from
|
|
22
|
+
// the GET-friendly discovery endpoint and accepts that session's token for
|
|
23
|
+
// unsafe requests to any path.
|
|
24
|
+
const CSRF_FETCH_PATH = '/sap/bc/adt/discovery';
|
|
18
25
|
const TOKEN_TTL_MS = 15 * 60 * 1000;
|
|
19
26
|
const DEFAULT_MAX_TOKEN_FAILURES = 3;
|
|
20
27
|
const MAX_CONFIGURED_TOKEN_FAILURES = 10;
|
|
@@ -200,10 +207,10 @@ export function createBasDestinationRelay(destination, { env = process.env, fetc
|
|
|
200
207
|
return send(target, method, headers, body);
|
|
201
208
|
}
|
|
202
209
|
|
|
203
|
-
async function fetchTokenFresh(
|
|
204
|
-
const key =
|
|
210
|
+
async function fetchTokenFresh(requestHeaders) {
|
|
211
|
+
const key = CSRF_FETCH_PATH;
|
|
205
212
|
stats.csrfFetches += 1;
|
|
206
|
-
const target = resolveTarget(
|
|
213
|
+
const target = resolveTarget(new URL(CSRF_FETCH_PATH, base));
|
|
207
214
|
const cookieTarget = resolveCookieTarget(target);
|
|
208
215
|
const jar = copyCookieJar(sharedCookieJar);
|
|
209
216
|
await seedRequestCookies(jar, requestHeaders.cookie, cookieTarget);
|
|
@@ -229,13 +236,13 @@ export function createBasDestinationRelay(destination, { env = process.env, fetc
|
|
|
229
236
|
return session;
|
|
230
237
|
}
|
|
231
238
|
|
|
232
|
-
async function fetchToken(
|
|
233
|
-
const key =
|
|
239
|
+
async function fetchToken(requestHeaders = {}) {
|
|
240
|
+
const key = CSRF_FETCH_PATH;
|
|
234
241
|
const cached = tokenCache.get(key);
|
|
235
242
|
if (cached && Date.now() - cached.fetchedAt < TOKEN_TTL_MS) return cached;
|
|
236
243
|
const pending = tokenFetches.get(key);
|
|
237
244
|
if (pending) return pending;
|
|
238
|
-
const fetching = fetchTokenFresh(
|
|
245
|
+
const fetching = fetchTokenFresh(requestHeaders);
|
|
239
246
|
tokenFetches.set(key, fetching);
|
|
240
247
|
try {
|
|
241
248
|
return await fetching;
|
|
@@ -244,14 +251,17 @@ export function createBasDestinationRelay(destination, { env = process.env, fetc
|
|
|
244
251
|
}
|
|
245
252
|
}
|
|
246
253
|
|
|
247
|
-
async function probeCsrfSession(
|
|
248
|
-
const
|
|
249
|
-
const session = await fetchToken(requestUrl, { accept: 'application/xml,text/xml,*/*' });
|
|
254
|
+
async function probeCsrfSession() {
|
|
255
|
+
const session = await fetchToken({ accept: 'application/xml,text/xml,*/*' });
|
|
250
256
|
return {
|
|
251
257
|
httpStatus: session?.httpStatus || 0,
|
|
252
258
|
tokenReceived: Boolean(session?.token),
|
|
253
259
|
cookieCount: session?.cookieCount || 0,
|
|
254
|
-
|
|
260
|
+
// A token alone makes unsafe requests viable: BAS proxy routes deliver
|
|
261
|
+
// tokens but strip cookies, and the backend accepts the token on the
|
|
262
|
+
// proxy-established Basic-auth session. cookieCount stays reported for
|
|
263
|
+
// diagnosis; setup's override validation requires cookies separately.
|
|
264
|
+
sessionUsable: Boolean(session?.token)
|
|
255
265
|
};
|
|
256
266
|
}
|
|
257
267
|
|
|
@@ -265,7 +275,14 @@ export function createBasDestinationRelay(destination, { env = process.env, fetc
|
|
|
265
275
|
function authHeaders(extra = {}) {
|
|
266
276
|
if (!credentials?.user || !credentials?.password) return extra;
|
|
267
277
|
const basic = Buffer.from(`${credentials.user}:${credentials.password}`).toString('base64');
|
|
268
|
-
|
|
278
|
+
// The stored credential override wins over any inbound authorization
|
|
279
|
+
// header: the override exists precisely to pair Basic auth with the
|
|
280
|
+
// relay's CSRF session, and silently forwarding a caller-supplied header
|
|
281
|
+
// would disable that pairing without any diagnostic.
|
|
282
|
+
const headers = { ...extra };
|
|
283
|
+
delete headers.authorization;
|
|
284
|
+
delete headers.Authorization;
|
|
285
|
+
return { ...headers, authorization: `Basic ${basic}` };
|
|
269
286
|
}
|
|
270
287
|
|
|
271
288
|
async function forward(method, requestUrl, requestHeaders, body, session) {
|
|
@@ -291,27 +308,32 @@ export function createBasDestinationRelay(destination, { env = process.env, fetc
|
|
|
291
308
|
// a failing destination from looping forever.
|
|
292
309
|
async function forwardWithRetry(method, requestUrl, requestHeaders, body, failures = 0) {
|
|
293
310
|
const unsafe = UNSAFE.has(method);
|
|
294
|
-
const session = unsafe ? await fetchToken(
|
|
295
|
-
if (unsafe &&
|
|
311
|
+
const session = unsafe ? await fetchToken(requestHeaders) : null;
|
|
312
|
+
if (unsafe && !session?.token) {
|
|
296
313
|
stats.csrfSessionFailures += 1;
|
|
297
|
-
const
|
|
298
|
-
? 'could not obtain a CSRF token'
|
|
299
|
-
: 'received a CSRF token without a session cookie';
|
|
300
|
-
const message = `BAS destination relay ${detail} for ${requestUrl.pathname}; no unsafe request was sent`;
|
|
314
|
+
const message = `BAS destination relay could not obtain a CSRF token for ${requestUrl.pathname}; no unsafe request was sent`;
|
|
301
315
|
log(`[${destination.name}] ${message}`);
|
|
302
316
|
const status = session?.httpStatus >= 400 ? session.httpStatus : 503;
|
|
303
317
|
const response = new Response(message, { status, headers: { 'content-type': 'text/plain; charset=utf-8' } });
|
|
304
318
|
return { response, body: Buffer.from(message) };
|
|
305
319
|
}
|
|
320
|
+
// A token without cookies is still sent: BAS proxy routes deliver tokens
|
|
321
|
+
// but strip Set-Cookie, and the backend accepts the token on the Basic-
|
|
322
|
+
// auth session the proxy establishes per hop. A retry cannot help there
|
|
323
|
+
// (a fresh token still arrives cookie-less), so a CSRF rejection on a
|
|
324
|
+
// cookie-less session is surfaced instead of retried.
|
|
325
|
+
const tokenOnly = unsafe && !session.cookieCount;
|
|
306
326
|
const { response, body: responseBody } = await forward(method, requestUrl, requestHeaders, body, session);
|
|
307
327
|
const rejected = csrfFailure(response.status, response.headers, responseBody);
|
|
308
|
-
if (unsafe && rejected && failures < maxFailures) {
|
|
328
|
+
if (unsafe && rejected && !tokenOnly && failures < maxFailures) {
|
|
309
329
|
log(`[${destination.name}] BAS relay CSRF session rejected for ${requestUrl.pathname}; re-establishing token and session (attempt ${failures + 1}/${maxFailures})`);
|
|
310
330
|
stats.csrfRetries += 1;
|
|
311
|
-
tokenCache.delete(
|
|
331
|
+
tokenCache.delete(CSRF_FETCH_PATH);
|
|
312
332
|
return forwardWithRetry(method, requestUrl, requestHeaders, body, failures + 1);
|
|
313
333
|
}
|
|
314
|
-
if (unsafe && rejected &&
|
|
334
|
+
if (unsafe && rejected && tokenOnly) {
|
|
335
|
+
log(`[${destination.name}] BAS relay CSRF token rejected without a session cookie for ${requestUrl.pathname}; not retried (route delivers no cookies; a credential override enables a paired session)`);
|
|
336
|
+
} else if (unsafe && rejected && failures >= maxFailures) {
|
|
315
337
|
log(`[${destination.name}] BAS relay stopped after ${maxFailures} CSRF session retries for ${requestUrl.pathname}`);
|
|
316
338
|
}
|
|
317
339
|
return { response, body: responseBody };
|
|
@@ -325,11 +347,17 @@ export function createBasDestinationRelay(destination, { env = process.env, fetc
|
|
|
325
347
|
const body = UNSAFE.has(method) ? await readRequestBody(req) : Buffer.alloc(0);
|
|
326
348
|
const { response, body: responseBody } = await forwardWithRetry(method, requestUrl, req.headers, body);
|
|
327
349
|
const responseHeaders = {};
|
|
350
|
+
// SAP sessions commonly set several cookies at once; a plain object
|
|
351
|
+
// assignment would collapse them to the last value. undici exposes the
|
|
352
|
+
// full list via getSetCookie, and Node's http server serializes an
|
|
353
|
+
// array as repeated headers.
|
|
354
|
+
const setCookies = response.headers.getSetCookie?.() || [];
|
|
355
|
+
if (setCookies.length) responseHeaders['set-cookie'] = setCookies;
|
|
328
356
|
for (const [name, value] of response.headers) {
|
|
329
357
|
const lower = name.toLowerCase();
|
|
330
358
|
// undici decompresses response bodies; the stale content-encoding and
|
|
331
359
|
// content-length would misdescribe the plain bytes handed to VSP.
|
|
332
|
-
if (HOP_BY_HOP.has(lower) || lower === 'content-encoding' || lower === 'content-length') continue;
|
|
360
|
+
if (lower === 'set-cookie' || HOP_BY_HOP.has(lower) || lower === 'content-encoding' || lower === 'content-length') continue;
|
|
333
361
|
responseHeaders[name] = value;
|
|
334
362
|
}
|
|
335
363
|
responseHeaders['x-sap-ai-dev-toolkit-relay'] = 'bas-destination';
|
|
@@ -337,10 +365,18 @@ export function createBasDestinationRelay(destination, { env = process.env, fetc
|
|
|
337
365
|
res.end(responseBody);
|
|
338
366
|
})().catch(error => {
|
|
339
367
|
if (!res.headersSent) res.writeHead(502, { 'content-type': 'text/plain; charset=utf-8', 'connection': 'close' });
|
|
340
|
-
|
|
368
|
+
// Proxy and transport errors can embed the full proxy URL including
|
|
369
|
+
// userinfo; redact before the body reaches the child (and the tool result).
|
|
370
|
+
res.end(`BAS destination relay failed: ${redactText(error.message)}\n`);
|
|
341
371
|
});
|
|
342
372
|
});
|
|
343
373
|
|
|
374
|
+
// Keep an error listener for the server's whole lifetime: after listen()
|
|
375
|
+
// succeeds, a later 'error' event (port collision cleanup, socket reset)
|
|
376
|
+
// with no listener would be an uncaught exception that kills the process.
|
|
377
|
+
server.on('error', error => {
|
|
378
|
+
log(`[${destination.name}] BAS relay server error: ${redactText(error.message)}`);
|
|
379
|
+
});
|
|
344
380
|
// Bind eagerly and report the real port through `ready`. Callers must
|
|
345
381
|
// await `ready` before handing the URL to a VSP child; binding failures
|
|
346
382
|
// surface as a rejected promise instead of a silently wrong guessed port.
|
package/src/bas-discovery.mjs
CHANGED
|
@@ -92,9 +92,15 @@ export function sanitizeChildEnv(input = process.env) {
|
|
|
92
92
|
if (!SECRET_KEY.test(key)) env[key] = value;
|
|
93
93
|
}
|
|
94
94
|
env.SAP_PROXY_CONTEXTID_GUARD = 'true';
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
95
|
+
// The BAS dev-space proxy (127.0.0.1:8887) is only implied inside BAS
|
|
96
|
+
// (H2O_URL present). Outside a dev space the default would route every
|
|
97
|
+
// child request — including SAP Basic credentials — to whatever local
|
|
98
|
+
// process happens to own that port, so it is not applied there.
|
|
99
|
+
const proxy = env.HTTP_PROXY || env.http_proxy || (env.H2O_URL ? DEFAULT_PROXY : undefined);
|
|
100
|
+
if (proxy) {
|
|
101
|
+
if (!env.HTTP_PROXY && !env.http_proxy) env.HTTP_PROXY = proxy;
|
|
102
|
+
if (!env.HTTPS_PROXY && !env.https_proxy) env.HTTPS_PROXY = proxy;
|
|
103
|
+
}
|
|
98
104
|
const noProxyKey = env.NO_PROXY != null ? 'NO_PROXY' : (env.no_proxy != null ? 'no_proxy' : 'NO_PROXY');
|
|
99
105
|
const entries = String(env[noProxyKey] || '').split(',').map(value => value.trim()).filter(Boolean).filter(value => !value.toLowerCase().includes('.dest'));
|
|
100
106
|
env[noProxyKey] = entries.join(',');
|
|
@@ -203,11 +209,21 @@ export async function discoverDestinations(options = {}) {
|
|
|
203
209
|
const configuredProxy = brandedProxy !== undefined ? String(brandedProxy) : (env.HTTP_PROXY ?? env.http_proxy);
|
|
204
210
|
const proxyUrl = configuredProxy !== undefined ? configuredProxy : DEFAULT_PROXY;
|
|
205
211
|
const skipProbe = String(brandedEnvValue(env, 'SKIP_PROBE') || '').toLowerCase() === 'true' || options.skipProbe;
|
|
206
|
-
const
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
212
|
+
const sorted = normalized.sort((a, b) => a.name.localeCompare(b.name));
|
|
213
|
+
// Probes run in parallel so startup and setup pay one probe round trip
|
|
214
|
+
// instead of one per destination; the pool is capped to avoid saturating
|
|
215
|
+
// the BAS proxy with large destination counts. Workers claim destinations
|
|
216
|
+
// in index order (synchronously until the first await), which keeps the
|
|
217
|
+
// probe invocation and result order deterministic.
|
|
218
|
+
const result = new Array(sorted.length);
|
|
219
|
+
let cursor = 0;
|
|
220
|
+
const probeWorker = async () => {
|
|
221
|
+
while (cursor < sorted.length) {
|
|
222
|
+
const index = cursor++;
|
|
223
|
+
result[index] = { ...sorted[index], probe: await probeADT(sorted[index], { ...options, proxyUrl, skipProbe }) };
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
await Promise.all(Array.from({ length: Math.min(8, sorted.length) }, () => probeWorker()));
|
|
211
227
|
return result;
|
|
212
228
|
}
|
|
213
229
|
|