sap-ai-dev-toolkit 0.9.9906 → 0.9.9908
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 +7 -3
- 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 +2 -2
- package/patches/vsp-bas-proxy-auth.patch +162 -5
- package/src/abaplint.mjs +229 -24
- package/src/harness-mcp-config.mjs +9 -1
- package/src/harnesses.mjs +21 -4
- package/src/launcher.mjs +1 -1
- package/src/mcp-proxy.mjs +7 -1
- package/src/setup.mjs +54 -10
- package/src/sso-browsers.mjs +154 -4
- package/tools.md +3 -2
package/README.md
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
<p align="center">
|
|
17
17
|
<img src="https://img.shields.io/badge/SAP-Business%20Application%20Studio-0A6ED1?style=flat-square&logo=sap&logoColor=white" alt="SAP Business Application Studio">
|
|
18
|
-
<img src="https://img.shields.io/badge/AI%20agents-Copilot%20%7C%20Claude%20%7C%20Codex%20%7C%20Cursor%20%7C%20Gemini%20%7C%20opencode%20%7C%20Pi-000000?style=flat-square" alt="Works with GitHub Copilot, Claude Code, OpenAI Codex, Cursor, Gemini CLI, opencode,
|
|
18
|
+
<img src="https://img.shields.io/badge/AI%20agents-Copilot%20%7C%20Claude%20%7C%20Codex%20%7C%20Cursor%20%7C%20Gemini%20%7C%20opencode%20%7C%20Pi%20%7C%20OMP%20%7C%20Cline%20%7C%20Roo%20%7C%20Windsurf-000000?style=flat-square" alt="Works with GitHub Copilot, Claude Code, OpenAI Codex, Cursor, Gemini CLI, opencode, Pi Coding Agent, Oh My Pi, Cline, Roo Code, and Windsurf">
|
|
19
19
|
<img src="https://img.shields.io/badge/MCP-enabled-7B61FF?style=flat-square" alt="MCP enabled">
|
|
20
20
|
<img src="https://img.shields.io/badge/SAP%20ADT-connected-0A6ED1?style=flat-square&logo=sap&logoColor=white" alt="SAP ADT connected">
|
|
21
21
|
<img src="https://img.shields.io/badge/ABAP-CDS%20%7C%20RAP-EA4AAA?style=flat-square" alt="ABAP CDS RAP">
|
|
@@ -89,7 +89,7 @@ Use the attached destination-prefixed tools directly in chat. Do not launch `sap
|
|
|
89
89
|
|
|
90
90
|
**Included skills:** `abap-development` · `abap-testing-quality` · `cds-development` · `rap-development` · `abap-debugging` · `abap-runtime-analysis` · `rap-service-delivery` · `sap-standard-api-analysis` · `clean-core-extensibility` · `sap-sdlc-orchestration` · `sap-transport-release` · `hana-cloud-inspection` · `hana-cloud-native-development` · `hana-cloud-validation`
|
|
91
91
|
|
|
92
|
-
Agents and skills install for the harnesses you pick during global installation: a multi-select offers GitHub Copilot and Claude Code pre-checked, plus OpenAI Codex, Cursor, Gemini CLI, opencode,
|
|
92
|
+
Agents and skills install for the harnesses you pick during global installation: a multi-select offers GitHub Copilot and Claude Code pre-checked, plus OpenAI Codex, Cursor, Gemini CLI, opencode, Pi Coding Agent, Oh My Pi (OMP), Cline, Roo Code, and Windsurf / Devin Desktop. In non-interactive installs they are installed for GitHub Copilot only; run `sap-ai-dev --setup` afterwards to configure destinations or local SAP GUI systems. Repository-scoped installation instructions appear below.
|
|
93
93
|
|
|
94
94
|
## ✨ Your SAP development cockpit, inside your AI assistant
|
|
95
95
|
|
|
@@ -334,7 +334,7 @@ sap-ai-dev --setup
|
|
|
334
334
|
5. Choose Windows / smart card SSO or username/password when the prompt is available.
|
|
335
335
|
6. Start each generated MCP server from VS Code's **MCP: List Servers** command.
|
|
336
336
|
|
|
337
|
-
The same postinstall offers the bundled agents and skills for several AI coding harnesses in a checkbox picker. **GitHub Copilot** and **Claude Code** start pre-checked, so pressing **Enter** alone installs both; **Space** selects or deselects a harness, **a** toggles all
|
|
337
|
+
The same postinstall offers the bundled agents and skills for several AI coding harnesses in a checkbox picker. **GitHub Copilot** and **Claude Code** start pre-checked, so pressing **Enter** alone installs both; **Space** selects or deselects a harness, **a** toggles all eleven, and confirming with none selected skips the step without changing files. When MCP setup succeeds, generated managed MCP servers are also mirrored into selected harnesses with documented user-level JSON configuration (Claude Code, Cursor, Gemini CLI, Pi Coding Agent, OMP, and Windsurf / Devin Desktop). GitHub Copilot continues to use the VS Code/BAS MCP configuration written during setup. With no interactive terminal, the assets are installed for GitHub Copilot only; set `SAP_AI_DEV_TOOLKIT_HARNESSES` to a comma-separated list of harness ids (`github-copilot`, `claude-code`, `codex`, `cursor`, `gemini-cli`, `opencode`, `pi-coding-agent`, `omp`, `cline`, `roo-code`, `windsurf`) to choose non-interactively.
|
|
338
338
|
|
|
339
339
|
Some current npm versions also require install hooks to be approved. If npm reports that `sap-ai-dev-toolkit`'s `postinstall` was blocked, allow it during a fresh install with `npm install --global --allow-scripts=sap-ai-dev-toolkit sap-ai-dev-toolkit`, or run `sap-ai-dev --setup` from an interactive terminal after installation.
|
|
340
340
|
|
|
@@ -418,6 +418,10 @@ During npm postinstall, or after interactive `sap-ai-dev --setup` in BAS or on a
|
|
|
418
418
|
| Gemini CLI | `$HOME/.gemini/skills/` | — | `$HOME/.gemini/settings.json` |
|
|
419
419
|
| opencode | `$XDG_CONFIG_HOME/opencode/skills/` (default `$HOME/.config/opencode/skills/`) | `$XDG_CONFIG_HOME/opencode/agents/<id>.md` (as subagents) | Not changed automatically |
|
|
420
420
|
| Pi Coding Agent | `$PI_CODING_AGENT_DIR/skills/` (default `$HOME/.pi/agent/skills/`) | `$PI_CODING_AGENT_DIR/prompts/<id>.md` (as `/` prompt commands) | `$PI_CODING_AGENT_DIR/mcp.json` (default `$HOME/.pi/agent/mcp.json`) |
|
|
421
|
+
| Oh My Pi (OMP) | `$HOME/.omp/agent/skills/` (overridden by `PI_CONFIG_DIR` or `PI_CODING_AGENT_DIR`) | — | `$HOME/.omp/agent/mcp.json` (same overrides) |
|
|
422
|
+
| Cline | `$HOME/.cline/skills/` | — | Not changed automatically |
|
|
423
|
+
| Roo Code | `$HOME/.roo/skills/` | — | Not changed automatically |
|
|
424
|
+
| Windsurf / Devin Desktop | `$XDG_CONFIG_HOME/devin/skills/` (default `$HOME/.config/devin/skills/`); Windows: `$HOME/.codeium/windsurf/skills/` | — | `$XDG_CONFIG_HOME/devin/mcp_config.json` (default `$HOME/.config/devin/mcp_config.json`); Windows: `%APPDATA%\devin\mcp_config.json` |
|
|
421
425
|
|
|
422
426
|
All 14 skills use the portable `SKILL.md` format and are installed verbatim. The 5 agents use Copilot's `.agent.md` format, so they are installed as native custom agents for harnesses with a custom-agent mechanism (GitHub Copilot, Claude Code, opencode), with frontmatter adapted where needed. Pi receives equivalent prompt templates (`/abap-developer`, `/sap-solution-architect`, and so on) because Pi exposes reusable personas through prompts rather than a separate custom-agent file type. Selecting several harnesses duplicates the small markdown payloads on disk.
|
|
423
427
|
|
package/dist/checksums.txt
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
f10b759b5e320c6fec4c176ef9bce49f783d796eeb8a2476dd5898c94cb14afd vsp-linux-x64
|
|
2
|
+
a4c47e4c15538cc16b540d19f55771e4c1340389efeca815e1881eafd89e45c5 vsp-linux-arm64
|
|
3
|
+
20a197921c88f539ea1a7f5236509dbaa9b9f906a6333e6fb1dce9f8b5eec85c vsp-darwin-x64
|
|
4
|
+
24a8810a17d31a434b92957ea41e6347b24e2a6a35e47b3b2fc13a16082a44c3 vsp-darwin-arm64
|
|
5
|
+
b318fdf5194a7f15ebcf7ee912fb2493df21aba337540a2402f81786300fd0ea 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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sap-ai-dev-toolkit",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.9908",
|
|
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",
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
},
|
|
10
10
|
"dependencies": {
|
|
11
11
|
"@abaplint/core": "2.119.0",
|
|
12
|
-
"@modelcontextprotocol/sdk": "1.
|
|
12
|
+
"@modelcontextprotocol/sdk": "1.32.1",
|
|
13
13
|
"@sap/hana-client": "2.30.27",
|
|
14
14
|
"terminal-kit": "^3.1.4",
|
|
15
15
|
"undici": "^6.21.3",
|
|
@@ -620,10 +620,30 @@ index 0000000..5e880bd
|
|
|
620
620
|
+ }
|
|
621
621
|
+}
|
|
622
622
|
diff --git a/pkg/adt/http.go b/pkg/adt/http.go
|
|
623
|
-
index 0b4d9f1..
|
|
623
|
+
index 0b4d9f1..f0b5b51 100755
|
|
624
624
|
--- a/pkg/adt/http.go
|
|
625
625
|
+++ b/pkg/adt/http.go
|
|
626
|
-
@@ -
|
|
626
|
+
@@ -313,7 +313,7 @@ func (t *Transport) request(ctx context.Context, path string, opts *RequestOptio
|
|
627
|
+
// The same expiry reaches a plain read as a successful-looking response that
|
|
628
|
+
// was in fact served by the identity provider. Nothing downstream would
|
|
629
|
+
// recognise the logon page it carries, so catch it here by origin.
|
|
630
|
+
- if resp.StatusCode < 400 && t.canReauth() && t.redirectedAwayFromSAP(resp) {
|
|
631
|
+
+ if t.canReauth() && ((resp.StatusCode < 400 && t.redirectedAwayFromSAP(resp)) || isSSOLoginResponse(resp, body)) {
|
|
632
|
+
t.setCSRFToken("")
|
|
633
|
+
t.setSessionID("")
|
|
634
|
+
if err := t.callReauthFunc(ctx); err != nil {
|
|
635
|
+
@@ -448,6 +448,10 @@ func (t *Transport) retryRequest(ctx context.Context, path string, opts *Request
|
|
636
|
+
}
|
|
637
|
+
traceHTTPResponse(resp, body)
|
|
638
|
+
|
|
639
|
+
+ if t.canReauth() && (t.redirectedAwayFromSAP(resp) || isSSOLoginResponse(resp, body)) {
|
|
640
|
+
+ return nil, fmt.Errorf("SSO session is still unauthenticated after re-authentication on %s", path)
|
|
641
|
+
+ }
|
|
642
|
+
+
|
|
643
|
+
if resp.StatusCode >= 400 {
|
|
644
|
+
return nil, &APIError{
|
|
645
|
+
StatusCode: resp.StatusCode,
|
|
646
|
+
@@ -499,6 +503,7 @@ func (t *Transport) fetchCSRFTokenFor(ctx context.Context, stateful bool) error
|
|
627
647
|
// allowReauth exists to break a cycle: re-authenticating ends with a token
|
|
628
648
|
// fetch of its own, and that fetch must not start another re-authentication.
|
|
629
649
|
func (t *Transport) fetchCSRFTokenWithReauth(ctx context.Context, allowReauth bool, stateful bool) error {
|
|
@@ -631,7 +651,7 @@ index 0b4d9f1..3473536 100755
|
|
|
631
651
|
token, status, redirected, err := t.probeCSRFToken(ctx, http.MethodHead, stateful)
|
|
632
652
|
if err != nil {
|
|
633
653
|
return err
|
|
634
|
-
@@ -513,6 +
|
|
654
|
+
@@ -513,6 +518,29 @@ func (t *Transport) fetchCSRFTokenWithReauth(ctx context.Context, allowReauth bo
|
|
635
655
|
if err != nil {
|
|
636
656
|
return err
|
|
637
657
|
}
|
|
@@ -661,7 +681,16 @@ index 0b4d9f1..3473536 100755
|
|
|
661
681
|
if !isCSRFToken(token) {
|
|
662
682
|
// An expired SSO session rarely announces itself as a 401. ICF sends
|
|
663
683
|
// the request on to the identity provider, the redirect chain is
|
|
664
|
-
@@ -
|
|
684
|
+
@@ -520,7 +548,7 @@ func (t *Transport) fetchCSRFTokenWithReauth(ctx context.Context, allowReauth bo
|
|
685
|
+
// 200 — with no CSRF token in it, because it is not ADT answering.
|
|
686
|
+
// A live ADT session always yields a token, so its absence here is
|
|
687
|
+
// the signal, and a hop to a foreign host is the confirmation.
|
|
688
|
+
- if allowReauth && t.canReauth() && getStatus != http.StatusForbidden {
|
|
689
|
+
+ if allowReauth && t.canReauth() && (getStatus != http.StatusForbidden || redirected || getRedirected) {
|
|
690
|
+
reason := fmt.Sprintf("no CSRF token (HEAD %d, GET %d)", status, getStatus)
|
|
691
|
+
if redirected || getRedirected {
|
|
692
|
+
reason = "the identity provider answered instead of SAP"
|
|
693
|
+
@@ -537,6 +565,9 @@ func (t *Transport) fetchCSRFTokenWithReauth(ctx context.Context, allowReauth bo
|
|
665
694
|
}
|
|
666
695
|
return t.fetchCSRFTokenWithReauth(ctx, false, stateful)
|
|
667
696
|
}
|
|
@@ -671,7 +700,7 @@ index 0b4d9f1..3473536 100755
|
|
|
671
700
|
switch getStatus {
|
|
672
701
|
case http.StatusUnauthorized:
|
|
673
702
|
return fmt.Errorf("authentication failed (401): check username/password")
|
|
674
|
-
@@ -556,7 +
|
|
703
|
+
@@ -556,7 +587,11 @@ func (t *Transport) fetchCSRFTokenWithReauth(ctx context.Context, allowReauth bo
|
|
675
704
|
// returns the token (empty when the server did not supply one), the status, and
|
|
676
705
|
// whether the answer came from somewhere other than the SAP host.
|
|
677
706
|
func (t *Transport) probeCSRFToken(ctx context.Context, method string, stateful bool) (token string, status int, redirected bool, err error) {
|
|
@@ -684,6 +713,42 @@ index 0b4d9f1..3473536 100755
|
|
|
684
713
|
if err != nil {
|
|
685
714
|
return "", 0, false, fmt.Errorf("building URL: %w", err)
|
|
686
715
|
}
|
|
716
|
+
@@ -596,12 +631,34 @@ func (t *Transport) probeCSRFToken(ctx context.Context, method string, stateful
|
|
717
|
+
return "", 0, false, fmt.Errorf("executing request: %w", err)
|
|
718
|
+
}
|
|
719
|
+
defer resp.Body.Close()
|
|
720
|
+
+ body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
|
|
721
|
+
+ if err != nil {
|
|
722
|
+
+ return "", 0, false, fmt.Errorf("reading CSRF response: %w", err)
|
|
723
|
+
+ }
|
|
724
|
+
// Drain the body so the connection can be reused.
|
|
725
|
+
_, _ = io.Copy(io.Discard, resp.Body)
|
|
726
|
+
traceHTTPResponse(resp, nil)
|
|
727
|
+
t.adoptServerCookies(resp)
|
|
728
|
+
|
|
729
|
+
- return resp.Header.Get("X-CSRF-Token"), resp.StatusCode, t.redirectedAwayFromSAP(resp), nil
|
|
730
|
+
+ return resp.Header.Get("X-CSRF-Token"), resp.StatusCode, t.redirectedAwayFromSAP(resp) || isSSOLoginResponse(resp, body), nil
|
|
731
|
+
+}
|
|
732
|
+
+
|
|
733
|
+
+func isSSOLoginResponse(resp *http.Response, body []byte) bool {
|
|
734
|
+
+ if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusUnauthorized && resp.StatusCode != http.StatusForbidden {
|
|
735
|
+
+ return false
|
|
736
|
+
+ }
|
|
737
|
+
+ page := strings.ToLower(strings.TrimSpace(string(body)))
|
|
738
|
+
+ html := strings.Contains(strings.ToLower(resp.Header.Get("Content-Type")), "text/html") ||
|
|
739
|
+
+ strings.HasPrefix(page, "<!doctype html") || strings.HasPrefix(page, "<html")
|
|
740
|
+
+ if !html || !strings.Contains(page, "<form") {
|
|
741
|
+
+ return false
|
|
742
|
+
+ }
|
|
743
|
+
+ for _, marker := range []string{"samlrequest", "samlresponse", "sap-system-login", "sap-password", `type="password"`, `type='password'`} {
|
|
744
|
+
+ if strings.Contains(page, marker) {
|
|
745
|
+
+ return true
|
|
746
|
+
+ }
|
|
747
|
+
+ }
|
|
748
|
+
+ return false
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// redirectedAwayFromSAP reports whether a response was ultimately served by
|
|
687
752
|
diff --git a/pkg/adt/http_csrf_discovery_test.go b/pkg/adt/http_csrf_discovery_test.go
|
|
688
753
|
new file mode 100644
|
|
689
754
|
index 0000000..80741da
|
|
@@ -746,6 +811,98 @@ index 0000000..80741da
|
|
|
746
811
|
+ }
|
|
747
812
|
+}
|
|
748
813
|
+
|
|
814
|
+
diff --git a/pkg/adt/http_sso_same_host_test.go b/pkg/adt/http_sso_same_host_test.go
|
|
815
|
+
new file mode 100644
|
|
816
|
+
index 0000000..0c39b96
|
|
817
|
+
--- /dev/null
|
|
818
|
+
+++ b/pkg/adt/http_sso_same_host_test.go
|
|
819
|
+
@@ -0,0 +1,86 @@
|
|
820
|
+
+package adt
|
|
821
|
+
+
|
|
822
|
+
+import (
|
|
823
|
+
+ "context"
|
|
824
|
+
+ "io"
|
|
825
|
+
+ "net/http"
|
|
826
|
+
+ "net/http/httptest"
|
|
827
|
+
+ "strings"
|
|
828
|
+
+ "testing"
|
|
829
|
+
+)
|
|
830
|
+
+
|
|
831
|
+
+func TestSameHostLoginPageRefreshesSSO(t *testing.T) {
|
|
832
|
+
+ for _, method := range []string{http.MethodGet, http.MethodPost} {
|
|
833
|
+
+ for _, status := range []int{http.StatusOK, http.StatusUnauthorized, http.StatusForbidden} {
|
|
834
|
+
+ t.Run(method+"/"+http.StatusText(status), func(t *testing.T) {
|
|
835
|
+
+ refreshes := 0
|
|
836
|
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
837
|
+
+ cookie, err := r.Cookie("SAP_SESSIONID")
|
|
838
|
+
+ if err != nil || cookie.Value != "fresh" {
|
|
839
|
+
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
840
|
+
+ w.WriteHeader(status)
|
|
841
|
+
+ _, _ = io.WriteString(w, `<html><body><form action="/sap/saml2/sp/acs"><input name="SAMLRequest" value="fixture"></form></body></html>`)
|
|
842
|
+
+ return
|
|
843
|
+
+ }
|
|
844
|
+
+ w.Header().Set("X-CSRF-Token", "fresh-token")
|
|
845
|
+
+ w.Header().Set("Content-Type", "application/xml")
|
|
846
|
+
+ _, _ = io.WriteString(w, "<source>authenticated</source>")
|
|
847
|
+
+ }))
|
|
848
|
+
+ defer server.Close()
|
|
849
|
+
+ transport := NewTransportWithClient(&Config{
|
|
850
|
+
+ BaseURL: server.URL,
|
|
851
|
+
+ Cookies: map[string]string{"SAP_SESSIONID": "expired"},
|
|
852
|
+
+ ReauthFunc: func(context.Context) (map[string]string, error) {
|
|
853
|
+
+ refreshes++
|
|
854
|
+
+ return map[string]string{"SAP_SESSIONID": "fresh"}, nil
|
|
855
|
+
+ },
|
|
856
|
+
+ }, server.Client())
|
|
857
|
+
+ response, err := transport.Request(context.Background(), "/sap/bc/adt/source", &RequestOptions{Method: method})
|
|
858
|
+
+ if err != nil {
|
|
859
|
+
+ t.Fatalf("request failed: %v", err)
|
|
860
|
+
+ }
|
|
861
|
+
+ if refreshes != 1 || string(response.Body) != "<source>authenticated</source>" {
|
|
862
|
+
+ t.Fatalf("refreshes=%d body=%q, want one SSO refresh and authenticated source", refreshes, response.Body)
|
|
863
|
+
+ }
|
|
864
|
+
+ })
|
|
865
|
+
+ }
|
|
866
|
+
+ }
|
|
867
|
+
+}
|
|
868
|
+
+
|
|
869
|
+
+func TestOrdinaryForbiddenDoesNotRefreshSSO(t *testing.T) {
|
|
870
|
+
+ refreshes := 0
|
|
871
|
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
872
|
+
+ w.WriteHeader(http.StatusForbidden)
|
|
873
|
+
+ _, _ = io.WriteString(w, "Missing development authorization")
|
|
874
|
+
+ }))
|
|
875
|
+
+ defer server.Close()
|
|
876
|
+
+ transport := NewTransportWithClient(&Config{BaseURL: server.URL, ReauthFunc: func(context.Context) (map[string]string, error) {
|
|
877
|
+
+ refreshes++
|
|
878
|
+
+ return nil, nil
|
|
879
|
+
+ }}, server.Client())
|
|
880
|
+
+ _, err := transport.Request(context.Background(), "/sap/bc/adt/source", &RequestOptions{Method: http.MethodGet})
|
|
881
|
+
+ if err == nil || refreshes != 0 {
|
|
882
|
+
+ t.Fatalf("err=%v refreshes=%d, want authorization error without SSO refresh", err, refreshes)
|
|
883
|
+
+ }
|
|
884
|
+
+}
|
|
885
|
+
+
|
|
886
|
+
+func TestSSOLoginPageAfterRefreshFailsWithoutLoop(t *testing.T) {
|
|
887
|
+
+ refreshes := 0
|
|
888
|
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
889
|
+
+ if r.URL.Path == "/sap/bc/adt/source" {
|
|
890
|
+
+ w.Header().Set("Content-Type", "text/html")
|
|
891
|
+
+ _, _ = io.WriteString(w, `<html><form><input type="password"></form></html>`)
|
|
892
|
+
+ return
|
|
893
|
+
+ }
|
|
894
|
+
+ w.Header().Set("X-CSRF-Token", "fresh-token")
|
|
895
|
+
+ }))
|
|
896
|
+
+ defer server.Close()
|
|
897
|
+
+ transport := NewTransportWithClient(&Config{BaseURL: server.URL, ReauthFunc: func(context.Context) (map[string]string, error) {
|
|
898
|
+
+ refreshes++
|
|
899
|
+
+ return map[string]string{"SAP_SESSIONID": "fresh"}, nil
|
|
900
|
+
+ }}, server.Client())
|
|
901
|
+
+ _, err := transport.Request(context.Background(), "/sap/bc/adt/source", &RequestOptions{Method: http.MethodGet})
|
|
902
|
+
+ if err == nil || !strings.Contains(err.Error(), "still unauthenticated") || refreshes != 1 {
|
|
903
|
+
+ t.Fatalf("err=%v refreshes=%d, want explicit failure after one refresh", err, refreshes)
|
|
904
|
+
+ }
|
|
905
|
+
+}
|
|
749
906
|
diff --git a/pkg/adt/servicebinding_publish_test.go b/pkg/adt/servicebinding_publish_test.go
|
|
750
907
|
new file mode 100644
|
|
751
908
|
index 0000000..93c19f9
|
package/src/abaplint.mjs
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
|
+
import { lstat, readdir, readFile, realpath } from 'node:fs/promises';
|
|
2
|
+
import { isAbsolute, relative, resolve, sep } from 'node:path';
|
|
1
3
|
import { Config, MemoryFile, Registry } from '@abaplint/core';
|
|
2
4
|
|
|
5
|
+
const MAX_PROJECT_FILES = 20_000;
|
|
6
|
+
const MAX_PROJECT_FILE_BYTES = 2 * 1024 * 1024;
|
|
7
|
+
const MAX_PROJECT_SOURCE_BYTES = 32 * 1024 * 1024;
|
|
8
|
+
const IGNORED_DIRECTORIES = new Set(['.git', '.hg', '.svn', '.build', '.next', '.cache', 'coverage', 'dist', 'node_modules']);
|
|
9
|
+
const PROJECT_CONFIG_FILES = ['.abaplint.jsonc', 'abaplint.jsonc', '.abaplint.json', 'abaplint.json'];
|
|
10
|
+
|
|
3
11
|
export const ABAP_LINT_TOOL = {
|
|
4
12
|
name: 'LintABAP',
|
|
5
13
|
description: 'Run abaplint on caller-supplied source files in memory; does not read workspace files or call SAP.',
|
|
@@ -26,6 +34,21 @@ export const ABAP_LINT_TOOL = {
|
|
|
26
34
|
}
|
|
27
35
|
};
|
|
28
36
|
|
|
37
|
+
export const ABAP_LINT_PROJECT_TOOL = {
|
|
38
|
+
name: 'LintABAPProject',
|
|
39
|
+
description: 'Run read-only abaplint checks over serialized ABAP objects under the configured workspace root. Loads the project config and local dependency folders when present; does not call SAP, write files, or activate ABAP.',
|
|
40
|
+
inputSchema: {
|
|
41
|
+
type: 'object',
|
|
42
|
+
properties: {
|
|
43
|
+
directory: { type: 'string', description: 'Optional project subdirectory relative to the configured workspace root.' },
|
|
44
|
+
config: { type: 'object', description: 'Optional abaplint config object that overrides project config discovery.' }
|
|
45
|
+
},
|
|
46
|
+
required: [],
|
|
47
|
+
additionalProperties: false
|
|
48
|
+
},
|
|
49
|
+
annotations: { readOnlyHint: true }
|
|
50
|
+
};
|
|
51
|
+
|
|
29
52
|
function isObject(value) {
|
|
30
53
|
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
31
54
|
}
|
|
@@ -65,6 +88,170 @@ function errorResult(error) {
|
|
|
65
88
|
};
|
|
66
89
|
}
|
|
67
90
|
|
|
91
|
+
function reportResult(report) {
|
|
92
|
+
return {
|
|
93
|
+
isError: false,
|
|
94
|
+
content: [{ type: 'text', text: JSON.stringify(report) }],
|
|
95
|
+
structuredContent: report
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function lintReport(registry, filesChecked) {
|
|
100
|
+
const issues = registry.findIssues();
|
|
101
|
+
const counts = { Error: 0, Warning: 0, Info: 0 };
|
|
102
|
+
const reportIssues = issues.map(issue => {
|
|
103
|
+
counts[issue.getSeverity()] += 1;
|
|
104
|
+
const start = issue.getStart();
|
|
105
|
+
const end = issue.getEnd();
|
|
106
|
+
return {
|
|
107
|
+
filename: issue.getFilename(),
|
|
108
|
+
rule: issue.getKey(),
|
|
109
|
+
severity: issue.getSeverity(),
|
|
110
|
+
message: issue.getMessage(),
|
|
111
|
+
start: { line: start.getRow(), column: start.getCol() },
|
|
112
|
+
end: { line: end.getRow(), column: end.getCol() }
|
|
113
|
+
};
|
|
114
|
+
});
|
|
115
|
+
return {
|
|
116
|
+
status: reportIssues.length === 0 ? 'clean' : 'issues',
|
|
117
|
+
filesChecked,
|
|
118
|
+
issueCount: reportIssues.length,
|
|
119
|
+
errors: counts.Error,
|
|
120
|
+
warnings: counts.Warning,
|
|
121
|
+
infos: counts.Info,
|
|
122
|
+
issues: reportIssues
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function pathIsWithin(parent, candidate) {
|
|
127
|
+
const path = relative(parent, candidate);
|
|
128
|
+
return path === '' || (path !== '..' && !path.startsWith(`..${sep}`) && !isAbsolute(path));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function validateProjectArguments(arguments_) {
|
|
132
|
+
if (!isObject(arguments_)) throw new Error('Arguments must be an object');
|
|
133
|
+
rejectUnknownFields(arguments_, new Set(['directory', 'config']), 'argument');
|
|
134
|
+
if (arguments_.directory !== undefined) {
|
|
135
|
+
if (typeof arguments_.directory !== 'string' || arguments_.directory.includes('\0') ||
|
|
136
|
+
isAbsolute(arguments_.directory) || /^[A-Za-z]:[\\/]/.test(arguments_.directory) ||
|
|
137
|
+
arguments_.directory.split(/[\\/]+/).includes('..')) {
|
|
138
|
+
throw new Error('directory must be a relative path inside the configured workspace root');
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (arguments_.config !== undefined && !isObject(arguments_.config)) {
|
|
142
|
+
throw new Error('config must be an object');
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function loadProjectConfig(directory, override) {
|
|
147
|
+
if (override !== undefined) return { config: new Config(JSON.stringify(override)), source: 'caller' };
|
|
148
|
+
for (const filename of PROJECT_CONFIG_FILES) {
|
|
149
|
+
const configPath = resolve(directory, filename);
|
|
150
|
+
let info;
|
|
151
|
+
try { info = await lstat(configPath); }
|
|
152
|
+
catch (error) {
|
|
153
|
+
if (error?.code === 'ENOENT') continue;
|
|
154
|
+
throw error;
|
|
155
|
+
}
|
|
156
|
+
if (info.isSymbolicLink() || !info.isFile()) throw new Error(`${filename} must be a regular file inside the workspace`);
|
|
157
|
+
try {
|
|
158
|
+
return { config: new Config(await readFile(configPath, 'utf8')), source: filename };
|
|
159
|
+
} catch (error) {
|
|
160
|
+
throw new Error(`Could not load ${filename}: ${error.message || error}`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return { config: Config.getDefault(), source: 'default' };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function enableSyntaxRules(config) {
|
|
167
|
+
const projectConfig = config.get();
|
|
168
|
+
const rules = { ...(projectConfig.rules || {}) };
|
|
169
|
+
const disabled = [];
|
|
170
|
+
for (const rule of ['parser_error', 'check_syntax']) {
|
|
171
|
+
if (rules[rule] === false) disabled.push(rule);
|
|
172
|
+
else if (rules[rule] === undefined) rules[rule] = true;
|
|
173
|
+
}
|
|
174
|
+
return {
|
|
175
|
+
config: new Config(JSON.stringify({ ...projectConfig, rules })),
|
|
176
|
+
disabled
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function dependencyRoots(config, projectRoot) {
|
|
181
|
+
const declared = config.get()?.dependencies;
|
|
182
|
+
if (!Array.isArray(declared)) return { roots: [], missing: [], count: 0 };
|
|
183
|
+
const roots = [];
|
|
184
|
+
const missing = [];
|
|
185
|
+
for (const dependency of declared) {
|
|
186
|
+
const folder = dependency?.folder;
|
|
187
|
+
if (typeof folder !== 'string' || !folder.trim()) {
|
|
188
|
+
missing.push(String(folder || '(unspecified folder)'));
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
const relativeFolder = folder.replace(/^[\\/]+/, '');
|
|
192
|
+
if (/^[A-Za-z]:[\\/]/.test(relativeFolder) || relativeFolder.split(/[\\/]+/).includes('..')) {
|
|
193
|
+
throw new Error('Dependency folders must stay inside the configured project directory');
|
|
194
|
+
}
|
|
195
|
+
const candidate = resolve(projectRoot, relativeFolder || '.');
|
|
196
|
+
if (!pathIsWithin(projectRoot, candidate)) throw new Error('Dependency folders must stay inside the configured project directory');
|
|
197
|
+
let canonical;
|
|
198
|
+
try { canonical = await realpath(candidate); }
|
|
199
|
+
catch (error) {
|
|
200
|
+
if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') {
|
|
201
|
+
missing.push(folder);
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
throw error;
|
|
205
|
+
}
|
|
206
|
+
if (!pathIsWithin(projectRoot, canonical)) throw new Error('Dependency folders must stay inside the configured project directory');
|
|
207
|
+
const info = await lstat(canonical);
|
|
208
|
+
if (!info.isDirectory()) {
|
|
209
|
+
missing.push(folder);
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
roots.push(canonical);
|
|
213
|
+
}
|
|
214
|
+
return { roots, missing, count: declared.length };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function collectProjectFiles(projectRoot, dependencies) {
|
|
218
|
+
const sourceFiles = [];
|
|
219
|
+
const dependencyFiles = [];
|
|
220
|
+
const pending = [projectRoot];
|
|
221
|
+
let totalBytes = 0;
|
|
222
|
+
while (pending.length) {
|
|
223
|
+
const directory = pending.pop();
|
|
224
|
+
const entries = await readdir(directory, { withFileTypes: true });
|
|
225
|
+
entries.sort((left, right) => left.name.localeCompare(right.name));
|
|
226
|
+
for (const entry of entries) {
|
|
227
|
+
const absolutePath = resolve(directory, entry.name);
|
|
228
|
+
if (entry.isDirectory()) {
|
|
229
|
+
const isDependencyPath = dependencies.some(root => pathIsWithin(absolutePath, root) || pathIsWithin(root, absolutePath));
|
|
230
|
+
if (!IGNORED_DIRECTORIES.has(entry.name) || isDependencyPath) pending.push(absolutePath);
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
if (!entry.isFile()) continue;
|
|
234
|
+
const filename = relative(projectRoot, absolutePath).split(sep).join('/');
|
|
235
|
+
const basename = filename.split('/').at(-1);
|
|
236
|
+
const nameParts = basename.split('.');
|
|
237
|
+
if (nameParts.length < 3 || !nameParts[0] || !nameParts[1]) continue;
|
|
238
|
+
if (!new MemoryFile(filename, '').getObjectType()) continue;
|
|
239
|
+
const info = await lstat(absolutePath);
|
|
240
|
+
if (!info.isFile() || info.isSymbolicLink()) continue;
|
|
241
|
+
if (info.size > MAX_PROJECT_FILE_BYTES) throw new Error(`ABAP file exceeds ${MAX_PROJECT_FILE_BYTES} bytes: ${filename}`);
|
|
242
|
+
totalBytes += info.size;
|
|
243
|
+
if (totalBytes > MAX_PROJECT_SOURCE_BYTES) throw new Error(`Workspace ABAP sources exceed ${MAX_PROJECT_SOURCE_BYTES} bytes`);
|
|
244
|
+
const file = new MemoryFile(filename, await readFile(absolutePath, 'utf8'));
|
|
245
|
+
const isDependency = dependencies.some(root => pathIsWithin(root, absolutePath));
|
|
246
|
+
(isDependency ? dependencyFiles : sourceFiles).push(file);
|
|
247
|
+
if (sourceFiles.length + dependencyFiles.length > MAX_PROJECT_FILES) {
|
|
248
|
+
throw new Error(`Workspace contains more than ${MAX_PROJECT_FILES} ABAP files`);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return { sourceFiles, dependencyFiles };
|
|
253
|
+
}
|
|
254
|
+
|
|
68
255
|
export async function runABAPLint(arguments_) {
|
|
69
256
|
try {
|
|
70
257
|
validateArguments(arguments_);
|
|
@@ -74,31 +261,49 @@ export async function runABAPLint(arguments_) {
|
|
|
74
261
|
const files = arguments_.files.map(({ filename, source }) => new MemoryFile(filename, source));
|
|
75
262
|
const registry = new Registry(config).addFiles(files);
|
|
76
263
|
await registry.parseAsync();
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
264
|
+
return { isError: false, content: [{ type: 'text', text: JSON.stringify(lintReport(registry, files.length)) }] };
|
|
265
|
+
} catch (error) {
|
|
266
|
+
return errorResult(error);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export async function runABAPLintProject(arguments_, { workspaceRoot = process.cwd() } = {}) {
|
|
271
|
+
try {
|
|
272
|
+
validateProjectArguments(arguments_);
|
|
273
|
+
const root = await realpath(workspaceRoot);
|
|
274
|
+
const directory = resolve(root, arguments_.directory || '.');
|
|
275
|
+
if (!pathIsWithin(root, directory)) throw new Error('directory must stay inside the configured workspace root');
|
|
276
|
+
const projectRoot = await realpath(directory);
|
|
277
|
+
if (!pathIsWithin(root, projectRoot)) throw new Error('directory must stay inside the configured workspace root');
|
|
278
|
+
const projectInfo = await lstat(projectRoot);
|
|
279
|
+
if (!projectInfo.isDirectory()) throw new Error('directory must identify a workspace directory');
|
|
280
|
+
|
|
281
|
+
const { config: projectConfig, source: configSource } = await loadProjectConfig(projectRoot, arguments_.config);
|
|
282
|
+
const { config, disabled: disabledSyntaxRules } = enableSyntaxRules(projectConfig);
|
|
283
|
+
const dependencies = await dependencyRoots(config, projectRoot);
|
|
284
|
+
const { sourceFiles, dependencyFiles } = await collectProjectFiles(projectRoot, dependencies.roots);
|
|
285
|
+
const registry = new Registry(config);
|
|
286
|
+
registry.addDependencies(dependencyFiles);
|
|
287
|
+
registry.addFiles(sourceFiles);
|
|
288
|
+
await registry.parseAsync();
|
|
289
|
+
|
|
290
|
+
const report = lintReport(registry, sourceFiles.length);
|
|
291
|
+
if (report.filesChecked === 0) report.status = 'no-abap-files';
|
|
292
|
+
else if (report.status === 'clean' && (dependencies.missing.length || disabledSyntaxRules.length)) report.status = 'incomplete';
|
|
293
|
+
return reportResult({
|
|
294
|
+
...report,
|
|
295
|
+
scope: 'workspace-static',
|
|
296
|
+
activation: 'not-performed',
|
|
297
|
+
workspaceDirectory: arguments_.directory || '.',
|
|
298
|
+
configSource,
|
|
299
|
+
targetRelease: config.getVersion(),
|
|
300
|
+
syntaxRules: { enabled: ['parser_error', 'check_syntax'].filter(rule => !disabledSyntaxRules.includes(rule)), disabled: disabledSyntaxRules },
|
|
301
|
+
dependencies: {
|
|
302
|
+
declared: dependencies.count,
|
|
303
|
+
filesLoaded: dependencyFiles.length,
|
|
304
|
+
missingFolders: dependencies.missing
|
|
305
|
+
}
|
|
91
306
|
});
|
|
92
|
-
const report = {
|
|
93
|
-
status: reportIssues.length === 0 ? 'clean' : 'issues',
|
|
94
|
-
filesChecked: files.length,
|
|
95
|
-
issueCount: reportIssues.length,
|
|
96
|
-
errors: counts.Error,
|
|
97
|
-
warnings: counts.Warning,
|
|
98
|
-
infos: counts.Info,
|
|
99
|
-
issues: reportIssues
|
|
100
|
-
};
|
|
101
|
-
return { isError: false, content: [{ type: 'text', text: JSON.stringify(report) }] };
|
|
102
307
|
} catch (error) {
|
|
103
308
|
return errorResult(error);
|
|
104
309
|
}
|
|
@@ -7,7 +7,7 @@ import { decodeJsonFileBuffer } from './mcp-config.mjs';
|
|
|
7
7
|
|
|
8
8
|
const MANAGED_ENV = 'SAP_AI_DEV_TOOLKIT_MANAGED';
|
|
9
9
|
|
|
10
|
-
function mcpServersPath(harness, { home = process.env.HOME || homedir(), env = process.env } = {}) {
|
|
10
|
+
function mcpServersPath(harness, { home = process.env.HOME || homedir(), env = process.env, platform = process.platform } = {}) {
|
|
11
11
|
switch (harness.id) {
|
|
12
12
|
case 'claude-code':
|
|
13
13
|
return join(home, '.claude.json');
|
|
@@ -15,8 +15,16 @@ function mcpServersPath(harness, { home = process.env.HOME || homedir(), env = p
|
|
|
15
15
|
return join(harnessRoot(harness, { home, env }), 'mcp.json');
|
|
16
16
|
case 'gemini-cli':
|
|
17
17
|
return join(harnessRoot(harness, { home, env }), 'settings.json');
|
|
18
|
+
case 'omp':
|
|
19
|
+
return join(harnessRoot(harness, { home, env }), 'mcp.json');
|
|
18
20
|
case 'pi-coding-agent':
|
|
19
21
|
return join(harnessRoot(harness, { home, env }), 'mcp.json');
|
|
22
|
+
case 'windsurf': {
|
|
23
|
+
const configRoot = platform === 'win32'
|
|
24
|
+
? (typeof env?.APPDATA === 'string' && env.APPDATA.trim() ? env.APPDATA.trim() : join(home, 'AppData', 'Roaming'))
|
|
25
|
+
: (typeof env?.XDG_CONFIG_HOME === 'string' && env.XDG_CONFIG_HOME.trim() ? env.XDG_CONFIG_HOME.trim() : join(home, '.config'));
|
|
26
|
+
return join(configRoot, 'devin', 'mcp_config.json');
|
|
27
|
+
}
|
|
20
28
|
default:
|
|
21
29
|
return null;
|
|
22
30
|
}
|
package/src/harnesses.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { homedir } from 'node:os';
|
|
2
|
-
import { join } from 'node:path';
|
|
2
|
+
import { join, resolve } from 'node:path';
|
|
3
3
|
|
|
4
4
|
export const HARNESS_ENV_VAR = 'SAP_AI_DEV_TOOLKIT_HARNESSES';
|
|
5
5
|
export const DEFAULT_HARNESS_IDS = ['github-copilot', 'claude-code'];
|
|
@@ -38,6 +38,19 @@ export function piCodingAgentRoot({ home = process.env.HOME || homedir(), env =
|
|
|
38
38
|
return override || join(home, '.pi', 'agent');
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
export function ompAgentRoot({ home = process.env.HOME || homedir(), env = process.env } = {}) {
|
|
42
|
+
const agentOverride = typeof env?.PI_CODING_AGENT_DIR === 'string' ? env.PI_CODING_AGENT_DIR.trim() : '';
|
|
43
|
+
if (agentOverride) return resolve(agentOverride);
|
|
44
|
+
const configDirectory = typeof env?.PI_CONFIG_DIR === 'string' ? env.PI_CONFIG_DIR.trim() : '';
|
|
45
|
+
return join(home, configDirectory || '.omp', 'agent');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function windsurfSkillsRoot({ home = process.env.HOME || homedir(), env = process.env, platform = process.platform } = {}) {
|
|
49
|
+
if (platform === 'win32') return join(home, '.codeium', 'windsurf');
|
|
50
|
+
const override = typeof env?.XDG_CONFIG_HOME === 'string' ? env.XDG_CONFIG_HOME.trim() : '';
|
|
51
|
+
return join(override || join(home, '.config'), 'devin');
|
|
52
|
+
}
|
|
53
|
+
|
|
41
54
|
export function claudeCodeAgentDocument(source) {
|
|
42
55
|
const frontmatter = splitFrontmatter(source);
|
|
43
56
|
if (!frontmatter) throw new Error('unsupported agent frontmatter');
|
|
@@ -86,7 +99,11 @@ export const HARNESSES = [
|
|
|
86
99
|
{ id: 'cursor', label: 'Cursor', directory: '.cursor', supportsAgents: false },
|
|
87
100
|
{ id: 'gemini-cli', label: 'Gemini CLI', directory: '.gemini', supportsAgents: false },
|
|
88
101
|
{ id: 'opencode', label: 'opencode', directory: 'opencode', configHome: true, supportsAgents: true, agentFileName: agentMarkdownName, agentDocument: opencodeAgentDocument },
|
|
89
|
-
{ id: 'pi-coding-agent', label: 'Pi Coding Agent', directory: '.pi/agent', supportsAgents: true, agentTargetPrefix: 'prompts', agentFileName: agentMarkdownName, agentDocument: piCodingAgentPromptDocument, root: piCodingAgentRoot }
|
|
102
|
+
{ id: 'pi-coding-agent', label: 'Pi Coding Agent', directory: '.pi/agent', supportsAgents: true, agentTargetPrefix: 'prompts', agentFileName: agentMarkdownName, agentDocument: piCodingAgentPromptDocument, root: piCodingAgentRoot },
|
|
103
|
+
{ id: 'omp', label: 'Oh My Pi (OMP)', directory: '.omp/agent', supportsAgents: false, root: ompAgentRoot },
|
|
104
|
+
{ id: 'cline', label: 'Cline', directory: '.cline', supportsAgents: false },
|
|
105
|
+
{ id: 'roo-code', label: 'Roo Code', directory: '.roo', supportsAgents: false },
|
|
106
|
+
{ id: 'windsurf', label: 'Windsurf / Devin Desktop', directory: 'devin', supportsAgents: false, root: windsurfSkillsRoot }
|
|
90
107
|
];
|
|
91
108
|
|
|
92
109
|
export function harnessById(id) {
|
|
@@ -97,8 +114,8 @@ export function harnessById(id) {
|
|
|
97
114
|
return harness;
|
|
98
115
|
}
|
|
99
116
|
|
|
100
|
-
export function harnessRoot(harness, { home = process.env.HOME || homedir(), env = process.env } = {}) {
|
|
101
|
-
if (harness.root) return harness.root({ home, env });
|
|
117
|
+
export function harnessRoot(harness, { home = process.env.HOME || homedir(), env = process.env, platform = process.platform } = {}) {
|
|
118
|
+
if (harness.root) return harness.root({ home, env, platform });
|
|
102
119
|
if (harness.configHome) {
|
|
103
120
|
const override = typeof env?.XDG_CONFIG_HOME === 'string' ? env.XDG_CONFIG_HOME.trim() : '';
|
|
104
121
|
return join(override || join(home, '.config'), harness.directory);
|
package/src/launcher.mjs
CHANGED
|
@@ -107,7 +107,7 @@ async function runDoctor(destinations) {
|
|
|
107
107
|
// unprefixed (get_source, run_query); match the slug-prefixed form too
|
|
108
108
|
// in case this ever runs against a multi-destination server.
|
|
109
109
|
const toolPrefix = `${slugifyDestination(destination.name)}_`;
|
|
110
|
-
const localSegments = new Set(['lint_abap', 'get_application_log', 'prepare_abap_change_set', 'apply_abap_change_set', 'check_transport_readiness', 'plan_abap_cloud_migration', 'generate_rap_regression_suite', 'run_rap_regression_suite']);
|
|
110
|
+
const localSegments = new Set(['lint_abap', 'lint_abap_project', 'get_application_log', 'prepare_abap_change_set', 'apply_abap_change_set', 'check_transport_readiness', 'plan_abap_cloud_migration', 'generate_rap_regression_suite', 'run_rap_regression_suite']);
|
|
111
111
|
const isUpstreamTool = tool => (tool.name.startsWith(toolPrefix) ? tool.name.slice(toolPrefix.length) : tool.name);
|
|
112
112
|
const upstreamCount = tools.filter(tool => !localSegments.has(isUpstreamTool(tool))).length;
|
|
113
113
|
checks.push(doctorRow(destination.name, 'MCP tools/list', upstreamCount ? 'passed' : 'failed', `${upstreamCount} upstream tools and ${tools.length} total MCP tools returned; chat-picker binding is host-managed`));
|
package/src/mcp-proxy.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { spawn as nodeSpawn } from 'node:child_process';
|
|
2
2
|
import { once } from 'node:events';
|
|
3
3
|
import { sanitizeChildEnv, slugifyDestination } from './bas-discovery.mjs';
|
|
4
|
-
import { ABAP_LINT_TOOL, runABAPLint } from './abaplint.mjs';
|
|
4
|
+
import { ABAP_LINT_PROJECT_TOOL, ABAP_LINT_TOOL, runABAPLint, runABAPLintProject } from './abaplint.mjs';
|
|
5
5
|
import { createEngineeringTools } from './engineering-tools.mjs';
|
|
6
6
|
import { brandedEnvValue } from './branding.mjs';
|
|
7
7
|
import { diagnosticText, redactText } from './redact.mjs';
|
|
@@ -698,6 +698,12 @@ export class MCPProxy {
|
|
|
698
698
|
});
|
|
699
699
|
}
|
|
700
700
|
}
|
|
701
|
+
const projectLintName = snakeCaseName(ABAP_LINT_PROJECT_TOOL.name);
|
|
702
|
+
publish(projectLintName, {
|
|
703
|
+
handler: arguments_ => runABAPLintProject(arguments_, {
|
|
704
|
+
workspaceRoot: brandedEnvValue(this.env, 'WORKSPACE_ROOT') || process.cwd()
|
|
705
|
+
})
|
|
706
|
+
}, { ...ABAP_LINT_PROJECT_TOOL, name: projectLintName });
|
|
701
707
|
if (!merged.length && this.children.length) throw new Error('No destination child provided tools');
|
|
702
708
|
// Swap atomically so a concurrent tools/call never sees a partial map.
|
|
703
709
|
this.namespace = namespace;
|
package/src/setup.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import { adtBaseUrl, adtPort, defaultAdtUrl, discoverSapGuiSystems, hasExplicitP
|
|
|
8
8
|
import { discoverCloudFoundryDestinations, getCloudFoundryTarget, deleteManagedCloudFoundryServiceKeys, findOrphanedCloudFoundryServiceKeys } from './cf-destination.mjs';
|
|
9
9
|
import { SAP_DEVELOPMENT_MCP_SERVERS, collectCloudFoundryKeyReferencesFromAllEntries, collectManagedCloudFoundryKeyReferences, installMcpConfig, readMcpConfig, resolveMcpConfigPath } from './mcp-config.mjs';
|
|
10
10
|
import { discoverCertificateDnsNames } from './tls-adt-proxy.mjs';
|
|
11
|
+
import { findRemoteControllableSsoBrowsers } from './sso-browsers.mjs';
|
|
11
12
|
|
|
12
13
|
const SETUP_COMMAND = 'sap-ai-dev --setup';
|
|
13
14
|
|
|
@@ -66,6 +67,45 @@ async function chooseLocalAuthMode(destination, { input = stdin, output = stdout
|
|
|
66
67
|
}, { input, output });
|
|
67
68
|
}
|
|
68
69
|
|
|
70
|
+
function createSsoBrowserSelector({ discoverSsoBrowsers, input, output, env }) {
|
|
71
|
+
let selectedPath;
|
|
72
|
+
let selectionComplete = false;
|
|
73
|
+
return async destination => {
|
|
74
|
+
if (selectionComplete) return selectedPath;
|
|
75
|
+
selectionComplete = true;
|
|
76
|
+
print(output, formatStatus('Checking installed browsers for remote DevTools access.', 'progress', output, 'SSO'));
|
|
77
|
+
let result;
|
|
78
|
+
try {
|
|
79
|
+
result = await discoverSsoBrowsers({ env });
|
|
80
|
+
} catch (error) {
|
|
81
|
+
const reason = String(error?.message || error).replace(/\s+/g, ' ').slice(0, 240);
|
|
82
|
+
print(output, formatStatus(`Browser control check failed (${reason}). Setup will keep automatic browser discovery.`, 'warning', output, 'SSO'));
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
const { browsers = [], unavailable = [] } = result || {};
|
|
86
|
+
if (!browsers.length) {
|
|
87
|
+
const details = unavailable.map(browser => `${browser.name}: ${browser.reason}`).join('; ').slice(0, 300);
|
|
88
|
+
print(output, formatStatus(`No remotely controllable SSO browser was found${details ? ` (${details})` : ''}. Setup will keep automatic browser discovery.`, 'warning', output, 'SSO'));
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
if (browsers.length === 1) {
|
|
92
|
+
selectedPath = browsers[0].path;
|
|
93
|
+
print(output, formatStatus(`Using ${browsers[0].name}; it responded to a remote DevTools command.`, 'success', output, 'SSO browser'));
|
|
94
|
+
return selectedPath;
|
|
95
|
+
}
|
|
96
|
+
selectedPath = await selectPrompt({
|
|
97
|
+
message: `Browser for SSO (${destination.name})`,
|
|
98
|
+
defaultValue: browsers[0].path,
|
|
99
|
+
choices: browsers.map((browser, index) => ({
|
|
100
|
+
name: `${browser.name} (${browser.path})`,
|
|
101
|
+
value: browser.path,
|
|
102
|
+
shortcut: String(index + 1)
|
|
103
|
+
}))
|
|
104
|
+
}, { input, output });
|
|
105
|
+
return selectedPath;
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
69
109
|
async function confirmCloudFoundryImport({ input = stdin, output = stdout } = {}) {
|
|
70
110
|
const readline = createInterface({ input, output });
|
|
71
111
|
try {
|
|
@@ -238,7 +278,7 @@ async function confirmPrompt(message, { input = stdin, output = stdout } = {}) {
|
|
|
238
278
|
return !/^n(?:o)?$/i.test(String(answer || '').trim());
|
|
239
279
|
}
|
|
240
280
|
|
|
241
|
-
function buildLocalClientDestination(destination, client, authMode, { multipleClients = false, duplicateSystemName = false } = {}) {
|
|
281
|
+
function buildLocalClientDestination(destination, client, authMode, { multipleClients = false, duplicateSystemName = false, browserPath } = {}) {
|
|
242
282
|
const name = localClientDestinationName(destination, client, { multipleClients, duplicateSystemName });
|
|
243
283
|
const localDestination = { ...destination, name, serverName: name, client };
|
|
244
284
|
const userInputId = `sap-ai-dev-${localDestination.serverName || localDestination.name}-user`.toLowerCase().replace(/[^a-z0-9_-]+/g, '-');
|
|
@@ -248,7 +288,7 @@ function buildLocalClientDestination(destination, client, authMode, { multipleCl
|
|
|
248
288
|
{ id: passwordInputId, type: 'promptString', description: `SAP password for ${localDestination.name}`, password: true }
|
|
249
289
|
];
|
|
250
290
|
if (authMode === 'sso') {
|
|
251
|
-
localDestination.childEnv = { SAP_AUTH_MODE: 'sso' };
|
|
291
|
+
localDestination.childEnv = { SAP_AUTH_MODE: 'sso', ...(browserPath ? { SAP_BROWSER_EXEC: browserPath } : {}) };
|
|
252
292
|
localDestination.inputs = [];
|
|
253
293
|
} else {
|
|
254
294
|
localDestination.childEnv = {
|
|
@@ -285,7 +325,7 @@ async function maybeConfigureTlsSelfHeal(destination, promptLabel, { input = std
|
|
|
285
325
|
// collects the minimal ADT configuration directly from the user and produces
|
|
286
326
|
// the same local destination shape as the SAP GUI flow, so installation,
|
|
287
327
|
// credential inputs, and TLS handling stay identical.
|
|
288
|
-
async function runManualEntryWizard({ env = process.env, input = stdin, output = stdout, detectAdtUrl } = {}) {
|
|
328
|
+
async function runManualEntryWizard({ env = process.env, input = stdin, output = stdout, detectAdtUrl, selectSsoBrowser } = {}) {
|
|
289
329
|
print(output, '');
|
|
290
330
|
print(output, colorText('✍️ Manual system entry — skip discovery, add your SAP system directly', 'magenta', output));
|
|
291
331
|
print(output, colorText(' The ADT URL is the base URL that Eclipse ADT or VS Code ADT tools use, for example https://myhost.example:44300.', 'cyan', output));
|
|
@@ -338,10 +378,11 @@ async function runManualEntryWizard({ env = process.env, input = stdin, output =
|
|
|
338
378
|
} else {
|
|
339
379
|
if (authMode === 'sso') warnSsoIpHost(destination, name, output);
|
|
340
380
|
else await maybeConfigureTlsSelfHeal(destination, name, { input, output });
|
|
381
|
+
const browserPath = authMode === 'sso' ? await selectSsoBrowser(destination) : undefined;
|
|
341
382
|
destination.authentication = localAuthenticationLabel(authMode);
|
|
342
383
|
destination.authMode = authMode;
|
|
343
384
|
for (const client of clients) {
|
|
344
|
-
expanded.push(buildLocalClientDestination(destination, client, authMode, { multipleClients: clients.length > 1 }));
|
|
385
|
+
expanded.push(buildLocalClientDestination(destination, client, authMode, { multipleClients: clients.length > 1, browserPath }));
|
|
345
386
|
}
|
|
346
387
|
print(output, formatStatus(`Added ${name} (client${clients.length === 1 ? '' : 's'} ${clients.join(', ')}).`, 'success', output, 'Manual entry'));
|
|
347
388
|
}
|
|
@@ -469,7 +510,7 @@ function printConnectionInstructions(output, result) {
|
|
|
469
510
|
|
|
470
511
|
// Checkbox selection over the discovered destinations plus, outside BAS, the
|
|
471
512
|
// per-system confirmation prompts (clients, ADT URL, TLS self-heal, login).
|
|
472
|
-
async function chooseDiscoveredDestinations(destinations, { isBas, input = stdin, output = stdout, env = process.env, detectAdtUrl, previousAdtUrls = new Map() } = {}) {
|
|
513
|
+
async function chooseDiscoveredDestinations(destinations, { isBas, input = stdin, output = stdout, env = process.env, detectAdtUrl, previousAdtUrls = new Map(), selectSsoBrowser } = {}) {
|
|
473
514
|
const orderedDestinations = [...destinations].sort((left, right) => {
|
|
474
515
|
const leftRank = left.disabledReason ? 2 : (left.probe?.available === false ? 1 : 0);
|
|
475
516
|
const rightRank = right.disabledReason ? 2 : (right.probe?.available === false ? 1 : 0);
|
|
@@ -495,7 +536,7 @@ async function chooseDiscoveredDestinations(destinations, { isBas, input = stdin
|
|
|
495
536
|
print(output, colorText(' Space = select/deselect · a = toggle all · m = add a system manually · Enter = confirm.', 'cyan', output));
|
|
496
537
|
print(output, colorText(' 💡 No system listed, or need an extra one? Press m to skip discovery and add any SAP system manually.', 'magenta', output));
|
|
497
538
|
print(output, '');
|
|
498
|
-
const addManually = async () => (await runManualEntryWizard({ env, input, output, detectAdtUrl })).map(destination => ({
|
|
539
|
+
const addManually = async () => (await runManualEntryWizard({ env, input, output, detectAdtUrl, selectSsoBrowser })).map(destination => ({
|
|
499
540
|
value: destination,
|
|
500
541
|
name: `${destination.name} (manual, client ${destination.client})`
|
|
501
542
|
}));
|
|
@@ -549,10 +590,11 @@ async function chooseDiscoveredDestinations(destinations, { isBas, input = stdin
|
|
|
549
590
|
}
|
|
550
591
|
if (authMode === 'sso') warnSsoIpHost(destination, promptLabel, output);
|
|
551
592
|
else await maybeConfigureTlsSelfHeal(destination, promptLabel, { input, output });
|
|
593
|
+
const browserPath = authMode === 'sso' ? await selectSsoBrowser(destination) : undefined;
|
|
552
594
|
destination.authentication = localAuthenticationLabel(authMode);
|
|
553
595
|
destination.authMode = authMode;
|
|
554
596
|
for (const client of clients) {
|
|
555
|
-
expanded.push(buildLocalClientDestination(destination, client, authMode, { multipleClients: clients.length > 1, duplicateSystemName }));
|
|
597
|
+
expanded.push(buildLocalClientDestination(destination, client, authMode, { multipleClients: clients.length > 1, duplicateSystemName, browserPath }));
|
|
556
598
|
}
|
|
557
599
|
}
|
|
558
600
|
selected = expanded;
|
|
@@ -570,7 +612,8 @@ export async function runSetup({
|
|
|
570
612
|
confirmCfImport = confirmCloudFoundryImport,
|
|
571
613
|
install = installMcpConfig,
|
|
572
614
|
includeSapDevelopmentToolsPrompt = false,
|
|
573
|
-
detectAdtUrl
|
|
615
|
+
detectAdtUrl,
|
|
616
|
+
discoverSsoBrowsers = findRemoteControllableSsoBrowsers
|
|
574
617
|
} = {}) {
|
|
575
618
|
const isBas = Boolean(env.H2O_URL);
|
|
576
619
|
if (!isBas && (!input.isTTY || !output.isTTY)) {
|
|
@@ -581,6 +624,7 @@ export async function runSetup({
|
|
|
581
624
|
print(output, formatStatus(`Interactive setup needs a terminal. Rerun ${SETUP_COMMAND} from a terminal.`, 'warning', output, 'SAP AI Dev Toolkit'));
|
|
582
625
|
return { skipped: true, reason: 'non-tty' };
|
|
583
626
|
}
|
|
627
|
+
const selectSsoBrowser = createSsoBrowserSelector({ discoverSsoBrowsers, input, output, env });
|
|
584
628
|
print(output, '');
|
|
585
629
|
print(output, colorText('💡 Tip: you can skip auto-discovery and add a system manually — press m in the destination picker, or take the wizard offer when nothing is found.', 'magenta', output));
|
|
586
630
|
if (!isBas) print(output, formatStatus('Looking for local SAP GUI system configuration.', 'progress', output, 'Setup'));
|
|
@@ -721,11 +765,11 @@ export async function runSetup({
|
|
|
721
765
|
]
|
|
722
766
|
}, { input, output });
|
|
723
767
|
if (choice !== 'manual') return await exitWithoutDestinations();
|
|
724
|
-
selected = await runManualEntryWizard({ env, input, output, detectAdtUrl });
|
|
768
|
+
selected = await runManualEntryWizard({ env, input, output, detectAdtUrl, selectSsoBrowser });
|
|
725
769
|
additiveSetup = true;
|
|
726
770
|
if (!selected.length) return await exitWithoutDestinations();
|
|
727
771
|
} else {
|
|
728
|
-
selected = await chooseDiscoveredDestinations(destinations, { isBas, input, output, env, detectAdtUrl, previousAdtUrls });
|
|
772
|
+
selected = await chooseDiscoveredDestinations(destinations, { isBas, input, output, env, detectAdtUrl, previousAdtUrls, selectSsoBrowser });
|
|
729
773
|
additiveSetup = selected.length > 0;
|
|
730
774
|
}
|
|
731
775
|
|
package/src/sso-browsers.mjs
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
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';
|
|
1
|
+
import { execFile, spawn } from 'node:child_process';
|
|
2
|
+
import { access, constants, mkdtemp, rm } from 'node:fs/promises';
|
|
3
|
+
import { homedir, tmpdir } from 'node:os';
|
|
4
|
+
import { join, posix, win32 } from 'node:path';
|
|
5
5
|
import { promisify } from 'node:util';
|
|
6
|
+
import { WebSocket } from 'undici';
|
|
6
7
|
|
|
7
8
|
const execFileAsync = promisify(execFile);
|
|
8
9
|
|
|
@@ -59,6 +60,155 @@ async function fileExists(path) {
|
|
|
59
60
|
}
|
|
60
61
|
}
|
|
61
62
|
|
|
63
|
+
function remoteDebuggingEndpoint(output) {
|
|
64
|
+
return /DevTools listening on (ws:\/\/[^\s]+)/.exec(output)?.[1] || '';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function waitForRemoteDebuggingEndpoint(child, timeoutMs) {
|
|
68
|
+
return new Promise((resolve, reject) => {
|
|
69
|
+
let output = '';
|
|
70
|
+
let settled = false;
|
|
71
|
+
const finish = (error, endpoint) => {
|
|
72
|
+
if (settled) return;
|
|
73
|
+
settled = true;
|
|
74
|
+
clearTimeout(timer);
|
|
75
|
+
child.stdout?.off('data', onData);
|
|
76
|
+
child.stderr?.off('data', onData);
|
|
77
|
+
child.off('error', onError);
|
|
78
|
+
child.off('close', onClose);
|
|
79
|
+
if (error) reject(error);
|
|
80
|
+
else resolve(endpoint);
|
|
81
|
+
};
|
|
82
|
+
const onData = chunk => {
|
|
83
|
+
output = `${output}${chunk}`.slice(-8192);
|
|
84
|
+
const endpoint = remoteDebuggingEndpoint(output);
|
|
85
|
+
if (endpoint) finish(null, endpoint);
|
|
86
|
+
};
|
|
87
|
+
const onError = error => finish(error);
|
|
88
|
+
const onClose = (code, signal) => finish(new Error(`browser exited before exposing DevTools (code ${code ?? 'unknown'}, signal ${signal || 'none'})`));
|
|
89
|
+
const timer = setTimeout(() => finish(new Error(`browser did not expose DevTools within ${timeoutMs}ms`)), timeoutMs);
|
|
90
|
+
child.stdout?.on('data', onData);
|
|
91
|
+
child.stderr?.on('data', onData);
|
|
92
|
+
child.once('error', onError);
|
|
93
|
+
child.once('close', onClose);
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function controlBrowser(endpoint, timeoutMs) {
|
|
98
|
+
const url = new URL(endpoint);
|
|
99
|
+
if (url.protocol !== 'ws:' || !['localhost', '127.0.0.1', '::1', '[::1]'].includes(url.hostname)) {
|
|
100
|
+
throw new Error('browser exposed a non-local DevTools endpoint');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return new Promise((resolve, reject) => {
|
|
104
|
+
let settled = false;
|
|
105
|
+
const socket = new WebSocket(url.href);
|
|
106
|
+
const finish = error => {
|
|
107
|
+
if (settled) return;
|
|
108
|
+
settled = true;
|
|
109
|
+
clearTimeout(timer);
|
|
110
|
+
try { socket.close(); } catch {}
|
|
111
|
+
if (error) reject(error);
|
|
112
|
+
else resolve();
|
|
113
|
+
};
|
|
114
|
+
const timer = setTimeout(() => finish(new Error(`DevTools command timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
115
|
+
socket.addEventListener('open', () => socket.send(JSON.stringify({ id: 1, method: 'Browser.getVersion' })));
|
|
116
|
+
socket.addEventListener('message', event => {
|
|
117
|
+
let response;
|
|
118
|
+
try { response = JSON.parse(event.data); }
|
|
119
|
+
catch { return finish(new Error('DevTools returned an invalid response')); }
|
|
120
|
+
if (response.id !== 1) return;
|
|
121
|
+
if (response.error) return finish(new Error(response.error.message || 'DevTools rejected Browser.getVersion'));
|
|
122
|
+
if (!response.result?.product) return finish(new Error('DevTools returned no browser version'));
|
|
123
|
+
finish();
|
|
124
|
+
});
|
|
125
|
+
socket.addEventListener('error', () => finish(new Error('could not connect to the browser DevTools endpoint')));
|
|
126
|
+
socket.addEventListener('close', () => {
|
|
127
|
+
if (!settled) finish(new Error('browser closed the DevTools connection'));
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function waitForClose(child, timeoutMs) {
|
|
133
|
+
return new Promise(resolve => {
|
|
134
|
+
if (child.exitCode !== null || child.signalCode !== null) return resolve(true);
|
|
135
|
+
const timer = setTimeout(() => {
|
|
136
|
+
child.off('close', onClose);
|
|
137
|
+
resolve(false);
|
|
138
|
+
}, timeoutMs);
|
|
139
|
+
const onClose = () => {
|
|
140
|
+
clearTimeout(timer);
|
|
141
|
+
resolve(true);
|
|
142
|
+
};
|
|
143
|
+
child.once('close', onClose);
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function signalBrowserTree(child, platform, signal) {
|
|
148
|
+
if (platform === 'win32' && child.pid) {
|
|
149
|
+
try {
|
|
150
|
+
await execFileAsync('taskkill', [
|
|
151
|
+
'/pid', String(child.pid), '/t',
|
|
152
|
+
...(signal === 'SIGKILL' ? ['/f'] : [])
|
|
153
|
+
], { windowsHide: true, timeout: 1000 });
|
|
154
|
+
return;
|
|
155
|
+
} catch {}
|
|
156
|
+
}
|
|
157
|
+
try {
|
|
158
|
+
if (platform !== 'win32' && child.pid) process.kill(-child.pid, signal);
|
|
159
|
+
else child.kill(signal);
|
|
160
|
+
} catch {
|
|
161
|
+
try { child.kill(signal); } catch {}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function stopBrowser(child, platform) {
|
|
166
|
+
if (!child || child.exitCode !== null || child.signalCode !== null) return;
|
|
167
|
+
await signalBrowserTree(child, platform, 'SIGTERM');
|
|
168
|
+
if (await waitForClose(child, 1000)) return;
|
|
169
|
+
await signalBrowserTree(child, platform, 'SIGKILL');
|
|
170
|
+
await waitForClose(child, 1000);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export async function probeSsoBrowser(browser, {
|
|
174
|
+
platform = process.platform,
|
|
175
|
+
timeoutMs = 5000,
|
|
176
|
+
spawnBrowser = spawn,
|
|
177
|
+
controlRemote = controlBrowser
|
|
178
|
+
} = {}) {
|
|
179
|
+
let profile;
|
|
180
|
+
let child;
|
|
181
|
+
try {
|
|
182
|
+
profile = await mkdtemp(join(tmpdir(), 'sap-ai-dev-sso-probe-'));
|
|
183
|
+
child = spawnBrowser(browser.path, [
|
|
184
|
+
`--user-data-dir=${profile}`,
|
|
185
|
+
'--remote-debugging-port=0',
|
|
186
|
+
'--headless=new',
|
|
187
|
+
'--no-first-run',
|
|
188
|
+
'--no-default-browser-check',
|
|
189
|
+
'about:blank'
|
|
190
|
+
], { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, detached: platform !== 'win32' });
|
|
191
|
+
const endpoint = await waitForRemoteDebuggingEndpoint(child, timeoutMs);
|
|
192
|
+
await controlRemote(endpoint, timeoutMs);
|
|
193
|
+
return { usable: true };
|
|
194
|
+
} catch (error) {
|
|
195
|
+
const reason = String(error?.message || error).replace(/\s+/g, ' ').slice(0, 240);
|
|
196
|
+
return { usable: false, reason };
|
|
197
|
+
} finally {
|
|
198
|
+
await stopBrowser(child, platform);
|
|
199
|
+
if (profile) await rm(profile, { recursive: true, force: true });
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export async function findRemoteControllableSsoBrowsers({ probe = probeSsoBrowser, ...options } = {}) {
|
|
204
|
+
const candidates = await findSsoBrowsers(options);
|
|
205
|
+
const results = await Promise.all(candidates.map(async browser => ({ browser, ...await probe(browser) })));
|
|
206
|
+
return {
|
|
207
|
+
browsers: results.filter(result => result.usable).map(result => result.browser),
|
|
208
|
+
unavailable: results.filter(result => !result.usable).map(({ browser, reason }) => ({ ...browser, reason }))
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
62
212
|
// Every Chromium-based browser the runtime can drive, best first; SAP_BROWSER_EXEC pins one ahead of the rest.
|
|
63
213
|
export async function findSsoBrowsers({ env = process.env, platform = process.platform, exists = fileExists, appPaths = registeredAppPaths } = {}) {
|
|
64
214
|
const candidates = [];
|
package/tools.md
CHANGED
|
@@ -72,11 +72,12 @@ Hidden upstream tools are not directly callable through the proxy. Local workflo
|
|
|
72
72
|
|
|
73
73
|
Broad/destructive surfaces such as object deletion, SQL trace, arbitrary SAP router calls, transport release/delete, RFC execution, debugger listen/delete-breakpoint helpers, and one-off generator/update helpers are intentionally not advertised by the BAS proxy. UI5 repository writes are hidden too: the ADT filestore only reads UI5 apps, so deploy them with SAP Fiori tools instead.
|
|
74
74
|
|
|
75
|
-
##
|
|
75
|
+
## Local and destination-scoped workflow tools
|
|
76
76
|
|
|
77
|
-
The proxy adds local
|
|
77
|
+
The proxy adds local tools to the MCP surface. Some are workspace-wide; others are added to each destination when their required upstream capabilities are available. Like the upstream tools above, they are exposed publicly in lowercase snake_case (`lint_abap`, `lint_abap_project`, `get_application_log`, and so on):
|
|
78
78
|
|
|
79
79
|
- `LintABAP` — local in-memory ABAP linting.
|
|
80
|
+
- `LintABAPProject` — scan serialized ABAP objects in the configured workspace root (SAP_AI_DEV_TOOLKIT_WORKSPACE_ROOT, defaulting to the MCP server working directory) using the project abaplint config and available local dependency folders. This is static analysis only; it does not contact SAP or activate objects.
|
|
80
81
|
- `GetApplicationLog` — bounded convenience mapping to the upstream SAP application-log route when available.
|
|
81
82
|
- `PrepareABAPChangeSet` and `ApplyABAPChangeSet` — review full-source diffs and recheck source before explicit writes.
|
|
82
83
|
- `CheckTransportReadiness` — collect a whitelisted bundle of transport, dependency, inactive-object, ABAP Unit, and ATC results.
|