sap-ai-dev-toolkit 0.8.3 → 0.8.5
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 +9 -5
- package/package.json +1 -1
- package/src/launcher.mjs +20 -5
- package/src/mcp-config.mjs +11 -2
- package/src/setup.mjs +50 -19
- package/src/terminal-ui.mjs +98 -0
- package/src/windows-sso-adt-proxy.mjs +74 -12
package/README.md
CHANGED
|
@@ -278,7 +278,7 @@ The installer handles VSP provisioning automatically:
|
|
|
278
278
|
|
|
279
279
|
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.
|
|
280
280
|
|
|
281
|
-
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. 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 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, and you should run `sap-ai-dev --setup` afterwards.
|
|
281
|
+
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 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, and you should run `sap-ai-dev --setup` afterwards.
|
|
282
282
|
|
|
283
283
|
### 🖥️ Local machine SAP GUI support
|
|
284
284
|
|
|
@@ -290,7 +290,7 @@ Supported discovery and runtime behavior:
|
|
|
290
290
|
- **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.
|
|
291
291
|
- **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.
|
|
292
292
|
- **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`.
|
|
293
|
-
- **
|
|
293
|
+
- **Login methods:** local entries can use password, browser SAML/web SSO, SAML username/password, silent Windows Negotiate/SPNEGO, Windows Negotiate/SPNEGO with native Windows credential UI/smartcard PIN, or Windows SSO with password fallback. Browser/SAML modes are passed to the VSP runtime through `SAP_BROWSER_AUTH` / `SAP_SAML_AUTH`. Windows credential UI mode runs the bundled SSPI helper interactively so Windows can display its native credential/smartcard prompt when the desktop session allows it. NTLM fallback is rejected.
|
|
294
294
|
- **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.
|
|
295
295
|
- **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.
|
|
296
296
|
- **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.
|
|
@@ -740,7 +740,7 @@ The MCP client discovers the schemas; callers do not need to memorize every argu
|
|
|
740
740
|
| Add-on command options | `sap-ai-dev --help` |
|
|
741
741
|
| BAS destinations and probe status | `sap-ai-dev --list-destinations --json` (BAS only) |
|
|
742
742
|
| Destination availability only | `sap-ai-dev --check` |
|
|
743
|
-
| Generated server names and destination mapping | **MCP: Open User Configuration**; look for lowercase slug entries (for example `demo-abap`) whose `SAP_AI_DEV_TOOLKIT_DESTINATION` names the BAS destination or local SAP GUI system. |
|
|
743
|
+
| Generated server names and destination mapping | **MCP: Open User Configuration**; look for lowercase slug entries (for BAS, for example `demo-abap`; for local SAP GUI, system/client slugs such as `t4d-100`) whose `SAP_AI_DEV_TOOLKIT_DESTINATION` names the BAS destination or local SAP GUI system. |
|
|
744
744
|
| Running server | **MCP: List Servers**; select the server and choose **Start Server**. |
|
|
745
745
|
| Tools and exact schemas | Expand that server in the Chat tools picker. The MCP host requests `tools/list`, whose entries include `name`, `description`, and `inputSchema`; the agent should call those tools through chat, not reproduce the protocol in a terminal. |
|
|
746
746
|
| Runtime and tool-call logs | Select the MCP server in the Output view. Startup, call lifecycle, and child stderr logs are written to stderr; child MCP log notifications are forwarded to the client. Tool arguments and result contents are not logged. |
|
|
@@ -782,11 +782,15 @@ The response contains a `tools` array. A `RunQuery` entry resembles this excerpt
|
|
|
782
782
|
| `SAP_AI_DEV_TOOLKIT_DESTINATION` | Comma-separated destination allowlist for normal runtime discovery. Setup clears this temporarily so it can display all eligible systems. |
|
|
783
783
|
| `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`. |
|
|
784
784
|
| `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. |
|
|
785
|
-
| `SAP_AUTH_MODE=windows-sso` | Local SAP GUI entry mode for experimental Windows Integrated Authentication. Runtime starts a loopback ADT proxy
|
|
785
|
+
| `SAP_AUTH_MODE=windows-sso` | Local SAP GUI entry mode for experimental Windows Integrated Authentication over HTTP Negotiate/SPNEGO. Runtime starts a loopback ADT proxy. With `SAP_AI_DEV_TOOLKIT_WINDOWS_CREDENTIAL_UI=true`, the bundled helper is allowed to run interactively so Windows can show its native credential/smartcard PIN UI. |
|
|
786
786
|
| `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. |
|
|
787
787
|
| `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. |
|
|
788
788
|
| `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. |
|
|
789
|
-
| `
|
|
789
|
+
| `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. |
|
|
790
|
+
| `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`. |
|
|
791
|
+
| `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. |
|
|
792
|
+
| `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. |
|
|
793
|
+
| `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. |
|
|
790
794
|
| `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. |
|
|
791
795
|
| `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. |
|
|
792
796
|
| `SAP_AI_DEV_MCP_CONFIG` | Explicit MCP configuration path; highest precedence. |
|
package/package.json
CHANGED
package/src/launcher.mjs
CHANGED
|
@@ -195,23 +195,38 @@ async function configuredLocalSapGuiDestination(env) {
|
|
|
195
195
|
url: route.url,
|
|
196
196
|
backendUrl: env.SAP_URL,
|
|
197
197
|
client: env.SAP_CLIENT || '001',
|
|
198
|
+
systemId: env.SAP_SYSTEM_ID || '',
|
|
198
199
|
authentication: 'WindowsSSO',
|
|
199
200
|
childEnv: {},
|
|
200
201
|
close: () => closeRoute(route),
|
|
201
202
|
probe: { status: tlsRoute ? 'configured-tls-server-name' : 'configured', available: true }
|
|
202
203
|
};
|
|
203
204
|
}
|
|
205
|
+
const childEnv = (() => {
|
|
206
|
+
if (authMode === 'browser-saml') {
|
|
207
|
+
return { SAP_BROWSER_AUTH: 'true', SAP_SAML_AUTH: 'true' };
|
|
208
|
+
}
|
|
209
|
+
if (authMode === 'saml-password') {
|
|
210
|
+
return {
|
|
211
|
+
SAP_SAML_AUTH: 'true',
|
|
212
|
+
SAP_SAML_USER: env.SAP_SAML_USER || env.SAP_USER || env.SAP_USERNAME || '',
|
|
213
|
+
SAP_SAML_PASSWORD: env.SAP_SAML_PASSWORD || env.SAP_PASSWORD || env.SAP_PASS || ''
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
return {
|
|
217
|
+
SAP_USER: env.SAP_USER || env.SAP_USERNAME || '',
|
|
218
|
+
SAP_PASSWORD: env.SAP_PASSWORD || env.SAP_PASS || ''
|
|
219
|
+
};
|
|
220
|
+
})();
|
|
204
221
|
return {
|
|
205
222
|
source: 'sap-gui-local',
|
|
206
223
|
name,
|
|
207
224
|
url: adtUrl,
|
|
208
225
|
backendUrl: tlsRoute ? env.SAP_URL : undefined,
|
|
209
226
|
client: env.SAP_CLIENT || '001',
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
SAP_PASSWORD: env.SAP_PASSWORD || env.SAP_PASS || ''
|
|
214
|
-
},
|
|
227
|
+
systemId: env.SAP_SYSTEM_ID || '',
|
|
228
|
+
authentication: authMode === 'browser-saml' ? 'BrowserSAML' : authMode === 'saml-password' ? 'SAML' : 'Basic',
|
|
229
|
+
childEnv,
|
|
215
230
|
close: tlsRoute?.close,
|
|
216
231
|
probe: { status: tlsRoute ? 'configured-tls-server-name' : 'configured', available: true }
|
|
217
232
|
};
|
package/src/mcp-config.mjs
CHANGED
|
@@ -129,6 +129,15 @@ export function generatedServerName(destinationName) {
|
|
|
129
129
|
return slugifyDestination(destinationName);
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
+
export function generatedDestinationServerName(destination) {
|
|
133
|
+
if (destination?.source === 'sap-gui-local') {
|
|
134
|
+
const systemId = String(destination.systemId || '').trim();
|
|
135
|
+
const client = String(destination.client || '001').trim() || '001';
|
|
136
|
+
if (systemId) return generatedServerName(`${systemId}-${client}`);
|
|
137
|
+
}
|
|
138
|
+
return generatedServerName(destination?.source === 'cloud-foundry' ? destination.serverName : destination?.name);
|
|
139
|
+
}
|
|
140
|
+
|
|
132
141
|
export function buildSapDevelopmentMcpEntries(serverIds = SAP_DEVELOPMENT_MCP_SERVERS.map(server => server.id), { packageVersions = {}, packageManager = 'npx' } = {}) {
|
|
133
142
|
const selected = new Set(serverIds);
|
|
134
143
|
const entries = Object.create(null);
|
|
@@ -159,7 +168,7 @@ export function buildMcpEntries(destinations, env = process.env) {
|
|
|
159
168
|
for (const destination of selected) {
|
|
160
169
|
const isCf = destination.source === 'cloud-foundry';
|
|
161
170
|
const isLocalSapGui = destination.source === 'sap-gui-local';
|
|
162
|
-
const name =
|
|
171
|
+
const name = generatedDestinationServerName(destination);
|
|
163
172
|
if (Object.hasOwn(entries, name)) throw new Error(`Duplicate MCP destination server name: ${name} (two destination names normalize to the same lowercase server name)`);
|
|
164
173
|
const entryEnv = {
|
|
165
174
|
...(h2oUrl ? { H2O_URL: String(h2oUrl) } : {}),
|
|
@@ -402,9 +411,9 @@ export async function repairManagedMcpConfig(discoveredDestinations, { env = pro
|
|
|
402
411
|
for (const [serverName, entry] of Object.entries(config.servers)) {
|
|
403
412
|
if (!isManagedBasDestinationEntry(entry)) continue;
|
|
404
413
|
const destinationName = destinationValue(entry.env);
|
|
405
|
-
const expectedName = generatedServerName(destinationName);
|
|
406
414
|
const destination = basDestinations.get(destinationName);
|
|
407
415
|
if (!destination) continue;
|
|
416
|
+
const expectedName = generatedDestinationServerName(destination);
|
|
408
417
|
if (expectedName !== serverName) {
|
|
409
418
|
// Legacy mixed-case entries migrate to the normalized lowercase key.
|
|
410
419
|
// A user-owned entry on the target key blocks the rename; merging is
|
package/src/setup.mjs
CHANGED
|
@@ -2,7 +2,7 @@ import { createInterface } from 'node:readline/promises';
|
|
|
2
2
|
import { stdin, stdout } from 'node:process';
|
|
3
3
|
import { platform } from 'node:os';
|
|
4
4
|
import { isIP } from 'node:net';
|
|
5
|
-
import { checkboxPrompt, colorText, formatStatus, textPrompt } from './terminal-ui.mjs';
|
|
5
|
+
import { checkboxPrompt, colorText, formatStatus, selectPrompt, textPrompt } from './terminal-ui.mjs';
|
|
6
6
|
import { discoverDestinations, remediation } from './bas-discovery.mjs';
|
|
7
7
|
import { defaultAdtUrl, discoverSapGuiSystems } from './local-sap-gui.mjs';
|
|
8
8
|
import { discoverCloudFoundryDestinations, getCloudFoundryTarget, deleteManagedCloudFoundryServiceKeys, findOrphanedCloudFoundryServiceKeys } from './cf-destination.mjs';
|
|
@@ -56,17 +56,18 @@ function windowsSsoSetupAvailable(env = process.env) {
|
|
|
56
56
|
|
|
57
57
|
async function chooseLocalAuthMode(destination, { input = stdin, output = stdout, env = process.env } = {}) {
|
|
58
58
|
if (!windowsSsoSetupAvailable(env)) return 'basic';
|
|
59
|
-
|
|
60
|
-
message: `Authentication for ${destination.name}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
59
|
+
return await selectPrompt({
|
|
60
|
+
message: `Authentication for ${destination.name}`,
|
|
61
|
+
defaultValue: 'basic',
|
|
62
|
+
choices: [
|
|
63
|
+
{ name: 'password', value: 'basic', shortcut: 'p' },
|
|
64
|
+
{ name: 'windows-sso (Kerberos/SPNEGO, silent current Windows logon)', value: 'windows-sso', shortcut: 's' },
|
|
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' }
|
|
69
|
+
]
|
|
67
70
|
}, { input, output });
|
|
68
|
-
const normalized = String(answer || 'password').trim().toLowerCase();
|
|
69
|
-
return ['sso', 'windows-sso'].includes(normalized) ? 'windows-sso' : 'basic';
|
|
70
71
|
}
|
|
71
72
|
|
|
72
73
|
async function confirmCloudFoundryImport({ input = stdin, output = stdout } = {}) {
|
|
@@ -387,7 +388,7 @@ export async function runSetup({
|
|
|
387
388
|
print(output, '');
|
|
388
389
|
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'));
|
|
389
390
|
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.');
|
|
390
|
-
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.');
|
|
391
|
+
if (windowsSsoSetupAvailable(env)) print(output, ' Windows SSO is experimental and only applies to ADT systems configured for HTTP Integrated Authentication (Negotiate/SPNEGO). It uses the current Windows logon session, including smart-card-backed Windows logon; the toolkit cannot prompt for or accept manual bearer/SAML tokens. Username/password remains the default.');
|
|
391
392
|
const selectedNameCounts = selected.reduce((counts, destination) => {
|
|
392
393
|
const key = String(destination.name).toLowerCase();
|
|
393
394
|
counts.set(key, (counts.get(key) || 0) + 1);
|
|
@@ -441,26 +442,56 @@ export async function runSetup({
|
|
|
441
442
|
}
|
|
442
443
|
}
|
|
443
444
|
const authMode = await chooseLocalAuthMode({ ...destination, name: promptLabel }, { input, output, env });
|
|
444
|
-
destination.authentication =
|
|
445
|
+
destination.authentication = ['windows-sso', 'windows-credential-ui', 'windows-sso-basic-fallback'].includes(authMode) ? 'WindowsSSO' : 'Basic';
|
|
445
446
|
destination.authMode = authMode;
|
|
446
447
|
for (const client of clients) {
|
|
447
448
|
const name = localClientDestinationName(destination, client, { multipleClients: clients.length > 1, duplicateSystemName });
|
|
448
449
|
const localDestination = { ...destination, name, serverName: name, client };
|
|
450
|
+
const userInputId = `sap-ai-dev-${localDestination.serverName || localDestination.name}-user`.toLowerCase().replace(/[^a-z0-9_-]+/g, '-');
|
|
451
|
+
const passwordInputId = `sap-ai-dev-${localDestination.serverName || localDestination.name}-password`.toLowerCase().replace(/[^a-z0-9_-]+/g, '-');
|
|
452
|
+
const credentialInputs = [
|
|
453
|
+
{ id: userInputId, type: 'promptString', description: `SAP user for ${localDestination.name}` },
|
|
454
|
+
{ id: passwordInputId, type: 'promptString', description: `SAP password for ${localDestination.name}`, password: true }
|
|
455
|
+
];
|
|
449
456
|
if (authMode === 'windows-sso') {
|
|
450
457
|
localDestination.childEnv = { SAP_AUTH_MODE: 'windows-sso' };
|
|
451
458
|
localDestination.inputs = [];
|
|
459
|
+
} else if (authMode === 'windows-credential-ui') {
|
|
460
|
+
localDestination.childEnv = {
|
|
461
|
+
SAP_AUTH_MODE: 'windows-sso',
|
|
462
|
+
SAP_AI_DEV_TOOLKIT_WINDOWS_CREDENTIAL_UI: 'true'
|
|
463
|
+
};
|
|
464
|
+
localDestination.inputs = [];
|
|
465
|
+
} else if (authMode === 'windows-sso-basic-fallback') {
|
|
466
|
+
localDestination.childEnv = {
|
|
467
|
+
SAP_AUTH_MODE: 'windows-sso',
|
|
468
|
+
SAP_AUTH_FALLBACK_MODE: 'basic',
|
|
469
|
+
SAP_USER: `\${input:${userInputId}}`,
|
|
470
|
+
SAP_PASSWORD: `\${input:${passwordInputId}}`
|
|
471
|
+
};
|
|
472
|
+
localDestination.inputs = credentialInputs;
|
|
473
|
+
} else if (authMode === 'browser-saml') {
|
|
474
|
+
localDestination.childEnv = {
|
|
475
|
+
SAP_AUTH_MODE: 'browser-saml',
|
|
476
|
+
SAP_BROWSER_AUTH: 'true',
|
|
477
|
+
SAP_SAML_AUTH: 'true'
|
|
478
|
+
};
|
|
479
|
+
localDestination.inputs = [];
|
|
480
|
+
} else if (authMode === 'saml-password') {
|
|
481
|
+
localDestination.childEnv = {
|
|
482
|
+
SAP_AUTH_MODE: 'saml-password',
|
|
483
|
+
SAP_SAML_AUTH: 'true',
|
|
484
|
+
SAP_SAML_USER: `\${input:${userInputId}}`,
|
|
485
|
+
SAP_SAML_PASSWORD: `\${input:${passwordInputId}}`
|
|
486
|
+
};
|
|
487
|
+
localDestination.inputs = credentialInputs;
|
|
452
488
|
} else {
|
|
453
|
-
const userInputId = `sap-ai-dev-${localDestination.serverName || localDestination.name}-user`.toLowerCase().replace(/[^a-z0-9_-]+/g, '-');
|
|
454
|
-
const passwordInputId = `sap-ai-dev-${localDestination.serverName || localDestination.name}-password`.toLowerCase().replace(/[^a-z0-9_-]+/g, '-');
|
|
455
489
|
localDestination.childEnv = {
|
|
456
490
|
SAP_AUTH_MODE: 'basic',
|
|
457
491
|
SAP_USER: `\${input:${userInputId}}`,
|
|
458
492
|
SAP_PASSWORD: `\${input:${passwordInputId}}`
|
|
459
493
|
};
|
|
460
|
-
localDestination.inputs =
|
|
461
|
-
{ id: userInputId, type: 'promptString', description: `SAP user for ${localDestination.name}` },
|
|
462
|
-
{ id: passwordInputId, type: 'promptString', description: `SAP password for ${localDestination.name}`, password: true }
|
|
463
|
-
];
|
|
494
|
+
localDestination.inputs = credentialInputs;
|
|
464
495
|
}
|
|
465
496
|
expanded.push(localDestination);
|
|
466
497
|
}
|
package/src/terminal-ui.mjs
CHANGED
|
@@ -115,6 +115,104 @@ function checkboxPageSize(output, choiceCount) {
|
|
|
115
115
|
return clamp(Math.min(choiceCount, rows - 7), 1, Math.max(1, choiceCount));
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
+
export async function selectPrompt({ message, choices, defaultValue } = {}, { input = process.stdin, output = process.stdout } = {}) {
|
|
119
|
+
if (!Array.isArray(choices) || !choices.length) return undefined;
|
|
120
|
+
let cursor = choices.findIndex(choice => !choice.disabled && (choice.value === defaultValue || choice.checked));
|
|
121
|
+
if (cursor === -1) cursor = firstEnabledIndex(choices);
|
|
122
|
+
let top = 0;
|
|
123
|
+
let renderedLines = 0;
|
|
124
|
+
let done = false;
|
|
125
|
+
let previousRawMode;
|
|
126
|
+
|
|
127
|
+
const write = text => output.write(text);
|
|
128
|
+
const clearRendered = () => {
|
|
129
|
+
if (!renderedLines) return;
|
|
130
|
+
write(`\u001b[${renderedLines}A\u001b[J`);
|
|
131
|
+
renderedLines = 0;
|
|
132
|
+
};
|
|
133
|
+
const ensureVisible = pageSize => {
|
|
134
|
+
if (cursor < top) top = cursor;
|
|
135
|
+
if (cursor >= top + pageSize) top = cursor - pageSize + 1;
|
|
136
|
+
top = clamp(top, 0, Math.max(0, choices.length - pageSize));
|
|
137
|
+
};
|
|
138
|
+
const render = () => {
|
|
139
|
+
const columns = Number.isFinite(output?.columns) ? output.columns : 80;
|
|
140
|
+
const pageSize = checkboxPageSize(output, choices.length);
|
|
141
|
+
ensureVisible(pageSize);
|
|
142
|
+
const lines = [];
|
|
143
|
+
lines.push(truncateToColumns(`${message} (↑/↓: choose, Enter: confirm)`, columns));
|
|
144
|
+
const end = Math.min(choices.length, top + pageSize);
|
|
145
|
+
for (let index = top; index < end; index += 1) {
|
|
146
|
+
const choice = choices[index];
|
|
147
|
+
const pointer = index === cursor ? '❯' : ' ';
|
|
148
|
+
const marker = index === cursor ? colorText('●', 'green', output) : '○';
|
|
149
|
+
const disabled = choice.disabled ? ` — ${choice.disabled}` : '';
|
|
150
|
+
const label = `${pointer}${marker} ${choice.name}${disabled}`;
|
|
151
|
+
lines.push(truncateToColumns(label, columns));
|
|
152
|
+
}
|
|
153
|
+
if (choices.length > pageSize) {
|
|
154
|
+
lines.push(truncateToColumns(colorText(` Showing ${top + 1}-${end} of ${choices.length}; use ↑/↓ to scroll.`, 'cyan', output), columns));
|
|
155
|
+
}
|
|
156
|
+
clearRendered();
|
|
157
|
+
write(`${lines.join('\n')}\n`);
|
|
158
|
+
renderedLines = physicalRows(lines, columns);
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
return new Promise((resolve, reject) => {
|
|
162
|
+
const cleanup = () => {
|
|
163
|
+
input.off('keypress', onKeypress);
|
|
164
|
+
if (typeof input.setRawMode === 'function' && previousRawMode !== undefined) input.setRawMode(previousRawMode);
|
|
165
|
+
if (typeof input.pause === 'function') input.pause();
|
|
166
|
+
write('\u001b[?25h');
|
|
167
|
+
};
|
|
168
|
+
const finish = choice => {
|
|
169
|
+
if (done) return;
|
|
170
|
+
done = true;
|
|
171
|
+
clearRendered();
|
|
172
|
+
write(`${stripAnsi(message)} ${choice?.name || choice?.value || ''}\n`);
|
|
173
|
+
cleanup();
|
|
174
|
+
resolve(choice?.value);
|
|
175
|
+
};
|
|
176
|
+
const fail = error => {
|
|
177
|
+
if (done) return;
|
|
178
|
+
done = true;
|
|
179
|
+
cleanup();
|
|
180
|
+
reject(error);
|
|
181
|
+
};
|
|
182
|
+
const onKeypress = (chunk, key = {}) => {
|
|
183
|
+
if (key.ctrl && key.name === 'c') return fail(new Error('Prompt interrupted'));
|
|
184
|
+
if (key.name === 'up' || key.name === 'k') cursor = nextEnabledIndex(choices, cursor, -1);
|
|
185
|
+
else if (key.name === 'down' || key.name === 'j') cursor = nextEnabledIndex(choices, cursor, 1);
|
|
186
|
+
else if (key.name === 'return' || key.name === 'enter') return finish(choices[cursor]);
|
|
187
|
+
else if (chunk) {
|
|
188
|
+
const typed = String(chunk).toLowerCase();
|
|
189
|
+
let match = choices.findIndex(choice => !choice.disabled && String(choice.shortcut || '').toLowerCase() === typed);
|
|
190
|
+
if (match === -1) {
|
|
191
|
+
match = choices.findIndex(choice => !choice.disabled && (
|
|
192
|
+
String(choice.value || '').toLowerCase().startsWith(typed) ||
|
|
193
|
+
String(choice.name || '').toLowerCase().startsWith(typed)
|
|
194
|
+
));
|
|
195
|
+
}
|
|
196
|
+
if (match !== -1) cursor = match;
|
|
197
|
+
else return;
|
|
198
|
+
} else return;
|
|
199
|
+
render();
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
try {
|
|
203
|
+
readline.emitKeypressEvents(input);
|
|
204
|
+
previousRawMode = input.isRaw;
|
|
205
|
+
if (typeof input.setRawMode === 'function') input.setRawMode(true);
|
|
206
|
+
if (typeof input.resume === 'function') input.resume();
|
|
207
|
+
write('\u001b[?25l');
|
|
208
|
+
input.on('keypress', onKeypress);
|
|
209
|
+
render();
|
|
210
|
+
} catch (error) {
|
|
211
|
+
fail(error);
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
118
216
|
export async function checkboxPrompt({ message, choices, required = false, shortcuts = { all: 'a' }, validate } = {}, { input = process.stdin, output = process.stdout } = {}) {
|
|
119
217
|
if (!Array.isArray(choices) || !choices.length) return [];
|
|
120
218
|
const selectable = enabledChoices(choices);
|
|
@@ -21,9 +21,37 @@ function validateTarget(value) {
|
|
|
21
21
|
return url;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
function authChallengeValues(headers) {
|
|
25
|
+
const value = headers?.get?.('www-authenticate');
|
|
26
|
+
if (!value) return [];
|
|
27
|
+
const parts = [];
|
|
28
|
+
let start = 0;
|
|
29
|
+
let quoted = false;
|
|
30
|
+
let escaped = false;
|
|
31
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
32
|
+
const char = value[index];
|
|
33
|
+
if (escaped) {
|
|
34
|
+
escaped = false;
|
|
35
|
+
} else if (char === '\\' && quoted) {
|
|
36
|
+
escaped = true;
|
|
37
|
+
} else if (char === '"') {
|
|
38
|
+
quoted = !quoted;
|
|
39
|
+
} else if (char === ',' && !quoted) {
|
|
40
|
+
parts.push(value.slice(start, index).trim());
|
|
41
|
+
start = index + 1;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
parts.push(value.slice(start).trim());
|
|
45
|
+
return parts.filter(Boolean);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function hasAuthChallenge(headers, scheme) {
|
|
49
|
+
const pattern = new RegExp(`^${scheme}(?:\\s|$)`, 'i');
|
|
50
|
+
return authChallengeValues(headers).some(part => pattern.test(part));
|
|
51
|
+
}
|
|
52
|
+
|
|
24
53
|
function hasNegotiateChallenge(headers) {
|
|
25
|
-
|
|
26
|
-
return /(?:^|,)\s*Negotiate\b/i.test(value);
|
|
54
|
+
return hasAuthChallenge(headers, 'Negotiate');
|
|
27
55
|
}
|
|
28
56
|
|
|
29
57
|
function headerObject(headers) {
|
|
@@ -99,9 +127,10 @@ function helperCommand(env) {
|
|
|
99
127
|
const command = env.SAP_AI_DEV_TOOLKIT_NEGOTIATE_HELPER || env.SAP_AI_DEV_TOOLKIT_SSPI_HELPER;
|
|
100
128
|
if (command) return { command, args: [] };
|
|
101
129
|
if (process.platform !== 'win32') return null;
|
|
130
|
+
const interactiveCredentialUi = env.SAP_AI_DEV_TOOLKIT_WINDOWS_CREDENTIAL_UI === 'true' || env.SAP_WINDOWS_CREDENTIAL_UI === 'true';
|
|
102
131
|
return {
|
|
103
132
|
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')]
|
|
133
|
+
args: ['-NoLogo', '-NoProfile', ...(interactiveCredentialUi ? [] : ['-NonInteractive']), '-ExecutionPolicy', 'Bypass', '-File', join(root, 'scripts', 'windows-negotiate-helper.ps1')]
|
|
105
134
|
};
|
|
106
135
|
}
|
|
107
136
|
|
|
@@ -122,7 +151,7 @@ function execHelper(helper, payload, execFileImpl = nodeExecFile, env = process.
|
|
|
122
151
|
env: { ...process.env, ...env }
|
|
123
152
|
}, (error, stdout) => {
|
|
124
153
|
// execFile errors can contain helper stdout/stderr, including tokens.
|
|
125
|
-
if (error) return reject(safeError('Windows SSO helper failed; verify the
|
|
154
|
+
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.'));
|
|
126
155
|
const token = String(stdout || '').trim();
|
|
127
156
|
if (!/^[A-Za-z0-9+/=_-]+$/.test(token)) return reject(safeError('Windows SSO helper returned an invalid Negotiate token'));
|
|
128
157
|
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.'));
|
|
@@ -135,11 +164,33 @@ function execHelper(helper, payload, execFileImpl = nodeExecFile, env = process.
|
|
|
135
164
|
async function acquireNegotiateToken({ targetUrl, challenge, env, execFileImpl }) {
|
|
136
165
|
const helper = helperCommand(env);
|
|
137
166
|
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.');
|
|
167
|
+
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.');
|
|
139
168
|
}
|
|
140
169
|
return execHelper(helper, { url: targetUrl, challenge, mechanism: 'Negotiate' }, execFileImpl, env);
|
|
141
170
|
}
|
|
142
171
|
|
|
172
|
+
function basicFallbackCredentials(env) {
|
|
173
|
+
const mode = String(env.SAP_AUTH_FALLBACK_MODE || env.SAP_AI_DEV_TOOLKIT_AUTH_FALLBACK_MODE || '').trim().toLowerCase();
|
|
174
|
+
const enabled = mode === 'basic' || String(env.SAP_AI_DEV_TOOLKIT_SSO_BASIC_FALLBACK || '').toLowerCase() === 'true';
|
|
175
|
+
const user = String(env.SAP_USER || env.SAP_USERNAME || '').trim();
|
|
176
|
+
const password = String(env.SAP_PASSWORD || env.SAP_PASS || '');
|
|
177
|
+
if (!enabled || !user || !password) return null;
|
|
178
|
+
return { user, password };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function hasBasicChallenge(headers) {
|
|
182
|
+
return hasAuthChallenge(headers, 'Basic');
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function retryWithBasicFallback({ req, target, headers, body, fetchImpl, env }) {
|
|
186
|
+
const credentials = basicFallbackCredentials(env);
|
|
187
|
+
if (!credentials) return null;
|
|
188
|
+
return forward(req, target, {
|
|
189
|
+
...headers,
|
|
190
|
+
Authorization: `Basic ${Buffer.from(`${credentials.user}:${credentials.password}`).toString('base64')}`
|
|
191
|
+
}, body, fetchImpl);
|
|
192
|
+
}
|
|
193
|
+
|
|
143
194
|
async function forward(req, targetUrl, headers, body, fetchImpl) {
|
|
144
195
|
return fetchImpl(targetUrl, {
|
|
145
196
|
method: req.method,
|
|
@@ -181,15 +232,26 @@ export async function createWindowsSsoAdtProxy({ destinationUrl, env = process.e
|
|
|
181
232
|
let response = await forward(req, target, headers, body, fetchImpl);
|
|
182
233
|
if (response.status === 401 && hasNegotiateChallenge(response.headers)) {
|
|
183
234
|
const challenge = response.headers.get('www-authenticate') || '';
|
|
235
|
+
const cookies = mergeCookieHeader(headers.cookie || headers.Cookie, setCookies(response.headers));
|
|
184
236
|
const cancel = response.body?.cancel?.();
|
|
185
237
|
await cancel?.catch?.(() => {});
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
238
|
+
try {
|
|
239
|
+
const token = await acquireNegotiateToken({ targetUrl: target.href, challenge, env, execFileImpl });
|
|
240
|
+
const retryHeaders = { ...headers, Authorization: `Negotiate ${token}` };
|
|
241
|
+
delete retryHeaders.cookie;
|
|
242
|
+
delete retryHeaders.Cookie;
|
|
243
|
+
if (cookies) retryHeaders.Cookie = cookies;
|
|
244
|
+
response = await forward(req, target, retryHeaders, body, fetchImpl);
|
|
245
|
+
} catch (error) {
|
|
246
|
+
response = hasBasicChallenge(response.headers)
|
|
247
|
+
? await retryWithBasicFallback({ req, target, headers: { ...headers, ...(cookies ? { Cookie: cookies } : {}) }, body, fetchImpl, env })
|
|
248
|
+
: null;
|
|
249
|
+
if (!response) throw error;
|
|
250
|
+
}
|
|
251
|
+
} else if (response.status === 401 && hasBasicChallenge(response.headers) && basicFallbackCredentials(env)) {
|
|
252
|
+
const cancel = response.body?.cancel?.();
|
|
253
|
+
await cancel?.catch?.(() => {});
|
|
254
|
+
response = await retryWithBasicFallback({ req, target, headers, body, fetchImpl, env }) || response;
|
|
193
255
|
}
|
|
194
256
|
writeFetchResponse(res, response, base, proxyOrigin);
|
|
195
257
|
} catch (error) {
|