Add client steps implementation to support existing functionalities#6966
Add client steps implementation to support existing functionalities#6966alfonso-noriega wants to merge 3 commits intomainfrom
Conversation
This stack of pull requests is managed by Graphite. Learn more about stacking. |
|
We detected some changes at Caution DO NOT create changesets for features which you do not wish to be included in the public changelog of the next CLI release. |
Coverage report
Test suite run success3951 tests passing in 1518 suites. Report generated by 🧪jest coverage report action from b32f3a9 |
9c2e1fd to
7e48497
Compare
16768f3 to
59f0139
Compare
7e48497 to
159bb73
Compare
159bb73 to
e6f3577
Compare
| config.inclusions.map(async (entry) => { | ||
| if (entry.type === 'pattern') { | ||
| const sourceDir = entry.baseDir ? joinPath(extension.directory, entry.baseDir) : extension.directory | ||
| const destinationDir = entry.destination ? joinPath(outputDir, entry.destination) : outputDir |
There was a problem hiding this comment.
Path traversal: include_assets can write outside outputDir
joinPath(outputDir, entry.destination) is used directly with user-controlled config (destination). If destination is absolute (/etc) or contains ../.., it can escape outputDir and overwrite arbitrary files on the machine running the build (CI runner, developer machine). Same class of issue exists for entry.baseDir (controls sourceDir), copySourceEntry(...destination) (static destination), copyConfigKeyEntry(...destination) (configKey destination), and copyConfigKeyEntry where the config value itself can be ../../something.
Because build manifests/config are extension-provided, a malicious extension (or compromised repo) could cause the CLI to overwrite sensitive files or poison other build artifacts.
|
🤖 Code Review · #projects-dev-ai for questions ✅ Complete - 2 findings 📋 History❌ Failed → ✅ 1 findings → ❌ Failed → ❌ Failed → ✅ 1 findings → ✅ 2 findings |
bbc0817 to
7b68eb7
Compare
e6f3577 to
3f28156
Compare
3f28156 to
44034c5
Compare
f9f9e63 to
59dcaa7
Compare
44034c5 to
094a68c
Compare
59dcaa7 to
fb4c01d
Compare
0478573 to
a8b39c0
Compare
dcbe359 to
e5c297b
Compare
ba771b8 to
ac63c6b
Compare
e5c297b to
63f01da
Compare
ac63c6b to
1a2349b
Compare
1a2349b to
54efb29
Compare
63f01da to
576d4ea
Compare
576d4ea to
70b51d3
Compare
54efb29 to
14298db
Compare
70b51d3 to
f140406
Compare
14298db to
ac808f8
Compare
f140406 to
6b7f418
Compare
4ac5668 to
eb9a421
Compare
6b7f418 to
3c9a3bb
Compare
4feb5ae to
a872825
Compare
3c9a3bb to
bc8f04a
Compare
a872825 to
9688bf3
Compare
bc8f04a to
35aaabb
Compare
9688bf3 to
d42767e
Compare
35aaabb to
906c43e
Compare
…pattern copy - Add sanitizeDestination() that strips '..' segments from destination fields and emits a warning when any are removed - Sanitize entry.destination for all three inclusion types (pattern, static, configKey) before it reaches any path join - Add copy-time bounds check in copyByPattern: skip any file whose resolved destPath escapes outputDir and warn Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
isaacroldan
left a comment
There was a problem hiding this comment.
Review assisted by pair-review
packages/app/src/cli/services/build/steps/copy-static-assets-step.ts
Outdated
Show resolved
Hide resolved
packages/app/src/cli/services/build/steps/include_assets_step.ts
Outdated
Show resolved
Hide resolved
| await mkdir(dirname(destPath)) | ||
| await copyFile(sourcePath, destPath) | ||
| options.stdout.write(`Copied ${source} to ${destination}\n`) | ||
| return 1 |
There was a problem hiding this comment.
Human: I guess the AI is right, but not sure if the fix is necessary? how important is this count?
🐛 Bug: When a static entry has an explicit destination (lines 177-183), copyFile is called and the count is hardcoded to 1. Since copyFile wraps fs-extra's copy which handles directories recursively, this works functionally — but if the source is a directory containing many files, the reported count is still 1. This is inconsistent with the no-destination path (lines 193-200) which correctly counts files via glob.
Suggestion: Add an isDirectory branch in the destination-provided path: if the source is a directory, count the actual files after copy (via glob on the destination), otherwise return 1. This mirrors the logic in the no-destination path below.
| return 0 | ||
| } | ||
| const destDir = preserveStructure ? joinPath(effectiveOutputDir, basename(fullPath)) : effectiveOutputDir | ||
| await copyDirectoryContents(fullPath, destDir) |
There was a problem hiding this comment.
🐛 Bug: copyConfigKeyEntry unconditionally calls copyDirectoryContents(fullPath, destDir) at line 246 without checking whether the resolved path is actually a directory. If a config key resolves to a file (e.g., tools: 'tools-a.js'), copyDirectoryContents internally globs srcDir/**/* which matches nothing for a file — the copy silently becomes a no-op. The copySourceEntry function in this same PR correctly handles this with an isDirectory check (line 185), but the same fix wasn't applied here. Tests pass only because copyDirectoryContents is mocked.
Suggestion: Add the same isDirectory check that exists in copySourceEntry. When the resolved path is a file, use copyFile instead:
| await copyDirectoryContents(fullPath, destDir) | |
| if (!(await isDirectory(fullPath))) { | |
| const destPath = joinPath(effectiveOutputDir, basename(fullPath)) | |
| await mkdir(effectiveOutputDir) | |
| await copyFile(fullPath, destPath) | |
| options.stdout.write(`Copied '${sourcePath}' to ${basename(fullPath)}\n`) | |
| return 1 | |
| } | |
| const destDir = preserveStructure ? joinPath(effectiveOutputDir, basename(fullPath)) : effectiveOutputDir |
| if (duplicates.size > 0) { | ||
| const colliding = files.filter((fp) => duplicates.has(basename(fp))) | ||
| options.stdout.write( | ||
| `Warning: filename collision detected when flattening — the following files share a basename and will overwrite each other: ${colliding.join(', ')}\n`, |
There was a problem hiding this comment.
🐛 Bug: When preserveStructure is false and duplicate basenames are detected (lines 282-299), a warning is emitted but Promise.all at line 301 proceeds to copy all files concurrently. Since colliding files target the same destination path, the final file content depends on which copyFile promise resolves last — this is a race condition. The warning correctly identifies the problem but the behavior should be deterministic.
Suggestion: Consider either: (1) processing files sequentially when collisions are detected so the last entry in the array deterministically wins, or (2) deduplicating the file list before copying so only one file per basename is copied (e.g., last-in-array wins), adjusting the count accordingly.
| ) | ||
|
|
||
| options.stdout.write(`Copied ${files.length} file(s) from ${sourceDir} to ${outputDir}\n`) | ||
| return {filesCopied: files.length} |
There was a problem hiding this comment.
🐛 Bug: copyByPattern returns files.length as the copy count, but files can be skipped in two places: the path-traversal guard (lines 306-308, early return) and the same-path check (line 311, early return). The returned count overstates actual copies, and this value propagates to stepResults via BuildContext, affecting downstream logging or steps that consume it.
Suggestion: Track actual copies by having each map callback return 1 or 0, then sum:
| return {filesCopied: files.length} | |
| const copyResults = await Promise.all( | |
| files.map(async (filepath) => { | |
| const relPath = preserveStructure ? relativePath(sourceDir, filepath) : basename(filepath) | |
| const destPath = joinPath(outputDir, relPath) | |
| if (relativePath(outputDir, destPath).startsWith('..')) { | |
| options.stdout.write(`Warning: skipping '${filepath}' - resolved destination is outside the output directory\n`) | |
| return 0 | |
| } | |
| if (filepath === destPath) return 0 | |
| await mkdir(dirname(destPath)) | |
| await copyFile(filepath, destPath) | |
| return 1 | |
| }), | |
| ) | |
| const copiedCount = copyResults.reduce((sum, n) => sum + n, 0) | |
| options.stdout.write(`Copied ${copiedCount} file(s) from ${sourceDir} to ${outputDir}\n`) | |
| return {filesCopied: copiedCount} |
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Add build step infrastructure for extension asset management
WHY are these changes introduced?
This introduces a flexible build step system to handle various asset copying and processing needs for different extension types, replacing hardcoded build logic with configurable steps.
WHAT is this pull request doing?
'admin'to theCONFIG_EXTENSION_IDSarray for admin extensionsexecuteIncludeAssetsStep- handles pattern-based, static, and config-key-driven asset copying with comprehensive test coverageexecuteCopyStaticAssetsStep- copies static assets defined in extension build manifestsexecuteCreateTaxStubStep- generates minimal JavaScript stub files for tax calculation extensionsexecuteStepByType) that dispatches to appropriate handlers based on step typeHow to test your changes?
include_assetsstep type with different inclusion strategiesinclude-assets-step.test.tsMeasuring impact
How do we know this change was effective? Please choose one:
Checklist