Skip to content

Commit 89cd2ee

Browse files
authored
fix: copy upload_artifact files to staging in MCP server handler (#26157)
1 parent 0a7652e commit 89cd2ee

File tree

3 files changed

+229
-0
lines changed

3 files changed

+229
-0
lines changed

actions/setup/js/safe_outputs_handlers.cjs

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -890,9 +890,112 @@ function createHandlers(server, appendSafeOutput, config = {}) {
890890
};
891891
};
892892

893+
/**
894+
* Recursively copy all regular files from srcDir into destDir, preserving the relative
895+
* path structure under srcDir. Non-regular entries (sockets, devices, pipes, symlinks)
896+
* are skipped silently.
897+
* @param {string} srcDir - Absolute source directory path
898+
* @param {string} destDir - Absolute destination directory path
899+
*/
900+
function copyDirectoryRecursive(srcDir, destDir) {
901+
if (!fs.existsSync(destDir)) {
902+
fs.mkdirSync(destDir, { recursive: true });
903+
}
904+
for (const ent of fs.readdirSync(srcDir, { withFileTypes: true })) {
905+
const srcPath = path.join(srcDir, ent.name);
906+
const destPath = path.join(destDir, ent.name);
907+
if (ent.isDirectory()) {
908+
copyDirectoryRecursive(srcPath, destPath);
909+
} else if (ent.isFile() && !ent.isSymbolicLink() && !fs.existsSync(destPath)) {
910+
fs.copyFileSync(srcPath, destPath);
911+
}
912+
// Skip symlinks, sockets, pipes, block/char devices — non-regular file types.
913+
}
914+
}
915+
916+
/**
917+
* Handler for upload_artifact tool.
918+
*
919+
* When the agent calls upload_artifact with an absolute path (e.g.,
920+
* /tmp/gh-aw/python/charts/loc_by_language.png), the file lives only inside the
921+
* sandboxed container. After the container exits the file is gone, so the safe_outputs
922+
* job running on a different runner cannot find it.
923+
*
924+
* This handler copies the file (or directory) to the staging directory
925+
* ($RUNNER_TEMP/gh-aw/safeoutputs/upload-artifacts/), which is bind-mounted rw into
926+
* the container. The agent job then uploads that staging directory as the
927+
* safe-outputs-upload-artifacts artifact, and the safe_outputs job downloads it before
928+
* processing.
929+
*
930+
* For path-based requests with an absolute path the handler also rewrites entry.path to
931+
* the staging-relative basename so that upload_artifact.cjs on the safe_outputs runner
932+
* resolves the file from staging rather than trying the (non-existent) absolute path.
933+
*
934+
* Relative paths and filter-based requests are passed through unchanged because the
935+
* agent is expected to have placed those files in staging directly.
936+
*/
937+
const uploadArtifactHandler = args => {
938+
const entry = { ...(args || {}), type: "upload_artifact" };
939+
940+
if (typeof entry.path === "string" && path.isAbsolute(entry.path)) {
941+
const filePath = entry.path;
942+
943+
if (!fs.existsSync(filePath)) {
944+
throw {
945+
code: -32602,
946+
message: `${ERR_VALIDATION}: upload_artifact: file not found: ${filePath}`,
947+
};
948+
}
949+
950+
const stat = fs.lstatSync(filePath);
951+
if (stat.isSymbolicLink()) {
952+
throw {
953+
code: -32602,
954+
message: `${ERR_VALIDATION}: upload_artifact: symlinks are not allowed: ${filePath}`,
955+
};
956+
}
957+
958+
const stagingDir = path.join(process.env.RUNNER_TEMP || "/tmp", "gh-aw", "safeoutputs", "upload-artifacts");
959+
if (!fs.existsSync(stagingDir)) {
960+
fs.mkdirSync(stagingDir, { recursive: true });
961+
}
962+
963+
const destName = path.basename(filePath);
964+
965+
if (stat.isDirectory()) {
966+
copyDirectoryRecursive(filePath, path.join(stagingDir, destName));
967+
} else {
968+
const destPath = path.join(stagingDir, destName);
969+
if (!fs.existsSync(destPath)) {
970+
fs.copyFileSync(filePath, destPath);
971+
}
972+
}
973+
974+
// Rewrite to staging-relative path so upload_artifact.cjs resolves it from staging.
975+
entry.path = destName;
976+
server.debug(`upload_artifact: staged ${filePath} as ${destName}`);
977+
}
978+
979+
appendSafeOutput(entry);
980+
981+
const temporaryId = entry.temporary_id || null;
982+
return {
983+
content: [
984+
{
985+
type: "text",
986+
text: JSON.stringify({
987+
result: "success",
988+
...(temporaryId ? { temporary_id: temporaryId } : {}),
989+
}),
990+
},
991+
],
992+
};
993+
};
994+
893995
return {
894996
defaultHandler,
895997
uploadAssetHandler,
998+
uploadArtifactHandler,
896999
createPullRequestHandler,
8971000
pushToPullRequestBranchHandler,
8981001
pushRepoMemoryHandler,

actions/setup/js/safe_outputs_handlers.test.cjs

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,130 @@ describe("safe_outputs_handlers", () => {
279279
});
280280
});
281281

282+
describe("uploadArtifactHandler", () => {
283+
let testStagingDir;
284+
285+
beforeEach(() => {
286+
const testId = Math.random().toString(36).substring(7);
287+
testStagingDir = `/tmp/test-staging-${testId}`;
288+
process.env.RUNNER_TEMP = testStagingDir;
289+
});
290+
291+
afterEach(() => {
292+
delete process.env.RUNNER_TEMP;
293+
try {
294+
if (fs.existsSync(testStagingDir)) {
295+
fs.rmSync(testStagingDir, { recursive: true, force: true });
296+
}
297+
} catch {
298+
// Ignore cleanup errors
299+
}
300+
});
301+
302+
it("should copy absolute-path file to staging and rewrite path to basename", () => {
303+
const srcFile = path.join(testWorkspaceDir, "chart.png");
304+
fs.writeFileSync(srcFile, "png data");
305+
306+
const result = handlers.uploadArtifactHandler({ path: srcFile });
307+
308+
// File should be in staging
309+
const stagedPath = path.join(testStagingDir, "gh-aw", "safeoutputs", "upload-artifacts", "chart.png");
310+
expect(fs.existsSync(stagedPath)).toBe(true);
311+
expect(fs.readFileSync(stagedPath, "utf8")).toBe("png data");
312+
313+
// JSONL entry should use the basename, not the absolute path
314+
expect(mockAppendSafeOutput).toHaveBeenCalledWith(expect.objectContaining({ type: "upload_artifact", path: "chart.png" }));
315+
316+
// Response should be success
317+
const responseData = JSON.parse(result.content[0].text);
318+
expect(responseData.result).toBe("success");
319+
});
320+
321+
it("should include temporary_id in response when provided", () => {
322+
const srcFile = path.join(testWorkspaceDir, "plot.png");
323+
fs.writeFileSync(srcFile, "png data");
324+
325+
const result = handlers.uploadArtifactHandler({ path: srcFile, temporary_id: "aw_test123" });
326+
327+
const responseData = JSON.parse(result.content[0].text);
328+
expect(responseData.result).toBe("success");
329+
expect(responseData.temporary_id).toBe("aw_test123");
330+
});
331+
332+
it("should throw when absolute-path file does not exist", () => {
333+
expect(() => handlers.uploadArtifactHandler({ path: "/tmp/nonexistent-file.png" })).toThrow(expect.objectContaining({ message: expect.stringContaining("file not found") }));
334+
});
335+
336+
it("should throw when path is a symlink", () => {
337+
const srcFile = path.join(testWorkspaceDir, "real.png");
338+
fs.writeFileSync(srcFile, "data");
339+
const linkPath = path.join(testWorkspaceDir, "link.png");
340+
fs.symlinkSync(srcFile, linkPath);
341+
342+
expect(() => handlers.uploadArtifactHandler({ path: linkPath })).toThrow(expect.objectContaining({ message: expect.stringContaining("symlinks are not allowed") }));
343+
});
344+
345+
it("should not overwrite existing staged file on duplicate call", () => {
346+
const srcFile = path.join(testWorkspaceDir, "chart.png");
347+
fs.writeFileSync(srcFile, "original");
348+
349+
// First call stages the file
350+
handlers.uploadArtifactHandler({ path: srcFile });
351+
352+
const stagedPath = path.join(testStagingDir, "gh-aw", "safeoutputs", "upload-artifacts", "chart.png");
353+
expect(fs.readFileSync(stagedPath, "utf8")).toBe("original");
354+
355+
// Second call with modified source should not overwrite
356+
fs.writeFileSync(srcFile, "updated");
357+
handlers.uploadArtifactHandler({ path: srcFile });
358+
expect(fs.readFileSync(stagedPath, "utf8")).toBe("original");
359+
});
360+
361+
it("should pass through relative path without copying to staging", () => {
362+
// Relative paths reference files already in staging - no copy needed
363+
const result = handlers.uploadArtifactHandler({ path: "already-staged.png" });
364+
365+
// Staging dir should NOT have been created/written by the handler
366+
const stagingDir = path.join(testStagingDir, "gh-aw", "safeoutputs", "upload-artifacts");
367+
const stagedFile = path.join(stagingDir, "already-staged.png");
368+
expect(fs.existsSync(stagedFile)).toBe(false);
369+
370+
// JSONL entry should preserve the relative path as-is
371+
expect(mockAppendSafeOutput).toHaveBeenCalledWith(expect.objectContaining({ type: "upload_artifact", path: "already-staged.png" }));
372+
373+
const responseData = JSON.parse(result.content[0].text);
374+
expect(responseData.result).toBe("success");
375+
});
376+
377+
it("should pass through filters-based request without file copy", () => {
378+
const result = handlers.uploadArtifactHandler({ filters: { include: ["**/*.png"] } });
379+
380+
const stagingDir = path.join(testStagingDir, "gh-aw", "safeoutputs", "upload-artifacts");
381+
expect(fs.existsSync(stagingDir)).toBe(false);
382+
383+
expect(mockAppendSafeOutput).toHaveBeenCalledWith(expect.objectContaining({ type: "upload_artifact", filters: { include: ["**/*.png"] } }));
384+
385+
const responseData = JSON.parse(result.content[0].text);
386+
expect(responseData.result).toBe("success");
387+
});
388+
389+
it("should recursively copy directory to staging", () => {
390+
const srcDir = path.join(testWorkspaceDir, "charts");
391+
fs.mkdirSync(path.join(srcDir, "sub"), { recursive: true });
392+
fs.writeFileSync(path.join(srcDir, "a.png"), "a");
393+
fs.writeFileSync(path.join(srcDir, "sub", "b.png"), "b");
394+
395+
handlers.uploadArtifactHandler({ path: srcDir });
396+
397+
const stagingBase = path.join(testStagingDir, "gh-aw", "safeoutputs", "upload-artifacts", "charts");
398+
expect(fs.existsSync(path.join(stagingBase, "a.png"))).toBe(true);
399+
expect(fs.existsSync(path.join(stagingBase, "sub", "b.png"))).toBe(true);
400+
401+
// Entry path should be the directory basename
402+
expect(mockAppendSafeOutput).toHaveBeenCalledWith(expect.objectContaining({ type: "upload_artifact", path: "charts" }));
403+
});
404+
});
405+
282406
describe("createPullRequestHandler", () => {
283407
it("should be defined", () => {
284408
expect(handlers.createPullRequestHandler).toBeDefined();
@@ -446,6 +570,7 @@ describe("safe_outputs_handlers", () => {
446570
it("should export all required handlers", () => {
447571
expect(handlers.defaultHandler).toBeDefined();
448572
expect(handlers.uploadAssetHandler).toBeDefined();
573+
expect(handlers.uploadArtifactHandler).toBeDefined();
449574
expect(handlers.createPullRequestHandler).toBeDefined();
450575
expect(handlers.pushToPullRequestBranchHandler).toBeDefined();
451576
expect(handlers.pushRepoMemoryHandler).toBeDefined();

actions/setup/js/safe_outputs_tools_loader.cjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ function attachHandlers(tools, handlers) {
7676
push_to_pull_request_branch: handlers.pushToPullRequestBranchHandler,
7777
push_repo_memory: handlers.pushRepoMemoryHandler,
7878
upload_asset: handlers.uploadAssetHandler,
79+
upload_artifact: handlers.uploadArtifactHandler,
7980
create_project: handlers.createProjectHandler,
8081
add_comment: handlers.addCommentHandler,
8182
};

0 commit comments

Comments
 (0)