sap-ai-dev-toolkit 0.7.8 → 0.7.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -9
- package/package.json +1 -1
- package/scripts/postinstall.mjs +12 -4
- package/scripts/windows-negotiate-helper.ps1 +182 -0
- package/src/install-user-assets.mjs +45 -5
- package/src/launcher.mjs +47 -4
- package/src/local-sap-gui.mjs +217 -0
- package/src/mcp-config.mjs +45 -5
- package/src/mcp-proxy.mjs +1 -0
- package/src/setup.mjs +125 -26
- package/src/windows-sso-adt-proxy.mjs +224 -0
package/README.md
CHANGED
|
@@ -52,13 +52,13 @@ To also add optional full-stack SAP companion MCP servers for Fiori, UI5, CAP, a
|
|
|
52
52
|
sap-ai-dev --setup --tools
|
|
53
53
|
```
|
|
54
54
|
|
|
55
|
-
Then connect a destination in SAP Business Application Studio:
|
|
55
|
+
Then connect a destination in SAP Business Application Studio, or run the same setup locally to discover SAP GUI systems and create VS Code MCP entries:
|
|
56
56
|
|
|
57
57
|
1. Open the Command Palette.
|
|
58
58
|
2. Run **MCP: List Servers**.
|
|
59
|
-
3. Start the server named after your selected
|
|
59
|
+
3. Start the server named after your selected destination (the lowercase slug, for example `demo-abap` for destination `DEMO_ABAP`).
|
|
60
60
|
4. In your AI assistant's chat, choose the best-fit bundled agent from the agent picker (GitHub Copilot, Claude Code, and opencode install the agents) or invoke the matching Pi prompt template (`/sap-solution-architect`, `/abap-developer`, `/abap-runtime-debugger`, `/rap-service-developer`, or `/hana-cloud-hdi-specialist`): **SAP Solution Architect**, **ABAP Developer**, **ABAP Runtime Debugger**, **RAP Service Developer**, or **HANA Cloud/HDI Specialist**.
|
|
61
|
-
5. In the Chat tools picker, enable the server for that
|
|
61
|
+
5. In the Chat tools picker, enable the server for that destination.
|
|
62
62
|
6. Ask the agent to inspect, build, test, or verify something in your SAP landscape.
|
|
63
63
|
|
|
64
64
|
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.
|
|
@@ -236,13 +236,13 @@ Once the destination server is enabled in your chat's tools picker, ask for outc
|
|
|
236
236
|
| 🔒 **SAP authorization remains authoritative** | The add-on does not bypass backend SAP permissions. |
|
|
237
237
|
| 🚚 **Curated VSP tool surface** | The proxy exposes the same curated developer-lifecycle set for every destination, plus local workflow tools; SAP authorizations and VSP safety checks still apply. |
|
|
238
238
|
| 🧱 **Per-destination isolation** | Generated MCP entries are scoped to a single `SAP_AI_DEV_TOOLKIT_DESTINATION`. |
|
|
239
|
-
| 🔑 **
|
|
239
|
+
| 🔑 **Credential-aware setup** | BAS authentication material stays in Destination configuration. Local SAP GUI setup writes VS Code input prompts for SAP user/password instead of raw credentials. |
|
|
240
240
|
|
|
241
241
|
<a id="install"></a>
|
|
242
242
|
|
|
243
243
|
## 📦 Installation
|
|
244
244
|
|
|
245
|
-
Install globally from a
|
|
245
|
+
Install globally from SAP Business Application Studio or from a local workstation with SAP GUI installed:
|
|
246
246
|
|
|
247
247
|
```sh
|
|
248
248
|
npm install --global sap-ai-dev-toolkit
|
|
@@ -270,7 +270,9 @@ The installer handles VSP provisioning automatically:
|
|
|
270
270
|
- Uses the package's bundled patched VSP binary for the current platform, verified against the npm-published `dist/checksums.txt`. A checksum-anchored remote download is the fallback only when the package has no bundled asset.
|
|
271
271
|
- No Go toolchain is downloaded or required at install time; Go is only used by the repository's own `build:vsp` development script.
|
|
272
272
|
|
|
273
|
-
With `H2O_URL` set,
|
|
273
|
+
Setup detects where it is running. With `H2O_URL` set, it treats the environment as SAP Business Application Studio and opens a checkbox picker for BAS destinations, with no destinations selected by default. Use **Space** to choose destinations and **Enter** to confirm. Press **a** to toggle all destinations (select all if any are unchecked; otherwise clear the selection). Confirming with none selected removes this add-on's managed MCP entries. When the `cf` CLI 8.18 or newer is authenticated to a targeted space, setup first offers an optional import from that space's Destination service; type **y** then **Enter** to include it, or press **Enter** to skip. Accepted CF and BAS destinations appear together in the picker.
|
|
274
|
+
|
|
275
|
+
Without `H2O_URL`, interactive setup looks for local SAP GUI system configuration on macOS and Windows (for example SAP GUI landscape XML and `saplogon.ini`). SAP GUI landscape files describe SAP GUI/DIAG connectivity, not the ADT HTTP(S) endpoint, so setup asks you to confirm the ADT URL for each selected system. The generated MCP entry uses VS Code input prompts for the SAP user and password at server start; raw SAP credentials are not stored in `mcp.json`. On Windows, setup can instead write an experimental `SAP_AUTH_MODE=windows-sso` entry without username/password prompts; this requires the ADT endpoint to support HTTP Negotiate/SPNEGO. Runtime uses the bundled Windows Negotiate helper by default, or a trusted custom helper configured with `SAP_AI_DEV_TOOLKIT_NEGOTIATE_HELPER`. npm may run its install hook without an interactive terminal, even when the shell is interactive; in that case, destination selection is skipped without changing MCP config.
|
|
274
276
|
|
|
275
277
|
|
|
276
278
|
The same postinstall offers the bundled agents and skills for several AI coding harnesses in a checkbox picker. **GitHub Copilot** and **Claude Code** start pre-checked, so pressing **Enter** alone installs both; **Space** selects or deselects a harness, **a** toggles all seven, and confirming with none selected skips the step without changing files. When destination MCP setup succeeds, the generated managed MCP servers are also mirrored into selected harnesses that have a stable user-level JSON MCP configuration (Claude Code, Cursor, Gemini CLI, and Pi Coding Agent). GitHub Copilot continues to use the BAS/VS Code MCP configuration written during destination setup. With no interactive terminal, the assets are installed for GitHub Copilot only; set `SAP_AI_DEV_TOOLKIT_HARNESSES` to a comma-separated list of harness ids (`github-copilot`, `claude-code`, `codex`, `cursor`, `gemini-cli`, `opencode`, `pi-coding-agent`) to choose non-interactively.
|
|
@@ -279,9 +281,9 @@ Some current npm versions also require install hooks to be approved. If npm repo
|
|
|
279
281
|
|
|
280
282
|
The setup report uses icons and terminal colors; set `NO_COLOR=1` to disable ANSI colors. The table is a weather report, not a bouncer: green **PASS** means the ADT probe responded, red **FAIL** means it failed, and yellow **SKIPPED** means it was skipped. Probe failures do not block MCP registration or startup for destinations you select.
|
|
281
283
|
|
|
282
|
-
Run `sap-ai-dev --setup` later to change the destination selection or remove generated destination entries. Run `sap-ai-dev --setup --tools` to also choose optional companion tools. Use `--npx` to make generated
|
|
284
|
+
Run `sap-ai-dev --setup` later to change the destination selection or remove generated destination entries. Run `sap-ai-dev --setup --tools` to also choose optional companion tools. Use `--npx` to make generated destination entries start the pinned package through npm instead of relying on a global `sap-ai-dev` command; companion tools already use `npx` with their own npm packages.
|
|
283
285
|
|
|
284
|
-
If a setup step is skipped or fails, rerun it from an interactive
|
|
286
|
+
If a setup step is skipped or fails, rerun it from an interactive terminal:
|
|
285
287
|
|
|
286
288
|
```sh
|
|
287
289
|
sap-ai-dev --setup
|
|
@@ -293,7 +295,7 @@ To configure without a global install, run the guided setup directly through npm
|
|
|
293
295
|
npx --yes --ignore-scripts --package=sap-ai-dev-toolkit sap-ai-dev --setup --npx
|
|
294
296
|
```
|
|
295
297
|
|
|
296
|
-
This lists discovered systems in the same checkbox picker, with nothing selected by default. Use **Space** to choose destinations, **Enter** to confirm, and **a** to toggle all (select all if any are unchecked; otherwise clear the selection). It writes MCP entries that launch the selected servers through npx. The entries pin the package version used during setup, and `--ignore-scripts` avoids running the install-time wizard a second time. After setup, in BAS run **MCP: List Servers**, select each chosen destination, and choose **Start Server**.
|
|
298
|
+
This lists discovered systems in the same checkbox picker, with nothing selected by default. Use **Space** to choose destinations, **Enter** to confirm, and **a** to toggle all (select all if any are unchecked; otherwise clear the selection). It writes MCP entries that launch the selected servers through npx. The entries pin the package version used during setup, and `--ignore-scripts` avoids running the install-time wizard a second time. After setup, in BAS or VS Code run **MCP: List Servers**, select each chosen destination, and choose **Start Server**.
|
|
297
299
|
|
|
298
300
|
For a non-interactive installation or a platform without a published VSP asset, provide a trusted binary override:
|
|
299
301
|
|
|
@@ -747,6 +749,8 @@ The response contains a `tools` array. A `RunQuery` entry resembles this excerpt
|
|
|
747
749
|
| `SAP_AI_DEV_TOOLKIT_DESTINATION` | Comma-separated destination allowlist for normal runtime discovery. Setup clears this temporarily so it can display all eligible systems. |
|
|
748
750
|
| `SAP_AI_DEV_TOOLKIT_MODE` | VSP child mode (`expert` by default; `focused` omits `ActivateMultiple`, `GetUserTransports`, and `GetTransportInfo`). The proxy exposes its curated tool subset and local workflow tools, including `LintABAP`. |
|
|
749
751
|
| `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. |
|
|
752
|
+
| `SAP_AUTH_MODE=windows-sso` | Local SAP GUI entry mode for experimental Windows Integrated Authentication. Runtime starts a loopback ADT proxy and does not pass SAP username/password credentials to VSP. |
|
|
753
|
+
| `SAP_AI_DEV_TOOLKIT_NEGOTIATE_HELPER` / `SAP_AI_DEV_TOOLKIT_SSPI_HELPER` | Optional trusted Windows helper executable used by the local Windows SSO proxy to convert an HTTP `WWW-Authenticate: Negotiate` challenge into a Kerberos/SPNEGO token. NTLM fallback is rejected because it is connection-oriented and unsafe for this proxy shape. When unset on Windows, the bundled PowerShell SSPI helper is used. A custom helper receives one JSON line on stdin (`url`, `challenge`, `mechanism`) and prints the base64 token on stdout. |
|
|
750
754
|
| `SAP_AI_DEV_TOOLKIT_HTTP_PROXY` | Egress proxy for BAS discovery and probe traffic. Takes precedence over `HTTP_PROXY`/`http_proxy`; unset means the default BAS proxy for `.dest` hosts, empty means direct. |
|
|
751
755
|
| `SAP_AI_DEV_TOOLKIT_REQUEST_TIMEOUT_MS` | Per-request timeout for calls forwarded to a VSP child (default 600000 = 10 minutes; `0` disables). A stalled request fails with a timeout error; the child is left running. |
|
|
752
756
|
| `SAP_AI_DEV_MCP_CONFIG` | Explicit MCP configuration path; highest precedence. |
|
package/package.json
CHANGED
package/scripts/postinstall.mjs
CHANGED
|
@@ -88,7 +88,9 @@ async function installSelectedHarnessAssets(ids) {
|
|
|
88
88
|
}
|
|
89
89
|
for (const assets of results) {
|
|
90
90
|
const installed = assets.installed + assets.updated;
|
|
91
|
-
|
|
91
|
+
const removed = assets.removed || 0;
|
|
92
|
+
const cleanup = removed > 0 ? `; ${removed} duplicate or legacy file${removed === 1 ? '' : 's'} removed` : '';
|
|
93
|
+
await announce(`${assets.label}: ${installed} file${installed === 1 ? '' : 's'} installed or updated in ${assets.root}; ${assets.unchanged} already current${cleanup}.`, 'success');
|
|
92
94
|
if (assets.conflicts.length > 0) {
|
|
93
95
|
const paths = assets.conflicts.map(path => join(assets.root, path)).join(', ');
|
|
94
96
|
await announce(`${assets.label}: existing customizations were preserved; review these paths: ${paths}`, 'warning');
|
|
@@ -152,6 +154,11 @@ async function runHarnessAssetInstall() {
|
|
|
152
154
|
async function wireHarnessMcpServers(ids, servers) {
|
|
153
155
|
const entries = servers && Object.keys(servers).length ? servers : null;
|
|
154
156
|
if (!entries || !ids?.length) return;
|
|
157
|
+
const hasLocalSapGui = Object.values(entries).some(entry => entry?.env?.SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE === 'sap-gui-local');
|
|
158
|
+
if (hasLocalSapGui) {
|
|
159
|
+
await announce('Local SAP GUI MCP entries use VS Code input prompts for SAP login, so automatic wiring into non-VS Code harness MCP files was skipped. Configure those harnesses manually if they support secure runtime prompts.', 'info');
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
155
162
|
const { results, failures } = await installMcpServersForHarnesses(ids, entries, { env: runtimeEnv });
|
|
156
163
|
for (const result of results) {
|
|
157
164
|
if (!result.supported) {
|
|
@@ -190,7 +197,7 @@ export function destinationTable(destinations, registeredNames) {
|
|
|
190
197
|
const registered = registeredNames.has(generatedServerName(destination.serverName || destination.name));
|
|
191
198
|
const source = destination.source === 'cloud-foundry'
|
|
192
199
|
? `CF ${destination.cf?.destinationInstanceName || 'unknown instance'}`
|
|
193
|
-
: 'BAS';
|
|
200
|
+
: (destination.source === 'sap-gui-local' ? 'SAP GUI' : 'BAS');
|
|
194
201
|
return [
|
|
195
202
|
{ text: destination.name, output: destination.name },
|
|
196
203
|
{ text: source, output: source },
|
|
@@ -242,9 +249,10 @@ async function announceSetup(result) {
|
|
|
242
249
|
const registeredNames = new Set(servers.map(([name]) => name));
|
|
243
250
|
const entryDetails = servers.flatMap(([name, entry]) => {
|
|
244
251
|
const destination = destinationsByServer.get(name);
|
|
245
|
-
const
|
|
252
|
+
const configuredSource = brandedEnvValue(entry.env, 'DESTINATION_SOURCE') || destination?.source;
|
|
253
|
+
const source = configuredSource === 'cloud-foundry'
|
|
246
254
|
? 'Cloud Foundry'
|
|
247
|
-
: 'BAS';
|
|
255
|
+
: (configuredSource === 'sap-gui-local' ? 'Local SAP GUI' : 'BAS');
|
|
248
256
|
const destinationName = brandedEnvValue(entry.env, 'DESTINATION') || destination?.name || 'unknown';
|
|
249
257
|
const client = destination?.client || '001';
|
|
250
258
|
const authentication = destination?.authentication || 'unknown';
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# Windows Integrated Authentication helper for sap-ai-dev-toolkit.
|
|
2
|
+
# Reads one JSON object from stdin with: url, challenge, mechanism.
|
|
3
|
+
# Writes only the base64 Negotiate token to stdout.
|
|
4
|
+
# Errors are written to stderr and exit non-zero.
|
|
5
|
+
|
|
6
|
+
$ErrorActionPreference = 'Stop'
|
|
7
|
+
|
|
8
|
+
$sspiSource = @'
|
|
9
|
+
using System;
|
|
10
|
+
using System.ComponentModel;
|
|
11
|
+
using System.Runtime.InteropServices;
|
|
12
|
+
|
|
13
|
+
public static class SapAiDevSspiNegotiate
|
|
14
|
+
{
|
|
15
|
+
const int SECPKG_CRED_OUTBOUND = 2;
|
|
16
|
+
const int SECBUFFER_VERSION = 0;
|
|
17
|
+
const int SECBUFFER_TOKEN = 2;
|
|
18
|
+
const int SECURITY_NATIVE_DREP = 0x10;
|
|
19
|
+
const int SEC_E_OK = 0;
|
|
20
|
+
const int SEC_I_CONTINUE_NEEDED = 0x00090312;
|
|
21
|
+
|
|
22
|
+
[StructLayout(LayoutKind.Sequential)]
|
|
23
|
+
struct SecHandle
|
|
24
|
+
{
|
|
25
|
+
public IntPtr dwLower;
|
|
26
|
+
public IntPtr dwUpper;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
[StructLayout(LayoutKind.Sequential)]
|
|
30
|
+
struct SecBuffer
|
|
31
|
+
{
|
|
32
|
+
public int cbBuffer;
|
|
33
|
+
public int BufferType;
|
|
34
|
+
public IntPtr pvBuffer;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
[StructLayout(LayoutKind.Sequential)]
|
|
38
|
+
struct SecBufferDesc
|
|
39
|
+
{
|
|
40
|
+
public int ulVersion;
|
|
41
|
+
public int cBuffers;
|
|
42
|
+
public IntPtr pBuffers;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
[StructLayout(LayoutKind.Sequential)]
|
|
46
|
+
struct SECURITY_INTEGER
|
|
47
|
+
{
|
|
48
|
+
public uint LowPart;
|
|
49
|
+
public int HighPart;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
[DllImport("secur32.dll", CharSet = CharSet.Unicode, SetLastError = false)]
|
|
53
|
+
static extern int AcquireCredentialsHandle(
|
|
54
|
+
string pszPrincipal,
|
|
55
|
+
string pszPackage,
|
|
56
|
+
int fCredentialUse,
|
|
57
|
+
IntPtr pvLogonID,
|
|
58
|
+
IntPtr pAuthData,
|
|
59
|
+
IntPtr pGetKeyFn,
|
|
60
|
+
IntPtr pvGetKeyArgument,
|
|
61
|
+
out SecHandle phCredential,
|
|
62
|
+
out SECURITY_INTEGER ptsExpiry);
|
|
63
|
+
|
|
64
|
+
[DllImport("secur32.dll", CharSet = CharSet.Unicode, SetLastError = false)]
|
|
65
|
+
static extern int InitializeSecurityContext(
|
|
66
|
+
ref SecHandle phCredential,
|
|
67
|
+
IntPtr phContext,
|
|
68
|
+
string pszTargetName,
|
|
69
|
+
int fContextReq,
|
|
70
|
+
int Reserved1,
|
|
71
|
+
int TargetDataRep,
|
|
72
|
+
IntPtr pInput,
|
|
73
|
+
int Reserved2,
|
|
74
|
+
out SecHandle phNewContext,
|
|
75
|
+
IntPtr pOutput,
|
|
76
|
+
out int pfContextAttr,
|
|
77
|
+
out SECURITY_INTEGER ptsExpiry);
|
|
78
|
+
|
|
79
|
+
[DllImport("secur32.dll", SetLastError = false)]
|
|
80
|
+
static extern int FreeCredentialsHandle(ref SecHandle phCredential);
|
|
81
|
+
|
|
82
|
+
[DllImport("secur32.dll", SetLastError = false)]
|
|
83
|
+
static extern int DeleteSecurityContext(ref SecHandle phContext);
|
|
84
|
+
|
|
85
|
+
static void Check(int status, string operation)
|
|
86
|
+
{
|
|
87
|
+
if (status == SEC_E_OK || status == SEC_I_CONTINUE_NEEDED) return;
|
|
88
|
+
throw new Win32Exception(status, operation + " failed with SSPI status 0x" + status.ToString("X8"));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
static IntPtr BufferDesc(byte[] token, out IntPtr tokenMemory, out IntPtr bufferMemory)
|
|
92
|
+
{
|
|
93
|
+
tokenMemory = IntPtr.Zero;
|
|
94
|
+
SecBuffer buffer = new SecBuffer();
|
|
95
|
+
buffer.BufferType = SECBUFFER_TOKEN;
|
|
96
|
+
if (token != null && token.Length > 0)
|
|
97
|
+
{
|
|
98
|
+
tokenMemory = Marshal.AllocHGlobal(token.Length);
|
|
99
|
+
Marshal.Copy(token, 0, tokenMemory, token.Length);
|
|
100
|
+
buffer.cbBuffer = token.Length;
|
|
101
|
+
buffer.pvBuffer = tokenMemory;
|
|
102
|
+
}
|
|
103
|
+
bufferMemory = Marshal.AllocHGlobal(Marshal.SizeOf(typeof(SecBuffer)));
|
|
104
|
+
Marshal.StructureToPtr(buffer, bufferMemory, false);
|
|
105
|
+
SecBufferDesc desc = new SecBufferDesc();
|
|
106
|
+
desc.ulVersion = SECBUFFER_VERSION;
|
|
107
|
+
desc.cBuffers = 1;
|
|
108
|
+
desc.pBuffers = bufferMemory;
|
|
109
|
+
IntPtr descMemory = Marshal.AllocHGlobal(Marshal.SizeOf(typeof(SecBufferDesc)));
|
|
110
|
+
Marshal.StructureToPtr(desc, descMemory, false);
|
|
111
|
+
return descMemory;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
static void FreeDesc(IntPtr descMemory, IntPtr tokenMemory, IntPtr bufferMemory)
|
|
115
|
+
{
|
|
116
|
+
if (descMemory != IntPtr.Zero) Marshal.FreeHGlobal(descMemory);
|
|
117
|
+
if (bufferMemory != IntPtr.Zero) Marshal.FreeHGlobal(bufferMemory);
|
|
118
|
+
if (tokenMemory != IntPtr.Zero) Marshal.FreeHGlobal(tokenMemory);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
public static string CreateToken(string targetName, string incomingBase64)
|
|
122
|
+
{
|
|
123
|
+
SecHandle credential;
|
|
124
|
+
SECURITY_INTEGER expiry;
|
|
125
|
+
int status = AcquireCredentialsHandle(null, "Negotiate", SECPKG_CRED_OUTBOUND, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero, out credential, out expiry);
|
|
126
|
+
Check(status, "AcquireCredentialsHandle");
|
|
127
|
+
|
|
128
|
+
SecHandle context = new SecHandle();
|
|
129
|
+
IntPtr inputDesc = IntPtr.Zero, inputToken = IntPtr.Zero, inputBuffer = IntPtr.Zero;
|
|
130
|
+
IntPtr outputDesc = IntPtr.Zero, outputToken = IntPtr.Zero, outputBuffer = IntPtr.Zero;
|
|
131
|
+
try
|
|
132
|
+
{
|
|
133
|
+
byte[] incoming = null;
|
|
134
|
+
if (!String.IsNullOrWhiteSpace(incomingBase64)) incoming = Convert.FromBase64String(incomingBase64);
|
|
135
|
+
if (incoming != null && incoming.Length > 0) inputDesc = BufferDesc(incoming, out inputToken, out inputBuffer);
|
|
136
|
+
|
|
137
|
+
outputDesc = BufferDesc(new byte[12288], out outputToken, out outputBuffer);
|
|
138
|
+
int attrs;
|
|
139
|
+
status = InitializeSecurityContext(ref credential, IntPtr.Zero, targetName, 0, 0, SECURITY_NATIVE_DREP, inputDesc, 0, out context, outputDesc, out attrs, out expiry);
|
|
140
|
+
Check(status, "InitializeSecurityContext");
|
|
141
|
+
|
|
142
|
+
SecBuffer outBuffer = (SecBuffer) Marshal.PtrToStructure(outputBuffer, typeof(SecBuffer));
|
|
143
|
+
if (outBuffer.cbBuffer <= 0 || outBuffer.pvBuffer == IntPtr.Zero) throw new Exception("SSPI returned an empty Negotiate token.");
|
|
144
|
+
byte[] bytes = new byte[outBuffer.cbBuffer];
|
|
145
|
+
Marshal.Copy(outBuffer.pvBuffer, bytes, 0, bytes.Length);
|
|
146
|
+
return Convert.ToBase64String(bytes);
|
|
147
|
+
}
|
|
148
|
+
finally
|
|
149
|
+
{
|
|
150
|
+
FreeDesc(inputDesc, inputToken, inputBuffer);
|
|
151
|
+
FreeDesc(outputDesc, outputToken, outputBuffer);
|
|
152
|
+
DeleteSecurityContext(ref context);
|
|
153
|
+
FreeCredentialsHandle(ref credential);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
'@
|
|
158
|
+
|
|
159
|
+
try {
|
|
160
|
+
$raw = [Console]::In.ReadToEnd()
|
|
161
|
+
if ([string]::IsNullOrWhiteSpace($raw)) { throw 'Missing helper request on stdin.' }
|
|
162
|
+
$request = $raw | ConvertFrom-Json
|
|
163
|
+
if (-not $request.url) { throw 'Missing target URL.' }
|
|
164
|
+
|
|
165
|
+
$uri = [Uri] [string] $request.url
|
|
166
|
+
if ($uri.Scheme -ne 'http' -and $uri.Scheme -ne 'https') { throw 'Target URL must use HTTP or HTTPS.' }
|
|
167
|
+
if ($uri.UserInfo) { throw 'Target URL must not contain user information.' }
|
|
168
|
+
|
|
169
|
+
Add-Type -TypeDefinition $sspiSource -Language CSharp
|
|
170
|
+
|
|
171
|
+
$incoming = $null
|
|
172
|
+
$challenge = [string] $request.challenge
|
|
173
|
+
if ($challenge -match '(?i)Negotiate\s+([^,\s]+)') { $incoming = $Matches[1] }
|
|
174
|
+
$targetName = 'HTTP/' + $uri.DnsSafeHost
|
|
175
|
+
$token = [SapAiDevSspiNegotiate]::CreateToken($targetName, $incoming)
|
|
176
|
+
if ([string]::IsNullOrWhiteSpace($token)) { throw 'Negotiate token generation returned an empty token.' }
|
|
177
|
+
[Console]::Out.WriteLine($token.Trim())
|
|
178
|
+
exit 0
|
|
179
|
+
} catch {
|
|
180
|
+
[Console]::Error.WriteLine($_.Exception.Message)
|
|
181
|
+
exit 1
|
|
182
|
+
}
|
|
@@ -69,17 +69,43 @@ async function planDestinations(harness, files) {
|
|
|
69
69
|
for (const file of files) {
|
|
70
70
|
const raw = await readFile(file.source);
|
|
71
71
|
if (!file.target.startsWith(`agents${sep}`)) {
|
|
72
|
-
destinations.push({ target: file.target, content: raw });
|
|
72
|
+
destinations.push({ target: file.target, content: raw, aliases: [] });
|
|
73
73
|
continue;
|
|
74
74
|
}
|
|
75
75
|
if (!harness.supportsAgents) continue;
|
|
76
|
-
const
|
|
76
|
+
const sourceName = basename(file.target);
|
|
77
|
+
const fileName = harness.agentFileName ? harness.agentFileName(sourceName) : sourceName;
|
|
78
|
+
const prefix = harness.agentTargetPrefix || 'agents';
|
|
79
|
+
const target = join(prefix, fileName);
|
|
80
|
+
const aliasCandidates = [
|
|
81
|
+
file.target,
|
|
82
|
+
join(prefix, sourceName),
|
|
83
|
+
join('agents', fileName)
|
|
84
|
+
];
|
|
85
|
+
const aliases = [...new Set(aliasCandidates.filter(alias => alias !== target))];
|
|
77
86
|
const content = harness.agentDocument ? Buffer.from(harness.agentDocument(raw.toString('utf8')), 'utf8') : raw;
|
|
78
|
-
destinations.push({ target
|
|
87
|
+
destinations.push({ target, content, aliases });
|
|
79
88
|
}
|
|
80
89
|
return destinations;
|
|
81
90
|
}
|
|
82
91
|
|
|
92
|
+
async function reconcileRemovableFile({ targetRoot, target, contentHash, previousFiles }) {
|
|
93
|
+
const path = join(targetRoot, target);
|
|
94
|
+
let existing;
|
|
95
|
+
try {
|
|
96
|
+
existing = await readFile(path);
|
|
97
|
+
} catch (error) {
|
|
98
|
+
if (error.code === 'ENOENT') return 'missing';
|
|
99
|
+
throw error;
|
|
100
|
+
}
|
|
101
|
+
const existingHash = digest(existing);
|
|
102
|
+
if (previousFiles[target] === existingHash || existingHash === contentHash) {
|
|
103
|
+
await rm(path, { force: true });
|
|
104
|
+
return 'removed';
|
|
105
|
+
}
|
|
106
|
+
return 'conflict';
|
|
107
|
+
}
|
|
108
|
+
|
|
83
109
|
export async function installUserAssets({ harness, home = process.env.HOME || homedir(), root = packageRoot, env = process.env } = {}) {
|
|
84
110
|
const destinations = await planDestinations(harness, await collectSourceFiles(root));
|
|
85
111
|
const targetRoot = harnessRoot(harness, { home, env });
|
|
@@ -89,15 +115,22 @@ export async function installUserAssets({ harness, home = process.env.HOME || ho
|
|
|
89
115
|
const legacyFiles = harness.legacyManifest ? await readManagedFiles(legacyManifestPath) : {};
|
|
90
116
|
const currentFiles = await readManagedFiles(manifestPath);
|
|
91
117
|
const previousFiles = { ...legacyFiles, ...currentFiles };
|
|
92
|
-
const managedFiles = {
|
|
118
|
+
const managedFiles = {};
|
|
119
|
+
const activeTargets = new Set(destinations.flatMap(file => [file.target, ...file.aliases]));
|
|
93
120
|
const conflicts = [];
|
|
94
121
|
let installed = 0;
|
|
95
122
|
let updated = 0;
|
|
96
123
|
let unchanged = 0;
|
|
124
|
+
let removed = 0;
|
|
97
125
|
|
|
98
126
|
for (const file of destinations) {
|
|
99
127
|
const destination = join(targetRoot, file.target);
|
|
100
128
|
const contentHash = digest(file.content);
|
|
129
|
+
for (const alias of file.aliases) {
|
|
130
|
+
const status = await reconcileRemovableFile({ targetRoot, target: alias, contentHash, previousFiles });
|
|
131
|
+
if (status === 'removed') removed += 1;
|
|
132
|
+
if (status === 'conflict') conflicts.push(alias);
|
|
133
|
+
}
|
|
101
134
|
await mkdir(dirname(destination), { recursive: true, mode: 0o755 });
|
|
102
135
|
|
|
103
136
|
let existing;
|
|
@@ -129,9 +162,16 @@ export async function installUserAssets({ harness, home = process.env.HOME || ho
|
|
|
129
162
|
}
|
|
130
163
|
}
|
|
131
164
|
|
|
165
|
+
for (const target of Object.keys(previousFiles)) {
|
|
166
|
+
if (activeTargets.has(target) || managedFiles[target]) continue;
|
|
167
|
+
const status = await reconcileRemovableFile({ targetRoot, target, contentHash: '', previousFiles });
|
|
168
|
+
if (status === 'removed') removed += 1;
|
|
169
|
+
if (status === 'conflict') conflicts.push(target);
|
|
170
|
+
}
|
|
171
|
+
|
|
132
172
|
await replaceFile(manifestPath, `${JSON.stringify({ version: 1, files: managedFiles }, null, 2)}\n`);
|
|
133
173
|
if (harness.legacyManifest) await rm(legacyManifestPath, { force: true });
|
|
134
|
-
return { harness: harness.id, label: harness.label, root: targetRoot, installed, updated, unchanged, conflicts };
|
|
174
|
+
return { harness: harness.id, label: harness.label, root: targetRoot, installed, updated, unchanged, removed, conflicts };
|
|
135
175
|
}
|
|
136
176
|
|
|
137
177
|
export async function installUserAssetsForHarnesses(ids, { home, root = packageRoot, env = process.env } = {}) {
|
package/src/launcher.mjs
CHANGED
|
@@ -4,11 +4,13 @@ import { readFile } from 'node:fs/promises';
|
|
|
4
4
|
import { dirname, join } from 'node:path';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
6
|
import { discoverDestinations, remediation, slugifyDestination, statusRows } from './bas-discovery.mjs';
|
|
7
|
+
import { discoverSapGuiSystems } from './local-sap-gui.mjs';
|
|
7
8
|
import { findBinary } from './binary.mjs';
|
|
8
9
|
import { MCPProxy } from './mcp-proxy.mjs';
|
|
9
10
|
import { installMcpConfig, repairManagedMcpConfig } from './mcp-config.mjs';
|
|
10
11
|
import { runSetup } from './setup.mjs';
|
|
11
12
|
import { resolveConfiguredCloudFoundryDestination } from './cf-destination.mjs';
|
|
13
|
+
import { createWindowsSsoAdtProxy } from './windows-sso-adt-proxy.mjs';
|
|
12
14
|
import { withBrandedEnvironment } from './branding.mjs';
|
|
13
15
|
import { redactText } from './redact.mjs';
|
|
14
16
|
const root = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
@@ -25,7 +27,7 @@ function usage() {
|
|
|
25
27
|
return [
|
|
26
28
|
'Usage: sap-ai-dev [options]',
|
|
27
29
|
'',
|
|
28
|
-
'With H2O_URL set
|
|
30
|
+
'With H2O_URL set, starts the SAP AI Dev Toolkit stdio server for selected BAS destinations; with local SAP GUI setup, starts the selected local ADT system.',
|
|
29
31
|
'Options:',
|
|
30
32
|
' --setup Configure generated BAS MCP servers',
|
|
31
33
|
' --tools Also offer full-stack SAP companion MCP servers (use with --setup)',
|
|
@@ -161,9 +163,46 @@ async function binaryOrError() {
|
|
|
161
163
|
async function discoverForCommand() {
|
|
162
164
|
const env = { ...runtimeEnv };
|
|
163
165
|
if (env.SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE === 'cloud-foundry') env.SAP_AI_DEV_TOOLKIT_DESTINATION = '';
|
|
166
|
+
if (!env.H2O_URL) {
|
|
167
|
+
const systems = await discoverSapGuiSystems({ env });
|
|
168
|
+
if (!systems.length) throw new Error('H2O_URL is required for BAS destination discovery');
|
|
169
|
+
return systems;
|
|
170
|
+
}
|
|
164
171
|
return discoverDestinations({ env });
|
|
165
172
|
}
|
|
166
173
|
|
|
174
|
+
async function configuredLocalSapGuiDestination(env) {
|
|
175
|
+
const name = env.SAP_AI_DEV_TOOLKIT_DESTINATION || env.SAP_SYSTEM_ID || 'local-sap';
|
|
176
|
+
if (!env.SAP_URL) throw new Error('SAP_URL is required for local SAP GUI MCP entries. Rerun sap-ai-dev --setup and enter the ADT URL.');
|
|
177
|
+
const authMode = String(env.SAP_AUTH_MODE || '').toLowerCase();
|
|
178
|
+
if (authMode === 'windows-sso') {
|
|
179
|
+
const route = await createWindowsSsoAdtProxy({ destinationUrl: env.SAP_URL, env });
|
|
180
|
+
return {
|
|
181
|
+
source: 'sap-gui-local',
|
|
182
|
+
name,
|
|
183
|
+
url: route.url,
|
|
184
|
+
backendUrl: env.SAP_URL,
|
|
185
|
+
client: env.SAP_CLIENT || '001',
|
|
186
|
+
authentication: 'WindowsSSO',
|
|
187
|
+
childEnv: {},
|
|
188
|
+
close: route.close,
|
|
189
|
+
probe: { status: 'configured', available: true }
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
return {
|
|
193
|
+
source: 'sap-gui-local',
|
|
194
|
+
name,
|
|
195
|
+
url: env.SAP_URL,
|
|
196
|
+
client: env.SAP_CLIENT || '001',
|
|
197
|
+
authentication: 'Basic',
|
|
198
|
+
childEnv: {
|
|
199
|
+
SAP_USER: env.SAP_USER || env.SAP_USERNAME || '',
|
|
200
|
+
SAP_PASSWORD: env.SAP_PASSWORD || env.SAP_PASS || ''
|
|
201
|
+
},
|
|
202
|
+
probe: { status: 'configured', available: true }
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
167
206
|
async function main() {
|
|
168
207
|
const setup = hasFlag('--setup');
|
|
169
208
|
const check = hasFlag('--check');
|
|
@@ -184,7 +223,9 @@ async function main() {
|
|
|
184
223
|
try {
|
|
185
224
|
const destinations = runtimeEnv.SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE === 'cloud-foundry'
|
|
186
225
|
? [await resolveConfiguredCloudFoundryDestination({ env: runtimeEnv })]
|
|
187
|
-
:
|
|
226
|
+
: (runtimeEnv.SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE === 'sap-gui-local'
|
|
227
|
+
? [await configuredLocalSapGuiDestination(runtimeEnv)]
|
|
228
|
+
: await discoverForCommand());
|
|
188
229
|
report = await runDoctor(destinations);
|
|
189
230
|
} catch (error) {
|
|
190
231
|
report = { ok: false, destinations: 0, checks: [doctorRow('-', 'destination discovery', 'failed', redactText(error.message || error).slice(0, 300))] };
|
|
@@ -219,8 +260,10 @@ async function main() {
|
|
|
219
260
|
return;
|
|
220
261
|
}
|
|
221
262
|
|
|
222
|
-
if (
|
|
223
|
-
const destination =
|
|
263
|
+
if (['cloud-foundry', 'sap-gui-local'].includes(runtimeEnv.SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE)) {
|
|
264
|
+
const destination = runtimeEnv.SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE === 'cloud-foundry'
|
|
265
|
+
? await resolveConfiguredCloudFoundryDestination({ env: runtimeEnv })
|
|
266
|
+
: await configuredLocalSapGuiDestination(runtimeEnv);
|
|
224
267
|
let proxy;
|
|
225
268
|
try {
|
|
226
269
|
const binary = await binaryOrError();
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { platform, homedir } from 'node:os';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
4
|
+
|
|
5
|
+
function text(value) { return value == null ? '' : String(value).trim(); }
|
|
6
|
+
function decodeXml(value) {
|
|
7
|
+
return text(value)
|
|
8
|
+
.replace(/"/g, '"')
|
|
9
|
+
.replace(/'/g, "'")
|
|
10
|
+
.replace(/</g, '<')
|
|
11
|
+
.replace(/>/g, '>')
|
|
12
|
+
.replace(/&/g, '&');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function exists(path) {
|
|
16
|
+
try { await stat(path); return true; }
|
|
17
|
+
catch (error) { if (error?.code === 'ENOENT') return false; throw error; }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function unique(values) {
|
|
21
|
+
return [...new Set(values.filter(Boolean))];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function sapGuiLandscapeCandidates(env = process.env) {
|
|
25
|
+
const home = env.HOME || env.USERPROFILE || homedir();
|
|
26
|
+
const appData = env.APPDATA || (env.USERPROFILE ? join(env.USERPROFILE, 'AppData', 'Roaming') : '');
|
|
27
|
+
const candidates = [];
|
|
28
|
+
if (platform() === 'win32' || appData) {
|
|
29
|
+
candidates.push(
|
|
30
|
+
join(appData || '', 'SAP', 'Common', 'SAPUILandscape.xml'),
|
|
31
|
+
join(appData || '', 'SAP', 'Common', 'SAPUILandscapeGlobal.xml'),
|
|
32
|
+
join(appData || '', 'SAP', 'Common', 'saplogon.ini')
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
candidates.push(
|
|
36
|
+
join(home, 'Library', 'Preferences', 'SAP', 'SAPGUILandscape.xml'),
|
|
37
|
+
join(home, 'Library', 'Preferences', 'SAP', 'SAPUILandscape.xml'),
|
|
38
|
+
join(home, 'Library', 'Preferences', 'SAP', 'connections'),
|
|
39
|
+
join(home, '.SAPGUI', 'SAPGUILandscape.xml')
|
|
40
|
+
);
|
|
41
|
+
return unique(candidates);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function attributes(fragment) {
|
|
45
|
+
const result = {};
|
|
46
|
+
for (const match of fragment.matchAll(/([A-Za-z_:][\w:.-]*)\s*=\s*"([^"]*)"|([A-Za-z_:][\w:.-]*)\s*=\s*'([^']*)'/g)) {
|
|
47
|
+
result[String(match[1] || match[3]).toLowerCase()] = decodeXml(match[2] ?? match[4]);
|
|
48
|
+
}
|
|
49
|
+
return result;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function ssoHintFrom(record) {
|
|
53
|
+
const ssoKeys = ['sncname', 'sncpartnername', 'sncmode', 'snc_mode', 'sncqop', 'snc_qop', 'use_sso', 'sso'];
|
|
54
|
+
if (ssoKeys.some(key => {
|
|
55
|
+
const value = text(record[key]);
|
|
56
|
+
return value && !['0', 'false', 'no', 'off'].includes(value.toLowerCase());
|
|
57
|
+
})) return true;
|
|
58
|
+
const values = [record.authentication, record.auth].map(text).filter(Boolean);
|
|
59
|
+
return values.some(value => /snc|sso|kerberos|spnego|secure\s*login/i.test(value) || ['1', 'true', 'yes'].includes(value.toLowerCase()));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function instanceFrom(value) {
|
|
63
|
+
const candidates = [value?.instance, value?.instancenumber, value?.instanceNumber, value?.sysnr, value?.systemnumber];
|
|
64
|
+
for (const candidate of candidates) {
|
|
65
|
+
const match = text(candidate).match(/^\d{2}$/);
|
|
66
|
+
if (match) return match[0];
|
|
67
|
+
}
|
|
68
|
+
return '';
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function normalizeRecord(record, sourcePath) {
|
|
72
|
+
const type = text(record.type || record.kind).toLowerCase();
|
|
73
|
+
if (type && !['sapgui', 'r/3', 'sap'].some(token => type.includes(token))) return null;
|
|
74
|
+
const name = text(record.name || record.description || record.desc || record.systemid || record.sid || record.uuid);
|
|
75
|
+
const address = text(record.server || record.host || record.applicationserver || record.messageserver || record.mshost);
|
|
76
|
+
// SAP GUI ports are DIAG dispatcher ports, not HTTP ports.
|
|
77
|
+
const endpoint = address.match(/^(\[[^\]]+\]|[^:/]+):(\d+)$/);
|
|
78
|
+
const host = endpoint ? endpoint[1] : address;
|
|
79
|
+
const systemId = text(record.systemid || record.sid || record.r3name).toUpperCase();
|
|
80
|
+
if (!name || !host) return null;
|
|
81
|
+
const port = endpoint ? Number(endpoint[2]) : 0;
|
|
82
|
+
const instance = instanceFrom(record) || (port >= 3200 && port <= 3299 ? String(port - 3200).padStart(2, '0') : '');
|
|
83
|
+
const client = text(record.client || record.sapclient) || '001';
|
|
84
|
+
return {
|
|
85
|
+
source: 'sap-gui-local',
|
|
86
|
+
name,
|
|
87
|
+
serverName: name,
|
|
88
|
+
host,
|
|
89
|
+
systemId,
|
|
90
|
+
instance,
|
|
91
|
+
client,
|
|
92
|
+
authentication: ssoHintFrom(record) ? 'Basic/SSO hint' : 'Basic',
|
|
93
|
+
authMode: 'basic',
|
|
94
|
+
ssoHint: ssoHintFrom(record),
|
|
95
|
+
proxyType: 'Internet',
|
|
96
|
+
landscapePath: sourcePath,
|
|
97
|
+
probe: { status: 'needs-adt-url' }
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function parseLandscapeXml(content, sourcePath) {
|
|
102
|
+
const records = [];
|
|
103
|
+
// Comments must not turn deleted entries into selectable systems.
|
|
104
|
+
content = content.replace(/<!--[\s\S]*?-->/g, '');
|
|
105
|
+
const messageServers = new Map();
|
|
106
|
+
for (const match of content.matchAll(/<Messageserver\b([^>]*)>/gi)) {
|
|
107
|
+
const record = attributes(match[1]);
|
|
108
|
+
if (record.uuid) messageServers.set(record.uuid, record);
|
|
109
|
+
}
|
|
110
|
+
for (const match of content.matchAll(/<Service\b([^>]*)\/?>(?:\s*<\/Service>)?/gi)) {
|
|
111
|
+
const record = attributes(match[1]);
|
|
112
|
+
if (record.msid) {
|
|
113
|
+
const messageServer = messageServers.get(record.msid);
|
|
114
|
+
// For load-balanced entries `server` is a logon group, not a host.
|
|
115
|
+
if (!messageServer?.host) continue;
|
|
116
|
+
record.server = messageServer.host;
|
|
117
|
+
}
|
|
118
|
+
const normalized = normalizeRecord(record, sourcePath);
|
|
119
|
+
if (normalized) records.push(normalized);
|
|
120
|
+
}
|
|
121
|
+
// SAP GUI for Java files may use generic Item/Connection elements.
|
|
122
|
+
for (const match of content.matchAll(/<(?:Item|Connection)\b([^>]*)\/?>(?:\s*<\/(?:Item|Connection)>)?/gi)) {
|
|
123
|
+
const normalized = normalizeRecord(attributes(match[1]), sourcePath);
|
|
124
|
+
if (normalized) records.push(normalized);
|
|
125
|
+
}
|
|
126
|
+
return records;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function parseSapLogonIni(content, sourcePath) {
|
|
130
|
+
const sections = new Map();
|
|
131
|
+
let section = '';
|
|
132
|
+
for (const line of content.split(/\r?\n/)) {
|
|
133
|
+
const header = line.match(/^\s*\[(.+)]\s*$/);
|
|
134
|
+
if (header) { section = header[1].toLowerCase(); if (!sections.has(section)) sections.set(section, {}); continue; }
|
|
135
|
+
const entry = line.match(/^\s*([^=]+?)\s*=\s*(.*?)\s*$/);
|
|
136
|
+
if (entry && section) sections.get(section)[entry[1].toLowerCase()] = entry[2];
|
|
137
|
+
}
|
|
138
|
+
const descriptions = sections.get('description') || sections.get('descriptions') || {};
|
|
139
|
+
const servers = sections.get('server') || sections.get('servers') || {};
|
|
140
|
+
const systems = sections.get('system') || sections.get('systems') || {};
|
|
141
|
+
const databases = sections.get('database') || sections.get('databases') || {};
|
|
142
|
+
const clients = sections.get('client') || sections.get('clients') || {};
|
|
143
|
+
const instances = sections.get('systemnumber') || {};
|
|
144
|
+
const sncNames = sections.get('sncname') || {};
|
|
145
|
+
const sncModes = sections.get('snc') || sections.get('sncmode') || {};
|
|
146
|
+
const keys = unique([...Object.keys(descriptions), ...Object.keys(servers)]);
|
|
147
|
+
return keys.map(key => normalizeRecord({
|
|
148
|
+
name: descriptions[key] || systems[key] || key,
|
|
149
|
+
server: servers[key],
|
|
150
|
+
systemid: databases[key] || systems[key],
|
|
151
|
+
client: clients[key],
|
|
152
|
+
systemnumber: instances[key],
|
|
153
|
+
sncname: sncNames[key],
|
|
154
|
+
sncmode: sncModes[key]
|
|
155
|
+
}, sourcePath)).filter(Boolean);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function readGuiFile(path) {
|
|
159
|
+
const bytes = await readFile(path);
|
|
160
|
+
if (bytes[0] === 0xff && bytes[1] === 0xfe) return bytes.subarray(2).toString('utf16le');
|
|
161
|
+
if (bytes[0] === 0xfe && bytes[1] === 0xff) return bytes.subarray(2).swap16().toString('utf16le');
|
|
162
|
+
return bytes.toString('utf8').replace(/^\uFEFF/, '');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function parseConnectionsFile(path) {
|
|
166
|
+
const content = await readGuiFile(path);
|
|
167
|
+
if (content.includes('<')) return parseLandscapeXml(content, path);
|
|
168
|
+
return content.split(/\r?\n/).map((line, index) => {
|
|
169
|
+
const parts = line.split(/[;,\t]/).map(text).filter(Boolean);
|
|
170
|
+
if (parts.length < 2) return null;
|
|
171
|
+
return normalizeRecord({ name: parts[0] || `SAP GUI ${index + 1}`, server: parts.find(part => /[A-Za-z]/.test(part) && part.includes('.')) || parts[1], systemid: parts.find(part => /^[A-Za-z0-9]{3}$/.test(part)) }, path);
|
|
172
|
+
}).filter(Boolean);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export async function discoverSapGuiSystems({ env = process.env, paths = sapGuiLandscapeCandidates(env) } = {}) {
|
|
176
|
+
const systems = [];
|
|
177
|
+
for (const path of unique(paths)) {
|
|
178
|
+
if (!path) continue;
|
|
179
|
+
try {
|
|
180
|
+
if (!await exists(path)) continue;
|
|
181
|
+
const info = await stat(path);
|
|
182
|
+
if (info.isDirectory()) {
|
|
183
|
+
for (const name of await readdir(path)) {
|
|
184
|
+
const child = join(path, name);
|
|
185
|
+
try {
|
|
186
|
+
if ((await stat(child)).isFile()) systems.push(...await parseConnectionsFile(child));
|
|
187
|
+
} catch {
|
|
188
|
+
// One unreadable child must not hide the rest of the directory.
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
} else if (/saplogon\.ini$/i.test(path)) {
|
|
192
|
+
systems.push(...parseSapLogonIni(await readGuiFile(path), path));
|
|
193
|
+
} else {
|
|
194
|
+
systems.push(...await parseConnectionsFile(path));
|
|
195
|
+
}
|
|
196
|
+
} catch {
|
|
197
|
+
// Ignore unreadable or unfamiliar SAP GUI files; discovery is best-effort.
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
const seen = new Set();
|
|
201
|
+
return systems.filter(system => {
|
|
202
|
+
const key = `${system.name}\0${system.host}\0${system.systemId}\0${system.instance}\0${system.client}`.toLowerCase();
|
|
203
|
+
if (seen.has(key)) return false;
|
|
204
|
+
seen.add(key);
|
|
205
|
+
return true;
|
|
206
|
+
}).sort((a, b) => a.name.localeCompare(b.name));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function defaultAdtUrl(system) {
|
|
210
|
+
const host = text(system.host);
|
|
211
|
+
const instance = text(system.instance);
|
|
212
|
+
if (!host) return '';
|
|
213
|
+
// Router strings and other DIAG routes cannot be used as HTTP hosts.
|
|
214
|
+
if (/[\s/@?#]/.test(host)) return '';
|
|
215
|
+
const authority = host.includes(':') && !host.startsWith('[') ? `[${host}]` : host;
|
|
216
|
+
return /^\d{2}$/.test(instance) ? `https://${authority}:443${instance}` : `https://${authority}`;
|
|
217
|
+
}
|
package/src/mcp-config.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { constants } from 'node:fs';
|
|
3
3
|
import { access, mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
4
|
-
import { homedir } from 'node:os';
|
|
4
|
+
import { homedir, platform } from 'node:os';
|
|
5
5
|
import { basename, delimiter, dirname, join, resolve } from 'node:path';
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
7
|
import { brandedEnvValue } from './branding.mjs';
|
|
@@ -103,11 +103,22 @@ export async function resolveMcpConfigPath(env = process.env) {
|
|
|
103
103
|
.find(value => typeof value === 'string' && value.length > 0);
|
|
104
104
|
if (configuredPath) return configuredPath;
|
|
105
105
|
const home = env.HOME || homedir();
|
|
106
|
-
const
|
|
106
|
+
const appData = env.APPDATA || (env.USERPROFILE ? join(env.USERPROFILE, 'AppData', 'Roaming') : '');
|
|
107
|
+
const localVsCodeCandidates = [
|
|
108
|
+
...(appData ? [join(appData, 'Code', 'User', 'mcp.json'), join(appData, 'Code - Insiders', 'User', 'mcp.json')] : []),
|
|
109
|
+
...(platform() === 'darwin' ? [
|
|
110
|
+
join(home, 'Library', 'Application Support', 'Code', 'User', 'mcp.json'),
|
|
111
|
+
join(home, 'Library', 'Application Support', 'Code - Insiders', 'User', 'mcp.json')
|
|
112
|
+
] : []),
|
|
113
|
+
join(home, '.config', 'Code', 'User', 'mcp.json'),
|
|
114
|
+
join(home, '.config', 'Code - Insiders', 'User', 'mcp.json')
|
|
115
|
+
];
|
|
116
|
+
const basCandidates = [
|
|
107
117
|
join(home, '.vscode', 'data', 'User', 'mcp.json'),
|
|
108
118
|
join(home, '.vscode-server', 'data', 'User', 'mcp.json'),
|
|
109
119
|
join(home, '.code-server', 'data', 'User', 'mcp.json')
|
|
110
120
|
];
|
|
121
|
+
const candidates = env.H2O_URL ? [...basCandidates, ...localVsCodeCandidates] : [...localVsCodeCandidates, ...basCandidates];
|
|
111
122
|
for (const candidate of candidates) if (await exists(candidate)) return candidate;
|
|
112
123
|
return candidates[0];
|
|
113
124
|
}
|
|
@@ -141,15 +152,17 @@ export function buildSapDevelopmentMcpEntries(serverIds = SAP_DEVELOPMENT_MCP_SE
|
|
|
141
152
|
|
|
142
153
|
export function buildMcpEntries(destinations, env = process.env) {
|
|
143
154
|
const h2oUrl = env.H2O_URL;
|
|
144
|
-
|
|
155
|
+
const hasNonLocal = destinations.some(destination => destination?.source !== 'sap-gui-local');
|
|
156
|
+
if (destinations.length && hasNonLocal && !h2oUrl) throw new Error('H2O_URL is required to write SAP AI Dev Toolkit configuration');
|
|
145
157
|
const entries = Object.create(null);
|
|
146
158
|
const selected = [...destinations].sort((a, b) => String(a.serverName || a.name).localeCompare(String(b.serverName || b.name)));
|
|
147
159
|
for (const destination of selected) {
|
|
148
160
|
const isCf = destination.source === 'cloud-foundry';
|
|
161
|
+
const isLocalSapGui = destination.source === 'sap-gui-local';
|
|
149
162
|
const name = generatedServerName(isCf ? destination.serverName : destination.name);
|
|
150
163
|
if (Object.hasOwn(entries, name)) throw new Error(`Duplicate MCP destination server name: ${name} (two destination names normalize to the same lowercase server name)`);
|
|
151
164
|
const entryEnv = {
|
|
152
|
-
H2O_URL: String(h2oUrl),
|
|
165
|
+
...(h2oUrl ? { H2O_URL: String(h2oUrl) } : {}),
|
|
153
166
|
SAP_ALLOW_TRANSPORTABLE_EDITS: 'true'
|
|
154
167
|
};
|
|
155
168
|
if (isCf) {
|
|
@@ -171,6 +184,16 @@ export function buildMcpEntries(destinations, env = process.env) {
|
|
|
171
184
|
BAS_CF_CONNECTIVITY_KEY: String(cf.connectivityKeyName)
|
|
172
185
|
});
|
|
173
186
|
}
|
|
187
|
+
} else if (isLocalSapGui) {
|
|
188
|
+
if (!destination.url) throw new Error(`Local SAP GUI destination "${destination.name}" is missing an ADT URL`);
|
|
189
|
+
Object.assign(entryEnv, {
|
|
190
|
+
SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE: 'sap-gui-local',
|
|
191
|
+
SAP_AI_DEV_TOOLKIT_DESTINATION: String(destination.name),
|
|
192
|
+
SAP_URL: String(destination.url),
|
|
193
|
+
SAP_CLIENT: String(destination.client || '001'),
|
|
194
|
+
...(destination.systemId ? { SAP_SYSTEM_ID: String(destination.systemId) } : {}),
|
|
195
|
+
...(destination.childEnv || {})
|
|
196
|
+
});
|
|
174
197
|
} else {
|
|
175
198
|
entryEnv.SAP_AI_DEV_TOOLKIT_DESTINATION = String(destination.name);
|
|
176
199
|
}
|
|
@@ -316,6 +339,12 @@ export async function installMcpConfig(destinationsOrOptions, options = {}) {
|
|
|
316
339
|
...buildMcpEntries(destinations, env),
|
|
317
340
|
...buildSapDevelopmentMcpEntries(options.sapDevelopmentServers || [], { packageVersions: companionVersions })
|
|
318
341
|
};
|
|
342
|
+
const generatedInputs = new Map();
|
|
343
|
+
for (const destination of destinations) {
|
|
344
|
+
for (const input of Array.isArray(destination?.inputs) ? destination.inputs : []) {
|
|
345
|
+
if (input?.id) generatedInputs.set(input.id, input);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
319
348
|
const launcherCommand = options.command || (destinations.length ? await resolveMcpServerCommand(env) : null);
|
|
320
349
|
if (launcherCommand) {
|
|
321
350
|
for (const entry of Object.values(generated)) {
|
|
@@ -325,12 +354,19 @@ export async function installMcpConfig(destinationsOrOptions, options = {}) {
|
|
|
325
354
|
}
|
|
326
355
|
}
|
|
327
356
|
const servers = Object.create(null);
|
|
357
|
+
const managedInputIds = new Set();
|
|
328
358
|
for (const [name, entry] of Object.entries(config.servers)) {
|
|
329
359
|
const managed = isPackageLauncher(entry)
|
|
330
360
|
&& typeof destinationValue(entry?.env) === 'string'
|
|
331
361
|
&& (destinationSource(entry.env) === 'cloud-foundry'
|
|
332
362
|
? typeof entry.env.BAS_CF_DESTINATION_KEY === 'string'
|
|
333
|
-
: destinationSource(entry.env) === undefined);
|
|
363
|
+
: (destinationSource(entry.env) === undefined || destinationSource(entry.env) === 'sap-gui-local'));
|
|
364
|
+
if (managed && destinationSource(entry.env) === 'sap-gui-local') {
|
|
365
|
+
for (const value of Object.values(entry.env || {})) {
|
|
366
|
+
const match = typeof value === 'string' ? value.match(/^\$\{input:([^}]+)}$/) : null;
|
|
367
|
+
if (match) managedInputIds.add(match[1]);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
334
370
|
if (!LEGACY_MCP_SERVER_PREFIXES.some(prefix => name.startsWith(prefix)) && !managed && !isCompanionServer(entry)) servers[name] = entry;
|
|
335
371
|
}
|
|
336
372
|
for (const name of Object.keys(generated)) {
|
|
@@ -339,6 +375,10 @@ export async function installMcpConfig(destinationsOrOptions, options = {}) {
|
|
|
339
375
|
}
|
|
340
376
|
}
|
|
341
377
|
config.servers = { ...servers, ...generated };
|
|
378
|
+
if (generatedInputs.size || managedInputIds.size) {
|
|
379
|
+
const existingInputs = Array.isArray(config.inputs) ? config.inputs.filter(input => !generatedInputs.has(input?.id) && !managedInputIds.has(input?.id)) : [];
|
|
380
|
+
config.inputs = [...existingInputs, ...generatedInputs.values()];
|
|
381
|
+
}
|
|
342
382
|
await writeConfig(path, config);
|
|
343
383
|
return { path, servers: generated };
|
|
344
384
|
}
|
package/src/mcp-proxy.mjs
CHANGED
|
@@ -168,6 +168,7 @@ function closeDestinationRoute(destination) {
|
|
|
168
168
|
|
|
169
169
|
|
|
170
170
|
function useProxyAuthentication(destination) {
|
|
171
|
+
if (destination.source === 'sap-gui-local') return destination.authentication === 'WindowsSSO';
|
|
171
172
|
return destination.source !== 'cloud-foundry' || destination.authentication === 'PrincipalPropagation';
|
|
172
173
|
}
|
|
173
174
|
|
package/src/setup.mjs
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { createInterface } from 'node:readline/promises';
|
|
2
2
|
import { stdin, stdout } from 'node:process';
|
|
3
|
-
import {
|
|
3
|
+
import { platform } from 'node:os';
|
|
4
|
+
import { checkboxPrompt, colorText, formatStatus, textPrompt } from './terminal-ui.mjs';
|
|
4
5
|
import { discoverDestinations, remediation } from './bas-discovery.mjs';
|
|
6
|
+
import { defaultAdtUrl, discoverSapGuiSystems } from './local-sap-gui.mjs';
|
|
5
7
|
import { discoverCloudFoundryDestinations, getCloudFoundryTarget, deleteManagedCloudFoundryServiceKeys, findOrphanedCloudFoundryServiceKeys } from './cf-destination.mjs';
|
|
6
8
|
import { SAP_DEVELOPMENT_MCP_SERVERS, collectCloudFoundryKeyReferencesFromAllEntries, collectManagedCloudFoundryKeyReferences, installMcpConfig, readMcpConfig, resolveMcpConfigPath } from './mcp-config.mjs';
|
|
7
9
|
|
|
@@ -11,6 +13,24 @@ function print(output, message) {
|
|
|
11
13
|
output.write(`${message}\n`);
|
|
12
14
|
}
|
|
13
15
|
|
|
16
|
+
function windowsSsoSetupAvailable(env = process.env) {
|
|
17
|
+
return platform() === 'win32' || env.SAP_AI_DEV_TOOLKIT_ENABLE_WINDOWS_SSO_SETUP === 'true';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function chooseLocalAuthMode(destination, { input = stdin, output = stdout, env = process.env } = {}) {
|
|
21
|
+
if (!windowsSsoSetupAvailable(env)) return 'basic';
|
|
22
|
+
const answer = await textPrompt({
|
|
23
|
+
message: `Authentication for ${destination.name} (password/sso)`,
|
|
24
|
+
placeholder: 'password',
|
|
25
|
+
required: false,
|
|
26
|
+
validate: value => {
|
|
27
|
+
const candidate = String(value || 'password').trim().toLowerCase();
|
|
28
|
+
return ['password', 'basic', 'sso', 'windows-sso'].includes(candidate) || 'Enter password or sso.';
|
|
29
|
+
}
|
|
30
|
+
}, { input, output });
|
|
31
|
+
const normalized = String(answer || 'password').trim().toLowerCase();
|
|
32
|
+
return ['sso', 'windows-sso'].includes(normalized) ? 'windows-sso' : 'basic';
|
|
33
|
+
}
|
|
14
34
|
|
|
15
35
|
async function confirmCloudFoundryImport({ input = stdin, output = stdout } = {}) {
|
|
16
36
|
const readline = createInterface({ input, output });
|
|
@@ -46,6 +66,24 @@ function safeBasDestination(destination) {
|
|
|
46
66
|
};
|
|
47
67
|
}
|
|
48
68
|
|
|
69
|
+
function safeLocalSapGuiDestination(destination) {
|
|
70
|
+
return {
|
|
71
|
+
source: 'sap-gui-local',
|
|
72
|
+
name: destination.name,
|
|
73
|
+
serverName: destination.serverName || destination.name,
|
|
74
|
+
url: destination.url,
|
|
75
|
+
host: destination.host,
|
|
76
|
+
systemId: destination.systemId,
|
|
77
|
+
instance: destination.instance,
|
|
78
|
+
client: destination.client || '001',
|
|
79
|
+
authentication: destination.authentication || 'Basic',
|
|
80
|
+
authMode: destination.authMode,
|
|
81
|
+
proxyType: 'Internet',
|
|
82
|
+
probe: safeProbe(destination.probe),
|
|
83
|
+
childEnv: destination.childEnv || {}
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
49
87
|
function safeCloudFoundryDestination(destination) {
|
|
50
88
|
const cf = destination.cf || {};
|
|
51
89
|
return {
|
|
@@ -99,28 +137,41 @@ export async function runSetup({
|
|
|
99
137
|
output = stdout,
|
|
100
138
|
discover = discoverDestinations,
|
|
101
139
|
discoverCf = discoverCloudFoundryDestinations,
|
|
140
|
+
discoverLocalSapGui = discoverSapGuiSystems,
|
|
102
141
|
confirmCfImport = confirmCloudFoundryImport,
|
|
103
142
|
install = installMcpConfig,
|
|
104
143
|
includeSapDevelopmentToolsPrompt = false
|
|
105
144
|
} = {}) {
|
|
106
|
-
|
|
107
|
-
|
|
145
|
+
const isBas = Boolean(env.H2O_URL);
|
|
146
|
+
if (!isBas && (!input.isTTY || !output.isTTY)) {
|
|
147
|
+
print(output, formatStatus('BAS destination setup was skipped because H2O_URL is not set.', 'info', output, 'SAP AI Dev Toolkit'));
|
|
108
148
|
return { skipped: true, reason: 'non-bas' };
|
|
109
149
|
}
|
|
110
150
|
if (!input.isTTY || !output.isTTY) {
|
|
111
|
-
print(output, formatStatus(`Interactive setup needs a terminal. Rerun ${SETUP_COMMAND} from a
|
|
151
|
+
print(output, formatStatus(`Interactive setup needs a terminal. Rerun ${SETUP_COMMAND} from a terminal.`, 'warning', output, 'SAP AI Dev Toolkit'));
|
|
112
152
|
return { skipped: true, reason: 'non-tty' };
|
|
113
153
|
}
|
|
114
154
|
print(output, '');
|
|
115
|
-
print(output, formatStatus('Contacting BAS to discover destinations; this may take a moment.', 'progress', output, 'Setup'));
|
|
155
|
+
print(output, formatStatus(isBas ? 'Contacting BAS to discover destinations; this may take a moment.' : 'Looking for local SAP GUI system configuration.', 'progress', output, 'Setup'));
|
|
116
156
|
|
|
117
|
-
let
|
|
118
|
-
|
|
119
|
-
basDestinations
|
|
120
|
-
|
|
121
|
-
|
|
157
|
+
let destinations = [];
|
|
158
|
+
if (isBas) {
|
|
159
|
+
let basDestinations;
|
|
160
|
+
try {
|
|
161
|
+
basDestinations = await discover({ env: { ...env, SAP_AI_DEV_TOOLKIT_DESTINATION: '' } });
|
|
162
|
+
} catch (error) {
|
|
163
|
+
throw new Error(`BAS discovery failed: ${error.message}`);
|
|
164
|
+
}
|
|
165
|
+
destinations = basDestinations.map(safeBasDestination);
|
|
166
|
+
} else {
|
|
167
|
+
let localSystems;
|
|
168
|
+
try {
|
|
169
|
+
localSystems = await discoverLocalSapGui({ env });
|
|
170
|
+
} catch (error) {
|
|
171
|
+
throw new Error(`SAP GUI discovery failed: ${error.message}`);
|
|
172
|
+
}
|
|
173
|
+
destinations = localSystems.map(safeLocalSapGuiDestination);
|
|
122
174
|
}
|
|
123
|
-
const destinations = basDestinations.map(safeBasDestination);
|
|
124
175
|
const warnings = [];
|
|
125
176
|
let createdKeys = [];
|
|
126
177
|
let configPath;
|
|
@@ -132,9 +183,9 @@ export async function runSetup({
|
|
|
132
183
|
warnings.push('Existing MCP config could not be read before setup; existing CF service keys will not be reused.');
|
|
133
184
|
}
|
|
134
185
|
|
|
135
|
-
const target = await getCloudFoundryTarget({ env });
|
|
186
|
+
const target = isBas ? await getCloudFoundryTarget({ env }) : { available: false, reason: 'Cloud Foundry import is only available in BAS setup.' };
|
|
136
187
|
if (!target.available) {
|
|
137
|
-
warnings.push(target.reason);
|
|
188
|
+
if (isBas) warnings.push(target.reason);
|
|
138
189
|
} else {
|
|
139
190
|
// Sweep for service keys this toolkit created but no config references
|
|
140
191
|
// (an interrupted earlier setup leaves them behind; a Destination key
|
|
@@ -205,8 +256,8 @@ export async function runSetup({
|
|
|
205
256
|
};
|
|
206
257
|
const selectable = destinations.filter(destination => !destination.disabledReason);
|
|
207
258
|
if (!selectable.length) {
|
|
208
|
-
print(output, formatStatus('No selectable BAS or Cloud Foundry destinations were found.', 'warning', output, 'Setup'));
|
|
209
|
-
print(output, remediation);
|
|
259
|
+
print(output, formatStatus(isBas ? 'No selectable BAS or Cloud Foundry destinations were found.' : 'No SAP GUI systems were found on this computer.', 'warning', output, 'Setup'));
|
|
260
|
+
print(output, isBas ? remediation : 'Install SAP GUI and create SAP Logon entries, or set SAP_AI_DEV_MCP_CONFIG and add systems manually. ADT still needs an HTTP(S) URL; SAP GUI files contain DIAG routing only.');
|
|
210
261
|
await reportWarnings(warnings);
|
|
211
262
|
const cleanupWarnings = await cleanupNewKeysWithoutConfigChange();
|
|
212
263
|
await reportWarnings(cleanupWarnings);
|
|
@@ -222,9 +273,9 @@ export async function runSetup({
|
|
|
222
273
|
const probe = destination.probe?.status || 'unknown';
|
|
223
274
|
const source = destination.source === 'cloud-foundry'
|
|
224
275
|
? `CF ${destination.cf.destinationInstanceName}`
|
|
225
|
-
: 'BAS';
|
|
276
|
+
: (destination.source === 'sap-gui-local' ? `SAP GUI ${destination.systemId || destination.host || ''}`.trim() : 'BAS');
|
|
226
277
|
const disabled = destination.disabledReason;
|
|
227
|
-
const state = disabled ? 'disabled' : (destination.probe?.available === false ? `fail:${probe}` : `ok:${probe}`);
|
|
278
|
+
const state = disabled ? 'disabled' : (probe === 'needs-adt-url' ? probe : (destination.probe?.available === false ? `fail:${probe}` : `ok:${probe}`));
|
|
228
279
|
return {
|
|
229
280
|
value: destination,
|
|
230
281
|
name: `${destination.name} (${source}, client ${destination.client}, ${state})`,
|
|
@@ -245,6 +296,52 @@ export async function runSetup({
|
|
|
245
296
|
shortcuts: { all: 'a' }
|
|
246
297
|
}, { input, output });
|
|
247
298
|
|
|
299
|
+
if (!isBas && selected.length) {
|
|
300
|
+
print(output, '');
|
|
301
|
+
print(output, formatStatus('SAP GUI landscapes do not contain ADT HTTP(S) endpoints. Confirm the ADT URL and authentication for each selected system.', 'step', output, 'Local SAP GUI'));
|
|
302
|
+
if (windowsSsoSetupAvailable(env)) print(output, ' Windows SSO is experimental and only applies to ADT systems configured for HTTP Integrated Authentication. Username/password remains the default.');
|
|
303
|
+
for (const destination of selected) {
|
|
304
|
+
const fallback = defaultAdtUrl(destination);
|
|
305
|
+
const url = await textPrompt({
|
|
306
|
+
message: `ADT URL for ${destination.name}`,
|
|
307
|
+
placeholder: fallback || 'https://host:44300',
|
|
308
|
+
validate: value => {
|
|
309
|
+
const candidate = value || fallback;
|
|
310
|
+
if (!candidate) return 'Enter an ADT base URL, for example https://host:44300.';
|
|
311
|
+
try {
|
|
312
|
+
const parsed = new URL(candidate);
|
|
313
|
+
if (!['http:', 'https:'].includes(parsed.protocol)) return 'Use an http:// or https:// URL.';
|
|
314
|
+
if (parsed.username || parsed.password) return 'Do not embed credentials in the ADT URL; use the login prompts.';
|
|
315
|
+
if (parsed.search || parsed.hash) return 'Enter an ADT base URL without a query or fragment.';
|
|
316
|
+
return true;
|
|
317
|
+
}
|
|
318
|
+
catch { return 'Enter a valid URL.'; }
|
|
319
|
+
},
|
|
320
|
+
required: false
|
|
321
|
+
}, { input, output });
|
|
322
|
+
destination.url = url || fallback;
|
|
323
|
+
const authMode = await chooseLocalAuthMode(destination, { input, output, env });
|
|
324
|
+
destination.authentication = authMode === 'windows-sso' ? 'WindowsSSO' : 'Basic';
|
|
325
|
+
destination.authMode = authMode;
|
|
326
|
+
if (authMode === 'windows-sso') {
|
|
327
|
+
destination.childEnv = { SAP_AUTH_MODE: 'windows-sso' };
|
|
328
|
+
destination.inputs = [];
|
|
329
|
+
} else {
|
|
330
|
+
const userInputId = `sap-ai-dev-${destination.serverName || destination.name}-user`.toLowerCase().replace(/[^a-z0-9_-]+/g, '-');
|
|
331
|
+
const passwordInputId = `sap-ai-dev-${destination.serverName || destination.name}-password`.toLowerCase().replace(/[^a-z0-9_-]+/g, '-');
|
|
332
|
+
destination.childEnv = {
|
|
333
|
+
SAP_AUTH_MODE: 'basic',
|
|
334
|
+
SAP_USER: `\${input:${userInputId}}`,
|
|
335
|
+
SAP_PASSWORD: `\${input:${passwordInputId}}`
|
|
336
|
+
};
|
|
337
|
+
destination.inputs = [
|
|
338
|
+
{ id: userInputId, type: 'promptString', description: `SAP user for ${destination.name}` },
|
|
339
|
+
{ id: passwordInputId, type: 'promptString', description: `SAP password for ${destination.name}`, password: true }
|
|
340
|
+
];
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
248
345
|
let sapDevelopmentServers = [];
|
|
249
346
|
if (includeSapDevelopmentToolsPrompt) {
|
|
250
347
|
print(output, '');
|
|
@@ -269,18 +366,20 @@ export async function runSetup({
|
|
|
269
366
|
const location = result?.path ? ` in ${result.path}` : '';
|
|
270
367
|
print(output, '');
|
|
271
368
|
const configuredCount = selected.length + sapDevelopmentServers.length;
|
|
272
|
-
print(output, formatStatus(`Configured ${configuredCount} MCP server${configuredCount === 1 ? '' : 's'}${location}.`, 'success', output, 'BAS setup'));
|
|
369
|
+
print(output, formatStatus(`Configured ${configuredCount} MCP server${configuredCount === 1 ? '' : 's'}${location}.`, 'success', output, isBas ? 'BAS setup' : 'Local setup'));
|
|
273
370
|
printConnectionInstructions(output, result);
|
|
274
371
|
let cleanupWarnings = [];
|
|
275
372
|
const finalPath = result?.path || configPath;
|
|
276
|
-
if (
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
373
|
+
if (isBas) {
|
|
374
|
+
if (!finalPath) {
|
|
375
|
+
cleanupWarnings.push('CF service-key cleanup skipped because the final MCP config path is unavailable.');
|
|
376
|
+
} else {
|
|
377
|
+
try {
|
|
378
|
+
const finalConfig = await readMcpConfig(finalPath);
|
|
379
|
+
cleanupWarnings = await cleanupKeys([...managedKeys, ...createdKeys], finalConfig);
|
|
380
|
+
} catch {
|
|
381
|
+
cleanupWarnings.push('CF service-key cleanup skipped because the final MCP config could not be read.');
|
|
382
|
+
}
|
|
284
383
|
}
|
|
285
384
|
}
|
|
286
385
|
await reportWarnings(warnings);
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import { once } from 'node:events';
|
|
3
|
+
import { execFile as nodeExecFile } from 'node:child_process';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
|
|
7
|
+
const MAX_HELPER_OUTPUT = 1024 * 1024;
|
|
8
|
+
const HOP_BY_HOP = new Set(['connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'te', 'trailer', 'transfer-encoding', 'upgrade']);
|
|
9
|
+
const root = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
10
|
+
|
|
11
|
+
function safeError(message) { return new Error(message); }
|
|
12
|
+
|
|
13
|
+
function validateTarget(value) {
|
|
14
|
+
let url;
|
|
15
|
+
try { url = new URL(value); }
|
|
16
|
+
catch { throw safeError('Windows SSO target ADT URL is invalid'); }
|
|
17
|
+
if (!['http:', 'https:'].includes(url.protocol) || !url.hostname || url.username || url.password) {
|
|
18
|
+
throw safeError('Windows SSO target ADT URL must be an HTTP(S) URL without embedded user information');
|
|
19
|
+
}
|
|
20
|
+
url.hash = '';
|
|
21
|
+
return url;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function hasNegotiateChallenge(headers) {
|
|
25
|
+
const value = headers.get?.('www-authenticate') || '';
|
|
26
|
+
return /(?:^|,)\s*Negotiate\b/i.test(value);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function headerObject(headers) {
|
|
30
|
+
const result = {};
|
|
31
|
+
for (const [name, value] of Object.entries(headers || {})) {
|
|
32
|
+
const lower = name.toLowerCase();
|
|
33
|
+
if (HOP_BY_HOP.has(lower) || lower === 'host') continue;
|
|
34
|
+
if (lower === 'authorization') continue;
|
|
35
|
+
if (value == null) continue;
|
|
36
|
+
result[name] = Array.isArray(value) ? value.join(', ') : String(value);
|
|
37
|
+
}
|
|
38
|
+
return result;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function copyResponseHeaders(response, { proxyOrigin, targetOrigin } = {}) {
|
|
42
|
+
const headers = {};
|
|
43
|
+
for (const [name, value] of response.headers) {
|
|
44
|
+
const lower = name.toLowerCase();
|
|
45
|
+
if (HOP_BY_HOP.has(lower) || ['content-encoding', 'content-length', 'set-cookie'].includes(lower)) continue;
|
|
46
|
+
// fetch exposes decompressed bytes, so upstream framing is no longer valid.
|
|
47
|
+
if (lower === 'location' && proxyOrigin && targetOrigin) {
|
|
48
|
+
try {
|
|
49
|
+
const location = new URL(value, targetOrigin);
|
|
50
|
+
if (location.origin === targetOrigin) {
|
|
51
|
+
headers[name] = `${proxyOrigin}${location.pathname}${location.search}${location.hash}`;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
} catch {
|
|
55
|
+
// Keep unparsable Location values unchanged; the client/backend can decide.
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
headers[name] = value;
|
|
59
|
+
}
|
|
60
|
+
const cookies = setCookies(response.headers);
|
|
61
|
+
if (cookies.length) headers['set-cookie'] = cookies;
|
|
62
|
+
return headers;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function setCookies(headers) {
|
|
66
|
+
if (typeof headers?.getSetCookie === 'function') return headers.getSetCookie();
|
|
67
|
+
const value = headers?.get?.('set-cookie');
|
|
68
|
+
return value ? [value] : [];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function mergeCookieHeader(existing, cookies) {
|
|
72
|
+
const pairs = new Map();
|
|
73
|
+
for (const part of String(existing || '').split(';')) {
|
|
74
|
+
const trimmed = part.trim();
|
|
75
|
+
const index = trimmed.indexOf('=');
|
|
76
|
+
if (index > 0) pairs.set(trimmed.slice(0, index), trimmed.slice(index + 1));
|
|
77
|
+
}
|
|
78
|
+
for (const cookie of cookies || []) {
|
|
79
|
+
const first = String(cookie || '').split(';', 1)[0]?.trim();
|
|
80
|
+
const index = first.indexOf('=');
|
|
81
|
+
if (index > 0) pairs.set(first.slice(0, index), first.slice(index + 1));
|
|
82
|
+
}
|
|
83
|
+
return [...pairs].map(([name, value]) => `${name}=${value}`).join('; ');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function readRequestBody(req, limit = 50 * 1024 * 1024) {
|
|
87
|
+
if (['GET', 'HEAD'].includes(req.method || 'GET')) return undefined;
|
|
88
|
+
const chunks = [];
|
|
89
|
+
let size = 0;
|
|
90
|
+
for await (const chunk of req) {
|
|
91
|
+
size += chunk.length;
|
|
92
|
+
if (size > limit) throw safeError('Windows SSO proxy request body is too large');
|
|
93
|
+
chunks.push(chunk);
|
|
94
|
+
}
|
|
95
|
+
return Buffer.concat(chunks);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function helperCommand(env) {
|
|
99
|
+
const command = env.SAP_AI_DEV_TOOLKIT_NEGOTIATE_HELPER || env.SAP_AI_DEV_TOOLKIT_SSPI_HELPER;
|
|
100
|
+
if (command) return { command, args: [] };
|
|
101
|
+
if (process.platform !== 'win32') return null;
|
|
102
|
+
return {
|
|
103
|
+
command: env.SystemRoot ? join(env.SystemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe') : 'powershell.exe',
|
|
104
|
+
args: ['-NoLogo', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', join(root, 'scripts', 'windows-negotiate-helper.ps1')]
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function tokenLooksLikeNtlm(token) {
|
|
109
|
+
try {
|
|
110
|
+
return Buffer.from(token, 'base64').subarray(0, 8).toString('ascii') === 'NTLMSSP\0';
|
|
111
|
+
} catch {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function execHelper(helper, payload, execFileImpl = nodeExecFile, env = process.env) {
|
|
117
|
+
return new Promise((resolve, reject) => {
|
|
118
|
+
const child = execFileImpl(helper.command, helper.args, {
|
|
119
|
+
windowsHide: true,
|
|
120
|
+
timeout: 30_000,
|
|
121
|
+
maxBuffer: MAX_HELPER_OUTPUT,
|
|
122
|
+
env: { ...process.env, ...env }
|
|
123
|
+
}, (error, stdout) => {
|
|
124
|
+
// execFile errors can contain helper stdout/stderr, including tokens.
|
|
125
|
+
if (error) return reject(safeError('Windows SSO helper failed; verify the helper and Windows Kerberos configuration.'));
|
|
126
|
+
const token = String(stdout || '').trim();
|
|
127
|
+
if (!/^[A-Za-z0-9+/=_-]+$/.test(token)) return reject(safeError('Windows SSO helper returned an invalid Negotiate token'));
|
|
128
|
+
if (tokenLooksLikeNtlm(token)) return reject(safeError('Windows SSO helper returned an NTLM token. ADT SSO requires Kerberos/SPNEGO; verify the backend SPN and Kerberos configuration, or rerun sap-ai-dev --setup and choose username/password.'));
|
|
129
|
+
resolve(token);
|
|
130
|
+
});
|
|
131
|
+
child.stdin?.end(`${JSON.stringify(payload)}\n`);
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function acquireNegotiateToken({ targetUrl, challenge, env, execFileImpl }) {
|
|
136
|
+
const helper = helperCommand(env);
|
|
137
|
+
if (!helper) {
|
|
138
|
+
throw safeError('Windows SSO requires an SSPI/Negotiate helper. Set SAP_AI_DEV_TOOLKIT_NEGOTIATE_HELPER to a trusted helper executable, or rerun sap-ai-dev --setup and choose username/password.');
|
|
139
|
+
}
|
|
140
|
+
return execHelper(helper, { url: targetUrl, challenge, mechanism: 'Negotiate' }, execFileImpl, env);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function forward(req, targetUrl, headers, body, fetchImpl) {
|
|
144
|
+
return fetchImpl(targetUrl, {
|
|
145
|
+
method: req.method,
|
|
146
|
+
headers,
|
|
147
|
+
body,
|
|
148
|
+
duplex: body ? 'half' : undefined,
|
|
149
|
+
redirect: 'manual'
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function writeFetchResponse(res, response, base, proxyOrigin) {
|
|
154
|
+
res.writeHead(response.status, response.statusText, copyResponseHeaders(response, { proxyOrigin, targetOrigin: base.origin }));
|
|
155
|
+
if (!response.body) return res.end();
|
|
156
|
+
response.body.pipeTo(new WritableStream({
|
|
157
|
+
write(chunk) { res.write(chunk); },
|
|
158
|
+
close() { res.end(); },
|
|
159
|
+
abort() { res.destroy(); }
|
|
160
|
+
})).catch(() => res.destroy());
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export async function createWindowsSsoAdtProxy({ destinationUrl, env = process.env, fetchImpl = globalThis.fetch, execFileImpl = nodeExecFile } = {}) {
|
|
164
|
+
if (process.platform !== 'win32' && env.SAP_AI_DEV_TOOLKIT_ALLOW_SSO_PROXY_ON_NON_WINDOWS !== 'true') {
|
|
165
|
+
throw safeError('Windows SSO for local ADT destinations is only supported on Windows');
|
|
166
|
+
}
|
|
167
|
+
if (typeof fetchImpl !== 'function') throw safeError('HTTP fetch implementation is unavailable');
|
|
168
|
+
const base = validateTarget(destinationUrl);
|
|
169
|
+
const sockets = new Set();
|
|
170
|
+
let closed = false;
|
|
171
|
+
let proxyOrigin = '';
|
|
172
|
+
|
|
173
|
+
const server = http.createServer(async (req, res) => {
|
|
174
|
+
if (closed) return res.destroy();
|
|
175
|
+
let target;
|
|
176
|
+
try {
|
|
177
|
+
target = new URL(req.url || '/', base);
|
|
178
|
+
if (target.origin !== base.origin) throw safeError('Cross-origin Windows SSO proxy request rejected');
|
|
179
|
+
const headers = headerObject(req.headers);
|
|
180
|
+
const body = await readRequestBody(req);
|
|
181
|
+
let response = await forward(req, target, headers, body, fetchImpl);
|
|
182
|
+
if (response.status === 401 && hasNegotiateChallenge(response.headers)) {
|
|
183
|
+
const challenge = response.headers.get('www-authenticate') || '';
|
|
184
|
+
const cancel = response.body?.cancel?.();
|
|
185
|
+
await cancel?.catch?.(() => {});
|
|
186
|
+
const token = await acquireNegotiateToken({ targetUrl: target.href, challenge, env, execFileImpl });
|
|
187
|
+
const retryHeaders = { ...headers, Authorization: `Negotiate ${token}` };
|
|
188
|
+
const cookies = mergeCookieHeader(headers.cookie || headers.Cookie, setCookies(response.headers));
|
|
189
|
+
delete retryHeaders.cookie;
|
|
190
|
+
delete retryHeaders.Cookie;
|
|
191
|
+
if (cookies) retryHeaders.Cookie = cookies;
|
|
192
|
+
response = await forward(req, target, retryHeaders, body, fetchImpl);
|
|
193
|
+
}
|
|
194
|
+
writeFetchResponse(res, response, base, proxyOrigin);
|
|
195
|
+
} catch (error) {
|
|
196
|
+
if (!res.headersSent) {
|
|
197
|
+
res.writeHead(502, { 'content-type': 'text/plain; charset=utf-8', connection: 'close' });
|
|
198
|
+
res.end(`${error.message || 'Windows SSO proxy request failed'}\n`);
|
|
199
|
+
} else {
|
|
200
|
+
res.destroy();
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
server.on('connection', socket => {
|
|
206
|
+
sockets.add(socket);
|
|
207
|
+
socket.on('close', () => sockets.delete(socket));
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
server.listen(0, '127.0.0.1');
|
|
211
|
+
await once(server, 'listening');
|
|
212
|
+
const address = server.address();
|
|
213
|
+
proxyOrigin = `http://127.0.0.1:${address.port}`;
|
|
214
|
+
return {
|
|
215
|
+
url: proxyOrigin,
|
|
216
|
+
close: async () => {
|
|
217
|
+
if (closed) return;
|
|
218
|
+
closed = true;
|
|
219
|
+
for (const socket of sockets) socket.destroy();
|
|
220
|
+
server.close();
|
|
221
|
+
await once(server, 'close').catch(() => {});
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
}
|