Skip to content

fix: copy upload_artifact files to staging in MCP server handler (#26090)#26157

Merged
pelikhan merged 1 commit intomainfrom
copilot/investigate-upload-artifact-failure
Apr 14, 2026
Merged

fix: copy upload_artifact files to staging in MCP server handler (#26090)#26157
pelikhan merged 1 commit intomainfrom
copilot/investigate-upload-artifact-failure

Conversation

Copy link
Copy Markdown
Contributor

Copilot AI commented Apr 14, 2026

Problem

When the agent calls upload_artifact via the safe-outputs MCP server, the handler used defaultHandler which 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. The safe_outputs job runs on a separate runner and cannot find the file, causing:

ERR_VALIDATION: upload_artifact: absolute path does not exist: /tmp/gh-aw/python/charts/loc_by_language.png

Reference run: https://github.com/github/gh-aw/actions/runs/24368062285/job/71166315203#step:9:1

Fix

Add a dedicated uploadArtifactHandler in safe_outputs_handlers.cjs that, when the agent provides an absolute path:

  1. Validates the file exists (inside the container)
  2. Copies it to $RUNNER_TEMP/gh-aw/safeoutputs/upload-artifacts/ — the staging directory that is bind-mounted rw into the container and survives container exit
  3. Rewrites entry.path to the staging-relative basename so upload_artifact.cjs on the safe_outputs runner resolves it from the downloaded staging artifact

The handler is registered in safe_outputs_tools_loader.cjs (previously upload_artifact fell through to defaultHandler).

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 — adds uploadArtifactHandler and copyDirectoryRecursive helper
  • actions/setup/js/safe_outputs_tools_loader.cjs — registers the new handler for upload_artifact
  • actions/setup/js/safe_outputs_handlers.test.cjs — 8 new tests + updated handler structure test

Copilot AI requested a review from pelikhan April 14, 2026 04:27
@pelikhan pelikhan marked this pull request as ready for review April 14, 2026 04:28
Copilot AI review requested due to automatic review settings April 14, 2026 04:28
@github-actions github-actions bot mentioned this pull request Apr 14, 2026
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_artifact tool in the tools loader (previously fell back to defaultHandler).
  • 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

Comment on lines +950 to +972
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" }));
});
@github-actions
Copy link
Copy Markdown
Contributor

🧪 Test Quality Sentinel Report

Test Quality Score: 93/100

Excellent test quality

Metric Value
New/modified tests analyzed 8
✅ Design tests (behavioral contracts) 8 (100%)
⚠️ Implementation tests (low value) 0 (0%)
Tests with error/edge cases 6 (75%)
Duplicate test clusters 0
Test inflation detected No (1.21:1 ratio)
🚨 Coding-guideline violations None

Test Classification Details

Test File Classification Issues Detected
should copy absolute-path file to staging and rewrite path to basename actions/setup/js/safe_outputs_handlers.test.cjs ✅ Design Happy path; verifies file content in staging, path rewrite, and response
should include temporary_id in response when provided actions/setup/js/safe_outputs_handlers.test.cjs ✅ Design Verifies optional field passthrough
should throw when absolute-path file does not exist actions/setup/js/safe_outputs_handlers.test.cjs ✅ Design Error path with message assertion
should throw when path is a symlink actions/setup/js/safe_outputs_handlers.test.cjs ✅ Design Security edge case with message assertion
should not overwrite existing staged file on duplicate call actions/setup/js/safe_outputs_handlers.test.cjs ✅ Design Idempotency invariant — edge case
should pass through relative path without copying to staging actions/setup/js/safe_outputs_handlers.test.cjs ✅ Design Alternate-branch edge case (relative path)
should pass through filters-based request without file copy actions/setup/js/safe_outputs_handlers.test.cjs ✅ Design Alternate-branch edge case (filter-based)
should recursively copy directory to staging actions/setup/js/safe_outputs_handlers.test.cjs ✅ Design Directory input, verifies nested file structure

Observations

  • mockAppendSafeOutput is an injected external dependency (appendSafeOutput parameter of createHandlers()), not an internal business-logic function. Mocking it is entirely appropriate.
  • Real filesystem I/O is used throughout (fs.writeFileSync, fs.symlinkSync, fs.mkdirSync) — tests interact with real components as the project guideline requires.
  • Test isolation is well managed via beforeEach/afterEach with unique temp directories per run.
  • Minor observation: the should copy absolute-path file to staging... test verifies four distinct behaviors in a single it() block. This is not a problem, but it could be split for finer failure attribution. Not flagged — this is a style preference, not a quality issue.

Language Support

Tests analyzed:

  • 🐹 Go (*_test.go): 0 tests
  • 🟨 JavaScript (*.test.cjs): 8 tests (vitest)

Verdict

Check passed. 0% of new tests are implementation tests (threshold: 30%). All 8 new tests verify observable behavioral contracts — file-system side effects, error messages, idempotency, and response shapes — with real filesystem interaction and no forbidden mocking patterns.


📖 Understanding Test Classifications

Design Tests (High Value) verify what the system does:

  • Assert on observable outputs, return values, or state changes
  • Cover error paths and boundary conditions
  • Would catch a behavioral regression if deleted
  • Remain valid even after internal refactoring

Implementation Tests (Low Value) verify how the system does it:

  • Assert on internal function calls (mocking internals)
  • Only test the happy path with typical inputs
  • Break during legitimate refactoring even when behavior is correct
  • Give false assurance: they pass even when the system is wrong

Goal: Shift toward tests that describe the system's behavioral contract — the promises it makes to its users and collaborators.

🧪 Test quality analysis by Test Quality Sentinel · ● 457.3K ·

Copy link
Copy Markdown
Contributor

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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.

@github-actions

This comment has been minimized.

@github-actions
Copy link
Copy Markdown
Contributor

Hey @Copilot 👋 — great work on this fix! The upload_artifact staging handler is a clean, well-reasoned solution to the container-exit file loss problem, and the test coverage is thorough.

This PR looks ready for maintainer review. ✅


Contribution check summary:

Check Result
On-topic ✅ yes
Follows process ✅ yes (Copilot agent + core team assignee)
Focused ✅ yes
New dependencies ✅ no
Has tests ✅ yes (8 new tests)
Has description ✅ yes
Lines changed 229

Verdict: 🟢 Aligned — quality: lgtm

Generated by Contribution Check · ● 1.4M ·

@pelikhan
Copy link
Copy Markdown
Collaborator

@copilot review all comments

@pelikhan pelikhan merged commit 89cd2ee into main Apr 14, 2026
78 of 79 checks passed
@pelikhan pelikhan deleted the copilot/investigate-upload-artifact-failure branch April 14, 2026 12:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants