@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.
Files changed (63) hide show
  1. package/dist/agent/loop.js +20 -8
  2. package/dist/agent/tool-dispatch.js +6 -0
  3. package/dist/auth/auth-file.js +173 -12
  4. package/dist/classifier/client.js +55 -30
  5. package/dist/cli.js +11 -0
  6. package/dist/commands/config-set.js +13 -0
  7. package/dist/commands/config-show.js +5 -1
  8. package/dist/commands/cost.js +24 -22
  9. package/dist/commands/deploy-card.js +135 -0
  10. package/dist/commands/login.js +31 -13
  11. package/dist/commands/plan-chain.js +31 -0
  12. package/dist/commands/plan-continue.js +1 -0
  13. package/dist/commands/plan-resume.js +49 -2
  14. package/dist/cost/cost-log.js +76 -4
  15. package/dist/cost/credits-wire.js +9 -0
  16. package/dist/cost/credits.js +24 -6
  17. package/dist/cost/router-spend.js +24 -0
  18. package/dist/cost/session-cost.js +16 -2
  19. package/dist/models/server-config.js +21 -1
  20. package/dist/one-shot.js +12 -2
  21. package/dist/projects/detail-path.js +117 -0
  22. package/dist/projects/merge-cca.js +19 -0
  23. package/dist/projects/merge-upgrade.js +19 -1
  24. package/dist/projects/plan-lane.js +47 -0
  25. package/dist/projects/plan-run.js +8 -0
  26. package/dist/projects/plan-schema.js +2 -0
  27. package/dist/projects/promote.js +7 -1
  28. package/dist/projects/save-command.js +3 -2
  29. package/dist/register/records.js +7 -2
  30. package/dist/renderer/footer-line.js +10 -4
  31. package/dist/renderer/status-footer.js +5 -4
  32. package/dist/renderer/tool-labels.js +2 -1
  33. package/dist/repl.js +41 -8
  34. package/dist/router/byok-classifier.js +159 -0
  35. package/dist/router/classifier.js +130 -6
  36. package/dist/router/router-core.js +307 -0
  37. package/dist/router/router-prompt.js +121 -0
  38. package/dist/router/routing-failure.js +65 -3
  39. package/dist/skills/bundled-skills.js +45 -45
  40. package/dist/tools/_filesystem-shared.js +363 -33
  41. package/dist/tools/cap/metadata-import.js +10 -4
  42. package/dist/tools/filesystem/file-write.js +12 -39
  43. package/dist/tools/filesystem/glob.js +25 -2
  44. package/dist/tools/filesystem/grep.js +25 -2
  45. package/dist/tools/fiori/apply.js +30 -21
  46. package/dist/tools/fiori/commit-gate.js +58 -0
  47. package/dist/tools/fiori/deploy/existing-bsp.js +67 -0
  48. package/dist/tools/fiori/deploy/run.js +608 -0
  49. package/dist/tools/fiori/deploy/scope.js +8 -0
  50. package/dist/tools/fiori/deploy/yaml-read.js +236 -0
  51. package/dist/tools/fiori/deploy-config.js +24 -6
  52. package/dist/tools/fiori/fe-extend.js +6 -1
  53. package/dist/tools/fiori/fe-scaffold.js +6 -1
  54. package/dist/tools/fiori/path-params.js +78 -0
  55. package/dist/tools/fiori/scaffold.js +2 -1
  56. package/dist/tools/fiori/tools.js +170 -19
  57. package/dist/tools/local-build.js +3 -0
  58. package/dist/tools/project/_merge-shared.js +36 -1
  59. package/dist/tools/project/cca_merge.js +4 -0
  60. package/dist/tools/project/upgrade_merge_progress.js +21 -10
  61. package/dist/tools/subagent/read_agent.js +4 -2
  62. package/dist/tools/tool-loading.js +1 -1
  63. 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
- /** Resolve a user-supplied dir inside the project root, mapping the escape error. */
84
- function safeDir(ctx, userPath) {
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
- return { abs: resolveSafePath(ctx.cwd, userPath) };
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: args.ignoreCertErrors === true,
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. Run "npm run deploy-test" to validate without deploying.`,
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 = safeDir(ctx, args.appDir);
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
- 'fiori deploy takes env-var NAMES at deploy time. appDir is sandboxed to the project root.',
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
- const detailAbs = isAbsolute(scanContent.detailPath)
92
- ? scanContent.detailPath
93
- : join(ctx.cwd, scanContent.detailPath);
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(detailAbs);
108
+ sha256 = sha256OfFile(detailReal);
97
109
  }
98
- catch (e) {
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 { computeSessionCost } from '../../cost/session-cost.js';
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
- ctx.readerSpend.cost += computeSessionCost(childSession);
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
  ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cspeach/cli",
3
- "version": "1.1.24",
3
+ "version": "1.1.26",
4
4
  "description": "CSPeach — AI-assisted ABAP development CLI. Direct SAP access, 34 skills, safety gates.",
5
5
  "keywords": [
6
6
  "abap",