sap-ai-dev-toolkit 0.9.92 → 0.9.97
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 +5 -2
- 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/patches/vsp-bas-proxy-auth.patch +109 -9
- package/src/launcher.mjs +38 -19
- package/src/mcp-config.mjs +48 -7
- package/src/mcp-proxy.mjs +48 -15
- package/src/sso-browsers.mjs +90 -0
package/README.md
CHANGED
|
@@ -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 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: the first sign-in opens an Edge window immediately, and an expired session is refreshed without a window for up to 15 seconds before Edge opens again. While a sign-in is pending, the MCP server output repeats a reminder every 15 seconds.
|
|
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: the first sign-in opens an Edge window immediately, and an expired session is refreshed without a window for up to 15 seconds before Edge opens again. While a sign-in is pending, the MCP server output repeats a reminder every 15 seconds. If a sign-in fails — the browser cannot start, its window is closed, or the sign-in times out — the server retries with the next Chromium-based browser it finds (Microsoft Edge, Google Chrome, Brave, Vivaldi, Chromium, including per-user installs and Windows App Paths registrations), or once more with the only one, and logs each attempt with its reason.
|
|
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.
|
|
@@ -338,7 +338,7 @@ Some current npm versions also require install hooks to be approved. If npm repo
|
|
|
338
338
|
|
|
339
339
|
The setup report uses icons and terminal colors; set `NO_COLOR=1` to disable ANSI colors. Long-running steps (BAS destination discovery, ADT probes, VSP runtime provisioning, `--doctor` checks) show an animated progress line so the terminal never looks stuck; it appears only on interactive terminals and is disabled automatically on CI — set `SAP_AI_DEV_TOOLKIT_DISABLE_SCAN_ANIMATION=true` to turn it off. The table is a weather report, not a bouncer: green **PASS** means the ADT probe responded, red **FAIL** means it failed, and yellow **SKIPPED** means it was skipped. Probe failures do not block MCP registration or startup for destinations you select.
|
|
340
340
|
|
|
341
|
-
Run `sap-ai-dev --setup` later to change the destination selection or remove generated destination entries. Run `sap-ai-dev --setup --tools` to also choose optional companion tools. Generated destination entries use the global `sap-ai-dev` command when it is on `PATH
|
|
341
|
+
Run `sap-ai-dev --setup` later to change the destination selection or remove generated destination entries. Run `sap-ai-dev --setup --tools` to also choose optional companion tools. Generated destination entries use the global `sap-ai-dev` command when it is on `PATH` and is the same package version as the setup that writes them; otherwise (for example after a pure npx setup, or when an older global install is still on `PATH`) they automatically launch the pinned package version through `npx`, routed through `cmd /c npx` on Windows so every MCP host can spawn them. Setup warns when it skips a global install of another version. Pass `--npx` to force the npx launcher even when a global command exists; companion tools already use `npx` with their own npm packages.
|
|
342
342
|
|
|
343
343
|
If a setup step is skipped or fails, rerun it from an interactive terminal:
|
|
344
344
|
|
|
@@ -811,6 +811,7 @@ The response contains a `tools` array. A `RunQuery` entry resembles this excerpt
|
|
|
811
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_SSO_FIRST_LOGIN` | Local SSO entries default to `window`: with no cached session, VSP opens the Edge sign-in window at once. Set `silent` to try a hidden browser first. |
|
|
813
813
|
| `SAP_SSO_SILENT_TIMEOUT` | How long a hidden SSO refresh may run before the Edge window opens, as a Go duration. Local SSO entries default to `15s`; VSP's own default is `45s`. |
|
|
814
|
+
| `SAP_BROWSER_EXEC` | Full path of the Chromium-based browser for SSO sign-in. It is tried first; the other browsers found on the machine remain fallbacks. |
|
|
814
815
|
| `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. |
|
|
815
816
|
| `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. |
|
|
816
817
|
| `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. |
|
|
@@ -823,6 +824,7 @@ The response contains a `tools` array. A `RunQuery` entry resembles this excerpt
|
|
|
823
824
|
| `SAP_AI_DEV_TOOLKIT_BINARY_URL` | Alternate VSP binary download URL (checksum-verified). Legacy `BAS_VSP_BINARY_URL` still works. |
|
|
824
825
|
| `SAP_AI_DEV_TOOLKIT_RELEASE_BASE_URL` | Override the base URL that VSP release binaries are downloaded from. |
|
|
825
826
|
| `SAP_AI_DEV_TOOLKIT_CACHE_DIR` | Binary cache directory. Legacy `BAS_VSP_CACHE_DIR` still works. |
|
|
827
|
+
| `SAP_AI_DEV_TOOLKIT_VERSION` | Written by setup into each destination entry: the toolkit version that configured it. An older `sap-ai-dev` started from that entry hands off to this version through `npx` (see Self-healing modes). |
|
|
826
828
|
| `BAS_CF_SPACE_GUID`, `BAS_CF_DESTINATION_INSTANCE_GUID`, `BAS_CF_DESTINATION_INSTANCE`, `BAS_CF_DESTINATION_KEY`, `BAS_CF_DESTINATION_NAME`, `BAS_CF_CONNECTIVITY_INSTANCE_GUID`, `BAS_CF_CONNECTIVITY_INSTANCE`, `BAS_CF_CONNECTIVITY_KEY` | Written into generated Cloud Foundry destination entries; they reference service instances and key **names** (never credentials). |
|
|
827
829
|
| `GO_BINARY` | Explicit Go executable for the repository-only `build:vsp` script; not used during installation. |
|
|
828
830
|
| `SAP_AI_DEV_TOOLKIT_SKIP_PROBE=true` | Skip destination probes; useful for controlled diagnostics or fixtures. |
|
|
@@ -833,6 +835,7 @@ The response contains a `tools` array. A `RunQuery` entry resembles this excerpt
|
|
|
833
835
|
|
|
834
836
|
VSP children connect to each configured destination URL directly. BAS destination authentication remains selected through VSP's `--proxy-auth` path; Cloud Foundry on-premise destinations continue through the separate Connectivity proxy.
|
|
835
837
|
Child crash recovery restarts a failed VSP child, re-initializes it, re-registers tools, and retries the interrupted `tools/call` once before surfacing an error. State-changing tools are not retried.
|
|
838
|
+
Stale launcher recovery: when an MCP entry starts a `sap-ai-dev` older than the `SAP_AI_DEV_TOOLKIT_VERSION` setup recorded in it (for example an outdated global install left on `PATH`), the launcher logs a warning and hands off to that version through `npx` on the same stdio, so the host never runs the stale launcher or VSP binary. A source checkout (`npm link`) always runs as-is.
|
|
836
839
|
Existing installs that still set the previous `BAS_VSP_*` environment variables remain supported. The setup wizard writes new MCP entries with the `SAP_AI_DEV_TOOLKIT_*` names.
|
|
837
840
|
|
|
838
841
|
#### Server logging
|
package/dist/checksums.txt
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
0be30e23546799f11b2632cb77cb1eac9f5d6f1ed900f6d8c6843b8909827341 vsp-linux-x64
|
|
2
|
+
6fab2480c6cf8b9600ae5ba28155ada86f43a2968ef6a324c93b544c13748d8d vsp-linux-arm64
|
|
3
|
+
63f889fda4cc529f9cd3eeb4b106bd070cffa9295ee4a781e80a8a23f268a9b1 vsp-darwin-x64
|
|
4
|
+
715f5e58f34908eb0357db456c43878361cc8c94e39e39427100326a30fb85a2 vsp-darwin-arm64
|
|
5
|
+
e8b61a148451758ecaca81789d182c22f43582d394dd846de5afc8939f94e082 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
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
diff --git a/cmd/vsp/main.go b/cmd/vsp/main.go
|
|
2
|
-
index 5007935..
|
|
2
|
+
index 5007935..3b1fdd8 100755
|
|
3
3
|
--- a/cmd/vsp/main.go
|
|
4
4
|
+++ b/cmd/vsp/main.go
|
|
5
5
|
@@ -100,6 +100,7 @@ func init() {
|
|
@@ -89,7 +89,7 @@ index 5007935..715c0e9 100755
|
|
|
89
89
|
if !ssoRequested(cmd) {
|
|
90
90
|
return nil
|
|
91
91
|
}
|
|
92
|
-
@@ -626,22 +657,
|
|
92
|
+
@@ -626,22 +657,28 @@ func processSSOAuth(cmd *cobra.Command) error {
|
|
93
93
|
}
|
|
94
94
|
|
|
95
95
|
onExpiry := stringOpt("sso-on-expiry", "SSO_ON_EXPIRY")
|
|
@@ -113,6 +113,7 @@ index 5007935..715c0e9 100755
|
|
|
113
113
|
+ TriggerURL: stringOpt("sso-trigger-url", "SSO_TRIGGER_URL"),
|
|
114
114
|
+ Profile: stringOpt("sso-profile", "SSO_PROFILE"),
|
|
115
115
|
+ HelperPath: stringOpt("sso-helper", "SSO_HELPER"),
|
|
116
|
+
+ BrowserPath: stringOpt("browser-exec", "BROWSER_EXEC"),
|
|
116
117
|
+ Interactive: !strings.EqualFold(onExpiry, "error"),
|
|
117
118
|
+ SilentTimeout: silentTimeout,
|
|
118
119
|
+ Insecure: cfg.InsecureSkipVerify,
|
|
@@ -127,7 +128,7 @@ index 5007935..715c0e9 100755
|
|
|
127
128
|
if err != nil {
|
|
128
129
|
return fmt.Errorf("browser SSO: %w", err)
|
|
129
130
|
}
|
|
130
|
-
@@ -657,6 +
|
|
131
|
+
@@ -657,6 +694,9 @@ func processSSOAuth(cmd *cobra.Command) error {
|
|
131
132
|
}
|
|
132
133
|
|
|
133
134
|
func processSAMLAuth(cmd *cobra.Command) error {
|
|
@@ -137,7 +138,7 @@ index 5007935..715c0e9 100755
|
|
|
137
138
|
samlAuth, _ := cmd.Flags().GetBool("saml-auth")
|
|
138
139
|
if !samlAuth && !viper.GetBool("SAML_AUTH") {
|
|
139
140
|
return nil
|
|
140
|
-
@@ -752,6 +
|
|
141
|
+
@@ -752,6 +792,9 @@ func processSAMLAuth(cmd *cobra.Command) error {
|
|
141
142
|
}
|
|
142
143
|
|
|
143
144
|
func processCookieAuth(cmd *cobra.Command) error {
|
|
@@ -312,6 +313,19 @@ index dd433d6..5553b7f 100644
|
|
|
312
313
|
),
|
|
313
314
|
), s.handleActivateMultiple)
|
|
314
315
|
}
|
|
316
|
+
diff --git a/pkg/adt/browser_auth.go b/pkg/adt/browser_auth.go
|
|
317
|
+
index ea3c1a9..5bc7a01 100644
|
|
318
|
+
--- a/pkg/adt/browser_auth.go
|
|
319
|
+
+++ b/pkg/adt/browser_auth.go
|
|
320
|
+
@@ -86,6 +86,8 @@ func friendlyBrowserName(path string) string {
|
|
321
|
+
return "Microsoft Edge"
|
|
322
|
+
case strings.Contains(lower, "brave"):
|
|
323
|
+
return "Brave"
|
|
324
|
+
+ case strings.Contains(lower, "vivaldi"):
|
|
325
|
+
+ return "Vivaldi"
|
|
326
|
+
case strings.Contains(lower, "chromium"):
|
|
327
|
+
return "Chromium"
|
|
328
|
+
default:
|
|
315
329
|
diff --git a/pkg/adt/client.go b/pkg/adt/client.go
|
|
316
330
|
index 979e908..c7e9501 100755
|
|
317
331
|
--- a/pkg/adt/client.go
|
|
@@ -549,19 +563,31 @@ index 0000000..80741da
|
|
|
549
563
|
+}
|
|
550
564
|
+
|
|
551
565
|
diff --git a/pkg/adt/sso.go b/pkg/adt/sso.go
|
|
552
|
-
index d67c4e8..
|
|
566
|
+
index d67c4e8..a03f740 100644
|
|
553
567
|
--- a/pkg/adt/sso.go
|
|
554
568
|
+++ b/pkg/adt/sso.go
|
|
555
|
-
@@ -
|
|
569
|
+
@@ -7,6 +7,7 @@ import (
|
|
570
|
+
"net/url"
|
|
571
|
+
"os"
|
|
572
|
+
"path/filepath"
|
|
573
|
+
+ "strings"
|
|
574
|
+
"time"
|
|
575
|
+
|
|
576
|
+
"github.com/chromedp/cdproto/network"
|
|
577
|
+
@@ -154,8 +155,11 @@ func CaptureSSOCookies(ctx context.Context, opts SSOOptions) (map[string]string,
|
|
578
|
+
// regardless, so a navigation error here is not fatal — the cookie poll
|
|
556
579
|
// below is the real verdict.
|
|
557
580
|
if err := chromedp.Run(timeoutCtx, chromedp.Navigate(opts.TriggerURL)); err != nil {
|
|
581
|
+
+ if chromedp.FromContext(timeoutCtx).Browser == nil {
|
|
582
|
+
+ return nil, browserStartFailure(opts.Headless, browserName, execPath, err)
|
|
583
|
+
+ }
|
|
558
584
|
if timeoutCtx.Err() != nil {
|
|
559
585
|
- return nil, fmt.Errorf("SSO capture timed out after %s", timeout)
|
|
560
586
|
+ return nil, navigationTimeout(opts.Headless, timeout)
|
|
561
587
|
}
|
|
562
588
|
if opts.Verbose {
|
|
563
589
|
fmt.Fprintf(os.Stderr, "[SSO] navigation reported %v (normal during SSO)\n", err)
|
|
564
|
-
@@ -174,6 +
|
|
590
|
+
@@ -174,6 +178,26 @@ func CaptureSSOCookies(ctx context.Context, opts SSOOptions) (map[string]string,
|
|
565
591
|
return cookies, nil
|
|
566
592
|
}
|
|
567
593
|
|
|
@@ -575,10 +601,66 @@ index d67c4e8..4e9dc45 100644
|
|
|
575
601
|
+ }
|
|
576
602
|
+ return fmt.Errorf("SSO capture timed out after %s", timeout)
|
|
577
603
|
+}
|
|
604
|
+
+
|
|
605
|
+
+// browserStartFailure ends a capture whose browser never came up; a silent one still escalates to a window.
|
|
606
|
+
+func browserStartFailure(headless bool, name, path string, err error) error {
|
|
607
|
+
+ detail := strings.Join(strings.Fields(err.Error()), " ")
|
|
608
|
+
+ if headless {
|
|
609
|
+
+ return fmt.Errorf("%w: %s did not start without a window (%s): %s", ErrSSOInteractiveRequired, name, path, detail)
|
|
610
|
+
+ }
|
|
611
|
+
+ return fmt.Errorf("%s could not be started from %s: %s", name, path, detail)
|
|
612
|
+
+}
|
|
578
613
|
+
|
|
579
614
|
// DefaultSSOProfileDir returns a per-host browser profile directory under the
|
|
580
615
|
// user's cache location. Keeping one profile per host means a silent refresh
|
|
581
616
|
// for one system is never disturbed by a login to another.
|
|
617
|
+
diff --git a/pkg/adt/sso_browser_start_test.go b/pkg/adt/sso_browser_start_test.go
|
|
618
|
+
new file mode 100644
|
|
619
|
+
index 0000000..88a9dbe
|
|
620
|
+
--- /dev/null
|
|
621
|
+
+++ b/pkg/adt/sso_browser_start_test.go
|
|
622
|
+
@@ -0,0 +1,41 @@
|
|
623
|
+
+package adt
|
|
624
|
+
+
|
|
625
|
+
+import (
|
|
626
|
+
+ "context"
|
|
627
|
+
+ "errors"
|
|
628
|
+
+ "os"
|
|
629
|
+
+ "path/filepath"
|
|
630
|
+
+ "runtime"
|
|
631
|
+
+ "strings"
|
|
632
|
+
+ "testing"
|
|
633
|
+
+ "time"
|
|
634
|
+
+)
|
|
635
|
+
+
|
|
636
|
+
+func TestBrowserThatCannotStartFailsFast(t *testing.T) {
|
|
637
|
+
+ if runtime.GOOS == "windows" {
|
|
638
|
+
+ t.Skip("the stand-in browser is a shell script")
|
|
639
|
+
+ }
|
|
640
|
+
+ browser := filepath.Join(t.TempDir(), "blocked-browser")
|
|
641
|
+
+ if err := os.WriteFile(browser, []byte("#!/bin/sh\necho 'blocked by policy' >&2\nexit 3\n"), 0o755); err != nil {
|
|
642
|
+
+ t.Fatal(err)
|
|
643
|
+
+ }
|
|
644
|
+
+ for _, headless := range []bool{true, false} {
|
|
645
|
+
+ started := time.Now()
|
|
646
|
+
+ _, err := CaptureSSOCookies(context.Background(), SSOOptions{
|
|
647
|
+
+ TriggerURL: "https://sap.example/sap/bc/adt/",
|
|
648
|
+
+ BrowserPath: browser,
|
|
649
|
+
+ Headless: headless,
|
|
650
|
+
+ Timeout: 45 * time.Second,
|
|
651
|
+
+ })
|
|
652
|
+
+ if elapsed := time.Since(started); elapsed > 15*time.Second {
|
|
653
|
+
+ t.Fatalf("headless=%v waited %v; a browser that never starts must not wait out the budget", headless, elapsed)
|
|
654
|
+
+ }
|
|
655
|
+
+ if err == nil || !strings.Contains(err.Error(), browser) || !strings.Contains(err.Error(), "blocked by policy") {
|
|
656
|
+
+ t.Fatalf("headless=%v err = %v, want the browser path and its output", headless, err)
|
|
657
|
+
+ }
|
|
658
|
+
+ // A silent start failure must still escalate to a visible window.
|
|
659
|
+
+ if errors.Is(err, ErrSSOInteractiveRequired) != headless {
|
|
660
|
+
+ t.Fatalf("headless=%v err = %v, want ErrSSOInteractiveRequired only when silent", headless, err)
|
|
661
|
+
+ }
|
|
662
|
+
+ }
|
|
663
|
+
+}
|
|
582
664
|
diff --git a/pkg/adt/sso_first_login_test.go b/pkg/adt/sso_first_login_test.go
|
|
583
665
|
new file mode 100644
|
|
584
666
|
index 0000000..f8efda4
|
|
@@ -660,10 +742,20 @@ index 0000000..c851021
|
|
|
660
742
|
+ }
|
|
661
743
|
+}
|
|
662
744
|
diff --git a/pkg/adt/sso_provider.go b/pkg/adt/sso_provider.go
|
|
663
|
-
index 8e5bed4..
|
|
745
|
+
index 8e5bed4..544b873 100644
|
|
664
746
|
--- a/pkg/adt/sso_provider.go
|
|
665
747
|
+++ b/pkg/adt/sso_provider.go
|
|
666
|
-
@@ -
|
|
748
|
+
@@ -42,6 +42,9 @@ type SSOConfig struct {
|
|
749
|
+
// HelperPath points at the Windows capture helper. Empty auto-discovers it.
|
|
750
|
+
HelperPath string
|
|
751
|
+
|
|
752
|
+
+ // BrowserPath is the Chromium-based browser to drive. Empty auto-detects one.
|
|
753
|
+
+ BrowserPath string
|
|
754
|
+
+
|
|
755
|
+
// Interactive permits escalating to a visible browser window when a silent
|
|
756
|
+
// capture cannot finish. Off means such a case is reported as an error
|
|
757
|
+
// naming the command to run, which is the right behaviour where no one is
|
|
758
|
+
@@ -180,6 +183,27 @@ func (p *SSOProvider) Login(ctx context.Context) (map[string]string, error) {
|
|
667
759
|
return p.acquire(ctx, true)
|
|
668
760
|
}
|
|
669
761
|
|
|
@@ -691,3 +783,11 @@ index 8e5bed4..6977a33 100644
|
|
|
691
783
|
// Clear removes the cached session, so the next call captures a fresh one.
|
|
692
784
|
func (p *SSOProvider) Clear() error {
|
|
693
785
|
p.mu.Lock()
|
|
786
|
+
@@ -240,6 +264,7 @@ func (p *SSOProvider) capture(ctx context.Context, trigger string, headed bool)
|
|
787
|
+
return CaptureSSOCookies(ctx, SSOOptions{
|
|
788
|
+
TriggerURL: trigger,
|
|
789
|
+
UserDataDir: p.cfg.Profile,
|
|
790
|
+
+ BrowserPath: p.cfg.BrowserPath,
|
|
791
|
+
Headless: !headed,
|
|
792
|
+
Timeout: p.timeout(headed),
|
|
793
|
+
Insecure: p.cfg.Insecure,
|
package/src/launcher.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { spawn } from 'node:child_process';
|
|
3
|
+
import { existsSync } from 'node:fs';
|
|
3
4
|
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
4
5
|
import { isIP } from 'node:net';
|
|
5
6
|
import { dirname, join } from 'node:path';
|
|
@@ -8,10 +9,11 @@ import { discoverDestinations, remediation, slugifyDestination, statusRows } fro
|
|
|
8
9
|
import { adtBaseUrl, adtPort, detectAdtUrl, discoverSapGuiSystems, hasExplicitPort, hasPathPrefix } from './local-sap-gui.mjs';
|
|
9
10
|
import { binaryTarget, cacheDirectory, downloadBinary, findBinary } from './binary.mjs';
|
|
10
11
|
import { MCPProxy } from './mcp-proxy.mjs';
|
|
11
|
-
import { installMcpConfig,
|
|
12
|
+
import { configuredVersionHandoff, installMcpConfig, npxPackageLauncher, repairManagedMcpConfig } from './mcp-config.mjs';
|
|
12
13
|
import { runSetup } from './setup.mjs';
|
|
13
14
|
import { resolveConfiguredCloudFoundryDestination } from './cf-destination.mjs';
|
|
14
15
|
import { createTlsServerNameAdtProxy } from './tls-adt-proxy.mjs';
|
|
16
|
+
import { findSsoBrowsers } from './sso-browsers.mjs';
|
|
15
17
|
import { withBrandedEnvironment } from './branding.mjs';
|
|
16
18
|
import { redactText } from './redact.mjs';
|
|
17
19
|
import { startProgress } from './terminal-ui.mjs';
|
|
@@ -267,6 +269,33 @@ function noProxyEnv(env, host) {
|
|
|
267
269
|
return { NO_PROXY: noProxy, no_proxy: noProxy };
|
|
268
270
|
}
|
|
269
271
|
|
|
272
|
+
// Runs a child on this process's stdio and mirrors its exit; rejects only when it cannot start.
|
|
273
|
+
async function runOnOwnStdio(command, commandArgs, env) {
|
|
274
|
+
const child = spawn(command, commandArgs, { env, stdio: 'inherit' });
|
|
275
|
+
process.once('SIGINT', () => child.kill('SIGINT'));
|
|
276
|
+
process.once('SIGTERM', () => child.kill('SIGTERM'));
|
|
277
|
+
const [code, signal] = await new Promise((resolve, reject) => {
|
|
278
|
+
child.once('error', reject);
|
|
279
|
+
child.once('exit', (exitCode, exitSignal) => resolve([exitCode, exitSignal]));
|
|
280
|
+
});
|
|
281
|
+
if (signal) process.kill(process.pid, signal);
|
|
282
|
+
process.exitCode = code ?? 1;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// A source checkout runs as-is: its developer chose that code.
|
|
286
|
+
async function handOffToConfiguredVersion() {
|
|
287
|
+
const handoff = existsSync(join(root, '.git')) ? null : configuredVersionHandoff(runtimeEnv, pkg.version);
|
|
288
|
+
if (!handoff) return false;
|
|
289
|
+
logLine(`[sap-ai-dev] sap-ai-dev-toolkit ${pkg.version} is older than ${handoff.version}, which configured this MCP entry; self-healing: handing off to sap-ai-dev-toolkit@${handoff.version} through npx. Run "npm install -g sap-ai-dev-toolkit@${handoff.version}" to update the global install.`);
|
|
290
|
+
try {
|
|
291
|
+
await runOnOwnStdio(handoff.command, handoff.args, { ...runtimeEnv, SAP_AI_DEV_TOOLKIT_HANDOFF: pkg.version });
|
|
292
|
+
return true;
|
|
293
|
+
} catch (error) {
|
|
294
|
+
logLine(`[sap-ai-dev] handoff to sap-ai-dev-toolkit@${handoff.version} could not start (${error.message}); continuing with ${pkg.version}`);
|
|
295
|
+
return false;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
270
299
|
async function discoverForCommand() {
|
|
271
300
|
const env = { ...runtimeEnv };
|
|
272
301
|
if (env.SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE === 'cloud-foundry') env.SAP_AI_DEV_TOOLKIT_DESTINATION = '';
|
|
@@ -301,6 +330,8 @@ async function configuredLocalSapGuiDestination(env) {
|
|
|
301
330
|
if (sso) {
|
|
302
331
|
const cacheKey = `${systemId || new URL(configuredUrl).hostname}-${client}`.toLowerCase().replace(/[^a-z0-9.-]+/g, '-');
|
|
303
332
|
if (isIP(new URL(adt.url).hostname.replace(/^\[(.*)\]$/, '$1'))) logLine(`[sap-ai-dev] ${name}: SSO is configured for an IP address; Kerberos and the browser certificate check need the DNS host name`);
|
|
333
|
+
const ssoBrowsers = await findSsoBrowsers({ env });
|
|
334
|
+
logLine(`[sap-ai-dev] ${name}: ${ssoBrowsers.length ? `SSO browsers: ${ssoBrowsers.map(browser => `${browser.name} (${browser.path})`).join(', ')}` : 'no Chromium-based browser found for SSO; install Microsoft Edge or set SAP_BROWSER_EXEC'}`);
|
|
304
335
|
return {
|
|
305
336
|
source: 'sap-gui-local',
|
|
306
337
|
name,
|
|
@@ -308,9 +339,10 @@ async function configuredLocalSapGuiDestination(env) {
|
|
|
308
339
|
client,
|
|
309
340
|
systemId,
|
|
310
341
|
authentication: 'SSO',
|
|
342
|
+
ssoBrowsers,
|
|
311
343
|
// SAP_SAML_AUTH survives sanitizeChildEnv and would make VSP demand SAML credentials.
|
|
312
344
|
// A first sign-in has no session a hidden browser could renew, so its window opens at once.
|
|
313
|
-
childEnv: { SAP_SSO: 'true', SAP_SSO_SYSTEM: cacheKey, SAP_SAML_AUTH: '', SAP_SSO_FIRST_LOGIN: env.SAP_SSO_FIRST_LOGIN || 'window', SAP_SSO_SILENT_TIMEOUT: env.SAP_SSO_SILENT_TIMEOUT || '15s', ...direct },
|
|
345
|
+
childEnv: { SAP_SSO: 'true', SAP_SSO_SYSTEM: cacheKey, SAP_SAML_AUTH: '', SAP_SSO_FIRST_LOGIN: env.SAP_SSO_FIRST_LOGIN || 'window', SAP_SSO_SILENT_TIMEOUT: env.SAP_SSO_SILENT_TIMEOUT || '15s', ...(ssoBrowsers.length ? { SAP_BROWSER_EXEC: ssoBrowsers[0].path } : {}), ...direct },
|
|
314
346
|
probe
|
|
315
347
|
};
|
|
316
348
|
}
|
|
@@ -345,6 +377,7 @@ async function main() {
|
|
|
345
377
|
process.stdout.write(usage());
|
|
346
378
|
return;
|
|
347
379
|
}
|
|
380
|
+
if (!setup && !check && !list && !doctor && !demo && await handOffToConfiguredVersion()) return;
|
|
348
381
|
if (doctor && demo) throw new Error('Use either --doctor or --demo, not both.');
|
|
349
382
|
if (demo) {
|
|
350
383
|
await runOfflineDemo();
|
|
@@ -379,14 +412,8 @@ async function main() {
|
|
|
379
412
|
detectAdtUrl: detectLocalAdtUrl
|
|
380
413
|
};
|
|
381
414
|
if (hasFlag('--npx')) {
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
const launcher = npxMcpLauncher();
|
|
385
|
-
setupOptions.install = (selected, options) => installMcpConfig(selected, {
|
|
386
|
-
...options,
|
|
387
|
-
command: launcher.command,
|
|
388
|
-
args: [...launcher.prefixArgs, '--yes', '--ignore-scripts', `--package=sap-ai-dev-toolkit@${pkg.version}`, 'sap-ai-dev']
|
|
389
|
-
});
|
|
415
|
+
const launcher = npxPackageLauncher(pkg.version);
|
|
416
|
+
setupOptions.install = (selected, options) => installMcpConfig(selected, { ...options, ...launcher });
|
|
390
417
|
}
|
|
391
418
|
await runSetup(setupOptions);
|
|
392
419
|
return;
|
|
@@ -446,15 +473,7 @@ async function main() {
|
|
|
446
473
|
: (/\.(?:cmd|bat)$/i.test(binary)
|
|
447
474
|
? { command: 'cmd', prefixArgs: ['/c', binary] }
|
|
448
475
|
: { command: binary, prefixArgs: [] });
|
|
449
|
-
|
|
450
|
-
process.once('SIGINT', () => child.kill('SIGINT'));
|
|
451
|
-
process.once('SIGTERM', () => child.kill('SIGTERM'));
|
|
452
|
-
const [code, signal] = await new Promise((resolve, reject) => {
|
|
453
|
-
child.once('error', reject);
|
|
454
|
-
child.once('exit', (exitCode, exitSignal) => resolve([exitCode, exitSignal]));
|
|
455
|
-
});
|
|
456
|
-
if (signal) process.kill(process.pid, signal);
|
|
457
|
-
process.exitCode = code ?? 1;
|
|
476
|
+
await runOnOwnStdio(spawnTarget.command, [...spawnTarget.prefixArgs, ...process.argv.slice(2)], runtimeEnv);
|
|
458
477
|
return;
|
|
459
478
|
}
|
|
460
479
|
|
package/src/mcp-config.mjs
CHANGED
|
@@ -131,6 +131,26 @@ export function npxMcpLauncher(platform = process.platform) {
|
|
|
131
131
|
: { command: 'npx', prefixArgs: [] };
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
+
export function npxPackageLauncher(version, platform = process.platform) {
|
|
135
|
+
const { command, prefixArgs } = npxMcpLauncher(platform);
|
|
136
|
+
return { command, args: [...prefixArgs, '--yes', '--ignore-scripts', `--package=sap-ai-dev-toolkit${version ? `@${version}` : ''}`, 'sap-ai-dev'] };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function isOlderVersion(version, other) {
|
|
140
|
+
const parts = value => Array.from({ length: 3 }, (_, index) => Number.parseInt(String(value).split('.')[index], 10) || 0);
|
|
141
|
+
const [current, target] = [parts(version), parts(other)];
|
|
142
|
+
const index = current.findIndex((part, position) => part !== target[position]);
|
|
143
|
+
return index >= 0 && current[index] < target[index];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Setup pins its version into each entry, so a stale sap-ai-dev on PATH can hand off
|
|
147
|
+
// to that version through npx instead of running old code; the handoff happens once.
|
|
148
|
+
export function configuredVersionHandoff(env, runningVersion, platform = process.platform) {
|
|
149
|
+
const configured = brandedEnvValue(env, 'VERSION');
|
|
150
|
+
if (!configured || brandedEnvValue(env, 'HANDOFF') || !isOlderVersion(runningVersion, configured)) return null;
|
|
151
|
+
return { version: configured, ...npxPackageLauncher(configured, platform) };
|
|
152
|
+
}
|
|
153
|
+
|
|
134
154
|
// Normalizes the argument list of npx-launched entries, whether written as
|
|
135
155
|
// `npx ...` or the Windows `cmd /c npx ...` form; returns null when the entry
|
|
136
156
|
// is not npx-launched.
|
|
@@ -146,17 +166,36 @@ function npxLaunchArgs(entry) {
|
|
|
146
166
|
return null;
|
|
147
167
|
}
|
|
148
168
|
|
|
169
|
+
// The package version a PATH sap-ai-dev starts, found through npm's global
|
|
170
|
+
// layouts (Unix bin symlink, Windows .cmd shim); null when the layout is unknown.
|
|
171
|
+
async function commandPackageVersion(command) {
|
|
172
|
+
const target = await realpath(command).catch(() => command);
|
|
173
|
+
for (const manifest of [join(dirname(dirname(target)), 'package.json'), join(dirname(command), 'node_modules', 'sap-ai-dev-toolkit', 'package.json')]) {
|
|
174
|
+
try {
|
|
175
|
+
const pkg = JSON.parse(await readFile(manifest, 'utf8'));
|
|
176
|
+
if (pkg.name === 'sap-ai-dev-toolkit') return pkg.version;
|
|
177
|
+
} catch {
|
|
178
|
+
// Try the other layout.
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
|
|
149
184
|
// Resolves how generated destination entries launch the toolkit: the global
|
|
150
|
-
// sap-ai-dev command when it is on PATH, otherwise the
|
|
151
|
-
// through npx (routed through cmd /c on Windows) so entries
|
|
152
|
-
// pure `npx sap-ai-dev-toolkit --setup` run still start on every host.
|
|
185
|
+
// sap-ai-dev command when it is on PATH and is this version, otherwise the
|
|
186
|
+
// pinned package through npx (routed through cmd /c on Windows) so entries
|
|
187
|
+
// written by a pure `npx sap-ai-dev-toolkit --setup` run still start on every host.
|
|
153
188
|
async function resolveLauncherInstallCommand(env) {
|
|
154
189
|
const resolved = await resolveMcpServerCommand(env);
|
|
155
|
-
if (resolved !== 'sap-ai-dev') return { command: resolved, args: null };
|
|
156
|
-
const { command, prefixArgs } = npxMcpLauncher();
|
|
157
190
|
const selfVersion = await runningPackageVersion();
|
|
158
|
-
|
|
159
|
-
|
|
191
|
+
let warning;
|
|
192
|
+
if (resolved !== 'sap-ai-dev') {
|
|
193
|
+
const installed = await commandPackageVersion(resolved);
|
|
194
|
+
if (installed === null || !selfVersion || installed === selfVersion) return { command: resolved, args: null };
|
|
195
|
+
// A stale global install would keep starting the old launcher and VSP binary.
|
|
196
|
+
warning = `${resolved} is sap-ai-dev-toolkit ${installed}, not ${selfVersion}; MCP entries start sap-ai-dev-toolkit@${selfVersion} through npx instead. Run "npm install -g sap-ai-dev-toolkit@${selfVersion}" to use the global command.`;
|
|
197
|
+
}
|
|
198
|
+
return { ...npxPackageLauncher(selfVersion), warning };
|
|
160
199
|
}
|
|
161
200
|
|
|
162
201
|
export async function resolveMcpConfigPath(env = process.env) {
|
|
@@ -576,12 +615,14 @@ export async function installMcpConfig(destinationsOrOptions, options = {}) {
|
|
|
576
615
|
const resolved = await resolveLauncherInstallCommand(env);
|
|
577
616
|
launcherCommand = resolved.command;
|
|
578
617
|
launcherArgs = resolved.args;
|
|
618
|
+
if (resolved.warning) installWarnings.push(resolved.warning);
|
|
579
619
|
}
|
|
580
620
|
if (launcherCommand) {
|
|
581
621
|
for (const entry of Object.values(generated)) {
|
|
582
622
|
if (isCompanionServer(entry)) continue;
|
|
583
623
|
entry.command = launcherCommand;
|
|
584
624
|
if (launcherArgs?.length) entry.args = [...launcherArgs];
|
|
625
|
+
if (selfVersion) entry.env.SAP_AI_DEV_TOOLKIT_VERSION = selfVersion;
|
|
585
626
|
}
|
|
586
627
|
}
|
|
587
628
|
const servers = Object.create(null);
|
package/src/mcp-proxy.mjs
CHANGED
|
@@ -228,8 +228,11 @@ class Child {
|
|
|
228
228
|
this.process.stdout.on('data', chunk => this.onData(chunk));
|
|
229
229
|
this.process.stderr.setEncoding('utf8');
|
|
230
230
|
this.process.stderr.on('data', chunk => {
|
|
231
|
-
|
|
231
|
+
const text = redactText(chunk).trimEnd();
|
|
232
|
+
this.lastError = text.split(/\r?\n/).filter(line => line.startsWith('Error: ')).pop()?.slice(7) ?? this.lastError;
|
|
233
|
+
if (options.log) options.log(`[${destination.name}] ${text}`);
|
|
232
234
|
});
|
|
235
|
+
this.stderrClosed = once(this.process.stderr, 'close').catch(() => {});
|
|
233
236
|
// A write racing child death surfaces as an EPIPE 'error' event on stdin.
|
|
234
237
|
// Without a listener that is an uncaught exception that kills the whole
|
|
235
238
|
// MCP server — exactly the crash window self-healing is meant to cover.
|
|
@@ -324,6 +327,14 @@ class Child {
|
|
|
324
327
|
try { this.process.stdin.write(`${JSON.stringify({ jsonrpc: JSONRPC, method, ...(params === undefined ? {} : { params })})}\n`); } catch {}
|
|
325
328
|
}
|
|
326
329
|
|
|
330
|
+
// VSP's own "Error:" line explains an exit, and stderr can still be draining when 'exit' fires.
|
|
331
|
+
async stopReason(waitMs = 1000) {
|
|
332
|
+
let timer;
|
|
333
|
+
await Promise.race([this.stderrClosed, new Promise(resolve => { timer = setTimeout(resolve, waitMs); })]);
|
|
334
|
+
clearTimeout(timer);
|
|
335
|
+
return this.lastError;
|
|
336
|
+
}
|
|
337
|
+
|
|
327
338
|
async initialize(params) {
|
|
328
339
|
const response = await this.request('initialize', params);
|
|
329
340
|
this.notify('notifications/initialized');
|
|
@@ -531,21 +542,33 @@ export class MCPProxy {
|
|
|
531
542
|
// Children initialize concurrently; healthy entries keep destination
|
|
532
543
|
// order so tool naming stays deterministic.
|
|
533
544
|
const initialized = await Promise.all(this.children.map(async entry => {
|
|
545
|
+
const { destination } = entry;
|
|
534
546
|
const startedAt = Date.now();
|
|
535
|
-
this.eventSink(`[${
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
547
|
+
this.eventSink(`[${destination.name}] initializing VSP MCP session`);
|
|
548
|
+
for (let attempt = 1; ; attempt += 1) {
|
|
549
|
+
const attemptStartedAt = Date.now();
|
|
550
|
+
// VSP answers initialize only after its browser sign-in, which waits on a person.
|
|
551
|
+
const signIn = destination.authentication === 'SSO'
|
|
552
|
+
? setInterval(() => this.eventSink(`[${destination.name}] still waiting for the SSO sign-in (${Math.round((Date.now() - attemptStartedAt) / 1000)} s): finish it in the browser window (it may be behind other windows); restarting the server cancels it`), this.ssoHeartbeatMs)
|
|
553
|
+
: undefined;
|
|
554
|
+
try {
|
|
555
|
+
entry.server = await entry.child.initialize(params).finally(() => clearInterval(signIn));
|
|
556
|
+
this.eventSink(`[${destination.name}] VSP MCP session initialized (${Date.now() - startedAt}ms)`);
|
|
557
|
+
return entry;
|
|
558
|
+
} catch (error) {
|
|
559
|
+
const reason = await entry.child.stopReason();
|
|
560
|
+
const failure = `${diagnosticText(error.message)}${reason ? `: ${diagnosticText(reason)}` : ''}`;
|
|
561
|
+
const next = this.signInRetry(destination, attempt, reason);
|
|
562
|
+
await entry.child.close();
|
|
563
|
+
if (!next) {
|
|
564
|
+
this.eventSink(`[${destination.name}] initialization failed: ${failure}`, 'error');
|
|
565
|
+
await closeDestinationRoute(destination);
|
|
566
|
+
return null;
|
|
567
|
+
}
|
|
568
|
+
this.eventSink(`[${destination.name}] SSO sign-in failed (${failure}); self-healing: retrying with ${next.name} (attempt ${attempt + 1} of ${next.attempts})`, 'warning');
|
|
569
|
+
destination.childEnv = { ...destination.childEnv, SAP_BROWSER_EXEC: next.path };
|
|
570
|
+
entry.child = new Child(this.binary, destination, this.childSpawnOptions(destination));
|
|
571
|
+
}
|
|
549
572
|
}
|
|
550
573
|
}));
|
|
551
574
|
const healthy = initialized.filter(Boolean);
|
|
@@ -561,6 +584,15 @@ export class MCPProxy {
|
|
|
561
584
|
return healthy;
|
|
562
585
|
}
|
|
563
586
|
|
|
587
|
+
// A failed browser sign-in moves on to the next discovered browser, or tries the only one once more.
|
|
588
|
+
signInRetry(destination, attempt, reason) {
|
|
589
|
+
// A client that hung up gets no new sign-in windows.
|
|
590
|
+
if (destination.authentication !== 'SSO' || !/browser SSO/i.test(String(reason)) || this.inputEnded || this.shuttingDown) return null;
|
|
591
|
+
const browsers = destination.ssoBrowsers || [];
|
|
592
|
+
const plan = browsers.length === 1 ? [browsers[0], browsers[0]] : browsers;
|
|
593
|
+
return attempt < plan.length ? { ...plan[attempt], attempts: plan.length } : null;
|
|
594
|
+
}
|
|
595
|
+
|
|
564
596
|
toolsCacheValid() {
|
|
565
597
|
const cached = this.toolsCache;
|
|
566
598
|
if (!cached) return false;
|
|
@@ -822,6 +854,7 @@ export class MCPProxy {
|
|
|
822
854
|
input.on('data', onData);
|
|
823
855
|
try {
|
|
824
856
|
await once(input, 'end');
|
|
857
|
+
this.inputEnded = true;
|
|
825
858
|
await Promise.allSettled([...inFlight]);
|
|
826
859
|
} finally {
|
|
827
860
|
input.off('data', onData);
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { access, constants } from 'node:fs/promises';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { posix, win32 } from 'node:path';
|
|
5
|
+
import { promisify } from 'node:util';
|
|
6
|
+
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
8
|
+
|
|
9
|
+
// Edge leads: Entra device-based Conditional Access only completes in a browser bridged to the Windows account broker.
|
|
10
|
+
const BROWSERS = [
|
|
11
|
+
{ windows: 'Microsoft\\Edge\\Application\\msedge.exe', appPath: 'msedge.exe', mac: 'Microsoft Edge', linux: ['microsoft-edge', 'microsoft-edge-stable'] },
|
|
12
|
+
{ windows: 'Google\\Chrome\\Application\\chrome.exe', appPath: 'chrome.exe', mac: 'Google Chrome', linux: ['google-chrome', 'google-chrome-stable'] },
|
|
13
|
+
{ windows: 'BraveSoftware\\Brave-Browser\\Application\\brave.exe', appPath: 'brave.exe', mac: 'Brave Browser', linux: ['brave-browser'] },
|
|
14
|
+
{ windows: 'Vivaldi\\Application\\vivaldi.exe', appPath: 'vivaldi.exe', mac: 'Vivaldi', linux: ['vivaldi'] },
|
|
15
|
+
{ windows: 'Chromium\\Application\\chrome.exe', mac: 'Chromium', linux: ['chromium', 'chromium-browser'] }
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
function envValue(env, name) {
|
|
19
|
+
if (env[name] !== undefined) return env[name];
|
|
20
|
+
const key = Object.keys(env).find(candidate => candidate.toLowerCase() === name.toLowerCase());
|
|
21
|
+
return key === undefined ? undefined : env[key];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function ssoBrowserName(path) {
|
|
25
|
+
const lower = String(path).toLowerCase();
|
|
26
|
+
if (lower.includes('edge')) return 'Microsoft Edge';
|
|
27
|
+
if (lower.includes('brave')) return 'Brave';
|
|
28
|
+
if (lower.includes('vivaldi')) return 'Vivaldi';
|
|
29
|
+
if (lower.includes('chromium')) return 'Chromium';
|
|
30
|
+
if (lower.includes('chrome')) return 'Google Chrome';
|
|
31
|
+
return 'browser';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Localized Windows names the default value "(Standard)" and the like, so it is found by its type.
|
|
35
|
+
export function parseAppPath(output, env = {}) {
|
|
36
|
+
const value = /REG_(?:EXPAND_)?SZ\s+(.+?)\s*$/m.exec(String(output))?.[1];
|
|
37
|
+
if (!value) return '';
|
|
38
|
+
return value.replace(/^"(.*)"$/, '$1').replace(/%([^%]+)%/g, (match, name) => envValue(env, name) ?? match);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function registeredAppPaths(exe, env) {
|
|
42
|
+
const found = await Promise.all(['HKLM', 'HKCU'].map(async hive => {
|
|
43
|
+
try {
|
|
44
|
+
const { stdout } = await execFileAsync('reg', ['query', `${hive}\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\${exe}`, '/ve'], { windowsHide: true, timeout: 5000 });
|
|
45
|
+
return parseAppPath(stdout, env);
|
|
46
|
+
} catch {
|
|
47
|
+
return '';
|
|
48
|
+
}
|
|
49
|
+
}));
|
|
50
|
+
return found.filter(Boolean);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function fileExists(path) {
|
|
54
|
+
try {
|
|
55
|
+
await access(path, constants.F_OK);
|
|
56
|
+
return true;
|
|
57
|
+
} catch {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Every Chromium-based browser VSP can drive, best first; SAP_BROWSER_EXEC pins one ahead of the rest.
|
|
63
|
+
export async function findSsoBrowsers({ env = process.env, platform = process.platform, exists = fileExists, appPaths = registeredAppPaths } = {}) {
|
|
64
|
+
const candidates = [];
|
|
65
|
+
const pinned = envValue(env, 'SAP_BROWSER_EXEC');
|
|
66
|
+
if (pinned) candidates.push(pinned);
|
|
67
|
+
if (platform === 'win32') {
|
|
68
|
+
const roots = [envValue(env, 'ProgramFiles') || 'C:\\Program Files', envValue(env, 'ProgramFiles(x86)') || 'C:\\Program Files (x86)', envValue(env, 'LOCALAPPDATA')].filter(Boolean);
|
|
69
|
+
const registered = await Promise.all(BROWSERS.map(browser => (browser.appPath ? appPaths(browser.appPath, env) : [])));
|
|
70
|
+
BROWSERS.forEach((browser, index) => candidates.push(...roots.map(root => win32.join(root, browser.windows)), ...registered[index]));
|
|
71
|
+
} else if (platform === 'darwin') {
|
|
72
|
+
const home = envValue(env, 'HOME') || homedir();
|
|
73
|
+
for (const browser of BROWSERS) {
|
|
74
|
+
for (const root of ['/Applications', posix.join(home, 'Applications')]) candidates.push(posix.join(root, `${browser.mac}.app`, 'Contents', 'MacOS', browser.mac));
|
|
75
|
+
}
|
|
76
|
+
} else {
|
|
77
|
+
const directories = String(envValue(env, 'PATH') || '').split(':').filter(Boolean);
|
|
78
|
+
for (const browser of BROWSERS) for (const command of browser.linux) candidates.push(...directories.map(directory => posix.join(directory, command)));
|
|
79
|
+
}
|
|
80
|
+
const seen = new Set();
|
|
81
|
+
const browsers = [];
|
|
82
|
+
for (const path of candidates) {
|
|
83
|
+
const key = platform === 'win32' ? path.toLowerCase() : path;
|
|
84
|
+
if (seen.has(key)) continue;
|
|
85
|
+
seen.add(key);
|
|
86
|
+
// A pinned browser is kept even when missing, so its failure shows up in the log.
|
|
87
|
+
if (path === pinned || await exists(path)) browsers.push({ name: ssoBrowserName(path), path });
|
|
88
|
+
}
|
|
89
|
+
return browsers;
|
|
90
|
+
}
|