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.
- package/.github/agents/abap-developer.agent.md +3 -1
- package/.github/agents/abap-runtime-debugger.agent.md +3 -1
- package/.github/agents/rap-service-developer.agent.md +3 -1
- package/.github/agents/sap-solution-architect.agent.md +3 -1
- package/README.md +10 -5
- package/package.json +1 -1
- package/src/bas-destination-relay.mjs +24 -13
- package/src/credential-overrides.mjs +75 -0
- package/src/credentials-store.mjs +14 -5
- package/src/launcher.mjs +10 -32
- package/src/setup.mjs +84 -59
- package/test/bas-destination-relay.test.mjs +50 -0
- package/test/cf-runtime.test.mjs +27 -5
- package/test/copilot-content.test.mjs +15 -0
- package/test/credentials-store.test.mjs +31 -0
- package/test/launcher.test.mjs +52 -0
- package/test/setup-cf.test.mjs +131 -4
|
@@ -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.
|
|
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.**
|
|
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.**
|
|
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.**
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
@@ -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
|
|
108
|
-
const
|
|
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 =
|
|
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
|
-
//
|
|
8
|
-
// (never inside it), with 0600 permissions and no tokens or cookies.
|
|
9
|
-
//
|
|
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)] = {
|
|
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 {
|
|
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}:
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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('
|
|
41
|
-
print(output,
|
|
42
|
-
print(output, '
|
|
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
|
|
47
|
-
const
|
|
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
|
|
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(`👤 ${
|
|
74
|
+
message: colorText(`👤 ${routeLabel} — SAP user`, 'cyan', output),
|
|
53
75
|
required: false,
|
|
54
|
-
placeholder: known ?
|
|
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(`🔒 ${
|
|
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
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
+
});
|
package/test/cf-runtime.test.mjs
CHANGED
|
@@ -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('
|
|
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('
|
|
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('
|
|
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('
|
|
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));
|
package/test/launcher.test.mjs
CHANGED
|
@@ -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) => {
|
package/test/setup-cf.test.mjs
CHANGED
|
@@ -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.
|
|
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([{
|