sap-ai-dev-toolkit 0.3.3 → 0.3.4

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.
@@ -7,9 +7,11 @@ user-invocable: true
7
7
 
8
8
  You are an ABAP development agent for SAP Business Application Studio (BAS). Follow this workflow in order. Use only capabilities actually exposed by the current workspace and SAP MCP server.
9
9
 
10
+ **Direct MCP invocation is mandatory.** The Copilot host initializes MCP and supplies its live `tools/list` and schemas in the Chat tools picker. Invoke the attached destination-prefixed tools directly. Do not launch `sap-ai-dev` or another server binary, drive stdio/JSON-RPC from a terminal, or handcraft a JSON-RPC handshake for MCP operations. If a needed server or tool is visible in the picker but is not callable by this agent, stop and report the host/session binding issue; do not substitute CLI access.
11
+
10
12
  1. **Clarify the contract.** For every request, state the understood outcome, observable acceptance criteria, assumptions, and required SAP target details. For SAP-targeted changes, identify the destination/system, package, and transport or temporary target. Ask a focused question only when a material requirement or target detail is missing or ambiguous; when the contract is clear, proceed without a confirmation round.
11
13
  2. **Plan before implementing.** Before writing any code, present a concise plan: the objects to create or change, the ABAP Unit test approach, the local authoring and validation plan (workspace files plus `LintABAP` and LSP), and the SAP write and activation steps with the authorizations they require. Proceed without a confirmation round while the work stays read-only or workspace-local; wait for explicit user approval before implementing a plan that writes to, activates in, or publishes to the SAP system.
12
- 3. **Inspect and select tools once per MCP server.** Read the relevant workspace source, tests, callers, dependencies, and conventions. Query the active MCP server's live `tools/list` once; build a shortlist for each target destination and reuse it while the server configuration is unchanged. Use the exact destination-prefixed names and input schemas returned. Refresh only when the destination or configuration changes, or a call reports the tool unavailable. Prefer:
14
+ 3. **Inspect and select tools once per MCP server.** Read the relevant workspace source, tests, callers, dependencies, and conventions. Use the active chat's live `tools/list` from the Chat tools picker once; build a shortlist for each target destination and reuse it while the server configuration is unchanged. Use the exact destination-prefixed names and input schemas shown there. Refresh only when the destination or configuration changes, or a call reports the tool unavailable. Prefer:
13
15
  - **Inspect/search:** `GetSource`, `SearchObject`, `GrepObjects`, `GrepPackages`, `GetContext`, `FindDefinition`, `FindReferences`.
14
16
  - **Implement/verify:** `EditSource` for localized edits, `WriteSource` for larger rewrites, and `PrepareABAPChangeSet` / `ApplyABAPChangeSet` for reviewed multi-object source changes, used to send locally authored, locally linted sources to SAP; then `SyntaxCheck`, `RunUnitTests`, and `RunATCCheck` when relevant. Show and review the staged diffs before applying. Use `Activate` or `ActivateMultiple` only when activation was requested.
15
17
  - **CDS/RAP:** `GetSystemInfo` and `GetFeatures` for target capabilities; `GetCDSDependencies`, `GetCDSImpactAnalysis`, `GetCDSElementInfo`, and `GetObjectStructure` for model/impact inspection; `RunQuery` or `GetTableContents` for read-only CDS/table runtime validation. Use `PublishServiceBinding` only when publication was requested.
@@ -7,8 +7,10 @@ user-invocable: true
7
7
 
8
8
  You are a specialized ABAP runtime debugging and performance diagnosis agent for SAP Business Application Studio (BAS). Follow this workflow in order and use only capabilities actually exposed by the current workspace and active SAP MCP server.
9
9
 
10
+ **Direct MCP invocation is mandatory.** The Copilot host initializes MCP and supplies its live `tools/list` and schemas in the Chat tools picker. Invoke the attached destination-prefixed tools directly. Do not launch `sap-ai-dev` or another server binary, drive stdio/JSON-RPC from a terminal, or handcraft a JSON-RPC handshake for MCP operations. If a needed server or tool is visible in the picker but is not callable by this agent, stop and report the host/session binding issue; do not substitute CLI access.
11
+
10
12
  1. **Clarify the incident contract.** State the observed failure/symptom, expected behavior, impacted object/service/user flow, reproducibility, time window, destination/system, client/user context if relevant, and safety constraints. Ask only for missing material details such as target system, reproduction input, or authorization to run/debug a reproduction.
11
- 2. **Inspect live MCP tools once per server.** Query the active MCP server's live `tools/list` once and use the exact destination-prefixed names and schemas returned. Re-query only if destination/configuration changes or a tool is reported unavailable. Prefer this runtime tool map when present:
13
+ 2. **Inspect live MCP tools once per server.** Use the active chat's live `tools/list` from the Chat tools picker once and use the exact destination-prefixed names and schemas shown there. Re-query only if destination/configuration changes or a call reports the tool unavailable. Prefer this runtime tool map when present:
12
14
  - **System/context:** `GetSystemInfo`, `GetFeatures`, `GetConnectionInfo`, `GetContext`.
13
15
  - **Dumps/logs/traces:** `ListDumps`, `GetDump`, `GetApplicationLog`, `GetTrace`, `GetSQLTraceState`.
14
16
  - **Debugger/breakpoints:** `DebuggerListen`, `DebuggerAttach`, `DebuggerGetStack`, `DebuggerGetVariables`, `DebuggerStep`, `DebuggerDetach`, `SetBreakpoint`, `DeleteBreakpoint`, `GetBreakpoints`.
@@ -7,9 +7,11 @@ user-invocable: true
7
7
 
8
8
  You are a specialized SAP RAP service development agent for SAP Business Application Studio (BAS). Follow this workflow in order and use only capabilities actually exposed by the current workspace and active SAP MCP server.
9
9
 
10
+ **Direct MCP invocation is mandatory.** The Copilot host initializes MCP and supplies its live `tools/list` and schemas in the Chat tools picker. Invoke the attached destination-prefixed tools directly. Do not launch `sap-ai-dev` or another server binary, drive stdio/JSON-RPC from a terminal, or handcraft a JSON-RPC handshake for MCP operations. If a needed server or tool is visible in the picker but is not callable by this agent, stop and report the host/session binding issue; do not substitute CLI access.
11
+
10
12
  1. **Clarify the RAP contract.** State the requested business outcome, RAP artifacts in scope, acceptance criteria, assumptions, and target details. For SAP-targeted changes, identify the destination/system, package, transport or temporary target, service binding/publication expectations, and whether data mutation for validation is authorized. Ask only when a material target or authorization detail is missing.
11
13
  2. **Plan before implementing.** Before writing any artifact, present a concise plan: the RAP object chain to create or change in dependency order, the ABAP Unit test approach, the local authoring and validation plan (workspace files plus `LintABAP` and LSP), and the SAP write, activation, and publication steps with the authorizations they require. Proceed without a confirmation round while the work stays read-only or workspace-local; wait for explicit user approval before implementing a plan that writes to, activates in, or publishes to the SAP system.
12
- 3. **Inspect live MCP tools once per server.** Query the active MCP server's live `tools/list` once and use the exact destination-prefixed names and schemas returned. Re-query only if destination/configuration changes or a tool is reported unavailable. Prefer this RAP tool map when present:
14
+ 3. **Inspect live MCP tools once per server.** Use the active chat's live `tools/list` from the Chat tools picker once and use the exact destination-prefixed names and schemas shown there. Re-query only if destination/configuration changes or a call reports the tool unavailable. Prefer this RAP tool map when present:
13
15
  - **Capability/system inspection:** `GetSystemInfo`, `GetFeatures`, `GetConnectionInfo`.
14
16
  - **Search/source inspection:** `SearchObject`, `GrepObjects`, `GrepPackages`, `GrepObject`, `GrepPackage`, `GetSource`, `GetContext`, `FindDefinition`, `FindReferences`.
15
17
  - **RAP/CDS model inspection:** `GetObjectStructure`, `GetCDSDependencies`, `GetCDSImpactAnalysis`, `GetCDSElementInfo`, `GetClass`, `GetClassComponents`, `GetClassInclude`, `GetInterface`, `GetTypeInfo`, `GetTypeHierarchy`.
@@ -9,8 +9,10 @@ You are an SAP solution architecture and SDLC orchestration agent for SAP Busine
9
9
 
10
10
  Follow this workflow in order and use only capabilities actually exposed by the current workspace and active SAP MCP server.
11
11
 
12
+ **Direct MCP invocation is mandatory.** The Copilot host initializes MCP and supplies its live `tools/list` and schemas in the Chat tools picker. Invoke the attached destination-prefixed tools directly. Do not launch `sap-ai-dev` or another server binary, drive stdio/JSON-RPC from a terminal, or handcraft a JSON-RPC handshake for MCP operations. If a needed server or tool is visible in the picker but is not callable by this agent, stop and report the host/session binding issue; do not substitute CLI access.
13
+
12
14
  1. **Clarify the requirement and target landscape.** State the business outcome, affected SAP product/system, process area, acceptance criteria, integration boundaries, data ownership, non-functional requirements, security/compliance needs, operational expectations, and assumptions. Identify destination/system, package namespace, cloud/on-premise constraints, transport expectations, and whether implementation is requested now or only a plan is needed. Ask only for missing material details.
13
- 2. **Inspect live MCP tools once per server and investigate the system.** Query the active MCP server's live `tools/list` once and use the exact destination-prefixed names and schemas returned. Re-query only when destination/configuration changes or a tool is reported unavailable. Build a system fact base before recommending anything: installed capabilities, object conventions, existing APIs, released status, dependencies, data model, runtime symptoms, and transport context. Prefer this analysis tool map when present:
15
+ 2. **Inspect live MCP tools once per server and investigate the system.** Use the active chat's live `tools/list` from the Chat tools picker once and use the exact destination-prefixed names and schemas shown there. Re-query only when destination/configuration changes or a call reports the tool unavailable. Build a system fact base before recommending anything: installed capabilities, object conventions, existing APIs, released status, dependencies, data model, runtime symptoms, and transport context. Prefer this analysis tool map when present:
14
16
  - **System and capability context:** `GetSystemInfo`, `GetFeatures`, `GetConnectionInfo`.
15
17
  - **Repository and implementation discovery:** `SearchObject`, `GrepObjects`, `GrepPackages`, `GetSource`, `GetContext`, `FindDefinition`, `FindReferences`, `GetObjectStructure`, `GetClass`, `GetInterface`, `GetTypeInfo`, `GetTypeHierarchy`.
16
18
  - **CDS/RAP/API analysis:** `GetCDSDependencies`, `GetCDSImpactAnalysis`, `GetCDSElementInfo`, `RunQuery`, `GetTableContents`, and `GetAPIReleaseState` for relevant dependencies and candidate APIs.
package/README.md CHANGED
@@ -66,6 +66,8 @@ Then connect a destination in SAP Business Application Studio:
66
66
  5. In the Chat tools picker, enable the server for that BAS destination.
67
67
  6. Ask Copilot to inspect, build, test, or verify something in your SAP landscape.
68
68
 
69
+ Use the attached destination-prefixed tools directly in chat. Do not launch `sap-ai-dev` or handcraft MCP JSON-RPC in a terminal to discover or call them.
70
+
69
71
  **Prerequisite:** Node.js 20 or newer. If Go is not already available, the installer can provision the pinned supported Go release automatically.
70
72
 
71
73
  ## 🤖 Available agents and skills
@@ -374,7 +376,7 @@ The sample values sketch an on-premise ABAP backend routed through SAP Cloud Con
374
376
  | `WebIDEUsage` | `dev_abap,odata_abap` | Include `dev_abap` for ABAP development; add other usages required by your BAS scenario. |
375
377
  | `CloudConnectorLocationId` | `DEMO-LOCATION` | Optional; set only when the Cloud Connector uses a location ID. |
376
378
 
377
- For on-premise systems, configure Cloud Connector access to the backend host and port first. Keep credentials and authentication material in the BAS destination configuration; never put them in `mcp.json`.
379
+ 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.
378
380
 
379
381
  Think of `H2O_URL` as BAS's front door: it must point to the endpoint serving `/api/listDestinations`, not to the SAP backend.
380
382
 
@@ -660,11 +662,13 @@ The MCP client discovers the schemas; callers do not need to memorize every argu
660
662
  | Destination availability only | `sap-ai-dev --check` |
661
663
  | Generated server names and destination mapping | **MCP: Open User Configuration**; look for entries named after the BAS destination and `BAS_VSP_DESTINATION`. |
662
664
  | Running server | **MCP: List Servers**; select the server and choose **Start Server**. |
663
- | Tools and exact schemas | Expand that server in the Chat tools picker. At the protocol level, MCP clients request `tools/list`, whose entries include `name`, `description`, and `inputSchema`. |
665
+ | Tools and exact schemas | Expand that server in the Chat tools picker. The MCP host requests `tools/list`, whose entries include `name`, `description`, and `inputSchema`; the agent should call those tools through chat, not reproduce the protocol in a terminal. |
664
666
  | Runtime and tool-call logs | Select the MCP server in the Output view. Startup, call lifecycle, and child stderr logs are written to stderr; child MCP log notifications are forwarded to the client. Tool arguments and result contents are not logged. |
665
667
  | Installed package version | `npm list --global sap-ai-dev-toolkit` |
666
668
 
667
- After MCP initialization, a raw inspection request has this shape (normally sent by the client, not typed into the terminal):
669
+ If the server is running but an agent reports that SAP tools are unavailable, verify that the same chat is in Agent mode, the generated server is enabled in that chat's tools picker, and concrete names such as `s4h__GetSystemInfo` are listed. Reselect the agent or start a new chat if the tool binding is stale, then inspect the MCP server's Output log for startup or `tools/list` errors. Do not try to work around a missing chat binding by starting the server or handcrafting JSON-RPC from the terminal.
670
+
671
+ After MCP initialization, the host internally sends a request like this; agents should not reproduce it in the terminal:
668
672
 
669
673
  ```json
670
674
  {"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}
@@ -699,7 +703,7 @@ The response contains a `tools` array. A `RunQuery` entry resembles this excerpt
699
703
  | `SAP_AI_DEV_TOOLKIT_MODE` | VSP child mode (`expert` by default; `focused` omits `ActivateMultiple`, `GetUserTransports`, and `GetTransportInfo`). The proxy exposes its curated tools plus tools listed in `tools.md` when registered by that mode, including local `LintABAP`. |
700
704
  | `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. |
701
705
  | `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. |
702
- | `SAP_AI_DEV_TOOLKIT_HTTP_PROXY` | Egress proxy for relay and discovery traffic (falls back to `HTTP_PROXY`/`http_proxy`; unset means the default BAS proxy for `.dest` hosts, empty means direct). |
706
+ | `SAP_AI_DEV_TOOLKIT_HTTP_PROXY` | Egress proxy for relay and discovery traffic (falls back to `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). |
703
707
  | `SAP_AI_DEV_TOOLKIT_MAX_CSRF_RETRIES` | Bounded CSRF/session self-healing retries per unsafe request (default 3). |
704
708
 
705
709
  #### Self-healing modes
@@ -710,7 +714,8 @@ The relay between the VSP child and each BAS destination recovers from the failu
710
714
  2. **Session refresh** — when SAP rejects or rotates a session (401/403 after a token was already accepted), the cached session is dropped, a fresh token+cookie pair is fetched, and the request retried, bounded by the retry limit.
711
715
  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.
712
716
  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.
713
- 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 basic authentication, setup prompts for a SAP user and password and stores them in `sap-ai-dev-toolkit-credentials.json` **next to** `mcp.json` (never inside it, permissions `0600`). The relay then connects straight to the backend host with Basic auth — cookies survive, CSRF pairing works, and writes behave like they do from Eclipse/ADT. Rerun `sap-ai-dev --setup` to change or clear stored credentials; re-selecting nothing removes stale entries.
717
+ 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.
718
+ 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.
714
719
  | `SAP_AI_DEV_MCP_CONFIG` | Explicit MCP user configuration path. |
715
720
  | `BAS_VSP_MCP_CONFIG` | Backward-compatible alias for the MCP user configuration path. |
716
721
  | `BAS_VSP_BINARY` | Trusted prebuilt VSP executable; skips Go and binary provisioning. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sap-ai-dev-toolkit",
3
- "version": "0.3.3",
3
+ "version": "0.3.4",
4
4
  "description": "SAP AI development toolkit for BAS, ABAP, RAP, CAP, Fiori, UI5, and MCP",
5
5
  "author": "Gurkan Yilmaz",
6
6
  "type": "module",
@@ -97,16 +97,15 @@ async function readRequestBody(req) {
97
97
  export function createBasDestinationRelay(destination, { env = process.env, fetchImpl = undiciFetch, log = () => {} } = {}) {
98
98
  if (!destination?.url) throw new Error('Destination relay requires a BAS destination URL');
99
99
  const base = new URL(destination.url);
100
- // Self-healing mode 5 (direct connect): the BAS .dest proxy strips
101
- // Set-Cookie, which makes SAP CSRF session binding impossible. When the
102
- // operator stored credentials for this destination (setup prompts for
103
- // Internet destinations), the relay connects straight to the backend host
104
- // with Basic auth — cookies survive, CSRF pairing works, and writes go
105
- // through exactly like they do from Eclipse/ADT.
106
100
  const credentials = destination.credentials;
107
- const directBase = credentials?.host ? new URL(String(credentials.host)) : null;
108
- const effectiveBase = directBase || base;
101
+ const tunnelMode = credentials?.mode === 'bas-tunnel';
102
+ const directHost = tunnelMode ? '' : credentials?.host;
103
+ // Internet overrides retain the direct-backend path. OnPremise overrides
104
+ // must keep the BAS .dest route (and its Cloud Connector mapping), so they
105
+ // use HTTP CONNECT through the BAS proxy instead of credentials.host.
106
+ const directBase = directHost ? new URL(String(directHost)) : null;
109
107
  if (directBase) log(`[${destination.name}] BAS relay direct connect active (${directBase.origin}); BAS proxy cookie stripping bypassed`);
108
+ if (tunnelMode) log(`[${destination.name}] BAS relay using a BAS proxy tunnel for the OnPremise destination`);
110
109
  // Proxy resolution: an explicitly configured proxy wins (an explicit empty
111
110
  // value means "go direct"). Without configuration, BAS virtual .dest hosts
112
111
  // imply the default BAS proxy; any other host (tests, direct URLs) is
@@ -114,22 +113,23 @@ export function createBasDestinationRelay(destination, { env = process.env, fetc
114
113
  // foreign proxy.
115
114
  const explicitProxy = env.HTTP_PROXY ?? env.http_proxy;
116
115
  const proxyUrl = directBase ? '' : (explicitProxy !== undefined ? String(explicitProxy) : (/\.dest$/i.test(base.hostname) ? DEFAULT_PROXY : ''));
116
+ if (tunnelMode && !proxyUrl) throw new Error('OnPremise credential overrides require the BAS destination proxy to preserve the Cloud Connector route');
117
117
  const dispatcher = proxyUrl ? new ProxyAgent({ uri: proxyUrl, proxyTunnel: false }) : undefined;
118
118
  // Self-healing mode 2: if the BAS proxy refuses absolute-form proxied
119
119
  // requests (its own 502/504), retry through a CONNECT tunnel so the relay
120
120
  // keeps working through the same egress.
121
121
  const tunnelDispatcher = proxyUrl ? new ProxyAgent({ uri: proxyUrl, proxyTunnel: true }) : undefined;
122
- let tunneled = false;
122
+ let tunneled = tunnelMode;
123
123
  // Observable self-healing counters, surfaced by --doctor and tests.
124
- const stats = { requests: 0, csrfFetches: 0, csrfRetries: 0, tunnelFallbacks: 0, direct: Boolean(directBase) };
124
+ const stats = { requests: 0, csrfFetches: 0, csrfRetries: 0, tunnelFallbacks: 0, direct: Boolean(directBase), proxyTunnel: tunnelMode };
125
125
  // Per-path CSRF sessions: { token, jar, fetchedAt } where jar holds the
126
126
  // Set-Cookie state SAP returned together with the token.
127
127
  const tokenCache = new Map();
128
128
 
129
- function rememberSession(key, token, headers) {
129
+ function rememberSession(key, token, headers, httpStatus) {
130
130
  const jar = new Map();
131
131
  recordCookies(jar, headers);
132
- tokenCache.set(key, { token, jar, fetchedAt: Date.now() });
132
+ tokenCache.set(key, { token, jar, fetchedAt: Date.now(), httpStatus });
133
133
  }
134
134
 
135
135
  async function send(target, method, headers, body) {
@@ -171,12 +171,22 @@ export function createBasDestinationRelay(destination, { env = process.env, fetc
171
171
  await response.arrayBuffer().catch(() => undefined);
172
172
  const token = response.headers.get('x-csrf-token');
173
173
  if (token && token.toLowerCase() !== 'required') {
174
- rememberSession(key, token, response.headers);
174
+ rememberSession(key, token, response.headers, response.status);
175
175
  return tokenCache.get(key);
176
176
  }
177
177
  return null;
178
178
  }
179
179
 
180
+ async function probeCsrfSession(path = '/sap/bc/adt/discovery') {
181
+ const requestUrl = new URL(path, base);
182
+ const session = await fetchToken(requestUrl, { accept: 'application/xml,text/xml,*/*' });
183
+ return {
184
+ httpStatus: session?.httpStatus || 0,
185
+ tokenReceived: Boolean(session?.token),
186
+ cookieCount: session?.jar?.size || 0
187
+ };
188
+ }
189
+
180
190
  // Rewrite an incoming .dest target onto the direct backend host when
181
191
  // credentials are configured, keeping path and query intact.
182
192
  function resolveTarget(target) {
@@ -261,6 +271,7 @@ export function createBasDestinationRelay(destination, { env = process.env, fetc
261
271
  return {
262
272
  ready,
263
273
  stats,
274
+ probeCsrfSession,
264
275
  close: async () => {
265
276
  await dispatcher?.close?.();
266
277
  await tunnelDispatcher?.close?.();
@@ -0,0 +1,75 @@
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
+ }
@@ -4,10 +4,11 @@ import { homedir } from 'node:os';
4
4
  import { basename, dirname, join } from 'node:path';
5
5
  import { brandedEnvValue } from './branding.mjs';
6
6
 
7
- // Credentials for direct-connect destinations live next to the MCP config
8
- // (never inside it), with 0600 permissions and no tokens or cookies. The
9
- // file maps destination names to { host, user, password, updatedAt }.
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
10
  const CREDENTIALS_FILENAME = 'sap-ai-dev-toolkit-credentials.json';
11
+ const CREDENTIAL_MODES = new Set(['direct', 'bas-tunnel', 'cf-connectivity']);
11
12
 
12
13
  const SECRET_PATTERN = /(authorization|cookie|password|secret|token|bearer|credential)/i;
13
14
 
@@ -44,6 +45,7 @@ function sanitizeEntry(entry) {
44
45
  if (typeof entry?.host === 'string' && entry.host) result.host = entry.host;
45
46
  if (typeof entry?.user === 'string' && entry.user) result.user = entry.user;
46
47
  if (typeof entry?.password === 'string' && entry.password) result.password = entry.password;
48
+ if (CREDENTIAL_MODES.has(entry?.mode)) result.mode = entry.mode;
47
49
  if (typeof entry?.updatedAt === 'string') result.updatedAt = entry.updatedAt;
48
50
  return result;
49
51
  }
@@ -87,10 +89,17 @@ async function writeCredentials(path, destinations) {
87
89
  }
88
90
  }
89
91
 
90
- export async function storeDestinationCredentials(path, name, { host, user, password }) {
92
+ export async function storeDestinationCredentials(path, name, { host, user, password, mode }) {
93
+ if (mode != null && !CREDENTIAL_MODES.has(mode)) throw new Error('Credential override route mode is unsupported');
91
94
  const current = await readCredentials(path).catch(error => { throw error; });
92
95
  const destinations = { ...current.destinations };
93
- destinations[String(name)] = { host: String(host || ''), user: String(user || ''), password: String(password || ''), updatedAt: new Date().toISOString() };
96
+ destinations[String(name)] = {
97
+ host: String(host || ''),
98
+ user: String(user || ''),
99
+ password: String(password || ''),
100
+ ...(mode ? { mode } : {}),
101
+ updatedAt: new Date().toISOString()
102
+ };
94
103
  await writeCredentials(path, destinations);
95
104
  return { path, name };
96
105
  }
package/src/launcher.mjs CHANGED
@@ -10,36 +10,11 @@ import { installMcpConfig } 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 { readCredentials, resolveCredentialsPath } from './credentials-store.mjs';
13
+ import { enrichWithStoredCredentials } from './credential-overrides.mjs';
14
14
  const root = dirname(dirname(fileURLToPath(import.meta.url)));
15
15
  const pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'));
16
16
  const runtimeEnv = withBrandedEnvironment(process.env);
17
17
 
18
- // Attach stored direct-connect credentials (setup prompts for Internet
19
- // destinations) to matching discovered destinations. Credentials stay in
20
- // memory only; nothing is logged or written to the MCP config.
21
- async function enrichWithStoredCredentials(destinations, env = runtimeEnv) {
22
- const needingCredentials = destinations.filter(destination =>
23
- destination.source !== 'cloud-foundry'
24
- && destination.url
25
- && String(destination.url).includes('.dest'));
26
- if (!needingCredentials.length) return destinations;
27
- let stored;
28
- try {
29
- const path = await resolveCredentialsPath(env);
30
- stored = await readCredentials(path);
31
- } catch (error) {
32
- console.error(`[sap-ai-dev] stored credentials could not be read: ${String(error.message).slice(0, 200)}`);
33
- return destinations;
34
- }
35
- return destinations.map(destination => {
36
- const entry = stored.destinations?.[destination.name];
37
- if (!entry?.host || !entry.user || !entry.password) return destination;
38
- if (!destination.backendUrl) return destination;
39
- return { ...destination, credentials: { host: entry.host, user: entry.user, password: entry.password } };
40
- });
41
- }
42
-
43
18
  function hasFlag(name) { return process.argv.slice(2).includes(name); }
44
19
  function json(value) { process.stdout.write(`${JSON.stringify(value)}\n`); }
45
20
 
@@ -111,7 +86,9 @@ async function runDoctor(destinations) {
111
86
  } else {
112
87
  checks.push(doctorRow(destination.name, 'BAS destination relay', 'skipped', 'Not applicable for this destination.'));
113
88
  }
114
- if (relayedDestination?.credentials) {
89
+ if (relayedDestination?.credentials?.mode === 'bas-tunnel') {
90
+ checks.push(doctorRow(destination.name, 'OnPremise credential override', 'passed', 'Basic auth is routed through a BAS proxy tunnel; Cloud Connector routing is retained'));
91
+ } else if (relayedDestination?.credentials?.host) {
115
92
  checks.push(doctorRow(destination.name, 'Direct connect', 'passed', `active (${new URL(relayedDestination.credentials.host).origin}); ADT writes use stored credentials`));
116
93
  }
117
94
  const listed = await proxy.handle({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} });
@@ -203,8 +180,8 @@ async function main() {
203
180
  let report;
204
181
  try {
205
182
  const destinations = runtimeEnv.SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE === 'cloud-foundry'
206
- ? [await resolveConfiguredCloudFoundryDestination({ env: runtimeEnv })]
207
- : await enrichWithStoredCredentials(await discoverForCommand());
183
+ ? await enrichWithStoredCredentials([await resolveConfiguredCloudFoundryDestination({ env: runtimeEnv })], runtimeEnv)
184
+ : await enrichWithStoredCredentials(await discoverForCommand(), runtimeEnv);
208
185
  report = await runDoctor(destinations);
209
186
  } catch (error) {
210
187
  report = { ok: false, destinations: 0, checks: [doctorRow('-', 'destination discovery', 'failed', redactText(error.message || error).slice(0, 300))] };
@@ -240,7 +217,7 @@ async function main() {
240
217
  }
241
218
 
242
219
  if (runtimeEnv.SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE === 'cloud-foundry') {
243
- const destination = await resolveConfiguredCloudFoundryDestination({ env: runtimeEnv });
220
+ const destination = (await enrichWithStoredCredentials([await resolveConfiguredCloudFoundryDestination({ env: runtimeEnv })], runtimeEnv))[0];
244
221
  let proxy;
245
222
  try {
246
223
  const binary = await binaryOrError();
@@ -270,14 +247,15 @@ async function main() {
270
247
 
271
248
  const discovered = await discoverDestinations({ env: runtimeEnv });
272
249
  for (const destination of discovered) console.error(probeDiagnostic(destination));
273
- const destinations = await enrichWithStoredCredentials(discovered);
250
+ const destinations = await enrichWithStoredCredentials(discovered, runtimeEnv);
274
251
  if (!destinations.length) {
275
252
  console.error('[sap-ai-dev] destination discovery returned no named BAS destinations');
276
253
  throw new Error(remediation);
277
254
  }
278
255
  const binary = await binaryOrError();
279
256
  for (const destination of destinations) {
280
- if (destination.credentials) console.error(`[sap-ai-dev] ${destination.name}: direct connect enabled through stored credentials`);
257
+ if (destination.credentials?.mode === 'bas-tunnel') console.error(`[sap-ai-dev] ${destination.name}: Basic credentials enabled through the BAS proxy tunnel`);
258
+ else if (destination.credentials?.host) console.error(`[sap-ai-dev] ${destination.name}: direct connect enabled through stored credentials`);
281
259
  }
282
260
  console.error(`[sap-ai-dev] starting MCP proxy for ${destinations.map(destination => `${destination.name} (client=${destination.client})`).join(', ')}`);
283
261
  const proxy = new MCPProxy({ binary, destinations, env: process.env, log: message => console.error(message) });
package/src/setup.mjs CHANGED
@@ -5,6 +5,8 @@ import { discoverDestinations, remediation } from './bas-discovery.mjs';
5
5
  import { discoverCloudFoundryDestinations, getCloudFoundryTarget, deleteManagedCloudFoundryServiceKeys } from './cf-destination.mjs';
6
6
  import { SAP_DEVELOPMENT_MCP_SERVERS, collectCloudFoundryKeyReferencesFromAllEntries, collectManagedCloudFoundryKeyReferences, installMcpConfig, readMcpConfig, resolveMcpConfigPath } from './mcp-config.mjs';
7
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';
8
10
 
9
11
  const SETUP_COMMAND = 'sap-ai-dev --setup';
10
12
 
@@ -24,54 +26,88 @@ async function confirmCloudFoundryImport({ input = stdin, output = stdout } = {}
24
26
  }
25
27
  }
26
28
 
27
- // Internet destinations reach SAP through the BAS .dest proxy, which strips
28
- // Set-Cookie and makes ADT CSRF-protected writes impossible. Ask once for
29
- // direct-connection credentials and store them next to the MCP config; the
30
- // relay then connects straight to the backend host with Basic auth.
31
- async function promptForInternetDestinationCredentials({ destinations, credentialsPath, existing = {}, input = stdin, output = stdout } = {}) {
32
- const stored = {};
33
- const internetDestinations = destinations.filter(destination =>
34
- destination.source !== 'cloud-foundry'
35
- && String(destination.proxyType || '').toLowerCase() === 'internet'
36
- && /^basicauthentication$/i.test(String(destination.authentication || '')));
37
- if (!internetDestinations.length) return stored;
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;
38
46
 
39
47
  print(output, '');
40
- print(output, formatStatus('Some selected destinations are Internet type with basic authentication.', 'step', output, 'Direct connect'));
41
- print(output, ' The BAS proxy strips SAP session cookies, which blocks ADT writes (CSRF validation).');
42
- print(output, ' Providing a user and password enables a direct connection for those destinations.');
43
- print(output, ` Credentials are stored beside your MCP config in ${credentialsPath} (never inside mcp.json).`);
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.');
44
51
  print(output, '');
45
52
 
46
- for (const destination of internetDestinations) {
47
- const known = existing[destination.name];
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
+
48
70
  if (known?.user && known?.password) {
49
- print(output, formatStatus(`Credentials for ${destination.name} are already stored (user ${known.user}). Press Enter at both prompts to keep them.`, 'info', output, 'Direct connect'));
71
+ print(output, formatStatus(`Credentials for ${destination.name} are already stored. Press Enter at both prompts to keep them.`, 'info', output, 'Credentials'));
50
72
  }
51
73
  const userAnswer = await textPrompt({
52
- message: colorText(`👤 ${destination.name} — SAP user`, 'cyan', output),
74
+ message: colorText(`👤 ${routeLabel} — SAP user`, 'cyan', output),
53
75
  required: false,
54
- placeholder: known ? `${known.user} (Enter keeps stored user)` : 'SAP user name'
76
+ placeholder: known ? 'Enter keeps the stored user' : 'SAP user name'
55
77
  }, { input, output });
56
78
  const user = userAnswer.trim() || known?.user || '';
57
79
  const passwordAnswer = await textPrompt({
58
- message: colorText(`🔒 ${destination.name} — SAP password${known ? ' (Enter keeps stored password)' : ''}`, 'cyan', output),
80
+ message: colorText(`🔒 ${routeLabel} — SAP password${known ? ' (Enter keeps stored password)' : ''}`, 'cyan', output),
59
81
  secret: true,
60
82
  required: false
61
83
  }, { input, output });
62
84
  const password = passwordAnswer || known?.password || '';
63
85
  if (!user || !password) {
64
- print(output, formatStatus(`No complete credentials for ${destination.name}; direct connect stays inactive for it.`, 'warning', output, 'Direct connect'));
86
+ await removeDestinationCredentials(credentialsPath, [key]);
87
+ print(output, formatStatus(`No complete credentials for ${destination.name}; no override was stored.`, 'warning', output, 'Credentials'));
65
88
  continue;
66
89
  }
67
- stored[destination.name] = {
68
- host: destination.backendUrl || known?.host || '',
69
- user,
70
- password
71
- };
72
- print(output, formatStatus(`Stored credentials for ${destination.name}.`, 'success', output, 'Direct connect'));
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'));
73
109
  }
74
- return stored;
110
+ return retained;
75
111
  }
76
112
 
77
113
  function safeProbe(probe) {
@@ -137,6 +173,7 @@ function printConnectionInstructions(output, result) {
137
173
  print(output, ` • ${name} — ${detail}`);
138
174
  }
139
175
  print(output, formatStatus('Open the Command Palette → “MCP: List Servers”, select a generated server, and choose “Start Server”.', 'step', output, 'Next'));
176
+ print(output, formatStatus('In GitHub Copilot Chat, select an agent, open the Chat tools picker, and enable each selected destination server for this chat.', 'step', output, 'Copilot Chat'));
140
177
  print(output, formatStatus('Inspect or edit entries with “MCP: Open User Configuration”.', 'info', output, 'Config'));
141
178
  }
142
179
 
@@ -149,6 +186,7 @@ export async function runSetup({
149
186
  discoverCf = discoverCloudFoundryDestinations,
150
187
  confirmCfImport = confirmCloudFoundryImport,
151
188
  install = installMcpConfig,
189
+ verifyOnPremCredentialRoute = probeBasOnPremCredentialRoute,
152
190
  includeSapDevelopmentToolsPrompt = false
153
191
  } = {}) {
154
192
  if (!env.H2O_URL) {
@@ -283,37 +321,24 @@ export async function runSetup({
283
321
  }, { input, output });
284
322
  }
285
323
 
286
- // Ask for direct-connect credentials for selected Internet destinations
287
- // so ADT writes work despite the BAS proxy stripping session cookies.
324
+ // Ask for an explicit credential override choice for each selected supported destination.
288
325
  const credentialsPath = await resolveCredentialsPath(env, configPath);
289
- const internetSelected = selected.filter(destination =>
290
- destination.source !== 'cloud-foundry'
291
- && String(destination.proxyType || '').toLowerCase() === 'internet'
292
- && /^basicauthentication$/i.test(String(destination.authentication || '')));
293
- if (internetSelected.length) {
294
- try {
295
- const existingMap = (await readCredentials(credentialsPath).catch(() => ({ destinations: {} }))).destinations || {};
296
- const storedNow = await promptForInternetDestinationCredentials({
297
- destinations: selected,
298
- credentialsPath,
299
- existing: existingMap,
300
- input,
301
- output
302
- });
303
- for (const [name, entry] of Object.entries(storedNow)) {
304
- await storeDestinationCredentials(credentialsPath, name, entry);
305
- }
306
- // Remove stored credentials for Internet destinations that are no
307
- // longer selected, so the file only tracks active servers.
308
- const stillSelected = new Set(selected.map(destination => destination.name));
309
- const stale = Object.keys(existingMap)
310
- .filter(name => !stillSelected.has(name))
311
- .filter(name => internetSelected.every(destination => destination.name !== name));
312
- if (stale.length) await removeDestinationCredentials(credentialsPath, stale);
313
- } catch (error) {
314
- if (/Prompt interrupted/.test(error.message)) throw error;
315
- credentialWarnings.push(`Direct-connect credentials could not be stored: ${error.message}`);
316
- }
326
+ try {
327
+ const existingMap = (await readCredentials(credentialsPath).catch(() => ({ destinations: {} }))).destinations || {};
328
+ const retained = await promptForDestinationCredentials({
329
+ destinations: selected,
330
+ credentialsPath,
331
+ existing: existingMap,
332
+ input,
333
+ output,
334
+ env,
335
+ verifyOnPremCredentialRoute
336
+ });
337
+ const stale = Object.keys(existingMap).filter(name => !retained.has(name));
338
+ if (stale.length) await removeDestinationCredentials(credentialsPath, stale);
339
+ } catch (error) {
340
+ if (/Prompt interrupted/.test(error.message)) throw error;
341
+ credentialWarnings.push(`Credential overrides could not be stored: ${error.message}`);
317
342
  }
318
343
  try {
319
344
  const result = await install(selected, { env, sapDevelopmentServers });
@@ -1,6 +1,7 @@
1
1
  import test from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
3
  import http from 'node:http';
4
+ import net from 'node:net';
4
5
  import { request as httpRequest } from 'node:http';
5
6
  import { createBasDestinationRelay } from '../src/bas-destination-relay.mjs';
6
7
 
@@ -186,3 +187,52 @@ test('falls back to a proxy tunnel when the absolute-form request is refused', a
186
187
  await new Promise(resolve => server.close(resolve));
187
188
  }
188
189
  });
190
+
191
+ test('keeps OnPremise Basic auth on a BAS proxy tunnel and verifies the CSRF session', async t => {
192
+ const { state, server } = sapLikeBackend();
193
+ const backendPort = await startServer(server);
194
+ const connectTargets = [];
195
+ const proxy = http.createServer();
196
+ proxy.on('connect', (request, clientSocket, head) => {
197
+ connectTargets.push(request.url);
198
+ const upstream = net.connect(backendPort, '127.0.0.1', () => {
199
+ clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
200
+ if (head.length) upstream.write(head);
201
+ clientSocket.pipe(upstream);
202
+ upstream.pipe(clientSocket);
203
+ });
204
+ clientSocket.on('error', () => upstream.destroy());
205
+ upstream.on('error', () => clientSocket.destroy());
206
+ });
207
+ const proxyPort = await startServer(proxy);
208
+ const logs = [];
209
+ const relay = createBasDestinationRelay({
210
+ name: 'CC-SYSTEM',
211
+ url: 'http://cc-system.dest',
212
+ authentication: 'BasicAuthentication',
213
+ credentials: { user: 'DEVELOPER', password: 'secret123', mode: 'bas-tunnel' }
214
+ }, {
215
+ env: { HTTP_PROXY: `http://127.0.0.1:${proxyPort}` },
216
+ log: message => logs.push(message)
217
+ });
218
+ const relayUrl = await relay.ready;
219
+ t.after(async () => {
220
+ await relay.close();
221
+ proxy.closeAllConnections?.();
222
+ await new Promise(resolve => proxy.close(resolve));
223
+ await new Promise(resolve => server.close(resolve));
224
+ });
225
+
226
+ const probe = await relay.probeCsrfSession();
227
+ assert.deepEqual(probe, { httpStatus: 200, tokenReceived: true, cookieCount: 2 });
228
+ const response = await relayFetch(relayUrl, '/sap/bc/adt/oo/classes', { method: 'POST', body: '<x/>' });
229
+ assert.equal(response.status, 201, `expected a paired CSRF session, got ${response.status}: ${response.body}`);
230
+ const post = state.requests.find(request => request.method === 'POST');
231
+ assert.match(post.headers.authorization, /^Basic /);
232
+ assert.match(String(post.headers.cookie), /sap-session-\d+=/);
233
+ assert.ok(connectTargets.length >= 2);
234
+ assert.ok(connectTargets.every(target => target === 'cc-system.dest:80'));
235
+ assert.equal(relay.stats.direct, false);
236
+ assert.equal(relay.stats.proxyTunnel, true);
237
+ assert.ok(logs.some(message => message.includes('BAS proxy tunnel')));
238
+ });
@@ -6,6 +6,7 @@ import { spawn } from 'node:child_process';
6
6
  import { tmpdir } from 'node:os';
7
7
  import { join } from 'node:path';
8
8
  import { fileURLToPath } from 'node:url';
9
+ import { storeDestinationCredentials } from '../src/credentials-store.mjs';
9
10
 
10
11
  const launcher = fileURLToPath(new URL('../src/launcher.mjs', import.meta.url));
11
12
  const fakeVsp = fileURLToPath(new URL('./fixtures/fake-vsp.mjs', import.meta.url));
@@ -257,6 +258,12 @@ test('routes a PrincipalPropagation MCP call through CF Connectivity to the SAP
257
258
  connectivityProxyPort,
258
259
  connectivityTokenUrl: `${serviceOrigin}/connectivity/oauth/token`
259
260
  });
261
+ const credentialsPath = join(directory, 'sap-ai-dev-toolkit-credentials.json');
262
+ await storeDestinationCredentials(credentialsPath, `cloud-foundry:cf:${SPACE_GUID}:${INSTANCE_GUID}:pp-target`, {
263
+ user: 'must-not-override',
264
+ password: 'must-not-override-secret',
265
+ mode: 'cf-connectivity'
266
+ });
260
267
  const childLog = join(directory, 'vsp-children.jsonl');
261
268
  t.after(async () => {
262
269
  await new Promise(resolve => service.close(resolve));
@@ -271,6 +278,7 @@ test('routes a PrincipalPropagation MCP call through CF Connectivity to the SAP
271
278
  SAP_AI_DEV_TOOLKIT_BINARY: fakeVsp,
272
279
  SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE: 'cloud-foundry',
273
280
  SAP_AI_DEV_TOOLKIT_DESTINATION: 'pp-target',
281
+ SAP_AI_DEV_TOOLKIT_CREDENTIALS_FILE: credentialsPath,
274
282
  BAS_CF_SPACE_GUID: SPACE_GUID,
275
283
  BAS_CF_DESTINATION_INSTANCE_GUID: INSTANCE_GUID,
276
284
  BAS_CF_DESTINATION_INSTANCE: INSTANCE_NAME,
@@ -313,17 +321,17 @@ test('routes a PrincipalPropagation MCP call through CF Connectivity to the SAP
313
321
  assert.equal(initialized.argv.includes('--proxy-auth'), true);
314
322
  assert.equal(initialized.env.user, undefined);
315
323
  assert.equal(initialized.env.password, undefined);
316
- for (const secret of ['destination-key-secret', 'destination-api-token', 'connectivity-key-secret', 'user-exchange-token', 'current-cf-user-jwt', 'parent-user-secret', 'parent-password-secret']) {
324
+ for (const secret of ['must-not-override', 'must-not-override-secret', 'destination-key-secret', 'destination-api-token', 'connectivity-key-secret', 'user-exchange-token', 'current-cf-user-jwt', 'parent-user-secret', 'parent-password-secret']) {
317
325
  assert.equal(`${result.stdout}\\n${result.stderr}\\n${JSON.stringify(initialized.argv)}`.includes(secret), false);
318
326
  }
319
327
  });
320
328
 
321
- test('routes a CF Basic OnPremise MCP call through the HTTP Connectivity proxy', async t => {
329
+ test('overrides CF Basic OnPremise backend auth while retaining the HTTP Connectivity proxy', async t => {
322
330
  const directory = await mkdtemp(join(tmpdir(), 'bas-cf-basic-onprem-'));
323
331
  const observed = { destinationToken: [], destinationList: [], connectivityToken: [], proxy: [], connect: [], target: [] };
324
332
  const target = createServer((request, response) => {
325
333
  observed.target.push({ path: request.url, authorization: request.headers.authorization });
326
- response.writeHead(request.headers.authorization === `Basic ${Buffer.from('sap-user:sap-password').toString('base64')}` ? 200 : 401, {
334
+ response.writeHead(request.headers.authorization === `Basic ${Buffer.from('override-user:override-password').toString('base64')}` ? 200 : 401, {
327
335
  'content-type': 'text/plain'
328
336
  });
329
337
  response.end('SAP system information: client=100');
@@ -409,6 +417,12 @@ test('routes a CF Basic OnPremise MCP call through the HTTP Connectivity proxy',
409
417
  connectivityProxyPort,
410
418
  connectivityTokenUrl: `${serviceOrigin}/connectivity/oauth/token`
411
419
  });
420
+ const credentialsPath = join(directory, 'sap-ai-dev-toolkit-credentials.json');
421
+ await storeDestinationCredentials(credentialsPath, `cloud-foundry:cf:${SPACE_GUID}:${INSTANCE_GUID}:basic-onprem`, {
422
+ user: 'override-user',
423
+ password: 'override-password',
424
+ mode: 'cf-connectivity'
425
+ });
412
426
  const childLog = join(directory, 'vsp-children.jsonl');
413
427
  const servers = [service, target, connectivityProxy];
414
428
  t.after(async () => {
@@ -423,6 +437,7 @@ test('routes a CF Basic OnPremise MCP call through the HTTP Connectivity proxy',
423
437
  SAP_AI_DEV_TOOLKIT_BINARY: fakeVsp,
424
438
  SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE: 'cloud-foundry',
425
439
  SAP_AI_DEV_TOOLKIT_DESTINATION: 'basic-onprem',
440
+ SAP_AI_DEV_TOOLKIT_CREDENTIALS_FILE: credentialsPath,
426
441
  BAS_CF_SPACE_GUID: SPACE_GUID,
427
442
  BAS_CF_DESTINATION_INSTANCE_GUID: INSTANCE_GUID,
428
443
  BAS_CF_DESTINATION_INSTANCE: INSTANCE_NAME,
@@ -461,13 +476,20 @@ test('routes a CF Basic OnPremise MCP call through the HTTP Connectivity proxy',
461
476
  'GET',
462
477
  `${targetOrigin}/sap/bc/adt/discovery`,
463
478
  'Bearer application-proxy-token',
464
- `Basic ${Buffer.from('sap-user:sap-password').toString('base64')}`
479
+ `Basic ${Buffer.from('override-user:override-password').toString('base64')}`
465
480
  ]]);
466
481
  assert.deepEqual(observed.connect, []);
467
482
  assert.deepEqual(observed.target, [{
468
483
  path: '/sap/bc/adt/discovery',
469
- authorization: `Basic ${Buffer.from('sap-user:sap-password').toString('base64')}`
484
+ authorization: `Basic ${Buffer.from('override-user:override-password').toString('base64')}`
470
485
  }]);
486
+ const events = (await readFile(childLog, 'utf8')).trim().split('\n').map(line => JSON.parse(line));
487
+ const initialized = events.find(event => event.event === 'initialize');
488
+ assert.deepEqual([initialized.env.user, initialized.env.password], ['override-user', 'override-password']);
489
+ assert.equal(initialized.argv.includes('--proxy-auth'), false);
490
+ for (const secret of ['override-user', 'override-password', 'sap-password', 'connectivity-key-secret', 'application-proxy-token']) {
491
+ assert.equal(`${result.stdout}\n${result.stderr}\n${JSON.stringify(initialized.argv)}`.includes(secret), false, `${secret} must not appear in output or argv`);
492
+ }
471
493
  });
472
494
 
473
495
  test('CF list-destinations clears the CF name filter and remains BAS-only', async t => {
@@ -49,6 +49,12 @@ function assertMentionsLiveToolDiscovery(markdown, path) {
49
49
  assert.match(markdown, /schema/i, `${path} must tell agents to follow live tool schemas`);
50
50
  }
51
51
 
52
+ function assertDirectMcpUsage(markdown, path) {
53
+ assert.match(markdown, /Direct MCP invocation is mandatory/i, `${path} must require direct MCP calls`);
54
+ assert.match(markdown, /Do not launch `sap-ai-dev`.*MCP operations/i, `${path} must forbid terminal-driven MCP discovery/calls`);
55
+ assert.match(markdown, /host\/session binding issue/i, `${path} must report host tool-binding failures instead of using a CLI fallback`);
56
+ }
57
+
52
58
  function assertValidationPolicy(markdown, path) {
53
59
  assert.match(markdown, /ABAP Unit|RunUnitTests/i, `${path} must include ABAP Unit or test execution guidance`);
54
60
  assert.match(markdown, /runtime validation|Self-validate|executable validation|reproduce/i, `${path} must require executable/runtime validation evidence`);
@@ -85,6 +91,15 @@ test('packaged Copilot agent and skill inventory is complete and canonical', asy
85
91
  }
86
92
  });
87
93
 
94
+ test('all bundled agents invoke chat-attached MCP tools without terminal JSON-RPC fallbacks', async () => {
95
+ for (const name of expectedAgents) {
96
+ const path = join(agentsRoot, name);
97
+ const markdown = await readMarkdown(path);
98
+ assertDirectMcpUsage(markdown, path);
99
+ assertMentionsLiveToolDiscovery(markdown, path);
100
+ }
101
+ });
102
+
88
103
  test('ABAP Developer agent has valid metadata and invokes the packaged skills', async () => {
89
104
  const markdown = await readMarkdown(agentPath);
90
105
  const metadata = parseFrontmatter(markdown, agentPath);
@@ -7,6 +7,7 @@ import http from 'node:http';
7
7
  import { request as httpRequest } from 'node:http';
8
8
  import { createBasDestinationRelay } from '../src/bas-destination-relay.mjs';
9
9
  import { readCredentials, removeDestinationCredentials, resolveCredentialsPath, storeDestinationCredentials } from '../src/credentials-store.mjs';
10
+ import { canPromptForCredentials, credentialKeyForDestination, credentialModeForDestination } from '../src/credential-overrides.mjs';
10
11
 
11
12
  const UNSAFE_METHODS = ['POST', 'PUT', 'PATCH', 'DELETE'];
12
13
 
@@ -105,6 +106,36 @@ test('credentials file with malformed entries keeps only complete records', asyn
105
106
  assert.deepEqual(Object.keys(stored.destinations), ['GOOD']);
106
107
  });
107
108
 
109
+ test('credential overrides use route-aware modes and distinct CF destination keys', async t => {
110
+ const directory = await mkdtemp(join(tmpdir(), 'bas-creds-modes-'));
111
+ const path = join(directory, 'sap-ai-dev-toolkit-credentials.json');
112
+ t.after(async () => { await rm(directory, { recursive: true, force: true }); });
113
+
114
+ const basDestination = { name: 'shared-name', source: 'bas', proxyType: 'Internet', authentication: 'BasicAuthentication', backendUrl: 'https://internet.example' };
115
+ const basOnPremise = { name: 'shared-name', source: 'bas', proxyType: 'OnPremise', authentication: 'BasicAuthentication' };
116
+ const cfOnPremise = { name: 'shared-name', serverName: 'cf:space-one:instance-one:shared-name', source: 'cloud-foundry', proxyType: 'OnPremise', authentication: 'BasicAuthentication' };
117
+ const principalPropagation = { ...cfOnPremise, authentication: 'PrincipalPropagation' };
118
+
119
+ assert.equal(canPromptForCredentials(basDestination), true);
120
+ assert.equal(credentialModeForDestination(basDestination), 'direct');
121
+ assert.equal(credentialModeForDestination(basOnPremise), 'bas-tunnel');
122
+ assert.equal(credentialModeForDestination(cfOnPremise), 'cf-connectivity');
123
+ assert.equal(canPromptForCredentials(principalPropagation), false);
124
+ assert.equal(credentialModeForDestination({ ...cfOnPremise, proxyType: 'Internet' }), null);
125
+ assert.equal(canPromptForCredentials({ ...basDestination, backendUrl: null }), false);
126
+ assert.notEqual(credentialKeyForDestination(basOnPremise), credentialKeyForDestination(cfOnPremise));
127
+
128
+ const cfKey = credentialKeyForDestination(cfOnPremise);
129
+ await storeDestinationCredentials(path, cfKey, { user: 'OVERRIDE', password: 'secret', mode: 'cf-connectivity' });
130
+ const stored = await readCredentials(path);
131
+ assert.equal(stored.destinations[cfKey].mode, 'cf-connectivity');
132
+ assert.equal(stored.destinations[cfKey].host, undefined);
133
+ await assert.rejects(
134
+ () => storeDestinationCredentials(path, 'INVALID', { user: 'U', password: 'P', mode: 'bypass-cloud-connector' }),
135
+ /route mode is unsupported/
136
+ );
137
+ });
138
+
108
139
  test('relay direct connect sends Basic auth and CSRF pairing succeeds', async t => {
109
140
  const { state, server } = authedBackend();
110
141
  await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
@@ -7,6 +7,8 @@ import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises
7
7
  import { tmpdir } from 'node:os';
8
8
  import { join } from 'node:path';
9
9
  import { spawnWithPty } from './pty.mjs';
10
+ import { storeDestinationCredentials } from '../src/credentials-store.mjs';
11
+ import { credentialKeyForDestination, enrichWithStoredCredentials } from '../src/credential-overrides.mjs';
10
12
 
11
13
  const launcher = fileURLToPath(new URL('../src/launcher.mjs', import.meta.url));
12
14
  const fakeVsp = fileURLToPath(new URL('./fixtures/fake-vsp.mjs', import.meta.url));
@@ -43,6 +45,56 @@ function runLauncherTty(env, input, args = ['--setup']) {
43
45
  });
44
46
  }
45
47
 
48
+ test('enriches stored credentials without switching BAS OnPremise or CF Connectivity routes', async t => {
49
+ const directory = await mkdtemp(join(tmpdir(), 'bas-launcher-credentials-'));
50
+ const credentialsPath = join(directory, 'credentials.json');
51
+ t.after(async () => { await rm(directory, { recursive: true, force: true }); });
52
+
53
+ const internet = {
54
+ name: 'internet-system', url: 'http://internet-system.dest', backendUrl: 'https://internet.example:44300',
55
+ authentication: 'BasicAuthentication', proxyType: 'Internet'
56
+ };
57
+ const basOnPremise = {
58
+ name: 'shared-system', url: 'http://shared-system.dest', backendUrl: 'https://internal.example:44300',
59
+ authentication: 'BasicAuthentication', proxyType: 'OnPremise'
60
+ };
61
+ const cfOnPremise = {
62
+ source: 'cloud-foundry', name: 'shared-system', serverName: 'cf:space-one:instance-one:shared-system',
63
+ url: 'https://sap.internal:44300', authentication: 'BasicAuthentication', proxyType: 'OnPremise',
64
+ childEnv: { HTTP_PROXY: 'http://127.0.0.1:3333', SAP_USER: 'configured-user', SAP_PASSWORD: 'configured-password' }
65
+ };
66
+ const principalPropagation = { ...cfOnPremise, name: 'principal-system', serverName: 'cf:space-one:instance-one:principal-system', authentication: 'PrincipalPropagation' };
67
+
68
+ await storeDestinationCredentials(credentialsPath, 'internet-system', {
69
+ host: internet.backendUrl, user: 'INTERNET_USER', password: 'internet-secret'
70
+ });
71
+ await storeDestinationCredentials(credentialsPath, 'shared-system', {
72
+ user: 'BAS_USER', password: 'bas-secret', mode: 'bas-tunnel'
73
+ });
74
+ await storeDestinationCredentials(credentialsPath, credentialKeyForDestination(cfOnPremise), {
75
+ user: 'CF_USER', password: 'cf-secret', mode: 'cf-connectivity'
76
+ });
77
+ await storeDestinationCredentials(credentialsPath, credentialKeyForDestination(principalPropagation), {
78
+ user: 'SHOULD_NOT_APPLY', password: 'pp-secret', mode: 'cf-connectivity'
79
+ });
80
+
81
+ const result = await enrichWithStoredCredentials(
82
+ [internet, basOnPremise, cfOnPremise, principalPropagation],
83
+ { SAP_AI_DEV_TOOLKIT_CREDENTIALS_FILE: credentialsPath },
84
+ () => {}
85
+ );
86
+ assert.deepEqual(result[0].credentials, {
87
+ host: internet.backendUrl, user: 'INTERNET_USER', password: 'internet-secret', mode: 'direct'
88
+ });
89
+ assert.deepEqual(result[1].credentials, { user: 'BAS_USER', password: 'bas-secret', mode: 'bas-tunnel' });
90
+ assert.equal(result[1].credentials.host, undefined, 'OnPremise must not bypass the BAS destination route');
91
+ assert.deepEqual(result[2].childEnv, {
92
+ HTTP_PROXY: 'http://127.0.0.1:3333', SAP_USER: 'CF_USER', SAP_PASSWORD: 'cf-secret', SAP_VERBOSE: 'false'
93
+ });
94
+ assert.equal(result[3].childEnv.SAP_USER, 'configured-user', 'PrincipalPropagation must not receive a Basic override');
95
+ assert.equal(result[3].childEnv.SAP_PASSWORD, 'configured-password');
96
+ });
97
+
46
98
  test('help exits before BAS destination discovery', async t => {
47
99
  let requests = 0;
48
100
  const server = createServer((_request, response) => {
@@ -6,6 +6,8 @@ import { join } from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { installMcpConfig } from '../src/mcp-config.mjs';
8
8
  import { destinationTable } from '../scripts/postinstall.mjs';
9
+ import { readCredentials, resolveCredentialsPath, storeDestinationCredentials } from '../src/credentials-store.mjs';
10
+ import { credentialKeyForDestination } from '../src/credential-overrides.mjs';
9
11
  import { spawnWithPty } from './pty.mjs';
10
12
 
11
13
  const setupModule = fileURLToPath(new URL('../src/setup.mjs', import.meta.url));
@@ -28,6 +30,22 @@ function cloudFoundryDestination({ name, serverName, instanceGuid, instanceName,
28
30
  };
29
31
  }
30
32
 
33
+ function cloudFoundryOnPremiseDestination() {
34
+ const destination = cloudFoundryDestination({
35
+ name: 'shared-name',
36
+ serverName: 'cf:space-one:cf-destination:shared-name',
37
+ instanceGuid: 'cf-destination',
38
+ instanceName: 'destination-service',
39
+ keyName: 'destination-key'
40
+ });
41
+ destination.authentication = 'BasicAuthentication';
42
+ destination.proxyType = 'OnPremise';
43
+ destination.cf.connectivityInstanceGuid = 'cf-connectivity';
44
+ destination.cf.connectivityInstanceName = 'connectivity-service';
45
+ destination.cf.connectivityKeyName = 'connectivity-key';
46
+ return destination;
47
+ }
48
+
31
49
  async function makeFixture(t, { version = 'cf version 8.18.0', space = 'space-one', deleteMode = 'success' } = {}) {
32
50
  const directory = await mkdtemp(join(tmpdir(), 'bas-cf-setup-'));
33
51
  const bin = join(directory, 'bin');
@@ -63,6 +81,7 @@ await runSetup({
63
81
  await writeFile(process.env.CF_DISCOVERY_LOG, JSON.stringify({ spaceGuid, managedKeys }));
64
82
  return JSON.parse(process.env.TEST_CF_RESULT);
65
83
  },
84
+ verifyOnPremCredentialRoute: async () => JSON.parse(process.env.TEST_ONPREM_CSRF_RESULT),
66
85
  ...(process.env.TEST_INSTALL_FAIL === 'true' ? {
67
86
  install: async () => { throw new Error('simulated config write failure'); }
68
87
  } : {})
@@ -81,13 +100,14 @@ await runSetup({
81
100
  TEST_CF_DELETE_MODE: deleteMode,
82
101
  TEST_INSTALL_FAIL: 'false',
83
102
  TEST_BAS_DESTINATIONS: JSON.stringify([]),
84
- TEST_CF_RESULT: JSON.stringify({ destinations: [], createdKeys: [], warnings: [] })
103
+ TEST_CF_RESULT: JSON.stringify({ destinations: [], createdKeys: [], warnings: [] }),
104
+ TEST_ONPREM_CSRF_RESULT: JSON.stringify({ httpStatus: 200, tokenReceived: true, cookieCount: 1 })
85
105
  };
86
106
  t.after(() => rm(directory, { recursive: true, force: true }));
87
107
  return { directory, configPath, callLog, discoveryLog, harness, env };
88
108
  }
89
109
 
90
- function runSetupInPty(fixture, { importAnswer = 'y\r', selection = ' \r' } = {}) {
110
+ function runSetupInPty(fixture, { importAnswer = 'y\r', selection = ' \r', promptAnswers = [] } = {}) {
91
111
  return new Promise((resolve, reject) => {
92
112
  const command = `${JSON.stringify(process.execPath)} ${JSON.stringify(fixture.harness)}`;
93
113
  const child = spawnWithPty(command, { env: fixture.env, stdio: ['pipe', 'pipe', 'pipe'] });
@@ -95,6 +115,7 @@ function runSetupInPty(fixture, { importAnswer = 'y\r', selection = ' \r' } = {}
95
115
  let stderr = '';
96
116
  let importSent = false;
97
117
  let selectionSent = false;
118
+ let promptAnswerIndex = 0;
98
119
  const timeout = setTimeout(() => child.kill('SIGKILL'), 15000);
99
120
  child.stdout.on('data', chunk => {
100
121
  stdout += chunk.toString();
@@ -104,7 +125,16 @@ function runSetupInPty(fixture, { importAnswer = 'y\r', selection = ' \r' } = {}
104
125
  }
105
126
  if (!selectionSent && stdout.includes('Select destinations')) {
106
127
  selectionSent = true;
107
- child.stdin.end(selection);
128
+ if (promptAnswers.length) child.stdin.write(selection);
129
+ else child.stdin.end(selection);
130
+ }
131
+ if (selectionSent && promptAnswerIndex < promptAnswers.length) {
132
+ const answer = promptAnswers[promptAnswerIndex];
133
+ if (stdout.includes(answer.when)) {
134
+ promptAnswerIndex++;
135
+ child.stdin.write(answer.value);
136
+ if (promptAnswerIndex === promptAnswers.length) child.stdin.end();
137
+ }
108
138
  }
109
139
  });
110
140
  child.stderr.on('data', chunk => { stderr += chunk.toString(); });
@@ -114,7 +144,7 @@ function runSetupInPty(fixture, { importAnswer = 'y\r', selection = ' \r' } = {}
114
144
  });
115
145
  child.on('exit', (code, signal) => {
116
146
  clearTimeout(timeout);
117
- resolve({ code, signal, stdout, stderr, importSent, selectionSent });
147
+ resolve({ code, signal, stdout, stderr, importSent, selectionSent, promptAnswersSent: promptAnswerIndex });
118
148
  });
119
149
  });
120
150
  }
@@ -145,6 +175,7 @@ test('imports selected CF destinations, passes managed key references, and remov
145
175
  assert.equal(result.selectionSent, true, result.stdout);
146
176
  assert.match(result.stdout, /CF new-destination-service/);
147
177
  assert.match(result.stdout, /Configured 1 MCP server/);
178
+ assert.match(result.stdout, /GitHub Copilot Chat.*Chat tools picker.*enable each selected destination server/s);
148
179
  assert.doesNotMatch(`${result.stdout}\n${result.stderr}`, /SECRET|clientSecret|Password/);
149
180
 
150
181
  const snapshot = JSON.parse(await readFile(fixture.discoveryLog, 'utf8'));
@@ -163,6 +194,102 @@ test('imports selected CF destinations, passes managed key references, and remov
163
194
  assert.equal(calls.some(args => args.at(-1) === 'new-key'), false);
164
195
  });
165
196
 
197
+ test('prompts for BAS and CF OnPremise overrides separately and keeps their routes distinct', async t => {
198
+ const fixture = await makeFixture(t);
199
+ const cfDestination = cloudFoundryOnPremiseDestination();
200
+ fixture.env.TEST_BAS_DESTINATIONS = JSON.stringify([
201
+ {
202
+ name: 'internet-system', url: 'http://internet-system.dest', backendUrl: 'https://internet.example:44300',
203
+ client: '100', authentication: 'BasicAuthentication', proxyType: 'Internet',
204
+ probe: { status: 'available', available: true }
205
+ },
206
+ {
207
+ name: 'shared-name', url: 'http://shared-name.dest', backendUrl: 'https://internal.example:44300',
208
+ client: '101', authentication: 'BasicAuthentication', proxyType: 'OnPremise',
209
+ probe: { status: 'available', available: true }
210
+ },
211
+ {
212
+ name: 'principal-system', url: 'http://principal-system.dest',
213
+ client: '102', authentication: 'PrincipalPropagation', proxyType: 'OnPremise',
214
+ probe: { status: 'available', available: true }
215
+ }
216
+ ]);
217
+ fixture.env.TEST_CF_RESULT = JSON.stringify({ destinations: [cfDestination], createdKeys: [], warnings: [] });
218
+
219
+ const result = await runSetupInPty(fixture, {
220
+ selection: 'a\r',
221
+ promptAnswers: [
222
+ { when: 'BAS Internet — internet-system — override SAP credentials?', value: 'y\r' },
223
+ { when: 'BAS Internet — internet-system — SAP user', value: 'INTERNET_USER\r' },
224
+ { when: 'BAS Internet — internet-system — SAP password', value: 'internet-password-secret\r' },
225
+ { when: 'BAS OnPremise — shared-name — override SAP credentials?', value: 'y\r' },
226
+ { when: 'BAS OnPremise — shared-name — SAP user', value: 'BAS_CC_USER\r' },
227
+ { when: 'BAS OnPremise — shared-name — SAP password', value: 'bas-cc-password-secret\r' },
228
+ { when: 'Cloud Foundry OnPremise — shared-name — override SAP credentials?', value: 'y\r' },
229
+ { when: 'Cloud Foundry OnPremise — shared-name — SAP user', value: 'CF_CC_USER\r' },
230
+ { when: 'Cloud Foundry OnPremise — shared-name — SAP password', value: 'cf-cc-password-secret\r' }
231
+ ]
232
+ });
233
+ assert.equal(result.code, 0, `${result.stdout}\n${result.stderr}`);
234
+ assert.equal(result.promptAnswersSent, 9, result.stdout);
235
+ assert.doesNotMatch(result.stdout, /PrincipalPropagation — principal-system — override SAP credentials/);
236
+ assert.doesNotMatch(`${result.stdout}\n${result.stderr}`, /internet-password-secret|bas-cc-password-secret|cf-cc-password-secret/);
237
+
238
+ const credentialsPath = await resolveCredentialsPath(fixture.env, fixture.configPath);
239
+ const stored = (await readCredentials(credentialsPath)).destinations;
240
+ assert.equal(stored['internet-system'].mode, 'direct');
241
+ assert.equal(stored['internet-system'].host, 'https://internet.example:44300');
242
+ assert.equal(stored['shared-name'].mode, 'bas-tunnel');
243
+ assert.equal(stored['shared-name'].host, undefined, 'BAS OnPremise must not store or use the backend URL');
244
+ const cfKey = credentialKeyForDestination(cfDestination);
245
+ assert.equal(stored[cfKey].mode, 'cf-connectivity');
246
+ assert.equal(stored[cfKey].host, undefined);
247
+ assert.deepEqual(
248
+ [stored['shared-name'].user, stored[cfKey].user],
249
+ ['BAS_CC_USER', 'CF_CC_USER'],
250
+ 'same destination names from BAS and CF must remain distinct'
251
+ );
252
+
253
+ const mcpConfig = JSON.parse(await readFile(fixture.configPath, 'utf8'));
254
+ assert.equal(JSON.stringify(mcpConfig).includes('bas-cc-password-secret'), false);
255
+ assert.equal(JSON.stringify(mcpConfig).includes('cf-cc-password-secret'), false);
256
+ });
257
+
258
+ test('No removes a stored BAS OnPremise override and failed CSRF preflight refuses a new one', async t => {
259
+ const fixture = await makeFixture(t);
260
+ fixture.env.TEST_BAS_DESTINATIONS = JSON.stringify([{
261
+ name: 'shared-name', url: 'http://shared-name.dest', backendUrl: 'https://internal.example:44300',
262
+ client: '100', authentication: 'BasicAuthentication', proxyType: 'OnPremise',
263
+ probe: { status: 'available', available: true }
264
+ }]);
265
+ fixture.env.TEST_CF_RESULT = JSON.stringify({ destinations: [], createdKeys: [], warnings: [] });
266
+ const credentialsPath = await resolveCredentialsPath(fixture.env, fixture.configPath);
267
+ await storeDestinationCredentials(credentialsPath, 'shared-name', {
268
+ user: 'OLD_USER', password: 'old-password-secret', mode: 'bas-tunnel'
269
+ });
270
+
271
+ const declined = await runSetupInPty(fixture, {
272
+ selection: ' \r',
273
+ promptAnswers: [{ when: 'BAS OnPremise — shared-name — override SAP credentials?', value: 'n\r' }]
274
+ });
275
+ assert.equal(declined.code, 0, `${declined.stdout}\n${declined.stderr}`);
276
+ assert.equal((await readCredentials(credentialsPath)).destinations['shared-name'], undefined);
277
+
278
+ fixture.env.TEST_ONPREM_CSRF_RESULT = JSON.stringify({ httpStatus: 200, tokenReceived: true, cookieCount: 0 });
279
+ const rejected = await runSetupInPty(fixture, {
280
+ selection: ' \r',
281
+ promptAnswers: [
282
+ { when: 'BAS OnPremise — shared-name — override SAP credentials?', value: 'y\r' },
283
+ { when: 'BAS OnPremise — shared-name — SAP user', value: 'NEW_USER\r' },
284
+ { when: 'BAS OnPremise — shared-name — SAP password', value: 'new-password-secret\r' }
285
+ ]
286
+ });
287
+ assert.equal(rejected.code, 0, `${rejected.stdout}\n${rejected.stderr}`);
288
+ assert.match(rejected.stdout, /did not return both a CSRF token and session cookie/);
289
+ assert.equal((await readCredentials(credentialsPath)).destinations['shared-name'], undefined);
290
+ assert.doesNotMatch(`${rejected.stdout}\n${rejected.stderr}`, /new-password-secret/);
291
+ });
292
+
166
293
  test('declining CF import keeps BAS setup and does not create CF keys', async t => {
167
294
  const fixture = await makeFixture(t);
168
295
  fixture.env.TEST_BAS_DESTINATIONS = JSON.stringify([{