sap-ai-dev-toolkit 0.9.4 → 0.9.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -13
- package/dist/checksums.txt +5 -5
- package/dist/vsp-darwin-arm64 +0 -0
- package/dist/vsp-darwin-x64 +0 -0
- package/dist/vsp-linux-arm64 +0 -0
- package/dist/vsp-linux-x64 +0 -0
- package/dist/vsp-win32-x64.exe +0 -0
- package/package.json +1 -1
- package/src/binary.mjs +12 -4
- package/src/launcher.mjs +46 -46
- package/src/mcp-proxy.mjs +1 -1
- package/src/setup.mjs +7 -40
- package/scripts/windows-negotiate-helper.ps1 +0 -182
- package/src/windows-sso-adt-proxy.mjs +0 -257
package/README.md
CHANGED
|
@@ -262,7 +262,7 @@ Prefer a persistent global install? Install globally from SAP Business Applicati
|
|
|
262
262
|
| --- | --- | --- |
|
|
263
263
|
| **Any environment (default — no global install)** | `npx --yes --ignore-scripts sap-ai-dev-toolkit --setup` | Runs the guided setup through npm and writes generated entries that launch the pinned package through `npx` (`cmd /c npx` on Windows). Add `--npx` to force the npx launcher even when a global `sap-ai-dev` command exists. |
|
|
264
264
|
| **SAP Business Application Studio dev space (global install)** | `npm install --global sap-ai-dev-toolkit`<br>`sap-ai-dev --setup` | Discovers BAS destinations, optionally imports Cloud Foundry Destination service records, and writes VS Code/BAS MCP entries. |
|
|
265
|
-
| **Windows workstation with SAP GUI (global install)** | `npm install --global sap-ai-dev-toolkit`<br>`sap-ai-dev --setup` | Discovers SAP GUI landscape entries, asks for SAP client(s) and ADT URL, then writes local VS Code MCP entries. Windows
|
|
265
|
+
| **Windows workstation with SAP GUI (global install)** | `npm install --global sap-ai-dev-toolkit`<br>`sap-ai-dev --setup` | Discovers SAP GUI landscape entries, asks for SAP client(s) and ADT URL, then writes local VS Code MCP entries. Windows / smart card SSO (Edge) or username/password can be selected. |
|
|
266
266
|
| **macOS workstation with SAP GUI (global install)** | `npm install --global sap-ai-dev-toolkit`<br>`sap-ai-dev --setup` | Discovers SAP GUI landscape entries, asks for SAP client(s) and ADT URL, then writes local VS Code MCP entries with secure login prompts. |
|
|
267
267
|
| **Any local interactive terminal, already installed** | `sap-ai-dev --setup` | Re-runs the wizard to add, update, or remove generated MCP entries. Plain `sap-ai-dev` with no arguments also starts local setup when no BAS environment is detected. |
|
|
268
268
|
| **npm install hook did not show a wizard** | `sap-ai-dev --setup` | This is expected on some npm/Windows combinations because lifecycle scripts may run without an interactive console. The explicit setup command is the reliable path. |
|
|
@@ -276,7 +276,7 @@ Everything works natively on Windows — PowerShell, cmd, and Windows Terminal a
|
|
|
276
276
|
- The pinned VSP runtime ships inside the package as a native `vsp-win32-x64.exe`; no Go toolchain, WSL, or extra downloads are required.
|
|
277
277
|
- MCP entries that launch through npm are written as `cmd /c npx ...` on Windows — the form VS Code, Claude Code, and Cursor can spawn there. Entries written on Linux/macOS use plain `npx`.
|
|
278
278
|
- Setup reads `mcp.json` even when Windows tools saved it with a UTF-8/UTF-16 BOM (PowerShell `>`/`Set-Content`, Notepad "UTF-8 with BOM") and rewrites it as plain UTF-8. If the existing file cannot be used as-is, setup self-heals before falling back to a reset: VS Code-style comments and trailing commas (JSONC) are recovered in place, torn reads from a concurrent editor save are re-read once, and broken `servers`/`inputs` fields are repaired individually. Every lossy change first backs up the original next to the real file as `mcp.json.healed|repaired|invalid-<timestamp>.bak` (exclusive create, so reruns never overwrite an earlier backup; symlinked configs keep their link). `sap-ai-dev --doctor` stays strict and reports such files instead of rewriting them — run `sap-ai-dev --setup` to heal.
|
|
279
|
-
- Local SAP GUI discovery reads the Windows landscape files and registry, and setup can write
|
|
279
|
+
- Local SAP GUI discovery reads the Windows landscape files and registry, and setup can write a Windows / smart card SSO entry that signs in through Microsoft Edge; see the “Local machine SAP GUI support” section below.
|
|
280
280
|
|
|
281
281
|
If you previously installed the old package, remove it first because the legacy `bas-vsp-mcp` command is no longer shipped:
|
|
282
282
|
|
|
@@ -297,12 +297,12 @@ The guided setup can also offer companion MCP entries with `sap-ai-dev --setup -
|
|
|
297
297
|
|
|
298
298
|
The installer handles VSP provisioning automatically:
|
|
299
299
|
|
|
300
|
-
- Uses the package's bundled patched VSP binary for the current platform, verified against the npm-published `dist/checksums.txt`.
|
|
300
|
+
- Uses the package's bundled patched VSP binary for the current platform, verified against the npm-published `dist/checksums.txt`. When the bundled asset is missing (an interrupted install, an antivirus quarantine, or a git checkout without `dist/`), a checksum-anchored download from the matching GitHub release is the fallback; MCP server startup runs it too, because npx entries skip install scripts.
|
|
301
301
|
- No Go toolchain is downloaded or required at install time; Go is only used by the repository's own `build:vsp` development script.
|
|
302
302
|
|
|
303
303
|
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.
|
|
304
304
|
|
|
305
|
-
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 or a complete ABAP client catalog, so setup asks for the SAP client or comma-separated clients to configure for each selected system (for example `100,200`), proposes an ADT URL from the SAP GUI host and instance when it can, and asks you to confirm or edit that value. Multiple entered clients create separate isolated MCP entries, one per system/client pair. Local SAP GUI MCP server keys use the SAP system ID and client (for example `t4d-100`) instead of the longer SAP GUI description, while `SAP_AI_DEV_TOOLKIT_DESTINATION` still preserves the display name. When the ADT URL uses an IP address, setup automatically inspects the SAP HTTPS certificate, extracts DNS subjectAltName entries, and offers to enable secure TLS self-healing with a simple yes/no confirmation; you do not need to type certificate names. 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
|
|
305
|
+
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 or a complete ABAP client catalog, so setup asks for the SAP client or comma-separated clients to configure for each selected system (for example `100,200`), proposes an ADT URL from the SAP GUI host and instance when it can, and asks you to confirm or edit that value. Multiple entered clients create separate isolated MCP entries, one per system/client pair. Local SAP GUI MCP server keys use the SAP system ID and client (for example `t4d-100`) instead of the longer SAP GUI description, while `SAP_AI_DEV_TOOLKIT_DESTINATION` still preserves the display name. When the ADT URL uses an IP address, setup automatically inspects the SAP HTTPS certificate, extracts DNS subjectAltName entries, and offers to enable secure TLS self-healing with a simple yes/no confirmation; you do not need to type certificate names. 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 offers Windows / smart card SSO (the default) instead, which writes `SAP_AUTH_MODE=sso` without username/password prompts. At server start, VSP opens Microsoft Edge on the ADT URL; Edge completes the login the same way it does in your browser (smart card certificate and PIN, Kerberos, or company SAML/Entra login), and the session is cached and refreshed automatically. SSO entries need an ADT URL with a host name, not an IP address. 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, and you should run `sap-ai-dev --setup` afterwards.
|
|
306
306
|
|
|
307
307
|
### 🖥️ Local machine SAP GUI support
|
|
308
308
|
|
|
@@ -314,7 +314,7 @@ Supported discovery and runtime behavior:
|
|
|
314
314
|
- **Client selection:** SAP GUI entries may contain one default client but cannot reliably list every ABAP client available through ADT. Setup asks for one or more 3-digit clients per selected system; enter comma-separated values such as `100,200` to create separate MCP servers for each client.
|
|
315
315
|
- **ADT endpoint derivation:** SAP GUI entries usually contain DIAG/router information, not the HTTP(S) ADT endpoint. Setup derives the common ADT port from the SAP instance when possible, for example instance `00` → `https://host:44300`, and asks for confirmation only when configuring the selected entry.
|
|
316
316
|
- **Credential handling:** username/password local entries use VS Code MCP input prompts (`${input:...}`) at server start. Plain SAP credentials are not written to `mcp.json`.
|
|
317
|
-
- **Login methods:** local entries
|
|
317
|
+
- **Login methods:** local entries use either username/password or Windows / smart card SSO. SSO sets `SAP_SSO=true` for the VSP runtime, which drives Microsoft Edge for the login and re-opens an Edge window only when the cached session can no longer be refreshed silently.
|
|
318
318
|
- **IP-address HTTPS self-healing:** if the confirmed ADT URL is `https://<ip>:<port>` and the certificate is issued to DNS names, setup reads the certificate DNS SANs automatically and stores them as `SAP_TLS_SERVER_NAMES`. At runtime a loopback proxy dials the IP address but verifies TLS against those discovered DNS names, trying them one by one. Certificate verification stays enabled; there is no `--insecure` mode.
|
|
319
319
|
- **Private CAs:** if the SAP HTTPS certificate chains to an internal CA that is not trusted by the local Node runtime, set `SAP_TLS_CA_FILE` or `SAP_AI_DEV_TOOLKIT_TLS_CA_FILE` to a PEM CA bundle. This adds trust roots for the self-healing proxy without disabling certificate validation.
|
|
320
320
|
- **Redirect and request safety:** local loopback proxies reject cross-origin forward-form requests and rewrite same-origin SAP redirects back through the loopback listener, so VSP stays on the safe route.
|
|
@@ -329,7 +329,7 @@ sap-ai-dev --setup
|
|
|
329
329
|
2. Confirm the detected/default SAP client, or enter multiple clients such as `100,200`.
|
|
330
330
|
3. Accept the proposed ADT URL when it is correct, or edit it if your landscape exposes ADT through a different HTTP(S) host/port.
|
|
331
331
|
4. If setup discovers certificate DNS names for an IP-based HTTPS URL, accept the yes/no TLS self-healing prompt.
|
|
332
|
-
5. Choose
|
|
332
|
+
5. Choose Windows / smart card SSO or username/password when the prompt is available.
|
|
333
333
|
6. Start each generated MCP server from VS Code's **MCP: List Servers** command.
|
|
334
334
|
|
|
335
335
|
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 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 VS Code/BAS MCP configuration written during 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.
|
|
@@ -808,15 +808,10 @@ The response contains a `tools` array. A `RunQuery` entry resembles this excerpt
|
|
|
808
808
|
| `SAP_AI_DEV_TOOLKIT_DESTINATION` | Comma-separated destination allowlist for normal runtime discovery. Setup clears this temporarily so it can display all eligible systems. |
|
|
809
809
|
| `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`. |
|
|
810
810
|
| `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. |
|
|
811
|
-
| `SAP_AUTH_MODE=
|
|
811
|
+
| `SAP_AUTH_MODE=sso` | Local SAP GUI entry mode for Windows / smart card SSO. Runtime starts VSP with `SAP_SSO=true`, which signs in through Microsoft Edge and caches the session per system and client. Older `windows-sso`, `browser-saml`, and `saml-password` entries are treated as `sso`. |
|
|
812
812
|
| `SAP_TLS_SERVER_NAMES` / `SAP_AI_DEV_TOOLKIT_TLS_SERVER_NAMES` | Comma-separated local SAP GUI TLS self-healing names, normally auto-discovered during setup from the SAP HTTPS certificate when `SAP_URL` uses an IP address. Runtime tries them one by one while keeping certificate validation enabled. |
|
|
813
813
|
| `SAP_TLS_SERVER_NAME` / `SAP_AI_DEV_TOOLKIT_TLS_SERVER_NAME` | Single-name compatibility form of the local SAP GUI TLS self-healing override. Prefer the auto-discovered plural variable written by setup. |
|
|
814
814
|
| `SAP_TLS_CA_FILE` / `SAP_AI_DEV_TOOLKIT_TLS_CA_FILE` | Optional PEM CA bundle for the local TLS self-healing proxy when the SAP HTTPS certificate chains to a private CA that is not in the OS/Node trust store. Certificate verification remains enabled. |
|
|
815
|
-
| `SAP_AUTH_MODE=browser-saml` with `SAP_BROWSER_AUTH=true` / `SAP_SAML_AUTH=true` | Local browser SAML / web SSO mode. The generated entry delegates browser login handling to the VSP runtime instead of storing SAP credentials. |
|
|
816
|
-
| `SAP_AUTH_MODE=saml-password` with `SAP_SAML_AUTH=true` | Local SAML username/password mode. Generated entries use MCP input prompts for `SAP_SAML_USER` and `SAP_SAML_PASSWORD`; raw credentials are not written to `mcp.json`. |
|
|
817
|
-
| `SAP_AI_DEV_TOOLKIT_WINDOWS_CREDENTIAL_UI=true` / `SAP_WINDOWS_CREDENTIAL_UI=true` | Allows the bundled Windows SSPI helper to run without PowerShell `-NonInteractive`, enabling native Windows credential UI / smartcard PIN prompts when Windows and the MCP host desktop session permit them. |
|
|
818
|
-
| `SAP_AUTH_FALLBACK_MODE=basic` / `SAP_AI_DEV_TOOLKIT_AUTH_FALLBACK_MODE=basic` | Enables Windows SSO self-healing fallback for local SAP GUI entries. If Negotiate/SPNEGO is unavailable or the SSPI helper cannot acquire a token, the loopback proxy retries with Basic auth using `SAP_USER`/`SAP_PASSWORD` from the MCP input prompts. |
|
|
819
|
-
| `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 answer an HTTP `WWW-Authenticate: Negotiate` challenge with the current Windows SSPI/Kerberos/SPNEGO credentials. It is not a prompt for a user-entered token; the helper must obtain credentials from Windows. 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 Negotiate response on stdout. |
|
|
820
815
|
| `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. |
|
|
821
816
|
| `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. |
|
|
822
817
|
| `SAP_AI_DEV_MCP_CONFIG` | Explicit MCP configuration path; highest precedence. |
|
|
@@ -885,7 +880,7 @@ NPM_PUBLISH_TOKEN=npm_...
|
|
|
885
880
|
- To publish a patch bump, run `npm run publish:npm -- --patch`; it updates `package.json` and `package-lock.json` before publishing. If publishing fails after the bump, retry without `--patch`.
|
|
886
881
|
- npm's `prepublishOnly` hook builds the pinned VSP binaries for all platforms (including the Go toolchain if missing) and refuses to ship a tarball whose `dist/checksums.txt` does not list all five assets; it guards `npm run publish:npm` and a plain `npm publish` alike. Pass `--skip-build` to republish an already-built `dist/` without rebuilding.
|
|
887
882
|
- Preview the package without publishing, changing its version, or building with `npm run publish:npm -- --dry-run`.
|
|
888
|
-
- GitHub Actions runs the test suite on pushes to `main` and pull requests. A push to `main` that changes the `package.json` version runs the same checks, builds the VSP binaries, publishes that version to npm, and creates a `v<version>` GitHub release with generated notes.
|
|
883
|
+
- GitHub Actions runs the test suite on pushes to `main` and pull requests. A push to `main` that changes the `package.json` version runs the same checks, builds the VSP binaries, publishes that version to npm, and creates a `v<version>` GitHub release with generated notes and the VSP binaries plus `checksums.txt` attached for the download fallback.
|
|
889
884
|
- The publish workflow uses the `NPM_PUBLISH_TOKEN` GitHub repository secret. Verify it with `gh secret list`; the token value is never stored in the repository.
|
|
890
885
|
|
|
891
886
|
The token is not printed or stored in the repository.
|
package/dist/checksums.txt
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
a5b602573f54ce3d03d161aaaeb78559fd73bd51f2ae9a3b7549cee1ddc3fbf6 vsp-linux-x64
|
|
2
|
+
2e336e2999c3654e5eb1e3a8bb42616eb5d5e422ab7ae74ffbff4634be45189d vsp-linux-arm64
|
|
3
|
+
afc7fd0f2e40ae5ebfcd3ddb8ac3cb7e7501b2b66d76ac96e3b6054b09366bb5 vsp-darwin-x64
|
|
4
|
+
2d28a870c4a006900eec245384b4bbc2e84651e0ba911cca6107c132ace73575 vsp-darwin-arm64
|
|
5
|
+
7d0189aa7ff936a145b0e3f15bffce8de5aebc252465a720edc3a9b122bd2a28 vsp-win32-x64.exe
|
package/dist/vsp-darwin-arm64
CHANGED
|
Binary file
|
package/dist/vsp-darwin-x64
CHANGED
|
Binary file
|
package/dist/vsp-linux-arm64
CHANGED
|
Binary file
|
package/dist/vsp-linux-x64
CHANGED
|
Binary file
|
package/dist/vsp-win32-x64.exe
CHANGED
|
Binary file
|
package/package.json
CHANGED
package/src/binary.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { chmod, mkdir, readFile, rename, stat, writeFile } from 'node:fs/promises';
|
|
3
|
-
import { homedir
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
4
|
import { dirname, join } from 'node:path';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
6
|
import { brandedEnvValue } from './branding.mjs';
|
|
@@ -114,12 +114,14 @@ export async function findBinary(pkg, options = {}) {
|
|
|
114
114
|
await stat(bundled);
|
|
115
115
|
const verified = await verifyAgainstPackagedChecksums(bundled, target);
|
|
116
116
|
if (verified === false) {
|
|
117
|
-
log(`bundled VSP binary ${
|
|
117
|
+
log(`bundled VSP binary ${bundled} failed checksum verification; ignoring it`);
|
|
118
118
|
} else {
|
|
119
119
|
if (verified === null) log(`bundled VSP binary ${target.asset} has no packaged checksum entry; using it unverified`);
|
|
120
120
|
return bundled;
|
|
121
121
|
}
|
|
122
|
-
} catch {
|
|
122
|
+
} catch (error) {
|
|
123
|
+
log(`bundled VSP binary not found at ${bundled} (${error.code || error.message})`);
|
|
124
|
+
}
|
|
123
125
|
}
|
|
124
126
|
const path = cachedBinaryPath(pkg, options.platform, options.arch, env);
|
|
125
127
|
try {
|
|
@@ -158,6 +160,11 @@ export async function installBinary(pkg, options = {}) {
|
|
|
158
160
|
if (String(error?.message || '').includes('checksum verification')) throw error;
|
|
159
161
|
}
|
|
160
162
|
}
|
|
163
|
+
return downloadBinary(pkg, options);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export async function downloadBinary(pkg, options = {}) {
|
|
167
|
+
const env = options.env || process.env;
|
|
161
168
|
const target = binaryTarget(options.platform, options.arch);
|
|
162
169
|
const base = releaseBaseUrl(pkg, pkg.version);
|
|
163
170
|
const assetUrl = brandedEnvValue(env, 'BINARY_URL') || `${base}/${target.asset}`;
|
|
@@ -184,7 +191,8 @@ export async function installBinary(pkg, options = {}) {
|
|
|
184
191
|
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
185
192
|
await chmod(dir, 0o700);
|
|
186
193
|
const destination = cachedBinaryPath(pkg, options.platform, options.arch, env);
|
|
187
|
-
|
|
194
|
+
// Same directory as the destination: rename fails with EXDEV across filesystems (tmpfs /tmp).
|
|
195
|
+
const temporary = `${destination}.${process.pid}-${Date.now()}.tmp`;
|
|
188
196
|
await writeFile(temporary, bytes, { mode: 0o700 });
|
|
189
197
|
await chmod(temporary, 0o700);
|
|
190
198
|
await rename(temporary, destination);
|
package/src/launcher.mjs
CHANGED
|
@@ -5,12 +5,11 @@ 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
7
|
import { discoverSapGuiSystems } from './local-sap-gui.mjs';
|
|
8
|
-
import { findBinary } from './binary.mjs';
|
|
8
|
+
import { binaryTarget, downloadBinary, findBinary } from './binary.mjs';
|
|
9
9
|
import { MCPProxy } from './mcp-proxy.mjs';
|
|
10
10
|
import { installMcpConfig, npxMcpLauncher, repairManagedMcpConfig } from './mcp-config.mjs';
|
|
11
11
|
import { runSetup } from './setup.mjs';
|
|
12
12
|
import { resolveConfiguredCloudFoundryDestination } from './cf-destination.mjs';
|
|
13
|
-
import { createWindowsSsoAdtProxy } from './windows-sso-adt-proxy.mjs';
|
|
14
13
|
import { createTlsServerNameAdtProxy } from './tls-adt-proxy.mjs';
|
|
15
14
|
import { withBrandedEnvironment } from './branding.mjs';
|
|
16
15
|
import { redactText } from './redact.mjs';
|
|
@@ -164,10 +163,27 @@ function probeDiagnostic(destination) {
|
|
|
164
163
|
return `[sap-ai-dev] ${destination.name}: probe=${probe.status || 'unknown'}${details.length ? ` (${details.join('; ')})` : ''}`;
|
|
165
164
|
}
|
|
166
165
|
|
|
166
|
+
// npx MCP entries run with --ignore-scripts, so postinstall's verified
|
|
167
|
+
// download fallback never ran; a missing or corrupt bundled binary heals here.
|
|
167
168
|
async function binaryOrError() {
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
169
|
+
const notes = [];
|
|
170
|
+
const found = await findBinary(pkg, { env: runtimeEnv, log: note => notes.push(note) });
|
|
171
|
+
if (found) return found;
|
|
172
|
+
logLine(`[sap-ai-dev] ${notes.join('; ') || 'no VSP binary found'}; downloading a checksum-verified copy`);
|
|
173
|
+
try {
|
|
174
|
+
const downloaded = await downloadBinary(pkg, { env: runtimeEnv });
|
|
175
|
+
logLine(`[sap-ai-dev] VSP binary installed at ${downloaded}`);
|
|
176
|
+
return downloaded;
|
|
177
|
+
} catch (error) {
|
|
178
|
+
notes.push(`download fallback failed: ${error.message}${error.cause?.code ? ` (${error.cause.code})` : ''}`);
|
|
179
|
+
}
|
|
180
|
+
const npxDirectory = root.match(/^(.*[\\/]_npx[\\/][^\\/]+)[\\/]node_modules[\\/]/)?.[1];
|
|
181
|
+
throw new Error([
|
|
182
|
+
`VSP binary ${binaryTarget().asset} is unavailable for ${pkg.name}@${pkg.version}: ${notes.join('; ')}.`,
|
|
183
|
+
npxDirectory
|
|
184
|
+
? `The npm package ships this binary, so an interrupted install or an antivirus quarantine usually removed it: delete ${npxDirectory} and restart the MCP server so npx reinstalls the package, or set SAP_AI_DEV_TOOLKIT_BINARY to a trusted patched VSP binary.`
|
|
185
|
+
: `Reinstall ${pkg.name} (a git checkout needs npm run build:vsp), check whether antivirus quarantined the binary, or set SAP_AI_DEV_TOOLKIT_BINARY to a trusted patched VSP binary.`
|
|
186
|
+
].join(' '));
|
|
171
187
|
}
|
|
172
188
|
|
|
173
189
|
async function discoverForCommand() {
|
|
@@ -184,57 +200,41 @@ async function discoverForCommand() {
|
|
|
184
200
|
async function configuredLocalSapGuiDestination(env) {
|
|
185
201
|
const name = env.SAP_AI_DEV_TOOLKIT_DESTINATION || env.SAP_SYSTEM_ID || 'local-sap';
|
|
186
202
|
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.');
|
|
187
|
-
const
|
|
203
|
+
const client = env.SAP_CLIENT || '001';
|
|
204
|
+
const systemId = env.SAP_SYSTEM_ID || '';
|
|
205
|
+
// Older entries used windows-sso, browser-saml or saml-password; all of them now mean browser SSO.
|
|
206
|
+
if (!['', 'basic'].includes(String(env.SAP_AUTH_MODE || '').toLowerCase())) {
|
|
207
|
+
const cacheKey = `${systemId || new URL(env.SAP_URL).hostname}-${client}`.toLowerCase().replace(/[^a-z0-9.-]+/g, '-');
|
|
208
|
+
return {
|
|
209
|
+
source: 'sap-gui-local',
|
|
210
|
+
name,
|
|
211
|
+
url: env.SAP_URL,
|
|
212
|
+
client,
|
|
213
|
+
systemId,
|
|
214
|
+
authentication: 'SSO',
|
|
215
|
+
// SAP_SAML_AUTH survives sanitizeChildEnv and would make VSP demand SAML credentials.
|
|
216
|
+
childEnv: { SAP_SSO: 'true', SAP_SSO_SYSTEM: cacheKey, SAP_SAML_AUTH: '' },
|
|
217
|
+
probe: { status: 'configured', available: true }
|
|
218
|
+
};
|
|
219
|
+
}
|
|
188
220
|
const tlsServerName = String(env.SAP_TLS_SERVER_NAME || env.SAP_AI_DEV_TOOLKIT_TLS_SERVER_NAME || '').trim();
|
|
189
221
|
const tlsServerNames = String(env.SAP_TLS_SERVER_NAMES || env.SAP_AI_DEV_TOOLKIT_TLS_SERVER_NAMES || tlsServerName).split(',').map(value => value.trim()).filter(Boolean);
|
|
190
222
|
const tlsCaFile = String(env.SAP_TLS_CA_FILE || env.SAP_AI_DEV_TOOLKIT_TLS_CA_FILE || '').trim();
|
|
191
223
|
const tlsRoute = tlsServerNames.length
|
|
192
224
|
? await createTlsServerNameAdtProxy({ destinationUrl: env.SAP_URL, tlsServerNames, caFile: tlsCaFile || undefined })
|
|
193
225
|
: null;
|
|
194
|
-
const adtUrl = tlsRoute?.url || env.SAP_URL;
|
|
195
|
-
const closeRoute = async route => {
|
|
196
|
-
await Promise.all([route?.close?.(), tlsRoute?.close?.()].filter(Boolean).map(close => Promise.resolve().then(close).catch(() => {})));
|
|
197
|
-
};
|
|
198
|
-
if (authMode === 'windows-sso') {
|
|
199
|
-
const route = await createWindowsSsoAdtProxy({ destinationUrl: adtUrl, env });
|
|
200
|
-
return {
|
|
201
|
-
source: 'sap-gui-local',
|
|
202
|
-
name,
|
|
203
|
-
url: route.url,
|
|
204
|
-
backendUrl: env.SAP_URL,
|
|
205
|
-
client: env.SAP_CLIENT || '001',
|
|
206
|
-
systemId: env.SAP_SYSTEM_ID || '',
|
|
207
|
-
authentication: 'WindowsSSO',
|
|
208
|
-
childEnv: {},
|
|
209
|
-
close: () => closeRoute(route),
|
|
210
|
-
probe: { status: tlsRoute ? 'configured-tls-server-name' : 'configured', available: true }
|
|
211
|
-
};
|
|
212
|
-
}
|
|
213
|
-
const childEnv = (() => {
|
|
214
|
-
if (authMode === 'browser-saml') {
|
|
215
|
-
return { SAP_BROWSER_AUTH: 'true', SAP_SAML_AUTH: 'true' };
|
|
216
|
-
}
|
|
217
|
-
if (authMode === 'saml-password') {
|
|
218
|
-
return {
|
|
219
|
-
SAP_SAML_AUTH: 'true',
|
|
220
|
-
SAP_SAML_USER: env.SAP_SAML_USER || env.SAP_USER || env.SAP_USERNAME || '',
|
|
221
|
-
SAP_SAML_PASSWORD: env.SAP_SAML_PASSWORD || env.SAP_PASSWORD || env.SAP_PASS || ''
|
|
222
|
-
};
|
|
223
|
-
}
|
|
224
|
-
return {
|
|
225
|
-
SAP_USER: env.SAP_USER || env.SAP_USERNAME || '',
|
|
226
|
-
SAP_PASSWORD: env.SAP_PASSWORD || env.SAP_PASS || ''
|
|
227
|
-
};
|
|
228
|
-
})();
|
|
229
226
|
return {
|
|
230
227
|
source: 'sap-gui-local',
|
|
231
228
|
name,
|
|
232
|
-
url:
|
|
229
|
+
url: tlsRoute?.url || env.SAP_URL,
|
|
233
230
|
backendUrl: tlsRoute ? env.SAP_URL : undefined,
|
|
234
|
-
client
|
|
235
|
-
systemId
|
|
236
|
-
authentication:
|
|
237
|
-
childEnv
|
|
231
|
+
client,
|
|
232
|
+
systemId,
|
|
233
|
+
authentication: 'Basic',
|
|
234
|
+
childEnv: {
|
|
235
|
+
SAP_USER: env.SAP_USER || env.SAP_USERNAME || '',
|
|
236
|
+
SAP_PASSWORD: env.SAP_PASSWORD || env.SAP_PASS || ''
|
|
237
|
+
},
|
|
238
238
|
close: tlsRoute?.close,
|
|
239
239
|
probe: { status: tlsRoute ? 'configured-tls-server-name' : 'configured', available: true }
|
|
240
240
|
};
|
package/src/mcp-proxy.mjs
CHANGED
|
@@ -168,7 +168,7 @@ function closeDestinationRoute(destination) {
|
|
|
168
168
|
|
|
169
169
|
|
|
170
170
|
function useProxyAuthentication(destination) {
|
|
171
|
-
if (destination.source === 'sap-gui-local') return
|
|
171
|
+
if (destination.source === 'sap-gui-local') return false;
|
|
172
172
|
return destination.source !== 'cloud-foundry' || destination.authentication === 'PrincipalPropagation';
|
|
173
173
|
}
|
|
174
174
|
|
package/src/setup.mjs
CHANGED
|
@@ -58,14 +58,10 @@ async function chooseLocalAuthMode(destination, { input = stdin, output = stdout
|
|
|
58
58
|
if (!windowsSsoSetupAvailable(env)) return 'basic';
|
|
59
59
|
return await selectPrompt({
|
|
60
60
|
message: `Authentication for ${destination.name}`,
|
|
61
|
-
defaultValue: '
|
|
61
|
+
defaultValue: 'sso',
|
|
62
62
|
choices: [
|
|
63
|
-
{ name: '
|
|
64
|
-
{ name: '
|
|
65
|
-
{ name: 'windows-sso with native Windows credential UI / smartcard PIN', value: 'windows-credential-ui', shortcut: 'w' },
|
|
66
|
-
{ name: 'windows-sso with password fallback (self-heal when Negotiate/PIN is unavailable)', value: 'windows-sso-basic-fallback', shortcut: 'f' },
|
|
67
|
-
{ name: 'browser SAML / web SSO (opens browser if supported by VSP)', value: 'browser-saml', shortcut: 'b' },
|
|
68
|
-
{ name: 'SAML username/password', value: 'saml-password', shortcut: 'm' }
|
|
63
|
+
{ name: 'Windows / smart card SSO (Edge signs in automatically)', value: 'sso', shortcut: 's' },
|
|
64
|
+
{ name: 'Username/password', value: 'basic', shortcut: 'p' }
|
|
69
65
|
]
|
|
70
66
|
}, { input, output });
|
|
71
67
|
}
|
|
@@ -125,7 +121,7 @@ function validateAdtUrlCandidate(candidate) {
|
|
|
125
121
|
}
|
|
126
122
|
|
|
127
123
|
function localAuthenticationLabel(authMode) {
|
|
128
|
-
return
|
|
124
|
+
return authMode === 'sso' ? 'SSO' : 'Basic';
|
|
129
125
|
}
|
|
130
126
|
|
|
131
127
|
async function confirmPrompt(message, { input = stdin, output = stdout } = {}) {
|
|
@@ -147,38 +143,9 @@ function buildLocalClientDestination(destination, client, authMode, { multipleCl
|
|
|
147
143
|
{ id: userInputId, type: 'promptString', description: `SAP user for ${localDestination.name}` },
|
|
148
144
|
{ id: passwordInputId, type: 'promptString', description: `SAP password for ${localDestination.name}`, password: true }
|
|
149
145
|
];
|
|
150
|
-
if (authMode === '
|
|
151
|
-
localDestination.childEnv = { SAP_AUTH_MODE: '
|
|
146
|
+
if (authMode === 'sso') {
|
|
147
|
+
localDestination.childEnv = { SAP_AUTH_MODE: 'sso' };
|
|
152
148
|
localDestination.inputs = [];
|
|
153
|
-
} else if (authMode === 'windows-credential-ui') {
|
|
154
|
-
localDestination.childEnv = {
|
|
155
|
-
SAP_AUTH_MODE: 'windows-sso',
|
|
156
|
-
SAP_AI_DEV_TOOLKIT_WINDOWS_CREDENTIAL_UI: 'true'
|
|
157
|
-
};
|
|
158
|
-
localDestination.inputs = [];
|
|
159
|
-
} else if (authMode === 'windows-sso-basic-fallback') {
|
|
160
|
-
localDestination.childEnv = {
|
|
161
|
-
SAP_AUTH_MODE: 'windows-sso',
|
|
162
|
-
SAP_AUTH_FALLBACK_MODE: 'basic',
|
|
163
|
-
SAP_USER: `\${input:${userInputId}}`,
|
|
164
|
-
SAP_PASSWORD: `\${input:${passwordInputId}}`
|
|
165
|
-
};
|
|
166
|
-
localDestination.inputs = credentialInputs;
|
|
167
|
-
} else if (authMode === 'browser-saml') {
|
|
168
|
-
localDestination.childEnv = {
|
|
169
|
-
SAP_AUTH_MODE: 'browser-saml',
|
|
170
|
-
SAP_BROWSER_AUTH: 'true',
|
|
171
|
-
SAP_SAML_AUTH: 'true'
|
|
172
|
-
};
|
|
173
|
-
localDestination.inputs = [];
|
|
174
|
-
} else if (authMode === 'saml-password') {
|
|
175
|
-
localDestination.childEnv = {
|
|
176
|
-
SAP_AUTH_MODE: 'saml-password',
|
|
177
|
-
SAP_SAML_AUTH: 'true',
|
|
178
|
-
SAP_SAML_USER: `\${input:${userInputId}}`,
|
|
179
|
-
SAP_SAML_PASSWORD: `\${input:${passwordInputId}}`
|
|
180
|
-
};
|
|
181
|
-
localDestination.inputs = credentialInputs;
|
|
182
149
|
} else {
|
|
183
150
|
localDestination.childEnv = {
|
|
184
151
|
SAP_AUTH_MODE: 'basic',
|
|
@@ -422,7 +389,7 @@ async function chooseDiscoveredDestinations(destinations, { isBas, input = stdin
|
|
|
422
389
|
print(output, '');
|
|
423
390
|
print(output, formatStatus('SAP GUI landscapes do not contain ADT HTTP(S) endpoints or a complete client catalog. Confirm the SAP client(s), ADT URL, and authentication for each selected system.', 'step', output, 'Local SAP GUI'));
|
|
424
391
|
print(output, ' Enter multiple clients as comma-separated 3-digit values, for example 100,200. Setup creates one isolated MCP server per system/client pair.');
|
|
425
|
-
if (windowsSsoSetupAvailable(env)) print(output, ' Windows
|
|
392
|
+
if (windowsSsoSetupAvailable(env)) print(output, ' Windows / smart card SSO signs in through Microsoft Edge (smart card, Kerberos or company login) and keeps the session refreshed; no password is stored.');
|
|
426
393
|
const selectedNameCounts = selected.reduce((counts, destination) => {
|
|
427
394
|
const key = String(destination.name).toLowerCase();
|
|
428
395
|
counts.set(key, (counts.get(key) || 0) + 1);
|
|
@@ -1,182 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,257 +0,0 @@
|
|
|
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
|
-
const interactiveCredentialUi = env.SAP_AI_DEV_TOOLKIT_WINDOWS_CREDENTIAL_UI === 'true' || env.SAP_WINDOWS_CREDENTIAL_UI === 'true';
|
|
103
|
-
return {
|
|
104
|
-
command: env.SystemRoot ? join(env.SystemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe') : 'powershell.exe',
|
|
105
|
-
args: ['-NoLogo', '-NoProfile', ...(interactiveCredentialUi ? [] : ['-NonInteractive']), '-ExecutionPolicy', 'Bypass', '-File', join(root, 'scripts', 'windows-negotiate-helper.ps1')]
|
|
106
|
-
};
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
function tokenLooksLikeNtlm(token) {
|
|
110
|
-
try {
|
|
111
|
-
return Buffer.from(token, 'base64').subarray(0, 8).toString('ascii') === 'NTLMSSP\0';
|
|
112
|
-
} catch {
|
|
113
|
-
return false;
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
function execHelper(helper, payload, execFileImpl = nodeExecFile, env = process.env) {
|
|
118
|
-
return new Promise((resolve, reject) => {
|
|
119
|
-
const child = execFileImpl(helper.command, helper.args, {
|
|
120
|
-
windowsHide: true,
|
|
121
|
-
timeout: 30_000,
|
|
122
|
-
maxBuffer: MAX_HELPER_OUTPUT,
|
|
123
|
-
env: { ...process.env, ...env }
|
|
124
|
-
}, (error, stdout) => {
|
|
125
|
-
// execFile errors can contain helper stdout/stderr, including tokens.
|
|
126
|
-
if (error) return reject(safeError('Windows SSO helper failed; verify the ADT endpoint supports HTTP Negotiate/SPNEGO for the signed-in Windows identity (including smart-card-backed Windows logon) and that Kerberos/SPN configuration is correct. The toolkit cannot prompt for or accept manually pasted bearer/SAML tokens.'));
|
|
127
|
-
const token = String(stdout || '').trim();
|
|
128
|
-
if (!/^[A-Za-z0-9+/=_-]+$/.test(token)) return reject(safeError('Windows SSO helper returned an invalid Negotiate token'));
|
|
129
|
-
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.'));
|
|
130
|
-
resolve(token);
|
|
131
|
-
});
|
|
132
|
-
child.stdin?.end(`${JSON.stringify(payload)}\n`);
|
|
133
|
-
});
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
async function acquireNegotiateToken({ targetUrl, challenge, env, execFileImpl }) {
|
|
137
|
-
const helper = helperCommand(env);
|
|
138
|
-
if (!helper) {
|
|
139
|
-
throw safeError('Windows SSO requires an SSPI/Negotiate helper that uses the current Windows logon session (including smart-card-backed logon). Set SAP_AI_DEV_TOOLKIT_NEGOTIATE_HELPER to a trusted helper executable, or rerun sap-ai-dev --setup and choose username/password. Manual token entry is not supported.');
|
|
140
|
-
}
|
|
141
|
-
return execHelper(helper, { url: targetUrl, challenge, mechanism: 'Negotiate' }, execFileImpl, env);
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
function basicFallbackCredentials(env) {
|
|
145
|
-
const mode = String(env.SAP_AUTH_FALLBACK_MODE || env.SAP_AI_DEV_TOOLKIT_AUTH_FALLBACK_MODE || '').toLowerCase();
|
|
146
|
-
const enabled = mode === 'basic' || env.SAP_AI_DEV_TOOLKIT_SSO_BASIC_FALLBACK === 'true';
|
|
147
|
-
const user = String(env.SAP_USER || env.SAP_USERNAME || '').trim();
|
|
148
|
-
const password = String(env.SAP_PASSWORD || env.SAP_PASS || '');
|
|
149
|
-
if (!enabled || !user || !password) return null;
|
|
150
|
-
return { user, password };
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
function hasBasicChallenge(headers) {
|
|
154
|
-
const value = headers.get?.('www-authenticate') || '';
|
|
155
|
-
return /(?:^|,)\s*Basic\b/i.test(value);
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
async function retryWithBasicFallback({ req, target, headers, body, fetchImpl, env }) {
|
|
159
|
-
const credentials = basicFallbackCredentials(env);
|
|
160
|
-
if (!credentials) return null;
|
|
161
|
-
return forward(req, target, {
|
|
162
|
-
...headers,
|
|
163
|
-
Authorization: `Basic ${Buffer.from(`${credentials.user}:${credentials.password}`).toString('base64')}`
|
|
164
|
-
}, body, fetchImpl);
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
async function forward(req, targetUrl, headers, body, fetchImpl) {
|
|
168
|
-
return fetchImpl(targetUrl, {
|
|
169
|
-
method: req.method,
|
|
170
|
-
headers,
|
|
171
|
-
body,
|
|
172
|
-
duplex: body ? 'half' : undefined,
|
|
173
|
-
redirect: 'manual'
|
|
174
|
-
});
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
function writeFetchResponse(res, response, base, proxyOrigin) {
|
|
178
|
-
res.writeHead(response.status, response.statusText, copyResponseHeaders(response, { proxyOrigin, targetOrigin: base.origin }));
|
|
179
|
-
if (!response.body) return res.end();
|
|
180
|
-
response.body.pipeTo(new WritableStream({
|
|
181
|
-
write(chunk) { res.write(chunk); },
|
|
182
|
-
close() { res.end(); },
|
|
183
|
-
abort() { res.destroy(); }
|
|
184
|
-
})).catch(() => res.destroy());
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
export async function createWindowsSsoAdtProxy({ destinationUrl, env = process.env, fetchImpl = globalThis.fetch, execFileImpl = nodeExecFile } = {}) {
|
|
188
|
-
if (process.platform !== 'win32' && env.SAP_AI_DEV_TOOLKIT_ALLOW_SSO_PROXY_ON_NON_WINDOWS !== 'true') {
|
|
189
|
-
throw safeError('Windows SSO for local ADT destinations is only supported on Windows');
|
|
190
|
-
}
|
|
191
|
-
if (typeof fetchImpl !== 'function') throw safeError('HTTP fetch implementation is unavailable');
|
|
192
|
-
const base = validateTarget(destinationUrl);
|
|
193
|
-
const sockets = new Set();
|
|
194
|
-
let closed = false;
|
|
195
|
-
let proxyOrigin = '';
|
|
196
|
-
|
|
197
|
-
const server = http.createServer(async (req, res) => {
|
|
198
|
-
if (closed) return res.destroy();
|
|
199
|
-
let target;
|
|
200
|
-
try {
|
|
201
|
-
target = new URL(req.url || '/', base);
|
|
202
|
-
if (target.origin !== base.origin) throw safeError('Cross-origin Windows SSO proxy request rejected');
|
|
203
|
-
const headers = headerObject(req.headers);
|
|
204
|
-
const body = await readRequestBody(req);
|
|
205
|
-
let response = await forward(req, target, headers, body, fetchImpl);
|
|
206
|
-
if (response.status === 401 && hasNegotiateChallenge(response.headers)) {
|
|
207
|
-
const challenge = response.headers.get('www-authenticate') || '';
|
|
208
|
-
const cookies = mergeCookieHeader(headers.cookie || headers.Cookie, setCookies(response.headers));
|
|
209
|
-
const cancel = response.body?.cancel?.();
|
|
210
|
-
await cancel?.catch?.(() => {});
|
|
211
|
-
try {
|
|
212
|
-
const token = await acquireNegotiateToken({ targetUrl: target.href, challenge, env, execFileImpl });
|
|
213
|
-
const retryHeaders = { ...headers, Authorization: `Negotiate ${token}` };
|
|
214
|
-
delete retryHeaders.cookie;
|
|
215
|
-
delete retryHeaders.Cookie;
|
|
216
|
-
if (cookies) retryHeaders.Cookie = cookies;
|
|
217
|
-
response = await forward(req, target, retryHeaders, body, fetchImpl);
|
|
218
|
-
} catch (error) {
|
|
219
|
-
response = await retryWithBasicFallback({ req, target, headers: { ...headers, ...(cookies ? { Cookie: cookies } : {}) }, body, fetchImpl, env });
|
|
220
|
-
if (!response) throw error;
|
|
221
|
-
}
|
|
222
|
-
} else if (response.status === 401 && hasBasicChallenge(response.headers) && basicFallbackCredentials(env)) {
|
|
223
|
-
const cancel = response.body?.cancel?.();
|
|
224
|
-
await cancel?.catch?.(() => {});
|
|
225
|
-
response = await retryWithBasicFallback({ req, target, headers, body, fetchImpl, env }) || response;
|
|
226
|
-
}
|
|
227
|
-
writeFetchResponse(res, response, base, proxyOrigin);
|
|
228
|
-
} catch (error) {
|
|
229
|
-
if (!res.headersSent) {
|
|
230
|
-
res.writeHead(502, { 'content-type': 'text/plain; charset=utf-8', connection: 'close' });
|
|
231
|
-
res.end(`${error.message || 'Windows SSO proxy request failed'}\n`);
|
|
232
|
-
} else {
|
|
233
|
-
res.destroy();
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
});
|
|
237
|
-
|
|
238
|
-
server.on('connection', socket => {
|
|
239
|
-
sockets.add(socket);
|
|
240
|
-
socket.on('close', () => sockets.delete(socket));
|
|
241
|
-
});
|
|
242
|
-
|
|
243
|
-
server.listen(0, '127.0.0.1');
|
|
244
|
-
await once(server, 'listening');
|
|
245
|
-
const address = server.address();
|
|
246
|
-
proxyOrigin = `http://127.0.0.1:${address.port}`;
|
|
247
|
-
return {
|
|
248
|
-
url: proxyOrigin,
|
|
249
|
-
close: async () => {
|
|
250
|
-
if (closed) return;
|
|
251
|
-
closed = true;
|
|
252
|
-
for (const socket of sockets) socket.destroy();
|
|
253
|
-
server.close();
|
|
254
|
-
await once(server, 'close').catch(() => {});
|
|
255
|
-
}
|
|
256
|
-
};
|
|
257
|
-
}
|