sap-ai-dev-toolkit 0.5.7 → 0.6.1
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 +11 -9
- package/inventory.md +1 -1
- package/package.json +1 -1
- package/scripts/install-local.mjs +1 -1
- package/scripts/postinstall.mjs +19 -17
- package/src/bas-destination-relay.mjs +13 -41
- package/src/launcher.mjs +5 -15
- package/src/mcp-proxy.mjs +9 -35
- package/src/setup.mjs +2 -110
- package/src/terminal-ui.mjs +3 -1
- package/tools.md +0 -3
- package/src/credential-overrides.mjs +0 -75
- package/src/credentials-store.mjs +0 -113
package/README.md
CHANGED
|
@@ -84,7 +84,7 @@ Use the attached destination-prefixed tools directly in chat. Do not launch `sap
|
|
|
84
84
|
|
|
85
85
|
**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`
|
|
86
86
|
|
|
87
|
-
|
|
87
|
+
Agents and skills install into `$HOME/.copilot` by default during global installation; press Enter to accept or type `n` to skip. In non-interactive installs they are installed automatically. Repository-scoped installation instructions appear below.
|
|
88
88
|
|
|
89
89
|
## ✨ Your SAP development cockpit, inside Copilot Chat
|
|
90
90
|
|
|
@@ -237,10 +237,9 @@ Once the destination server is enabled in Copilot Chat, ask for outcomes instead
|
|
|
237
237
|
| --- | --- |
|
|
238
238
|
| 🎯 **Explicit target** | SAP changes only proceed when the destination and required target details are known. |
|
|
239
239
|
| 🚦 **Explicit state change** | Activation, service publication, and transport creation happen only when requested and authorized. |
|
|
240
|
-
| 👁️ **Optional read-only mode** | `SAP_AI_DEV_TOOLKIT_READ_ONLY=true` removes every write/activate/transport-create tool from the surface and starts VSP with `--transport-read-only`. Recommended for exploration destinations. |
|
|
241
240
|
| 🧪 **Verification first** | The agent uses available lint, syntax, unit-test, ATC, and diagnostics workflows and reports what actually ran. |
|
|
242
241
|
| 🔒 **SAP authorization remains authoritative** | The add-on does not bypass backend SAP permissions. |
|
|
243
|
-
| 🚚 **Curated VSP tool surface** | The proxy exposes
|
|
242
|
+
| 🚚 **Curated VSP tool surface** | The proxy exposes the same curated developer-lifecycle set for every destination, plus local workflow tools; SAP authorizations and VSP safety checks still apply. |
|
|
244
243
|
| 🧱 **Per-destination isolation** | Generated MCP entries are scoped to a single `SAP_AI_DEV_TOOLKIT_DESTINATION`. |
|
|
245
244
|
| 🔑 **No credentials in `mcp.json`** | Authentication material stays in BAS destination configuration rather than MCP config. |
|
|
246
245
|
|
|
@@ -278,6 +277,9 @@ The installer handles VSP provisioning automatically:
|
|
|
278
277
|
|
|
279
278
|
With `H2O_URL` set, an interactive install opens a checkbox picker with no destinations selected by default. Use **Space** to choose destinations and **Enter** to confirm. Press **a** to toggle all destinations (select all if any are unchecked; otherwise clear the selection). Confirming with none selected removes this add-on's managed MCP entries. When the `cf` CLI 8.18 or newer is authenticated to a targeted space, setup first offers an optional import from that space's Destination service; type **y** then **Enter** to include it, or press **Enter** to skip. Accepted CF and BAS destinations appear together in the picker. npm may run its install hook without an interactive terminal, even when the shell is interactive; in that case, selection is skipped without changing MCP config.
|
|
280
279
|
|
|
280
|
+
|
|
281
|
+
The same postinstall installs bundled Copilot agents and skills under `$HOME/.copilot` by default. Press Enter at `[Y/n]` to install or update them; type `n` to leave them unchanged. With no interactive terminal, installation proceeds automatically.
|
|
282
|
+
|
|
281
283
|
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 BAS terminal after installation.
|
|
282
284
|
|
|
283
285
|
The setup report uses icons and terminal colors; set `NO_COLOR=1` to disable ANSI colors. The table is a weather report, not a bouncer: green **PASS** means the ADT probe responded, red **FAIL** means it failed, and yellow **SKIPPED** means it was skipped. Probe failures do not block MCP registration or startup for destinations you select.
|
|
@@ -400,7 +402,7 @@ The sample values sketch an on-premise ABAP backend routed through SAP Cloud Con
|
|
|
400
402
|
| `WebIDEUsage` | `dev_abap,odata_abap` | Include `dev_abap` for ABAP development; add other usages required by your BAS scenario. |
|
|
401
403
|
| `CloudConnectorLocationId` | `DEMO-LOCATION` | Optional; set only when the Cloud Connector uses a location ID. |
|
|
402
404
|
|
|
403
|
-
For on-premise systems, configure Cloud Connector access to the backend host and port first.
|
|
405
|
+
For on-premise systems, configure Cloud Connector access to the backend host and port first. The add-on uses the authentication already configured in BAS or the Cloud Foundry Destination service; manage SAP credentials there.
|
|
404
406
|
|
|
405
407
|
Think of `H2O_URL` as BAS's front door: it must point to the endpoint serving `/api/listDestinations`, not to the SAP backend.
|
|
406
408
|
|
|
@@ -617,8 +619,11 @@ The write and activation tools change SAP state. Confirm the target, package, an
|
|
|
617
619
|
| `GetUserTransports` | Read and group a user's transport requests and tasks; supports organizer filters and source selection. |
|
|
618
620
|
| `GetTransport` | Read a transport request's details, objects, and tasks. |
|
|
619
621
|
| `GetTransportInfo` | Find eligible transports and lock status for an ABAP object or package. |
|
|
622
|
+
| `LockObject` | Lock an ABAP repository object in SAP. |
|
|
623
|
+
| `UnlockObject` | Unlock an ABAP repository object in SAP. |
|
|
620
624
|
| `CreateTransport` | Create a transport request. |
|
|
621
625
|
| `CheckTransportReadiness` | Collect a whitelisted bundle of transport, dependency, inactive-object, ABAP Unit, and ATC evidence. |
|
|
626
|
+
`LockObject` and `UnlockObject` are state-changing VSP tools; the proxy does not retry them after a child crash to avoid duplicate side effects.
|
|
622
627
|
|
|
623
628
|
Transport release and deletion are not exposed by the curated proxy surface; release or delete transports outside this add-on after review.
|
|
624
629
|
|
|
@@ -734,14 +739,12 @@ The response contains a `tools` array. A `RunQuery` entry resembles this excerpt
|
|
|
734
739
|
| `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`. |
|
|
735
740
|
| `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. |
|
|
736
741
|
| `SAP_AI_DEV_TOOLKIT_DISABLE_BAS_RELAY=true` | Disable the built-in BAS destination relay. By default the add-on self-heals `.dest` destinations through a local relay that keeps all access destination-based while handling ADT CSRF fetch/retry behavior before VSP calls SAP. |
|
|
737
|
-
| `SAP_AI_DEV_TOOLKIT_HTTP_PROXY` | Egress proxy for relay and discovery traffic. Takes precedence over `HTTP_PROXY`/`http_proxy`; unset means the default BAS proxy for `.dest` hosts, empty means direct
|
|
742
|
+
| `SAP_AI_DEV_TOOLKIT_HTTP_PROXY` | Egress proxy for relay and discovery traffic. Takes precedence over `HTTP_PROXY`/`http_proxy`; unset means the default BAS proxy for `.dest` hosts, empty means direct. |
|
|
738
743
|
| `SAP_AI_DEV_TOOLKIT_MAX_CSRF_RETRIES` | Bounded retries after explicit CSRF-session rejection per unsafe request (default 3, maximum 10). Invalid values use the default. |
|
|
739
|
-
| `SAP_AI_DEV_TOOLKIT_READ_ONLY=true` | Read-only mode: hides `WriteSource`, `EditSource`, `Activate`, `ActivateMultiple`, `CreateTransport`, `SetBreakpoint`, and the change-set workflow tools, and starts VSP with `--transport-read-only`. |
|
|
740
744
|
| `SAP_AI_DEV_TOOLKIT_REQUEST_TIMEOUT_MS` | Per-request timeout for calls forwarded to a VSP child (default 600000 = 10 minutes; `0` disables). A stalled request fails with a timeout error; the child is left running. |
|
|
741
745
|
| `SAP_AI_DEV_MCP_CONFIG` | Explicit MCP configuration path; highest precedence. |
|
|
742
746
|
| `SAP_AI_DEV_TOOLKIT_MCP_CONFIG` | Branded compatibility alias for the MCP configuration path. |
|
|
743
747
|
| `BAS_VSP_MCP_CONFIG` | Backward-compatible alias for the MCP configuration path. |
|
|
744
|
-
| `SAP_AI_DEV_TOOLKIT_CREDENTIALS_FILE` | Explicit path for the optional SAP backend credential-override file (default: beside `mcp.json`). |
|
|
745
748
|
| `SAP_AI_DEV_TOOLKIT_BINARY` | Trusted prebuilt VSP executable; skips binary provisioning. Legacy `BAS_VSP_BINARY` still works. |
|
|
746
749
|
| `SAP_AI_DEV_TOOLKIT_BINARY_URL` | Alternate VSP binary download URL (checksum-verified). Legacy `BAS_VSP_BINARY_URL` still works. |
|
|
747
750
|
| `SAP_AI_DEV_TOOLKIT_RELEASE_BASE_URL` | Override the base URL that VSP release binaries are downloaded from. |
|
|
@@ -760,8 +763,7 @@ The relay between the VSP child and each BAS destination recovers from the failu
|
|
|
760
763
|
2. **Session refresh** — when SAP explicitly rejects a CSRF token, the cached session is dropped, a fresh token+cookie pair is fetched, and the request is retried within the configured bound. Generic authorization 401/403 responses are returned without being mislabeled or retried as CSRF failures.
|
|
761
764
|
3. **Proxy tunnel fallback** — when the BAS proxy refuses absolute-form requests (502/504 or transport errors), the relay switches to a CONNECT tunnel through the same proxy and keeps going.
|
|
762
765
|
4. **Child crash recovery** — a crashed VSP child is restarted transparently, re-initialized, tools re-registered, and the interrupted `tools/call` retried once before any error reaches the client.
|
|
763
|
-
5. **
|
|
764
|
-
6. **Cloud Connector credential overrides** — setup also offers per-destination overrides for BasicAuthentication OnPremise destinations. BAS OnPremise credentials travel through an HTTP CONNECT tunnel to the BAS `.dest` endpoint, retaining its Cloud Connector mapping; setup saves the override only after a read-only probe returns both a CSRF token and a session cookie. If that route check fails, the override is not saved. Cloud Foundry OnPremise overrides replace only the SAP backend user/password; Connectivity service-key authentication and its proxy route remain unchanged. PrincipalPropagation is never overridden. Rerun `sap-ai-dev --setup` to change credentials or answer No to disable a stored override. Deselecting a destination (or selecting none) removes stale overrides.
|
|
766
|
+
5. **Configured destination authentication** — the add-on always uses the URL, authentication mode, and credentials already configured for the selected destination. Setup does not prompt for or store SAP usernames/passwords, and the relay does not connect directly to a backend host as a credential fallback. Correct the credentials in BAS or the Cloud Foundry Destination service if SAP rejects them.
|
|
765
767
|
Existing installs that still set the previous `BAS_VSP_*` environment variables remain supported. The setup wizard writes new MCP entries with the `SAP_AI_DEV_TOOLKIT_*` names.
|
|
766
768
|
|
|
767
769
|
#### Server logging
|
package/inventory.md
CHANGED
|
@@ -163,7 +163,7 @@ These logical names are exposed through MCP in lowercase snake_case (`lint_abap`
|
|
|
163
163
|
|
|
164
164
|
- Runtime tool names are unprefixed lowercase snake_case (`run_query`) on the normal single-destination server; a `<destination-slug>_` prefix appears only on multi-destination servers. `tools/call` accepts the exact name returned by `tools/list`.
|
|
165
165
|
- The proxy starts VSP with `--enable-transports`; generated MCP entries set `SAP_ALLOW_TRANSPORTABLE_EDITS=true`.
|
|
166
|
-
-
|
|
166
|
+
- The curated VSP tool surface and enabled transport mode are the same for every destination; SAP authorization remains authoritative.
|
|
167
167
|
- `SAP_AI_DEV_TOOLKIT_REQUEST_TIMEOUT_MS` (default 600000) bounds each forwarded call; stalled requests fail without killing the child.
|
|
168
168
|
- Transport release/deletion and the general-purpose `SAP` router are intentionally hidden from direct proxy calls. `GetApplicationLog` is the bounded convenience mapping for the SAP application-log route.
|
|
169
169
|
|
package/package.json
CHANGED
|
@@ -14,7 +14,7 @@ function usage() {
|
|
|
14
14
|
'',
|
|
15
15
|
' (default) Packs the workspace and installs the tarball globally, then',
|
|
16
16
|
' runs the normal postinstall wizard (binary provisioning,',
|
|
17
|
-
' BAS destination setup,
|
|
17
|
+
' BAS destination setup, and default Copilot asset installation).',
|
|
18
18
|
' --link Installs with npm link instead of a tarball; edits to src/',
|
|
19
19
|
' and scripts/ take effect immediately without reinstalling.',
|
|
20
20
|
' --verify Verifies the global sap-ai-dev command resolves to this',
|
package/scripts/postinstall.mjs
CHANGED
|
@@ -68,19 +68,29 @@ async function runInstallSetup() {
|
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
async function runCopilotAssetInstall() {
|
|
71
|
+
const copilotRoot = join(process.env.HOME || homedir(), '.copilot');
|
|
72
|
+
const installAssets = async () => {
|
|
73
|
+
const assets = await installUserCopilotAssets({ root });
|
|
74
|
+
const installed = assets.installed + assets.updated;
|
|
75
|
+
await announce(`Installed ${installed} Copilot files in ${assets.root}; ${assets.unchanged} already current.`, 'success');
|
|
76
|
+
if (assets.conflicts.length > 0) {
|
|
77
|
+
const paths = assets.conflicts.map(path => join(assets.root, path)).join(', ');
|
|
78
|
+
await announce(`Existing Copilot customizations were preserved; review these paths: ${paths}`, 'warning');
|
|
79
|
+
}
|
|
80
|
+
};
|
|
71
81
|
await withInstallTerminal(async terminal => {
|
|
72
82
|
if (!terminal) {
|
|
73
|
-
await announce('
|
|
83
|
+
await announce('Installing bundled agents and skills by default because no interactive terminal is available.', 'progress');
|
|
84
|
+
await installAssets();
|
|
74
85
|
return;
|
|
75
86
|
}
|
|
76
|
-
const copilotRoot = join(process.env.HOME || homedir(), '.copilot');
|
|
77
87
|
await announce([
|
|
78
|
-
'
|
|
88
|
+
'Bundled Copilot agents and skills are installed by default.',
|
|
79
89
|
'',
|
|
80
|
-
'Type
|
|
90
|
+
'Type n then press Enter to skip installing or updating the bundled files.',
|
|
81
91
|
'',
|
|
82
|
-
`${colorText('✅
|
|
83
|
-
`${colorText('⏭️
|
|
92
|
+
`${colorText('✅ Press Enter', 'green', true)} to install or update the bundled agents and all skills.`,
|
|
93
|
+
`${colorText('⏭️ Type n then Enter', 'yellow', true)} to skip this step.`,
|
|
84
94
|
'',
|
|
85
95
|
`${colorText('📁 Target folder:', 'cyan', true)}`,
|
|
86
96
|
` ${copilotRoot}`,
|
|
@@ -89,23 +99,15 @@ async function runCopilotAssetInstall() {
|
|
|
89
99
|
const prompt = createInterface({ input: terminal.input, output: terminal.output });
|
|
90
100
|
let answer;
|
|
91
101
|
try {
|
|
92
|
-
answer = await prompt.question(`${colorText('🤖 Install the bundled agents and all skills?', 'magenta', terminal.output)} ${colorText('[
|
|
102
|
+
answer = await prompt.question(`${colorText('🤖 Install the bundled agents and all skills?', 'magenta', terminal.output)} ${colorText('[Y/n]', 'yellow', terminal.output)} `);
|
|
93
103
|
} finally {
|
|
94
104
|
prompt.close();
|
|
95
105
|
}
|
|
96
|
-
|
|
97
|
-
// every Copilot session on the machine, so it needs an explicit yes.
|
|
98
|
-
if (!/^y(?:es)?$/i.test(answer.trim())) {
|
|
106
|
+
if (/^n(?:o)?$/i.test(answer.trim())) {
|
|
99
107
|
await announce('Copilot agent and skills were skipped. Your files were not changed.', 'info');
|
|
100
108
|
return;
|
|
101
109
|
}
|
|
102
|
-
|
|
103
|
-
const installed = assets.installed + assets.updated;
|
|
104
|
-
await announce(`Installed ${installed} Copilot files in ${assets.root}; ${assets.unchanged} already current.`, 'success');
|
|
105
|
-
if (assets.conflicts.length > 0) {
|
|
106
|
-
const paths = assets.conflicts.map(path => join(assets.root, path)).join(', ');
|
|
107
|
-
await announce(`Existing Copilot customizations were preserved; review these paths: ${paths}`, 'warning');
|
|
108
|
-
}
|
|
110
|
+
await installAssets();
|
|
109
111
|
});
|
|
110
112
|
}
|
|
111
113
|
|
|
@@ -133,14 +133,7 @@ async function readRequestBody(req) {
|
|
|
133
133
|
export function createBasDestinationRelay(destination, { env = process.env, fetchImpl = undiciFetch, log = () => {} } = {}) {
|
|
134
134
|
if (!destination?.url) throw new Error('Destination relay requires a BAS destination URL');
|
|
135
135
|
const base = new URL(destination.url);
|
|
136
|
-
const
|
|
137
|
-
const tunnelMode = credentials?.mode === 'bas-tunnel';
|
|
138
|
-
const directHost = tunnelMode ? '' : credentials?.host;
|
|
139
|
-
// Internet overrides retain the direct-backend path. OnPremise overrides
|
|
140
|
-
// must keep the BAS .dest route (and its Cloud Connector mapping), so they
|
|
141
|
-
// use HTTP CONNECT through the BAS proxy instead of credentials.host.
|
|
142
|
-
const directBase = directHost ? new URL(String(directHost)) : null;
|
|
143
|
-
const cookieBase = directBase || (destination.backendUrl
|
|
136
|
+
const cookieBase = destination.backendUrl
|
|
144
137
|
? new URL(String(destination.backendUrl))
|
|
145
138
|
: (() => {
|
|
146
139
|
const target = new URL(base);
|
|
@@ -149,9 +142,8 @@ export function createBasDestinationRelay(destination, { env = process.env, fetc
|
|
|
149
142
|
// omits the backend URL so Secure session cookies are not discarded.
|
|
150
143
|
if (target.protocol === 'http:' && /\.dest$/i.test(target.hostname)) target.protocol = 'https:';
|
|
151
144
|
return target;
|
|
152
|
-
})()
|
|
153
|
-
|
|
154
|
-
if (tunnelMode) log(`[${destination.name}] BAS relay using a BAS proxy tunnel for the OnPremise destination`);
|
|
145
|
+
})();
|
|
146
|
+
// BAS destinations must keep their configured .dest route and authentication.
|
|
155
147
|
// Proxy resolution: an explicitly configured proxy wins (an explicit empty
|
|
156
148
|
// value means "go direct"). Without configuration, BAS virtual .dest hosts
|
|
157
149
|
// imply the default BAS proxy; any other host (tests, direct URLs) is
|
|
@@ -159,16 +151,15 @@ export function createBasDestinationRelay(destination, { env = process.env, fetc
|
|
|
159
151
|
// foreign proxy.
|
|
160
152
|
const brandedProxy = brandedEnvValue(env, 'HTTP_PROXY');
|
|
161
153
|
const explicitProxy = brandedProxy !== undefined ? brandedProxy : (env.HTTP_PROXY ?? env.http_proxy);
|
|
162
|
-
const proxyUrl =
|
|
163
|
-
if (tunnelMode && !proxyUrl) throw new Error('OnPremise credential overrides require the BAS destination proxy to preserve the Cloud Connector route');
|
|
154
|
+
const proxyUrl = explicitProxy !== undefined ? String(explicitProxy) : (/\.dest$/i.test(base.hostname) ? DEFAULT_PROXY : '');
|
|
164
155
|
const dispatcher = proxyUrl ? new ProxyAgent({ uri: proxyUrl, proxyTunnel: false }) : undefined;
|
|
165
156
|
// Self-healing mode 2: if the BAS proxy refuses absolute-form proxied
|
|
166
157
|
// requests (its own 502/504), retry through a CONNECT tunnel so the relay
|
|
167
158
|
// keeps working through the same egress.
|
|
168
159
|
const tunnelDispatcher = proxyUrl ? new ProxyAgent({ uri: proxyUrl, proxyTunnel: true }) : undefined;
|
|
169
|
-
let tunneled =
|
|
160
|
+
let tunneled = false;
|
|
170
161
|
// Observable self-healing counters, surfaced by --doctor and tests.
|
|
171
|
-
const stats = { requests: 0, csrfFetches: 0, csrfRetries: 0, csrfSessionFailures: 0, tunnelFallbacks: 0, direct:
|
|
162
|
+
const stats = { requests: 0, csrfFetches: 0, csrfRetries: 0, csrfSessionFailures: 0, tunnelFallbacks: 0, direct: false, proxyTunnel: false };
|
|
172
163
|
const maxFailures = maxTokenFailures(env, log, destination.name);
|
|
173
164
|
// The shared jar captures SAP cookies from safe reads as well as CSRF fetches.
|
|
174
165
|
// Each cached token gets its own snapshot so later responses cannot silently
|
|
@@ -210,16 +201,16 @@ export function createBasDestinationRelay(destination, { env = process.env, fetc
|
|
|
210
201
|
async function fetchTokenFresh(requestHeaders) {
|
|
211
202
|
const key = CSRF_FETCH_PATH;
|
|
212
203
|
stats.csrfFetches += 1;
|
|
213
|
-
const target =
|
|
204
|
+
const target = new URL(CSRF_FETCH_PATH, base);
|
|
214
205
|
const cookieTarget = resolveCookieTarget(target);
|
|
215
206
|
const jar = copyCookieJar(sharedCookieJar);
|
|
216
207
|
await seedRequestCookies(jar, requestHeaders.cookie, cookieTarget);
|
|
217
208
|
const outgoingCookie = await cookieHeader(jar, cookieTarget);
|
|
218
|
-
const response = await sendWithFallback(target, 'GET',
|
|
209
|
+
const response = await sendWithFallback(target, 'GET', fetchHeaders(requestHeaders, {
|
|
219
210
|
'x-csrf-token': 'Fetch',
|
|
220
211
|
accept: requestHeaders.accept || 'application/xml,*/*',
|
|
221
212
|
...(outgoingCookie ? { cookie: outgoingCookie } : {})
|
|
222
|
-
}))
|
|
213
|
+
}));
|
|
223
214
|
await response.arrayBuffer().catch(() => undefined);
|
|
224
215
|
const token = response.headers.get('x-csrf-token');
|
|
225
216
|
await recordCookies(jar, response.headers, cookieTarget, log, destination.name);
|
|
@@ -265,36 +256,17 @@ export function createBasDestinationRelay(destination, { env = process.env, fetc
|
|
|
265
256
|
};
|
|
266
257
|
}
|
|
267
258
|
|
|
268
|
-
// Rewrite an incoming .dest target onto the direct backend host when
|
|
269
|
-
// credentials are configured, keeping path and query intact.
|
|
270
|
-
function resolveTarget(target) {
|
|
271
|
-
if (!directBase) return target;
|
|
272
|
-
return new URL(`${target.pathname}${target.search}`, directBase);
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
function authHeaders(extra = {}) {
|
|
276
|
-
if (!credentials?.user || !credentials?.password) return extra;
|
|
277
|
-
const basic = Buffer.from(`${credentials.user}:${credentials.password}`).toString('base64');
|
|
278
|
-
// The stored credential override wins over any inbound authorization
|
|
279
|
-
// header: the override exists precisely to pair Basic auth with the
|
|
280
|
-
// relay's CSRF session, and silently forwarding a caller-supplied header
|
|
281
|
-
// would disable that pairing without any diagnostic.
|
|
282
|
-
const headers = { ...extra };
|
|
283
|
-
delete headers.authorization;
|
|
284
|
-
delete headers.Authorization;
|
|
285
|
-
return { ...headers, authorization: `Basic ${basic}` };
|
|
286
|
-
}
|
|
287
259
|
|
|
288
260
|
async function forward(method, requestUrl, requestHeaders, body, session) {
|
|
289
|
-
const target =
|
|
261
|
+
const target = requestUrl;
|
|
290
262
|
const cookieTarget = resolveCookieTarget(target);
|
|
291
263
|
const cookieJar = session?.jar || sharedCookieJar;
|
|
292
264
|
if (!session) await seedRequestCookies(cookieJar, requestHeaders.cookie, cookieTarget);
|
|
293
265
|
const sessionCookie = await cookieHeader(cookieJar, cookieTarget);
|
|
294
|
-
const headers =
|
|
266
|
+
const headers = headersFrom(requestHeaders, {
|
|
295
267
|
...(session?.token ? { 'x-csrf-token': session.token } : {}),
|
|
296
268
|
...(sessionCookie ? { cookie: sessionCookie } : {})
|
|
297
|
-
})
|
|
269
|
+
});
|
|
298
270
|
const response = await sendWithFallback(target, method, headers, body);
|
|
299
271
|
const responseBody = Buffer.from(await response.arrayBuffer());
|
|
300
272
|
if (session) await recordCookies(session.jar, response.headers, cookieTarget, log, destination.name);
|
|
@@ -332,7 +304,7 @@ export function createBasDestinationRelay(destination, { env = process.env, fetc
|
|
|
332
304
|
return forwardWithRetry(method, requestUrl, requestHeaders, body, failures + 1);
|
|
333
305
|
}
|
|
334
306
|
if (unsafe && rejected && tokenOnly) {
|
|
335
|
-
log(`[${destination.name}] BAS relay CSRF token rejected without a session cookie for ${requestUrl.pathname}; not retried (route
|
|
307
|
+
log(`[${destination.name}] BAS relay CSRF token rejected without a session cookie for ${requestUrl.pathname}; not retried (the configured destination route delivered no cookies)`);
|
|
336
308
|
} else if (unsafe && rejected && failures >= maxFailures) {
|
|
337
309
|
log(`[${destination.name}] BAS relay stopped after ${maxFailures} CSRF session retries for ${requestUrl.pathname}`);
|
|
338
310
|
}
|
package/src/launcher.mjs
CHANGED
|
@@ -10,7 +10,6 @@ import { installMcpConfig, repairManagedMcpConfig } from './mcp-config.mjs';
|
|
|
10
10
|
import { runSetup } from './setup.mjs';
|
|
11
11
|
import { resolveConfiguredCloudFoundryDestination } from './cf-destination.mjs';
|
|
12
12
|
import { withBrandedEnvironment } from './branding.mjs';
|
|
13
|
-
import { enrichWithStoredCredentials } from './credential-overrides.mjs';
|
|
14
13
|
import { redactText } from './redact.mjs';
|
|
15
14
|
const root = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
16
15
|
const pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'));
|
|
@@ -100,11 +99,6 @@ async function runDoctor(destinations) {
|
|
|
100
99
|
} else {
|
|
101
100
|
checks.push(doctorRow(destination.name, 'BAS destination relay', 'skipped', 'Not applicable for this destination.'));
|
|
102
101
|
}
|
|
103
|
-
if (relayedDestination?.credentials?.mode === 'bas-tunnel') {
|
|
104
|
-
checks.push(doctorRow(destination.name, 'OnPremise credential override', 'passed', 'Basic auth is routed through a BAS proxy tunnel; Cloud Connector routing is retained'));
|
|
105
|
-
} else if (relayedDestination?.credentials?.host) {
|
|
106
|
-
checks.push(doctorRow(destination.name, 'Direct connect', 'passed', `active (${new URL(relayedDestination.credentials.host).origin}); ADT writes use stored credentials`));
|
|
107
|
-
}
|
|
108
102
|
const listed = await proxy.handle({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} });
|
|
109
103
|
if (listed?.error) throw new Error(listed.error.message || 'MCP tools/list failed');
|
|
110
104
|
const tools = listed.result?.tools || [];
|
|
@@ -137,7 +131,7 @@ async function runDoctor(destinations) {
|
|
|
137
131
|
: `HTTP ${session?.httpStatus || 0}; token=${session?.tokenReceived ? 'received' : 'missing'}; cookies=${session?.cookieCount || 0}; usableSession=${sessionReady}; csrfFetches=${stats.csrfFetches || 0}; csrfRetries=${stats.csrfRetries || 0}; sessionFailures=${stats.csrfSessionFailures || 0}; tunnelFallbacks=${stats.tunnelFallbacks || 0}`;
|
|
138
132
|
checks.push(doctorRow(destination.name, 'BAS relay CSRF preflight', status, detail));
|
|
139
133
|
if (!sessionReady) {
|
|
140
|
-
const remediation = 'GetSystemInfo was not called because the relay could not establish a paired CSRF token/session.
|
|
134
|
+
const remediation = 'GetSystemInfo was not called because the relay could not establish a paired CSRF token/session. Confirm the configured destination authentication and BAS proxy route.';
|
|
141
135
|
checks.push(doctorRow(destination.name, 'SAP system check', 'skipped', preflightError ? `${remediation} Relay error: ${preflightError}` : remediation));
|
|
142
136
|
}
|
|
143
137
|
}
|
|
@@ -222,8 +216,8 @@ async function main() {
|
|
|
222
216
|
let report;
|
|
223
217
|
try {
|
|
224
218
|
const destinations = runtimeEnv.SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE === 'cloud-foundry'
|
|
225
|
-
?
|
|
226
|
-
: await
|
|
219
|
+
? [await resolveConfiguredCloudFoundryDestination({ env: runtimeEnv })]
|
|
220
|
+
: await discoverForCommand();
|
|
227
221
|
report = await runDoctor(destinations);
|
|
228
222
|
} catch (error) {
|
|
229
223
|
report = { ok: false, destinations: 0, checks: [doctorRow('-', 'destination discovery', 'failed', redactText(error.message || error).slice(0, 300))] };
|
|
@@ -259,7 +253,7 @@ async function main() {
|
|
|
259
253
|
}
|
|
260
254
|
|
|
261
255
|
if (runtimeEnv.SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE === 'cloud-foundry') {
|
|
262
|
-
const destination =
|
|
256
|
+
const destination = await resolveConfiguredCloudFoundryDestination({ env: runtimeEnv });
|
|
263
257
|
let proxy;
|
|
264
258
|
try {
|
|
265
259
|
const binary = await binaryOrError();
|
|
@@ -289,7 +283,7 @@ async function main() {
|
|
|
289
283
|
|
|
290
284
|
const discovered = await discoverDestinations({ env: runtimeEnv });
|
|
291
285
|
for (const destination of discovered) logLine(probeDiagnostic(destination));
|
|
292
|
-
const destinations =
|
|
286
|
+
const destinations = discovered;
|
|
293
287
|
if (!destinations.length) {
|
|
294
288
|
logLine('[sap-ai-dev] destination discovery returned no named BAS destinations');
|
|
295
289
|
throw new Error(remediation);
|
|
@@ -297,10 +291,6 @@ async function main() {
|
|
|
297
291
|
const binary = await binaryOrError();
|
|
298
292
|
logLine(`[sap-ai-dev] sap-ai-dev-toolkit v${pkg.version} (node ${process.version}, pid ${process.pid})`);
|
|
299
293
|
logLine(`[sap-ai-dev] VSP binary: ${binary}`);
|
|
300
|
-
for (const destination of destinations) {
|
|
301
|
-
if (destination.credentials?.mode === 'bas-tunnel') logLine(`[sap-ai-dev] ${destination.name}: Basic credentials enabled through the BAS proxy tunnel`);
|
|
302
|
-
else if (destination.credentials?.host) logLine(`[sap-ai-dev] ${destination.name}: direct connect enabled through stored credentials`);
|
|
303
|
-
}
|
|
304
294
|
logLine(`[sap-ai-dev] starting MCP proxy for ${destinations.map(destination => `${destination.name} (client=${destination.client})`).join(', ')}`);
|
|
305
295
|
const proxy = new MCPProxy({ binary, destinations, env: process.env, log: logLine, version: pkg.version });
|
|
306
296
|
const shutdown = signal => { void proxy.close().finally(() => process.exit(signal === 'SIGINT' ? 130 : 143)); };
|
package/src/mcp-proxy.mjs
CHANGED
|
@@ -38,18 +38,6 @@ const APPLICATION_LOG_SCHEMA = {
|
|
|
38
38
|
};
|
|
39
39
|
const APPLICATION_LOG_DESCRIPTION = 'Read SAP application log (SLG1) entries. Results are newest first and limited to 100 by default; set messages=true to include log message details.';
|
|
40
40
|
|
|
41
|
-
// Read-only mode (SAP_AI_DEV_TOOLKIT_READ_ONLY=true) removes every tool that
|
|
42
|
-
// changes SAP state from the public surface and starts VSP with
|
|
43
|
-
// --transport-read-only, so an exploration destination cannot be written to
|
|
44
|
-
// through this proxy even if a chat prompt asks for it.
|
|
45
|
-
const READ_ONLY_HIDDEN_VSP_TOOLS = new Set([
|
|
46
|
-
'Activate',
|
|
47
|
-
'ActivateMultiple',
|
|
48
|
-
'CreateTransport',
|
|
49
|
-
'EditSource',
|
|
50
|
-
'SetBreakpoint',
|
|
51
|
-
'WriteSource'
|
|
52
|
-
]);
|
|
53
41
|
|
|
54
42
|
// Self-healing restarts retry the interrupted tools/call once. Retrying a
|
|
55
43
|
// state-changing tool after a crash can apply the same write twice (the child
|
|
@@ -60,6 +48,8 @@ const NON_RETRIABLE_VSP_TOOLS = new Set([
|
|
|
60
48
|
'ActivateMultiple',
|
|
61
49
|
'CreateTransport',
|
|
62
50
|
'EditSource',
|
|
51
|
+
'LockObject',
|
|
52
|
+
'UnlockObject',
|
|
63
53
|
'WriteSource'
|
|
64
54
|
]);
|
|
65
55
|
|
|
@@ -78,10 +68,6 @@ function requestTimeoutMs(env) {
|
|
|
78
68
|
return parsed > 0 ? Math.floor(parsed) : 0;
|
|
79
69
|
}
|
|
80
70
|
|
|
81
|
-
export function readOnlyMode(env = process.env) {
|
|
82
|
-
const raw = brandedEnvValue(env, 'READ_ONLY');
|
|
83
|
-
return raw !== undefined && ['1', 'true', 'yes'].includes(String(raw).trim().toLowerCase());
|
|
84
|
-
}
|
|
85
71
|
|
|
86
72
|
// Keep the default public VSP surface small enough for developer-lifecycle use.
|
|
87
73
|
// Hidden upstream tools can still be used by local workflow tools when needed.
|
|
@@ -129,6 +115,7 @@ export const PUBLIC_VSP_TOOLS = new Set([
|
|
|
129
115
|
'GrepPackages',
|
|
130
116
|
'ListDependencies',
|
|
131
117
|
'ListTransports',
|
|
118
|
+
'LockObject',
|
|
132
119
|
'PrettyPrint',
|
|
133
120
|
'RunATCCheck',
|
|
134
121
|
'RunQuery',
|
|
@@ -136,15 +123,10 @@ export const PUBLIC_VSP_TOOLS = new Set([
|
|
|
136
123
|
'SearchObject',
|
|
137
124
|
'SetBreakpoint',
|
|
138
125
|
'SyntaxCheck',
|
|
126
|
+
'UnlockObject',
|
|
139
127
|
'WriteSource'
|
|
140
128
|
]);
|
|
141
129
|
|
|
142
|
-
function exposeVspTool(tool, readOnly = false) {
|
|
143
|
-
if (!PUBLIC_VSP_TOOLS.has(tool?.name)) return false;
|
|
144
|
-
// Read-only destinations never expose the write/activate/transport-create
|
|
145
|
-
// surface, even when VSP registers it.
|
|
146
|
-
return !(readOnly && READ_ONLY_HIDDEN_VSP_TOOLS.has(tool.name));
|
|
147
|
-
}
|
|
148
130
|
|
|
149
131
|
// Public tool names are lowercase snake_case. BAS/VS Code chat tool references
|
|
150
132
|
// are lowercase-only, so mixed-case names never bind in the tools picker even
|
|
@@ -210,13 +192,12 @@ function childEnvironment(destination, env) {
|
|
|
210
192
|
|
|
211
193
|
|
|
212
194
|
export function childArguments(destination, env = process.env) {
|
|
213
|
-
// The proxy allowlist
|
|
214
|
-
//
|
|
195
|
+
// The proxy allowlist controls transport operations; source edits are
|
|
196
|
+
// controlled by SAP_ALLOW_TRANSPORTABLE_EDITS and SAP authorizations.
|
|
215
197
|
const mode = brandedEnvValue(env, 'MODE') || 'expert';
|
|
216
198
|
const args = ['--url', destination.url, '--client', destination.client || '001', '--mode', mode];
|
|
217
199
|
if (destination.source !== 'cloud-foundry' || destination.authentication === 'PrincipalPropagation') args.push('--proxy-auth');
|
|
218
|
-
|
|
219
|
-
else args.push('--enable-transports');
|
|
200
|
+
args.push('--enable-transports');
|
|
220
201
|
return args;
|
|
221
202
|
}
|
|
222
203
|
|
|
@@ -394,7 +375,6 @@ export class MCPProxy {
|
|
|
394
375
|
this.log = log;
|
|
395
376
|
this.output = output;
|
|
396
377
|
this.version = version;
|
|
397
|
-
this.readOnly = readOnlyMode(env);
|
|
398
378
|
this.requestTimeoutMs = requestTimeoutMs(env);
|
|
399
379
|
this.children = [];
|
|
400
380
|
this.started = false;
|
|
@@ -663,17 +643,11 @@ export class MCPProxy {
|
|
|
663
643
|
});
|
|
664
644
|
}
|
|
665
645
|
|
|
666
|
-
if (!
|
|
646
|
+
if (!PUBLIC_VSP_TOOLS.has(tool?.name)) continue;
|
|
667
647
|
const name = publicName(tool.name);
|
|
668
648
|
publish(name, { entry, upstream: tool.name }, { ...tool, name, description: `${tool.description || tool.name} [destination: ${entry.destination.name}]` });
|
|
669
649
|
}
|
|
670
|
-
|
|
671
|
-
// read-only mode the hidden write tools must not enable change-set
|
|
672
|
-
// staging either.
|
|
673
|
-
const effectiveUpstream = this.readOnly
|
|
674
|
-
? upstreamTools.filter(tool => !READ_ONLY_HIDDEN_VSP_TOOLS.has(tool.name))
|
|
675
|
-
: upstreamTools;
|
|
676
|
-
for (const localTool of createEngineeringTools(entry, effectiveUpstream, { env: this.env, log: this.log })) {
|
|
650
|
+
for (const localTool of createEngineeringTools(entry, upstreamTools, { env: this.env, log: this.log })) {
|
|
677
651
|
const name = publicName(localTool.definition.name);
|
|
678
652
|
publish(name, { handler: localTool.handler }, {
|
|
679
653
|
...localTool.definition,
|
package/src/setup.mjs
CHANGED
|
@@ -1,12 +1,9 @@
|
|
|
1
1
|
import { createInterface } from 'node:readline/promises';
|
|
2
2
|
import { stdin, stdout } from 'node:process';
|
|
3
|
-
import { checkboxPrompt, colorText, formatStatus
|
|
3
|
+
import { checkboxPrompt, colorText, formatStatus } from './terminal-ui.mjs';
|
|
4
4
|
import { discoverDestinations, remediation } from './bas-discovery.mjs';
|
|
5
5
|
import { discoverCloudFoundryDestinations, getCloudFoundryTarget, deleteManagedCloudFoundryServiceKeys, findOrphanedCloudFoundryServiceKeys } from './cf-destination.mjs';
|
|
6
6
|
import { SAP_DEVELOPMENT_MCP_SERVERS, collectCloudFoundryKeyReferencesFromAllEntries, collectManagedCloudFoundryKeyReferences, installMcpConfig, readMcpConfig, resolveMcpConfigPath } from './mcp-config.mjs';
|
|
7
|
-
import { readCredentials, removeDestinationCredentials, resolveCredentialsPath, storeDestinationCredentials } from './credentials-store.mjs';
|
|
8
|
-
import { canPromptForCredentials, credentialKeyForDestination, credentialModeForDestination } from './credential-overrides.mjs';
|
|
9
|
-
import { createBasDestinationRelay } from './bas-destination-relay.mjs';
|
|
10
7
|
|
|
11
8
|
const SETUP_COMMAND = 'sap-ai-dev --setup';
|
|
12
9
|
|
|
@@ -26,89 +23,6 @@ async function confirmCloudFoundryImport({ input = stdin, output = stdout } = {}
|
|
|
26
23
|
}
|
|
27
24
|
}
|
|
28
25
|
|
|
29
|
-
async function probeBasOnPremCredentialRoute({ destination, user, password, env }) {
|
|
30
|
-
const relay = createBasDestinationRelay({
|
|
31
|
-
...destination,
|
|
32
|
-
credentials: { user, password, mode: 'bas-tunnel' }
|
|
33
|
-
}, { env, log: () => {} });
|
|
34
|
-
try {
|
|
35
|
-
await relay.ready;
|
|
36
|
-
return await relay.probeCsrfSession();
|
|
37
|
-
} finally {
|
|
38
|
-
await relay.close();
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
async function promptForDestinationCredentials({ destinations, credentialsPath, existing = {}, input = stdin, output = stdout, env = process.env, verifyOnPremCredentialRoute = probeBasOnPremCredentialRoute } = {}) {
|
|
43
|
-
const eligible = destinations.filter(canPromptForCredentials);
|
|
44
|
-
const retained = new Set();
|
|
45
|
-
if (!eligible.length) return retained;
|
|
46
|
-
|
|
47
|
-
print(output, '');
|
|
48
|
-
print(output, formatStatus('Credential overrides are optional and apply only to selected BasicAuthentication destinations.', 'step', output, 'Credentials'));
|
|
49
|
-
print(output, ` Overrides are stored beside your MCP config in ${credentialsPath} with owner-only permissions (never inside mcp.json).`);
|
|
50
|
-
print(output, ' For OnPremise destinations, BAS/Cloud Foundry proxy credentials remain separate from the SAP backend credentials.');
|
|
51
|
-
print(output, '');
|
|
52
|
-
|
|
53
|
-
for (const destination of eligible) {
|
|
54
|
-
const key = credentialKeyForDestination(destination);
|
|
55
|
-
if (!key) continue;
|
|
56
|
-
const mode = credentialModeForDestination(destination);
|
|
57
|
-
const known = existing[key];
|
|
58
|
-
const routeLabel = `${destination.source === 'cloud-foundry' ? 'Cloud Foundry' : 'BAS'} ${destination.proxyType} — ${destination.name}`;
|
|
59
|
-
const answer = await textPrompt({
|
|
60
|
-
message: colorText(`🔐 ${routeLabel} — override SAP credentials? (y/n)`, 'cyan', output),
|
|
61
|
-
required: true,
|
|
62
|
-
validate: value => /^(?:y|yes|n|no)$/i.test(value.trim()) || 'Enter y or n.'
|
|
63
|
-
}, { input, output });
|
|
64
|
-
if (/^n/i.test(answer.trim())) {
|
|
65
|
-
await removeDestinationCredentials(credentialsPath, [key]);
|
|
66
|
-
print(output, formatStatus(`Credential override disabled for ${destination.name}.`, 'info', output, 'Credentials'));
|
|
67
|
-
continue;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
if (known?.user && known?.password) {
|
|
71
|
-
print(output, formatStatus(`Credentials for ${destination.name} are already stored. Press Enter at both prompts to keep them.`, 'info', output, 'Credentials'));
|
|
72
|
-
}
|
|
73
|
-
const userAnswer = await textPrompt({
|
|
74
|
-
message: colorText(`👤 ${routeLabel} — SAP user`, 'cyan', output),
|
|
75
|
-
required: false,
|
|
76
|
-
placeholder: known ? 'Enter keeps the stored user' : 'SAP user name'
|
|
77
|
-
}, { input, output });
|
|
78
|
-
const user = userAnswer.trim() || known?.user || '';
|
|
79
|
-
const passwordAnswer = await textPrompt({
|
|
80
|
-
message: colorText(`🔒 ${routeLabel} — SAP password${known ? ' (Enter keeps stored password)' : ''}`, 'cyan', output),
|
|
81
|
-
secret: true,
|
|
82
|
-
required: false
|
|
83
|
-
}, { input, output });
|
|
84
|
-
const password = passwordAnswer || known?.password || '';
|
|
85
|
-
if (!user || !password) {
|
|
86
|
-
await removeDestinationCredentials(credentialsPath, [key]);
|
|
87
|
-
print(output, formatStatus(`No complete credentials for ${destination.name}; no override was stored.`, 'warning', output, 'Credentials'));
|
|
88
|
-
continue;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
if (mode === 'bas-tunnel') {
|
|
92
|
-
let route;
|
|
93
|
-
try {
|
|
94
|
-
route = await verifyOnPremCredentialRoute({ destination, user, password, env });
|
|
95
|
-
} catch {
|
|
96
|
-
route = null;
|
|
97
|
-
}
|
|
98
|
-
if (!(route?.httpStatus >= 200 && route.httpStatus < 300) || !route.tokenReceived || !(route.cookieCount > 0)) {
|
|
99
|
-
await removeDestinationCredentials(credentialsPath, [key]);
|
|
100
|
-
print(output, formatStatus(`No override stored for ${destination.name}: the BAS/Cloud Connector route did not return both a CSRF token and session cookie.`, 'warning', output, 'Credentials'));
|
|
101
|
-
continue;
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
const host = mode === 'direct' ? (destination.backendUrl || known?.host || '') : '';
|
|
106
|
-
await storeDestinationCredentials(credentialsPath, key, { host, user, password, mode });
|
|
107
|
-
retained.add(key);
|
|
108
|
-
print(output, formatStatus(`Stored credential override for ${destination.name}.`, 'success', output, 'Credentials'));
|
|
109
|
-
}
|
|
110
|
-
return retained;
|
|
111
|
-
}
|
|
112
26
|
|
|
113
27
|
function safeProbe(probe) {
|
|
114
28
|
const result = {};
|
|
@@ -187,7 +101,6 @@ export async function runSetup({
|
|
|
187
101
|
discoverCf = discoverCloudFoundryDestinations,
|
|
188
102
|
confirmCfImport = confirmCloudFoundryImport,
|
|
189
103
|
install = installMcpConfig,
|
|
190
|
-
verifyOnPremCredentialRoute = probeBasOnPremCredentialRoute,
|
|
191
104
|
includeSapDevelopmentToolsPrompt = false
|
|
192
105
|
} = {}) {
|
|
193
106
|
if (!env.H2O_URL) {
|
|
@@ -209,7 +122,6 @@ export async function runSetup({
|
|
|
209
122
|
}
|
|
210
123
|
const destinations = basDestinations.map(safeBasDestination);
|
|
211
124
|
const warnings = [];
|
|
212
|
-
const credentialWarnings = [];
|
|
213
125
|
let createdKeys = [];
|
|
214
126
|
let configPath;
|
|
215
127
|
let managedKeys = [];
|
|
@@ -352,25 +264,6 @@ export async function runSetup({
|
|
|
352
264
|
}, { input, output });
|
|
353
265
|
}
|
|
354
266
|
|
|
355
|
-
// Ask for an explicit credential override choice for each selected supported destination.
|
|
356
|
-
const credentialsPath = await resolveCredentialsPath(env, configPath);
|
|
357
|
-
try {
|
|
358
|
-
const existingMap = (await readCredentials(credentialsPath).catch(() => ({ destinations: {} }))).destinations || {};
|
|
359
|
-
const retained = await promptForDestinationCredentials({
|
|
360
|
-
destinations: selected,
|
|
361
|
-
credentialsPath,
|
|
362
|
-
existing: existingMap,
|
|
363
|
-
input,
|
|
364
|
-
output,
|
|
365
|
-
env,
|
|
366
|
-
verifyOnPremCredentialRoute
|
|
367
|
-
});
|
|
368
|
-
const stale = Object.keys(existingMap).filter(name => !retained.has(name));
|
|
369
|
-
if (stale.length) await removeDestinationCredentials(credentialsPath, stale);
|
|
370
|
-
} catch (error) {
|
|
371
|
-
if (/Prompt interrupted/.test(error.message)) throw error;
|
|
372
|
-
credentialWarnings.push(`Credential overrides could not be stored: ${error.message}`);
|
|
373
|
-
}
|
|
374
267
|
try {
|
|
375
268
|
const result = await install(selected, { env, sapDevelopmentServers });
|
|
376
269
|
const location = result?.path ? ` in ${result.path}` : '';
|
|
@@ -391,9 +284,8 @@ export async function runSetup({
|
|
|
391
284
|
}
|
|
392
285
|
}
|
|
393
286
|
await reportWarnings(warnings);
|
|
394
|
-
await reportWarnings(credentialWarnings);
|
|
395
287
|
await reportWarnings(cleanupWarnings);
|
|
396
|
-
return { selected, ...(result || {}), warnings: [...warnings, ...
|
|
288
|
+
return { selected, ...(result || {}), warnings: [...warnings, ...cleanupWarnings] };
|
|
397
289
|
} catch (error) {
|
|
398
290
|
const cleanupWarnings = [];
|
|
399
291
|
if (createdKeys.length) {
|
package/src/terminal-ui.mjs
CHANGED
|
@@ -139,7 +139,9 @@ export async function checkboxPrompt({ message, choices, required = false, short
|
|
|
139
139
|
for (let index = top; index < end; index += 1) {
|
|
140
140
|
const choice = choices[index];
|
|
141
141
|
const pointer = index === cursor ? '❯' : ' ';
|
|
142
|
-
const marker = selected.has(index)
|
|
142
|
+
const marker = selected.has(index)
|
|
143
|
+
? colorText('✓', 'green', output)
|
|
144
|
+
: colorText('✗', 'red', output);
|
|
143
145
|
const disabled = choice.disabled ? ` — ${choice.disabled}` : '';
|
|
144
146
|
const label = `${pointer}${marker} ${choice.name}${disabled}`;
|
|
145
147
|
lines.push(truncateToColumns(label, columns));
|
package/tools.md
CHANGED
|
@@ -77,9 +77,6 @@ The proxy adds local multi-step tools to each destination when their required VS
|
|
|
77
77
|
|
|
78
78
|
`sap-ai-dev --doctor` checks destination probing, VSP startup, the `get_system_info` probe, and MCP tool listing. `sap-ai-dev --demo` runs the sample tools and OData fixtures without contacting SAP; demo writes and transport creation remain in memory until that process exits.
|
|
79
79
|
|
|
80
|
-
### Read-only mode
|
|
81
|
-
|
|
82
|
-
Set `SAP_AI_DEV_TOOLKIT_READ_ONLY=true` (or legacy `BAS_VSP_READ_ONLY`) on a destination server to run it strictly read-only: `WriteSource`, `EditSource`, `Activate`, `ActivateMultiple`, `CreateTransport`, `SetBreakpoint`, and the change-set workflow tools are removed from the surface, and the VSP child starts with `--transport-read-only` so transport writes are rejected upstream as well. Inspection, queries, linting, and checks remain available.
|
|
83
80
|
|
|
84
81
|
### Request timeouts
|
|
85
82
|
|
|
@@ -1,75 +0,0 @@
|
|
|
1
|
-
import { readCredentials, resolveCredentialsPath } from './credentials-store.mjs';
|
|
2
|
-
|
|
3
|
-
const CLOUD_FOUNDRY_KEY_PREFIX = 'cloud-foundry:';
|
|
4
|
-
|
|
5
|
-
function isBasicAuthentication(value) {
|
|
6
|
-
return /^basic(?:authentication)?$/i.test(String(value || '').replace(/\s+/g, ''));
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
export function credentialKeyForDestination(destination = {}) {
|
|
10
|
-
if (destination.source === 'cloud-foundry') {
|
|
11
|
-
const serverName = String(destination.serverName || '').trim();
|
|
12
|
-
return serverName ? `${CLOUD_FOUNDRY_KEY_PREFIX}${serverName}` : '';
|
|
13
|
-
}
|
|
14
|
-
return String(destination.name || '').trim();
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export function credentialModeForDestination(destination = {}) {
|
|
18
|
-
if (!isBasicAuthentication(destination.authentication)) return null;
|
|
19
|
-
const proxyType = String(destination.proxyType || '').trim().toLowerCase();
|
|
20
|
-
if (destination.source === 'cloud-foundry') {
|
|
21
|
-
return proxyType === 'onpremise' ? 'cf-connectivity' : null;
|
|
22
|
-
}
|
|
23
|
-
if (proxyType === 'internet') return 'direct';
|
|
24
|
-
if (proxyType === 'onpremise') return 'bas-tunnel';
|
|
25
|
-
return null;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export function canPromptForCredentials(destination = {}) {
|
|
29
|
-
const mode = credentialModeForDestination(destination);
|
|
30
|
-
return Boolean(mode && (mode !== 'direct' || destination.backendUrl));
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export async function enrichWithStoredCredentials(destinations, env = process.env, log = message => console.error(message)) {
|
|
34
|
-
const needingCredentials = destinations.filter(destination => {
|
|
35
|
-
const mode = credentialModeForDestination(destination);
|
|
36
|
-
if (!mode || !canPromptForCredentials(destination)) return false;
|
|
37
|
-
if (mode === 'cf-connectivity') return Boolean(destination.serverName);
|
|
38
|
-
return Boolean(destination.url && String(destination.url).includes('.dest'));
|
|
39
|
-
});
|
|
40
|
-
if (!needingCredentials.length) return destinations;
|
|
41
|
-
let stored;
|
|
42
|
-
try {
|
|
43
|
-
const path = await resolveCredentialsPath(env);
|
|
44
|
-
stored = await readCredentials(path);
|
|
45
|
-
} catch (error) {
|
|
46
|
-
log(`[sap-ai-dev] stored credentials could not be read: ${String(error.message).slice(0, 200)}`);
|
|
47
|
-
return destinations;
|
|
48
|
-
}
|
|
49
|
-
return destinations.map(destination => {
|
|
50
|
-
const mode = credentialModeForDestination(destination);
|
|
51
|
-
const key = credentialKeyForDestination(destination);
|
|
52
|
-
const entry = key ? stored.destinations?.[key] : undefined;
|
|
53
|
-
if (!mode || !entry?.user || !entry?.password) return destination;
|
|
54
|
-
if (mode === 'cf-connectivity') {
|
|
55
|
-
if (entry.mode !== mode || destination.source !== 'cloud-foundry') return destination;
|
|
56
|
-
return {
|
|
57
|
-
...destination,
|
|
58
|
-
childEnv: {
|
|
59
|
-
...(destination.childEnv || {}),
|
|
60
|
-
SAP_USER: entry.user,
|
|
61
|
-
SAP_PASSWORD: entry.password,
|
|
62
|
-
SAP_VERBOSE: 'false'
|
|
63
|
-
}
|
|
64
|
-
};
|
|
65
|
-
}
|
|
66
|
-
if (mode === 'bas-tunnel') {
|
|
67
|
-
if (entry.mode !== mode) return destination;
|
|
68
|
-
return { ...destination, credentials: { user: entry.user, password: entry.password, mode } };
|
|
69
|
-
}
|
|
70
|
-
if (!destination.backendUrl || !destination.url || !String(destination.url).includes('.dest')) return destination;
|
|
71
|
-
if (entry.mode && entry.mode !== 'direct') return destination;
|
|
72
|
-
const host = entry.host || destination.backendUrl;
|
|
73
|
-
return { ...destination, credentials: { host, user: entry.user, password: entry.password, mode: 'direct' } };
|
|
74
|
-
});
|
|
75
|
-
}
|
|
@@ -1,113 +0,0 @@
|
|
|
1
|
-
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import { chmod, mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
3
|
-
import { homedir } from 'node:os';
|
|
4
|
-
import { basename, dirname, join } from 'node:path';
|
|
5
|
-
import { brandedEnvValue } from './branding.mjs';
|
|
6
|
-
|
|
7
|
-
// Optional SAP backend credential overrides live next to the MCP config
|
|
8
|
-
// (never inside it), with 0600 permissions and no tokens or cookies. Entries
|
|
9
|
-
// are keyed by a stable destination identity and may include a route mode.
|
|
10
|
-
const CREDENTIALS_FILENAME = 'sap-ai-dev-toolkit-credentials.json';
|
|
11
|
-
const CREDENTIAL_MODES = new Set(['direct', 'bas-tunnel', 'cf-connectivity']);
|
|
12
|
-
|
|
13
|
-
async function exists(path) {
|
|
14
|
-
try {
|
|
15
|
-
await stat(path);
|
|
16
|
-
return true;
|
|
17
|
-
} catch (error) {
|
|
18
|
-
if (error?.code === 'ENOENT') return false;
|
|
19
|
-
throw error;
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export async function resolveCredentialsPath(env = process.env, mcpConfigPath) {
|
|
24
|
-
const configured = brandedEnvValue(env, 'CREDENTIALS_FILE');
|
|
25
|
-
if (configured) return configured;
|
|
26
|
-
if (mcpConfigPath) return join(dirname(mcpConfigPath), CREDENTIALS_FILENAME);
|
|
27
|
-
const home = env.HOME || homedir();
|
|
28
|
-
const candidates = [
|
|
29
|
-
join(home, '.vscode', 'data', 'User'),
|
|
30
|
-
join(home, '.vscode-server', 'data', 'User'),
|
|
31
|
-
join(home, '.code-server', 'data', 'User')
|
|
32
|
-
];
|
|
33
|
-
for (const candidate of candidates) if (await exists(candidate)) return join(candidate, CREDENTIALS_FILENAME);
|
|
34
|
-
return join(candidates[0], CREDENTIALS_FILENAME);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function sanitizeEntry(entry) {
|
|
38
|
-
const result = {};
|
|
39
|
-
if (typeof entry?.host === 'string' && entry.host) result.host = entry.host;
|
|
40
|
-
if (typeof entry?.user === 'string' && entry.user) result.user = entry.user;
|
|
41
|
-
if (typeof entry?.password === 'string' && entry.password) result.password = entry.password;
|
|
42
|
-
if (CREDENTIAL_MODES.has(entry?.mode)) result.mode = entry.mode;
|
|
43
|
-
if (typeof entry?.updatedAt === 'string') result.updatedAt = entry.updatedAt;
|
|
44
|
-
return result;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
export async function readCredentials(path) {
|
|
48
|
-
let raw;
|
|
49
|
-
try {
|
|
50
|
-
raw = await readFile(path, 'utf8');
|
|
51
|
-
} catch (error) {
|
|
52
|
-
if (error?.code === 'ENOENT') return {};
|
|
53
|
-
throw new Error(`Credentials file ${path} could not be read: ${error.message}`);
|
|
54
|
-
}
|
|
55
|
-
let parsed;
|
|
56
|
-
try {
|
|
57
|
-
parsed = JSON.parse(raw);
|
|
58
|
-
} catch (error) {
|
|
59
|
-
throw new Error(`Credentials file ${path} contains invalid JSON: ${error.message}`);
|
|
60
|
-
}
|
|
61
|
-
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
62
|
-
throw new Error(`Credentials file ${path} must contain a top-level JSON object`);
|
|
63
|
-
}
|
|
64
|
-
const destinations = {};
|
|
65
|
-
for (const [name, entry] of Object.entries(parsed.destinations || {})) {
|
|
66
|
-
const clean = sanitizeEntry(entry);
|
|
67
|
-
if (clean.user && clean.password) destinations[name] = clean;
|
|
68
|
-
}
|
|
69
|
-
return { destinations };
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
async function writeCredentials(path, destinations) {
|
|
73
|
-
const directory = dirname(path);
|
|
74
|
-
await mkdir(directory, { recursive: true });
|
|
75
|
-
const temporary = join(directory, `.${basename(path)}.${process.pid}.${randomUUID()}.tmp`);
|
|
76
|
-
try {
|
|
77
|
-
await writeFile(temporary, `${JSON.stringify({ version: 1, destinations }, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
78
|
-
await rename(temporary, path);
|
|
79
|
-
await chmod(path, 0o600).catch(() => {});
|
|
80
|
-
} catch (error) {
|
|
81
|
-
await rm(temporary, { force: true }).catch(() => {});
|
|
82
|
-
throw new Error(`Credentials file ${path} could not be written: ${error.message}`);
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
export async function storeDestinationCredentials(path, name, { host, user, password, mode }) {
|
|
87
|
-
if (mode != null && !CREDENTIAL_MODES.has(mode)) throw new Error('Credential override route mode is unsupported');
|
|
88
|
-
const current = await readCredentials(path).catch(error => { throw error; });
|
|
89
|
-
const destinations = { ...current.destinations };
|
|
90
|
-
destinations[String(name)] = {
|
|
91
|
-
host: String(host || ''),
|
|
92
|
-
user: String(user || ''),
|
|
93
|
-
password: String(password || ''),
|
|
94
|
-
...(mode ? { mode } : {}),
|
|
95
|
-
updatedAt: new Date().toISOString()
|
|
96
|
-
};
|
|
97
|
-
await writeCredentials(path, destinations);
|
|
98
|
-
return { path, name };
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
export async function removeDestinationCredentials(path, names) {
|
|
102
|
-
const current = await readCredentials(path);
|
|
103
|
-
const destinations = { ...current.destinations };
|
|
104
|
-
const removed = [];
|
|
105
|
-
for (const name of names) {
|
|
106
|
-
if (Object.hasOwn(destinations, String(name))) {
|
|
107
|
-
delete destinations[String(name)];
|
|
108
|
-
removed.push(String(name));
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
if (removed.length) await writeCredentials(path, destinations);
|
|
112
|
-
return { path, removed };
|
|
113
|
-
}
|