fix: copy upload_artifact files to staging in MCP server handler (#26090)#26157
fix: copy upload_artifact files to staging in MCP server handler (#26090)#26157
Conversation
Agent-Logs-Url: https://github.com/github/gh-aw/sessions/686ec95a-80c7-4228-97ff-7421c91a24bb Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
This PR fixes upload_artifact calls made via the safe-outputs MCP server when the agent provides an absolute path that only exists inside the sandboxed container, by staging the referenced file(s) into a runner-mounted directory that survives container exit.
Changes:
- Added a dedicated
uploadArtifactHandler(plus a recursive copy helper) to copy absolute-path files/dirs into$RUNNER_TEMP/gh-aw/safeoutputs/upload-artifacts/and rewrite the recorded path. - Registered the new handler for the
upload_artifacttool in the tools loader (previously fell back todefaultHandler). - Added test coverage for the new handler’s staging and pass-through behaviors.
Show a summary per file
| File | Description |
|---|---|
| actions/setup/js/safe_outputs_tools_loader.cjs | Registers upload_artifact to use the new dedicated handler. |
| actions/setup/js/safe_outputs_handlers.cjs | Implements staging/copy logic for absolute-path upload_artifact requests. |
| actions/setup/js/safe_outputs_handlers.test.cjs | Adds tests validating staging + path rewrite and pass-through behaviors. |
Copilot's findings
Tip
Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comments suppressed due to low confidence (2)
actions/setup/js/safe_outputs_handlers.cjs:976
- uploadArtifactHandler stages absolute paths using only path.basename(filePath) and skips copying if the destination already exists. If two different absolute paths share the same basename (or a pre-staged file already exists), the second call will still rewrite entry.path to that basename and silently reference the wrong staged file. Consider generating a collision-resistant staged relative path (e.g., include a hash of the source path/content or preserve a sanitized relative path structure) and ensure entry.path matches the staged location.
const destName = path.basename(filePath);
if (stat.isDirectory()) {
copyDirectoryRecursive(filePath, path.join(stagingDir, destName));
} else {
const destPath = path.join(stagingDir, destName);
if (!fs.existsSync(destPath)) {
fs.copyFileSync(filePath, destPath);
}
}
// Rewrite to staging-relative path so upload_artifact.cjs resolves it from staging.
entry.path = destName;
server.debug(`upload_artifact: staged ${filePath} as ${destName}`);
actions/setup/js/safe_outputs_handlers.cjs:970
- destName is derived from path.basename(filePath) and then joined into stagingDir. For edge-case inputs like '/' (empty basename) or paths ending in '.' / '..' (basename '.' or '..'), this can cause staging into the staging root or its parent (path traversal via '..') and potentially attempt to copy an enormous directory tree. Please validate destName is non-empty and not '.'/'..', and (ideally) verify the resolved destination path remains within stagingDir before copying.
const stagingDir = path.join(process.env.RUNNER_TEMP || "/tmp", "gh-aw", "safeoutputs", "upload-artifacts");
if (!fs.existsSync(stagingDir)) {
fs.mkdirSync(stagingDir, { recursive: true });
}
const destName = path.basename(filePath);
if (stat.isDirectory()) {
copyDirectoryRecursive(filePath, path.join(stagingDir, destName));
} else {
const destPath = path.join(stagingDir, destName);
if (!fs.existsSync(destPath)) {
fs.copyFileSync(filePath, destPath);
- Files reviewed: 3/3 changed files
- Comments generated: 2
| const stat = fs.lstatSync(filePath); | ||
| if (stat.isSymbolicLink()) { | ||
| throw { | ||
| code: -32602, | ||
| message: `${ERR_VALIDATION}: upload_artifact: symlinks are not allowed: ${filePath}`, | ||
| }; | ||
| } | ||
|
|
||
| const stagingDir = path.join(process.env.RUNNER_TEMP || "/tmp", "gh-aw", "safeoutputs", "upload-artifacts"); | ||
| if (!fs.existsSync(stagingDir)) { | ||
| fs.mkdirSync(stagingDir, { recursive: true }); | ||
| } | ||
|
|
||
| const destName = path.basename(filePath); | ||
|
|
||
| if (stat.isDirectory()) { | ||
| copyDirectoryRecursive(filePath, path.join(stagingDir, destName)); | ||
| } else { | ||
| const destPath = path.join(stagingDir, destName); | ||
| if (!fs.existsSync(destPath)) { | ||
| fs.copyFileSync(filePath, destPath); | ||
| } | ||
| } |
|
|
||
| // Entry path should be the directory basename | ||
| expect(mockAppendSafeOutput).toHaveBeenCalledWith(expect.objectContaining({ type: "upload_artifact", path: "charts" })); | ||
| }); |
🧪 Test Quality Sentinel ReportTest Quality Score: 93/100✅ Excellent test quality
Test Classification Details
Observations
Language SupportTests analyzed:
Verdict
📖 Understanding Test ClassificationsDesign Tests (High Value) verify what the system does:
Implementation Tests (Low Value) verify how the system does it:
Goal: Shift toward tests that describe the system's behavioral contract — the promises it makes to its users and collaborators.
|
There was a problem hiding this comment.
✅ Test Quality Sentinel: 93/100. Test quality is excellent — 0% of new tests are implementation tests (threshold: 30%). All 8 new uploadArtifactHandler tests verify real behavioral contracts using actual filesystem I/O, cover both error paths and edge cases (symlinks, idempotency, relative paths, filter-based requests, directory recursion), and follow the project's no-mock-libraries guideline.
This comment has been minimized.
This comment has been minimized.
|
Hey This PR looks ready for maintainer review. ✅ Contribution check summary:
Verdict: 🟢 Aligned — quality:
|
|
@copilot review all comments |
Problem
When the agent calls
upload_artifactvia the safe-outputs MCP server, the handler useddefaultHandlerwhich just writes the file path to the safe-outputs JSONL without touching the file itself.The file (e.g.
/tmp/gh-aw/python/charts/loc_by_language.png) lives inside the sandboxed container. After the container exits the file is gone. Thesafe_outputsjob runs on a separate runner and cannot find the file, causing:Reference run: https://github.com/github/gh-aw/actions/runs/24368062285/job/71166315203#step:9:1
Fix
Add a dedicated
uploadArtifactHandlerinsafe_outputs_handlers.cjsthat, when the agent provides an absolute path:$RUNNER_TEMP/gh-aw/safeoutputs/upload-artifacts/— the staging directory that is bind-mounted rw into the container and survives container exitentry.pathto the staging-relative basename soupload_artifact.cjson the safe_outputs runner resolves it from the downloaded staging artifactThe handler is registered in
safe_outputs_tools_loader.cjs(previouslyupload_artifactfell through todefaultHandler).Relative paths and filter-based requests are passed through unchanged (they already reference staging or use staging-based filters).
Symlinks and special file types (sockets, pipes, devices) are rejected/skipped.
Files Changed
actions/setup/js/safe_outputs_handlers.cjs— addsuploadArtifactHandlerandcopyDirectoryRecursivehelperactions/setup/js/safe_outputs_tools_loader.cjs— registers the new handler forupload_artifactactions/setup/js/safe_outputs_handlers.test.cjs— 8 new tests + updated handler structure test