sap-ai-dev-toolkit 0.5.5 → 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 +6 -6
  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 +14 -16
  25. package/src/bas-destination-relay.mjs +25 -3
  26. package/src/bas-discovery.mjs +9 -3
  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 +8 -11
  33. package/src/mcp-config.mjs +19 -1
  34. package/src/mcp-proxy.mjs +230 -76
  35. package/src/redact.mjs +28 -0
  36. package/src/setup.mjs +30 -1
  37. package/tools.md +1 -1
  38. package/test/bas-destination-relay.test.mjs +0 -415
  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 -282
  46. package/test/fixtures/fake-vsp.mjs +0 -266
  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 -401
  51. package/test/live-s4h.test.mjs +0 -204
  52. package/test/mcp-config-cf.test.mjs +0 -475
  53. package/test/mcp-proxy.test.mjs +0 -604
  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
@@ -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';
@@ -12,6 +11,7 @@ import { homedir } from 'node:os';
12
11
  import { colorText, formatStatus } from '../src/terminal-ui.mjs';
13
12
  import { brandedEnvValue, withBrandedEnvironment } from '../src/branding.mjs';
14
13
  import { generatedServerName } from '../src/mcp-config.mjs';
14
+ import { redactText } from '../src/redact.mjs';
15
15
 
16
16
  const root = dirname(dirname(fileURLToPath(import.meta.url)));
17
17
  const pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'));
@@ -77,10 +77,10 @@ async function runCopilotAssetInstall() {
77
77
  await announce([
78
78
  'Optional Copilot setup is waiting for your choice.',
79
79
  '',
80
- '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.',
81
81
  '',
82
- `${colorText('✅ Press Enter', 'green', true)} to install the bundled agents and all skills.`,
83
- `${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).`,
84
84
  '',
85
85
  `${colorText('📁 Target folder:', 'cyan', true)}`,
86
86
  ` ${copilotRoot}`,
@@ -89,11 +89,13 @@ async function runCopilotAssetInstall() {
89
89
  const prompt = createInterface({ input: terminal.input, output: terminal.output });
90
90
  let answer;
91
91
  try {
92
- 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)} `);
93
93
  } finally {
94
94
  prompt.close();
95
95
  }
96
- 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())) {
97
99
  await announce('Copilot agent and skills were skipped. Your files were not changed.', 'info');
98
100
  return;
99
101
  }
@@ -111,8 +113,7 @@ async function runCopilotAssetInstall() {
111
113
  function probeCell(destination) {
112
114
  const probe = destination.probe || {};
113
115
  if (probe.status === 'skipped') return { text: 'SKIPPED (probe disabled)', color: 'yellow' };
114
- const error = destination.source === 'cloud-foundry' ? '' : String(probe.error || '')
115
- .replace(/(authorization|cookie|password|secret|token)\s*[:=]\s*[^\s,;]+/gi, '$1=[redacted]')
116
+ const error = destination.source === 'cloud-foundry' ? '' : redactText(probe.error || '')
116
117
  .replace(/[\u0000-\u001f\u007f]/g, ' ')
117
118
  .replace(/\s+/g, ' ')
118
119
  .slice(0, 100);
@@ -219,17 +220,14 @@ async function announceSetup(result) {
219
220
  async function main() {
220
221
  if (process.env.npm_config_ignore_scripts === 'true') return;
221
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.
222
227
  try {
223
228
  if (brandedEnvValue(runtimeEnv, 'BINARY')) {
224
229
  await announce('Using the SAP_AI_DEV_TOOLKIT_BINARY override.', 'info');
225
230
  } else {
226
- await announce('Checking for Go. The supported version installs automatically if needed; this may take a few minutes.', 'progress');
227
- try {
228
- await ensureGo();
229
- await announce('Go is ready.', 'success');
230
- } catch (error) {
231
- throw new Error(`Go provisioning failed: ${error.message}`);
232
- }
233
231
  await announce('Preparing the pinned VSP runtime for this platform.', 'progress');
234
232
  try {
235
233
  await installBinary(pkg, { env: runtimeEnv });
@@ -240,7 +238,7 @@ async function main() {
240
238
  }
241
239
  } catch (error) {
242
240
  await announce(error.message, 'error');
243
- 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');
244
242
  process.exitCode = 1;
245
243
  return;
246
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;
@@ -274,7 +275,14 @@ export function createBasDestinationRelay(destination, { env = process.env, fetc
274
275
  function authHeaders(extra = {}) {
275
276
  if (!credentials?.user || !credentials?.password) return extra;
276
277
  const basic = Buffer.from(`${credentials.user}:${credentials.password}`).toString('base64');
277
- 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}` };
278
286
  }
279
287
 
280
288
  async function forward(method, requestUrl, requestHeaders, body, session) {
@@ -339,11 +347,17 @@ export function createBasDestinationRelay(destination, { env = process.env, fetc
339
347
  const body = UNSAFE.has(method) ? await readRequestBody(req) : Buffer.alloc(0);
340
348
  const { response, body: responseBody } = await forwardWithRetry(method, requestUrl, req.headers, body);
341
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;
342
356
  for (const [name, value] of response.headers) {
343
357
  const lower = name.toLowerCase();
344
358
  // undici decompresses response bodies; the stale content-encoding and
345
359
  // content-length would misdescribe the plain bytes handed to VSP.
346
- 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;
347
361
  responseHeaders[name] = value;
348
362
  }
349
363
  responseHeaders['x-sap-ai-dev-toolkit-relay'] = 'bas-destination';
@@ -351,10 +365,18 @@ export function createBasDestinationRelay(destination, { env = process.env, fetc
351
365
  res.end(responseBody);
352
366
  })().catch(error => {
353
367
  if (!res.headersSent) res.writeHead(502, { 'content-type': 'text/plain; charset=utf-8', 'connection': 'close' });
354
- 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`);
355
371
  });
356
372
  });
357
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
+ });
358
380
  // Bind eagerly and report the real port through `ready`. Callers must
359
381
  // await `ready` before handing the URL to a VSP child; binding failures
360
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(',');
package/src/binary.mjs CHANGED
@@ -1,11 +1,13 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { chmod, mkdir, rename, stat, writeFile } from 'node:fs/promises';
2
+ import { chmod, mkdir, readFile, rename, stat, writeFile } from 'node:fs/promises';
3
3
  import { homedir, tmpdir } from 'node:os';
4
4
  import { dirname, join } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { brandedEnvValue } from './branding.mjs';
7
7
 
8
8
  const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
9
+ // Single source of truth for the upstream VSP commit; scripts/build-vsp.mjs
10
+ // imports this constant so the built artifact and the runtime cannot drift.
9
11
  export const UPSTREAM_COMMIT = '9886d2727f47506368b0a3c2f1c1766f1200f747';
10
12
 
11
13
  const platformMap = {
@@ -26,6 +28,46 @@ export function bundledBinaryPath(platform = process.platform, arch = process.ar
26
28
  return join(packageRoot, 'dist', binaryTarget(platform, arch).asset);
27
29
  }
28
30
 
31
+ // The npm-published dist/checksums.txt is the trust anchor: unlike a GitHub
32
+ // release (mutable) or a downloaded checksum file (same origin as the
33
+ // binary), its content is fixed the moment the package version is published.
34
+ let packagedChecksumsCache;
35
+ async function packagedChecksums() {
36
+ if (packagedChecksumsCache !== undefined) return packagedChecksumsCache;
37
+ const map = new Map();
38
+ try {
39
+ const text = await readFile(join(packageRoot, 'dist', 'checksums.txt'), 'utf8');
40
+ for (const line of text.split(/\r?\n/)) {
41
+ const parts = line.trim().split(/\s+/);
42
+ if (parts.length >= 2) map.set(parts[1], parts[0].toLowerCase());
43
+ }
44
+ } catch {
45
+ // Development checkouts without a dist/ fall through to the remote
46
+ // release checksum during install; local candidates stay unverified.
47
+ }
48
+ packagedChecksumsCache = map;
49
+ return map;
50
+ }
51
+
52
+ async function sha256File(path) {
53
+ const bytes = await readFile(path);
54
+ return createHash('sha256').update(bytes).digest('hex');
55
+ }
56
+
57
+ // Verifies a locally present binary against the packaged checksum manifest.
58
+ // Returns true (verified), false (mismatch — must not be executed), or null
59
+ // (no packaged entry; caller decides whether to trust the candidate).
60
+ async function verifyAgainstPackagedChecksums(path, target) {
61
+ const checksums = await packagedChecksums();
62
+ const expected = checksums.get(target.asset);
63
+ if (!expected) return null;
64
+ try {
65
+ return (await sha256File(path)) === expected;
66
+ } catch {
67
+ return false;
68
+ }
69
+ }
70
+
29
71
  export function packageRepositoryUrl(pkg) {
30
72
  const raw = typeof pkg.repository === 'string' ? pkg.repository : pkg.repository?.url;
31
73
  if (!raw) throw new Error('package.json repository.url is required to resolve VSP release assets');
@@ -36,12 +78,12 @@ export function releaseBaseUrl(pkg, version = pkg.version) {
36
78
  const override = brandedEnvValue(process.env, 'RELEASE_BASE_URL');
37
79
  if (override) return `${override.replace(/\/$/, '')}/v${version}`;
38
80
  const repo = packageRepositoryUrl(pkg).replace(/^ssh:\/\/git@github.com:/, 'https://github.com/').replace(/^git@github.com:/, 'https://github.com/');
39
- if (!repo.startsWith('http')) throw new Error('package repository URL must be an HTTPS URL for release assets');
81
+ if (!repo.startsWith('https://')) throw new Error('package repository URL must be an HTTPS URL for release assets');
40
82
  return `${repo}/releases/download/v${version}`;
41
83
  }
42
84
 
43
- export function cacheDirectory() {
44
- return process.env.BAS_VSP_CACHE_DIR || join(process.env.XDG_CACHE_HOME || join(homedir(), '.cache'), 'sap-ai-dev-toolkit');
85
+ export function cacheDirectory(env = process.env) {
86
+ return brandedEnvValue(env, 'CACHE_DIR') || join(env.XDG_CACHE_HOME || join(homedir(), '.cache'), 'sap-ai-dev-toolkit');
45
87
  }
46
88
 
47
89
  export function cachedBinaryPath(pkg, platform = process.platform, arch = process.arch, env = process.env) {
@@ -51,22 +93,49 @@ export function cachedBinaryPath(pkg, platform = process.platform, arch = proces
51
93
 
52
94
  function legacyCachedBinaryPath(pkg, platform, arch, env) {
53
95
  const target = binaryTarget(platform, arch);
54
- const configuredCache = brandedEnvValue(env, 'CACHE_DIR');
55
- const cacheRoot = configuredCache || join(env.XDG_CACHE_HOME || join(homedir(), '.cache'), 'bas-mcp-addon');
96
+ const cacheRoot = join(env.XDG_CACHE_HOME || join(homedir(), '.cache'), 'bas-mcp-addon');
56
97
  return join(cacheRoot, `bas-mcp-addon-${pkg.version}-${target.asset}`);
57
98
  }
58
99
 
59
100
  export async function findBinary(pkg, options = {}) {
60
101
  const env = options.env || process.env;
102
+ const log = options.log || (() => {});
103
+ const target = binaryTarget(options.platform, options.arch);
61
104
  if (brandedEnvValue(env, 'BINARY')) return brandedEnvValue(env, 'BINARY');
62
105
  if (!brandedEnvValue(env, 'BINARY_URL')) {
63
106
  const bundled = bundledBinaryPath(options.platform, options.arch);
64
- try { await stat(bundled); return bundled; } catch {}
107
+ try {
108
+ await stat(bundled);
109
+ const verified = await verifyAgainstPackagedChecksums(bundled, target);
110
+ if (verified === false) {
111
+ log(`bundled VSP binary ${target.asset} failed checksum verification; ignoring it`);
112
+ } else {
113
+ if (verified === null) log(`bundled VSP binary ${target.asset} has no packaged checksum entry; using it unverified`);
114
+ return bundled;
115
+ }
116
+ } catch {}
65
117
  }
66
118
  const path = cachedBinaryPath(pkg, options.platform, options.arch, env);
67
- try { await stat(path); return path; } catch {}
119
+ try {
120
+ await stat(path);
121
+ const verified = await verifyAgainstPackagedChecksums(path, target);
122
+ if (verified === false) {
123
+ log(`cached VSP binary ${target.asset} failed checksum verification; ignoring it`);
124
+ } else {
125
+ if (verified === null) log(`cached VSP binary ${target.asset} has no packaged checksum entry; using it unverified`);
126
+ return path;
127
+ }
128
+ } catch {}
129
+ // The legacy bas-mcp-addon cache was written by an older release this code
130
+ // never verified; only execute it when the packaged checksum confirms it.
68
131
  const legacyPath = legacyCachedBinaryPath(pkg, options.platform, options.arch, env);
69
- try { await stat(legacyPath); return legacyPath; } catch { return null; }
132
+ try {
133
+ await stat(legacyPath);
134
+ const verified = await verifyAgainstPackagedChecksums(legacyPath, target);
135
+ if (verified === true) return legacyPath;
136
+ log(`legacy cached VSP binary ${legacyPath} has no packaged checksum match; ignoring it`);
137
+ } catch {}
138
+ return null;
70
139
  }
71
140
 
72
141
  export async function installBinary(pkg, options = {}) {
@@ -74,22 +143,37 @@ export async function installBinary(pkg, options = {}) {
74
143
  if (brandedEnvValue(env, 'BINARY')) return brandedEnvValue(env, 'BINARY');
75
144
  if (!brandedEnvValue(env, 'BINARY_URL')) {
76
145
  const bundled = bundledBinaryPath(options.platform, options.arch);
77
- try { await stat(bundled); return bundled; } catch {}
146
+ try {
147
+ await stat(bundled);
148
+ const target0 = binaryTarget(options.platform, options.arch);
149
+ if (await verifyAgainstPackagedChecksums(bundled, target0) !== false) return bundled;
150
+ throw new Error(`bundled VSP binary ${target0.asset} failed checksum verification`);
151
+ } catch (error) {
152
+ if (String(error?.message || '').includes('checksum verification')) throw error;
153
+ }
78
154
  }
79
155
  const target = binaryTarget(options.platform, options.arch);
80
156
  const base = releaseBaseUrl(pkg, pkg.version);
81
157
  const assetUrl = brandedEnvValue(env, 'BINARY_URL') || `${base}/${target.asset}`;
82
- const checksumUrl = `${base}/checksums.txt`;
83
158
  const response = await fetch(assetUrl);
84
159
  if (!response.ok) throw new Error(`VSP binary download failed (${response.status}); set SAP_AI_DEV_TOOLKIT_BINARY or SAP_AI_DEV_TOOLKIT_BINARY_URL`);
85
160
  const bytes = Buffer.from(await response.arrayBuffer());
86
- const checksumResponse = await fetch(checksumUrl);
87
- if (!checksumResponse.ok) throw new Error(`VSP checksum download failed (${checksumResponse.status}); set SAP_AI_DEV_TOOLKIT_BINARY or SAP_AI_DEV_TOOLKIT_BINARY_URL`);
88
- const checksumText = await checksumResponse.text();
89
- const expected = checksumText.split(/\r?\n/).map(line => line.trim().split(/\s+/)).find(parts => parts.length >= 2 && (parts[1] === target.asset || parts[1].endsWith(`/${target.asset}`)))?.[0];
90
- if (!expected) throw new Error(`checksums.txt has no entry for ${target.asset}; set SAP_AI_DEV_TOOLKIT_BINARY or SAP_AI_DEV_TOOLKIT_BINARY_URL`);
91
161
  const actual = createHash('sha256').update(bytes).digest('hex');
92
- if (actual.toLowerCase() !== expected.toLowerCase()) throw new Error(`VSP checksum mismatch for ${target.asset}`);
162
+ // Prefer the packaged checksum manifest (immutable per published version)
163
+ // as the anchor; only when it has no entry fall back to the release's own
164
+ // checksums.txt so pre-release builds keep working.
165
+ let expected = (await packagedChecksums()).get(target.asset);
166
+ let expectedSource = 'packaged checksums.txt';
167
+ if (!expected) {
168
+ const checksumUrl = `${base}/checksums.txt`;
169
+ const checksumResponse = await fetch(checksumUrl);
170
+ if (!checksumResponse.ok) throw new Error(`VSP checksum download failed (${checksumResponse.status}); set SAP_AI_DEV_TOOLKIT_BINARY or SAP_AI_DEV_TOOLKIT_BINARY_URL`);
171
+ const checksumText = await checksumResponse.text();
172
+ expected = checksumText.split(/\r?\n/).map(line => line.trim().split(/\s+/)).find(parts => parts.length >= 2 && (parts[1] === target.asset || parts[1].endsWith(`/${target.asset}`)))?.[0]?.toLowerCase();
173
+ expectedSource = 'release checksums.txt';
174
+ if (!expected) throw new Error(`checksums.txt has no entry for ${target.asset}; set SAP_AI_DEV_TOOLKIT_BINARY or SAP_AI_DEV_TOOLKIT_BINARY_URL`);
175
+ }
176
+ if (actual !== expected) throw new Error(`VSP checksum mismatch for ${target.asset} (verified against ${expectedSource})`);
93
177
  const dir = cacheDirectory(env);
94
178
  await mkdir(dir, { recursive: true, mode: 0o700 });
95
179
  await chmod(dir, 0o700);
@@ -114,6 +114,51 @@ export async function deleteManagedCloudFoundryServiceKeys({ env = process.env,
114
114
  return { warnings };
115
115
  }
116
116
 
117
+ async function listToolkitServiceKeys({ env = process.env, spaceGuid, execFileImpl = nodeExecFile } = {}) {
118
+ const options = { env, execFileImpl };
119
+ const keys = [];
120
+ let path = `/v3/service_credential_instances?space_guids=${encodeURIComponent(spaceGuid)}&type=key&per_page=500`;
121
+ const visited = new Set();
122
+ while (path && !visited.has(path)) {
123
+ visited.add(path);
124
+ const page = await cfJson(path, options);
125
+ if (!Array.isArray(page?.resources)) return keys;
126
+ for (const resource of page.resources) {
127
+ const name = text(resource?.name);
128
+ const instanceGuid = resource?.relationships?.service_instance?.data?.guid;
129
+ if (!name.startsWith(KEY_PREFIX) || !instanceGuid) continue;
130
+ keys.push({ keyName: name, instanceGuid: String(instanceGuid) });
131
+ }
132
+ path = page?.metadata?.pagination?.next?.href ? apiPath(page.metadata.pagination.next.href) : '';
133
+ }
134
+ return keys;
135
+ }
136
+
137
+ // Reconcile toolkit-created service keys against the references recorded in
138
+ // the MCP config. A Destination-instance service key exposes client
139
+ // credentials for every destination in the instance, so a key orphaned by an
140
+ // interrupted setup must not linger unnoticed.
141
+ export async function findOrphanedCloudFoundryServiceKeys({ env = process.env, spaceGuid, managedKeys = [], execFileImpl = nodeExecFile } = {}) {
142
+ const listed = await listToolkitServiceKeys({ env, spaceGuid, execFileImpl });
143
+ const referenced = new Set((managedKeys || [])
144
+ .filter(ref => ref?.spaceGuid === spaceGuid && ref?.instanceGuid && ref?.keyName)
145
+ .map(ref => `${ref.instanceGuid}\0${ref.keyName}`));
146
+ const orphans = [];
147
+ const instanceNames = new Map();
148
+ for (const key of listed) {
149
+ if (referenced.has(`${key.instanceGuid}\0${key.keyName}`)) continue;
150
+ if (!instanceNames.has(key.instanceGuid)) {
151
+ try {
152
+ instanceNames.set(key.instanceGuid, text((await cfJson(`/v3/service_instances/${encodeURIComponent(key.instanceGuid)}`, { env, execFileImpl })).name));
153
+ } catch {
154
+ instanceNames.set(key.instanceGuid, '');
155
+ }
156
+ }
157
+ orphans.push({ kind: 'toolkit-created', spaceGuid, instanceGuid: key.instanceGuid, instanceName: instanceNames.get(key.instanceGuid) || key.instanceGuid, keyName: key.keyName });
158
+ }
159
+ return orphans;
160
+ }
161
+
117
162
  async function cfJson(path, options) {
118
163
  const value = await runCf(['curl', path], options);
119
164
  try {
@@ -493,10 +538,19 @@ function probeResult(status) {
493
538
  return { status: 'unavailable', available: false, httpStatus: status };
494
539
  }
495
540
 
541
+ function isLoopbackHostname(hostname) {
542
+ const host = String(hostname || '').toLowerCase().replace(/^\[|\]$/g, '');
543
+ return host === '127.0.0.1' || host === 'localhost' || host === '::1';
544
+ }
545
+
496
546
  async function probeRecord(record, env, { forcedProxy, fetchImpl = undiciFetch } = {}) {
497
547
  const target = new URL('/sap/bc/adt/discovery', record.url);
498
548
  const headers = { accept: 'application/xml,text/xml,*/*' };
499
- if (record.authentication === 'BasicAuthentication') {
549
+ // Never send SAP Basic credentials over cleartext HTTP to a remote host:
550
+ // an http:// destination URL is allowed for reachability probing, but the
551
+ // probe runs unauthenticated then (401/403 still counts as reachable).
552
+ const plaintextRemote = target.protocol === 'http:' && !isLoopbackHostname(target.hostname);
553
+ if (record.authentication === 'BasicAuthentication' && !plaintextRemote) {
500
554
  headers.authorization = `Basic ${Buffer.from(`${record.user}:${record.password}`).toString('base64')}`;
501
555
  }
502
556
  let dispatcher;
@@ -10,8 +10,6 @@ import { brandedEnvValue } from './branding.mjs';
10
10
  const CREDENTIALS_FILENAME = 'sap-ai-dev-toolkit-credentials.json';
11
11
  const CREDENTIAL_MODES = new Set(['direct', 'bas-tunnel', 'cf-connectivity']);
12
12
 
13
- const SECRET_PATTERN = /(authorization|cookie|password|secret|token|bearer|credential)/i;
14
-
15
13
  async function exists(path) {
16
14
  try {
17
15
  await stat(path);
@@ -22,10 +20,6 @@ async function exists(path) {
22
20
  }
23
21
  }
24
22
 
25
- export function redactCredentialValue(value) {
26
- return SECRET_PATTERN.test(String(value)) ? '[redacted]' : value;
27
- }
28
-
29
23
  export async function resolveCredentialsPath(env = process.env, mcpConfigPath) {
30
24
  const configured = brandedEnvValue(env, 'CREDENTIALS_FILE');
31
25
  if (configured) return configured;
@@ -117,7 +111,3 @@ export async function removeDestinationCredentials(path, names) {
117
111
  if (removed.length) await writeCredentials(path, destinations);
118
112
  return { path, removed };
119
113
  }
120
-
121
- export function listCredentialDestinations(credentials) {
122
- return Object.entries(credentials?.destinations || {}).map(([name, entry]) => ({ name, host: entry.host, user: entry.user, updatedAt: entry.updatedAt }));
123
- }
@@ -1,12 +1,14 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
2
  import { ProxyAgent, fetch as undiciFetch } from 'undici';
3
3
  import { sanitizeChildEnv } from './bas-discovery.mjs';
4
+ import { redactText } from './redact.mjs';
4
5
 
5
6
  const CHANGESET_WRITE_TOOLS = new Set(['WriteSource']);
6
7
  const TRANSPORT_CHECK_TOOLS = new Set(['GetTransport', 'GetTransportInfo', 'ListDependencies', 'GetInactiveObjects', 'RunUnitTests', 'RunATCCheck']);
7
8
  const MAX_CHANGE_BYTES = 128 * 1024;
8
9
  const MAX_CHANGESET_SIZE = 12;
9
10
  const MAX_REMOTE_BODY_BYTES = 2 * 1024 * 1024;
11
+ const MAX_SUITE_CASES = 30;
10
12
 
11
13
  function object(value) {
12
14
  return value !== null && typeof value === 'object' && !Array.isArray(value);
@@ -21,9 +23,7 @@ function toolJson(value) {
21
23
  }
22
24
 
23
25
  function safeDiagnostic(value) {
24
- return String(value?.message || value || 'unknown error')
25
- .replace(/(authorization|proxy-authorization)\s*[:=]\s*(?:bearer\s+|basic\s+)?[^\s,;]+/gi, '$1=[redacted]')
26
- .replace(/(cookie|password|secret|token)\s*[:=]\s*[^\s,;]+/gi, '$1=[redacted]')
26
+ return redactText(value?.message || value || 'unknown error')
27
27
  .replace(/\s+/g, ' ')
28
28
  .slice(0, 300);
29
29
  }
@@ -339,27 +339,43 @@ function responseForDemo(path) {
339
339
  return { status: 200, contentType: 'application/json', body: JSON.stringify({ value: [{ ID: '10000001', Description: 'Sample order' }] }) };
340
340
  }
341
341
 
342
+ function isLoopbackHost(hostname) {
343
+ const host = String(hostname || '').toLowerCase().replace(/^\[|\]$/g, '');
344
+ return host === '127.0.0.1' || host === 'localhost' || host === '::1';
345
+ }
346
+
347
+ // OData reads follow the same egress rules as the relay: loopback targets
348
+ // (the local BAS relay the child talks to) must go direct, NO_PROXY
349
+ // exemptions are honored, and everything else uses the configured proxy.
350
+ function proxyUrlFor(target, env) {
351
+ if (isLoopbackHost(target.hostname)) return '';
352
+ const host = target.hostname.toLowerCase();
353
+ const noProxy = String(env.NO_PROXY || env.no_proxy || '').split(',').map(value => value.trim().toLowerCase()).filter(Boolean);
354
+ if (noProxy.some(entry => entry === '*' || host === entry || host.endsWith(entry.startsWith('.') ? entry : `.${entry}`))) return '';
355
+ return String(env.HTTP_PROXY || env.http_proxy || env.HTTPS_PROXY || env.https_proxy || '');
356
+ }
357
+
342
358
  async function fetchOData(entry, path, accept, env) {
343
359
  if (entry.destination.demo) return responseForDemo(path);
344
360
  const childEnv = { ...sanitizeChildEnv(env), ...(entry.destination.childEnv || {}) };
345
- const proxyUrl = childEnv.HTTP_PROXY || childEnv.http_proxy;
346
- if (!proxyUrl) throw new Error('No HTTP proxy is configured for the destination');
347
361
  const target = new URL(path, entry.destination.url);
348
362
  const base = new URL(entry.destination.url);
349
363
  if (target.origin !== base.origin || target.username || target.password || target.hash) throw new Error('OData request escaped the connected destination');
350
- const dispatcher = new ProxyAgent(proxyUrl);
364
+ const proxyUrl = proxyUrlFor(target, childEnv);
365
+ if (!proxyUrl && /\.dest$/i.test(target.hostname)) throw new Error('No HTTP proxy is configured for the .dest destination');
366
+ const dispatcher = proxyUrl ? new ProxyAgent(proxyUrl) : undefined;
351
367
  try {
352
368
  const response = await undiciFetch(target, {
353
369
  method: 'GET',
354
370
  headers: { accept },
355
- dispatcher,
371
+ ...(dispatcher ? { dispatcher } : {}),
356
372
  signal: AbortSignal.timeout(10_000),
357
373
  redirect: 'manual'
358
374
  });
359
375
  const body = await readBody(response);
360
376
  return { status: response.status, contentType: response.headers.get('content-type') || '', body };
361
377
  } finally {
362
- await dispatcher.close();
378
+ await dispatcher?.close();
363
379
  }
364
380
  }
365
381
 
@@ -576,17 +592,23 @@ export function createEngineeringTools(entry, upstreamTools, { env = process.env
576
592
  if (response.status < 200 || response.status >= 300) throw new Error(`OData metadata request returned HTTP ${response.status}`);
577
593
  const entitySets = parseEntitySets(response.body);
578
594
  if (!entitySets.length) throw new Error('OData metadata did not contain entity sets');
595
+ // Cap generation so the emitted suite is always accepted by
596
+ // RunRAPRegressionSuite's own 30-case limit; report what was omitted.
597
+ const included = entitySets.slice(0, MAX_SUITE_CASES - 1);
579
598
  return toolJson({
580
599
  suite: {
581
600
  name: `${entry.destination.name} RAP smoke suite`,
582
601
  service_root: serviceRoot,
583
602
  cases: [
584
603
  { name: 'OData metadata', path: '$metadata', expected_status: 200, expected_content_type: 'xml' },
585
- ...entitySets.map(name => ({ name: `Read ${name}`, path: `${name}?$top=1`, expected_status: 200, expected_content_type: 'json' }))
604
+ ...included.map(name => ({ name: `Read ${name}`, path: `${name}?$top=1`, expected_status: 200, expected_content_type: 'json' }))
586
605
  ]
587
606
  },
588
607
  entity_set_count: entitySets.length,
589
- destination: entry.destination.name
608
+ destination: entry.destination.name,
609
+ ...(entitySets.length > included.length
610
+ ? { note: `${entitySets.length - included.length} entity sets were omitted to keep the suite within the ${MAX_SUITE_CASES}-case limit; generate targeted suites for them separately.` }
611
+ : {})
590
612
  });
591
613
  });
592
614
 
@@ -7,7 +7,11 @@ const MAX_COLUMNS = 40;
7
7
  const MAX_FILTERS = 20;
8
8
  const MAX_SELECTED_COLUMN_WIDTH = 8192;
9
9
  const UNBOUNDED_COLUMN_TYPES = new Set(['BLOB', 'CLOB', 'NCLOB', 'BINARY', 'VARBINARY', 'ST_GEOMETRY', 'ST_POINT']);
10
- const SENSITIVE_COLUMN_NAME = /(?:PASS(?:WORD|WD)|SECRET|(?:ACCESS|REFRESH|AUTH)?_?TOKEN|API_?KEY|PRIVATE_?KEY|CREDENTIAL)/i;
10
+ // Denylist of credential-like column names. This is a heuristic — the real
11
+ // control is the dedicated read-only HANA identity — but it keeps obvious
12
+ // secret material (password hashes, salts, signatures, certificates, JWTs,
13
+ // session ids) out of tool results even under that identity.
14
+ const SENSITIVE_COLUMN_NAME = /(?:PASS(?:WORD|WD)?|PWD|PASSPHRASE|SECRET|CREDENTIAL|(?:ACCESS|REFRESH|AUTH|SESSION)?_?TOKEN|API_?KEY|ACCESS_?KEY|PRIVATE_?KEY|SIGNATURE|SALT|CERT(?:IFICATE)?|JWT|BEARER)/i;
11
15
  const SAFE_INSPECTION_ERRORS = new Set([
12
16
  'HANA result is too large to return safely; request fewer rows or columns.',
13
17
  'The requested object was not found in the bound HDI schema.',
@@ -181,7 +185,10 @@ async function readRows(database, schema, args) {
181
185
  const where = conditions.length ? ` WHERE ${conditions.join(' AND ')}` : '';
182
186
  const sql = `SELECT ${selected} FROM ${quoteHanaIdentifier(schema)}.${quoteHanaIdentifier(args.objectName)}${where}${order} LIMIT ${args.limit}`;
183
187
  const values = args.filters.map(filter => filter.value);
184
- const rows = rowsOf(await database.query(sql, values));
188
+ // Defense in depth: the SQL LIMIT and the zod cap already bound the read,
189
+ // but the returned rows are sliced again so no future query-shape change
190
+ // can smuggle more than HANA_MAX_ROWS into a tool result.
191
+ const rows = rowsOf(await database.query(sql, values)).slice(0, HANA_MAX_ROWS);
185
192
  return {
186
193
  schema,
187
194
  ...target,