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.
Files changed (57) hide show
  1. package/.github/agents/abap-developer.agent.md +3 -3
  2. package/.github/agents/abap-runtime-debugger.agent.md +3 -3
  3. package/.github/agents/hana-cloud-hdi-specialist.agent.md +1 -1
  4. package/.github/agents/rap-service-developer.agent.md +1 -1
  5. package/.github/agents/sap-solution-architect.agent.md +1 -1
  6. package/.github/skills/abap-debugging/SKILL.md +3 -3
  7. package/.github/skills/abap-development/SKILL.md +1 -1
  8. package/.github/skills/abap-runtime-analysis/SKILL.md +3 -3
  9. package/.github/skills/abap-testing-quality/SKILL.md +1 -1
  10. package/.github/skills/cds-development/SKILL.md +1 -1
  11. package/.github/skills/clean-core-extensibility/SKILL.md +1 -1
  12. package/.github/skills/hana-cloud-inspection/SKILL.md +1 -1
  13. package/.github/skills/hana-cloud-native-development/SKILL.md +2 -2
  14. package/.github/skills/hana-cloud-validation/SKILL.md +1 -1
  15. package/.github/skills/rap-development/SKILL.md +1 -1
  16. package/.github/skills/rap-service-delivery/SKILL.md +1 -1
  17. package/.github/skills/sap-sdlc-orchestration/SKILL.md +1 -1
  18. package/.github/skills/sap-standard-api-analysis/SKILL.md +1 -1
  19. package/.github/skills/sap-transport-release/SKILL.md +1 -1
  20. package/README.md +17 -11
  21. package/inventory.md +179 -0
  22. package/package.json +2 -2
  23. package/scripts/build-vsp.mjs +2 -1
  24. package/scripts/postinstall.mjs +17 -18
  25. package/src/bas-destination-relay.mjs +58 -22
  26. package/src/bas-discovery.mjs +24 -8
  27. package/src/binary.mjs +101 -17
  28. package/src/cf-destination.mjs +55 -1
  29. package/src/credentials-store.mjs +0 -10
  30. package/src/engineering-tools.mjs +32 -10
  31. package/src/hana-tools.mjs +9 -2
  32. package/src/launcher.mjs +22 -19
  33. package/src/mcp-config.mjs +41 -7
  34. package/src/mcp-proxy.mjs +392 -104
  35. package/src/redact.mjs +28 -0
  36. package/src/setup.mjs +33 -3
  37. package/tools.md +4 -2
  38. package/test/bas-destination-relay.test.mjs +0 -374
  39. package/test/cf-connectivity.test.mjs +0 -287
  40. package/test/cf-destination.test.mjs +0 -307
  41. package/test/cf-runtime.test.mjs +0 -527
  42. package/test/copilot-assets.test.mjs +0 -63
  43. package/test/copilot-content.test.mjs +0 -249
  44. package/test/credentials-store.test.mjs +0 -187
  45. package/test/discovery.test.mjs +0 -257
  46. package/test/fixtures/fake-vsp.mjs +0 -263
  47. package/test/hana-config.test.mjs +0 -168
  48. package/test/hana-inspector-stdio.test.mjs +0 -44
  49. package/test/hana-tools.test.mjs +0 -206
  50. package/test/launcher.test.mjs +0 -399
  51. package/test/live-s4h.test.mjs +0 -202
  52. package/test/mcp-config-cf.test.mjs +0 -407
  53. package/test/mcp-proxy.test.mjs +0 -409
  54. package/test/pty.mjs +0 -8
  55. package/test/setup-cf.test.mjs +0 -489
  56. package/test/setup.test.mjs +0 -417
  57. 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.0",
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
  ],
@@ -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 = '9886d2727f47506368b0a3c2f1c1766f1200f747';
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', ''],
@@ -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
- 'Press Enter to install the bundled agents and all skills in the path shown below; type n then press Enter to skip.',
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('✅ Press Enter', 'green', true)} to install the bundled agents and all skills.`,
82
- `${colorText('⏭️ Type n then Enter', 'yellow', true)} to skip this optional step.`,
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('[Y/n]', 'yellow', terminal.output)} `);
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
- if (/^n(?:o)?$/i.test(answer.trim())) {
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' ? '' : String(probe.error || '')
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('Go is provisioned automatically. Set SAP_AI_DEV_TOOLKIT_BINARY only when supplying a trusted prebuilt VSP executable.', 'info');
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(requestUrl, requestHeaders) {
204
- const key = requestUrl.pathname;
210
+ async function fetchTokenFresh(requestHeaders) {
211
+ const key = CSRF_FETCH_PATH;
205
212
  stats.csrfFetches += 1;
206
- const target = resolveTarget(requestUrl);
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(requestUrl, requestHeaders = {}) {
233
- const key = requestUrl.pathname;
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(requestUrl, requestHeaders);
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(path = '/sap/bc/adt/discovery') {
248
- const requestUrl = new URL(path, base);
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
- sessionUsable: Boolean(session?.token && session.cookieCount > 0)
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
- return { authorization: `Basic ${basic}`, ...extra };
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(requestUrl, requestHeaders) : null;
295
- if (unsafe && (!session?.token || !session.cookieCount)) {
311
+ const session = unsafe ? await fetchToken(requestHeaders) : null;
312
+ if (unsafe && !session?.token) {
296
313
  stats.csrfSessionFailures += 1;
297
- const detail = !session?.token
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(requestUrl.pathname);
331
+ tokenCache.delete(CSRF_FETCH_PATH);
312
332
  return forwardWithRetry(method, requestUrl, requestHeaders, body, failures + 1);
313
333
  }
314
- if (unsafe && rejected && failures >= maxFailures) {
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
- res.end(`BAS destination relay failed: ${error.message}\n`);
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.
@@ -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
- const proxy = env.HTTP_PROXY || env.http_proxy || DEFAULT_PROXY;
96
- if (!env.HTTP_PROXY && !env.http_proxy) env.HTTP_PROXY = proxy;
97
- if (!env.HTTPS_PROXY && !env.https_proxy) env.HTTPS_PROXY = proxy;
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 result = [];
207
- for (const destination of normalized.sort((a, b) => a.name.localeCompare(b.name))) {
208
- const probe = await probeADT(destination, { ...options, proxyUrl, skipProbe });
209
- result.push({ ...destination, probe });
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