@cspeach/cli 1.1.22 → 1.1.23
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/dist/agent/tool-dispatch.js +4 -2
- package/dist/cli.js +13 -0
- package/dist/commands/plan-audit-evidence.js +61 -4
- package/dist/commands/plan-audit.js +13 -1
- package/dist/commands/sap-link.js +52 -0
- package/dist/lib/shutdown-hooks.js +87 -0
- package/dist/one-shot.js +4 -0
- package/dist/projects/plan-run.js +5 -1
- package/dist/projects/plan-schema.js +2 -1
- package/dist/renderer/tool-labels.js +5 -1
- package/dist/repl/credential-handover-gate.js +12 -6
- package/dist/repl.js +4 -0
- package/dist/sap/connection-manager.js +30 -24
- package/dist/sap-link/policy.js +185 -0
- package/dist/sap-link/project.js +163 -0
- package/dist/sap-link/registry.js +259 -0
- package/dist/sap-link/server.js +351 -0
- package/dist/sap-link/upstream.js +367 -0
- package/dist/sap-link/write-log.js +60 -0
- package/dist/skills/bundled-skills.js +1 -1
- package/dist/tools/cap/_fetch-errors.js +20 -0
- package/dist/tools/cap/link-smoke.js +703 -0
- package/dist/tools/cap/metadata-import.js +325 -0
- package/dist/tools/cap/sap-link-tool.js +133 -0
- package/dist/tools/cap/service-catalog.js +248 -0
- package/dist/tools/fiori/preview/registry.js +30 -24
- package/dist/tools/local-build.js +11 -0
- package/dist/tools/subagent/_background-shared.js +59 -37
- package/dist/tools/subagent/background_run.js +9 -5
- package/package.json +121 -121
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
// cspeach-cli/src/sap-link/upstream.ts
|
|
2
|
+
/**
|
|
3
|
+
* CAP phase 2 — the SAP link's one upstream session to SAP Gateway (spec §6.3).
|
|
4
|
+
*
|
|
5
|
+
* It adds the developer's SAP login (basic auth) and the sap-client, keeps its own
|
|
6
|
+
* cookie jar and CSRF token (separate from the ADT connection, whose token belongs to
|
|
7
|
+
* a different ICF service), and fetches the token before a write with one retry.
|
|
8
|
+
* The password lives only in this object, in this process. Nothing here logs.
|
|
9
|
+
*
|
|
10
|
+
* Layering: policy.ts is the PRIMARY gate (service allowlist, dev-only writes,
|
|
11
|
+
* tunnelling and encoding refusals). This module is the BACKSTOP: it re-checks what it
|
|
12
|
+
* can cheaply and never trusts that the caller already did.
|
|
13
|
+
*
|
|
14
|
+
* Hardening choices (Task 6 security requirements + fix round 1):
|
|
15
|
+
* - Only GET, HEAD, POST, PUT, PATCH, MERGE and DELETE are sent (upper-cased first).
|
|
16
|
+
* Anything else (TRACE can echo the Authorization header; CONNECT hangs) is refused
|
|
17
|
+
* with EBADMETHOD before a byte goes out.
|
|
18
|
+
* - Incoming headers the app may not set are dropped case-insensitively: login,
|
|
19
|
+
* cookies, token, hop-by-hop / framing headers, method tunnelling, forwarding
|
|
20
|
+
* headers, every `sap-*` header except `sap-language`, and every `x-sap-*`.
|
|
21
|
+
* - sap-client is FORCED: every `sap-*` query parameter except `sap-language` (decoded
|
|
22
|
+
* name, any case) is removed, and the configured sap-client appended. The rest of the query is kept byte-for-byte,
|
|
23
|
+
* never re-encoded, so OData `$filter` text reaches SAP unchanged.
|
|
24
|
+
* - GET and HEAD never carry a body.
|
|
25
|
+
* - Response headers that carry session, token, login challenge or framing are never
|
|
26
|
+
* returned, nor any header whose value contains the login (paranoid check).
|
|
27
|
+
* - The response body is capped (64 MiB default) → ERESPONSETOOLARGE. One overall
|
|
28
|
+
* deadline (60 s default) covers the whole exchange → ETIMEDOUT. Redirects are
|
|
29
|
+
* never followed (Node's http does not, and we add nothing that does).
|
|
30
|
+
* - The base URL must be http: or https:. https uses its own Agent with
|
|
31
|
+
* `rejectUnauthorized` and `ca` from config; NODE_TLS_REJECT_UNAUTHORIZED is never
|
|
32
|
+
* touched. The request goes to the configured host only: the path must be a plain
|
|
33
|
+
* absolute path (no `//host`, scheme, `#`, backslash, whitespace or control chars),
|
|
34
|
+
* and the only matrix parameter allowed in it is `;v=<digits>` (service version).
|
|
35
|
+
* - The cookie jar stores name=value only (attributes ignored), a later cookie of the
|
|
36
|
+
* same name replaces the earlier one, malformed cookies are dropped, and the jar is
|
|
37
|
+
* bounded. A cookie set to an empty value, `Max-Age<=0` or a past `Expires` is
|
|
38
|
+
* deleted from the jar.
|
|
39
|
+
* - close() destroys the agent and clears cookies and token; send() after close()
|
|
40
|
+
* throws ECLOSED without sending.
|
|
41
|
+
* - 401 latch (final wave): after the first 401 from SAP (any request, including the
|
|
42
|
+
* CSRF token fetch) every later send throws ELOGINREJECTED WITHOUT sending, until
|
|
43
|
+
* the link restarts (a new UpstreamSession). Repeated Basic-auth retries with a
|
|
44
|
+
* stale password can lock the developer's SAP user. Requests already in flight
|
|
45
|
+
* when the first 401 arrives are not recalled.
|
|
46
|
+
*/
|
|
47
|
+
import * as http from 'node:http';
|
|
48
|
+
import * as https from 'node:https';
|
|
49
|
+
const DEFAULT_MAX_RESPONSE_BYTES = 64 * 1024 * 1024;
|
|
50
|
+
const DEFAULT_TIMEOUT_MS = 60_000;
|
|
51
|
+
const MAX_COOKIES = 50;
|
|
52
|
+
/**
|
|
53
|
+
* The bare password is only scanned for in response headers when it has 4 or more
|
|
54
|
+
* characters: a 1-3 char string would match unrelated headers (content-type etc.).
|
|
55
|
+
* The full `Basic …` string and the base64 credential are ALWAYS scanned, so the
|
|
56
|
+
* login itself is covered whatever the password length.
|
|
57
|
+
*/
|
|
58
|
+
const MIN_PASSWORD_SCAN_LEN = 4;
|
|
59
|
+
/** Request headers the app may NOT set upstream — the link owns login, session and token. */
|
|
60
|
+
const DROP_IN = new Set([
|
|
61
|
+
'authorization', 'proxy-authorization', 'cookie', 'host', 'x-csrf-token',
|
|
62
|
+
'connection', 'keep-alive', 'content-length', 'transfer-encoding', 'te', 'upgrade',
|
|
63
|
+
'expect', 'trailer', 'x-http-method', 'x-http-method-override', 'forwarded',
|
|
64
|
+
]);
|
|
65
|
+
function dropIncoming(lower) {
|
|
66
|
+
if (DROP_IN.has(lower))
|
|
67
|
+
return true;
|
|
68
|
+
if (lower.startsWith('x-forwarded-'))
|
|
69
|
+
return true;
|
|
70
|
+
if (lower.startsWith('x-sap-'))
|
|
71
|
+
return true;
|
|
72
|
+
if (lower.startsWith('sap-') && lower !== 'sap-language')
|
|
73
|
+
return true;
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
/** Response headers never handed back to the app. */
|
|
77
|
+
const DROP_OUT = new Set([
|
|
78
|
+
'set-cookie', 'set-cookie2', 'x-csrf-token', 'connection', 'keep-alive',
|
|
79
|
+
'transfer-encoding', 'content-length', 'www-authenticate', 'proxy-authenticate',
|
|
80
|
+
]);
|
|
81
|
+
/** RFC 7230 token — valid cookie names. */
|
|
82
|
+
const COOKIE_NAME = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
|
|
83
|
+
/** RFC 6265 cookie-octets (quotes allowed around the whole value). */
|
|
84
|
+
const COOKIE_VALUE = /^"?[\x21\x23-\x2B\x2D-\x3A\x3C-\x5B\x5D-\x7E]*"?$/;
|
|
85
|
+
function codedError(message, code) {
|
|
86
|
+
return Object.assign(new Error(message), { code });
|
|
87
|
+
}
|
|
88
|
+
/** A plain absolute path on the configured host — nothing that can change the target. */
|
|
89
|
+
function assertPlainPath(p, what) {
|
|
90
|
+
if (!p.startsWith('/') || p.startsWith('//') || /[\\#\s\x00-\x1f\x7f]/.test(p)) {
|
|
91
|
+
throw codedError(`Upstream ${what} is not a plain absolute path.`, 'EBADPATH');
|
|
92
|
+
}
|
|
93
|
+
// The only matrix parameter allowed in the path is the service version `;v=<digits>`
|
|
94
|
+
// (e.g. `API_X;v=0002`). `;sap-client=…`, `;o=…` and the like are refused.
|
|
95
|
+
const q = p.indexOf('?');
|
|
96
|
+
const pathname = q < 0 ? p : p.slice(0, q);
|
|
97
|
+
if (pathname.replace(/;v=\d+(?=\/|$)/g, '').includes(';')) {
|
|
98
|
+
throw codedError(`Upstream ${what} has a matrix parameter that is not allowed.`, 'EBADPATH');
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const ALLOWED_METHOD = /^(GET|HEAD|POST|PUT|PATCH|MERGE|DELETE)$/;
|
|
102
|
+
/** True when a Set-Cookie line says the cookie is already gone (Max-Age<=0 or past Expires). */
|
|
103
|
+
function isExpired(setCookie) {
|
|
104
|
+
let maxAge;
|
|
105
|
+
let expires;
|
|
106
|
+
for (const a of setCookie.split(';').slice(1)) {
|
|
107
|
+
const i = a.indexOf('=');
|
|
108
|
+
const k = (i < 0 ? a : a.slice(0, i)).trim().toLowerCase();
|
|
109
|
+
const v = i < 0 ? '' : a.slice(i + 1).trim();
|
|
110
|
+
if (k === 'max-age' && /^-?\d+$/.test(v))
|
|
111
|
+
maxAge = Number(v);
|
|
112
|
+
else if (k === 'expires') {
|
|
113
|
+
const t = Date.parse(v);
|
|
114
|
+
if (!Number.isNaN(t))
|
|
115
|
+
expires = t;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (maxAge !== undefined)
|
|
119
|
+
return maxAge <= 0; // Max-Age wins over Expires (RFC 6265 §5.3)
|
|
120
|
+
return expires !== undefined && expires <= Date.now();
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* One upstream session to SAP Gateway for the SAP link (spec §6.3). Its own cookie
|
|
124
|
+
* jar and CSRF token. The password lives only in `this.auth`, in this process.
|
|
125
|
+
*/
|
|
126
|
+
export class UpstreamSession {
|
|
127
|
+
cfg;
|
|
128
|
+
auth;
|
|
129
|
+
secrets;
|
|
130
|
+
agent;
|
|
131
|
+
isHttps;
|
|
132
|
+
host;
|
|
133
|
+
port;
|
|
134
|
+
maxResponseBytes;
|
|
135
|
+
timeoutMs;
|
|
136
|
+
cookies = new Map();
|
|
137
|
+
token = '';
|
|
138
|
+
tokenInFlight;
|
|
139
|
+
closed = false;
|
|
140
|
+
/** Set by the first upstream 401; never cleared (a restart builds a new session). */
|
|
141
|
+
loginRejected = false;
|
|
142
|
+
constructor(cfg, opts = {}) {
|
|
143
|
+
this.cfg = cfg;
|
|
144
|
+
let u;
|
|
145
|
+
try {
|
|
146
|
+
u = new URL(cfg.baseUrl);
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
throw codedError('SAP base URL is not a valid URL.', 'EBADURL');
|
|
150
|
+
}
|
|
151
|
+
if (u.protocol !== 'https:' && u.protocol !== 'http:') {
|
|
152
|
+
throw codedError('SAP base URL must use http or https.', 'EBADURL');
|
|
153
|
+
}
|
|
154
|
+
this.isHttps = u.protocol === 'https:';
|
|
155
|
+
this.host = u.hostname.replace(/^\[(.*)\]$/, '$1');
|
|
156
|
+
this.port = u.port ? Number(u.port) : undefined;
|
|
157
|
+
const cred = Buffer.from(`${cfg.username}:${cfg.password}`).toString('base64');
|
|
158
|
+
this.auth = 'Basic ' + cred;
|
|
159
|
+
this.secrets = [this.auth.toLowerCase(), cred.toLowerCase()];
|
|
160
|
+
if (cfg.password.length >= MIN_PASSWORD_SCAN_LEN)
|
|
161
|
+
this.secrets.push(cfg.password.toLowerCase());
|
|
162
|
+
this.agent = this.isHttps
|
|
163
|
+
? new https.Agent({ keepAlive: true, rejectUnauthorized: cfg.rejectUnauthorized, ...(cfg.ca ? { ca: cfg.ca } : {}) })
|
|
164
|
+
: new http.Agent({ keepAlive: true });
|
|
165
|
+
this.maxResponseBytes = opts.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES;
|
|
166
|
+
this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
167
|
+
}
|
|
168
|
+
/** True once SAP answered 401: this session sends nothing more (the link must restart). */
|
|
169
|
+
isLoginRejected() {
|
|
170
|
+
return this.loginRejected;
|
|
171
|
+
}
|
|
172
|
+
/** Drop the connections, cookies and token. Later send() calls throw ECLOSED. */
|
|
173
|
+
close() {
|
|
174
|
+
this.closed = true;
|
|
175
|
+
this.agent.destroy();
|
|
176
|
+
this.cookies.clear();
|
|
177
|
+
this.token = '';
|
|
178
|
+
}
|
|
179
|
+
async send(req) {
|
|
180
|
+
if (this.closed)
|
|
181
|
+
throw codedError('Upstream session is closed.', 'ECLOSED');
|
|
182
|
+
const method = typeof req.method === 'string' ? req.method.toUpperCase() : '';
|
|
183
|
+
if (!ALLOWED_METHOD.test(method))
|
|
184
|
+
throw codedError('Upstream method is not allowed.', 'EBADMETHOD');
|
|
185
|
+
assertPlainPath(req.path, 'path');
|
|
186
|
+
if (req.write)
|
|
187
|
+
assertPlainPath(req.servicePrefix, 'service prefix');
|
|
188
|
+
if (req.write && !this.token)
|
|
189
|
+
await this.fetchToken(req.servicePrefix);
|
|
190
|
+
let res = await this.raw(method, req.path, this.outHeaders(req), req.body);
|
|
191
|
+
if (req.write && res.status === 403 && isRequired(res.rawHeaders['x-csrf-token'])) {
|
|
192
|
+
// One refetch + one retry. A second failure goes back to the app as-is.
|
|
193
|
+
this.token = '';
|
|
194
|
+
await this.fetchToken(req.servicePrefix);
|
|
195
|
+
res = await this.raw(method, req.path, this.outHeaders(req), req.body);
|
|
196
|
+
}
|
|
197
|
+
return { status: res.status, headers: this.inHeaders(res.rawHeaders), body: res.body };
|
|
198
|
+
}
|
|
199
|
+
outHeaders(req) {
|
|
200
|
+
const h = {};
|
|
201
|
+
for (const [k, v] of Object.entries(req.headers)) {
|
|
202
|
+
const lower = k.toLowerCase();
|
|
203
|
+
if (typeof v !== 'string' || dropIncoming(lower))
|
|
204
|
+
continue;
|
|
205
|
+
h[lower] = v;
|
|
206
|
+
}
|
|
207
|
+
if (req.write && this.token)
|
|
208
|
+
h['x-csrf-token'] = this.token;
|
|
209
|
+
return h;
|
|
210
|
+
}
|
|
211
|
+
inHeaders(raw) {
|
|
212
|
+
const out = {};
|
|
213
|
+
for (const [k, v] of Object.entries(raw)) {
|
|
214
|
+
const lower = k.toLowerCase();
|
|
215
|
+
if (v === undefined || DROP_OUT.has(lower))
|
|
216
|
+
continue;
|
|
217
|
+
const value = Array.isArray(v) ? v.join(', ') : String(v);
|
|
218
|
+
const lv = value.toLowerCase();
|
|
219
|
+
if (this.secrets.some((s) => lv.includes(s)))
|
|
220
|
+
continue;
|
|
221
|
+
out[lower] = value;
|
|
222
|
+
}
|
|
223
|
+
return out;
|
|
224
|
+
}
|
|
225
|
+
fetchToken(servicePrefix) {
|
|
226
|
+
if (!this.tokenInFlight) {
|
|
227
|
+
this.tokenInFlight = (async () => {
|
|
228
|
+
const res = await this.raw('GET', `${servicePrefix.replace(/\/+$/, '')}/`, { 'x-csrf-token': 'Fetch', accept: 'application/json' }, Buffer.alloc(0));
|
|
229
|
+
if (res.status === 401)
|
|
230
|
+
throw loginRejectedError();
|
|
231
|
+
const t = res.rawHeaders['x-csrf-token'];
|
|
232
|
+
const value = Array.isArray(t) ? t[0] : t;
|
|
233
|
+
this.token = value && !isRequired(value) ? String(value) : '';
|
|
234
|
+
})().finally(() => { this.tokenInFlight = undefined; });
|
|
235
|
+
}
|
|
236
|
+
return this.tokenInFlight;
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Remove every `sap-*` query parameter except `sap-language` (decoded name, any case)
|
|
240
|
+
* and append the configured sap-client. Other segments are kept byte-for-byte.
|
|
241
|
+
*/
|
|
242
|
+
withClient(p) {
|
|
243
|
+
const q = p.indexOf('?');
|
|
244
|
+
const pathname = q < 0 ? p : p.slice(0, q);
|
|
245
|
+
const query = q < 0 ? '' : p.slice(q + 1);
|
|
246
|
+
const kept = query.split('&').filter((seg) => {
|
|
247
|
+
if (seg === '')
|
|
248
|
+
return false;
|
|
249
|
+
const eq = seg.indexOf('=');
|
|
250
|
+
const rawName = eq < 0 ? seg : seg.slice(0, eq);
|
|
251
|
+
let name = rawName;
|
|
252
|
+
try {
|
|
253
|
+
name = decodeURIComponent(rawName.replace(/\+/g, ' '));
|
|
254
|
+
}
|
|
255
|
+
catch { /* keep raw name */ }
|
|
256
|
+
const lower = name.trim().toLowerCase();
|
|
257
|
+
return !lower.startsWith('sap-') || lower === 'sap-language';
|
|
258
|
+
});
|
|
259
|
+
kept.push(`sap-client=${encodeURIComponent(this.cfg.client)}`);
|
|
260
|
+
return `${pathname}?${kept.join('&')}`;
|
|
261
|
+
}
|
|
262
|
+
storeCookies(setCookie) {
|
|
263
|
+
for (const c of setCookie ?? []) {
|
|
264
|
+
const pair = c.split(';', 1)[0] ?? '';
|
|
265
|
+
const i = pair.indexOf('=');
|
|
266
|
+
if (i <= 0)
|
|
267
|
+
continue;
|
|
268
|
+
const name = pair.slice(0, i).trim();
|
|
269
|
+
const value = pair.slice(i + 1).trim();
|
|
270
|
+
if (!COOKIE_NAME.test(name))
|
|
271
|
+
continue;
|
|
272
|
+
if (value === '' || value === '""' || isExpired(c)) {
|
|
273
|
+
this.cookies.delete(name);
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
if (!COOKIE_VALUE.test(value))
|
|
277
|
+
continue;
|
|
278
|
+
if (!this.cookies.has(name) && this.cookies.size >= MAX_COOKIES)
|
|
279
|
+
continue;
|
|
280
|
+
this.cookies.set(name, value);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
raw(method, p, headers, body) {
|
|
284
|
+
if (this.closed)
|
|
285
|
+
return Promise.reject(codedError('Upstream session is closed.', 'ECLOSED'));
|
|
286
|
+
if (this.loginRejected)
|
|
287
|
+
return Promise.reject(loginRejectedError());
|
|
288
|
+
const noBody = /^(GET|HEAD)$/i.test(method);
|
|
289
|
+
const all = { ...headers, authorization: this.auth };
|
|
290
|
+
if (!noBody)
|
|
291
|
+
all['content-length'] = String(body.length);
|
|
292
|
+
if (this.cookies.size)
|
|
293
|
+
all.cookie = [...this.cookies].map(([k, v]) => `${k}=${v}`).join('; ');
|
|
294
|
+
const lib = this.isHttps ? https : http;
|
|
295
|
+
return new Promise((resolve, reject) => {
|
|
296
|
+
let settled = false;
|
|
297
|
+
let r;
|
|
298
|
+
const fail = (err) => {
|
|
299
|
+
if (settled)
|
|
300
|
+
return;
|
|
301
|
+
settled = true;
|
|
302
|
+
clearTimeout(timer);
|
|
303
|
+
reject(err);
|
|
304
|
+
// Destroy WITHOUT the error: we already rejected, and destroy(err) re-emits it
|
|
305
|
+
// on streams that may have no listener left (uncaught exception).
|
|
306
|
+
r?.destroy();
|
|
307
|
+
};
|
|
308
|
+
const timer = setTimeout(() => fail(codedError('upstream timeout', 'ETIMEDOUT')), this.timeoutMs);
|
|
309
|
+
try {
|
|
310
|
+
r = lib.request({
|
|
311
|
+
protocol: this.isHttps ? 'https:' : 'http:',
|
|
312
|
+
host: this.host,
|
|
313
|
+
...(this.port !== undefined ? { port: this.port } : {}),
|
|
314
|
+
path: this.withClient(p),
|
|
315
|
+
method,
|
|
316
|
+
headers: all,
|
|
317
|
+
agent: this.agent,
|
|
318
|
+
}, (res) => {
|
|
319
|
+
// Listeners first: fail() destroys the request, which errors `res`.
|
|
320
|
+
res.on('error', fail);
|
|
321
|
+
res.on('aborted', () => fail(codedError('upstream connection closed', 'ECONNRESET')));
|
|
322
|
+
// Latch as soon as the status is known, even if the body later fails.
|
|
323
|
+
if (res.statusCode === 401)
|
|
324
|
+
this.loginRejected = true;
|
|
325
|
+
this.storeCookies(res.headers['set-cookie']);
|
|
326
|
+
const declared = Number(res.headers['content-length']);
|
|
327
|
+
if (Number.isFinite(declared) && declared > this.maxResponseBytes) {
|
|
328
|
+
return fail(codedError('upstream response too large', 'ERESPONSETOOLARGE'));
|
|
329
|
+
}
|
|
330
|
+
const chunks = [];
|
|
331
|
+
let total = 0;
|
|
332
|
+
res.on('data', (d) => {
|
|
333
|
+
if (settled)
|
|
334
|
+
return;
|
|
335
|
+
total += d.length;
|
|
336
|
+
if (total > this.maxResponseBytes)
|
|
337
|
+
return fail(codedError('upstream response too large', 'ERESPONSETOOLARGE'));
|
|
338
|
+
chunks.push(d);
|
|
339
|
+
});
|
|
340
|
+
res.on('end', () => {
|
|
341
|
+
if (settled)
|
|
342
|
+
return;
|
|
343
|
+
settled = true;
|
|
344
|
+
clearTimeout(timer);
|
|
345
|
+
resolve({ status: res.statusCode ?? 0, rawHeaders: res.headers, body: Buffer.concat(chunks) });
|
|
346
|
+
});
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
catch (err) {
|
|
350
|
+
fail(err instanceof Error ? err : new Error('upstream request failed'));
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
r.on('error', fail);
|
|
354
|
+
if (noBody)
|
|
355
|
+
r.end();
|
|
356
|
+
else
|
|
357
|
+
r.end(body);
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
function loginRejectedError() {
|
|
362
|
+
return codedError('SAP rejected the stored login; nothing more is sent until the link restarts.', 'ELOGINREJECTED');
|
|
363
|
+
}
|
|
364
|
+
function isRequired(v) {
|
|
365
|
+
const s = Array.isArray(v) ? v[0] : v;
|
|
366
|
+
return typeof s === 'string' && s.trim().toLowerCase() === 'required';
|
|
367
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// cspeach-cli/src/sap-link/write-log.ts
|
|
2
|
+
/**
|
|
3
|
+
* CAP phase 2 — the SAP link's write log. One JSON line per forwarded write:
|
|
4
|
+
* time, method, path (entity key included, query removed), status. Never headers,
|
|
5
|
+
* never bodies, never the login.
|
|
6
|
+
*
|
|
7
|
+
* status 0 means "outcome unknown": the write was sent but no answer came back
|
|
8
|
+
* (timeout, reset, any upstream error). SAP may or may not have applied it (ruling R16).
|
|
9
|
+
*
|
|
10
|
+
* Only the path is run through scrubSecrets (the only field that can carry request
|
|
11
|
+
* text). The row is built from the clean fields AFTER scrubbing, so a short or numeric
|
|
12
|
+
* password (e.g. "2026") can never corrupt `at`, `method` or `status`.
|
|
13
|
+
*
|
|
14
|
+
* The directory is created 0700 and the file 0600, and on POSIX both are chmod-ed to
|
|
15
|
+
* those modes after every append (best effort) so an existing loose file is tightened.
|
|
16
|
+
* On Windows modes are skipped; the file lives under the user's own profile.
|
|
17
|
+
*/
|
|
18
|
+
import { appendFileSync, chmodSync, mkdirSync, readFileSync } from 'node:fs';
|
|
19
|
+
import * as path from 'node:path';
|
|
20
|
+
import { scrubSecrets } from '../tools/fiori/preview/env.js';
|
|
21
|
+
/** One line per forwarded write: time, method, path (entity key included), status. No headers, no bodies. */
|
|
22
|
+
export function appendWriteRow(file, row, secret) {
|
|
23
|
+
const dir = path.dirname(file);
|
|
24
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
25
|
+
// Only these four fields, whatever else the caller's object carries. Query dropped defensively.
|
|
26
|
+
const clean = {
|
|
27
|
+
at: String(row.at),
|
|
28
|
+
method: String(row.method),
|
|
29
|
+
path: scrubSecrets(String(row.path).split(/[?#]/, 1)[0], secret.password, secret.username),
|
|
30
|
+
status: Number(row.status),
|
|
31
|
+
};
|
|
32
|
+
appendFileSync(file, JSON.stringify(clean) + '\n', { encoding: 'utf8', mode: 0o600 });
|
|
33
|
+
if (process.platform !== 'win32') {
|
|
34
|
+
try {
|
|
35
|
+
chmodSync(dir, 0o700);
|
|
36
|
+
}
|
|
37
|
+
catch { /* best effort */ }
|
|
38
|
+
try {
|
|
39
|
+
chmodSync(file, 0o600);
|
|
40
|
+
}
|
|
41
|
+
catch { /* best effort */ }
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
export function readWriteRows(file, sinceIso) {
|
|
45
|
+
let text = '';
|
|
46
|
+
try {
|
|
47
|
+
text = readFileSync(file, 'utf8');
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return [];
|
|
51
|
+
}
|
|
52
|
+
return text.split('\n').filter(Boolean).flatMap((l) => {
|
|
53
|
+
try {
|
|
54
|
+
return [JSON.parse(l)];
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return [];
|
|
58
|
+
}
|
|
59
|
+
}).filter((r) => !sinceIso || r.at >= sinceIso);
|
|
60
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// GENERATED FILE — bundled skills for local mode.
|
|
2
2
|
// Source: https://manifest.cspeach.dev/v1.json
|
|
3
|
-
// Generated: 2026-
|
|
3
|
+
// Generated: 2026-10-01T18:59:42.215Z
|
|
4
4
|
// Manifest signature verified at build time.
|
|
5
5
|
export const BUNDLED_SKILLS = {
|
|
6
6
|
"abap-atc-fix": {
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// cspeach-cli/src/tools/cap/_fetch-errors.ts
|
|
2
|
+
//
|
|
3
|
+
// Plain, secret-free lines for SAP fetch failures in the CAP tools
|
|
4
|
+
// (cap_metadata_import, sap_service_catalog). The raw error message is never
|
|
5
|
+
// shown: sap-client and fetch errors can carry headers, cookies or the user name.
|
|
6
|
+
import { unreachableCode } from '../../sap/unreachable.js';
|
|
7
|
+
/** A fetch error whose message is already plain and safe to show. */
|
|
8
|
+
export class PlainFetchError extends Error {
|
|
9
|
+
}
|
|
10
|
+
/** The one line for a SAP system this machine cannot reach. */
|
|
11
|
+
export function cantReachMessage(alias, host, port) {
|
|
12
|
+
return `Can't reach ${alias} (${host}:${port}) from this machine. Check network access to the SAP system.`;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Map a connection error to one plain line: the reach message when the host is
|
|
16
|
+
* unreachable, otherwise `fallback`. Never includes the raw message.
|
|
17
|
+
*/
|
|
18
|
+
export function plainSapFetchError(err, alias, host, port, fallback) {
|
|
19
|
+
return unreachableCode(err) ? cantReachMessage(alias, host, port) : fallback;
|
|
20
|
+
}
|