@cspeach/cli 1.1.24 → 1.1.26
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 +20 -8
- package/dist/agent/tool-dispatch.js +6 -0
- package/dist/auth/auth-file.js +173 -12
- package/dist/classifier/client.js +55 -30
- package/dist/cli.js +11 -0
- package/dist/commands/config-set.js +13 -0
- package/dist/commands/config-show.js +5 -1
- package/dist/commands/cost.js +24 -22
- package/dist/commands/deploy-card.js +135 -0
- package/dist/commands/login.js +31 -13
- package/dist/commands/plan-chain.js +31 -0
- package/dist/commands/plan-continue.js +1 -0
- package/dist/commands/plan-resume.js +49 -2
- package/dist/cost/cost-log.js +76 -4
- package/dist/cost/credits-wire.js +9 -0
- package/dist/cost/credits.js +24 -6
- package/dist/cost/router-spend.js +24 -0
- package/dist/cost/session-cost.js +16 -2
- package/dist/models/server-config.js +21 -1
- package/dist/one-shot.js +12 -2
- package/dist/projects/detail-path.js +117 -0
- package/dist/projects/merge-cca.js +19 -0
- package/dist/projects/merge-upgrade.js +19 -1
- package/dist/projects/plan-lane.js +47 -0
- package/dist/projects/plan-run.js +8 -0
- package/dist/projects/plan-schema.js +2 -0
- package/dist/projects/promote.js +7 -1
- package/dist/projects/save-command.js +3 -2
- package/dist/register/records.js +7 -2
- package/dist/renderer/footer-line.js +10 -4
- package/dist/renderer/status-footer.js +5 -4
- package/dist/renderer/tool-labels.js +2 -1
- package/dist/repl.js +41 -8
- package/dist/router/byok-classifier.js +159 -0
- package/dist/router/classifier.js +130 -6
- package/dist/router/router-core.js +307 -0
- package/dist/router/router-prompt.js +121 -0
- package/dist/router/routing-failure.js +65 -3
- package/dist/skills/bundled-skills.js +45 -45
- package/dist/tools/_filesystem-shared.js +363 -33
- package/dist/tools/cap/metadata-import.js +10 -4
- package/dist/tools/filesystem/file-write.js +12 -39
- package/dist/tools/filesystem/glob.js +25 -2
- package/dist/tools/filesystem/grep.js +25 -2
- package/dist/tools/fiori/apply.js +30 -21
- package/dist/tools/fiori/commit-gate.js +58 -0
- package/dist/tools/fiori/deploy/existing-bsp.js +67 -0
- package/dist/tools/fiori/deploy/run.js +608 -0
- package/dist/tools/fiori/deploy/scope.js +8 -0
- package/dist/tools/fiori/deploy/yaml-read.js +236 -0
- package/dist/tools/fiori/deploy-config.js +24 -6
- package/dist/tools/fiori/fe-extend.js +6 -1
- package/dist/tools/fiori/fe-scaffold.js +6 -1
- package/dist/tools/fiori/path-params.js +78 -0
- package/dist/tools/fiori/scaffold.js +2 -1
- package/dist/tools/fiori/tools.js +170 -19
- package/dist/tools/local-build.js +3 -0
- package/dist/tools/project/_merge-shared.js +36 -1
- package/dist/tools/project/cca_merge.js +4 -0
- package/dist/tools/project/upgrade_merge_progress.js +21 -10
- package/dist/tools/subagent/read_agent.js +4 -2
- package/dist/tools/tool-loading.js +1 -1
- package/package.json +1 -1
|
@@ -47,12 +47,14 @@
|
|
|
47
47
|
import { promises as fs } from 'node:fs';
|
|
48
48
|
import * as path from 'node:path';
|
|
49
49
|
import { registerTool } from '../index.js';
|
|
50
|
-
import { resolveSafePath, PathOutsideRootError } from '../_filesystem-shared.js';
|
|
50
|
+
import { resolveSafePath, realPathForWrite, isDenylistedPath, PathOutsideRootError, DENYLIST_DESCRIPTION, } from '../_filesystem-shared.js';
|
|
51
51
|
import { scaffoldFreestyle } from './scaffold.js';
|
|
52
52
|
import { decodeHtmlEscapes } from './html-escapes.js';
|
|
53
53
|
import { applyEntry } from './apply.js';
|
|
54
54
|
import { listCatalog } from './catalog/index.js';
|
|
55
55
|
import { writeDeployConfig } from './deploy-config.js';
|
|
56
|
+
import { runFioriDeploy } from './deploy/run.js';
|
|
57
|
+
import { FIORI_DEPLOY_HANDOVER_SCOPE } from './deploy/scope.js';
|
|
56
58
|
import { scaffoldFioriElements } from './fe-scaffold.js';
|
|
57
59
|
import { deriveExpectedColumns } from './smoke/audit-columns.js';
|
|
58
60
|
import { PINNED_UI5_VERSION } from './ui5-version.js';
|
|
@@ -62,7 +64,7 @@ import { runDraftSmoke, draftSmokePreconditions } from './smoke/draft-smoke.js';
|
|
|
62
64
|
import { customerService, isLoopbackAppUrl, capService, isCapDevIndex } from './smoke/draft-safety.js';
|
|
63
65
|
import { verifyAndSpendApprovalId } from '../../approvals/jwt.js';
|
|
64
66
|
import { deriveFreestyleSmokeSpec, FreestyleSmokeDerivationError } from './smoke/freestyle-spec.js';
|
|
65
|
-
import { loadConfig, resolveRenderSmoke } from '../../config/loader.js';
|
|
67
|
+
import { loadConfig, resolveRenderSmoke, resolveSapTls } from '../../config/loader.js';
|
|
66
68
|
import { startPreview } from './preview/start.js';
|
|
67
69
|
import { loginNeed, readStartScript } from './preview/app-guard.js';
|
|
68
70
|
import { isCredentialHandoverApproved } from '../../repl/credential-handover-gate.js';
|
|
@@ -80,16 +82,53 @@ const SAMPLE_INDEX = sampleIndexJson;
|
|
|
80
82
|
// model can cite the source (licensing constraint A-M1). Derived from the
|
|
81
83
|
// corpus NOTICE: OpenUI5 sources, adapted, under Apache-2.0.
|
|
82
84
|
const SAMPLE_ATTRIBUTION = 'Adapted from OpenUI5 (github.com/SAP/openui5) sample sources, licensed under Apache-2.0.';
|
|
83
|
-
/**
|
|
84
|
-
function
|
|
85
|
+
/** Shared denylist refusal text for a protected app / base folder. */
|
|
86
|
+
function protectedDirError(p, ctx) {
|
|
87
|
+
const verdict = isDenylistedPath(p, ctx.cwd);
|
|
88
|
+
return verdict.blocked
|
|
89
|
+
? `"${verdict.relFromRoot}" is a protected folder (denylist: ${DENYLIST_DESCRIPTION})`
|
|
90
|
+
: null;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Sync form of safeDir for pure read helpers: containment + the shared
|
|
94
|
+
* denylist on the spelled path (which already covers NTFS stream suffixes,
|
|
95
|
+
* trailing dots/spaces and 8.3 short names). No link resolution.
|
|
96
|
+
*/
|
|
97
|
+
function safeDirSync(ctx, userPath) {
|
|
98
|
+
let abs;
|
|
85
99
|
try {
|
|
86
|
-
|
|
100
|
+
abs = resolveSafePath(ctx.cwd, userPath);
|
|
87
101
|
}
|
|
88
102
|
catch (err) {
|
|
89
103
|
if (err instanceof PathOutsideRootError)
|
|
90
104
|
return { error: err.message };
|
|
91
105
|
return { error: err instanceof Error ? err.message : String(err) };
|
|
92
106
|
}
|
|
107
|
+
const refused = protectedDirError(abs, ctx);
|
|
108
|
+
return refused ? { error: refused } : { abs };
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Resolve a user-supplied dir inside the project root, mapping the escape error.
|
|
112
|
+
* The dir must also not be a protected folder (`.git`, `.cspeach`, …) or an
|
|
113
|
+
* alias of one — checked on the spelled path AND on its real path (links,
|
|
114
|
+
* Windows 8.3 short names), through the shared denylist. The returned `abs` is
|
|
115
|
+
* still the spelled path so result strings and registry keys stay unchanged.
|
|
116
|
+
*/
|
|
117
|
+
async function safeDir(ctx, userPath) {
|
|
118
|
+
const spelled = safeDirSync(ctx, userPath);
|
|
119
|
+
if ('error' in spelled)
|
|
120
|
+
return spelled;
|
|
121
|
+
let real;
|
|
122
|
+
try {
|
|
123
|
+
real = await realPathForWrite(spelled.abs, ctx.cwd);
|
|
124
|
+
}
|
|
125
|
+
catch (err) {
|
|
126
|
+
if (err instanceof PathOutsideRootError)
|
|
127
|
+
return { error: err.message };
|
|
128
|
+
return { error: err instanceof Error ? err.message : String(err) };
|
|
129
|
+
}
|
|
130
|
+
const refused = protectedDirError(real, ctx);
|
|
131
|
+
return refused ? { error: refused } : spelled;
|
|
93
132
|
}
|
|
94
133
|
/** Relative-to-cwd, forward-slash path for stable, readable result strings. */
|
|
95
134
|
function rel(ctx, abs) {
|
|
@@ -127,11 +166,12 @@ export async function fioriScaffoldHandler(args, ctx) {
|
|
|
127
166
|
return { content: 'error: basePath is required', is_error: true };
|
|
128
167
|
if (!args.appId)
|
|
129
168
|
return { content: 'error: appId is required', is_error: true };
|
|
130
|
-
const resolved = safeDir(ctx, args.basePath);
|
|
169
|
+
const resolved = await safeDir(ctx, args.basePath);
|
|
131
170
|
if ('error' in resolved)
|
|
132
171
|
return { content: `error: ${resolved.error}`, is_error: true };
|
|
133
172
|
try {
|
|
134
173
|
await scaffoldFreestyle({
|
|
174
|
+
denyRoot: ctx.cwd,
|
|
135
175
|
basePath: resolved.abs,
|
|
136
176
|
appId: args.appId,
|
|
137
177
|
appTitle: decodeTitle(args.appTitle ?? args.appId),
|
|
@@ -160,7 +200,7 @@ export async function fioriApplyHandler(args, ctx) {
|
|
|
160
200
|
return { content: 'error: appDir is required', is_error: true };
|
|
161
201
|
if (!args.entry)
|
|
162
202
|
return { content: 'error: entry is required', is_error: true };
|
|
163
|
-
const resolved = safeDir(ctx, args.appDir);
|
|
203
|
+
const resolved = await safeDir(ctx, args.appDir);
|
|
164
204
|
if ('error' in resolved)
|
|
165
205
|
return { content: `error: ${resolved.error}`, is_error: true };
|
|
166
206
|
// Params may arrive as a parsed object (preferred) or a JSON string (mirrors
|
|
@@ -181,7 +221,7 @@ export async function fioriApplyHandler(args, ctx) {
|
|
|
181
221
|
// applyEntry preserves the engine's loud-on-conflict manifest merge — a
|
|
182
222
|
// conflicting manifest patch throws here and surfaces as is_error, never a
|
|
183
223
|
// silent last-wins overwrite.
|
|
184
|
-
applyEntry({ appDir: resolved.abs, entryName: args.entry, params });
|
|
224
|
+
applyEntry({ appDir: resolved.abs, entryName: args.entry, params, denyRoot: ctx.cwd });
|
|
185
225
|
}
|
|
186
226
|
catch (err) {
|
|
187
227
|
return { content: `error: apply failed — ${err instanceof Error ? err.message : String(err)}`, is_error: true };
|
|
@@ -199,7 +239,7 @@ export async function fioriListHandler(_args, _ctx) {
|
|
|
199
239
|
return { content: JSON.stringify(entries, null, 2) };
|
|
200
240
|
}
|
|
201
241
|
// ──────────────────────────── fiori_deploy_config ─────────────────────────
|
|
202
|
-
export async function fioriDeployConfigHandler(args, ctx) {
|
|
242
|
+
export async function fioriDeployConfigHandler(args, ctx, deps) {
|
|
203
243
|
if (!args.appDir)
|
|
204
244
|
return { content: 'error: appDir is required', is_error: true };
|
|
205
245
|
if (!args.url)
|
|
@@ -210,9 +250,26 @@ export async function fioriDeployConfigHandler(args, ctx) {
|
|
|
210
250
|
return { content: 'error: name (BSP app name) is required', is_error: true };
|
|
211
251
|
if (!args.package)
|
|
212
252
|
return { content: 'error: package is required', is_error: true };
|
|
213
|
-
const resolved = safeDir(ctx, args.appDir);
|
|
253
|
+
const resolved = await safeDir(ctx, args.appDir);
|
|
214
254
|
if ('error' in resolved)
|
|
215
255
|
return { content: `error: ${resolved.error}`, is_error: true };
|
|
256
|
+
// Review 2026-10-03 — TLS follows the alias: when the alias verifies
|
|
257
|
+
// certificates (a CA is configured, or the system trust store), the yaml
|
|
258
|
+
// never says ignoreCertErrors: true (fiori_deploy would override it anyway).
|
|
259
|
+
let ignoreCertErrors = args.ignoreCertErrors === true;
|
|
260
|
+
let tlsNote = '';
|
|
261
|
+
if (ignoreCertErrors) {
|
|
262
|
+
try {
|
|
263
|
+
const cfg = await (deps?.loadConfig ?? loadConfig)();
|
|
264
|
+
const sys = cfg.sap?.[ctx.sapAlias];
|
|
265
|
+
if (sys && resolveSapTls(sys).mode !== 'insecure') {
|
|
266
|
+
ignoreCertErrors = false;
|
|
267
|
+
tlsNote = `
|
|
268
|
+
ignoreCertErrors was written as false: alias ${ctx.sapAlias} verifies certificates${sys.ca_cert_path ? ` (CA ${sys.ca_cert_path})` : ''}.`;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
catch { /* no config — keep what was asked */ }
|
|
272
|
+
}
|
|
216
273
|
try {
|
|
217
274
|
writeDeployConfig(resolved.abs, {
|
|
218
275
|
url: args.url,
|
|
@@ -221,7 +278,7 @@ export async function fioriDeployConfigHandler(args, ctx) {
|
|
|
221
278
|
description: args.description,
|
|
222
279
|
package: args.package,
|
|
223
280
|
transport: args.transport ?? '',
|
|
224
|
-
ignoreCertErrors
|
|
281
|
+
ignoreCertErrors,
|
|
225
282
|
});
|
|
226
283
|
}
|
|
227
284
|
catch (err) {
|
|
@@ -233,9 +290,58 @@ export async function fioriDeployConfigHandler(args, ctx) {
|
|
|
233
290
|
return {
|
|
234
291
|
content: `Deploy config written: ${appDir}/ui5-deploy.yaml (BSP app ${args.name} → package ${args.package}` +
|
|
235
292
|
`${args.transport ? `, transport ${args.transport}` : ''}).\n` +
|
|
236
|
-
`package.json gained "deploy" and "deploy-test" scripts.
|
|
293
|
+
`package.json gained "deploy" and "deploy-test" scripts. Next: fiori_deploy with mode "test" (the backend validates, ` +
|
|
294
|
+
'nothing is deployed), then mode "deploy" — it uses the SAP logon CSPeach already stores; no password or variable is needed.' +
|
|
295
|
+
tlsNote,
|
|
237
296
|
};
|
|
238
297
|
}
|
|
298
|
+
/** The credential-handover scope: a fiori_preview approval for the alias does not cover a deploy. */
|
|
299
|
+
export { FIORI_DEPLOY_HANDOVER_SCOPE };
|
|
300
|
+
/**
|
|
301
|
+
* fiori_deploy — build + `fiori deploy` the app as a BSP with the SAP logon
|
|
302
|
+
* CSPeach already holds (deploy/run.ts has the full contract). The handler
|
|
303
|
+
* only sandboxes appDir, re-checks the credential-handover approval
|
|
304
|
+
* (TOCTOU, same as fiori_preview) and renders the result.
|
|
305
|
+
*/
|
|
306
|
+
export async function fioriDeployHandler(args, ctx, deps) {
|
|
307
|
+
if (!args || typeof args.appDir !== 'string' || args.appDir.trim() === '') {
|
|
308
|
+
return { content: 'error: appDir is required (the scaffolded app root, relative to the project root)', is_error: true };
|
|
309
|
+
}
|
|
310
|
+
const mode = args.mode ?? 'test';
|
|
311
|
+
if (mode !== 'test' && mode !== 'deploy') {
|
|
312
|
+
return { content: 'error: mode must be "test" (the backend validates, nothing is deployed) or "deploy"', is_error: true };
|
|
313
|
+
}
|
|
314
|
+
const resolved = await safeDir(ctx, args.appDir);
|
|
315
|
+
if ('error' in resolved)
|
|
316
|
+
return { content: `error: ${resolved.error}`, is_error: true };
|
|
317
|
+
const alias = args.alias ?? ctx.sapAlias;
|
|
318
|
+
const approved = (deps?.isLoginApproved ?? ((a) => isCredentialHandoverApproved(ctx.session?.id, a, FIORI_DEPLOY_HANDOVER_SCOPE)))(alias);
|
|
319
|
+
if (!approved) {
|
|
320
|
+
return {
|
|
321
|
+
content: `error: fiori_deploy refused (nothing was sent): handing the SAP logon for alias ${alias} to fiori deploy was not ` +
|
|
322
|
+
'approved in this session. Call fiori_deploy again so the approval prompt can be shown.',
|
|
323
|
+
is_error: true,
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
const { isLoginApproved: _gate, ...runDeps } = deps ?? {};
|
|
327
|
+
const r = await runFioriDeploy({
|
|
328
|
+
appDir: resolved.abs,
|
|
329
|
+
projectRoot: ctx.cwd,
|
|
330
|
+
mode,
|
|
331
|
+
alias,
|
|
332
|
+
...(typeof args.approval_id === 'string' ? { approvalId: args.approval_id } : {}),
|
|
333
|
+
}, { log: (line) => ctx.chunkEmitter?.emit('info', line), ...runDeps });
|
|
334
|
+
if (!r.ok) {
|
|
335
|
+
const head = r.stage === 'refused' ? 'fiori_deploy refused (nothing was sent)' : `fiori_deploy failed at the ${r.stage} step`;
|
|
336
|
+
return { content: `error: ${head}: ${r.error}`, is_error: true };
|
|
337
|
+
}
|
|
338
|
+
const lines = [r.summary, ...r.notes.map((n) => `note: ${n}`)];
|
|
339
|
+
if (r.url)
|
|
340
|
+
lines.push(`deployedUrl: ${r.url}`);
|
|
341
|
+
else
|
|
342
|
+
lines.push('Next: run fiori_deploy with mode "deploy" (needs an approval_id for the BSP).');
|
|
343
|
+
return { content: lines.join('\n') };
|
|
344
|
+
}
|
|
239
345
|
// ──────────────────────────── fiori_scaffold_fe ───────────────────────────
|
|
240
346
|
export async function fioriScaffoldFeHandler(args, ctx) {
|
|
241
347
|
if (!args.basePath)
|
|
@@ -248,11 +354,12 @@ export async function fioriScaffoldFeHandler(args, ctx) {
|
|
|
248
354
|
return { content: 'error: localAnnotations requires both technicalName and xml', is_error: true };
|
|
249
355
|
}
|
|
250
356
|
// metadata is OPTIONAL (the FE writer does not need it — spike §4); no $metadata fetch.
|
|
251
|
-
const resolved = safeDir(ctx, args.basePath);
|
|
357
|
+
const resolved = await safeDir(ctx, args.basePath);
|
|
252
358
|
if ('error' in resolved)
|
|
253
359
|
return { content: `error: ${resolved.error}`, is_error: true };
|
|
254
360
|
try {
|
|
255
361
|
await scaffoldFioriElements({
|
|
362
|
+
denyRoot: ctx.cwd,
|
|
256
363
|
basePath: resolved.abs,
|
|
257
364
|
appId: args.appId,
|
|
258
365
|
appTitle: decodeTitle(args.appTitle ?? args.appId),
|
|
@@ -284,12 +391,12 @@ export async function feExtendHandler(args, ctx) {
|
|
|
284
391
|
if (!FE_EXTEND_OPS.includes(args.op)) {
|
|
285
392
|
return { content: `error: unknown op "${args.op}" — expected one of ${FE_EXTEND_OPS.join(', ')}`, is_error: true };
|
|
286
393
|
}
|
|
287
|
-
const resolved = safeDir(ctx, args.basePath);
|
|
394
|
+
const resolved = await safeDir(ctx, args.basePath);
|
|
288
395
|
if ('error' in resolved)
|
|
289
396
|
return { content: `error: ${resolved.error}`, is_error: true };
|
|
290
397
|
const before = new Set(await listFilesUnder(ctx, resolved.abs));
|
|
291
398
|
try {
|
|
292
|
-
await feExtend({ basePath: resolved.abs, op: args.op, params: args.params ?? {} });
|
|
399
|
+
await feExtend({ basePath: resolved.abs, op: args.op, params: args.params ?? {}, denyRoot: ctx.cwd });
|
|
293
400
|
}
|
|
294
401
|
catch (err) {
|
|
295
402
|
// V4-only scope limit: fe-fpm-writer refuses non-V4-FE apps (no
|
|
@@ -369,7 +476,7 @@ export async function fioriPreviewHandler(args, ctx, deps) {
|
|
|
369
476
|
}
|
|
370
477
|
if (!args.appDir)
|
|
371
478
|
return { content: 'error: appDir is required for action "start"', is_error: true };
|
|
372
|
-
const resolved = safeDir(ctx, args.appDir);
|
|
479
|
+
const resolved = await safeDir(ctx, args.appDir);
|
|
373
480
|
if ('error' in resolved)
|
|
374
481
|
return { content: `error: ${resolved.error}`, is_error: true };
|
|
375
482
|
const result = await startPreview({
|
|
@@ -442,7 +549,7 @@ export function resolvePreviewAppUrl(args) {
|
|
|
442
549
|
* without launching a browser; the handler only orchestrates.
|
|
443
550
|
*/
|
|
444
551
|
export function resolveFreestyleSmokeSpec(args, ctx) {
|
|
445
|
-
const resolved =
|
|
552
|
+
const resolved = safeDirSync(ctx, args.appDir);
|
|
446
553
|
if ('error' in resolved)
|
|
447
554
|
return { error: resolved.error };
|
|
448
555
|
let derived;
|
|
@@ -909,7 +1016,7 @@ registerTool({
|
|
|
909
1016
|
description: 'Write the ABAP-repository (BSP) deploy configuration for a scaffolded app: ui5-deploy.yaml ' +
|
|
910
1017
|
'plus "deploy" / "deploy-test" package.json scripts. Validates BSP app name (≤15 chars, ' +
|
|
911
1018
|
'uppercase), package namespace, and transport format. Credentials are NEVER written — ' +
|
|
912
|
-
'
|
|
1019
|
+
'deploy with fiori_deploy, which uses the stored SAP logon. appDir is sandboxed to the project root.',
|
|
913
1020
|
isMutating: true,
|
|
914
1021
|
category: 'fiori',
|
|
915
1022
|
flagGated: true,
|
|
@@ -929,6 +1036,50 @@ registerTool({
|
|
|
929
1036
|
},
|
|
930
1037
|
handler: fioriDeployConfigHandler,
|
|
931
1038
|
});
|
|
1039
|
+
registerTool({
|
|
1040
|
+
name: 'fiori_deploy',
|
|
1041
|
+
description: 'Deploy a scaffolded UI5 app to the ABAP repository as a BSP application with the SAP "fiori deploy" tool, using ' +
|
|
1042
|
+
'the SAP logon CSPeach already keeps for the alias in the OS credential store — the developer types no password ' +
|
|
1043
|
+
'and sets no environment variable. Reads ui5-deploy.yaml in the app (write it first with fiori_deploy_config) and ' +
|
|
1044
|
+
'REFUSES when it is not valid (BSP name, package, transport rules) or when its target url/port/client are not the ' +
|
|
1045
|
+
'system of the SAP alias (a yaml that names the box by IP passes only when the alias host resolves to that IP); ' +
|
|
1046
|
+
'the deploy then goes to the alias url. Runs the "build" script of the app first (without the logon). The logon ' +
|
|
1047
|
+
'reaches only the fiori deploy process, by environment, and is scrubbed from all output. mode "test" (default): ' +
|
|
1048
|
+
'the backend validates the upload and deploys nothing — no write approval needed. mode "deploy": a SAP WRITE — ' +
|
|
1049
|
+
'call request_approval with a change {op:"create", object:"<BSP NAME>", type:"WAPA"} first and pass the id as ' +
|
|
1050
|
+
'approval_id (one approval, one deploy; an auto-approval does not count); refused in advisory-only mode and on production-tagged aliases. On ' +
|
|
1051
|
+
'success the tool GETs /sap/bc/ui5_ui5/sap/<bsp>/index.html over the alias connection and returns deployedUrl ' +
|
|
1052
|
+
'only when it answered 200. Asks once per session per alias before handing the logon over. Flag-gated (enabled ' +
|
|
1053
|
+
'with local_build).',
|
|
1054
|
+
isMutating: true,
|
|
1055
|
+
requiresSap: true,
|
|
1056
|
+
category: 'fiori',
|
|
1057
|
+
flagGated: true,
|
|
1058
|
+
input_schema: {
|
|
1059
|
+
type: 'object',
|
|
1060
|
+
properties: {
|
|
1061
|
+
appDir: { type: 'string', description: 'The scaffolded app root (relative to the project root, sandboxed). In a plan: phases[ui.build].work.app.dir.' },
|
|
1062
|
+
mode: { type: 'string', enum: ['test', 'deploy'], description: '"test" (default): backend validation only, nothing deployed. "deploy": the real deploy.' },
|
|
1063
|
+
alias: { type: 'string', description: 'SAP alias whose system and stored logon are used. Defaults to the session alias.' },
|
|
1064
|
+
approval_id: { type: 'string', description: 'mode "deploy" only: from request_approval, change {op:"create", object:"<BSP NAME>", type:"WAPA"}.' },
|
|
1065
|
+
},
|
|
1066
|
+
required: ['appDir'],
|
|
1067
|
+
},
|
|
1068
|
+
// The logon is handed to a child process in BOTH modes — dispatchTool asks a
|
|
1069
|
+
// human once per session per alias (scope fiori_deploy, so an earlier
|
|
1070
|
+
// fiori_preview approval does not cover it). The /abap-plan deploy card
|
|
1071
|
+
// records this approval when the developer confirms it.
|
|
1072
|
+
credentialHandover: (args, ctx) => {
|
|
1073
|
+
const alias = args?.alias ?? ctx.sapAlias;
|
|
1074
|
+
return {
|
|
1075
|
+
alias,
|
|
1076
|
+
scope: FIORI_DEPLOY_HANDOVER_SCOPE,
|
|
1077
|
+
summary: `CSPeach's stored SAP logon for alias ${alias} is passed to "fiori deploy" (${args?.appDir ?? '(no appDir given)'}) ` +
|
|
1078
|
+
'through its environment only — never on the command line, never in a file. Asked once per session for each alias.',
|
|
1079
|
+
};
|
|
1080
|
+
},
|
|
1081
|
+
handler: fioriDeployHandler,
|
|
1082
|
+
});
|
|
932
1083
|
registerTool({
|
|
933
1084
|
name: 'fiori_scaffold_fe',
|
|
934
1085
|
description: 'Scaffold a trivial Fiori Elements app shell (List Report Object Page, Worklist, Overview Page, or Analytical List Page) ' +
|
|
@@ -1233,7 +1384,7 @@ registerTool({
|
|
|
1233
1384
|
try {
|
|
1234
1385
|
if (!args.appDir)
|
|
1235
1386
|
return handover;
|
|
1236
|
-
const resolved = safeDir(ctx, args.appDir);
|
|
1387
|
+
const resolved = await safeDir(ctx, args.appDir);
|
|
1237
1388
|
if ('error' in resolved)
|
|
1238
1389
|
return handover;
|
|
1239
1390
|
const startScript = await readStartScript(resolved.abs);
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* filesystem: file_read, file_write, file_edit, glob, grep
|
|
12
12
|
* shell: shell_exec
|
|
13
13
|
* fiori: fiori_scaffold, fiori_apply, fiori_list, fiori_deploy_config,
|
|
14
|
+
* fiori_deploy (BSP deploy with the stored SAP logon — approval-gated write),
|
|
14
15
|
* fiori_scaffold_fe, fiori_fe_extend, fiori_render_smoke,
|
|
15
16
|
* fiori_preview (L1-6 — the preview server every browser check
|
|
16
17
|
* depends on), fiori_metadata_audit, fiori_draft_smoke
|
|
@@ -66,6 +67,8 @@ export const LOCAL_BUILD_TOOLS = [
|
|
|
66
67
|
'fiori_apply',
|
|
67
68
|
'fiori_list',
|
|
68
69
|
'fiori_deploy_config',
|
|
70
|
+
// Deploy with the stored SAP logon (2026-10-02) — a SAP write in mode 'deploy', approval-gated.
|
|
71
|
+
'fiori_deploy',
|
|
69
72
|
'fiori_scaffold_fe',
|
|
70
73
|
'fiori_fe_extend',
|
|
71
74
|
'fiori_render_smoke',
|
|
@@ -11,7 +11,10 @@
|
|
|
11
11
|
* (no ensureWorkspace side effects from a tool call); only bare tokens
|
|
12
12
|
* trigger the workspace lookup.
|
|
13
13
|
*/
|
|
14
|
-
import { basename, isAbsolute, join } from 'node:path';
|
|
14
|
+
import { basename, isAbsolute, join, resolve } from 'node:path';
|
|
15
|
+
import { promises as fsp } from 'node:fs';
|
|
16
|
+
import { isDenylistedPath, isInsideRoot } from '../_filesystem-shared.js';
|
|
17
|
+
import { workspaceFolderFor } from '../../lane/roots.js';
|
|
15
18
|
import { readProjectFile } from '../../projects/status.js';
|
|
16
19
|
import { resolveAtTokenAsync, formatProjectFileList } from '../../projects/workspace.js';
|
|
17
20
|
export function labelFor(path) {
|
|
@@ -42,8 +45,40 @@ export async function resolveEnvelopeToken(rawToken, cwd) {
|
|
|
42
45
|
* Resolve + read + type-check one envelope input. readProjectFile
|
|
43
46
|
* already validates the envelope schema and throws readable errors.
|
|
44
47
|
*/
|
|
48
|
+
/**
|
|
49
|
+
* SEC (review round 1): the merge tools must not be a file-existence / JSON
|
|
50
|
+
* oracle for arbitrary paths (`~/.cspeach/auth.json`, `../../other/x`). An
|
|
51
|
+
* input is read only when its REAL path is
|
|
52
|
+
* - inside the project folder (cwd) and not a protected path, or
|
|
53
|
+
* - inside the resolved workspace folder (which may sit outside cwd, e.g. a
|
|
54
|
+
* git-root `.cspeach/` or a configured `workspace_folder`) AND named
|
|
55
|
+
* `*.cspeach.json` — a saved envelope, never a secret.
|
|
56
|
+
* The spelled path is checked BEFORE the disk is touched, so an outside path
|
|
57
|
+
* gets the same answer whether it exists or not.
|
|
58
|
+
*/
|
|
59
|
+
async function assertEnvelopeInputAllowed(rawToken, filePath, cwd) {
|
|
60
|
+
const refused = new Error(`'${rawToken}' is outside the project and its workspace folder, or a protected path — `
|
|
61
|
+
+ 'merge inputs must be saved .cspeach.json envelopes inside this project or its workspace folder.');
|
|
62
|
+
const abs = resolve(cwd, filePath);
|
|
63
|
+
const workspace = await workspaceFolderFor(cwd);
|
|
64
|
+
const isEnvelope = /\.cspeach\.json$/i.test(basename(abs));
|
|
65
|
+
const allowedAt = (p) => (isInsideRoot(p, cwd) && !isDenylistedPath(p, cwd).blocked)
|
|
66
|
+
|| (isEnvelope && /\.cspeach\.json$/i.test(basename(p)) && isInsideRoot(p, workspace));
|
|
67
|
+
if (!allowedAt(abs))
|
|
68
|
+
throw refused;
|
|
69
|
+
let real;
|
|
70
|
+
try {
|
|
71
|
+
real = await fsp.realpath(abs);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
throw new Error(`cannot read '${rawToken}': file not found`);
|
|
75
|
+
}
|
|
76
|
+
if (!allowedAt(real))
|
|
77
|
+
throw refused;
|
|
78
|
+
}
|
|
45
79
|
export async function loadEnvelope(rawToken, cwd, expectedType) {
|
|
46
80
|
const path = await resolveEnvelopeToken(rawToken, cwd);
|
|
81
|
+
await assertEnvelopeInputAllowed(rawToken, path, cwd);
|
|
47
82
|
let envelope;
|
|
48
83
|
try {
|
|
49
84
|
envelope = readProjectFile(path);
|
|
@@ -29,6 +29,7 @@ import { dirname, isAbsolute, join } from 'node:path';
|
|
|
29
29
|
import { registerTool } from '../index.js';
|
|
30
30
|
import { mergeCcaAssessments, renderCcaMergeManifest, } from '../../projects/merge-cca.js';
|
|
31
31
|
import { loadEnvelope, MANIFEST_PASTE_INSTRUCTIONS } from './_merge-shared.js';
|
|
32
|
+
import { assertWritable } from '../_filesystem-shared.js';
|
|
32
33
|
export async function ccaMergeHandler(args, ctx) {
|
|
33
34
|
if (!Array.isArray(args.inputs) || args.inputs.some((i) => typeof i !== 'string')) {
|
|
34
35
|
return { content: 'error: inputs must be an array of envelope paths / @-tokens.', is_error: true };
|
|
@@ -99,6 +100,9 @@ export async function ccaMergeHandler(args, ctx) {
|
|
|
99
100
|
let detailWritten = true;
|
|
100
101
|
const detailAbs = isAbsolute(merged.detailPath) ? merged.detailPath : join(ctx.cwd, merged.detailPath);
|
|
101
102
|
try {
|
|
103
|
+
// SEC (review round 2): the real landing path must stay in the project and
|
|
104
|
+
// not be protected (a junctioned .cspeach/<domain> -> .git is refused).
|
|
105
|
+
await assertWritable(detailAbs, ctx.cwd);
|
|
102
106
|
mkdirSync(dirname(detailAbs), { recursive: true });
|
|
103
107
|
writeFileSync(detailAbs, JSON.stringify({
|
|
104
108
|
generatedAt: new Date().toISOString(),
|
|
@@ -31,6 +31,8 @@ import { dirname, isAbsolute, join } from 'node:path';
|
|
|
31
31
|
import { registerTool } from '../index.js';
|
|
32
32
|
import { mergeUpgradeProgress, renderUpgradeMergeManifest, } from '../../projects/merge-upgrade.js';
|
|
33
33
|
import { loadEnvelope, MANIFEST_PASTE_INSTRUCTIONS } from './_merge-shared.js';
|
|
34
|
+
import { assertWritable } from '../_filesystem-shared.js';
|
|
35
|
+
import { resolveDetailFile } from '../../projects/detail-path.js';
|
|
34
36
|
function sha256OfFile(path) {
|
|
35
37
|
return createHash('sha256').update(readFileSync(path)).digest('hex');
|
|
36
38
|
}
|
|
@@ -88,19 +90,25 @@ export async function upgradeMergeProgressHandler(args, ctx) {
|
|
|
88
90
|
const scanContent = scan.envelope.content;
|
|
89
91
|
// baseline_sha256 is the sha256 of the baseline DETAIL JSON body (the
|
|
90
92
|
// upgrade-fix manifest contract), not of the envelope.
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
93
|
+
// SEC (review round 2): detailPath comes from INSIDE the scan envelope
|
|
94
|
+
// (model-writable). It is resolved only to a real file in a
|
|
95
|
+
// `.cspeach/upgrades/` working folder of this project or its workspace,
|
|
96
|
+
// never a protected path. Refused and missing get ONE message that never
|
|
97
|
+
// echoes the path — no existence oracle, no hash of an arbitrary file.
|
|
98
|
+
const unavailable = {
|
|
99
|
+
content: "error: the scan's baseline detail file is not available. The merge needs the scan's detail JSON "
|
|
100
|
+
+ 'on this machine, saved under .cspeach/upgrades/ in this project or its workspace folder.',
|
|
101
|
+
is_error: true,
|
|
102
|
+
};
|
|
103
|
+
const detailReal = await resolveDetailFile(scanContent.detailPath, 'upgrades', ctx.cwd);
|
|
104
|
+
if (!detailReal)
|
|
105
|
+
return unavailable;
|
|
94
106
|
let sha256;
|
|
95
107
|
try {
|
|
96
|
-
sha256 = sha256OfFile(
|
|
108
|
+
sha256 = sha256OfFile(detailReal);
|
|
97
109
|
}
|
|
98
|
-
catch
|
|
99
|
-
return
|
|
100
|
-
content: `error: cannot hash baseline detail file '${scanContent.detailPath}' (resolved: ${detailAbs}): `
|
|
101
|
-
+ `${e instanceof Error ? e.message : String(e)}. The merge needs the scan's detail JSON on this machine.`,
|
|
102
|
-
is_error: true,
|
|
103
|
-
};
|
|
110
|
+
catch {
|
|
111
|
+
return unavailable;
|
|
104
112
|
}
|
|
105
113
|
baseline = {
|
|
106
114
|
sha256,
|
|
@@ -143,6 +151,9 @@ export async function upgradeMergeProgressHandler(args, ctx) {
|
|
|
143
151
|
let detailWritten = true;
|
|
144
152
|
const mergedDetailAbs = isAbsolute(merged.detailPath) ? merged.detailPath : join(ctx.cwd, merged.detailPath);
|
|
145
153
|
try {
|
|
154
|
+
// SEC (review round 2): the real landing path must stay in the project and
|
|
155
|
+
// not be protected (a junctioned .cspeach/<domain> -> .git is refused).
|
|
156
|
+
await assertWritable(mergedDetailAbs, ctx.cwd);
|
|
146
157
|
mkdirSync(dirname(mergedDetailAbs), { recursive: true });
|
|
147
158
|
writeFileSync(mergedDetailAbs, JSON.stringify({
|
|
148
159
|
generatedAt: new Date().toISOString(),
|
|
@@ -22,7 +22,7 @@ import { listTools, registerTool, toolsForContext } from '../index.js';
|
|
|
22
22
|
import { runTurn } from '../../agent/loop.js';
|
|
23
23
|
import { collectTurnAssistantText } from '../../agent/turn-assistant-text.js';
|
|
24
24
|
import { newSession } from '../../session/schema.js';
|
|
25
|
-
import {
|
|
25
|
+
import { computeSessionModelCost } from '../../cost/session-cost.js';
|
|
26
26
|
import { sessionIdOf, spillIfLarge } from '../result-spill.js';
|
|
27
27
|
import { INTERRUPTED_LINE, READER_SKILL, READER_SYSTEM_PROMPT, READER_TOOLS, hasReaderReadTools } from './reader-prompt.js';
|
|
28
28
|
export { INTERRUPTED_LINE, READER_SKILL, READER_SYSTEM_PROMPT, READER_TOOLS, hasReaderReadTools };
|
|
@@ -118,7 +118,9 @@ export async function readAgentHandler(args, ctx) {
|
|
|
118
118
|
finally {
|
|
119
119
|
// Spend counts whether or not the reader finished.
|
|
120
120
|
if (ctx.readerSpend) {
|
|
121
|
-
|
|
121
|
+
// The child's own model cost only — the BYOK router spend is already in
|
|
122
|
+
// the session total once (review S1, 2026-10-02).
|
|
123
|
+
ctx.readerSpend.cost += computeSessionModelCost(childSession);
|
|
122
124
|
ctx.readerSpend.calls += 1;
|
|
123
125
|
}
|
|
124
126
|
}
|
|
@@ -35,7 +35,7 @@ export const CORE_TOOLS = Object.freeze([
|
|
|
35
35
|
'sap_create_object',
|
|
36
36
|
]);
|
|
37
37
|
const FIORI_FAMILY = [
|
|
38
|
-
'fiori_apply', 'fiori_deploy_config', 'fiori_draft_smoke', 'fiori_fe_extend',
|
|
38
|
+
'fiori_apply', 'fiori_deploy', 'fiori_deploy_config', 'fiori_draft_smoke', 'fiori_fe_extend',
|
|
39
39
|
'fiori_list', 'fiori_metadata_audit', 'fiori_preview', 'fiori_render_smoke',
|
|
40
40
|
'fiori_sample_get', 'fiori_sample_search', 'fiori_scaffold', 'fiori_scaffold_fe',
|
|
41
41
|
];
|