@cspeach/cli 1.1.22 → 1.1.24
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/loop.js +124 -0
- package/dist/agent/tool-dispatch.js +4 -2
- package/dist/classifier/client.js +27 -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/plan-continue.js +11 -1
- package/dist/commands/plan-resume.js +38 -1
- package/dist/commands/sap-link.js +52 -0
- package/dist/lane/app-chain.js +297 -0
- package/dist/lane/app-flow.js +55 -0
- package/dist/lane/card.js +148 -0
- package/dist/lane/chain-token.js +59 -0
- package/dist/lane/inputs.js +459 -0
- package/dist/lane/paths.js +21 -0
- package/dist/lane/resolve.js +109 -0
- package/dist/lane/roots.js +113 -0
- package/dist/lane/signals.js +80 -0
- package/dist/lane/store.js +232 -0
- package/dist/lane/types.js +1 -0
- package/dist/lib/shutdown-hooks.js +87 -0
- package/dist/one-shot.js +5 -0
- package/dist/projects/extract-plan.js +46 -6
- package/dist/projects/plan-lane.js +214 -0
- package/dist/projects/plan-run.js +5 -1
- package/dist/projects/plan-schema.js +12 -1
- package/dist/projects/promote-command.js +8 -2
- package/dist/projects/save-command.js +71 -25
- package/dist/projects/workspace.js +10 -0
- package/dist/renderer/tool-labels.js +6 -1
- package/dist/repl/credential-handover-gate.js +12 -6
- package/dist/repl/resume-standalone.js +3 -0
- package/dist/repl.js +27 -2
- 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/skill-catalog.js +14 -6
- package/dist/skills/bundled-skills.js +61 -54
- 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/lane/app-lane-resolve.js +329 -0
- package/dist/tools/local-build.js +15 -0
- package/dist/tools/subagent/_background-shared.js +59 -37
- package/dist/tools/subagent/background_run.js +9 -5
- package/dist/tools/tool-loading.js +2 -0
- 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
|
+
}
|
package/dist/skill-catalog.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Static skill catalog for CSPeach CLI v0.2.
|
|
3
3
|
*
|
|
4
|
-
* STALENESS RISK (spec §3.3): This constant is hardcoded against the
|
|
4
|
+
* STALENESS RISK (spec §3.3): This constant is hardcoded against the 35 skills
|
|
5
5
|
* in .claude/skills/ as of 2026-07-03 (abap-dump-analyze + abap-dump-triage
|
|
6
6
|
* merged into abap-dump 2026-06-12; abap-extend-model added 2026-06-25;
|
|
7
7
|
* abap-radar/abap-document/abap-performance/abap-cloud-check merged into
|
|
8
8
|
* abap-explain/abap-handover/abap-review 2026-06-28 per
|
|
9
|
-
* plan 2026-06-28-skill-portfolio-merge; abap-clean-core added 2026-06-29
|
|
9
|
+
* plan 2026-06-28-skill-portfolio-merge; abap-clean-core added 2026-06-29; abap-cap added
|
|
10
|
+
* 2026-09-25; abap-app added 2026-10-01). It will drift if skills are added or
|
|
10
11
|
* removed. At Plan 4b kickoff decision the build-time generation option
|
|
11
12
|
* (scripts/generate-skill-catalog.ts reads SKILL.md frontmatter) or runtime
|
|
12
13
|
* fetch (/v1/skills endpoint) will be chosen to replace this file. Until then,
|
|
@@ -109,11 +110,18 @@ export const SKILL_CATALOG = [
|
|
|
109
110
|
{
|
|
110
111
|
name: 'abap-segw',
|
|
111
112
|
category: 'Modify / Fix',
|
|
112
|
-
description: 'Implement SEGW OData DPC_EXT method bodies — reads generated MPC/DPC base classes. SEGW project itself created in transaction SEGW. For S/4
|
|
113
|
-
whenToUse: `pick this for DPC_EXT method bodies on an EXISTING SEGW project (ECC / older S/4; not /abap-rap — that's S/4
|
|
113
|
+
description: 'Implement SEGW OData DPC_EXT method bodies — reads generated MPC/DPC base classes. SEGW project itself created in transaction SEGW. For S/4 2020+ RAP services use /abap-rap.',
|
|
114
|
+
whenToUse: `pick this for DPC_EXT method bodies on an EXISTING SEGW project (ECC / older S/4; not /abap-rap — that's S/4 2020+ RAP; SEGW project + MPC/DPC must already exist)`,
|
|
114
115
|
asks: 'which project and operation? e.g. "GET_ENTITYSET for ZORDER_SRV"',
|
|
115
116
|
},
|
|
116
117
|
// ── Create / Generate ────────────────────────────────────────────────────
|
|
118
|
+
{
|
|
119
|
+
name: 'abap-app',
|
|
120
|
+
category: 'Create / Generate',
|
|
121
|
+
description: 'One entry from a spec (Word, PDF, Excel or text) to a running app — spec gaps, then the app lane (backend + UI, picked by the CLI and confirmed on the lane card), then the plan and the build.',
|
|
122
|
+
whenToUse: `pick this to build an app FROM a spec document (not /abap-rap or /abap-cap — those build one stack you already chose)`,
|
|
123
|
+
asks: 'the spec file. e.g. "@specs/inspection-log.docx"',
|
|
124
|
+
},
|
|
117
125
|
{
|
|
118
126
|
name: 'abap-generate',
|
|
119
127
|
category: 'Create / Generate',
|
|
@@ -124,8 +132,8 @@ export const SKILL_CATALOG = [
|
|
|
124
132
|
{
|
|
125
133
|
name: 'abap-rap',
|
|
126
134
|
category: 'Create / Generate',
|
|
127
|
-
description: 'Scaffold a complete RAP stack on S/4HANA
|
|
128
|
-
whenToUse: `pick this for a full RAP stack on S/4
|
|
135
|
+
description: 'Scaffold a complete RAP stack on S/4HANA 2020+ — table, CDS interface, projection, BDEF, BP class, SRVD, SRVB, DDLX, DCL. ECC/older S/4 → use /abap-segw.',
|
|
136
|
+
whenToUse: `pick this for a full RAP stack on S/4 2020+ (10 objects, table → SRVB; not /abap-segw — that's ECC/older S/4 OData; not /abap-generate — that's one object)`,
|
|
129
137
|
asks: 'the business object. e.g. "maintenance requests with draft and an approve action"',
|
|
130
138
|
},
|
|
131
139
|
{
|