sap-ai-dev-toolkit 0.9.9 → 0.9.92
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 +4 -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 +207 -8
- package/src/launcher.mjs +12 -2
- package/src/local-sap-gui.mjs +4 -2
- package/src/mcp-proxy.mjs +7 -2
- package/src/setup.mjs +22 -10
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)
|
|
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
|
|
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.
|
|
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,8 @@ 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`. |
|
|
812
814
|
| `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
815
|
| `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
816
|
| `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. |
|
package/dist/checksums.txt
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
40bba6b57e0656249b3de91f70504b49e56c4a72cd418de47d5b3c4ff5b78466 vsp-linux-x64
|
|
2
|
+
86b1ecdf0f6af6778750b44caef6cc1e7f4ab9efc1cfe202f26766b85c48cbf4 vsp-linux-arm64
|
|
3
|
+
9ee2823cea8fc456f2fcd249abe8ffbd72e7ca52a3731ee228efbabe7099b455 vsp-darwin-x64
|
|
4
|
+
9148037eb23f6e80f03bb0080d3a12e5c73f25f8d2d9c262273b870ed0716782 vsp-darwin-arm64
|
|
5
|
+
5494267d5c47bed9155507cf5b156932933ce0f6aaf7999c6c60afe1ecc6367f 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..715c0e9 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
|
-
@@ -
|
|
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
|
-
@@ -
|
|
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 +
|
|
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 +
|
|
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 +
|
|
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,45 @@ index 5007935..b2bc6be 100755
|
|
|
71
89
|
if !ssoRequested(cmd) {
|
|
72
90
|
return nil
|
|
73
91
|
}
|
|
74
|
-
@@ -
|
|
92
|
+
@@ -626,22 +657,27 @@ 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
|
+
+ Interactive: !strings.EqualFold(onExpiry, "error"),
|
|
117
|
+
+ SilentTimeout: silentTimeout,
|
|
118
|
+
+ Insecure: cfg.InsecureSkipVerify,
|
|
119
|
+
+ Verbose: cfg.Verbose,
|
|
120
|
+
})
|
|
121
|
+
if err != nil {
|
|
122
|
+
return err
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
- cookies, err := provider.Cookies(cmd.Context())
|
|
126
|
+
+ cookies, err := provider.Start(cmd.Context(), strings.EqualFold(stringOpt("sso-first-login", "SSO_FIRST_LOGIN"), "window"))
|
|
127
|
+
if err != nil {
|
|
128
|
+
return fmt.Errorf("browser SSO: %w", err)
|
|
129
|
+
}
|
|
130
|
+
@@ -657,6 +693,9 @@ func processSSOAuth(cmd *cobra.Command) error {
|
|
75
131
|
}
|
|
76
132
|
|
|
77
133
|
func processSAMLAuth(cmd *cobra.Command) error {
|
|
@@ -81,7 +137,7 @@ index 5007935..b2bc6be 100755
|
|
|
81
137
|
samlAuth, _ := cmd.Flags().GetBool("saml-auth")
|
|
82
138
|
if !samlAuth && !viper.GetBool("SAML_AUTH") {
|
|
83
139
|
return nil
|
|
84
|
-
@@ -752,6 +
|
|
140
|
+
@@ -752,6 +791,9 @@ func processSAMLAuth(cmd *cobra.Command) error {
|
|
85
141
|
}
|
|
86
142
|
|
|
87
143
|
func processCookieAuth(cmd *cobra.Command) error {
|
|
@@ -492,3 +548,146 @@ index 0000000..80741da
|
|
|
492
548
|
+ }
|
|
493
549
|
+}
|
|
494
550
|
+
|
|
551
|
+
diff --git a/pkg/adt/sso.go b/pkg/adt/sso.go
|
|
552
|
+
index d67c4e8..4e9dc45 100644
|
|
553
|
+
--- a/pkg/adt/sso.go
|
|
554
|
+
+++ b/pkg/adt/sso.go
|
|
555
|
+
@@ -155,7 +155,7 @@ func CaptureSSOCookies(ctx context.Context, opts SSOOptions) (map[string]string,
|
|
556
|
+
// below is the real verdict.
|
|
557
|
+
if err := chromedp.Run(timeoutCtx, chromedp.Navigate(opts.TriggerURL)); err != nil {
|
|
558
|
+
if timeoutCtx.Err() != nil {
|
|
559
|
+
- return nil, fmt.Errorf("SSO capture timed out after %s", timeout)
|
|
560
|
+
+ return nil, navigationTimeout(opts.Headless, timeout)
|
|
561
|
+
}
|
|
562
|
+
if opts.Verbose {
|
|
563
|
+
fmt.Fprintf(os.Stderr, "[SSO] navigation reported %v (normal during SSO)\n", err)
|
|
564
|
+
@@ -174,6 +174,17 @@ func CaptureSSOCookies(ctx context.Context, opts SSOOptions) (map[string]string,
|
|
565
|
+
return cookies, nil
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
+// navigationTimeout explains a first page that never finished loading. In a
|
|
569
|
+
+// silent browser that page is waiting on a person — a smart-card certificate
|
|
570
|
+
+// picker or a login prompt headless mode cannot show — so a visible window is
|
|
571
|
+
+// the next step, not a failed start.
|
|
572
|
+
+func navigationTimeout(headless bool, timeout time.Duration) error {
|
|
573
|
+
+ if headless {
|
|
574
|
+
+ return fmt.Errorf("%w: the silent sign-in page did not finish loading within %s", ErrSSOInteractiveRequired, timeout)
|
|
575
|
+
+ }
|
|
576
|
+
+ return fmt.Errorf("SSO capture timed out after %s", timeout)
|
|
577
|
+
+}
|
|
578
|
+
+
|
|
579
|
+
// DefaultSSOProfileDir returns a per-host browser profile directory under the
|
|
580
|
+
// user's cache location. Keeping one profile per host means a silent refresh
|
|
581
|
+
// for one system is never disturbed by a login to another.
|
|
582
|
+
diff --git a/pkg/adt/sso_first_login_test.go b/pkg/adt/sso_first_login_test.go
|
|
583
|
+
new file mode 100644
|
|
584
|
+
index 0000000..f8efda4
|
|
585
|
+
--- /dev/null
|
|
586
|
+
+++ b/pkg/adt/sso_first_login_test.go
|
|
587
|
+
@@ -0,0 +1,52 @@
|
|
588
|
+
+package adt
|
|
589
|
+
+
|
|
590
|
+
+import (
|
|
591
|
+
+ "context"
|
|
592
|
+
+ "os"
|
|
593
|
+
+ "testing"
|
|
594
|
+
+)
|
|
595
|
+
+
|
|
596
|
+
+func TestStartWithWindowFirstReusesACachedSession(t *testing.T) {
|
|
597
|
+
+ t.Setenv("HOME", t.TempDir())
|
|
598
|
+
+ p, err := NewSSOProvider(SSOConfig{System: "devsys", BaseURL: "https://sap.example"})
|
|
599
|
+
+ if err != nil {
|
|
600
|
+
+ t.Fatal(err)
|
|
601
|
+
+ }
|
|
602
|
+
+ if err := SaveSSOSession(p.CachePath(), "sap.example", map[string]string{"SAP_SESSIONID_X": "live"}); err != nil {
|
|
603
|
+
+ t.Fatal(err)
|
|
604
|
+
+ }
|
|
605
|
+
+ // A browser launch here would fail the test on a build machine.
|
|
606
|
+
+ got, err := p.Start(context.Background(), true)
|
|
607
|
+
+ if err != nil {
|
|
608
|
+
+ t.Fatalf("Start: %v", err)
|
|
609
|
+
+ }
|
|
610
|
+
+ if got["SAP_SESSIONID_X"] != "live" {
|
|
611
|
+
+ t.Errorf("cookies = %v, want the cached session", got)
|
|
612
|
+
+ }
|
|
613
|
+
+}
|
|
614
|
+
+
|
|
615
|
+
+func TestHasSessionDecidesWhetherTheFirstSignInNeedsAWindow(t *testing.T) {
|
|
616
|
+
+ t.Setenv("HOME", t.TempDir())
|
|
617
|
+
+ p, err := NewSSOProvider(SSOConfig{System: "devsys", BaseURL: "https://sap.example"})
|
|
618
|
+
+ if err != nil {
|
|
619
|
+
+ t.Fatal(err)
|
|
620
|
+
+ }
|
|
621
|
+
+ if p.hasSession() {
|
|
622
|
+
+ t.Fatal("an empty cache must send the first sign-in to a window")
|
|
623
|
+
+ }
|
|
624
|
+
+ if err := SaveSSOSession(p.CachePath(), "sap.example", map[string]string{"SAP_SESSIONID_X": "live"}); err != nil {
|
|
625
|
+
+ t.Fatal(err)
|
|
626
|
+
+ }
|
|
627
|
+
+ if !p.hasSession() {
|
|
628
|
+
+ t.Fatal("a cached session must be reused, not replaced by a window")
|
|
629
|
+
+ }
|
|
630
|
+
+ if err := os.Remove(p.CachePath()); err != nil {
|
|
631
|
+
+ t.Fatal(err)
|
|
632
|
+
+ }
|
|
633
|
+
+ if err := os.Mkdir(p.CachePath(), 0700); err != nil {
|
|
634
|
+
+ t.Fatal(err)
|
|
635
|
+
+ }
|
|
636
|
+
+ if !p.hasSession() {
|
|
637
|
+
+ t.Fatal("an unreadable cache must reach Cookies, which reports the read error")
|
|
638
|
+
+ }
|
|
639
|
+
+}
|
|
640
|
+
diff --git a/pkg/adt/sso_navigation_test.go b/pkg/adt/sso_navigation_test.go
|
|
641
|
+
new file mode 100644
|
|
642
|
+
index 0000000..c851021
|
|
643
|
+
--- /dev/null
|
|
644
|
+
+++ b/pkg/adt/sso_navigation_test.go
|
|
645
|
+
@@ -0,0 +1,16 @@
|
|
646
|
+
+package adt
|
|
647
|
+
+
|
|
648
|
+
+import (
|
|
649
|
+
+ "errors"
|
|
650
|
+
+ "testing"
|
|
651
|
+
+ "time"
|
|
652
|
+
+)
|
|
653
|
+
+
|
|
654
|
+
+func TestSilentNavigationTimeoutAsksForAPerson(t *testing.T) {
|
|
655
|
+
+ if err := navigationTimeout(true, time.Second); !errors.Is(err, ErrSSOInteractiveRequired) {
|
|
656
|
+
+ t.Fatalf("silent timeout = %v, want ErrSSOInteractiveRequired so a visible window opens", err)
|
|
657
|
+
+ }
|
|
658
|
+
+ if err := navigationTimeout(false, time.Second); errors.Is(err, ErrSSOInteractiveRequired) {
|
|
659
|
+
+ t.Fatalf("headed timeout = %v, must not ask for yet another window", err)
|
|
660
|
+
+ }
|
|
661
|
+
+}
|
|
662
|
+
diff --git a/pkg/adt/sso_provider.go b/pkg/adt/sso_provider.go
|
|
663
|
+
index 8e5bed4..6977a33 100644
|
|
664
|
+
--- a/pkg/adt/sso_provider.go
|
|
665
|
+
+++ b/pkg/adt/sso_provider.go
|
|
666
|
+
@@ -180,6 +180,27 @@ func (p *SSOProvider) Login(ctx context.Context) (map[string]string, error) {
|
|
667
|
+
return p.acquire(ctx, true)
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
+// Start returns the session a server begins with; windowFirst skips the silent attempt when nothing is cached.
|
|
671
|
+
+func (p *SSOProvider) Start(ctx context.Context, windowFirst bool) (map[string]string, error) {
|
|
672
|
+
+ if windowFirst && !p.hasSession() {
|
|
673
|
+
+ fmt.Fprintf(os.Stderr, "[SSO] no cached session — opening a browser window to sign in\n")
|
|
674
|
+
+ return p.Login(ctx)
|
|
675
|
+
+ }
|
|
676
|
+
+ return p.Cookies(ctx)
|
|
677
|
+
+}
|
|
678
|
+
+
|
|
679
|
+
+// hasSession counts an unreadable cache as present so Cookies can report why.
|
|
680
|
+
+func (p *SSOProvider) hasSession() bool {
|
|
681
|
+
+ p.mu.Lock()
|
|
682
|
+
+ held := len(p.current) > 0
|
|
683
|
+
+ p.mu.Unlock()
|
|
684
|
+
+ if held {
|
|
685
|
+
+ return true
|
|
686
|
+
+ }
|
|
687
|
+
+ sess, err := LoadSSOSession(p.cachePath)
|
|
688
|
+
+ return err != nil || sess != nil
|
|
689
|
+
+}
|
|
690
|
+
+
|
|
691
|
+
// Clear removes the cached session, so the next call captures a fresh one.
|
|
692
|
+
func (p *SSOProvider) Clear() error {
|
|
693
|
+
p.mu.Lock()
|
package/src/launcher.mjs
CHANGED
|
@@ -222,7 +222,14 @@ async function rememberAdtUrl(configured, found) {
|
|
|
222
222
|
// swept only when the configuration never named one.
|
|
223
223
|
async function resolveLocalAdtUrl(name, configured, client) {
|
|
224
224
|
const log = message => logLine(`[sap-ai-dev] ${name}: ${message}`);
|
|
225
|
-
const detect =
|
|
225
|
+
const detect = async target => {
|
|
226
|
+
const started = Date.now();
|
|
227
|
+
// MCP output channels cannot animate, so a long scan reports that it is still running.
|
|
228
|
+
const heartbeat = setInterval(() => log(`port scan still running on ${new URL(target.url).hostname} (${Math.round((Date.now() - started) / 1000)} s)`), 20_000);
|
|
229
|
+
try { return await detectLocalAdtUrl({ client, ...target }); }
|
|
230
|
+
catch (error) { return { url: '', reason: error.message, failed: true }; }
|
|
231
|
+
finally { clearInterval(heartbeat); }
|
|
232
|
+
};
|
|
226
233
|
const explicit = hasExplicitPort(configured);
|
|
227
234
|
let verdict = { url: '', reason: '' };
|
|
228
235
|
if (explicit) {
|
|
@@ -302,7 +309,8 @@ async function configuredLocalSapGuiDestination(env) {
|
|
|
302
309
|
systemId,
|
|
303
310
|
authentication: 'SSO',
|
|
304
311
|
// SAP_SAML_AUTH survives sanitizeChildEnv and would make VSP demand SAML credentials.
|
|
305
|
-
|
|
312
|
+
// 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 },
|
|
306
314
|
probe
|
|
307
315
|
};
|
|
308
316
|
}
|
|
@@ -401,12 +409,14 @@ async function main() {
|
|
|
401
409
|
}
|
|
402
410
|
|
|
403
411
|
if (['cloud-foundry', 'sap-gui-local'].includes(runtimeEnv.SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE)) {
|
|
412
|
+
logLine(`[sap-ai-dev] sap-ai-dev-toolkit v${pkg.version} (node ${process.version}, pid ${process.pid})`);
|
|
404
413
|
const destination = runtimeEnv.SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE === 'cloud-foundry'
|
|
405
414
|
? await resolveConfiguredCloudFoundryDestination({ env: runtimeEnv })
|
|
406
415
|
: await configuredLocalSapGuiDestination(runtimeEnv);
|
|
407
416
|
let proxy;
|
|
408
417
|
try {
|
|
409
418
|
const binary = await binaryOrError();
|
|
419
|
+
logLine(`[sap-ai-dev] VSP binary: ${binary}`);
|
|
410
420
|
proxy = new MCPProxy({ binary, destinations: [destination], env: runtimeEnv, log: logLine, version: pkg.version });
|
|
411
421
|
const shutdown = signal => { void proxy.close().finally(() => process.exit(signal === 'SIGINT' ? 130 : 143)); };
|
|
412
422
|
process.once('SIGINT', () => shutdown('SIGINT'));
|
package/src/local-sap-gui.mjs
CHANGED
|
@@ -461,8 +461,10 @@ export function adtVerdict(result, protocol = 'https:') {
|
|
|
461
461
|
}
|
|
462
462
|
|
|
463
463
|
// A portless URL means 443, which on-premise ICMs rarely serve; VSP's `detect`
|
|
464
|
-
// probes the conventional ADT ports and reports which one answers.
|
|
465
|
-
|
|
464
|
+
// probes the conventional ADT ports and reports which one answers. A host that
|
|
465
|
+
// accepts every port makes each probe wait out its HTTP timeouts, so the full
|
|
466
|
+
// sweep gets minutes.
|
|
467
|
+
export async function detectAdtUrl(binary, { url, client, instance, env = process.env, exhaustive = false, ports = [], timeoutMs = exhaustive ? 600_000 : 120_000 } = {}) {
|
|
466
468
|
let configured;
|
|
467
469
|
try { configured = new URL(url); } catch { return { url: '', reason: `${url} is not a valid URL` }; }
|
|
468
470
|
const host = configured.hostname.replace(/^\[(.*)\]$/, '$1');
|
package/src/mcp-proxy.mjs
CHANGED
|
@@ -387,7 +387,7 @@ class Child {
|
|
|
387
387
|
}
|
|
388
388
|
|
|
389
389
|
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' }) {
|
|
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', ssoHeartbeatMs = 15_000 }) {
|
|
391
391
|
this.binary = binary;
|
|
392
392
|
this.destinations = destinations;
|
|
393
393
|
this.env = env;
|
|
@@ -396,6 +396,7 @@ export class MCPProxy {
|
|
|
396
396
|
this.log = log;
|
|
397
397
|
this.output = output;
|
|
398
398
|
this.version = version;
|
|
399
|
+
this.ssoHeartbeatMs = ssoHeartbeatMs;
|
|
399
400
|
this.requestTimeoutMs = requestTimeoutMs(env);
|
|
400
401
|
this.children = [];
|
|
401
402
|
this.started = false;
|
|
@@ -532,8 +533,12 @@ export class MCPProxy {
|
|
|
532
533
|
const initialized = await Promise.all(this.children.map(async entry => {
|
|
533
534
|
const startedAt = Date.now();
|
|
534
535
|
this.eventSink(`[${entry.destination.name}] initializing VSP MCP session`);
|
|
536
|
+
// VSP answers initialize only after its browser sign-in, which waits on a person.
|
|
537
|
+
const signIn = entry.destination.authentication === 'SSO'
|
|
538
|
+
? setInterval(() => this.eventSink(`[${entry.destination.name}] still waiting for the SSO sign-in (${Math.round((Date.now() - startedAt) / 1000)} s): finish it in the browser window (it may be behind other windows); restarting the server cancels it`), this.ssoHeartbeatMs)
|
|
539
|
+
: undefined;
|
|
535
540
|
try {
|
|
536
|
-
entry.server = await entry.child.initialize(params);
|
|
541
|
+
entry.server = await entry.child.initialize(params).finally(() => clearInterval(signIn));
|
|
537
542
|
this.eventSink(`[${entry.destination.name}] VSP MCP session initialized (${Date.now() - startedAt}ms)`);
|
|
538
543
|
return entry;
|
|
539
544
|
} catch (error) {
|
package/src/setup.mjs
CHANGED
|
@@ -141,30 +141,42 @@ export function adtCandidate(answer, baseUrl) {
|
|
|
141
141
|
// Cheapest evidence first: named ports get one probe, then the host's usual
|
|
142
142
|
// ports, then the message server's application servers, then every port.
|
|
143
143
|
async function findAdtUrl(destination, label, client, { candidates = [], scan = true, output = stdout, env = process.env, detectAdtUrl } = {}) {
|
|
144
|
-
const detect = async (
|
|
145
|
-
print(output, formatStatus(
|
|
146
|
-
const
|
|
144
|
+
const detect = async (line, activity, target) => {
|
|
145
|
+
print(output, formatStatus(line, 'progress', output, 'ADT'));
|
|
146
|
+
const started = Date.now();
|
|
147
|
+
const progress = startProgress(activity, { output, env, label: 'ADT' });
|
|
148
|
+
// A short, ticking spinner: a long line would truncate the animation away and look frozen.
|
|
149
|
+
const ticker = setInterval(() => progress.update(`${activity} (${Math.round((Date.now() - started) / 1000)} s)`), 1000);
|
|
147
150
|
try { return await detectAdtUrl({ client, ...target }); }
|
|
148
151
|
catch (error) { return { url: '', reason: error.message }; }
|
|
149
|
-
finally {
|
|
152
|
+
finally {
|
|
153
|
+
clearInterval(ticker);
|
|
154
|
+
progress();
|
|
155
|
+
}
|
|
150
156
|
};
|
|
151
157
|
let verdict = { url: '', reason: `no port was checked for ${label}` };
|
|
152
158
|
for (const candidate of [...new Set([destination.url, ...candidates].filter(url => url && hasExplicitPort(url)))]) {
|
|
153
|
-
verdict = await detect(`Checking ADT at ${candidate}`, { url: candidate, ports: [adtPort(candidate)] });
|
|
159
|
+
verdict = await detect(`Checking ADT at ${candidate}`, `Checking ${candidate}`, { url: candidate, ports: [adtPort(candidate)] });
|
|
154
160
|
if (verdict.url || verdict.unresolved) return verdict;
|
|
155
161
|
}
|
|
156
162
|
if (!scan) return verdict;
|
|
157
163
|
const host = new URL(destination.url).hostname;
|
|
158
|
-
verdict = await detect(`Port scan in progress on ${host} to find the ADT port for ${label}`, { url: destination.url, ...(destination.instance ? { instance: destination.instance } : {}) });
|
|
164
|
+
verdict = await detect(`Port scan in progress on ${host} to find the ADT port for ${label}`, `Scanning the usual ADT ports on ${host}`, { url: destination.url, ...(destination.instance ? { instance: destination.instance } : {}) });
|
|
159
165
|
if (verdict.url || verdict.unresolved) return verdict;
|
|
160
166
|
if (destination.messageServer) {
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
167
|
+
const { host: messageHost, httpPort } = destination.messageServer;
|
|
168
|
+
print(output, formatStatus(`Asking the message server ${messageHost}:${httpPort} for the application servers of ${label}`, 'progress', output, 'ADT'));
|
|
169
|
+
const servers = (await messageServerAppServers({ ...destination.messageServer, systemId: destination.systemId })).slice(0, 3);
|
|
170
|
+
print(output, formatStatus(servers.length
|
|
171
|
+
? `The message server lists ${servers.map(server => `${server.host} (instance ${server.instance})`).join(', ')}.`
|
|
172
|
+
: `The message server at ${messageHost}:${httpPort} listed no application servers.`, 'info', output, 'ADT'));
|
|
173
|
+
for (const server of servers) {
|
|
174
|
+
const found = await detect(`Port scan in progress on application server ${server.host} (instance ${server.instance})`, `Scanning ${server.host}`, { url: `${new URL(destination.url).protocol}//${server.host}`, instance: server.instance });
|
|
164
175
|
if (found.url) return found;
|
|
165
176
|
}
|
|
166
177
|
}
|
|
167
|
-
|
|
178
|
+
print(output, formatStatus(`No ADT port on the usual ports for ${label}: ${verdict.reason}.`, 'info', output, 'ADT'));
|
|
179
|
+
return detect(`Scanning the remaining conventional SAP ports on ${host} (about 300 ports; this can take a few minutes)`, `Scanning the remaining SAP ports on ${host}`, { url: destination.url, exhaustive: true });
|
|
168
180
|
}
|
|
169
181
|
|
|
170
182
|
// An SSO sign-in can only start where ADT answers, so for SSO setup does not
|