@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.
Files changed (54) hide show
  1. package/dist/agent/loop.js +124 -0
  2. package/dist/agent/tool-dispatch.js +4 -2
  3. package/dist/classifier/client.js +27 -2
  4. package/dist/cli.js +13 -0
  5. package/dist/commands/plan-audit-evidence.js +61 -4
  6. package/dist/commands/plan-audit.js +13 -1
  7. package/dist/commands/plan-continue.js +11 -1
  8. package/dist/commands/plan-resume.js +38 -1
  9. package/dist/commands/sap-link.js +52 -0
  10. package/dist/lane/app-chain.js +297 -0
  11. package/dist/lane/app-flow.js +55 -0
  12. package/dist/lane/card.js +148 -0
  13. package/dist/lane/chain-token.js +59 -0
  14. package/dist/lane/inputs.js +459 -0
  15. package/dist/lane/paths.js +21 -0
  16. package/dist/lane/resolve.js +109 -0
  17. package/dist/lane/roots.js +113 -0
  18. package/dist/lane/signals.js +80 -0
  19. package/dist/lane/store.js +232 -0
  20. package/dist/lane/types.js +1 -0
  21. package/dist/lib/shutdown-hooks.js +87 -0
  22. package/dist/one-shot.js +5 -0
  23. package/dist/projects/extract-plan.js +46 -6
  24. package/dist/projects/plan-lane.js +214 -0
  25. package/dist/projects/plan-run.js +5 -1
  26. package/dist/projects/plan-schema.js +12 -1
  27. package/dist/projects/promote-command.js +8 -2
  28. package/dist/projects/save-command.js +71 -25
  29. package/dist/projects/workspace.js +10 -0
  30. package/dist/renderer/tool-labels.js +6 -1
  31. package/dist/repl/credential-handover-gate.js +12 -6
  32. package/dist/repl/resume-standalone.js +3 -0
  33. package/dist/repl.js +27 -2
  34. package/dist/sap/connection-manager.js +30 -24
  35. package/dist/sap-link/policy.js +185 -0
  36. package/dist/sap-link/project.js +163 -0
  37. package/dist/sap-link/registry.js +259 -0
  38. package/dist/sap-link/server.js +351 -0
  39. package/dist/sap-link/upstream.js +367 -0
  40. package/dist/sap-link/write-log.js +60 -0
  41. package/dist/skill-catalog.js +14 -6
  42. package/dist/skills/bundled-skills.js +61 -54
  43. package/dist/tools/cap/_fetch-errors.js +20 -0
  44. package/dist/tools/cap/link-smoke.js +703 -0
  45. package/dist/tools/cap/metadata-import.js +325 -0
  46. package/dist/tools/cap/sap-link-tool.js +133 -0
  47. package/dist/tools/cap/service-catalog.js +248 -0
  48. package/dist/tools/fiori/preview/registry.js +30 -24
  49. package/dist/tools/lane/app-lane-resolve.js +329 -0
  50. package/dist/tools/local-build.js +15 -0
  51. package/dist/tools/subagent/_background-shared.js +59 -37
  52. package/dist/tools/subagent/background_run.js +9 -5
  53. package/dist/tools/tool-loading.js +2 -0
  54. package/package.json +121 -121
@@ -0,0 +1,351 @@
1
+ // cspeach-cli/src/sap-link/server.ts
2
+ /**
3
+ * CAP phase 2 — the SAP link (spec §6.3): a loopback forwarder INSIDE the CSPeach
4
+ * process. The generated app's [s4] profile points at it; it adds the keychain
5
+ * login (UpstreamSession), refuses anything outside the imported services, and
6
+ * passes writes only while the alias role is exactly 'dev' — read fresh per
7
+ * request, so a config change takes effect at once.
8
+ *
9
+ * Hardening choices (Task 7, ruling R1 + review carry-overs):
10
+ * - Browser / DNS-rebinding defence, checked BEFORE the role, the policy and the body:
11
+ * exactly one Host header, equal to 127.0.0.1:<listenPort> or localhost:<listenPort>
12
+ * (case-insensitive); an Origin header (if any) must be http://127.0.0.1[:port] or
13
+ * http://localhost[:port] (`null` refused); Sec-Fetch-Site (if any) must be
14
+ * same-origin or none. CAP's server-side calls send none of these browser headers.
15
+ * - Request body capped at 10 MiB: a larger content-length is refused up front, a
16
+ * streamed body is dropped the moment it passes the cap (413, connection closed).
17
+ * - Only the policy's output is forwarded (method, normalised path, service prefix,
18
+ * write flag), never req.method / req.url. GET and HEAD never carry a body upstream.
19
+ * - The role is read fresh on every request. If it cannot be read: 503, nothing sent.
20
+ * - Errors are mapped to fixed plain messages. No error text, stack, header or body
21
+ * from upstream ever reaches the app. Upstream 401 → the login message; after it the
22
+ * upstream refuses every send (ELOGINREJECTED) until the link restarts, same message.
23
+ * - Write log: time, method, path without query, status. A failed log append does not
24
+ * break the response (the write already happened upstream).
25
+ * - Server timeouts are set; close() drops all connections and is idempotent.
26
+ * - Nothing here logs requests, bodies, headers or the password.
27
+ *
28
+ * Fix round 1 (security review):
29
+ * - R16: a write whose upstream call throws is still logged, with status 0
30
+ * ("outcome unknown"): SAP may already have applied it.
31
+ * - For a write the role is read AGAIN after the body arrives; anything but exactly
32
+ * 'dev' is a 403, so a role flip during a slow upload still stops the write.
33
+ * - getRole() is raced against a timeout (5 s default, `roleTimeoutMs`); on timeout 503.
34
+ * - Every answer carries `X-Content-Type-Options: nosniff`; pass-through answers also
35
+ * carry `Content-Security-Policy: sandbox; default-src 'none'` (upstream values for
36
+ * both are replaced), so SAP HTML can never run as a page on this origin.
37
+ * - Only upstream statuses 200-599 are passed through; anything else is the generic 502.
38
+ * - plain() resets statusMessage and any half-set headers, so a fallback 502 after a
39
+ * failed pass-through never says "OK".
40
+ */
41
+ import * as http from 'node:http';
42
+ import { decideRequest, writesBlockedMessage } from './policy.js';
43
+ import { appendWriteRow } from './write-log.js';
44
+ export class SapLinkStartError extends Error {
45
+ code;
46
+ constructor(code, message) {
47
+ super(message);
48
+ this.code = code;
49
+ }
50
+ }
51
+ const REACH_CODES = new Set(['ECONNREFUSED', 'ENOTFOUND', 'ETIMEDOUT', 'EHOSTUNREACH', 'ENETUNREACH', 'ECONNRESET', 'EAI_AGAIN']);
52
+ export const MAX_REQUEST_BODY_BYTES = 10 * 1024 * 1024;
53
+ const DISCARD_BUDGET_BYTES = 4 * 1024 * 1024;
54
+ const DISCARD_GRACE_MS = 2_000;
55
+ const HEADERS_TIMEOUT_MS = 30_000;
56
+ const REQUEST_TIMEOUT_MS = 120_000;
57
+ const DEFAULT_ROLE_TIMEOUT_MS = 5_000;
58
+ const LOOPBACK_ORIGIN = /^http:\/\/(127\.0\.0\.1|localhost)(:\d{1,5})?$/i;
59
+ /** Framing headers we set ourselves; never copied from the upstream answer. */
60
+ const FRAMING_OUT = new Set(['content-length', 'transfer-encoding', 'connection', 'keep-alive']);
61
+ /** Security headers we set ourselves on pass-through answers; upstream values are replaced. */
62
+ const OWN_OUT = new Set(['x-content-type-options', 'content-security-policy']);
63
+ const SETTINGS_UNREADABLE = 'Could not read the SAP system settings.';
64
+ class RoleUnavailable extends Error {
65
+ }
66
+ /** getRole() with a deadline. Any throw or timeout becomes RoleUnavailable. */
67
+ async function readRole(getRole, timeoutMs) {
68
+ let timer;
69
+ try {
70
+ return await Promise.race([
71
+ Promise.resolve().then(getRole),
72
+ new Promise((_, reject) => { timer = setTimeout(() => reject(new RoleUnavailable()), timeoutMs); }),
73
+ ]);
74
+ }
75
+ catch {
76
+ throw new RoleUnavailable();
77
+ }
78
+ finally {
79
+ clearTimeout(timer);
80
+ }
81
+ }
82
+ export function reachMessage(alias, host, port) {
83
+ return `Can't reach ${alias} (${host}:${port}) from this machine. Check network access to the SAP system.`;
84
+ }
85
+ class BodyTooLarge extends Error {
86
+ }
87
+ class BodyAborted extends Error {
88
+ }
89
+ function plain(res, status, text, close = false) {
90
+ if (res.headersSent) {
91
+ res.destroy();
92
+ return;
93
+ }
94
+ // A failed pass-through writeHead may have left a status text ("OK") or headers behind.
95
+ for (const name of res.getHeaderNames())
96
+ res.removeHeader(name);
97
+ res.statusMessage = http.STATUS_CODES[status] ?? 'Error';
98
+ const headers = {
99
+ 'content-type': 'text/plain; charset=utf-8',
100
+ 'content-length': Buffer.byteLength(text),
101
+ 'x-content-type-options': 'nosniff',
102
+ };
103
+ if (close)
104
+ headers.connection = 'close';
105
+ res.writeHead(status, res.statusMessage, headers);
106
+ res.end(text);
107
+ }
108
+ /** All values of one header from rawHeaders (case-insensitive), duplicates kept. */
109
+ function rawValues(req, name) {
110
+ const out = [];
111
+ for (let i = 0; i + 1 < req.rawHeaders.length; i += 2) {
112
+ if (req.rawHeaders[i].toLowerCase() === name)
113
+ out.push(req.rawHeaders[i + 1]);
114
+ }
115
+ return out;
116
+ }
117
+ /** R1: null when the request may go on, else the refusal. */
118
+ function browserGuard(req, listenPort) {
119
+ const hosts = rawValues(req, 'host');
120
+ const want = [`127.0.0.1:${listenPort}`, `localhost:${listenPort}`];
121
+ if (hosts.length !== 1 || !want.includes(hosts[0].trim().toLowerCase())) {
122
+ return { status: 403, message: `The SAP link only answers requests addressed to 127.0.0.1:${listenPort}.` };
123
+ }
124
+ const origins = rawValues(req, 'origin');
125
+ if (origins.length > 1 || (origins.length === 1 && !LOOPBACK_ORIGIN.test(origins[0].trim()))) {
126
+ return { status: 403, message: 'The SAP link does not answer browser requests from other sites.' };
127
+ }
128
+ const sites = rawValues(req, 'sec-fetch-site');
129
+ if (sites.length > 1 || (sites.length === 1 && !['same-origin', 'none'].includes(sites[0].trim().toLowerCase()))) {
130
+ return { status: 403, message: 'The SAP link does not answer browser requests from other sites.' };
131
+ }
132
+ return null;
133
+ }
134
+ /** Reads the body up to `limit` bytes. Past the limit it stops reading and rejects. */
135
+ function readBody(req, limit) {
136
+ return new Promise((resolve, reject) => {
137
+ const chunks = [];
138
+ let total = 0;
139
+ let done = false;
140
+ const finish = (err) => {
141
+ if (done)
142
+ return;
143
+ done = true;
144
+ req.off('data', onData);
145
+ req.off('end', onEnd);
146
+ req.off('error', onError);
147
+ req.off('close', onClose);
148
+ if (err) {
149
+ chunks.length = 0;
150
+ reject(err);
151
+ }
152
+ else
153
+ resolve(Buffer.concat(chunks));
154
+ };
155
+ const onData = (c) => {
156
+ total += c.length;
157
+ if (total > limit)
158
+ return finish(new BodyTooLarge());
159
+ chunks.push(c);
160
+ };
161
+ const onEnd = () => finish();
162
+ const onError = () => finish(new BodyAborted());
163
+ const onClose = () => { if (!req.complete)
164
+ finish(new BodyAborted()); };
165
+ req.on('data', onData);
166
+ req.on('end', onEnd);
167
+ req.on('error', onError);
168
+ req.on('close', onClose);
169
+ });
170
+ }
171
+ /**
172
+ * 413 and stop. Nothing more is buffered. So the client can still read the 413, up to
173
+ * DISCARD_BUDGET_BYTES more are thrown away for at most DISCARD_GRACE_MS; past either
174
+ * limit the socket is dropped (the client may then see a reset instead of the 413).
175
+ */
176
+ function tooLarge(req, res) {
177
+ plain(res, 413, `The request body is larger than the SAP link accepts (${MAX_REQUEST_BODY_BYTES / (1024 * 1024)} MiB).`);
178
+ const socket = req.socket;
179
+ let discarded = 0;
180
+ const drop = () => { clearTimeout(timer); req.off('data', onData); socket.destroy(); };
181
+ const onData = (c) => { discarded += c.length; if (discarded > DISCARD_BUDGET_BYTES)
182
+ drop(); };
183
+ const timer = setTimeout(drop, DISCARD_GRACE_MS);
184
+ timer.unref();
185
+ socket.once('close', () => clearTimeout(timer));
186
+ req.on('data', onData);
187
+ req.resume();
188
+ }
189
+ export async function startSapLink(opts) {
190
+ let listenPort = opts.listenPort;
191
+ const roleTimeoutMs = opts.roleTimeoutMs ?? DEFAULT_ROLE_TIMEOUT_MS;
192
+ const handle = async (req, res) => {
193
+ // 1. R1 — browser / DNS-rebinding defence, before anything else.
194
+ const refused = browserGuard(req, listenPort);
195
+ if (refused)
196
+ return plain(res, refused.status, refused.message, true);
197
+ const declared = req.headers['content-length'];
198
+ if (declared !== undefined && !(Number(declared) <= MAX_REQUEST_BODY_BYTES))
199
+ return tooLarge(req, res);
200
+ // 2. Role, fresh on every request. Fail closed if it cannot be read.
201
+ let role;
202
+ try {
203
+ role = await readRole(opts.getRole, roleTimeoutMs);
204
+ }
205
+ catch {
206
+ return plain(res, 503, SETTINGS_UNREADABLE);
207
+ }
208
+ // 3. Policy. Only its output is forwarded from here on.
209
+ const d = decideRequest({
210
+ method: req.method ?? 'GET', rawUrl: req.url ?? '/',
211
+ headers: req.headers,
212
+ allowedPrefixes: opts.allowedPrefixes, alias: opts.alias, role,
213
+ });
214
+ if (!d.allow)
215
+ return plain(res, d.status, d.message);
216
+ // 4. Body (capped). GET and HEAD never carry one upstream.
217
+ let body;
218
+ try {
219
+ body = await readBody(req, MAX_REQUEST_BODY_BYTES);
220
+ }
221
+ catch (e) {
222
+ if (e instanceof BodyTooLarge)
223
+ return tooLarge(req, res);
224
+ res.destroy();
225
+ return;
226
+ }
227
+ // A write: read the role again now the body is in. A flip during the upload wins.
228
+ if (d.write) {
229
+ let again;
230
+ try {
231
+ again = await readRole(opts.getRole, roleTimeoutMs);
232
+ }
233
+ catch {
234
+ return plain(res, 503, SETTINGS_UNREADABLE);
235
+ }
236
+ if (again !== 'dev')
237
+ return plain(res, 403, writesBlockedMessage(opts.alias, again));
238
+ }
239
+ const noBody = d.method === 'GET' || d.method === 'HEAD';
240
+ if (noBody)
241
+ body = Buffer.alloc(0);
242
+ const logWrite = (status) => {
243
+ try {
244
+ appendWriteRow(opts.writeLogFile, { at: new Date().toISOString(), method: d.method, path: d.upstreamPath.split('?')[0], status }, opts.secret);
245
+ }
246
+ catch {
247
+ /* intentionally silent: no logging of anything request-related */
248
+ }
249
+ };
250
+ const flat = {};
251
+ for (const [k, v] of Object.entries(req.headers))
252
+ if (v !== undefined)
253
+ flat[k] = Array.isArray(v) ? v.join(', ') : v;
254
+ // 5. Upstream. Any throw maps to a fixed plain message. The first 401 and the
255
+ // upstream's latched refusal (ELOGINREJECTED, nothing sent) share one message.
256
+ const loginRejected = () => `${opts.alias} rejected the stored login for ${opts.secret.username}. Update the stored SAP password for this alias, then start the SAP link again (sap_link start).`;
257
+ let up;
258
+ try {
259
+ up = await opts.upstream.send({ method: d.method, path: d.upstreamPath, servicePrefix: d.servicePrefix, headers: flat, body, write: d.write });
260
+ }
261
+ catch (e) {
262
+ // R16: the write may have reached SAP. Log it as status 0 = outcome unknown.
263
+ if (d.write)
264
+ logWrite(0);
265
+ const code = e && typeof e === 'object' && typeof e.code === 'string' ? e.code : '';
266
+ if (REACH_CODES.has(code))
267
+ return plain(res, 502, reachMessage(opts.alias, opts.host, opts.port));
268
+ if (code === 'ELOGINREJECTED')
269
+ return plain(res, 502, loginRejected());
270
+ if (code === 'ERESPONSETOOLARGE')
271
+ return plain(res, 502, `The response from ${opts.alias} was too large.`);
272
+ return plain(res, 502, `The SAP link could not complete the request to ${opts.alias}.`);
273
+ }
274
+ // 6. Write log. The write already happened: a failed append must not hide the answer.
275
+ if (d.write)
276
+ logWrite(up.status);
277
+ // 7. Answer.
278
+ if (!Number.isInteger(up.status) || up.status < 200 || up.status > 599) {
279
+ return plain(res, 502, `The SAP link could not complete the request to ${opts.alias}.`);
280
+ }
281
+ if (up.status === 401)
282
+ return plain(res, 502, loginRejected());
283
+ const headers = {};
284
+ for (const [k, v] of Object.entries(up.headers ?? {})) {
285
+ const lower = k.toLowerCase();
286
+ if (!FRAMING_OUT.has(lower) && !OWN_OUT.has(lower))
287
+ headers[k] = v;
288
+ }
289
+ headers['x-content-type-options'] = 'nosniff';
290
+ headers['content-security-policy'] = "sandbox; default-src 'none'";
291
+ const isHead = d.method === 'HEAD' || req.method === 'HEAD';
292
+ const bodyless = isHead || up.status === 204 || up.status === 304;
293
+ if (!bodyless)
294
+ headers['content-length'] = up.body.length;
295
+ try {
296
+ res.writeHead(up.status, headers);
297
+ }
298
+ catch {
299
+ // Invalid status or header value from upstream: answer plainly instead.
300
+ return plain(res, 502, `The SAP link could not complete the request to ${opts.alias}.`);
301
+ }
302
+ if (bodyless)
303
+ res.end();
304
+ else
305
+ res.end(up.body);
306
+ };
307
+ const server = http.createServer({ requireHostHeader: false }, (req, res) => {
308
+ handle(req, res).catch(() => {
309
+ try {
310
+ plain(res, 502, `The SAP link could not complete the request to ${opts.alias}.`);
311
+ }
312
+ catch {
313
+ res.destroy();
314
+ }
315
+ });
316
+ });
317
+ server.headersTimeout = HEADERS_TIMEOUT_MS;
318
+ server.requestTimeout = REQUEST_TIMEOUT_MS;
319
+ await new Promise((resolve, reject) => {
320
+ const onError = (e) => {
321
+ reject(e.code === 'EADDRINUSE'
322
+ ? new SapLinkStartError('port-taken', `Port ${opts.listenPort} is in use. Stop the program using it, or change the port in package.json ([s4] credentials.url) and start the SAP link again.`)
323
+ : new SapLinkStartError('other', `The SAP link could not start: ${e.message}`));
324
+ };
325
+ server.once('error', onError);
326
+ try {
327
+ server.listen(opts.listenPort, '127.0.0.1', () => { server.off('error', onError); resolve(); });
328
+ }
329
+ catch (e) {
330
+ server.off('error', onError);
331
+ onError(e);
332
+ }
333
+ });
334
+ // After start, a server error must never crash CSPeach. Nothing is logged.
335
+ server.on('error', () => { });
336
+ listenPort = server.address().port;
337
+ let closing;
338
+ return {
339
+ url: `http://127.0.0.1:${listenPort}`,
340
+ listenPort,
341
+ alias: opts.alias,
342
+ writeLogFile: opts.writeLogFile,
343
+ close: () => {
344
+ closing ??= new Promise((r) => {
345
+ server.close(() => r());
346
+ server.closeAllConnections();
347
+ });
348
+ return closing;
349
+ },
350
+ };
351
+ }