sap-ai-dev-toolkit 0.5.7 → 0.6.1

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/README.md CHANGED
@@ -84,7 +84,7 @@ Use the attached destination-prefixed tools directly in chat. Do not launch `sap
84
84
 
85
85
  **Included skills:** `abap-development` · `abap-testing-quality` · `cds-development` · `rap-development` · `abap-debugging` · `abap-runtime-analysis` · `rap-service-delivery` · `sap-standard-api-analysis` · `clean-core-extensibility` · `sap-sdlc-orchestration` · `sap-transport-release` · `hana-cloud-inspection` · `hana-cloud-native-development` · `hana-cloud-validation`
86
86
 
87
- If the agents are not listed, install the optional agents and skills under `$HOME/.copilot` when prompted during an interactive global install, then reload BAS if needed. Repository-scoped installation instructions appear below.
87
+ Agents and skills install into `$HOME/.copilot` by default during global installation; press Enter to accept or type `n` to skip. In non-interactive installs they are installed automatically. Repository-scoped installation instructions appear below.
88
88
 
89
89
  ## ✨ Your SAP development cockpit, inside Copilot Chat
90
90
 
@@ -237,10 +237,9 @@ Once the destination server is enabled in Copilot Chat, ask for outcomes instead
237
237
  | --- | --- |
238
238
  | 🎯 **Explicit target** | SAP changes only proceed when the destination and required target details are known. |
239
239
  | 🚦 **Explicit state change** | Activation, service publication, and transport creation happen only when requested and authorized. |
240
- | 👁️ **Optional read-only mode** | `SAP_AI_DEV_TOOLKIT_READ_ONLY=true` removes every write/activate/transport-create tool from the surface and starts VSP with `--transport-read-only`. Recommended for exploration destinations. |
241
240
  | 🧪 **Verification first** | The agent uses available lint, syntax, unit-test, ATC, and diagnostics workflows and reports what actually ran. |
242
241
  | 🔒 **SAP authorization remains authoritative** | The add-on does not bypass backend SAP permissions. |
243
- | 🚚 **Curated VSP tool surface** | The proxy exposes a cherry-picked developer-lifecycle set from VSP plus local workflow tools, keeping one destination below 60 tools; SAP authorizations and VSP safety checks still apply. |
242
+ | 🚚 **Curated VSP tool surface** | The proxy exposes the same curated developer-lifecycle set for every destination, plus local workflow tools; SAP authorizations and VSP safety checks still apply. |
244
243
  | 🧱 **Per-destination isolation** | Generated MCP entries are scoped to a single `SAP_AI_DEV_TOOLKIT_DESTINATION`. |
245
244
  | 🔑 **No credentials in `mcp.json`** | Authentication material stays in BAS destination configuration rather than MCP config. |
246
245
 
@@ -278,6 +277,9 @@ The installer handles VSP provisioning automatically:
278
277
 
279
278
  With `H2O_URL` set, an interactive install opens a checkbox picker with no destinations selected by default. Use **Space** to choose destinations and **Enter** to confirm. Press **a** to toggle all destinations (select all if any are unchecked; otherwise clear the selection). Confirming with none selected removes this add-on's managed MCP entries. When the `cf` CLI 8.18 or newer is authenticated to a targeted space, setup first offers an optional import from that space's Destination service; type **y** then **Enter** to include it, or press **Enter** to skip. Accepted CF and BAS destinations appear together in the picker. npm may run its install hook without an interactive terminal, even when the shell is interactive; in that case, selection is skipped without changing MCP config.
280
279
 
280
+
281
+ The same postinstall installs bundled Copilot agents and skills under `$HOME/.copilot` by default. Press Enter at `[Y/n]` to install or update them; type `n` to leave them unchanged. With no interactive terminal, installation proceeds automatically.
282
+
281
283
  Some current npm versions also require install hooks to be approved. If npm reports that `sap-ai-dev-toolkit`'s `postinstall` was blocked, allow it during a fresh install with `npm install --global --allow-scripts=sap-ai-dev-toolkit sap-ai-dev-toolkit`, or run `sap-ai-dev --setup` from an interactive BAS terminal after installation.
282
284
 
283
285
  The setup report uses icons and terminal colors; set `NO_COLOR=1` to disable ANSI colors. The table is a weather report, not a bouncer: green **PASS** means the ADT probe responded, red **FAIL** means it failed, and yellow **SKIPPED** means it was skipped. Probe failures do not block MCP registration or startup for destinations you select.
@@ -400,7 +402,7 @@ The sample values sketch an on-premise ABAP backend routed through SAP Cloud Con
400
402
  | `WebIDEUsage` | `dev_abap,odata_abap` | Include `dev_abap` for ABAP development; add other usages required by your BAS scenario. |
401
403
  | `CloudConnectorLocationId` | `DEMO-LOCATION` | Optional; set only when the Cloud Connector uses a location ID. |
402
404
 
403
- For on-premise systems, configure Cloud Connector access to the backend host and port first. BAS Destination and Connectivity service credentials remain in their respective services; optional SAP backend overrides are stored in the owner-only credentials file beside `mcp.json`, never in `mcp.json`. The BAS OnPremise override is saved only when its tunnel route returns a CSRF token and session cookie.
405
+ For on-premise systems, configure Cloud Connector access to the backend host and port first. The add-on uses the authentication already configured in BAS or the Cloud Foundry Destination service; manage SAP credentials there.
404
406
 
405
407
  Think of `H2O_URL` as BAS's front door: it must point to the endpoint serving `/api/listDestinations`, not to the SAP backend.
406
408
 
@@ -617,8 +619,11 @@ The write and activation tools change SAP state. Confirm the target, package, an
617
619
  | `GetUserTransports` | Read and group a user's transport requests and tasks; supports organizer filters and source selection. |
618
620
  | `GetTransport` | Read a transport request's details, objects, and tasks. |
619
621
  | `GetTransportInfo` | Find eligible transports and lock status for an ABAP object or package. |
622
+ | `LockObject` | Lock an ABAP repository object in SAP. |
623
+ | `UnlockObject` | Unlock an ABAP repository object in SAP. |
620
624
  | `CreateTransport` | Create a transport request. |
621
625
  | `CheckTransportReadiness` | Collect a whitelisted bundle of transport, dependency, inactive-object, ABAP Unit, and ATC evidence. |
626
+ `LockObject` and `UnlockObject` are state-changing VSP tools; the proxy does not retry them after a child crash to avoid duplicate side effects.
622
627
 
623
628
  Transport release and deletion are not exposed by the curated proxy surface; release or delete transports outside this add-on after review.
624
629
 
@@ -734,14 +739,12 @@ The response contains a `tools` array. A `RunQuery` entry resembles this excerpt
734
739
  | `SAP_AI_DEV_TOOLKIT_MODE` | VSP child mode (`expert` by default; `focused` omits `ActivateMultiple`, `GetUserTransports`, and `GetTransportInfo`). The proxy exposes its curated tool subset and local workflow tools, including `LintABAP`. |
735
740
  | `SAP_ALLOW_TRANSPORTABLE_EDITS` | Generated MCP entries set this to `true` to permit source edits in transportable packages; VSP safety checks and SAP authorizations still apply. |
736
741
  | `SAP_AI_DEV_TOOLKIT_DISABLE_BAS_RELAY=true` | Disable the built-in BAS destination relay. By default the add-on self-heals `.dest` destinations through a local relay that keeps all access destination-based while handling ADT CSRF fetch/retry behavior before VSP calls SAP. |
737
- | `SAP_AI_DEV_TOOLKIT_HTTP_PROXY` | Egress proxy for relay and discovery traffic. Takes precedence over `HTTP_PROXY`/`http_proxy`; unset means the default BAS proxy for `.dest` hosts, empty means direct except for OnPremise credential overrides, which require a BAS proxy tunnel. |
742
+ | `SAP_AI_DEV_TOOLKIT_HTTP_PROXY` | Egress proxy for relay and discovery traffic. Takes precedence over `HTTP_PROXY`/`http_proxy`; unset means the default BAS proxy for `.dest` hosts, empty means direct. |
738
743
  | `SAP_AI_DEV_TOOLKIT_MAX_CSRF_RETRIES` | Bounded retries after explicit CSRF-session rejection per unsafe request (default 3, maximum 10). Invalid values use the default. |
739
- | `SAP_AI_DEV_TOOLKIT_READ_ONLY=true` | Read-only mode: hides `WriteSource`, `EditSource`, `Activate`, `ActivateMultiple`, `CreateTransport`, `SetBreakpoint`, and the change-set workflow tools, and starts VSP with `--transport-read-only`. |
740
744
  | `SAP_AI_DEV_TOOLKIT_REQUEST_TIMEOUT_MS` | Per-request timeout for calls forwarded to a VSP child (default 600000 = 10 minutes; `0` disables). A stalled request fails with a timeout error; the child is left running. |
741
745
  | `SAP_AI_DEV_MCP_CONFIG` | Explicit MCP configuration path; highest precedence. |
742
746
  | `SAP_AI_DEV_TOOLKIT_MCP_CONFIG` | Branded compatibility alias for the MCP configuration path. |
743
747
  | `BAS_VSP_MCP_CONFIG` | Backward-compatible alias for the MCP configuration path. |
744
- | `SAP_AI_DEV_TOOLKIT_CREDENTIALS_FILE` | Explicit path for the optional SAP backend credential-override file (default: beside `mcp.json`). |
745
748
  | `SAP_AI_DEV_TOOLKIT_BINARY` | Trusted prebuilt VSP executable; skips binary provisioning. Legacy `BAS_VSP_BINARY` still works. |
746
749
  | `SAP_AI_DEV_TOOLKIT_BINARY_URL` | Alternate VSP binary download URL (checksum-verified). Legacy `BAS_VSP_BINARY_URL` still works. |
747
750
  | `SAP_AI_DEV_TOOLKIT_RELEASE_BASE_URL` | Override the base URL that VSP release binaries are downloaded from. |
@@ -760,8 +763,7 @@ The relay between the VSP child and each BAS destination recovers from the failu
760
763
  2. **Session refresh** — when SAP explicitly rejects a CSRF token, the cached session is dropped, a fresh token+cookie pair is fetched, and the request is retried within the configured bound. Generic authorization 401/403 responses are returned without being mislabeled or retried as CSRF failures.
761
764
  3. **Proxy tunnel fallback** — when the BAS proxy refuses absolute-form requests (502/504 or transport errors), the relay switches to a CONNECT tunnel through the same proxy and keeps going.
762
765
  4. **Child crash recovery** — a crashed VSP child is restarted transparently, re-initialized, tools re-registered, and the interrupted `tools/call` retried once before any error reaches the client.
763
- 5. **Direct connect for Internet destinations** — the BAS `.dest` proxy strips SAP `Set-Cookie` headers, which makes CSRF token/session binding impossible for ADT writes (`403 CSRF token validation failed`). When you select an Internet destination with BasicAuthentication, setup asks whether to override its credentials. A Yes prompts for SAP user/password and stores them in `sap-ai-dev-toolkit-credentials.json` **next to** `mcp.json` (never inside it, permissions `0600`). The relay connects straight to the backend host so cookies survive and ADT writes can use the paired CSRF session.
764
- 6. **Cloud Connector credential overrides** — setup also offers per-destination overrides for BasicAuthentication OnPremise destinations. BAS OnPremise credentials travel through an HTTP CONNECT tunnel to the BAS `.dest` endpoint, retaining its Cloud Connector mapping; setup saves the override only after a read-only probe returns both a CSRF token and a session cookie. If that route check fails, the override is not saved. Cloud Foundry OnPremise overrides replace only the SAP backend user/password; Connectivity service-key authentication and its proxy route remain unchanged. PrincipalPropagation is never overridden. Rerun `sap-ai-dev --setup` to change credentials or answer No to disable a stored override. Deselecting a destination (or selecting none) removes stale overrides.
766
+ 5. **Configured destination authentication** — the add-on always uses the URL, authentication mode, and credentials already configured for the selected destination. Setup does not prompt for or store SAP usernames/passwords, and the relay does not connect directly to a backend host as a credential fallback. Correct the credentials in BAS or the Cloud Foundry Destination service if SAP rejects them.
765
767
  Existing installs that still set the previous `BAS_VSP_*` environment variables remain supported. The setup wizard writes new MCP entries with the `SAP_AI_DEV_TOOLKIT_*` names.
766
768
 
767
769
  #### Server logging
package/inventory.md CHANGED
@@ -163,7 +163,7 @@ These logical names are exposed through MCP in lowercase snake_case (`lint_abap`
163
163
 
164
164
  - Runtime tool names are unprefixed lowercase snake_case (`run_query`) on the normal single-destination server; a `<destination-slug>_` prefix appears only on multi-destination servers. `tools/call` accepts the exact name returned by `tools/list`.
165
165
  - The proxy starts VSP with `--enable-transports`; generated MCP entries set `SAP_ALLOW_TRANSPORTABLE_EDITS=true`.
166
- - `SAP_AI_DEV_TOOLKIT_READ_ONLY=true` switches a server to read-only: the write/activate/transport-create/breakpoint tools above are removed from the surface, change-set workflows are not registered, and VSP starts with `--transport-read-only` instead.
166
+ - The curated VSP tool surface and enabled transport mode are the same for every destination; SAP authorization remains authoritative.
167
167
  - `SAP_AI_DEV_TOOLKIT_REQUEST_TIMEOUT_MS` (default 600000) bounds each forwarded call; stalled requests fail without killing the child.
168
168
  - 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.
169
169
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sap-ai-dev-toolkit",
3
- "version": "0.5.7",
3
+ "version": "0.6.1",
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",
@@ -14,7 +14,7 @@ function usage() {
14
14
  '',
15
15
  ' (default) Packs the workspace and installs the tarball globally, then',
16
16
  ' runs the normal postinstall wizard (binary provisioning,',
17
- ' BAS destination setup, optional Copilot assets).',
17
+ ' BAS destination setup, and default Copilot asset installation).',
18
18
  ' --link Installs with npm link instead of a tarball; edits to src/',
19
19
  ' and scripts/ take effect immediately without reinstalling.',
20
20
  ' --verify Verifies the global sap-ai-dev command resolves to this',
@@ -68,19 +68,29 @@ async function runInstallSetup() {
68
68
  }
69
69
 
70
70
  async function runCopilotAssetInstall() {
71
+ const copilotRoot = join(process.env.HOME || homedir(), '.copilot');
72
+ const installAssets = async () => {
73
+ const assets = await installUserCopilotAssets({ root });
74
+ const installed = assets.installed + assets.updated;
75
+ await announce(`Installed ${installed} Copilot files in ${assets.root}; ${assets.unchanged} already current.`, 'success');
76
+ if (assets.conflicts.length > 0) {
77
+ const paths = assets.conflicts.map(path => join(assets.root, path)).join(', ');
78
+ await announce(`Existing Copilot customizations were preserved; review these paths: ${paths}`, 'warning');
79
+ }
80
+ };
71
81
  await withInstallTerminal(async terminal => {
72
82
  if (!terminal) {
73
- await announce('Optional Copilot installation skipped because no interactive terminal is available.', 'warning');
83
+ await announce('Installing bundled agents and skills by default because no interactive terminal is available.', 'progress');
84
+ await installAssets();
74
85
  return;
75
86
  }
76
- const copilotRoot = join(process.env.HOME || homedir(), '.copilot');
77
87
  await announce([
78
- 'Optional Copilot setup is waiting for your choice.',
88
+ 'Bundled Copilot agents and skills are installed by default.',
79
89
  '',
80
- 'Type y then press Enter to install the bundled agents and all skills in the path shown below; press Enter alone to skip.',
90
+ 'Type n then press Enter to skip installing or updating the bundled files.',
81
91
  '',
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).`,
92
+ `${colorText('✅ Press Enter', 'green', true)} to install or update the bundled agents and all skills.`,
93
+ `${colorText('⏭️ Type n then Enter', 'yellow', true)} to skip this step.`,
84
94
  '',
85
95
  `${colorText('📁 Target folder:', 'cyan', true)}`,
86
96
  ` ${copilotRoot}`,
@@ -89,23 +99,15 @@ async function runCopilotAssetInstall() {
89
99
  const prompt = createInterface({ input: terminal.input, output: terminal.output });
90
100
  let answer;
91
101
  try {
92
- answer = await prompt.question(`${colorText('🤖 Install the bundled agents and all skills?', 'magenta', terminal.output)} ${colorText('[y/N]', 'yellow', terminal.output)} `);
102
+ answer = await prompt.question(`${colorText('🤖 Install the bundled agents and all skills?', 'magenta', terminal.output)} ${colorText('[Y/n]', 'yellow', terminal.output)} `);
93
103
  } finally {
94
104
  prompt.close();
95
105
  }
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())) {
106
+ if (/^n(?:o)?$/i.test(answer.trim())) {
99
107
  await announce('Copilot agent and skills were skipped. Your files were not changed.', 'info');
100
108
  return;
101
109
  }
102
- const assets = await installUserCopilotAssets({ root });
103
- const installed = assets.installed + assets.updated;
104
- await announce(`Installed ${installed} Copilot files in ${assets.root}; ${assets.unchanged} already current.`, 'success');
105
- if (assets.conflicts.length > 0) {
106
- const paths = assets.conflicts.map(path => join(assets.root, path)).join(', ');
107
- await announce(`Existing Copilot customizations were preserved; review these paths: ${paths}`, 'warning');
108
- }
110
+ await installAssets();
109
111
  });
110
112
  }
111
113
 
@@ -133,14 +133,7 @@ async function readRequestBody(req) {
133
133
  export function createBasDestinationRelay(destination, { env = process.env, fetchImpl = undiciFetch, log = () => {} } = {}) {
134
134
  if (!destination?.url) throw new Error('Destination relay requires a BAS destination URL');
135
135
  const base = new URL(destination.url);
136
- const credentials = destination.credentials;
137
- const tunnelMode = credentials?.mode === 'bas-tunnel';
138
- const directHost = tunnelMode ? '' : credentials?.host;
139
- // Internet overrides retain the direct-backend path. OnPremise overrides
140
- // must keep the BAS .dest route (and its Cloud Connector mapping), so they
141
- // use HTTP CONNECT through the BAS proxy instead of credentials.host.
142
- const directBase = directHost ? new URL(String(directHost)) : null;
143
- const cookieBase = directBase || (destination.backendUrl
136
+ const cookieBase = destination.backendUrl
144
137
  ? new URL(String(destination.backendUrl))
145
138
  : (() => {
146
139
  const target = new URL(base);
@@ -149,9 +142,8 @@ export function createBasDestinationRelay(destination, { env = process.env, fetc
149
142
  // omits the backend URL so Secure session cookies are not discarded.
150
143
  if (target.protocol === 'http:' && /\.dest$/i.test(target.hostname)) target.protocol = 'https:';
151
144
  return target;
152
- })());
153
- if (directBase) log(`[${destination.name}] BAS relay direct connect active (${directBase.origin}); BAS proxy cookie stripping bypassed`);
154
- if (tunnelMode) log(`[${destination.name}] BAS relay using a BAS proxy tunnel for the OnPremise destination`);
145
+ })();
146
+ // BAS destinations must keep their configured .dest route and authentication.
155
147
  // Proxy resolution: an explicitly configured proxy wins (an explicit empty
156
148
  // value means "go direct"). Without configuration, BAS virtual .dest hosts
157
149
  // imply the default BAS proxy; any other host (tests, direct URLs) is
@@ -159,16 +151,15 @@ export function createBasDestinationRelay(destination, { env = process.env, fetc
159
151
  // foreign proxy.
160
152
  const brandedProxy = brandedEnvValue(env, 'HTTP_PROXY');
161
153
  const explicitProxy = brandedProxy !== undefined ? brandedProxy : (env.HTTP_PROXY ?? env.http_proxy);
162
- const proxyUrl = directBase ? '' : (explicitProxy !== undefined ? String(explicitProxy) : (/\.dest$/i.test(base.hostname) ? DEFAULT_PROXY : ''));
163
- if (tunnelMode && !proxyUrl) throw new Error('OnPremise credential overrides require the BAS destination proxy to preserve the Cloud Connector route');
154
+ const proxyUrl = explicitProxy !== undefined ? String(explicitProxy) : (/\.dest$/i.test(base.hostname) ? DEFAULT_PROXY : '');
164
155
  const dispatcher = proxyUrl ? new ProxyAgent({ uri: proxyUrl, proxyTunnel: false }) : undefined;
165
156
  // Self-healing mode 2: if the BAS proxy refuses absolute-form proxied
166
157
  // requests (its own 502/504), retry through a CONNECT tunnel so the relay
167
158
  // keeps working through the same egress.
168
159
  const tunnelDispatcher = proxyUrl ? new ProxyAgent({ uri: proxyUrl, proxyTunnel: true }) : undefined;
169
- let tunneled = tunnelMode;
160
+ let tunneled = false;
170
161
  // Observable self-healing counters, surfaced by --doctor and tests.
171
- const stats = { requests: 0, csrfFetches: 0, csrfRetries: 0, csrfSessionFailures: 0, tunnelFallbacks: 0, direct: Boolean(directBase), proxyTunnel: tunnelMode };
162
+ const stats = { requests: 0, csrfFetches: 0, csrfRetries: 0, csrfSessionFailures: 0, tunnelFallbacks: 0, direct: false, proxyTunnel: false };
172
163
  const maxFailures = maxTokenFailures(env, log, destination.name);
173
164
  // The shared jar captures SAP cookies from safe reads as well as CSRF fetches.
174
165
  // Each cached token gets its own snapshot so later responses cannot silently
@@ -210,16 +201,16 @@ export function createBasDestinationRelay(destination, { env = process.env, fetc
210
201
  async function fetchTokenFresh(requestHeaders) {
211
202
  const key = CSRF_FETCH_PATH;
212
203
  stats.csrfFetches += 1;
213
- const target = resolveTarget(new URL(CSRF_FETCH_PATH, base));
204
+ const target = new URL(CSRF_FETCH_PATH, base);
214
205
  const cookieTarget = resolveCookieTarget(target);
215
206
  const jar = copyCookieJar(sharedCookieJar);
216
207
  await seedRequestCookies(jar, requestHeaders.cookie, cookieTarget);
217
208
  const outgoingCookie = await cookieHeader(jar, cookieTarget);
218
- const response = await sendWithFallback(target, 'GET', authHeaders(fetchHeaders(requestHeaders, {
209
+ const response = await sendWithFallback(target, 'GET', fetchHeaders(requestHeaders, {
219
210
  'x-csrf-token': 'Fetch',
220
211
  accept: requestHeaders.accept || 'application/xml,*/*',
221
212
  ...(outgoingCookie ? { cookie: outgoingCookie } : {})
222
- })));
213
+ }));
223
214
  await response.arrayBuffer().catch(() => undefined);
224
215
  const token = response.headers.get('x-csrf-token');
225
216
  await recordCookies(jar, response.headers, cookieTarget, log, destination.name);
@@ -265,36 +256,17 @@ export function createBasDestinationRelay(destination, { env = process.env, fetc
265
256
  };
266
257
  }
267
258
 
268
- // Rewrite an incoming .dest target onto the direct backend host when
269
- // credentials are configured, keeping path and query intact.
270
- function resolveTarget(target) {
271
- if (!directBase) return target;
272
- return new URL(`${target.pathname}${target.search}`, directBase);
273
- }
274
-
275
- function authHeaders(extra = {}) {
276
- if (!credentials?.user || !credentials?.password) return extra;
277
- const basic = Buffer.from(`${credentials.user}:${credentials.password}`).toString('base64');
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}` };
286
- }
287
259
 
288
260
  async function forward(method, requestUrl, requestHeaders, body, session) {
289
- const target = resolveTarget(requestUrl);
261
+ const target = requestUrl;
290
262
  const cookieTarget = resolveCookieTarget(target);
291
263
  const cookieJar = session?.jar || sharedCookieJar;
292
264
  if (!session) await seedRequestCookies(cookieJar, requestHeaders.cookie, cookieTarget);
293
265
  const sessionCookie = await cookieHeader(cookieJar, cookieTarget);
294
- const headers = authHeaders(headersFrom(requestHeaders, {
266
+ const headers = headersFrom(requestHeaders, {
295
267
  ...(session?.token ? { 'x-csrf-token': session.token } : {}),
296
268
  ...(sessionCookie ? { cookie: sessionCookie } : {})
297
- }));
269
+ });
298
270
  const response = await sendWithFallback(target, method, headers, body);
299
271
  const responseBody = Buffer.from(await response.arrayBuffer());
300
272
  if (session) await recordCookies(session.jar, response.headers, cookieTarget, log, destination.name);
@@ -332,7 +304,7 @@ export function createBasDestinationRelay(destination, { env = process.env, fetc
332
304
  return forwardWithRetry(method, requestUrl, requestHeaders, body, failures + 1);
333
305
  }
334
306
  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)`);
307
+ log(`[${destination.name}] BAS relay CSRF token rejected without a session cookie for ${requestUrl.pathname}; not retried (the configured destination route delivered no cookies)`);
336
308
  } else if (unsafe && rejected && failures >= maxFailures) {
337
309
  log(`[${destination.name}] BAS relay stopped after ${maxFailures} CSRF session retries for ${requestUrl.pathname}`);
338
310
  }
package/src/launcher.mjs CHANGED
@@ -10,7 +10,6 @@ import { installMcpConfig, repairManagedMcpConfig } from './mcp-config.mjs';
10
10
  import { runSetup } from './setup.mjs';
11
11
  import { resolveConfiguredCloudFoundryDestination } from './cf-destination.mjs';
12
12
  import { withBrandedEnvironment } from './branding.mjs';
13
- import { enrichWithStoredCredentials } from './credential-overrides.mjs';
14
13
  import { redactText } from './redact.mjs';
15
14
  const root = dirname(dirname(fileURLToPath(import.meta.url)));
16
15
  const pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'));
@@ -100,11 +99,6 @@ async function runDoctor(destinations) {
100
99
  } else {
101
100
  checks.push(doctorRow(destination.name, 'BAS destination relay', 'skipped', 'Not applicable for this destination.'));
102
101
  }
103
- if (relayedDestination?.credentials?.mode === 'bas-tunnel') {
104
- checks.push(doctorRow(destination.name, 'OnPremise credential override', 'passed', 'Basic auth is routed through a BAS proxy tunnel; Cloud Connector routing is retained'));
105
- } else if (relayedDestination?.credentials?.host) {
106
- checks.push(doctorRow(destination.name, 'Direct connect', 'passed', `active (${new URL(relayedDestination.credentials.host).origin}); ADT writes use stored credentials`));
107
- }
108
102
  const listed = await proxy.handle({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} });
109
103
  if (listed?.error) throw new Error(listed.error.message || 'MCP tools/list failed');
110
104
  const tools = listed.result?.tools || [];
@@ -137,7 +131,7 @@ async function runDoctor(destinations) {
137
131
  : `HTTP ${session?.httpStatus || 0}; token=${session?.tokenReceived ? 'received' : 'missing'}; cookies=${session?.cookieCount || 0}; usableSession=${sessionReady}; csrfFetches=${stats.csrfFetches || 0}; csrfRetries=${stats.csrfRetries || 0}; sessionFailures=${stats.csrfSessionFailures || 0}; tunnelFallbacks=${stats.tunnelFallbacks || 0}`;
138
132
  checks.push(doctorRow(destination.name, 'BAS relay CSRF preflight', status, detail));
139
133
  if (!sessionReady) {
140
- const remediation = 'GetSystemInfo was not called because the relay could not establish a paired CSRF token/session. For OnPremise BasicAuth, run sap-ai-dev --setup and use a validated BAS proxy tunnel credential override.';
134
+ const remediation = 'GetSystemInfo was not called because the relay could not establish a paired CSRF token/session. Confirm the configured destination authentication and BAS proxy route.';
141
135
  checks.push(doctorRow(destination.name, 'SAP system check', 'skipped', preflightError ? `${remediation} Relay error: ${preflightError}` : remediation));
142
136
  }
143
137
  }
@@ -222,8 +216,8 @@ async function main() {
222
216
  let report;
223
217
  try {
224
218
  const destinations = runtimeEnv.SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE === 'cloud-foundry'
225
- ? await enrichWithStoredCredentials([await resolveConfiguredCloudFoundryDestination({ env: runtimeEnv })], runtimeEnv)
226
- : await enrichWithStoredCredentials(await discoverForCommand(), runtimeEnv);
219
+ ? [await resolveConfiguredCloudFoundryDestination({ env: runtimeEnv })]
220
+ : await discoverForCommand();
227
221
  report = await runDoctor(destinations);
228
222
  } catch (error) {
229
223
  report = { ok: false, destinations: 0, checks: [doctorRow('-', 'destination discovery', 'failed', redactText(error.message || error).slice(0, 300))] };
@@ -259,7 +253,7 @@ async function main() {
259
253
  }
260
254
 
261
255
  if (runtimeEnv.SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE === 'cloud-foundry') {
262
- const destination = (await enrichWithStoredCredentials([await resolveConfiguredCloudFoundryDestination({ env: runtimeEnv })], runtimeEnv))[0];
256
+ const destination = await resolveConfiguredCloudFoundryDestination({ env: runtimeEnv });
263
257
  let proxy;
264
258
  try {
265
259
  const binary = await binaryOrError();
@@ -289,7 +283,7 @@ async function main() {
289
283
 
290
284
  const discovered = await discoverDestinations({ env: runtimeEnv });
291
285
  for (const destination of discovered) logLine(probeDiagnostic(destination));
292
- const destinations = await enrichWithStoredCredentials(discovered, runtimeEnv);
286
+ const destinations = discovered;
293
287
  if (!destinations.length) {
294
288
  logLine('[sap-ai-dev] destination discovery returned no named BAS destinations');
295
289
  throw new Error(remediation);
@@ -297,10 +291,6 @@ async function main() {
297
291
  const binary = await binaryOrError();
298
292
  logLine(`[sap-ai-dev] sap-ai-dev-toolkit v${pkg.version} (node ${process.version}, pid ${process.pid})`);
299
293
  logLine(`[sap-ai-dev] VSP binary: ${binary}`);
300
- for (const destination of destinations) {
301
- if (destination.credentials?.mode === 'bas-tunnel') logLine(`[sap-ai-dev] ${destination.name}: Basic credentials enabled through the BAS proxy tunnel`);
302
- else if (destination.credentials?.host) logLine(`[sap-ai-dev] ${destination.name}: direct connect enabled through stored credentials`);
303
- }
304
294
  logLine(`[sap-ai-dev] starting MCP proxy for ${destinations.map(destination => `${destination.name} (client=${destination.client})`).join(', ')}`);
305
295
  const proxy = new MCPProxy({ binary, destinations, env: process.env, log: logLine, version: pkg.version });
306
296
  const shutdown = signal => { void proxy.close().finally(() => process.exit(signal === 'SIGINT' ? 130 : 143)); };
package/src/mcp-proxy.mjs CHANGED
@@ -38,18 +38,6 @@ const APPLICATION_LOG_SCHEMA = {
38
38
  };
39
39
  const APPLICATION_LOG_DESCRIPTION = 'Read SAP application log (SLG1) entries. Results are newest first and limited to 100 by default; set messages=true to include log message details.';
40
40
 
41
- // Read-only mode (SAP_AI_DEV_TOOLKIT_READ_ONLY=true) removes every tool that
42
- // changes SAP state from the public surface and starts VSP with
43
- // --transport-read-only, so an exploration destination cannot be written to
44
- // through this proxy even if a chat prompt asks for it.
45
- const READ_ONLY_HIDDEN_VSP_TOOLS = new Set([
46
- 'Activate',
47
- 'ActivateMultiple',
48
- 'CreateTransport',
49
- 'EditSource',
50
- 'SetBreakpoint',
51
- 'WriteSource'
52
- ]);
53
41
 
54
42
  // Self-healing restarts retry the interrupted tools/call once. Retrying a
55
43
  // state-changing tool after a crash can apply the same write twice (the child
@@ -60,6 +48,8 @@ const NON_RETRIABLE_VSP_TOOLS = new Set([
60
48
  'ActivateMultiple',
61
49
  'CreateTransport',
62
50
  'EditSource',
51
+ 'LockObject',
52
+ 'UnlockObject',
63
53
  'WriteSource'
64
54
  ]);
65
55
 
@@ -78,10 +68,6 @@ function requestTimeoutMs(env) {
78
68
  return parsed > 0 ? Math.floor(parsed) : 0;
79
69
  }
80
70
 
81
- export function readOnlyMode(env = process.env) {
82
- const raw = brandedEnvValue(env, 'READ_ONLY');
83
- return raw !== undefined && ['1', 'true', 'yes'].includes(String(raw).trim().toLowerCase());
84
- }
85
71
 
86
72
  // Keep the default public VSP surface small enough for developer-lifecycle use.
87
73
  // Hidden upstream tools can still be used by local workflow tools when needed.
@@ -129,6 +115,7 @@ export const PUBLIC_VSP_TOOLS = new Set([
129
115
  'GrepPackages',
130
116
  'ListDependencies',
131
117
  'ListTransports',
118
+ 'LockObject',
132
119
  'PrettyPrint',
133
120
  'RunATCCheck',
134
121
  'RunQuery',
@@ -136,15 +123,10 @@ export const PUBLIC_VSP_TOOLS = new Set([
136
123
  'SearchObject',
137
124
  'SetBreakpoint',
138
125
  'SyntaxCheck',
126
+ 'UnlockObject',
139
127
  'WriteSource'
140
128
  ]);
141
129
 
142
- function exposeVspTool(tool, readOnly = false) {
143
- if (!PUBLIC_VSP_TOOLS.has(tool?.name)) return false;
144
- // Read-only destinations never expose the write/activate/transport-create
145
- // surface, even when VSP registers it.
146
- return !(readOnly && READ_ONLY_HIDDEN_VSP_TOOLS.has(tool.name));
147
- }
148
130
 
149
131
  // Public tool names are lowercase snake_case. BAS/VS Code chat tool references
150
132
  // are lowercase-only, so mixed-case names never bind in the tools picker even
@@ -210,13 +192,12 @@ function childEnvironment(destination, env) {
210
192
 
211
193
 
212
194
  export function childArguments(destination, env = process.env) {
213
- // The proxy allowlist keeps transport mutations to CreateTransport.
214
- // Transportable source edits are controlled by SAP_ALLOW_TRANSPORTABLE_EDITS.
195
+ // The proxy allowlist controls transport operations; source edits are
196
+ // controlled by SAP_ALLOW_TRANSPORTABLE_EDITS and SAP authorizations.
215
197
  const mode = brandedEnvValue(env, 'MODE') || 'expert';
216
198
  const args = ['--url', destination.url, '--client', destination.client || '001', '--mode', mode];
217
199
  if (destination.source !== 'cloud-foundry' || destination.authentication === 'PrincipalPropagation') args.push('--proxy-auth');
218
- if (readOnlyMode(env)) args.push('--transport-read-only');
219
- else args.push('--enable-transports');
200
+ args.push('--enable-transports');
220
201
  return args;
221
202
  }
222
203
 
@@ -394,7 +375,6 @@ export class MCPProxy {
394
375
  this.log = log;
395
376
  this.output = output;
396
377
  this.version = version;
397
- this.readOnly = readOnlyMode(env);
398
378
  this.requestTimeoutMs = requestTimeoutMs(env);
399
379
  this.children = [];
400
380
  this.started = false;
@@ -663,17 +643,11 @@ export class MCPProxy {
663
643
  });
664
644
  }
665
645
 
666
- if (!exposeVspTool(tool, this.readOnly)) continue;
646
+ if (!PUBLIC_VSP_TOOLS.has(tool?.name)) continue;
667
647
  const name = publicName(tool.name);
668
648
  publish(name, { entry, upstream: tool.name }, { ...tool, name, description: `${tool.description || tool.name} [destination: ${entry.destination.name}]` });
669
649
  }
670
- // Local workflow tools gate on the upstream surface they can use; in
671
- // read-only mode the hidden write tools must not enable change-set
672
- // staging either.
673
- const effectiveUpstream = this.readOnly
674
- ? upstreamTools.filter(tool => !READ_ONLY_HIDDEN_VSP_TOOLS.has(tool.name))
675
- : upstreamTools;
676
- for (const localTool of createEngineeringTools(entry, effectiveUpstream, { env: this.env, log: this.log })) {
650
+ for (const localTool of createEngineeringTools(entry, upstreamTools, { env: this.env, log: this.log })) {
677
651
  const name = publicName(localTool.definition.name);
678
652
  publish(name, { handler: localTool.handler }, {
679
653
  ...localTool.definition,
package/src/setup.mjs CHANGED
@@ -1,12 +1,9 @@
1
1
  import { createInterface } from 'node:readline/promises';
2
2
  import { stdin, stdout } from 'node:process';
3
- import { checkboxPrompt, colorText, formatStatus, textPrompt } from './terminal-ui.mjs';
3
+ import { checkboxPrompt, colorText, formatStatus } from './terminal-ui.mjs';
4
4
  import { discoverDestinations, remediation } from './bas-discovery.mjs';
5
5
  import { discoverCloudFoundryDestinations, getCloudFoundryTarget, deleteManagedCloudFoundryServiceKeys, findOrphanedCloudFoundryServiceKeys } from './cf-destination.mjs';
6
6
  import { SAP_DEVELOPMENT_MCP_SERVERS, collectCloudFoundryKeyReferencesFromAllEntries, collectManagedCloudFoundryKeyReferences, installMcpConfig, readMcpConfig, resolveMcpConfigPath } from './mcp-config.mjs';
7
- import { readCredentials, removeDestinationCredentials, resolveCredentialsPath, storeDestinationCredentials } from './credentials-store.mjs';
8
- import { canPromptForCredentials, credentialKeyForDestination, credentialModeForDestination } from './credential-overrides.mjs';
9
- import { createBasDestinationRelay } from './bas-destination-relay.mjs';
10
7
 
11
8
  const SETUP_COMMAND = 'sap-ai-dev --setup';
12
9
 
@@ -26,89 +23,6 @@ async function confirmCloudFoundryImport({ input = stdin, output = stdout } = {}
26
23
  }
27
24
  }
28
25
 
29
- async function probeBasOnPremCredentialRoute({ destination, user, password, env }) {
30
- const relay = createBasDestinationRelay({
31
- ...destination,
32
- credentials: { user, password, mode: 'bas-tunnel' }
33
- }, { env, log: () => {} });
34
- try {
35
- await relay.ready;
36
- return await relay.probeCsrfSession();
37
- } finally {
38
- await relay.close();
39
- }
40
- }
41
-
42
- async function promptForDestinationCredentials({ destinations, credentialsPath, existing = {}, input = stdin, output = stdout, env = process.env, verifyOnPremCredentialRoute = probeBasOnPremCredentialRoute } = {}) {
43
- const eligible = destinations.filter(canPromptForCredentials);
44
- const retained = new Set();
45
- if (!eligible.length) return retained;
46
-
47
- print(output, '');
48
- print(output, formatStatus('Credential overrides are optional and apply only to selected BasicAuthentication destinations.', 'step', output, 'Credentials'));
49
- print(output, ` Overrides are stored beside your MCP config in ${credentialsPath} with owner-only permissions (never inside mcp.json).`);
50
- print(output, ' For OnPremise destinations, BAS/Cloud Foundry proxy credentials remain separate from the SAP backend credentials.');
51
- print(output, '');
52
-
53
- for (const destination of eligible) {
54
- const key = credentialKeyForDestination(destination);
55
- if (!key) continue;
56
- const mode = credentialModeForDestination(destination);
57
- const known = existing[key];
58
- const routeLabel = `${destination.source === 'cloud-foundry' ? 'Cloud Foundry' : 'BAS'} ${destination.proxyType} — ${destination.name}`;
59
- const answer = await textPrompt({
60
- message: colorText(`🔐 ${routeLabel} — override SAP credentials? (y/n)`, 'cyan', output),
61
- required: true,
62
- validate: value => /^(?:y|yes|n|no)$/i.test(value.trim()) || 'Enter y or n.'
63
- }, { input, output });
64
- if (/^n/i.test(answer.trim())) {
65
- await removeDestinationCredentials(credentialsPath, [key]);
66
- print(output, formatStatus(`Credential override disabled for ${destination.name}.`, 'info', output, 'Credentials'));
67
- continue;
68
- }
69
-
70
- if (known?.user && known?.password) {
71
- print(output, formatStatus(`Credentials for ${destination.name} are already stored. Press Enter at both prompts to keep them.`, 'info', output, 'Credentials'));
72
- }
73
- const userAnswer = await textPrompt({
74
- message: colorText(`👤 ${routeLabel} — SAP user`, 'cyan', output),
75
- required: false,
76
- placeholder: known ? 'Enter keeps the stored user' : 'SAP user name'
77
- }, { input, output });
78
- const user = userAnswer.trim() || known?.user || '';
79
- const passwordAnswer = await textPrompt({
80
- message: colorText(`🔒 ${routeLabel} — SAP password${known ? ' (Enter keeps stored password)' : ''}`, 'cyan', output),
81
- secret: true,
82
- required: false
83
- }, { input, output });
84
- const password = passwordAnswer || known?.password || '';
85
- if (!user || !password) {
86
- await removeDestinationCredentials(credentialsPath, [key]);
87
- print(output, formatStatus(`No complete credentials for ${destination.name}; no override was stored.`, 'warning', output, 'Credentials'));
88
- continue;
89
- }
90
-
91
- if (mode === 'bas-tunnel') {
92
- let route;
93
- try {
94
- route = await verifyOnPremCredentialRoute({ destination, user, password, env });
95
- } catch {
96
- route = null;
97
- }
98
- if (!(route?.httpStatus >= 200 && route.httpStatus < 300) || !route.tokenReceived || !(route.cookieCount > 0)) {
99
- await removeDestinationCredentials(credentialsPath, [key]);
100
- print(output, formatStatus(`No override stored for ${destination.name}: the BAS/Cloud Connector route did not return both a CSRF token and session cookie.`, 'warning', output, 'Credentials'));
101
- continue;
102
- }
103
- }
104
-
105
- const host = mode === 'direct' ? (destination.backendUrl || known?.host || '') : '';
106
- await storeDestinationCredentials(credentialsPath, key, { host, user, password, mode });
107
- retained.add(key);
108
- print(output, formatStatus(`Stored credential override for ${destination.name}.`, 'success', output, 'Credentials'));
109
- }
110
- return retained;
111
- }
112
26
 
113
27
  function safeProbe(probe) {
114
28
  const result = {};
@@ -187,7 +101,6 @@ export async function runSetup({
187
101
  discoverCf = discoverCloudFoundryDestinations,
188
102
  confirmCfImport = confirmCloudFoundryImport,
189
103
  install = installMcpConfig,
190
- verifyOnPremCredentialRoute = probeBasOnPremCredentialRoute,
191
104
  includeSapDevelopmentToolsPrompt = false
192
105
  } = {}) {
193
106
  if (!env.H2O_URL) {
@@ -209,7 +122,6 @@ export async function runSetup({
209
122
  }
210
123
  const destinations = basDestinations.map(safeBasDestination);
211
124
  const warnings = [];
212
- const credentialWarnings = [];
213
125
  let createdKeys = [];
214
126
  let configPath;
215
127
  let managedKeys = [];
@@ -352,25 +264,6 @@ export async function runSetup({
352
264
  }, { input, output });
353
265
  }
354
266
 
355
- // Ask for an explicit credential override choice for each selected supported destination.
356
- const credentialsPath = await resolveCredentialsPath(env, configPath);
357
- try {
358
- const existingMap = (await readCredentials(credentialsPath).catch(() => ({ destinations: {} }))).destinations || {};
359
- const retained = await promptForDestinationCredentials({
360
- destinations: selected,
361
- credentialsPath,
362
- existing: existingMap,
363
- input,
364
- output,
365
- env,
366
- verifyOnPremCredentialRoute
367
- });
368
- const stale = Object.keys(existingMap).filter(name => !retained.has(name));
369
- if (stale.length) await removeDestinationCredentials(credentialsPath, stale);
370
- } catch (error) {
371
- if (/Prompt interrupted/.test(error.message)) throw error;
372
- credentialWarnings.push(`Credential overrides could not be stored: ${error.message}`);
373
- }
374
267
  try {
375
268
  const result = await install(selected, { env, sapDevelopmentServers });
376
269
  const location = result?.path ? ` in ${result.path}` : '';
@@ -391,9 +284,8 @@ export async function runSetup({
391
284
  }
392
285
  }
393
286
  await reportWarnings(warnings);
394
- await reportWarnings(credentialWarnings);
395
287
  await reportWarnings(cleanupWarnings);
396
- return { selected, ...(result || {}), warnings: [...warnings, ...credentialWarnings, ...cleanupWarnings] };
288
+ return { selected, ...(result || {}), warnings: [...warnings, ...cleanupWarnings] };
397
289
  } catch (error) {
398
290
  const cleanupWarnings = [];
399
291
  if (createdKeys.length) {
@@ -139,7 +139,9 @@ export async function checkboxPrompt({ message, choices, required = false, short
139
139
  for (let index = top; index < end; index += 1) {
140
140
  const choice = choices[index];
141
141
  const pointer = index === cursor ? '❯' : ' ';
142
- const marker = selected.has(index) ? '◉' : '◯';
142
+ const marker = selected.has(index)
143
+ ? colorText('✓', 'green', output)
144
+ : colorText('✗', 'red', output);
143
145
  const disabled = choice.disabled ? ` — ${choice.disabled}` : '';
144
146
  const label = `${pointer}${marker} ${choice.name}${disabled}`;
145
147
  lines.push(truncateToColumns(label, columns));
package/tools.md CHANGED
@@ -77,9 +77,6 @@ The proxy adds local multi-step tools to each destination when their required VS
77
77
 
78
78
  `sap-ai-dev --doctor` checks destination probing, VSP startup, the `get_system_info` probe, and MCP tool listing. `sap-ai-dev --demo` runs the sample tools and OData fixtures without contacting SAP; demo writes and transport creation remain in memory until that process exits.
79
79
 
80
- ### Read-only mode
81
-
82
- Set `SAP_AI_DEV_TOOLKIT_READ_ONLY=true` (or legacy `BAS_VSP_READ_ONLY`) on a destination server to run it strictly read-only: `WriteSource`, `EditSource`, `Activate`, `ActivateMultiple`, `CreateTransport`, `SetBreakpoint`, and the change-set workflow tools are removed from the surface, and the VSP child starts with `--transport-read-only` so transport writes are rejected upstream as well. Inspection, queries, linting, and checks remain available.
83
80
 
84
81
  ### Request timeouts
85
82
 
@@ -1,75 +0,0 @@
1
- import { readCredentials, resolveCredentialsPath } from './credentials-store.mjs';
2
-
3
- const CLOUD_FOUNDRY_KEY_PREFIX = 'cloud-foundry:';
4
-
5
- function isBasicAuthentication(value) {
6
- return /^basic(?:authentication)?$/i.test(String(value || '').replace(/\s+/g, ''));
7
- }
8
-
9
- export function credentialKeyForDestination(destination = {}) {
10
- if (destination.source === 'cloud-foundry') {
11
- const serverName = String(destination.serverName || '').trim();
12
- return serverName ? `${CLOUD_FOUNDRY_KEY_PREFIX}${serverName}` : '';
13
- }
14
- return String(destination.name || '').trim();
15
- }
16
-
17
- export function credentialModeForDestination(destination = {}) {
18
- if (!isBasicAuthentication(destination.authentication)) return null;
19
- const proxyType = String(destination.proxyType || '').trim().toLowerCase();
20
- if (destination.source === 'cloud-foundry') {
21
- return proxyType === 'onpremise' ? 'cf-connectivity' : null;
22
- }
23
- if (proxyType === 'internet') return 'direct';
24
- if (proxyType === 'onpremise') return 'bas-tunnel';
25
- return null;
26
- }
27
-
28
- export function canPromptForCredentials(destination = {}) {
29
- const mode = credentialModeForDestination(destination);
30
- return Boolean(mode && (mode !== 'direct' || destination.backendUrl));
31
- }
32
-
33
- export async function enrichWithStoredCredentials(destinations, env = process.env, log = message => console.error(message)) {
34
- const needingCredentials = destinations.filter(destination => {
35
- const mode = credentialModeForDestination(destination);
36
- if (!mode || !canPromptForCredentials(destination)) return false;
37
- if (mode === 'cf-connectivity') return Boolean(destination.serverName);
38
- return Boolean(destination.url && String(destination.url).includes('.dest'));
39
- });
40
- if (!needingCredentials.length) return destinations;
41
- let stored;
42
- try {
43
- const path = await resolveCredentialsPath(env);
44
- stored = await readCredentials(path);
45
- } catch (error) {
46
- log(`[sap-ai-dev] stored credentials could not be read: ${String(error.message).slice(0, 200)}`);
47
- return destinations;
48
- }
49
- return destinations.map(destination => {
50
- const mode = credentialModeForDestination(destination);
51
- const key = credentialKeyForDestination(destination);
52
- const entry = key ? stored.destinations?.[key] : undefined;
53
- if (!mode || !entry?.user || !entry?.password) return destination;
54
- if (mode === 'cf-connectivity') {
55
- if (entry.mode !== mode || destination.source !== 'cloud-foundry') return destination;
56
- return {
57
- ...destination,
58
- childEnv: {
59
- ...(destination.childEnv || {}),
60
- SAP_USER: entry.user,
61
- SAP_PASSWORD: entry.password,
62
- SAP_VERBOSE: 'false'
63
- }
64
- };
65
- }
66
- if (mode === 'bas-tunnel') {
67
- if (entry.mode !== mode) return destination;
68
- return { ...destination, credentials: { user: entry.user, password: entry.password, mode } };
69
- }
70
- if (!destination.backendUrl || !destination.url || !String(destination.url).includes('.dest')) return destination;
71
- if (entry.mode && entry.mode !== 'direct') return destination;
72
- const host = entry.host || destination.backendUrl;
73
- return { ...destination, credentials: { host, user: entry.user, password: entry.password, mode: 'direct' } };
74
- });
75
- }
@@ -1,113 +0,0 @@
1
- import { randomUUID } from 'node:crypto';
2
- import { chmod, mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
3
- import { homedir } from 'node:os';
4
- import { basename, dirname, join } from 'node:path';
5
- import { brandedEnvValue } from './branding.mjs';
6
-
7
- // Optional SAP backend credential overrides live next to the MCP config
8
- // (never inside it), with 0600 permissions and no tokens or cookies. Entries
9
- // are keyed by a stable destination identity and may include a route mode.
10
- const CREDENTIALS_FILENAME = 'sap-ai-dev-toolkit-credentials.json';
11
- const CREDENTIAL_MODES = new Set(['direct', 'bas-tunnel', 'cf-connectivity']);
12
-
13
- async function exists(path) {
14
- try {
15
- await stat(path);
16
- return true;
17
- } catch (error) {
18
- if (error?.code === 'ENOENT') return false;
19
- throw error;
20
- }
21
- }
22
-
23
- export async function resolveCredentialsPath(env = process.env, mcpConfigPath) {
24
- const configured = brandedEnvValue(env, 'CREDENTIALS_FILE');
25
- if (configured) return configured;
26
- if (mcpConfigPath) return join(dirname(mcpConfigPath), CREDENTIALS_FILENAME);
27
- const home = env.HOME || homedir();
28
- const candidates = [
29
- join(home, '.vscode', 'data', 'User'),
30
- join(home, '.vscode-server', 'data', 'User'),
31
- join(home, '.code-server', 'data', 'User')
32
- ];
33
- for (const candidate of candidates) if (await exists(candidate)) return join(candidate, CREDENTIALS_FILENAME);
34
- return join(candidates[0], CREDENTIALS_FILENAME);
35
- }
36
-
37
- function sanitizeEntry(entry) {
38
- const result = {};
39
- if (typeof entry?.host === 'string' && entry.host) result.host = entry.host;
40
- if (typeof entry?.user === 'string' && entry.user) result.user = entry.user;
41
- if (typeof entry?.password === 'string' && entry.password) result.password = entry.password;
42
- if (CREDENTIAL_MODES.has(entry?.mode)) result.mode = entry.mode;
43
- if (typeof entry?.updatedAt === 'string') result.updatedAt = entry.updatedAt;
44
- return result;
45
- }
46
-
47
- export async function readCredentials(path) {
48
- let raw;
49
- try {
50
- raw = await readFile(path, 'utf8');
51
- } catch (error) {
52
- if (error?.code === 'ENOENT') return {};
53
- throw new Error(`Credentials file ${path} could not be read: ${error.message}`);
54
- }
55
- let parsed;
56
- try {
57
- parsed = JSON.parse(raw);
58
- } catch (error) {
59
- throw new Error(`Credentials file ${path} contains invalid JSON: ${error.message}`);
60
- }
61
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
62
- throw new Error(`Credentials file ${path} must contain a top-level JSON object`);
63
- }
64
- const destinations = {};
65
- for (const [name, entry] of Object.entries(parsed.destinations || {})) {
66
- const clean = sanitizeEntry(entry);
67
- if (clean.user && clean.password) destinations[name] = clean;
68
- }
69
- return { destinations };
70
- }
71
-
72
- async function writeCredentials(path, destinations) {
73
- const directory = dirname(path);
74
- await mkdir(directory, { recursive: true });
75
- const temporary = join(directory, `.${basename(path)}.${process.pid}.${randomUUID()}.tmp`);
76
- try {
77
- await writeFile(temporary, `${JSON.stringify({ version: 1, destinations }, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
78
- await rename(temporary, path);
79
- await chmod(path, 0o600).catch(() => {});
80
- } catch (error) {
81
- await rm(temporary, { force: true }).catch(() => {});
82
- throw new Error(`Credentials file ${path} could not be written: ${error.message}`);
83
- }
84
- }
85
-
86
- export async function storeDestinationCredentials(path, name, { host, user, password, mode }) {
87
- if (mode != null && !CREDENTIAL_MODES.has(mode)) throw new Error('Credential override route mode is unsupported');
88
- const current = await readCredentials(path).catch(error => { throw error; });
89
- const destinations = { ...current.destinations };
90
- destinations[String(name)] = {
91
- host: String(host || ''),
92
- user: String(user || ''),
93
- password: String(password || ''),
94
- ...(mode ? { mode } : {}),
95
- updatedAt: new Date().toISOString()
96
- };
97
- await writeCredentials(path, destinations);
98
- return { path, name };
99
- }
100
-
101
- export async function removeDestinationCredentials(path, names) {
102
- const current = await readCredentials(path);
103
- const destinations = { ...current.destinations };
104
- const removed = [];
105
- for (const name of names) {
106
- if (Object.hasOwn(destinations, String(name))) {
107
- delete destinations[String(name)];
108
- removed.push(String(name));
109
- }
110
- }
111
- if (removed.length) await writeCredentials(path, destinations);
112
- return { path, removed };
113
- }