sap-ai-dev-toolkit 0.9.91 → 0.9.94

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 CHANGED
@@ -302,7 +302,7 @@ The installer handles VSP provisioning automatically:
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: every row starts with an empty box `[ ]`. Use **Space** to tick a destination (`[✓]`) and **Enter** to confirm; the line under the list always names the next key to press. Press **a** to toggle all destinations (select all if any are unchecked; otherwise clear the selection). Pressing **Enter** with nothing selected asks for a second **Enter**, because 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 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.
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 a Microsoft Edge window on the ADT URL whenever no session is cached yet; Edge completes the login the same way it does in your browser (smart card certificate and PIN, Kerberos, or company SAML/Entra login). Keep the MCP server running until the sign-in finishes; the session is then 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 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.
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.
@@ -809,6 +809,9 @@ The response contains a `tools` array. A `RunQuery` entry resembles this excerpt
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
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
+ | `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
+ | `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. |
812
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. |
813
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. |
814
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. |
@@ -1,5 +1,5 @@
1
- 02831a47fa28a1807d94f6b8485c21f1221c7b3aa0a2b9b7bf0682ab4fb5618f vsp-linux-x64
2
- 4398dac4c9d304d63e03abbb86523b61e5180b5b355dd1a2ed0d2405454cc8f7 vsp-linux-arm64
3
- 356a10913b7a46a8ed67132e9b15ea7ff92b8adeb1c799111866e9af176e85fe vsp-darwin-x64
4
- fb5e72a59618578187eb8e4374571b32baab34397766fc6ea8ab328c6cea849b vsp-darwin-arm64
5
- 50d5c74194063bc9bcc641fb580f1c1232cce0c1577cdad6dc503f41efd3344c vsp-win32-x64.exe
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
Binary file
Binary file
Binary file
Binary file
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sap-ai-dev-toolkit",
3
- "version": "0.9.91",
3
+ "version": "0.9.94",
4
4
  "description": "SAP AI development toolkit for BAS, ABAP, RAP, CAP, HANA Cloud, Fiori, UI5, and MCP",
5
5
  "author": "Gurkan Yilmaz",
6
6
  "type": "module",
@@ -1,5 +1,5 @@
1
1
  diff --git a/cmd/vsp/main.go b/cmd/vsp/main.go
2
- index 5007935..b2bc6be 100755
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() {
@@ -10,7 +10,16 @@ index 5007935..b2bc6be 100755
10
10
  rootCmd.Flags().StringVarP(&cfg.Username, "user", "u", "", "SAP username")
11
11
  rootCmd.Flags().StringVarP(&cfg.Password, "password", "p", "", "SAP password")
12
12
  rootCmd.Flags().StringVar(&cfg.Password, "pass", "", "SAP password (alias for --password)")
13
- @@ -170,6 +171,7 @@ func init() {
13
+ @@ -126,6 +127,8 @@ func init() {
14
+ rootCmd.Flags().String("sso-profile", "", "Browser profile directory for SSO (a Windows path when running under WSL)")
15
+ rootCmd.Flags().String("sso-helper", "", "Path to the Windows SSO capture helper (vsp-sso.exe), used under WSL")
16
+ rootCmd.Flags().String("sso-on-expiry", "window", "When a silent SSO refresh needs a human: 'window' opens a browser, 'error' reports what to run")
17
+ + rootCmd.Flags().String("sso-first-login", "", "With no cached SSO session: 'window' signs in through a visible browser window at once (default: try silently first)")
18
+ + rootCmd.Flags().String("sso-silent-timeout", "", "Budget for a silent SSO capture, as a Go duration (default 45s)")
19
+ rootCmd.Flags().Bool("saml-auth", false, "Authenticate via programmatic SAML SSO (no browser, no MFA)")
20
+ rootCmd.Flags().String("saml-user", "", "SAML/IAS username (email)")
21
+ rootCmd.Flags().String("saml-password", "", "SAML/IAS password")
22
+ @@ -170,6 +173,7 @@ func init() {
14
23
  rootCmd.PersistentFlags().BoolVarP(&cfg.Verbose, "verbose", "v", false, "Enable verbose output to stderr")
15
24
 
16
25
  // Bind flags to viper for environment variable support
@@ -18,7 +27,16 @@ index 5007935..b2bc6be 100755
18
27
  viper.BindPFlag("url", rootCmd.Flags().Lookup("url"))
19
28
  viper.BindPFlag("user", rootCmd.Flags().Lookup("user"))
20
29
  viper.BindPFlag("password", rootCmd.Flags().Lookup("password"))
21
- @@ -233,6 +235,9 @@ func runServer(cmd *cobra.Command, args []string) error {
30
+ @@ -186,6 +190,8 @@ func init() {
31
+ viper.BindPFlag("sso-profile", rootCmd.Flags().Lookup("sso-profile"))
32
+ viper.BindPFlag("sso-helper", rootCmd.Flags().Lookup("sso-helper"))
33
+ viper.BindPFlag("sso-on-expiry", rootCmd.Flags().Lookup("sso-on-expiry"))
34
+ + viper.BindPFlag("sso-first-login", rootCmd.Flags().Lookup("sso-first-login"))
35
+ + viper.BindPFlag("sso-silent-timeout", rootCmd.Flags().Lookup("sso-silent-timeout"))
36
+ viper.BindPFlag("saml-auth", rootCmd.Flags().Lookup("saml-auth"))
37
+ viper.BindPFlag("saml-user", rootCmd.Flags().Lookup("saml-user"))
38
+ viper.BindPFlag("saml-password", rootCmd.Flags().Lookup("saml-password"))
39
+ @@ -233,6 +239,9 @@ func runServer(cmd *cobra.Command, args []string) error {
22
40
  }
23
41
 
24
42
  // Browser-based SSO authentication (must run before processCookieAuth)
@@ -28,7 +46,7 @@ index 5007935..b2bc6be 100755
28
46
  if err := processBrowserAuth(cmd); err != nil {
29
47
  return err
30
48
  }
31
- @@ -346,6 +351,7 @@ func runServer(cmd *cobra.Command, args []string) error {
49
+ @@ -346,6 +355,7 @@ func runServer(cmd *cobra.Command, args []string) error {
32
50
  }
33
51
 
34
52
  func resolveConfig(cmd *cobra.Command) {
@@ -36,7 +54,7 @@ index 5007935..b2bc6be 100755
36
54
  // Check if cookie auth is explicitly requested via CLI flags OR env vars
37
55
  // If so, we should NOT load user/password from env/.env to avoid conflicts
38
56
  // Cookie auth takes precedence over basic auth since it's more explicit
39
- @@ -530,6 +536,24 @@ func validateConfig() error {
57
+ @@ -530,6 +540,24 @@ func validateConfig() error {
40
58
  return nil
41
59
  }
42
60
 
@@ -61,7 +79,7 @@ index 5007935..b2bc6be 100755
61
79
  func processBrowserAuth(cmd *cobra.Command) error {
62
80
  browserAuth, _ := cmd.Flags().GetBool("browser-auth")
63
81
  if !browserAuth && !viper.GetBool("BROWSER_AUTH") {
64
- @@ -599,6 +623,9 @@ func ssoRequested(cmd *cobra.Command) bool {
82
+ @@ -599,6 +627,9 @@ func ssoRequested(cmd *cobra.Command) bool {
65
83
  // days will outlive any session it starts with, and the point of the exercise
66
84
  // is that nobody has to notice when it does.
67
85
  func processSSOAuth(cmd *cobra.Command) error {
@@ -71,7 +89,46 @@ index 5007935..b2bc6be 100755
71
89
  if !ssoRequested(cmd) {
72
90
  return nil
73
91
  }
74
- @@ -657,6 +684,9 @@ func processSSOAuth(cmd *cobra.Command) error {
92
+ @@ -626,22 +657,28 @@ func processSSOAuth(cmd *cobra.Command) error {
93
+ }
94
+
95
+ onExpiry := stringOpt("sso-on-expiry", "SSO_ON_EXPIRY")
96
+ + silentTimeout, err := parseOptionalDuration(stringOpt("sso-silent-timeout", "SSO_SILENT_TIMEOUT"), "--sso-silent-timeout")
97
+ + if err != nil {
98
+ + return err
99
+ + }
100
+ provider, err := adt.NewSSOProvider(adt.SSOConfig{
101
+ - System: stringOpt("sso-system", "SSO_SYSTEM"),
102
+ - BaseURL: cfg.BaseURL,
103
+ - Client: cfg.Client,
104
+ - TriggerURL: stringOpt("sso-trigger-url", "SSO_TRIGGER_URL"),
105
+ - Profile: stringOpt("sso-profile", "SSO_PROFILE"),
106
+ - HelperPath: stringOpt("sso-helper", "SSO_HELPER"),
107
+ - Interactive: !strings.EqualFold(onExpiry, "error"),
108
+ - Insecure: cfg.InsecureSkipVerify,
109
+ - Verbose: cfg.Verbose,
110
+ + System: stringOpt("sso-system", "SSO_SYSTEM"),
111
+ + BaseURL: cfg.BaseURL,
112
+ + Client: cfg.Client,
113
+ + TriggerURL: stringOpt("sso-trigger-url", "SSO_TRIGGER_URL"),
114
+ + Profile: stringOpt("sso-profile", "SSO_PROFILE"),
115
+ + HelperPath: stringOpt("sso-helper", "SSO_HELPER"),
116
+ + BrowserPath: stringOpt("browser-exec", "BROWSER_EXEC"),
117
+ + Interactive: !strings.EqualFold(onExpiry, "error"),
118
+ + SilentTimeout: silentTimeout,
119
+ + Insecure: cfg.InsecureSkipVerify,
120
+ + Verbose: cfg.Verbose,
121
+ })
122
+ if err != nil {
123
+ return err
124
+ }
125
+
126
+ - cookies, err := provider.Cookies(cmd.Context())
127
+ + cookies, err := provider.Start(cmd.Context(), strings.EqualFold(stringOpt("sso-first-login", "SSO_FIRST_LOGIN"), "window"))
128
+ if err != nil {
129
+ return fmt.Errorf("browser SSO: %w", err)
130
+ }
131
+ @@ -657,6 +694,9 @@ func processSSOAuth(cmd *cobra.Command) error {
75
132
  }
76
133
 
77
134
  func processSAMLAuth(cmd *cobra.Command) error {
@@ -81,7 +138,7 @@ index 5007935..b2bc6be 100755
81
138
  samlAuth, _ := cmd.Flags().GetBool("saml-auth")
82
139
  if !samlAuth && !viper.GetBool("SAML_AUTH") {
83
140
  return nil
84
- @@ -752,6 +782,9 @@ func processSAMLAuth(cmd *cobra.Command) error {
141
+ @@ -752,6 +792,9 @@ func processSAMLAuth(cmd *cobra.Command) error {
85
142
  }
86
143
 
87
144
  func processCookieAuth(cmd *cobra.Command) error {
@@ -256,6 +313,19 @@ index dd433d6..5553b7f 100644
256
313
  ),
257
314
  ), s.handleActivateMultiple)
258
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:
259
329
  diff --git a/pkg/adt/client.go b/pkg/adt/client.go
260
330
  index 979e908..c7e9501 100755
261
331
  --- a/pkg/adt/client.go
@@ -493,19 +563,31 @@ index 0000000..80741da
493
563
  +}
494
564
  +
495
565
  diff --git a/pkg/adt/sso.go b/pkg/adt/sso.go
496
- index d67c4e8..4e9dc45 100644
566
+ index d67c4e8..a03f740 100644
497
567
  --- a/pkg/adt/sso.go
498
568
  +++ b/pkg/adt/sso.go
499
- @@ -155,7 +155,7 @@ func CaptureSSOCookies(ctx context.Context, opts SSOOptions) (map[string]string,
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
500
579
  // below is the real verdict.
501
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
+ + }
502
584
  if timeoutCtx.Err() != nil {
503
585
  - return nil, fmt.Errorf("SSO capture timed out after %s", timeout)
504
586
  + return nil, navigationTimeout(opts.Headless, timeout)
505
587
  }
506
588
  if opts.Verbose {
507
589
  fmt.Fprintf(os.Stderr, "[SSO] navigation reported %v (normal during SSO)\n", err)
508
- @@ -174,6 +174,17 @@ func CaptureSSOCookies(ctx context.Context, opts SSOOptions) (map[string]string,
590
+ @@ -174,6 +178,26 @@ func CaptureSSOCookies(ctx context.Context, opts SSOOptions) (map[string]string,
509
591
  return cookies, nil
510
592
  }
511
593
 
@@ -519,10 +601,124 @@ index d67c4e8..4e9dc45 100644
519
601
  + }
520
602
  + return fmt.Errorf("SSO capture timed out after %s", timeout)
521
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
+ +}
522
613
  +
523
614
  // DefaultSSOProfileDir returns a per-host browser profile directory under the
524
615
  // user's cache location. Keeping one profile per host means a silent refresh
525
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
+ +}
664
+ diff --git a/pkg/adt/sso_first_login_test.go b/pkg/adt/sso_first_login_test.go
665
+ new file mode 100644
666
+ index 0000000..f8efda4
667
+ --- /dev/null
668
+ +++ b/pkg/adt/sso_first_login_test.go
669
+ @@ -0,0 +1,52 @@
670
+ +package adt
671
+ +
672
+ +import (
673
+ + "context"
674
+ + "os"
675
+ + "testing"
676
+ +)
677
+ +
678
+ +func TestStartWithWindowFirstReusesACachedSession(t *testing.T) {
679
+ + t.Setenv("HOME", t.TempDir())
680
+ + p, err := NewSSOProvider(SSOConfig{System: "devsys", BaseURL: "https://sap.example"})
681
+ + if err != nil {
682
+ + t.Fatal(err)
683
+ + }
684
+ + if err := SaveSSOSession(p.CachePath(), "sap.example", map[string]string{"SAP_SESSIONID_X": "live"}); err != nil {
685
+ + t.Fatal(err)
686
+ + }
687
+ + // A browser launch here would fail the test on a build machine.
688
+ + got, err := p.Start(context.Background(), true)
689
+ + if err != nil {
690
+ + t.Fatalf("Start: %v", err)
691
+ + }
692
+ + if got["SAP_SESSIONID_X"] != "live" {
693
+ + t.Errorf("cookies = %v, want the cached session", got)
694
+ + }
695
+ +}
696
+ +
697
+ +func TestHasSessionDecidesWhetherTheFirstSignInNeedsAWindow(t *testing.T) {
698
+ + t.Setenv("HOME", t.TempDir())
699
+ + p, err := NewSSOProvider(SSOConfig{System: "devsys", BaseURL: "https://sap.example"})
700
+ + if err != nil {
701
+ + t.Fatal(err)
702
+ + }
703
+ + if p.hasSession() {
704
+ + t.Fatal("an empty cache must send the first sign-in to a window")
705
+ + }
706
+ + if err := SaveSSOSession(p.CachePath(), "sap.example", map[string]string{"SAP_SESSIONID_X": "live"}); err != nil {
707
+ + t.Fatal(err)
708
+ + }
709
+ + if !p.hasSession() {
710
+ + t.Fatal("a cached session must be reused, not replaced by a window")
711
+ + }
712
+ + if err := os.Remove(p.CachePath()); err != nil {
713
+ + t.Fatal(err)
714
+ + }
715
+ + if err := os.Mkdir(p.CachePath(), 0700); err != nil {
716
+ + t.Fatal(err)
717
+ + }
718
+ + if !p.hasSession() {
719
+ + t.Fatal("an unreadable cache must reach Cookies, which reports the read error")
720
+ + }
721
+ +}
526
722
  diff --git a/pkg/adt/sso_navigation_test.go b/pkg/adt/sso_navigation_test.go
527
723
  new file mode 100644
528
724
  index 0000000..c851021
@@ -545,3 +741,53 @@ index 0000000..c851021
545
741
  + t.Fatalf("headed timeout = %v, must not ask for yet another window", err)
546
742
  + }
547
743
  +}
744
+ diff --git a/pkg/adt/sso_provider.go b/pkg/adt/sso_provider.go
745
+ index 8e5bed4..544b873 100644
746
+ --- a/pkg/adt/sso_provider.go
747
+ +++ b/pkg/adt/sso_provider.go
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) {
759
+ return p.acquire(ctx, true)
760
+ }
761
+
762
+ +// Start returns the session a server begins with; windowFirst skips the silent attempt when nothing is cached.
763
+ +func (p *SSOProvider) Start(ctx context.Context, windowFirst bool) (map[string]string, error) {
764
+ + if windowFirst && !p.hasSession() {
765
+ + fmt.Fprintf(os.Stderr, "[SSO] no cached session — opening a browser window to sign in\n")
766
+ + return p.Login(ctx)
767
+ + }
768
+ + return p.Cookies(ctx)
769
+ +}
770
+ +
771
+ +// hasSession counts an unreadable cache as present so Cookies can report why.
772
+ +func (p *SSOProvider) hasSession() bool {
773
+ + p.mu.Lock()
774
+ + held := len(p.current) > 0
775
+ + p.mu.Unlock()
776
+ + if held {
777
+ + return true
778
+ + }
779
+ + sess, err := LoadSSOSession(p.cachePath)
780
+ + return err != nil || sess != nil
781
+ +}
782
+ +
783
+ // Clear removes the cached session, so the next call captures a fresh one.
784
+ func (p *SSOProvider) Clear() error {
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
@@ -12,6 +12,7 @@ import { installMcpConfig, npxMcpLauncher, repairManagedMcpConfig } from './mcp-
12
12
  import { runSetup } from './setup.mjs';
13
13
  import { resolveConfiguredCloudFoundryDestination } from './cf-destination.mjs';
14
14
  import { createTlsServerNameAdtProxy } from './tls-adt-proxy.mjs';
15
+ import { findSsoBrowsers } from './sso-browsers.mjs';
15
16
  import { withBrandedEnvironment } from './branding.mjs';
16
17
  import { redactText } from './redact.mjs';
17
18
  import { startProgress } from './terminal-ui.mjs';
@@ -301,6 +302,8 @@ async function configuredLocalSapGuiDestination(env) {
301
302
  if (sso) {
302
303
  const cacheKey = `${systemId || new URL(configuredUrl).hostname}-${client}`.toLowerCase().replace(/[^a-z0-9.-]+/g, '-');
303
304
  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`);
305
+ const ssoBrowsers = await findSsoBrowsers({ env });
306
+ 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
307
  return {
305
308
  source: 'sap-gui-local',
306
309
  name,
@@ -308,8 +311,10 @@ async function configuredLocalSapGuiDestination(env) {
308
311
  client,
309
312
  systemId,
310
313
  authentication: 'SSO',
314
+ ssoBrowsers,
311
315
  // SAP_SAML_AUTH survives sanitizeChildEnv and would make VSP demand SAML credentials.
312
- childEnv: { SAP_SSO: 'true', SAP_SSO_SYSTEM: cacheKey, SAP_SAML_AUTH: '', ...direct },
316
+ // A first sign-in has no session a hidden browser could renew, so its window opens at once.
317
+ 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 },
313
318
  probe
314
319
  };
315
320
  }
@@ -408,12 +413,14 @@ async function main() {
408
413
  }
409
414
 
410
415
  if (['cloud-foundry', 'sap-gui-local'].includes(runtimeEnv.SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE)) {
416
+ logLine(`[sap-ai-dev] sap-ai-dev-toolkit v${pkg.version} (node ${process.version}, pid ${process.pid})`);
411
417
  const destination = runtimeEnv.SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE === 'cloud-foundry'
412
418
  ? await resolveConfiguredCloudFoundryDestination({ env: runtimeEnv })
413
419
  : await configuredLocalSapGuiDestination(runtimeEnv);
414
420
  let proxy;
415
421
  try {
416
422
  const binary = await binaryOrError();
423
+ logLine(`[sap-ai-dev] VSP binary: ${binary}`);
417
424
  proxy = new MCPProxy({ binary, destinations: [destination], env: runtimeEnv, log: logLine, version: pkg.version });
418
425
  const shutdown = signal => { void proxy.close().finally(() => process.exit(signal === 'SIGINT' ? 130 : 143)); };
419
426
  process.once('SIGINT', () => shutdown('SIGINT'));
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
- if (options.log) options.log(`[${destination.name}] ${redactText(chunk).trimEnd()}`);
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');
@@ -387,7 +398,7 @@ class Child {
387
398
  }
388
399
 
389
400
  export class MCPProxy {
390
- constructor({ binary, destinations, env = process.env, spawn = nodeSpawn, childArgs, log = message => console.error(message), output = line => process.stdout.write(`${line}\n`), version = '0.0.0' }) {
401
+ constructor({ binary, destinations, env = process.env, spawn = nodeSpawn, childArgs, log = message => console.error(message), output = line => process.stdout.write(`${line}\n`), version = '0.0.0', ssoHeartbeatMs = 15_000 }) {
391
402
  this.binary = binary;
392
403
  this.destinations = destinations;
393
404
  this.env = env;
@@ -396,6 +407,7 @@ export class MCPProxy {
396
407
  this.log = log;
397
408
  this.output = output;
398
409
  this.version = version;
410
+ this.ssoHeartbeatMs = ssoHeartbeatMs;
399
411
  this.requestTimeoutMs = requestTimeoutMs(env);
400
412
  this.children = [];
401
413
  this.started = false;
@@ -530,17 +542,33 @@ export class MCPProxy {
530
542
  // Children initialize concurrently; healthy entries keep destination
531
543
  // order so tool naming stays deterministic.
532
544
  const initialized = await Promise.all(this.children.map(async entry => {
545
+ const { destination } = entry;
533
546
  const startedAt = Date.now();
534
- this.eventSink(`[${entry.destination.name}] initializing VSP MCP session`);
535
- try {
536
- entry.server = await entry.child.initialize(params);
537
- this.eventSink(`[${entry.destination.name}] VSP MCP session initialized (${Date.now() - startedAt}ms)`);
538
- return entry;
539
- } catch (error) {
540
- this.eventSink(`[${entry.destination.name}] initialization failed: ${diagnosticText(error.message)}`, 'error');
541
- await entry.child.close();
542
- await closeDestinationRoute(entry.destination);
543
- return null;
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
+ }
544
572
  }
545
573
  }));
546
574
  const healthy = initialized.filter(Boolean);
@@ -556,6 +584,15 @@ export class MCPProxy {
556
584
  return healthy;
557
585
  }
558
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
+
559
596
  toolsCacheValid() {
560
597
  const cached = this.toolsCache;
561
598
  if (!cached) return false;
@@ -817,6 +854,7 @@ export class MCPProxy {
817
854
  input.on('data', onData);
818
855
  try {
819
856
  await once(input, 'end');
857
+ this.inputEnded = true;
820
858
  await Promise.allSettled([...inFlight]);
821
859
  } finally {
822
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
+ }