From e0eb1e22f07ded75f9185b87ce6a4efc32ddf70f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 15 Sep 2025 21:56:48 +0000 Subject: [PATCH 01/12] Initial plan From 8cb68b3e2cb41afda9a75bc52f3d56c2abb2a539 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 15 Sep 2025 22:17:34 +0000 Subject: [PATCH 02/12] Implement push-to-orphaned-branch safe output type with schema, MCP server, and job builder Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/ci-doctor.lock.yml | 47 ++++++ .github/workflows/dev.lock.yml | 47 ++++++ pkg/parser/schemas/main_workflow_schema.json | 21 +++ pkg/workflow/compiler.go | 34 ++++ pkg/workflow/js.go | 3 + pkg/workflow/js/push_to_orphaned_branch.cjs | 137 ++++++++++++++++ pkg/workflow/js/safe_outputs_mcp_server.cjs | 54 +++++++ pkg/workflow/js/types/safe-outputs.d.ts | 13 ++ .../output_push_to_orphaned_branch.go | 88 +++++++++++ .../output_push_to_orphaned_branch_test.go | 146 ++++++++++++++++++ pkg/workflow/safe_outputs.go | 1 + schemas/agent-output.json | 22 +++ 12 files changed, 613 insertions(+) create mode 100644 pkg/workflow/js/push_to_orphaned_branch.cjs create mode 100644 pkg/workflow/output_push_to_orphaned_branch.go create mode 100644 pkg/workflow/output_push_to_orphaned_branch_test.go diff --git a/.github/workflows/ci-doctor.lock.yml b/.github/workflows/ci-doctor.lock.yml index 7cb26f3ca4..e3d9fe676d 100644 --- a/.github/workflows/ci-doctor.lock.yml +++ b/.github/workflows/ci-doctor.lock.yml @@ -388,6 +388,53 @@ jobs: additionalProperties: false, }, }, + { + name: "push-to-orphaned-branch", + description: "Upload a file to an orphaned branch and get a GitHub raw URL", + inputSchema: { + type: "object", + required: ["filename"], + properties: { + filename: { + type: "string", + description: "Name of the file to upload. Screenshots and images can be uploaded using this safe output." + }, + }, + additionalProperties: false, + }, + handler: args => { + const fs = require("fs"); + const path = require("path"); + const { filename } = args; + if (!filename) { + throw new Error("filename is required"); + } + // Check if file exists + if (!fs.existsSync(filename)) { + throw new Error(`File not found: ${filename}`); + } + // Read file and encode as base64 + const fileContent = fs.readFileSync(filename); + const base64Content = fileContent.toString('base64'); + // Create the output entry with base64 content + const entry = { + type: "push-to-orphaned-branch", + filename: path.basename(filename), + content: base64Content + }; + appendSafeOutput(entry); + // Return a mock URL for now - the actual URL will be generated during the GitHub Actions job + const mockUrl = `https://raw.githubusercontent.com/org/repo/orphaned-branch/sha/${path.basename(filename)}`; + return { + content: [ + { + type: "text", + text: `File uploaded successfully. URL: ${mockUrl}`, + }, + ], + }; + }, + }, { name: "missing-tool", description: diff --git a/.github/workflows/dev.lock.yml b/.github/workflows/dev.lock.yml index c87b69025e..432d4a5da2 100644 --- a/.github/workflows/dev.lock.yml +++ b/.github/workflows/dev.lock.yml @@ -567,6 +567,53 @@ jobs: additionalProperties: false, }, }, + { + name: "push-to-orphaned-branch", + description: "Upload a file to an orphaned branch and get a GitHub raw URL", + inputSchema: { + type: "object", + required: ["filename"], + properties: { + filename: { + type: "string", + description: "Name of the file to upload. Screenshots and images can be uploaded using this safe output." + }, + }, + additionalProperties: false, + }, + handler: args => { + const fs = require("fs"); + const path = require("path"); + const { filename } = args; + if (!filename) { + throw new Error("filename is required"); + } + // Check if file exists + if (!fs.existsSync(filename)) { + throw new Error(`File not found: ${filename}`); + } + // Read file and encode as base64 + const fileContent = fs.readFileSync(filename); + const base64Content = fileContent.toString('base64'); + // Create the output entry with base64 content + const entry = { + type: "push-to-orphaned-branch", + filename: path.basename(filename), + content: base64Content + }; + appendSafeOutput(entry); + // Return a mock URL for now - the actual URL will be generated during the GitHub Actions job + const mockUrl = `https://raw.githubusercontent.com/org/repo/orphaned-branch/sha/${path.basename(filename)}`; + return { + content: [ + { + type: "text", + text: `File uploaded successfully. URL: ${mockUrl}`, + }, + ], + }; + }, + }, { name: "missing-tool", description: diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index cf8df75895..96a386c6e8 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -1577,6 +1577,27 @@ } ] }, + "push-to-orphaned-branch": { + "oneOf": [ + { + "type": "null", + "description": "Enable orphaned branch file upload with default configuration (max: 1)" + }, + { + "type": "object", + "description": "Configuration for uploading files to an orphaned branch from agentic workflow output. Screenshots and images can be uploaded using this safe output.", + "properties": { + "max": { + "type": "integer", + "description": "Maximum number of files to upload (default: 1)", + "minimum": 1, + "maximum": 100 + } + }, + "additionalProperties": false + } + ] + }, "missing-tool": { "oneOf": [ { diff --git a/pkg/workflow/compiler.go b/pkg/workflow/compiler.go index 5011a48f6a..4aa8e02e73 100644 --- a/pkg/workflow/compiler.go +++ b/pkg/workflow/compiler.go @@ -171,6 +171,7 @@ type SafeOutputsConfig struct { AddIssueLabels *AddIssueLabelsConfig `yaml:"add-issue-label,omitempty"` UpdateIssues *UpdateIssuesConfig `yaml:"update-issue,omitempty"` PushToPullRequestBranch *PushToPullRequestBranchConfig `yaml:"push-to-pr-branch,omitempty"` + PushToOrphanedBranch *PushToOrphanedBranchConfig `yaml:"push-to-orphaned-branch,omitempty"` MissingTool *MissingToolConfig `yaml:"missing-tool,omitempty"` // Optional for reporting missing functionality AllowedDomains []string `yaml:"allowed-domains,omitempty"` Staged *bool `yaml:"staged,omitempty"` // If true, emit step summary messages instead of making GitHub API calls @@ -245,6 +246,11 @@ type PushToPullRequestBranchConfig struct { IfNoChanges string `yaml:"if-no-changes,omitempty"` // Behavior when no changes to push: "warn", "error", or "ignore" (default: "warn") } +// PushToOrphanedBranchConfig holds configuration for uploading files to an orphaned branch +type PushToOrphanedBranchConfig struct { + Max int `yaml:"max,omitempty"` // Maximum number of files to upload (default: 1) +} + // MissingToolConfig holds configuration for reporting missing tools or functionality type MissingToolConfig struct { Max int `yaml:"max,omitempty"` // Maximum number of missing tool reports (default: unlimited) @@ -1957,6 +1963,17 @@ func (c *Compiler) buildJobs(data *WorkflowData, markdownPath string) error { } } + // Build push_to_orphaned_branch job if output.push-to-orphaned-branch is configured + if data.SafeOutputs.PushToOrphanedBranch != nil { + pushToOrphanedBranchJob, err := c.buildCreateOutputPushToOrphanedBranchJob(data, jobName) + if err != nil { + return fmt.Errorf("failed to build push_to_orphaned_branch job: %w", err) + } + if err := c.jobManager.AddJob(pushToOrphanedBranchJob); err != nil { + return fmt.Errorf("failed to add push_to_orphaned_branch job: %w", err) + } + } + // Build missing_tool job (always enabled when SafeOutputs exists) if data.SafeOutputs.MissingTool != nil { missingToolJob, err := c.buildCreateOutputMissingToolJob(data, jobName) @@ -3542,6 +3559,14 @@ func (c *Compiler) generatePrompt(yaml *strings.Builder, data *WorkflowData) { written = true } + if data.SafeOutputs.PushToOrphanedBranch != nil { + if written { + yaml.WriteString(", ") + } + yaml.WriteString("Uploading Files to Orphaned Branch") + written = true + } + if data.SafeOutputs.CreateCodeScanningAlerts != nil { if written { yaml.WriteString(", ") @@ -4420,6 +4445,15 @@ func (c *Compiler) generateSafeOutputsConfig(data *WorkflowData) string { } safeOutputsConfig["push-to-pr-branch"] = pushToBranchConfig } + if data.SafeOutputs.PushToOrphanedBranch != nil { + pushToOrphanedBranchConfig := map[string]interface{}{ + "enabled": true, + } + if data.SafeOutputs.PushToOrphanedBranch.Max > 0 { + pushToOrphanedBranchConfig["max"] = data.SafeOutputs.PushToOrphanedBranch.Max + } + safeOutputsConfig["push-to-orphaned-branch"] = pushToOrphanedBranchConfig + } if data.SafeOutputs.MissingTool != nil { missingToolConfig := map[string]interface{}{ "enabled": true, diff --git a/pkg/workflow/js.go b/pkg/workflow/js.go index f8468187bd..13999f41dd 100644 --- a/pkg/workflow/js.go +++ b/pkg/workflow/js.go @@ -39,6 +39,9 @@ var updateIssueScript string //go:embed js/push_to_pr_branch.cjs var pushToBranchScript string +//go:embed js/push_to_orphaned_branch.cjs +var pushToOrphanedBranchScript string + //go:embed js/setup_agent_output.cjs var setupAgentOutputScript string diff --git a/pkg/workflow/js/push_to_orphaned_branch.cjs b/pkg/workflow/js/push_to_orphaned_branch.cjs new file mode 100644 index 0000000000..56fa6d6556 --- /dev/null +++ b/pkg/workflow/js/push_to_orphaned_branch.cjs @@ -0,0 +1,137 @@ +const { execSync } = require('child_process'); +const fs = require('fs'); + +// Get environment variables +const agentOutput = process.env.GITHUB_AW_AGENT_OUTPUT || '{}'; +const maxCount = parseInt(process.env.GITHUB_AW_ORPHANED_BRANCH_MAX_COUNT || '1'); +const isStaged = process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === 'true'; + +const repo = context.repo; +const owner = context.repo.owner; + +core.info(`Processing agent output for orphaned branch upload`); +core.info(`Repository: ${owner}/${repo.repo}`); +core.info(`Max files allowed: ${maxCount}`); + +let parsedOutput; +try { + parsedOutput = JSON.parse(agentOutput); +} catch (error) { + core.setFailed(`Failed to parse agent output: ${error instanceof Error ? error.message : String(error)}`); + return; +} + +// Extract push-to-orphaned-branch items +const orphanedBranchItems = (parsedOutput.items || []).filter(item => + item.type === 'push-to-orphaned-branch' +); + +if (orphanedBranchItems.length === 0) { + core.info('No orphaned branch upload items found in agent output'); + return; +} + +if (orphanedBranchItems.length > maxCount) { + core.setFailed(`Too many files to upload: ${orphanedBranchItems.length} (max: ${maxCount})`); + return; +} + +core.info(`Found ${orphanedBranchItems.length} file(s) to upload to orphaned branch`); + +const uploadedFiles = []; +const fileUrls = []; + +if (isStaged) { + // In staged mode, just show what would be uploaded + core.summary.addHeading('Orphaned Branch File Upload (Staged Mode)', 2); + core.summary.addRaw('The following files would be uploaded to an orphaned branch:\n\n'); + + for (const item of orphanedBranchItems) { + core.summary.addRaw(`- **${item.filename}** (${Math.round(item.content.length * 0.75)} bytes)\n`); + uploadedFiles.push(item.filename); + fileUrls.push(`https://raw.githubusercontent.com/${owner}/${repo.repo}/orphaned-uploads/staged/${item.filename}`); + } + + await core.summary.write(); + +} else { + // Actually upload files to orphaned branch + const branchName = 'orphaned-uploads'; + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + + try { + // Create or switch to orphaned branch + try { + execSync(`git checkout ${branchName}`, { stdio: 'inherit' }); + core.info(`Switched to existing orphaned branch: ${branchName}`); + } catch (error) { + // Branch doesn't exist, create orphaned branch + execSync(`git checkout --orphan ${branchName}`, { stdio: 'inherit' }); + execSync(`git rm -rf .`, { stdio: 'inherit' }); + core.info(`Created new orphaned branch: ${branchName}`); + } + + // Upload each file + for (const item of orphanedBranchItems) { + const { filename, content } = item; + + if (!filename || !content) { + core.warning(`Skipping invalid item: ${JSON.stringify(item)}`); + continue; + } + + // Decode base64 content and write file + const fileBuffer = Buffer.from(content, 'base64'); + const safeFilename = filename.replace(/[^a-zA-Z0-9._-]/g, '_'); + const timestampedFilename = `${timestamp}-${safeFilename}`; + + fs.writeFileSync(timestampedFilename, fileBuffer); + core.info(`Created file: ${timestampedFilename} (${fileBuffer.length} bytes)`); + + // Add to git + execSync(`git add ${timestampedFilename}`, { stdio: 'inherit' }); + + uploadedFiles.push(timestampedFilename); + } + + // Commit files + const commitMessage = `Upload ${uploadedFiles.length} file(s) to orphaned branch\n\nFiles: ${uploadedFiles.join(', ')}`; + execSync(`git commit -m "${commitMessage}"`, { stdio: 'inherit' }); + + // Push to remote + execSync(`git push origin ${branchName}`, { stdio: 'inherit' }); + + // Get the commit SHA + const commitSha = execSync(`git rev-parse HEAD`, { encoding: 'utf8' }).trim(); + core.info(`Pushed to orphaned branch with commit: ${commitSha}`); + + // Generate GitHub raw URLs + for (const filename of uploadedFiles) { + const rawUrl = `https://raw.githubusercontent.com/${owner}/${repo.repo}/${commitSha}/${filename}`; + fileUrls.push(rawUrl); + core.info(`File URL: ${rawUrl}`); + } + + // Add summary + core.summary.addHeading('Files Uploaded to Orphaned Branch', 2); + core.summary.addRaw(`Successfully uploaded ${uploadedFiles.length} file(s) to orphaned branch \`${branchName}\`\n\n`); + core.summary.addRaw(`**Commit:** \`${commitSha}\`\n\n`); + core.summary.addRaw('**Files:**\n'); + + for (let i = 0; i < uploadedFiles.length; i++) { + core.summary.addRaw(`- [${uploadedFiles[i]}](${fileUrls[i]})\n`); + } + + await core.summary.write(); + + } catch (error) { + core.setFailed(`Failed to upload files to orphaned branch: ${error instanceof Error ? error.message : String(error)}`); + return; + } +} + +// Set outputs +core.setOutput('uploaded_files', JSON.stringify(uploadedFiles)); +core.setOutput('file_urls', JSON.stringify(fileUrls)); + +core.info(`Successfully processed ${uploadedFiles.length} file(s) for orphaned branch upload`); \ No newline at end of file diff --git a/pkg/workflow/js/safe_outputs_mcp_server.cjs b/pkg/workflow/js/safe_outputs_mcp_server.cjs index 926cd3ab22..fbc1709156 100644 --- a/pkg/workflow/js/safe_outputs_mcp_server.cjs +++ b/pkg/workflow/js/safe_outputs_mcp_server.cjs @@ -324,6 +324,60 @@ const TOOLS = Object.fromEntries( additionalProperties: false, }, }, + { + name: "push-to-orphaned-branch", + description: "Upload a file to an orphaned branch and get a GitHub raw URL", + inputSchema: { + type: "object", + required: ["filename"], + properties: { + filename: { + type: "string", + description: "Name of the file to upload. Screenshots and images can be uploaded using this safe output." + }, + }, + additionalProperties: false, + }, + handler: args => { + const fs = require("fs"); + const path = require("path"); + + const { filename } = args; + if (!filename) { + throw new Error("filename is required"); + } + + // Check if file exists + if (!fs.existsSync(filename)) { + throw new Error(`File not found: ${filename}`); + } + + // Read file and encode as base64 + const fileContent = fs.readFileSync(filename); + const base64Content = fileContent.toString('base64'); + + // Create the output entry with base64 content + const entry = { + type: "push-to-orphaned-branch", + filename: path.basename(filename), + content: base64Content + }; + + appendSafeOutput(entry); + + // Return a mock URL for now - the actual URL will be generated during the GitHub Actions job + const mockUrl = `https://raw.githubusercontent.com/org/repo/orphaned-branch/sha/${path.basename(filename)}`; + + return { + content: [ + { + type: "text", + text: `File uploaded successfully. URL: ${mockUrl}`, + }, + ], + }; + }, + }, { name: "missing-tool", description: diff --git a/pkg/workflow/js/types/safe-outputs.d.ts b/pkg/workflow/js/types/safe-outputs.d.ts index 257cacc336..cdcd78e02c 100644 --- a/pkg/workflow/js/types/safe-outputs.d.ts +++ b/pkg/workflow/js/types/safe-outputs.d.ts @@ -119,6 +119,17 @@ interface UpdateIssueItem extends BaseSafeOutputItem { issue_number?: number | string; } +/** + * JSONL item for pushing to an orphaned branch + */ +interface PushToOrphanedBranchItem extends BaseSafeOutputItem { + type: "push-to-orphaned-branch"; + /** Name of the file to upload. Screenshots and images can be uploaded using this safe output. */ + filename: string; + /** Base64 encoded file content */ + content: string; +} + /** * JSONL item for pushing to a PR branch */ @@ -156,6 +167,7 @@ type SafeOutputItem = | AddIssueLabelItem | UpdateIssueItem | PushToPrBranchItem + | PushToOrphanedBranchItem | MissingToolItem; @@ -173,6 +185,7 @@ export { AddIssueLabelItem, UpdateIssueItem, PushToPrBranchItem, + PushToOrphanedBranchItem, MissingToolItem, SafeOutputItem, }; diff --git a/pkg/workflow/output_push_to_orphaned_branch.go b/pkg/workflow/output_push_to_orphaned_branch.go new file mode 100644 index 0000000000..fba279ffe6 --- /dev/null +++ b/pkg/workflow/output_push_to_orphaned_branch.go @@ -0,0 +1,88 @@ +package workflow + +import ( + "fmt" +) + +// buildCreateOutputPushToOrphanedBranchJob creates the push_to_orphaned_branch job +func (c *Compiler) buildCreateOutputPushToOrphanedBranchJob(data *WorkflowData, mainJobName string) (*Job, error) { + if data.SafeOutputs == nil || data.SafeOutputs.PushToOrphanedBranch == nil { + return nil, fmt.Errorf("safe-outputs.push-to-orphaned-branch configuration is required") + } + + var steps []string + + // Step 1: Checkout repository + steps = append(steps, " - name: Checkout repository\n") + steps = append(steps, " uses: actions/checkout@v5\n") + steps = append(steps, " with:\n") + steps = append(steps, " fetch-depth: 0\n") + + // Step 2: Configure Git credentials + steps = append(steps, c.generateGitConfigurationSteps()...) + + // Step 3: Push to Orphaned Branch + steps = append(steps, " - name: Push to Orphaned Branch\n") + steps = append(steps, " id: push_to_orphaned_branch\n") + steps = append(steps, " uses: actions/github-script@v7\n") + + // Add environment variables + steps = append(steps, " env:\n") + // Add GH_TOKEN for authentication + steps = append(steps, " GH_TOKEN: ${{ github.token }}\n") + // Pass the agent output content from the main job + steps = append(steps, fmt.Sprintf(" GITHUB_AW_AGENT_OUTPUT: ${{ needs.%s.outputs.output }}\n", mainJobName)) + // Pass the max count configuration + maxCount := 1 + if data.SafeOutputs.PushToOrphanedBranch.Max > 0 { + maxCount = data.SafeOutputs.PushToOrphanedBranch.Max + } + steps = append(steps, fmt.Sprintf(" GITHUB_AW_ORPHANED_BRANCH_MAX_COUNT: %d\n", maxCount)) + + // Pass the staged flag if it's set to true + if data.SafeOutputs.Staged != nil && *data.SafeOutputs.Staged { + steps = append(steps, " GITHUB_AW_SAFE_OUTPUTS_STAGED: \"true\"\n") + } + + // Add custom environment variables from safe-outputs.env + c.addCustomSafeOutputEnvVars(&steps, data) + + steps = append(steps, " with:\n") + // Add github-token if specified + c.addSafeOutputGitHubToken(&steps, data) + steps = append(steps, " script: |\n") + + // Add each line of the script with proper indentation + formattedScript := FormatJavaScriptForYAML(pushToOrphanedBranchScript) + steps = append(steps, formattedScript...) + + // Create outputs for the job + outputs := map[string]string{ + "uploaded_files": "${{ steps.push_to_orphaned_branch.outputs.uploaded_files }}", + "file_urls": "${{ steps.push_to_orphaned_branch.outputs.file_urls }}", + } + + // This job can run in any context since it only uploads files to orphaned branches + jobCondition := "always()" + + // If this is a command workflow, add the command trigger condition + if data.Command != "" { + // Build the command trigger condition + commandCondition := buildCommandOnlyCondition(data.Command) + commandConditionStr := commandCondition.Render() + jobCondition = commandConditionStr + } + + job := &Job{ + Name: "push_to_orphaned_branch", + If: jobCondition, + RunsOn: "runs-on: ubuntu-latest", + Permissions: "permissions:\n contents: write\n actions: read", + TimeoutMinutes: 10, // 10-minute timeout as required + Steps: steps, + Outputs: outputs, + Needs: []string{mainJobName}, // Depend on the main workflow job + } + + return job, nil +} \ No newline at end of file diff --git a/pkg/workflow/output_push_to_orphaned_branch_test.go b/pkg/workflow/output_push_to_orphaned_branch_test.go new file mode 100644 index 0000000000..f57cadffe2 --- /dev/null +++ b/pkg/workflow/output_push_to_orphaned_branch_test.go @@ -0,0 +1,146 @@ +package workflow + +import ( + "strings" + "testing" +) + +func TestBuildCreateOutputPushToOrphanedBranchJob(t *testing.T) { + compiler := NewCompiler(false, "", "1.0.0") + + t.Run("basic_configuration", func(t *testing.T) { + workflowData := &WorkflowData{ + SafeOutputs: &SafeOutputsConfig{ + PushToOrphanedBranch: &PushToOrphanedBranchConfig{ + Max: 3, + }, + }, + } + + job, err := compiler.buildCreateOutputPushToOrphanedBranchJob(workflowData, "main_job") + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + if job.Name != "push_to_orphaned_branch" { + t.Errorf("Expected job name 'push_to_orphaned_branch', got: %s", job.Name) + } + + if job.If != "always()" { + t.Errorf("Expected job condition 'always()', got: %s", job.If) + } + + if !strings.Contains(job.Permissions, "contents: write") { + t.Errorf("Expected job to have contents: write permission") + } + + if job.TimeoutMinutes != 10 { + t.Errorf("Expected timeout of 10 minutes, got: %d", job.TimeoutMinutes) + } + + // Check that the main job is a dependency + found := false + for _, need := range job.Needs { + if need == "main_job" { + t.Logf("Found expected dependency: %s", need) + found = true + break + } + } + if !found { + t.Errorf("Expected 'main_job' to be in needs, got: %v", job.Needs) + } + + // Check for expected outputs + if _, ok := job.Outputs["uploaded_files"]; !ok { + t.Errorf("Expected 'uploaded_files' output to be present") + } + if _, ok := job.Outputs["file_urls"]; !ok { + t.Errorf("Expected 'file_urls' output to be present") + } + + // Check that steps contain expected elements + stepsStr := strings.Join(job.Steps, "") + if !strings.Contains(stepsStr, "Checkout repository") { + t.Errorf("Expected checkout step") + } + if !strings.Contains(stepsStr, "Push to Orphaned Branch") { + t.Errorf("Expected push to orphaned branch step") + } + if !strings.Contains(stepsStr, "GITHUB_AW_ORPHANED_BRANCH_MAX_COUNT: 3") { + t.Errorf("Expected max count environment variable to be set") + } + }) + + t.Run("default_max_count", func(t *testing.T) { + workflowData := &WorkflowData{ + SafeOutputs: &SafeOutputsConfig{ + PushToOrphanedBranch: &PushToOrphanedBranchConfig{}, + }, + } + + job, err := compiler.buildCreateOutputPushToOrphanedBranchJob(workflowData, "main_job") + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + stepsStr := strings.Join(job.Steps, "") + if !strings.Contains(stepsStr, "GITHUB_AW_ORPHANED_BRANCH_MAX_COUNT: 1") { + t.Errorf("Expected default max count of 1") + } + }) + + t.Run("command_workflow_condition", func(t *testing.T) { + workflowData := &WorkflowData{ + Command: "upload-files", + SafeOutputs: &SafeOutputsConfig{ + PushToOrphanedBranch: &PushToOrphanedBranchConfig{}, + }, + } + + job, err := compiler.buildCreateOutputPushToOrphanedBranchJob(workflowData, "main_job") + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + // Should have command trigger condition + if !strings.Contains(job.If, "upload-files") { + t.Errorf("Expected command condition in job.If, got: %s", job.If) + } + }) + + t.Run("missing_configuration", func(t *testing.T) { + workflowData := &WorkflowData{ + SafeOutputs: &SafeOutputsConfig{}, + } + + _, err := compiler.buildCreateOutputPushToOrphanedBranchJob(workflowData, "main_job") + if err == nil { + t.Fatalf("Expected error for missing configuration") + } + + if !strings.Contains(err.Error(), "safe-outputs.push-to-orphaned-branch configuration is required") { + t.Errorf("Expected specific error message, got: %v", err) + } + }) +} + +func TestHasSafeOutputsEnabledWithOrphanedBranch(t *testing.T) { + t.Run("enabled_with_orphaned_branch", func(t *testing.T) { + config := &SafeOutputsConfig{ + PushToOrphanedBranch: &PushToOrphanedBranchConfig{}, + } + + if !HasSafeOutputsEnabled(config) { + t.Errorf("Expected safe outputs to be enabled with orphaned branch config") + } + }) + + t.Run("disabled_without_orphaned_branch", func(t *testing.T) { + config := &SafeOutputsConfig{} + + if HasSafeOutputsEnabled(config) { + t.Errorf("Expected safe outputs to be disabled without any config") + } + }) +} \ No newline at end of file diff --git a/pkg/workflow/safe_outputs.go b/pkg/workflow/safe_outputs.go index bf717ee15b..4a59dd534a 100644 --- a/pkg/workflow/safe_outputs.go +++ b/pkg/workflow/safe_outputs.go @@ -11,5 +11,6 @@ func HasSafeOutputsEnabled(safeOutputs *SafeOutputsConfig) bool { safeOutputs.AddIssueLabels != nil || safeOutputs.UpdateIssues != nil || safeOutputs.PushToPullRequestBranch != nil || + safeOutputs.PushToOrphanedBranch != nil || safeOutputs.MissingTool != nil } diff --git a/schemas/agent-output.json b/schemas/agent-output.json index 81f34135e5..fcbd553258 100644 --- a/schemas/agent-output.json +++ b/schemas/agent-output.json @@ -33,6 +33,7 @@ {"$ref": "#/$defs/AddIssueLabelOutput"}, {"$ref": "#/$defs/UpdateIssueOutput"}, {"$ref": "#/$defs/PushToPullRequestBranchOutput"}, + {"$ref": "#/$defs/PushToOrphanedBranchOutput"}, {"$ref": "#/$defs/CreatePullRequestReviewCommentOutput"}, {"$ref": "#/$defs/CreateDiscussionOutput"}, {"$ref": "#/$defs/MissingToolOutput"}, @@ -193,6 +194,27 @@ "required": ["type"], "additionalProperties": false }, + "PushToOrphanedBranchOutput": { + "title": "Push to Orphaned Branch Output", + "description": "Output for uploading a file to an orphaned branch and getting a GitHub raw URL", + "type": "object", + "properties": { + "type": { + "const": "push-to-orphaned-branch" + }, + "filename": { + "type": "string", + "description": "Name of the file to upload. Screenshots and images can be uploaded using this safe output.", + "minLength": 1 + }, + "content": { + "type": "string", + "description": "Base64 encoded file content" + } + }, + "required": ["type", "filename", "content"], + "additionalProperties": false + }, "CreatePullRequestReviewCommentOutput": { "title": "Create Pull Request Review Comment Output", "description": "Output for creating a review comment on a specific line of code", From fdd967f49b3f2db9d5a30fd416d5a4fd2866e8a3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 15 Sep 2025 22:31:30 +0000 Subject: [PATCH 03/12] Complete push-to-orphaned-branch implementation with parsing fix and validation Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/workflow/compiler.go | 52 +++++++ pkg/workflow/js/push_to_orphaned_branch.cjs | 128 ++++++++++-------- pkg/workflow/js/safe_outputs_mcp_server.cjs | 26 ++-- .../output_push_to_orphaned_branch.go | 2 +- .../output_push_to_orphaned_branch_test.go | 2 +- 5 files changed, 143 insertions(+), 67 deletions(-) diff --git a/pkg/workflow/compiler.go b/pkg/workflow/compiler.go index 4aa8e02e73..6a22ac4e15 100644 --- a/pkg/workflow/compiler.go +++ b/pkg/workflow/compiler.go @@ -3746,6 +3746,12 @@ func (c *Compiler) extractSafeOutputsConfig(frontmatter map[string]any) *SafeOut config.PushToPullRequestBranch = pushToBranchConfig } + // Handle push-to-orphaned-branch + pushToOrphanedBranchConfig := c.parsePushToOrphanedBranchConfig(outputMap) + if pushToOrphanedBranchConfig != nil { + config.PushToOrphanedBranch = pushToOrphanedBranchConfig + } + // Handle missing-tool (parse configuration if present) missingToolConfig := c.parseMissingToolConfig(outputMap) if missingToolConfig != nil { @@ -4180,6 +4186,52 @@ func (c *Compiler) parsePushToPullRequestBranchConfig(outputMap map[string]any) return nil } +// parsePushToOrphanedBranchConfig handles push-to-orphaned-branch configuration +func (c *Compiler) parsePushToOrphanedBranchConfig(outputMap map[string]any) *PushToOrphanedBranchConfig { + if configData, exists := outputMap["push-to-orphaned-branch"]; exists { + pushToOrphanedBranchConfig := &PushToOrphanedBranchConfig{ + Max: 1, // Default: 1 file upload + } + + // Handle the case where configData is nil (push-to-orphaned-branch: with no value) + if configData == nil { + return pushToOrphanedBranchConfig + } + + if configMap, ok := configData.(map[string]any); ok { + // Parse max (optional, defaults to 1) + if maxCount, exists := configMap["max"]; exists { + // Handle different numeric types that YAML parsers might return + var maxCountInt int + var validMaxCount bool + switch v := maxCount.(type) { + case int: + maxCountInt = v + validMaxCount = true + case int64: + maxCountInt = int(v) + validMaxCount = true + case uint64: + maxCountInt = int(v) + validMaxCount = true + case float64: + maxCountInt = int(v) + validMaxCount = true + } + if validMaxCount && maxCountInt > 0 { + pushToOrphanedBranchConfig.Max = maxCountInt + } else if c.verbose { + fmt.Printf("Warning: invalid max value for push-to-orphaned-branch, using default 1\n") + } + } + } + + return pushToOrphanedBranchConfig + } + + return nil +} + // parseMissingToolConfig handles missing-tool configuration func (c *Compiler) parseMissingToolConfig(outputMap map[string]any) *MissingToolConfig { if configData, exists := outputMap["missing-tool"]; exists { diff --git a/pkg/workflow/js/push_to_orphaned_branch.cjs b/pkg/workflow/js/push_to_orphaned_branch.cjs index 56fa6d6556..d730195740 100644 --- a/pkg/workflow/js/push_to_orphaned_branch.cjs +++ b/pkg/workflow/js/push_to_orphaned_branch.cjs @@ -1,10 +1,12 @@ -const { execSync } = require('child_process'); -const fs = require('fs'); +const { execSync } = require("child_process"); +const fs = require("fs"); // Get environment variables -const agentOutput = process.env.GITHUB_AW_AGENT_OUTPUT || '{}'; -const maxCount = parseInt(process.env.GITHUB_AW_ORPHANED_BRANCH_MAX_COUNT || '1'); -const isStaged = process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === 'true'; +const agentOutput = process.env.GITHUB_AW_AGENT_OUTPUT || "{}"; +const maxCount = parseInt( + process.env.GITHUB_AW_ORPHANED_BRANCH_MAX_COUNT || "1" +); +const isStaged = process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true"; const repo = context.repo; const owner = context.repo.owner; @@ -17,121 +19,141 @@ let parsedOutput; try { parsedOutput = JSON.parse(agentOutput); } catch (error) { - core.setFailed(`Failed to parse agent output: ${error instanceof Error ? error.message : String(error)}`); + core.setFailed( + `Failed to parse agent output: ${error instanceof Error ? error.message : String(error)}` + ); return; } // Extract push-to-orphaned-branch items -const orphanedBranchItems = (parsedOutput.items || []).filter(item => - item.type === 'push-to-orphaned-branch' +const orphanedBranchItems = (parsedOutput.items || []).filter( + item => item.type === "push-to-orphaned-branch" ); if (orphanedBranchItems.length === 0) { - core.info('No orphaned branch upload items found in agent output'); + core.info("No orphaned branch upload items found in agent output"); return; } if (orphanedBranchItems.length > maxCount) { - core.setFailed(`Too many files to upload: ${orphanedBranchItems.length} (max: ${maxCount})`); + core.setFailed( + `Too many files to upload: ${orphanedBranchItems.length} (max: ${maxCount})` + ); return; } -core.info(`Found ${orphanedBranchItems.length} file(s) to upload to orphaned branch`); +core.info( + `Found ${orphanedBranchItems.length} file(s) to upload to orphaned branch` +); const uploadedFiles = []; const fileUrls = []; if (isStaged) { // In staged mode, just show what would be uploaded - core.summary.addHeading('Orphaned Branch File Upload (Staged Mode)', 2); - core.summary.addRaw('The following files would be uploaded to an orphaned branch:\n\n'); - + core.summary.addHeading("Orphaned Branch File Upload (Staged Mode)", 2); + core.summary.addRaw( + "The following files would be uploaded to an orphaned branch:\n\n" + ); + for (const item of orphanedBranchItems) { - core.summary.addRaw(`- **${item.filename}** (${Math.round(item.content.length * 0.75)} bytes)\n`); + core.summary.addRaw( + `- **${item.filename}** (${Math.round(item.content.length * 0.75)} bytes)\n` + ); uploadedFiles.push(item.filename); - fileUrls.push(`https://raw.githubusercontent.com/${owner}/${repo.repo}/orphaned-uploads/staged/${item.filename}`); + fileUrls.push( + `https://raw.githubusercontent.com/${owner}/${repo.repo}/orphaned-uploads/staged/${item.filename}` + ); } - + await core.summary.write(); - } else { // Actually upload files to orphaned branch - const branchName = 'orphaned-uploads'; - const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); - + const branchName = "orphaned-uploads"; + const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); + try { // Create or switch to orphaned branch try { - execSync(`git checkout ${branchName}`, { stdio: 'inherit' }); + execSync(`git checkout ${branchName}`, { stdio: "inherit" }); core.info(`Switched to existing orphaned branch: ${branchName}`); } catch (error) { // Branch doesn't exist, create orphaned branch - execSync(`git checkout --orphan ${branchName}`, { stdio: 'inherit' }); - execSync(`git rm -rf .`, { stdio: 'inherit' }); + execSync(`git checkout --orphan ${branchName}`, { stdio: "inherit" }); + execSync(`git rm -rf .`, { stdio: "inherit" }); core.info(`Created new orphaned branch: ${branchName}`); } - + // Upload each file for (const item of orphanedBranchItems) { const { filename, content } = item; - + if (!filename || !content) { core.warning(`Skipping invalid item: ${JSON.stringify(item)}`); continue; } - + // Decode base64 content and write file - const fileBuffer = Buffer.from(content, 'base64'); - const safeFilename = filename.replace(/[^a-zA-Z0-9._-]/g, '_'); + const fileBuffer = Buffer.from(content, "base64"); + const safeFilename = filename.replace(/[^a-zA-Z0-9._-]/g, "_"); const timestampedFilename = `${timestamp}-${safeFilename}`; - + fs.writeFileSync(timestampedFilename, fileBuffer); - core.info(`Created file: ${timestampedFilename} (${fileBuffer.length} bytes)`); - + core.info( + `Created file: ${timestampedFilename} (${fileBuffer.length} bytes)` + ); + // Add to git - execSync(`git add ${timestampedFilename}`, { stdio: 'inherit' }); - + execSync(`git add ${timestampedFilename}`, { stdio: "inherit" }); + uploadedFiles.push(timestampedFilename); } - + // Commit files - const commitMessage = `Upload ${uploadedFiles.length} file(s) to orphaned branch\n\nFiles: ${uploadedFiles.join(', ')}`; - execSync(`git commit -m "${commitMessage}"`, { stdio: 'inherit' }); - + const commitMessage = `Upload ${uploadedFiles.length} file(s) to orphaned branch\n\nFiles: ${uploadedFiles.join(", ")}`; + execSync(`git commit -m "${commitMessage}"`, { stdio: "inherit" }); + // Push to remote - execSync(`git push origin ${branchName}`, { stdio: 'inherit' }); - + execSync(`git push origin ${branchName}`, { stdio: "inherit" }); + // Get the commit SHA - const commitSha = execSync(`git rev-parse HEAD`, { encoding: 'utf8' }).trim(); + const commitSha = execSync(`git rev-parse HEAD`, { + encoding: "utf8", + }).trim(); core.info(`Pushed to orphaned branch with commit: ${commitSha}`); - + // Generate GitHub raw URLs for (const filename of uploadedFiles) { const rawUrl = `https://raw.githubusercontent.com/${owner}/${repo.repo}/${commitSha}/${filename}`; fileUrls.push(rawUrl); core.info(`File URL: ${rawUrl}`); } - + // Add summary - core.summary.addHeading('Files Uploaded to Orphaned Branch', 2); - core.summary.addRaw(`Successfully uploaded ${uploadedFiles.length} file(s) to orphaned branch \`${branchName}\`\n\n`); + core.summary.addHeading("Files Uploaded to Orphaned Branch", 2); + core.summary.addRaw( + `Successfully uploaded ${uploadedFiles.length} file(s) to orphaned branch \`${branchName}\`\n\n` + ); core.summary.addRaw(`**Commit:** \`${commitSha}\`\n\n`); - core.summary.addRaw('**Files:**\n'); - + core.summary.addRaw("**Files:**\n"); + for (let i = 0; i < uploadedFiles.length; i++) { core.summary.addRaw(`- [${uploadedFiles[i]}](${fileUrls[i]})\n`); } - + await core.summary.write(); - } catch (error) { - core.setFailed(`Failed to upload files to orphaned branch: ${error instanceof Error ? error.message : String(error)}`); + core.setFailed( + `Failed to upload files to orphaned branch: ${error instanceof Error ? error.message : String(error)}` + ); return; } } // Set outputs -core.setOutput('uploaded_files', JSON.stringify(uploadedFiles)); -core.setOutput('file_urls', JSON.stringify(fileUrls)); +core.setOutput("uploaded_files", JSON.stringify(uploadedFiles)); +core.setOutput("file_urls", JSON.stringify(fileUrls)); -core.info(`Successfully processed ${uploadedFiles.length} file(s) for orphaned branch upload`); \ No newline at end of file +core.info( + `Successfully processed ${uploadedFiles.length} file(s) for orphaned branch upload` +); diff --git a/pkg/workflow/js/safe_outputs_mcp_server.cjs b/pkg/workflow/js/safe_outputs_mcp_server.cjs index fbc1709156..0366794e90 100644 --- a/pkg/workflow/js/safe_outputs_mcp_server.cjs +++ b/pkg/workflow/js/safe_outputs_mcp_server.cjs @@ -326,14 +326,16 @@ const TOOLS = Object.fromEntries( }, { name: "push-to-orphaned-branch", - description: "Upload a file to an orphaned branch and get a GitHub raw URL", + description: + "Upload a file to an orphaned branch and get a GitHub raw URL", inputSchema: { type: "object", required: ["filename"], properties: { filename: { - type: "string", - description: "Name of the file to upload. Screenshots and images can be uploaded using this safe output." + type: "string", + description: + "Name of the file to upload. Screenshots and images can be uploaded using this safe output.", }, }, additionalProperties: false, @@ -341,33 +343,33 @@ const TOOLS = Object.fromEntries( handler: args => { const fs = require("fs"); const path = require("path"); - + const { filename } = args; if (!filename) { throw new Error("filename is required"); } - + // Check if file exists if (!fs.existsSync(filename)) { throw new Error(`File not found: ${filename}`); } - + // Read file and encode as base64 const fileContent = fs.readFileSync(filename); - const base64Content = fileContent.toString('base64'); - + const base64Content = fileContent.toString("base64"); + // Create the output entry with base64 content const entry = { type: "push-to-orphaned-branch", filename: path.basename(filename), - content: base64Content + content: base64Content, }; - + appendSafeOutput(entry); - + // Return a mock URL for now - the actual URL will be generated during the GitHub Actions job const mockUrl = `https://raw.githubusercontent.com/org/repo/orphaned-branch/sha/${path.basename(filename)}`; - + return { content: [ { diff --git a/pkg/workflow/output_push_to_orphaned_branch.go b/pkg/workflow/output_push_to_orphaned_branch.go index fba279ffe6..ebadfc78e2 100644 --- a/pkg/workflow/output_push_to_orphaned_branch.go +++ b/pkg/workflow/output_push_to_orphaned_branch.go @@ -85,4 +85,4 @@ func (c *Compiler) buildCreateOutputPushToOrphanedBranchJob(data *WorkflowData, } return job, nil -} \ No newline at end of file +} diff --git a/pkg/workflow/output_push_to_orphaned_branch_test.go b/pkg/workflow/output_push_to_orphaned_branch_test.go index f57cadffe2..b7f5f820f4 100644 --- a/pkg/workflow/output_push_to_orphaned_branch_test.go +++ b/pkg/workflow/output_push_to_orphaned_branch_test.go @@ -143,4 +143,4 @@ func TestHasSafeOutputsEnabledWithOrphanedBranch(t *testing.T) { t.Errorf("Expected safe outputs to be disabled without any config") } }) -} \ No newline at end of file +} From 95c92922f44935878997f13754703b0decda8ba9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 15 Sep 2025 23:11:37 +0000 Subject: [PATCH 04/12] Initial plan for implementing SHA-based filenames and branch configuration Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/ci-doctor.lock.yml | 12 +++++++----- .github/workflows/dev.lock.yml | 12 +++++++----- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci-doctor.lock.yml b/.github/workflows/ci-doctor.lock.yml index e3d9fe676d..b7376037c5 100644 --- a/.github/workflows/ci-doctor.lock.yml +++ b/.github/workflows/ci-doctor.lock.yml @@ -390,14 +390,16 @@ jobs: }, { name: "push-to-orphaned-branch", - description: "Upload a file to an orphaned branch and get a GitHub raw URL", + description: + "Upload a file to an orphaned branch and get a GitHub raw URL", inputSchema: { type: "object", required: ["filename"], properties: { filename: { - type: "string", - description: "Name of the file to upload. Screenshots and images can be uploaded using this safe output." + type: "string", + description: + "Name of the file to upload. Screenshots and images can be uploaded using this safe output.", }, }, additionalProperties: false, @@ -415,12 +417,12 @@ jobs: } // Read file and encode as base64 const fileContent = fs.readFileSync(filename); - const base64Content = fileContent.toString('base64'); + const base64Content = fileContent.toString("base64"); // Create the output entry with base64 content const entry = { type: "push-to-orphaned-branch", filename: path.basename(filename), - content: base64Content + content: base64Content, }; appendSafeOutput(entry); // Return a mock URL for now - the actual URL will be generated during the GitHub Actions job diff --git a/.github/workflows/dev.lock.yml b/.github/workflows/dev.lock.yml index 432d4a5da2..25c89ba48a 100644 --- a/.github/workflows/dev.lock.yml +++ b/.github/workflows/dev.lock.yml @@ -569,14 +569,16 @@ jobs: }, { name: "push-to-orphaned-branch", - description: "Upload a file to an orphaned branch and get a GitHub raw URL", + description: + "Upload a file to an orphaned branch and get a GitHub raw URL", inputSchema: { type: "object", required: ["filename"], properties: { filename: { - type: "string", - description: "Name of the file to upload. Screenshots and images can be uploaded using this safe output." + type: "string", + description: + "Name of the file to upload. Screenshots and images can be uploaded using this safe output.", }, }, additionalProperties: false, @@ -594,12 +596,12 @@ jobs: } // Read file and encode as base64 const fileContent = fs.readFileSync(filename); - const base64Content = fileContent.toString('base64'); + const base64Content = fileContent.toString("base64"); // Create the output entry with base64 content const entry = { type: "push-to-orphaned-branch", filename: path.basename(filename), - content: base64Content + content: base64Content, }; appendSafeOutput(entry); // Return a mock URL for now - the actual URL will be generated during the GitHub Actions job From 5465f6ee9cc20c8110deb57ed69b1305c70f1754 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 15 Sep 2025 23:22:38 +0000 Subject: [PATCH 05/12] Implement SHA-based filenames and branch configuration for push-to-orphaned-branch Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/ci-doctor.lock.yml | 19 +++++-- .github/workflows/dev.lock.yml | 19 +++++-- pkg/parser/schemas/main_workflow_schema.json | 7 ++- pkg/workflow/compiler.go | 15 +++++- pkg/workflow/js/push_to_orphaned_branch.cjs | 49 ++++++++++++++----- pkg/workflow/js/safe_outputs_mcp_server.cjs | 22 ++++++--- .../output_push_to_orphaned_branch.go | 7 +++ .../output_push_to_orphaned_branch_test.go | 47 ++++++++++++++++-- schemas/agent-output.json | 14 +++++- 9 files changed, 162 insertions(+), 37 deletions(-) diff --git a/.github/workflows/ci-doctor.lock.yml b/.github/workflows/ci-doctor.lock.yml index b7376037c5..40d0edf233 100644 --- a/.github/workflows/ci-doctor.lock.yml +++ b/.github/workflows/ci-doctor.lock.yml @@ -407,6 +407,7 @@ jobs: handler: args => { const fs = require("fs"); const path = require("path"); + const crypto = require("crypto"); const { filename } = args; if (!filename) { throw new Error("filename is required"); @@ -418,20 +419,28 @@ jobs: // Read file and encode as base64 const fileContent = fs.readFileSync(filename); const base64Content = fileContent.toString("base64"); - // Create the output entry with base64 content + // Compute SHA256 hash of the file content + const hash = crypto.createHash("sha256"); + hash.update(fileContent); + const fileSha = hash.digest("hex"); + // Get file extension from original filename + const originalExtension = path.extname(filename); + const shaFilename = fileSha + originalExtension; + // Create the output entry with base64 content and SHA filename const entry = { type: "push-to-orphaned-branch", - filename: path.basename(filename), + filename: shaFilename, + original_filename: path.basename(filename), + sha: fileSha, content: base64Content, }; appendSafeOutput(entry); - // Return a mock URL for now - the actual URL will be generated during the GitHub Actions job - const mockUrl = `https://raw.githubusercontent.com/org/repo/orphaned-branch/sha/${path.basename(filename)}`; + // Return response with SHA information return { content: [ { type: "text", - text: `File uploaded successfully. URL: ${mockUrl}`, + text: `File uploaded successfully. SHA: ${fileSha}, Original filename: ${path.basename(filename)}`, }, ], }; diff --git a/.github/workflows/dev.lock.yml b/.github/workflows/dev.lock.yml index 25c89ba48a..25564fedca 100644 --- a/.github/workflows/dev.lock.yml +++ b/.github/workflows/dev.lock.yml @@ -586,6 +586,7 @@ jobs: handler: args => { const fs = require("fs"); const path = require("path"); + const crypto = require("crypto"); const { filename } = args; if (!filename) { throw new Error("filename is required"); @@ -597,20 +598,28 @@ jobs: // Read file and encode as base64 const fileContent = fs.readFileSync(filename); const base64Content = fileContent.toString("base64"); - // Create the output entry with base64 content + // Compute SHA256 hash of the file content + const hash = crypto.createHash("sha256"); + hash.update(fileContent); + const fileSha = hash.digest("hex"); + // Get file extension from original filename + const originalExtension = path.extname(filename); + const shaFilename = fileSha + originalExtension; + // Create the output entry with base64 content and SHA filename const entry = { type: "push-to-orphaned-branch", - filename: path.basename(filename), + filename: shaFilename, + original_filename: path.basename(filename), + sha: fileSha, content: base64Content, }; appendSafeOutput(entry); - // Return a mock URL for now - the actual URL will be generated during the GitHub Actions job - const mockUrl = `https://raw.githubusercontent.com/org/repo/orphaned-branch/sha/${path.basename(filename)}`; + // Return response with SHA information return { content: [ { type: "text", - text: `File uploaded successfully. URL: ${mockUrl}`, + text: `File uploaded successfully. SHA: ${fileSha}, Original filename: ${path.basename(filename)}`, }, ], }; diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 96a386c6e8..732cf870a7 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -1581,7 +1581,7 @@ "oneOf": [ { "type": "null", - "description": "Enable orphaned branch file upload with default configuration (max: 1)" + "description": "Enable orphaned branch file upload with default configuration (max: 1, branch: assets/[workflow-id])" }, { "type": "object", @@ -1592,6 +1592,11 @@ "description": "Maximum number of files to upload (default: 1)", "minimum": 1, "maximum": 100 + }, + "branch": { + "type": "string", + "description": "Branch name for storing uploaded files (default: assets/[workflow-id])", + "minLength": 1 } }, "additionalProperties": false diff --git a/pkg/workflow/compiler.go b/pkg/workflow/compiler.go index 6a22ac4e15..34f5f97ab8 100644 --- a/pkg/workflow/compiler.go +++ b/pkg/workflow/compiler.go @@ -248,7 +248,8 @@ type PushToPullRequestBranchConfig struct { // PushToOrphanedBranchConfig holds configuration for uploading files to an orphaned branch type PushToOrphanedBranchConfig struct { - Max int `yaml:"max,omitempty"` // Maximum number of files to upload (default: 1) + Max int `yaml:"max,omitempty"` // Maximum number of files to upload (default: 1) + Branch string `yaml:"branch,omitempty"` // Branch name for storing uploaded files (default: assets/[workflow-id]) } // MissingToolConfig holds configuration for reporting missing tools or functionality @@ -4224,6 +4225,15 @@ func (c *Compiler) parsePushToOrphanedBranchConfig(outputMap map[string]any) *Pu fmt.Printf("Warning: invalid max value for push-to-orphaned-branch, using default 1\n") } } + + // Parse branch (optional, defaults to assets/[workflow-id]) + if branch, exists := configMap["branch"]; exists { + if branchStr, ok := branch.(string); ok && branchStr != "" { + pushToOrphanedBranchConfig.Branch = branchStr + } else if c.verbose { + fmt.Printf("Warning: invalid branch value for push-to-orphaned-branch, using default\n") + } + } } return pushToOrphanedBranchConfig @@ -4504,6 +4514,9 @@ func (c *Compiler) generateSafeOutputsConfig(data *WorkflowData) string { if data.SafeOutputs.PushToOrphanedBranch.Max > 0 { pushToOrphanedBranchConfig["max"] = data.SafeOutputs.PushToOrphanedBranch.Max } + if data.SafeOutputs.PushToOrphanedBranch.Branch != "" { + pushToOrphanedBranchConfig["branch"] = data.SafeOutputs.PushToOrphanedBranch.Branch + } safeOutputsConfig["push-to-orphaned-branch"] = pushToOrphanedBranchConfig } if data.SafeOutputs.MissingTool != nil { diff --git a/pkg/workflow/js/push_to_orphaned_branch.cjs b/pkg/workflow/js/push_to_orphaned_branch.cjs index d730195740..529a41978d 100644 --- a/pkg/workflow/js/push_to_orphaned_branch.cjs +++ b/pkg/workflow/js/push_to_orphaned_branch.cjs @@ -6,6 +6,8 @@ const agentOutput = process.env.GITHUB_AW_AGENT_OUTPUT || "{}"; const maxCount = parseInt( process.env.GITHUB_AW_ORPHANED_BRANCH_MAX_COUNT || "1" ); +const branchName = + process.env.GITHUB_AW_ORPHANED_BRANCH_NAME || "assets/workflow"; const isStaged = process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true"; const repo = context.repo; @@ -14,6 +16,7 @@ const owner = context.repo.owner; core.info(`Processing agent output for orphaned branch upload`); core.info(`Repository: ${owner}/${repo.repo}`); core.info(`Max files allowed: ${maxCount}`); +core.info(`Branch name: ${branchName}`); let parsedOutput; try { @@ -57,21 +60,20 @@ if (isStaged) { ); for (const item of orphanedBranchItems) { + const originalFilename = item.original_filename || item.filename; + const sha = item.sha || "unknown"; core.summary.addRaw( - `- **${item.filename}** (${Math.round(item.content.length * 0.75)} bytes)\n` + `- **${item.filename}** (${Math.round(item.content.length * 0.75)} bytes) - SHA: ${sha} - Original: ${originalFilename}\n` ); uploadedFiles.push(item.filename); fileUrls.push( - `https://raw.githubusercontent.com/${owner}/${repo.repo}/orphaned-uploads/staged/${item.filename}` + `https://raw.githubusercontent.com/${owner}/${repo.repo}/${branchName}/staged/${item.filename}` ); } await core.summary.write(); } else { // Actually upload files to orphaned branch - const branchName = "orphaned-uploads"; - const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); - try { // Create or switch to orphaned branch try { @@ -86,7 +88,7 @@ if (isStaged) { // Upload each file for (const item of orphanedBranchItems) { - const { filename, content } = item; + const { filename, content, original_filename, sha } = item; if (!filename || !content) { core.warning(`Skipping invalid item: ${JSON.stringify(item)}`); @@ -95,22 +97,35 @@ if (isStaged) { // Decode base64 content and write file const fileBuffer = Buffer.from(content, "base64"); + + // Use the SHA-based filename directly (it already includes the extension) const safeFilename = filename.replace(/[^a-zA-Z0-9._-]/g, "_"); - const timestampedFilename = `${timestamp}-${safeFilename}`; - fs.writeFileSync(timestampedFilename, fileBuffer); + fs.writeFileSync(safeFilename, fileBuffer); + const originalName = original_filename || filename; + const fileSha = sha || "unknown"; core.info( - `Created file: ${timestampedFilename} (${fileBuffer.length} bytes)` + `Created file: ${safeFilename} (${fileBuffer.length} bytes) - SHA: ${fileSha} - Original: ${originalName}` ); // Add to git - execSync(`git add ${timestampedFilename}`, { stdio: "inherit" }); + execSync(`git add ${safeFilename}`, { stdio: "inherit" }); - uploadedFiles.push(timestampedFilename); + uploadedFiles.push(safeFilename); } // Commit files - const commitMessage = `Upload ${uploadedFiles.length} file(s) to orphaned branch\n\nFiles: ${uploadedFiles.join(", ")}`; + const fileList = uploadedFiles + .map(filename => { + const item = orphanedBranchItems.find( + i => i.filename.replace(/[^a-zA-Z0-9._-]/g, "_") === filename + ); + const originalName = item?.original_filename || filename; + const sha = item?.sha || "unknown"; + return `${filename} (${originalName}, SHA: ${sha.substring(0, 8)})`; + }) + .join(", "); + const commitMessage = `Upload ${uploadedFiles.length} file(s) to orphaned branch\n\nFiles: ${fileList}`; execSync(`git commit -m "${commitMessage}"`, { stdio: "inherit" }); // Push to remote @@ -138,7 +153,15 @@ if (isStaged) { core.summary.addRaw("**Files:**\n"); for (let i = 0; i < uploadedFiles.length; i++) { - core.summary.addRaw(`- [${uploadedFiles[i]}](${fileUrls[i]})\n`); + const filename = uploadedFiles[i]; + const item = orphanedBranchItems.find( + item => item.filename.replace(/[^a-zA-Z0-9._-]/g, "_") === filename + ); + const originalName = item?.original_filename || filename; + const sha = item?.sha || "unknown"; + core.summary.addRaw( + `- [${filename}](${fileUrls[i]}) - Original: ${originalName} - SHA: ${sha.substring(0, 8)}\n` + ); } await core.summary.write(); diff --git a/pkg/workflow/js/safe_outputs_mcp_server.cjs b/pkg/workflow/js/safe_outputs_mcp_server.cjs index 0366794e90..ed472cb9d0 100644 --- a/pkg/workflow/js/safe_outputs_mcp_server.cjs +++ b/pkg/workflow/js/safe_outputs_mcp_server.cjs @@ -343,6 +343,7 @@ const TOOLS = Object.fromEntries( handler: args => { const fs = require("fs"); const path = require("path"); + const crypto = require("crypto"); const { filename } = args; if (!filename) { @@ -358,23 +359,32 @@ const TOOLS = Object.fromEntries( const fileContent = fs.readFileSync(filename); const base64Content = fileContent.toString("base64"); - // Create the output entry with base64 content + // Compute SHA256 hash of the file content + const hash = crypto.createHash("sha256"); + hash.update(fileContent); + const fileSha = hash.digest("hex"); + + // Get file extension from original filename + const originalExtension = path.extname(filename); + const shaFilename = fileSha + originalExtension; + + // Create the output entry with base64 content and SHA filename const entry = { type: "push-to-orphaned-branch", - filename: path.basename(filename), + filename: shaFilename, + original_filename: path.basename(filename), + sha: fileSha, content: base64Content, }; appendSafeOutput(entry); - // Return a mock URL for now - the actual URL will be generated during the GitHub Actions job - const mockUrl = `https://raw.githubusercontent.com/org/repo/orphaned-branch/sha/${path.basename(filename)}`; - + // Return response with SHA information return { content: [ { type: "text", - text: `File uploaded successfully. URL: ${mockUrl}`, + text: `File uploaded successfully. SHA: ${fileSha}, Original filename: ${path.basename(filename)}`, }, ], }; diff --git a/pkg/workflow/output_push_to_orphaned_branch.go b/pkg/workflow/output_push_to_orphaned_branch.go index ebadfc78e2..d1e187cbb7 100644 --- a/pkg/workflow/output_push_to_orphaned_branch.go +++ b/pkg/workflow/output_push_to_orphaned_branch.go @@ -39,6 +39,13 @@ func (c *Compiler) buildCreateOutputPushToOrphanedBranchJob(data *WorkflowData, } steps = append(steps, fmt.Sprintf(" GITHUB_AW_ORPHANED_BRANCH_MAX_COUNT: %d\n", maxCount)) + // Pass the branch configuration + branchName := fmt.Sprintf("assets/%s", data.Name) + if data.SafeOutputs.PushToOrphanedBranch.Branch != "" { + branchName = data.SafeOutputs.PushToOrphanedBranch.Branch + } + steps = append(steps, fmt.Sprintf(" GITHUB_AW_ORPHANED_BRANCH_NAME: %s\n", branchName)) + // Pass the staged flag if it's set to true if data.SafeOutputs.Staged != nil && *data.SafeOutputs.Staged { steps = append(steps, " GITHUB_AW_SAFE_OUTPUTS_STAGED: \"true\"\n") diff --git a/pkg/workflow/output_push_to_orphaned_branch_test.go b/pkg/workflow/output_push_to_orphaned_branch_test.go index b7f5f820f4..2c9926fc55 100644 --- a/pkg/workflow/output_push_to_orphaned_branch_test.go +++ b/pkg/workflow/output_push_to_orphaned_branch_test.go @@ -10,6 +10,7 @@ func TestBuildCreateOutputPushToOrphanedBranchJob(t *testing.T) { t.Run("basic_configuration", func(t *testing.T) { workflowData := &WorkflowData{ + Name: "test-workflow", SafeOutputs: &SafeOutputsConfig{ PushToOrphanedBranch: &PushToOrphanedBranchConfig{ Max: 3, @@ -38,6 +39,12 @@ func TestBuildCreateOutputPushToOrphanedBranchJob(t *testing.T) { t.Errorf("Expected timeout of 10 minutes, got: %d", job.TimeoutMinutes) } + // Check for default branch name in environment variables + stepsStr := strings.Join(job.Steps, "") + if !strings.Contains(stepsStr, "GITHUB_AW_ORPHANED_BRANCH_NAME: assets/test-workflow") { + t.Errorf("Expected default branch name 'assets/test-workflow' in steps") + } + // Check that the main job is a dependency found := false for _, need := range job.Needs { @@ -60,14 +67,14 @@ func TestBuildCreateOutputPushToOrphanedBranchJob(t *testing.T) { } // Check that steps contain expected elements - stepsStr := strings.Join(job.Steps, "") - if !strings.Contains(stepsStr, "Checkout repository") { + stepsString := strings.Join(job.Steps, "") + if !strings.Contains(stepsString, "Checkout repository") { t.Errorf("Expected checkout step") } - if !strings.Contains(stepsStr, "Push to Orphaned Branch") { + if !strings.Contains(stepsString, "Push to Orphaned Branch") { t.Errorf("Expected push to orphaned branch step") } - if !strings.Contains(stepsStr, "GITHUB_AW_ORPHANED_BRANCH_MAX_COUNT: 3") { + if !strings.Contains(stepsString, "GITHUB_AW_ORPHANED_BRANCH_MAX_COUNT: 3") { t.Errorf("Expected max count environment variable to be set") } }) @@ -125,6 +132,38 @@ func TestBuildCreateOutputPushToOrphanedBranchJob(t *testing.T) { }) } +func TestBuildCreateOutputPushToOrphanedBranchJobWithCustomBranch(t *testing.T) { + compiler := NewCompiler(false, "", "1.0.0") + + t.Run("custom_branch_configuration", func(t *testing.T) { + workflowData := &WorkflowData{ + Name: "test-workflow", + SafeOutputs: &SafeOutputsConfig{ + PushToOrphanedBranch: &PushToOrphanedBranchConfig{ + Max: 2, + Branch: "custom-uploads", + }, + }, + } + + job, err := compiler.buildCreateOutputPushToOrphanedBranchJob(workflowData, "main_job") + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + // Check for custom branch name in environment variables + stepsStr := strings.Join(job.Steps, "") + if !strings.Contains(stepsStr, "GITHUB_AW_ORPHANED_BRANCH_NAME: custom-uploads") { + t.Errorf("Expected custom branch name 'custom-uploads' in steps, got: %s", stepsStr) + } + + // Check that max count is correctly set + if !strings.Contains(stepsStr, "GITHUB_AW_ORPHANED_BRANCH_MAX_COUNT: 2") { + t.Errorf("Expected max count 2 in environment variables") + } + }) +} + func TestHasSafeOutputsEnabledWithOrphanedBranch(t *testing.T) { t.Run("enabled_with_orphaned_branch", func(t *testing.T) { config := &SafeOutputsConfig{ diff --git a/schemas/agent-output.json b/schemas/agent-output.json index fcbd553258..fb9d2d88e4 100644 --- a/schemas/agent-output.json +++ b/schemas/agent-output.json @@ -204,7 +204,17 @@ }, "filename": { "type": "string", - "description": "Name of the file to upload. Screenshots and images can be uploaded using this safe output.", + "description": "SHA-based filename with original extension (e.g., sha256hash.png). Screenshots and images can be uploaded using this safe output.", + "minLength": 1 + }, + "original_filename": { + "type": "string", + "description": "Original filename as provided by the user", + "minLength": 1 + }, + "sha": { + "type": "string", + "description": "SHA256 hash of the file content", "minLength": 1 }, "content": { @@ -212,7 +222,7 @@ "description": "Base64 encoded file content" } }, - "required": ["type", "filename", "content"], + "required": ["type", "filename", "original_filename", "sha", "content"], "additionalProperties": false }, "CreatePullRequestReviewCommentOutput": { From adbd59b9a5a67af49bbc9906763221e127e85a93 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 16 Sep 2025 04:57:12 +0000 Subject: [PATCH 06/12] Use branch name instead of commit SHA in raw URLs and add expected URL to MCP response Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/ci-doctor.lock.yml | 9 +++++++-- .github/workflows/dev.lock.yml | 9 +++++++-- pkg/workflow/js/push_to_orphaned_branch.cjs | 4 ++-- pkg/workflow/js/safe_outputs_mcp_server.cjs | 12 ++++++++++-- 4 files changed, 26 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci-doctor.lock.yml b/.github/workflows/ci-doctor.lock.yml index 40d0edf233..5ad91cc237 100644 --- a/.github/workflows/ci-doctor.lock.yml +++ b/.github/workflows/ci-doctor.lock.yml @@ -435,12 +435,17 @@ jobs: content: base64Content, }; appendSafeOutput(entry); - // Return response with SHA information + // Get branch configuration if available + const branchConfig = safeOutputsConfig["push-to-orphaned-branch"]?.branch; + const branchName = branchConfig || "assets/{workflow-name}"; + // Create template URL (will be resolved during GitHub Actions execution) + const templateUrl = `https://raw.githubusercontent.com/{owner}/{repo}/${branchName}/${shaFilename}`; + // Return response with SHA information and expected URL return { content: [ { type: "text", - text: `File uploaded successfully. SHA: ${fileSha}, Original filename: ${path.basename(filename)}`, + text: `File uploaded successfully. SHA: ${fileSha}, Original filename: ${path.basename(filename)}, Expected URL: ${templateUrl}`, }, ], }; diff --git a/.github/workflows/dev.lock.yml b/.github/workflows/dev.lock.yml index 25564fedca..2d8df95cb6 100644 --- a/.github/workflows/dev.lock.yml +++ b/.github/workflows/dev.lock.yml @@ -614,12 +614,17 @@ jobs: content: base64Content, }; appendSafeOutput(entry); - // Return response with SHA information + // Get branch configuration if available + const branchConfig = safeOutputsConfig["push-to-orphaned-branch"]?.branch; + const branchName = branchConfig || "assets/{workflow-name}"; + // Create template URL (will be resolved during GitHub Actions execution) + const templateUrl = `https://raw.githubusercontent.com/{owner}/{repo}/${branchName}/${shaFilename}`; + // Return response with SHA information and expected URL return { content: [ { type: "text", - text: `File uploaded successfully. SHA: ${fileSha}, Original filename: ${path.basename(filename)}`, + text: `File uploaded successfully. SHA: ${fileSha}, Original filename: ${path.basename(filename)}, Expected URL: ${templateUrl}`, }, ], }; diff --git a/pkg/workflow/js/push_to_orphaned_branch.cjs b/pkg/workflow/js/push_to_orphaned_branch.cjs index 529a41978d..d40148fd5e 100644 --- a/pkg/workflow/js/push_to_orphaned_branch.cjs +++ b/pkg/workflow/js/push_to_orphaned_branch.cjs @@ -137,9 +137,9 @@ if (isStaged) { }).trim(); core.info(`Pushed to orphaned branch with commit: ${commitSha}`); - // Generate GitHub raw URLs + // Generate GitHub raw URLs using branch name for (const filename of uploadedFiles) { - const rawUrl = `https://raw.githubusercontent.com/${owner}/${repo.repo}/${commitSha}/${filename}`; + const rawUrl = `https://raw.githubusercontent.com/${owner}/${repo.repo}/${branchName}/${filename}`; fileUrls.push(rawUrl); core.info(`File URL: ${rawUrl}`); } diff --git a/pkg/workflow/js/safe_outputs_mcp_server.cjs b/pkg/workflow/js/safe_outputs_mcp_server.cjs index ed472cb9d0..944177c59e 100644 --- a/pkg/workflow/js/safe_outputs_mcp_server.cjs +++ b/pkg/workflow/js/safe_outputs_mcp_server.cjs @@ -379,12 +379,20 @@ const TOOLS = Object.fromEntries( appendSafeOutput(entry); - // Return response with SHA information + // Get branch configuration if available + const branchConfig = + safeOutputsConfig["push-to-orphaned-branch"]?.branch; + const branchName = branchConfig || "assets/{workflow-name}"; + + // Create template URL (will be resolved during GitHub Actions execution) + const templateUrl = `https://raw.githubusercontent.com/{owner}/{repo}/${branchName}/${shaFilename}`; + + // Return response with SHA information and expected URL return { content: [ { type: "text", - text: `File uploaded successfully. SHA: ${fileSha}, Original filename: ${path.basename(filename)}`, + text: `File uploaded successfully. SHA: ${fileSha}, Original filename: ${path.basename(filename)}, Expected URL: ${templateUrl}`, }, ], }; From b0bf19eb3657d934120701d6e7652ac9a7e744cd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 16 Sep 2025 05:57:12 +0000 Subject: [PATCH 07/12] Add comprehensive validation and security features to push-to-orphaned-branch Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/ci-doctor.lock.yml | 24 +++++++- .github/workflows/dev.lock.yml | 24 +++++++- pkg/workflow/js/push_to_orphaned_branch.cjs | 21 ++++++- pkg/workflow/js/safe_outputs_mcp_server.cjs | 56 ++++++++++++++++++- .../output_push_to_orphaned_branch.go | 1 + 5 files changed, 119 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci-doctor.lock.yml b/.github/workflows/ci-doctor.lock.yml index 5ad91cc237..b4a8a27fe0 100644 --- a/.github/workflows/ci-doctor.lock.yml +++ b/.github/workflows/ci-doctor.lock.yml @@ -418,6 +418,12 @@ jobs: } // Read file and encode as base64 const fileContent = fs.readFileSync(filename); + // Check file size (10MB limit) + const fileSizeBytes = fileContent.length; + const maxSizeBytes = 10 * 1024 * 1024; // 10MB + if (fileSizeBytes > maxSizeBytes) { + throw new Error(`File size ${Math.round(fileSizeBytes / 1024 / 1024)}MB exceeds 10MB limit`); + } const base64Content = fileContent.toString("base64"); // Compute SHA256 hash of the file content const hash = crypto.createHash("sha256"); @@ -425,6 +431,15 @@ jobs: const fileSha = hash.digest("hex"); // Get file extension from original filename const originalExtension = path.extname(filename); + // Validate file extension is reasonable + const allowedExtensions = [ + '.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp', '.ico', + '.pdf', '.txt', '.md', '.json', '.yaml', '.yml', '.xml', '.csv', + '.log', '.zip', '.tar', '.gz', '.html', '.css', '.js', '.ts' + ]; + if (originalExtension && !allowedExtensions.includes(originalExtension.toLowerCase())) { + throw new Error(`File extension '${originalExtension}' is not allowed. Allowed extensions: ${allowedExtensions.join(', ')}`); + } const shaFilename = fileSha + originalExtension; // Create the output entry with base64 content and SHA filename const entry = { @@ -436,10 +451,14 @@ jobs: }; appendSafeOutput(entry); // Get branch configuration if available - const branchConfig = safeOutputsConfig["push-to-orphaned-branch"]?.branch; + const branchConfig = + safeOutputsConfig["push-to-orphaned-branch"]?.branch; const branchName = branchConfig || "assets/{workflow-name}"; + // Get repository information from environment or use placeholders + const owner = process.env.GITHUB_REPOSITORY_OWNER || "{owner}"; + const repo = process.env.GITHUB_REPOSITORY ? process.env.GITHUB_REPOSITORY.split('/')[1] : "{repo}"; // Create template URL (will be resolved during GitHub Actions execution) - const templateUrl = `https://raw.githubusercontent.com/{owner}/{repo}/${branchName}/${shaFilename}`; + const templateUrl = `https://raw.githubusercontent.com/${owner}/${repo}/${branchName}/${shaFilename}`; // Return response with SHA information and expected URL return { content: [ @@ -448,6 +467,7 @@ jobs: text: `File uploaded successfully. SHA: ${fileSha}, Original filename: ${path.basename(filename)}, Expected URL: ${templateUrl}`, }, ], + url: templateUrl, }; }, }, diff --git a/.github/workflows/dev.lock.yml b/.github/workflows/dev.lock.yml index 2d8df95cb6..7251c4cb46 100644 --- a/.github/workflows/dev.lock.yml +++ b/.github/workflows/dev.lock.yml @@ -597,6 +597,12 @@ jobs: } // Read file and encode as base64 const fileContent = fs.readFileSync(filename); + // Check file size (10MB limit) + const fileSizeBytes = fileContent.length; + const maxSizeBytes = 10 * 1024 * 1024; // 10MB + if (fileSizeBytes > maxSizeBytes) { + throw new Error(`File size ${Math.round(fileSizeBytes / 1024 / 1024)}MB exceeds 10MB limit`); + } const base64Content = fileContent.toString("base64"); // Compute SHA256 hash of the file content const hash = crypto.createHash("sha256"); @@ -604,6 +610,15 @@ jobs: const fileSha = hash.digest("hex"); // Get file extension from original filename const originalExtension = path.extname(filename); + // Validate file extension is reasonable + const allowedExtensions = [ + '.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp', '.ico', + '.pdf', '.txt', '.md', '.json', '.yaml', '.yml', '.xml', '.csv', + '.log', '.zip', '.tar', '.gz', '.html', '.css', '.js', '.ts' + ]; + if (originalExtension && !allowedExtensions.includes(originalExtension.toLowerCase())) { + throw new Error(`File extension '${originalExtension}' is not allowed. Allowed extensions: ${allowedExtensions.join(', ')}`); + } const shaFilename = fileSha + originalExtension; // Create the output entry with base64 content and SHA filename const entry = { @@ -615,10 +630,14 @@ jobs: }; appendSafeOutput(entry); // Get branch configuration if available - const branchConfig = safeOutputsConfig["push-to-orphaned-branch"]?.branch; + const branchConfig = + safeOutputsConfig["push-to-orphaned-branch"]?.branch; const branchName = branchConfig || "assets/{workflow-name}"; + // Get repository information from environment or use placeholders + const owner = process.env.GITHUB_REPOSITORY_OWNER || "{owner}"; + const repo = process.env.GITHUB_REPOSITORY ? process.env.GITHUB_REPOSITORY.split('/')[1] : "{repo}"; // Create template URL (will be resolved during GitHub Actions execution) - const templateUrl = `https://raw.githubusercontent.com/{owner}/{repo}/${branchName}/${shaFilename}`; + const templateUrl = `https://raw.githubusercontent.com/${owner}/${repo}/${branchName}/${shaFilename}`; // Return response with SHA information and expected URL return { content: [ @@ -627,6 +646,7 @@ jobs: text: `File uploaded successfully. SHA: ${fileSha}, Original filename: ${path.basename(filename)}, Expected URL: ${templateUrl}`, }, ], + url: templateUrl, }; }, }, diff --git a/pkg/workflow/js/push_to_orphaned_branch.cjs b/pkg/workflow/js/push_to_orphaned_branch.cjs index d40148fd5e..f8b164e990 100644 --- a/pkg/workflow/js/push_to_orphaned_branch.cjs +++ b/pkg/workflow/js/push_to_orphaned_branch.cjs @@ -51,6 +51,7 @@ core.info( const uploadedFiles = []; const fileUrls = []; +let commitSha = null; if (isStaged) { // In staged mode, just show what would be uploaded @@ -98,12 +99,25 @@ if (isStaged) { // Decode base64 content and write file const fileBuffer = Buffer.from(content, "base64"); + // Validate SHA matches the file content + const crypto = require("crypto"); + const computedHash = crypto.createHash("sha256"); + computedHash.update(fileBuffer); + const computedSha = computedHash.digest("hex"); + + const fileSha = sha || "unknown"; + if (fileSha !== "unknown" && fileSha !== computedSha) { + core.setFailed( + `SHA validation failed for ${filename}. Expected: ${fileSha}, Computed: ${computedSha}` + ); + return; + } + // Use the SHA-based filename directly (it already includes the extension) const safeFilename = filename.replace(/[^a-zA-Z0-9._-]/g, "_"); fs.writeFileSync(safeFilename, fileBuffer); const originalName = original_filename || filename; - const fileSha = sha || "unknown"; core.info( `Created file: ${safeFilename} (${fileBuffer.length} bytes) - SHA: ${fileSha} - Original: ${originalName}` ); @@ -132,7 +146,7 @@ if (isStaged) { execSync(`git push origin ${branchName}`, { stdio: "inherit" }); // Get the commit SHA - const commitSha = execSync(`git rev-parse HEAD`, { + commitSha = execSync(`git rev-parse HEAD`, { encoding: "utf8", }).trim(); core.info(`Pushed to orphaned branch with commit: ${commitSha}`); @@ -176,6 +190,9 @@ if (isStaged) { // Set outputs core.setOutput("uploaded_files", JSON.stringify(uploadedFiles)); core.setOutput("file_urls", JSON.stringify(fileUrls)); +if (commitSha) { + core.setOutput("commit_sha", commitSha); +} core.info( `Successfully processed ${uploadedFiles.length} file(s) for orphaned branch upload` diff --git a/pkg/workflow/js/safe_outputs_mcp_server.cjs b/pkg/workflow/js/safe_outputs_mcp_server.cjs index 944177c59e..8cddb0c77c 100644 --- a/pkg/workflow/js/safe_outputs_mcp_server.cjs +++ b/pkg/workflow/js/safe_outputs_mcp_server.cjs @@ -357,6 +357,16 @@ const TOOLS = Object.fromEntries( // Read file and encode as base64 const fileContent = fs.readFileSync(filename); + + // Check file size (10MB limit) + const fileSizeBytes = fileContent.length; + const maxSizeBytes = 10 * 1024 * 1024; // 10MB + if (fileSizeBytes > maxSizeBytes) { + throw new Error( + `File size ${Math.round(fileSizeBytes / 1024 / 1024)}MB exceeds 10MB limit` + ); + } + const base64Content = fileContent.toString("base64"); // Compute SHA256 hash of the file content @@ -366,6 +376,43 @@ const TOOLS = Object.fromEntries( // Get file extension from original filename const originalExtension = path.extname(filename); + + // Validate file extension is reasonable + const allowedExtensions = [ + ".png", + ".jpg", + ".jpeg", + ".gif", + ".webp", + ".svg", + ".bmp", + ".ico", + ".pdf", + ".txt", + ".md", + ".json", + ".yaml", + ".yml", + ".xml", + ".csv", + ".log", + ".zip", + ".tar", + ".gz", + ".html", + ".css", + ".js", + ".ts", + ]; + if ( + originalExtension && + !allowedExtensions.includes(originalExtension.toLowerCase()) + ) { + throw new Error( + `File extension '${originalExtension}' is not allowed. Allowed extensions: ${allowedExtensions.join(", ")}` + ); + } + const shaFilename = fileSha + originalExtension; // Create the output entry with base64 content and SHA filename @@ -384,8 +431,14 @@ const TOOLS = Object.fromEntries( safeOutputsConfig["push-to-orphaned-branch"]?.branch; const branchName = branchConfig || "assets/{workflow-name}"; + // Get repository information from environment or use placeholders + const owner = process.env.GITHUB_REPOSITORY_OWNER || "{owner}"; + const repo = process.env.GITHUB_REPOSITORY + ? process.env.GITHUB_REPOSITORY.split("/")[1] + : "{repo}"; + // Create template URL (will be resolved during GitHub Actions execution) - const templateUrl = `https://raw.githubusercontent.com/{owner}/{repo}/${branchName}/${shaFilename}`; + const templateUrl = `https://raw.githubusercontent.com/${owner}/${repo}/${branchName}/${shaFilename}`; // Return response with SHA information and expected URL return { @@ -395,6 +448,7 @@ const TOOLS = Object.fromEntries( text: `File uploaded successfully. SHA: ${fileSha}, Original filename: ${path.basename(filename)}, Expected URL: ${templateUrl}`, }, ], + url: templateUrl, }; }, }, diff --git a/pkg/workflow/output_push_to_orphaned_branch.go b/pkg/workflow/output_push_to_orphaned_branch.go index d1e187cbb7..4d4d094c53 100644 --- a/pkg/workflow/output_push_to_orphaned_branch.go +++ b/pkg/workflow/output_push_to_orphaned_branch.go @@ -67,6 +67,7 @@ func (c *Compiler) buildCreateOutputPushToOrphanedBranchJob(data *WorkflowData, outputs := map[string]string{ "uploaded_files": "${{ steps.push_to_orphaned_branch.outputs.uploaded_files }}", "file_urls": "${{ steps.push_to_orphaned_branch.outputs.file_urls }}", + "commit_sha": "${{ steps.push_to_orphaned_branch.outputs.commit_sha }}", } // This job can run in any context since it only uploads files to orphaned branches From c2b4746e44a3286abe5c6e3c15de4e9a7cadb3ad Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 16 Sep 2025 11:21:31 +0000 Subject: [PATCH 08/12] Update file extension validation and remove unsupported url field from MCP response Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/ci-doctor.lock.yml | 23 +- .github/workflows/dev.lock.yml | 470 +++++++++++++++++++- .github/workflows/dev.md | 16 +- pkg/workflow/js/safe_outputs_mcp_server.cjs | 46 +- 4 files changed, 491 insertions(+), 64 deletions(-) diff --git a/.github/workflows/ci-doctor.lock.yml b/.github/workflows/ci-doctor.lock.yml index b4a8a27fe0..4103f8a02d 100644 --- a/.github/workflows/ci-doctor.lock.yml +++ b/.github/workflows/ci-doctor.lock.yml @@ -422,7 +422,9 @@ jobs: const fileSizeBytes = fileContent.length; const maxSizeBytes = 10 * 1024 * 1024; // 10MB if (fileSizeBytes > maxSizeBytes) { - throw new Error(`File size ${Math.round(fileSizeBytes / 1024 / 1024)}MB exceeds 10MB limit`); + throw new Error( + `File size ${Math.round(fileSizeBytes / 1024 / 1024)}MB exceeds 10MB limit` + ); } const base64Content = fileContent.toString("base64"); // Compute SHA256 hash of the file content @@ -431,14 +433,12 @@ jobs: const fileSha = hash.digest("hex"); // Get file extension from original filename const originalExtension = path.extname(filename); - // Validate file extension is reasonable - const allowedExtensions = [ - '.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp', '.ico', - '.pdf', '.txt', '.md', '.json', '.yaml', '.yml', '.xml', '.csv', - '.log', '.zip', '.tar', '.gz', '.html', '.css', '.js', '.ts' - ]; - if (originalExtension && !allowedExtensions.includes(originalExtension.toLowerCase())) { - throw new Error(`File extension '${originalExtension}' is not allowed. Allowed extensions: ${allowedExtensions.join(', ')}`); + // Validate file extension is reasonable (up to 5 alphanumeric characters) + if (originalExtension) { + const extWithoutDot = originalExtension.slice(1); // Remove the leading dot + if (extWithoutDot.length > 5 || !/^[a-zA-Z0-9]+$/.test(extWithoutDot)) { + throw new Error(`File extension '${originalExtension}' is not allowed. Extension must be up to 5 alphanumeric characters.`); + } } const shaFilename = fileSha + originalExtension; // Create the output entry with base64 content and SHA filename @@ -456,7 +456,9 @@ jobs: const branchName = branchConfig || "assets/{workflow-name}"; // Get repository information from environment or use placeholders const owner = process.env.GITHUB_REPOSITORY_OWNER || "{owner}"; - const repo = process.env.GITHUB_REPOSITORY ? process.env.GITHUB_REPOSITORY.split('/')[1] : "{repo}"; + const repo = process.env.GITHUB_REPOSITORY + ? process.env.GITHUB_REPOSITORY.split("/")[1] + : "{repo}"; // Create template URL (will be resolved during GitHub Actions execution) const templateUrl = `https://raw.githubusercontent.com/${owner}/${repo}/${branchName}/${shaFilename}`; // Return response with SHA information and expected URL @@ -467,7 +469,6 @@ jobs: text: `File uploaded successfully. SHA: ${fileSha}, Original filename: ${path.basename(filename)}, Expected URL: ${templateUrl}`, }, ], - url: templateUrl, }; }, }, diff --git a/.github/workflows/dev.lock.yml b/.github/workflows/dev.lock.yml index 7251c4cb46..224be31afd 100644 --- a/.github/workflows/dev.lock.yml +++ b/.github/workflows/dev.lock.yml @@ -252,7 +252,7 @@ jobs: main(); - name: Setup Safe Outputs Collector MCP env: - GITHUB_AW_SAFE_OUTPUTS_CONFIG: "{\"missing-tool\":{\"enabled\":true}}" + GITHUB_AW_SAFE_OUTPUTS_CONFIG: "{\"create-issue\":true,\"missing-tool\":{\"enabled\":true},\"push-to-orphaned-branch\":{\"enabled\":true,\"max\":3}}" run: | mkdir -p /tmp/safe-outputs cat > /tmp/safe-outputs/mcp-server.cjs << 'EOF' @@ -601,7 +601,9 @@ jobs: const fileSizeBytes = fileContent.length; const maxSizeBytes = 10 * 1024 * 1024; // 10MB if (fileSizeBytes > maxSizeBytes) { - throw new Error(`File size ${Math.round(fileSizeBytes / 1024 / 1024)}MB exceeds 10MB limit`); + throw new Error( + `File size ${Math.round(fileSizeBytes / 1024 / 1024)}MB exceeds 10MB limit` + ); } const base64Content = fileContent.toString("base64"); // Compute SHA256 hash of the file content @@ -610,14 +612,12 @@ jobs: const fileSha = hash.digest("hex"); // Get file extension from original filename const originalExtension = path.extname(filename); - // Validate file extension is reasonable - const allowedExtensions = [ - '.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp', '.ico', - '.pdf', '.txt', '.md', '.json', '.yaml', '.yml', '.xml', '.csv', - '.log', '.zip', '.tar', '.gz', '.html', '.css', '.js', '.ts' - ]; - if (originalExtension && !allowedExtensions.includes(originalExtension.toLowerCase())) { - throw new Error(`File extension '${originalExtension}' is not allowed. Allowed extensions: ${allowedExtensions.join(', ')}`); + // Validate file extension is reasonable (up to 5 alphanumeric characters) + if (originalExtension) { + const extWithoutDot = originalExtension.slice(1); // Remove the leading dot + if (extWithoutDot.length > 5 || !/^[a-zA-Z0-9]+$/.test(extWithoutDot)) { + throw new Error(`File extension '${originalExtension}' is not allowed. Extension must be up to 5 alphanumeric characters.`); + } } const shaFilename = fileSha + originalExtension; // Create the output entry with base64 content and SHA filename @@ -635,7 +635,9 @@ jobs: const branchName = branchConfig || "assets/{workflow-name}"; // Get repository information from environment or use placeholders const owner = process.env.GITHUB_REPOSITORY_OWNER || "{owner}"; - const repo = process.env.GITHUB_REPOSITORY ? process.env.GITHUB_REPOSITORY.split('/')[1] : "{repo}"; + const repo = process.env.GITHUB_REPOSITORY + ? process.env.GITHUB_REPOSITORY.split("/")[1] + : "{repo}"; // Create template URL (will be resolved during GitHub Actions execution) const templateUrl = `https://raw.githubusercontent.com/${owner}/${repo}/${branchName}/${shaFilename}`; // Return response with SHA information and expected URL @@ -646,7 +648,6 @@ jobs: text: `File uploaded successfully. SHA: ${fileSha}, Original filename: ${path.basename(filename)}, Expected URL: ${templateUrl}`, }, ], - url: templateUrl, }; }, }, @@ -769,7 +770,7 @@ jobs: - name: Setup MCPs env: GITHUB_AW_SAFE_OUTPUTS: ${{ env.GITHUB_AW_SAFE_OUTPUTS }} - GITHUB_AW_SAFE_OUTPUTS_CONFIG: "{\"missing-tool\":{\"enabled\":true}}" + GITHUB_AW_SAFE_OUTPUTS_CONFIG: "{\"create-issue\":true,\"missing-tool\":{\"enabled\":true},\"push-to-orphaned-branch\":{\"enabled\":true,\"max\":3}}" run: | mkdir -p /tmp/mcp-config cat > /tmp/mcp-config/mcp-servers.json << 'EOF' @@ -789,6 +790,14 @@ jobs: "GITHUB_PERSONAL_ACCESS_TOKEN": "${{ secrets.GITHUB_TOKEN }}" } }, + "playwright": { + "command": "npx", + "args": [ + "@playwright/mcp@latest", + "--allowed-origins", + "localhost,127.0.0.1,*.github.com,github.com" + ] + }, "safe_outputs": { "command": "node", "args": ["/tmp/safe-outputs/mcp-server.cjs"], @@ -807,12 +816,18 @@ jobs: run: | mkdir -p /tmp/aw-prompts cat > $GITHUB_AW_PROMPT << 'EOF' - Try to call a tool, `draw_pelican` that draws a pelican. + You have access to a `push-to-orphaned-branch` tool that can upload files (like screenshots) to an orphaned branch and return a GitHub raw URL. Use the expected URL from the response in your issue descriptions. + + Please: + 1. Build the documentation by running appropriate build commands + 2. Take a screenshot of the documentation using playwright + 3. Upload the screenshot using the push-to-orphaned-branch tool - it will give you a URL to use + 4. Create an issue describing the documentation status and include the screenshot using the URL provided by the upload tool --- - ## Reporting Missing Tools or Functionality + ## Creating an IssueUploading Files to Orphaned Branch, Reporting Missing Tools or Functionality **IMPORTANT**: To do the actions mentioned in the header of this section, use the **safe-outputs** tools, do NOT attempt to use `gh`, do NOT attempt to use the GitHub API. You don't have write access to the GitHub repo. EOF @@ -920,11 +935,32 @@ jobs: # - mcp__github__search_pull_requests # - mcp__github__search_repositories # - mcp__github__search_users + # - mcp__playwright__browser_click + # - mcp__playwright__browser_close + # - mcp__playwright__browser_console_messages + # - mcp__playwright__browser_drag + # - mcp__playwright__browser_evaluate + # - mcp__playwright__browser_file_upload + # - mcp__playwright__browser_fill_form + # - mcp__playwright__browser_handle_dialog + # - mcp__playwright__browser_hover + # - mcp__playwright__browser_install + # - mcp__playwright__browser_navigate + # - mcp__playwright__browser_navigate_back + # - mcp__playwright__browser_network_requests + # - mcp__playwright__browser_press_key + # - mcp__playwright__browser_resize + # - mcp__playwright__browser_select_option + # - mcp__playwright__browser_snapshot + # - mcp__playwright__browser_tabs + # - mcp__playwright__browser_take_screenshot + # - mcp__playwright__browser_type + # - mcp__playwright__browser_wait_for timeout-minutes: 5 run: | set -o pipefail # Execute Claude Code CLI with prompt from file - npx @anthropic-ai/claude-code@latest --print --max-turns 5 --mcp-config /tmp/mcp-config/mcp-servers.json --allowed-tools "ExitPlanMode,Glob,Grep,LS,NotebookRead,Read,Task,TodoWrite,Write,mcp__github__download_workflow_run_artifact,mcp__github__get_code_scanning_alert,mcp__github__get_commit,mcp__github__get_dependabot_alert,mcp__github__get_discussion,mcp__github__get_discussion_comments,mcp__github__get_file_contents,mcp__github__get_issue,mcp__github__get_issue_comments,mcp__github__get_job_logs,mcp__github__get_me,mcp__github__get_notification_details,mcp__github__get_pull_request,mcp__github__get_pull_request_comments,mcp__github__get_pull_request_diff,mcp__github__get_pull_request_files,mcp__github__get_pull_request_reviews,mcp__github__get_pull_request_status,mcp__github__get_secret_scanning_alert,mcp__github__get_tag,mcp__github__get_workflow_run,mcp__github__get_workflow_run_logs,mcp__github__get_workflow_run_usage,mcp__github__list_branches,mcp__github__list_code_scanning_alerts,mcp__github__list_commits,mcp__github__list_dependabot_alerts,mcp__github__list_discussion_categories,mcp__github__list_discussions,mcp__github__list_issues,mcp__github__list_notifications,mcp__github__list_pull_requests,mcp__github__list_secret_scanning_alerts,mcp__github__list_tags,mcp__github__list_workflow_jobs,mcp__github__list_workflow_run_artifacts,mcp__github__list_workflow_runs,mcp__github__list_workflows,mcp__github__search_code,mcp__github__search_issues,mcp__github__search_orgs,mcp__github__search_pull_requests,mcp__github__search_repositories,mcp__github__search_users" --debug --verbose --permission-mode bypassPermissions --output-format json --settings /tmp/.claude/settings.json "$(cat /tmp/aw-prompts/prompt.txt)" 2>&1 | tee /tmp/dev.log + npx @anthropic-ai/claude-code@latest --print --max-turns 5 --mcp-config /tmp/mcp-config/mcp-servers.json --allowed-tools "ExitPlanMode,Glob,Grep,LS,NotebookRead,Read,Task,TodoWrite,Write,mcp__github__download_workflow_run_artifact,mcp__github__get_code_scanning_alert,mcp__github__get_commit,mcp__github__get_dependabot_alert,mcp__github__get_discussion,mcp__github__get_discussion_comments,mcp__github__get_file_contents,mcp__github__get_issue,mcp__github__get_issue_comments,mcp__github__get_job_logs,mcp__github__get_me,mcp__github__get_notification_details,mcp__github__get_pull_request,mcp__github__get_pull_request_comments,mcp__github__get_pull_request_diff,mcp__github__get_pull_request_files,mcp__github__get_pull_request_reviews,mcp__github__get_pull_request_status,mcp__github__get_secret_scanning_alert,mcp__github__get_tag,mcp__github__get_workflow_run,mcp__github__get_workflow_run_logs,mcp__github__get_workflow_run_usage,mcp__github__list_branches,mcp__github__list_code_scanning_alerts,mcp__github__list_commits,mcp__github__list_dependabot_alerts,mcp__github__list_discussion_categories,mcp__github__list_discussions,mcp__github__list_issues,mcp__github__list_notifications,mcp__github__list_pull_requests,mcp__github__list_secret_scanning_alerts,mcp__github__list_tags,mcp__github__list_workflow_jobs,mcp__github__list_workflow_run_artifacts,mcp__github__list_workflow_runs,mcp__github__list_workflows,mcp__github__search_code,mcp__github__search_issues,mcp__github__search_orgs,mcp__github__search_pull_requests,mcp__github__search_repositories,mcp__github__search_users,mcp__playwright__browser_click,mcp__playwright__browser_close,mcp__playwright__browser_console_messages,mcp__playwright__browser_drag,mcp__playwright__browser_evaluate,mcp__playwright__browser_file_upload,mcp__playwright__browser_fill_form,mcp__playwright__browser_handle_dialog,mcp__playwright__browser_hover,mcp__playwright__browser_install,mcp__playwright__browser_navigate,mcp__playwright__browser_navigate_back,mcp__playwright__browser_network_requests,mcp__playwright__browser_press_key,mcp__playwright__browser_resize,mcp__playwright__browser_select_option,mcp__playwright__browser_snapshot,mcp__playwright__browser_tabs,mcp__playwright__browser_take_screenshot,mcp__playwright__browser_type,mcp__playwright__browser_wait_for" --debug --verbose --permission-mode bypassPermissions --output-format json --settings /tmp/.claude/settings.json "$(cat /tmp/aw-prompts/prompt.txt)" 2>&1 | tee /tmp/dev.log env: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} DISABLE_TELEMETRY: "1" @@ -972,7 +1008,7 @@ jobs: uses: actions/github-script@v7 env: GITHUB_AW_SAFE_OUTPUTS: ${{ env.GITHUB_AW_SAFE_OUTPUTS }} - GITHUB_AW_SAFE_OUTPUTS_CONFIG: "{\"missing-tool\":{\"enabled\":true}}" + GITHUB_AW_SAFE_OUTPUTS_CONFIG: "{\"create-issue\":true,\"missing-tool\":{\"enabled\":true},\"push-to-orphaned-branch\":{\"enabled\":true,\"max\":3}}" with: script: | async function main() { @@ -2274,6 +2310,406 @@ jobs: path: /tmp/dev.log if-no-files-found: warn + create_issue: + needs: dev + runs-on: ubuntu-latest + permissions: + contents: read + issues: write + timeout-minutes: 10 + outputs: + issue_number: ${{ steps.create_issue.outputs.issue_number }} + issue_url: ${{ steps.create_issue.outputs.issue_url }} + steps: + - name: Create Output Issue + id: create_issue + uses: actions/github-script@v7 + env: + GITHUB_AW_AGENT_OUTPUT: ${{ needs.dev.outputs.output }} + GITHUB_AW_SAFE_OUTPUTS_STAGED: "true" + with: + script: | + async function main() { + // Check if we're in staged mode + const isStaged = process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true"; + // Read the validated output content from environment variable + const outputContent = process.env.GITHUB_AW_AGENT_OUTPUT; + if (!outputContent) { + core.info("No GITHUB_AW_AGENT_OUTPUT environment variable found"); + return; + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return; + } + core.info(`Agent output content length: ${outputContent.length}`); + // Parse the validated output JSON + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } catch (error) { + core.setFailed( + `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}` + ); + return; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.info("No valid items found in agent output"); + return; + } + // Find all create-issue items + const createIssueItems = validatedOutput.items.filter( + /** @param {any} item */ item => item.type === "create-issue" + ); + if (createIssueItems.length === 0) { + core.info("No create-issue items found in agent output"); + return; + } + core.info(`Found ${createIssueItems.length} create-issue item(s)`); + // If in staged mode, emit step summary instead of creating issues + if (isStaged) { + let summaryContent = "## 🎭 Staged Mode: Create Issues Preview\n\n"; + summaryContent += + "The following issues would be created if staged mode was disabled:\n\n"; + for (let i = 0; i < createIssueItems.length; i++) { + const item = createIssueItems[i]; + summaryContent += `### Issue ${i + 1}\n`; + summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; + if (item.body) { + summaryContent += `**Body:**\n${item.body}\n\n`; + } + if (item.labels && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; + } + // Write to step summary + await core.summary.addRaw(summaryContent).write(); + core.info("📝 Issue creation preview written to step summary"); + return; + } + // Check if we're in an issue context (triggered by an issue event) + const parentIssueNumber = context.payload?.issue?.number; + // Parse labels from environment variable (comma-separated string) + const labelsEnv = process.env.GITHUB_AW_ISSUE_LABELS; + let envLabels = labelsEnv + ? labelsEnv + .split(",") + .map(/** @param {string} label */ label => label.trim()) + .filter(/** @param {string} label */ label => label) + : []; + const createdIssues = []; + // Process each create-issue item + for (let i = 0; i < createIssueItems.length; i++) { + const createIssueItem = createIssueItems[i]; + core.info( + `Processing create-issue item ${i + 1}/${createIssueItems.length}: title=${createIssueItem.title}, bodyLength=${createIssueItem.body.length}` + ); + // Merge environment labels with item-specific labels + let labels = [...envLabels]; + if (createIssueItem.labels && Array.isArray(createIssueItem.labels)) { + labels = [...labels, ...createIssueItem.labels].filter(Boolean); + } + // Extract title and body from the JSON item + let title = createIssueItem.title ? createIssueItem.title.trim() : ""; + let bodyLines = createIssueItem.body.split("\n"); + // If no title was found, use the body content as title (or a default) + if (!title) { + title = createIssueItem.body || "Agent Output"; + } + // Apply title prefix if provided via environment variable + const titlePrefix = process.env.GITHUB_AW_ISSUE_TITLE_PREFIX; + if (titlePrefix && !title.startsWith(titlePrefix)) { + title = titlePrefix + title; + } + if (parentIssueNumber) { + core.info("Detected issue context, parent issue #" + parentIssueNumber); + // Add reference to parent issue in the child issue body + bodyLines.push(`Related to #${parentIssueNumber}`); + } + // Add AI disclaimer with run id, run htmlurl + // Add AI disclaimer with workflow run information + const runId = context.runId; + const runUrl = context.payload.repository + ? `${context.payload.repository.html_url}/actions/runs/${runId}` + : `https://github.com/actions/runs/${runId}`; + bodyLines.push( + ``, + ``, + `> Generated by Agentic Workflow [Run](${runUrl})`, + "" + ); + // Prepare the body content + const body = bodyLines.join("\n").trim(); + core.info(`Creating issue with title: ${title}`); + core.info(`Labels: ${labels}`); + core.info(`Body length: ${body.length}`); + try { + // Create the issue using GitHub API + const { data: issue } = await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: title, + body: body, + labels: labels, + }); + core.info("Created issue #" + issue.number + ": " + issue.html_url); + createdIssues.push(issue); + // If we have a parent issue, add a comment to it referencing the new child issue + if (parentIssueNumber) { + try { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: parentIssueNumber, + body: `Created related issue: #${issue.number}`, + }); + core.info("Added comment to parent issue #" + parentIssueNumber); + } catch (error) { + core.info( + `Warning: Could not add comment to parent issue: ${error instanceof Error ? error.message : String(error)}` + ); + } + } + // Set output for the last created issue (for backward compatibility) + if (i === createIssueItems.length - 1) { + core.setOutput("issue_number", issue.number); + core.setOutput("issue_url", issue.html_url); + } + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + // Special handling for disabled issues repository + if ( + errorMessage.includes("Issues has been disabled in this repository") + ) { + core.info( + `⚠ Cannot create issue "${title}": Issues are disabled for this repository` + ); + core.info( + "Consider enabling issues in repository settings if you want to create issues automatically" + ); + continue; // Skip this issue but continue processing others + } + core.error(`✗ Failed to create issue "${title}": ${errorMessage}`); + throw error; + } + } + // Write summary for all created issues + if (createdIssues.length > 0) { + let summaryContent = "\n\n## GitHub Issues\n"; + for (const issue of createdIssues) { + summaryContent += `- Issue #${issue.number}: [${issue.title}](${issue.html_url})\n`; + } + await core.summary.addRaw(summaryContent).write(); + } + core.info(`Successfully created ${createdIssues.length} issue(s)`); + } + await main(); + + push_to_orphaned_branch: + needs: dev + if: always() + runs-on: ubuntu-latest + permissions: + contents: write + actions: read + timeout-minutes: 10 + outputs: + commit_sha: ${{ steps.push_to_orphaned_branch.outputs.commit_sha }} + file_urls: ${{ steps.push_to_orphaned_branch.outputs.file_urls }} + uploaded_files: ${{ steps.push_to_orphaned_branch.outputs.uploaded_files }} + steps: + - name: Checkout repository + uses: actions/checkout@v5 + with: + fetch-depth: 0 + - name: Configure Git credentials + run: | + git config --global user.email "github-actions[bot]@users.noreply.github.com" + git config --global user.name "${{ github.workflow }}" + echo "Git configured with standard GitHub Actions identity" + - name: Push to Orphaned Branch + id: push_to_orphaned_branch + uses: actions/github-script@v7 + env: + GH_TOKEN: ${{ github.token }} + GITHUB_AW_AGENT_OUTPUT: ${{ needs.dev.outputs.output }} + GITHUB_AW_ORPHANED_BRANCH_MAX_COUNT: 3 + GITHUB_AW_ORPHANED_BRANCH_NAME: assets/Dev + GITHUB_AW_SAFE_OUTPUTS_STAGED: "true" + with: + script: | + const { execSync } = require("child_process"); + const fs = require("fs"); + // Get environment variables + const agentOutput = process.env.GITHUB_AW_AGENT_OUTPUT || "{}"; + const maxCount = parseInt( + process.env.GITHUB_AW_ORPHANED_BRANCH_MAX_COUNT || "1" + ); + const branchName = + process.env.GITHUB_AW_ORPHANED_BRANCH_NAME || "assets/workflow"; + const isStaged = process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true"; + const repo = context.repo; + const owner = context.repo.owner; + core.info(`Processing agent output for orphaned branch upload`); + core.info(`Repository: ${owner}/${repo.repo}`); + core.info(`Max files allowed: ${maxCount}`); + core.info(`Branch name: ${branchName}`); + let parsedOutput; + try { + parsedOutput = JSON.parse(agentOutput); + } catch (error) { + core.setFailed( + `Failed to parse agent output: ${error instanceof Error ? error.message : String(error)}` + ); + return; + } + // Extract push-to-orphaned-branch items + const orphanedBranchItems = (parsedOutput.items || []).filter( + item => item.type === "push-to-orphaned-branch" + ); + if (orphanedBranchItems.length === 0) { + core.info("No orphaned branch upload items found in agent output"); + return; + } + if (orphanedBranchItems.length > maxCount) { + core.setFailed( + `Too many files to upload: ${orphanedBranchItems.length} (max: ${maxCount})` + ); + return; + } + core.info( + `Found ${orphanedBranchItems.length} file(s) to upload to orphaned branch` + ); + const uploadedFiles = []; + const fileUrls = []; + let commitSha = null; + if (isStaged) { + // In staged mode, just show what would be uploaded + core.summary.addHeading("Orphaned Branch File Upload (Staged Mode)", 2); + core.summary.addRaw( + "The following files would be uploaded to an orphaned branch:\n\n" + ); + for (const item of orphanedBranchItems) { + const originalFilename = item.original_filename || item.filename; + const sha = item.sha || "unknown"; + core.summary.addRaw( + `- **${item.filename}** (${Math.round(item.content.length * 0.75)} bytes) - SHA: ${sha} - Original: ${originalFilename}\n` + ); + uploadedFiles.push(item.filename); + fileUrls.push( + `https://raw.githubusercontent.com/${owner}/${repo.repo}/${branchName}/staged/${item.filename}` + ); + } + await core.summary.write(); + } else { + // Actually upload files to orphaned branch + try { + // Create or switch to orphaned branch + try { + execSync(`git checkout ${branchName}`, { stdio: "inherit" }); + core.info(`Switched to existing orphaned branch: ${branchName}`); + } catch (error) { + // Branch doesn't exist, create orphaned branch + execSync(`git checkout --orphan ${branchName}`, { stdio: "inherit" }); + execSync(`git rm -rf .`, { stdio: "inherit" }); + core.info(`Created new orphaned branch: ${branchName}`); + } + // Upload each file + for (const item of orphanedBranchItems) { + const { filename, content, original_filename, sha } = item; + if (!filename || !content) { + core.warning(`Skipping invalid item: ${JSON.stringify(item)}`); + continue; + } + // Decode base64 content and write file + const fileBuffer = Buffer.from(content, "base64"); + // Validate SHA matches the file content + const crypto = require("crypto"); + const computedHash = crypto.createHash("sha256"); + computedHash.update(fileBuffer); + const computedSha = computedHash.digest("hex"); + const fileSha = sha || "unknown"; + if (fileSha !== "unknown" && fileSha !== computedSha) { + core.setFailed( + `SHA validation failed for ${filename}. Expected: ${fileSha}, Computed: ${computedSha}` + ); + return; + } + // Use the SHA-based filename directly (it already includes the extension) + const safeFilename = filename.replace(/[^a-zA-Z0-9._-]/g, "_"); + fs.writeFileSync(safeFilename, fileBuffer); + const originalName = original_filename || filename; + core.info( + `Created file: ${safeFilename} (${fileBuffer.length} bytes) - SHA: ${fileSha} - Original: ${originalName}` + ); + // Add to git + execSync(`git add ${safeFilename}`, { stdio: "inherit" }); + uploadedFiles.push(safeFilename); + } + // Commit files + const fileList = uploadedFiles + .map(filename => { + const item = orphanedBranchItems.find( + i => i.filename.replace(/[^a-zA-Z0-9._-]/g, "_") === filename + ); + const originalName = item?.original_filename || filename; + const sha = item?.sha || "unknown"; + return `${filename} (${originalName}, SHA: ${sha.substring(0, 8)})`; + }) + .join(", "); + const commitMessage = `Upload ${uploadedFiles.length} file(s) to orphaned branch\n\nFiles: ${fileList}`; + execSync(`git commit -m "${commitMessage}"`, { stdio: "inherit" }); + // Push to remote + execSync(`git push origin ${branchName}`, { stdio: "inherit" }); + // Get the commit SHA + commitSha = execSync(`git rev-parse HEAD`, { + encoding: "utf8", + }).trim(); + core.info(`Pushed to orphaned branch with commit: ${commitSha}`); + // Generate GitHub raw URLs using branch name + for (const filename of uploadedFiles) { + const rawUrl = `https://raw.githubusercontent.com/${owner}/${repo.repo}/${branchName}/${filename}`; + fileUrls.push(rawUrl); + core.info(`File URL: ${rawUrl}`); + } + // Add summary + core.summary.addHeading("Files Uploaded to Orphaned Branch", 2); + core.summary.addRaw( + `Successfully uploaded ${uploadedFiles.length} file(s) to orphaned branch \`${branchName}\`\n\n` + ); + core.summary.addRaw(`**Commit:** \`${commitSha}\`\n\n`); + core.summary.addRaw("**Files:**\n"); + for (let i = 0; i < uploadedFiles.length; i++) { + const filename = uploadedFiles[i]; + const item = orphanedBranchItems.find( + item => item.filename.replace(/[^a-zA-Z0-9._-]/g, "_") === filename + ); + const originalName = item?.original_filename || filename; + const sha = item?.sha || "unknown"; + core.summary.addRaw( + `- [${filename}](${fileUrls[i]}) - Original: ${originalName} - SHA: ${sha.substring(0, 8)}\n` + ); + } + await core.summary.write(); + } catch (error) { + core.setFailed( + `Failed to upload files to orphaned branch: ${error instanceof Error ? error.message : String(error)}` + ); + return; + } + } + // Set outputs + core.setOutput("uploaded_files", JSON.stringify(uploadedFiles)); + core.setOutput("file_urls", JSON.stringify(fileUrls)); + if (commitSha) { + core.setOutput("commit_sha", commitSha); + } + core.info( + `Successfully processed ${uploadedFiles.length} file(s) for orphaned branch upload` + ); + missing_tool: needs: dev if: ${{ always() }} diff --git a/.github/workflows/dev.md b/.github/workflows/dev.md index 36f87303e2..dd009333ec 100644 --- a/.github/workflows/dev.md +++ b/.github/workflows/dev.md @@ -5,7 +5,15 @@ on: branches: - copilot/* - pelikhan/* +tools: + playwright: + docker_image_version: "v1.41.0" + allowed_domains: ["localhost", "127.0.0.1", "*.github.com", "github.com"] safe-outputs: + push-to-orphaned-branch: + max: 3 + create-issue: + max: 1 missing-tool: staged: true engine: @@ -14,4 +22,10 @@ engine: permissions: read-all --- -Try to call a tool, `draw_pelican` that draws a pelican. \ No newline at end of file +You have access to a `push-to-orphaned-branch` tool that can upload files (like screenshots) to an orphaned branch and return a GitHub raw URL. Use the expected URL from the response in your issue descriptions. + +Please: +1. Build the documentation by running appropriate build commands +2. Take a screenshot of the documentation using playwright +3. Upload the screenshot using the push-to-orphaned-branch tool - it will give you a URL to use +4. Create an issue describing the documentation status and include the screenshot using the URL provided by the upload tool \ No newline at end of file diff --git a/pkg/workflow/js/safe_outputs_mcp_server.cjs b/pkg/workflow/js/safe_outputs_mcp_server.cjs index 8cddb0c77c..42beabc7c7 100644 --- a/pkg/workflow/js/safe_outputs_mcp_server.cjs +++ b/pkg/workflow/js/safe_outputs_mcp_server.cjs @@ -377,40 +377,17 @@ const TOOLS = Object.fromEntries( // Get file extension from original filename const originalExtension = path.extname(filename); - // Validate file extension is reasonable - const allowedExtensions = [ - ".png", - ".jpg", - ".jpeg", - ".gif", - ".webp", - ".svg", - ".bmp", - ".ico", - ".pdf", - ".txt", - ".md", - ".json", - ".yaml", - ".yml", - ".xml", - ".csv", - ".log", - ".zip", - ".tar", - ".gz", - ".html", - ".css", - ".js", - ".ts", - ]; - if ( - originalExtension && - !allowedExtensions.includes(originalExtension.toLowerCase()) - ) { - throw new Error( - `File extension '${originalExtension}' is not allowed. Allowed extensions: ${allowedExtensions.join(", ")}` - ); + // Validate file extension is reasonable (up to 5 alphanumeric characters) + if (originalExtension) { + const extWithoutDot = originalExtension.slice(1); // Remove the leading dot + if ( + extWithoutDot.length > 5 || + !/^[a-zA-Z0-9]+$/.test(extWithoutDot) + ) { + throw new Error( + `File extension '${originalExtension}' is not allowed. Extension must be up to 5 alphanumeric characters.` + ); + } } const shaFilename = fileSha + originalExtension; @@ -448,7 +425,6 @@ const TOOLS = Object.fromEntries( text: `File uploaded successfully. SHA: ${fileSha}, Original filename: ${path.basename(filename)}, Expected URL: ${templateUrl}`, }, ], - url: templateUrl, }; }, }, From 0577df8ee6d84b77a990875c93bfa8fb21fb5363 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 16 Sep 2025 11:37:57 +0000 Subject: [PATCH 09/12] Implement file-based safe outputs architecture instead of embedding content in JSONL Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/ci-doctor.lock.yml | 33 +++++++++---- .github/workflows/dev.lock.yml | 52 ++++++++++++++------- pkg/workflow/compiler.go | 2 +- pkg/workflow/js/push_to_orphaned_branch.cjs | 20 +++++--- pkg/workflow/js/safe_outputs_mcp_server.cjs | 16 +++++-- pkg/workflow/js/setup_agent_output.cjs | 12 +++-- schemas/agent-output.json | 6 +-- 7 files changed, 94 insertions(+), 47 deletions(-) diff --git a/.github/workflows/ci-doctor.lock.yml b/.github/workflows/ci-doctor.lock.yml index 4103f8a02d..7aa0dabe0e 100644 --- a/.github/workflows/ci-doctor.lock.yml +++ b/.github/workflows/ci-doctor.lock.yml @@ -57,18 +57,20 @@ jobs: function main() { const fs = require("fs"); const crypto = require("crypto"); - // Generate a random filename for the output file - const randomId = crypto.randomBytes(8).toString("hex"); - const outputFile = `/tmp/aw_output_${randomId}.txt`; - // Ensure the /tmp directory exists - fs.mkdirSync("/tmp", { recursive: true }); + // Create the safe outputs directory structure + const safeOutputsDir = "/tmp/gh-aw/safe-outputs"; + const outputFile = `${safeOutputsDir}/outputs.jsonl`; + // Ensure the safe outputs directory exists + fs.mkdirSync(safeOutputsDir, { recursive: true }); // We don't create the file, as the name is sufficiently random // and some engines (Claude) fails first Write to the file // if it exists and has not been read. // Set the environment variable for subsequent steps core.exportVariable("GITHUB_AW_SAFE_OUTPUTS", outputFile); + core.exportVariable("GITHUB_AW_SAFE_OUTPUTS_DIR", safeOutputsDir); // Also set as step output for reference core.setOutput("output_file", outputFile); + core.setOutput("output_dir", safeOutputsDir); } main(); - name: Setup Safe Outputs Collector MCP @@ -436,18 +438,29 @@ jobs: // Validate file extension is reasonable (up to 5 alphanumeric characters) if (originalExtension) { const extWithoutDot = originalExtension.slice(1); // Remove the leading dot - if (extWithoutDot.length > 5 || !/^[a-zA-Z0-9]+$/.test(extWithoutDot)) { - throw new Error(`File extension '${originalExtension}' is not allowed. Extension must be up to 5 alphanumeric characters.`); + if ( + extWithoutDot.length > 5 || + !/^[a-zA-Z0-9]+$/.test(extWithoutDot) + ) { + throw new Error( + `File extension '${originalExtension}' is not allowed. Extension must be up to 5 alphanumeric characters.` + ); } } const shaFilename = fileSha + originalExtension; - // Create the output entry with base64 content and SHA filename + // Copy file to safe outputs directory with SHA-based filename + const safeOutputsDir = process.env.GITHUB_AW_SAFE_OUTPUTS_DIR || "/tmp/gh-aw/safe-outputs"; + const targetFile = path.join(safeOutputsDir, shaFilename); + // Ensure directory exists + fs.mkdirSync(safeOutputsDir, { recursive: true }); + // Copy the file + fs.copyFileSync(filename, targetFile); + // Create the output entry without base64 content (file is now copied to safe outputs dir) const entry = { type: "push-to-orphaned-branch", filename: shaFilename, original_filename: path.basename(filename), sha: fileSha, - content: base64Content, }; appendSafeOutput(entry); // Get branch configuration if available @@ -970,7 +983,7 @@ jobs: uses: actions/upload-artifact@v4 with: name: safe_output.jsonl - path: ${{ env.GITHUB_AW_SAFE_OUTPUTS }} + path: /tmp/gh-aw/safe-outputs/ if-no-files-found: warn - name: Ingest agent output id: collect_output diff --git a/.github/workflows/dev.lock.yml b/.github/workflows/dev.lock.yml index 224be31afd..b4204bf32a 100644 --- a/.github/workflows/dev.lock.yml +++ b/.github/workflows/dev.lock.yml @@ -236,18 +236,20 @@ jobs: function main() { const fs = require("fs"); const crypto = require("crypto"); - // Generate a random filename for the output file - const randomId = crypto.randomBytes(8).toString("hex"); - const outputFile = `/tmp/aw_output_${randomId}.txt`; - // Ensure the /tmp directory exists - fs.mkdirSync("/tmp", { recursive: true }); + // Create the safe outputs directory structure + const safeOutputsDir = "/tmp/gh-aw/safe-outputs"; + const outputFile = `${safeOutputsDir}/outputs.jsonl`; + // Ensure the safe outputs directory exists + fs.mkdirSync(safeOutputsDir, { recursive: true }); // We don't create the file, as the name is sufficiently random // and some engines (Claude) fails first Write to the file // if it exists and has not been read. // Set the environment variable for subsequent steps core.exportVariable("GITHUB_AW_SAFE_OUTPUTS", outputFile); + core.exportVariable("GITHUB_AW_SAFE_OUTPUTS_DIR", safeOutputsDir); // Also set as step output for reference core.setOutput("output_file", outputFile); + core.setOutput("output_dir", safeOutputsDir); } main(); - name: Setup Safe Outputs Collector MCP @@ -615,18 +617,29 @@ jobs: // Validate file extension is reasonable (up to 5 alphanumeric characters) if (originalExtension) { const extWithoutDot = originalExtension.slice(1); // Remove the leading dot - if (extWithoutDot.length > 5 || !/^[a-zA-Z0-9]+$/.test(extWithoutDot)) { - throw new Error(`File extension '${originalExtension}' is not allowed. Extension must be up to 5 alphanumeric characters.`); + if ( + extWithoutDot.length > 5 || + !/^[a-zA-Z0-9]+$/.test(extWithoutDot) + ) { + throw new Error( + `File extension '${originalExtension}' is not allowed. Extension must be up to 5 alphanumeric characters.` + ); } } const shaFilename = fileSha + originalExtension; - // Create the output entry with base64 content and SHA filename + // Copy file to safe outputs directory with SHA-based filename + const safeOutputsDir = process.env.GITHUB_AW_SAFE_OUTPUTS_DIR || "/tmp/gh-aw/safe-outputs"; + const targetFile = path.join(safeOutputsDir, shaFilename); + // Ensure directory exists + fs.mkdirSync(safeOutputsDir, { recursive: true }); + // Copy the file + fs.copyFileSync(filename, targetFile); + // Create the output entry without base64 content (file is now copied to safe outputs dir) const entry = { type: "push-to-orphaned-branch", filename: shaFilename, original_filename: path.basename(filename), sha: fileSha, - content: base64Content, }; appendSafeOutput(entry); // Get branch configuration if available @@ -1001,7 +1014,7 @@ jobs: uses: actions/upload-artifact@v4 with: name: safe_output.jsonl - path: ${{ env.GITHUB_AW_SAFE_OUTPUTS }} + path: /tmp/gh-aw/safe-outputs/ if-no-files-found: warn - name: Ingest agent output id: collect_output @@ -2618,14 +2631,20 @@ jobs: } // Upload each file for (const item of orphanedBranchItems) { - const { filename, content, original_filename, sha } = item; - if (!filename || !content) { + const { filename, original_filename, sha } = item; + if (!filename) { core.warning(`Skipping invalid item: ${JSON.stringify(item)}`); continue; } - // Decode base64 content and write file - const fileBuffer = Buffer.from(content, "base64"); - // Validate SHA matches the file content + // Find the file in the artifact directory (safe outputs dir) + const artifactDir = "/tmp/gh-aw/safe-outputs"; + const sourceFile = `${artifactDir}/${filename}`; + if (!fs.existsSync(sourceFile)) { + core.setFailed(`File not found in artifact: ${sourceFile}`); + return; + } + // Read the file and validate SHA + const fileBuffer = fs.readFileSync(sourceFile); const crypto = require("crypto"); const computedHash = crypto.createHash("sha256"); computedHash.update(fileBuffer); @@ -2639,7 +2658,8 @@ jobs: } // Use the SHA-based filename directly (it already includes the extension) const safeFilename = filename.replace(/[^a-zA-Z0-9._-]/g, "_"); - fs.writeFileSync(safeFilename, fileBuffer); + // Copy file to working directory for git operations + fs.copyFileSync(sourceFile, safeFilename); const originalName = original_filename || filename; core.info( `Created file: ${safeFilename} (${fileBuffer.length} bytes) - SHA: ${fileSha} - Original: ${originalName}` diff --git a/pkg/workflow/compiler.go b/pkg/workflow/compiler.go index 34f5f97ab8..bdba9f7115 100644 --- a/pkg/workflow/compiler.go +++ b/pkg/workflow/compiler.go @@ -4558,7 +4558,7 @@ func (c *Compiler) generateOutputCollectionStep(yaml *strings.Builder, data *Wor yaml.WriteString(" uses: actions/upload-artifact@v4\n") yaml.WriteString(" with:\n") fmt.Fprintf(yaml, " name: %s\n", OutputArtifactName) - yaml.WriteString(" path: ${{ env.GITHUB_AW_SAFE_OUTPUTS }}\n") + yaml.WriteString(" path: /tmp/gh-aw/safe-outputs/\n") yaml.WriteString(" if-no-files-found: warn\n") yaml.WriteString(" - name: Ingest agent output\n") diff --git a/pkg/workflow/js/push_to_orphaned_branch.cjs b/pkg/workflow/js/push_to_orphaned_branch.cjs index f8b164e990..510f2af646 100644 --- a/pkg/workflow/js/push_to_orphaned_branch.cjs +++ b/pkg/workflow/js/push_to_orphaned_branch.cjs @@ -89,17 +89,24 @@ if (isStaged) { // Upload each file for (const item of orphanedBranchItems) { - const { filename, content, original_filename, sha } = item; + const { filename, original_filename, sha } = item; - if (!filename || !content) { + if (!filename) { core.warning(`Skipping invalid item: ${JSON.stringify(item)}`); continue; } - // Decode base64 content and write file - const fileBuffer = Buffer.from(content, "base64"); + // Find the file in the artifact directory (safe outputs dir) + const artifactDir = "/tmp/gh-aw/safe-outputs"; + const sourceFile = `${artifactDir}/${filename}`; - // Validate SHA matches the file content + if (!fs.existsSync(sourceFile)) { + core.setFailed(`File not found in artifact: ${sourceFile}`); + return; + } + + // Read the file and validate SHA + const fileBuffer = fs.readFileSync(sourceFile); const crypto = require("crypto"); const computedHash = crypto.createHash("sha256"); computedHash.update(fileBuffer); @@ -116,7 +123,8 @@ if (isStaged) { // Use the SHA-based filename directly (it already includes the extension) const safeFilename = filename.replace(/[^a-zA-Z0-9._-]/g, "_"); - fs.writeFileSync(safeFilename, fileBuffer); + // Copy file to working directory for git operations + fs.copyFileSync(sourceFile, safeFilename); const originalName = original_filename || filename; core.info( `Created file: ${safeFilename} (${fileBuffer.length} bytes) - SHA: ${fileSha} - Original: ${originalName}` diff --git a/pkg/workflow/js/safe_outputs_mcp_server.cjs b/pkg/workflow/js/safe_outputs_mcp_server.cjs index 42beabc7c7..1fb9b18bd1 100644 --- a/pkg/workflow/js/safe_outputs_mcp_server.cjs +++ b/pkg/workflow/js/safe_outputs_mcp_server.cjs @@ -367,8 +367,6 @@ const TOOLS = Object.fromEntries( ); } - const base64Content = fileContent.toString("base64"); - // Compute SHA256 hash of the file content const hash = crypto.createHash("sha256"); hash.update(fileContent); @@ -392,13 +390,23 @@ const TOOLS = Object.fromEntries( const shaFilename = fileSha + originalExtension; - // Create the output entry with base64 content and SHA filename + // Copy file to safe outputs directory with SHA-based filename + const safeOutputsDir = + process.env.GITHUB_AW_SAFE_OUTPUTS_DIR || "/tmp/gh-aw/safe-outputs"; + const targetFile = path.join(safeOutputsDir, shaFilename); + + // Ensure directory exists + fs.mkdirSync(safeOutputsDir, { recursive: true }); + + // Copy the file + fs.copyFileSync(filename, targetFile); + + // Create the output entry without base64 content (file is now copied to safe outputs dir) const entry = { type: "push-to-orphaned-branch", filename: shaFilename, original_filename: path.basename(filename), sha: fileSha, - content: base64Content, }; appendSafeOutput(entry); diff --git a/pkg/workflow/js/setup_agent_output.cjs b/pkg/workflow/js/setup_agent_output.cjs index dfe4492e54..826aa502a8 100644 --- a/pkg/workflow/js/setup_agent_output.cjs +++ b/pkg/workflow/js/setup_agent_output.cjs @@ -2,12 +2,12 @@ function main() { const fs = require("fs"); const crypto = require("crypto"); - // Generate a random filename for the output file - const randomId = crypto.randomBytes(8).toString("hex"); - const outputFile = `/tmp/aw_output_${randomId}.txt`; + // Create the safe outputs directory structure + const safeOutputsDir = "/tmp/gh-aw/safe-outputs"; + const outputFile = `${safeOutputsDir}/outputs.jsonl`; - // Ensure the /tmp directory exists - fs.mkdirSync("/tmp", { recursive: true }); + // Ensure the safe outputs directory exists + fs.mkdirSync(safeOutputsDir, { recursive: true }); // We don't create the file, as the name is sufficiently random // and some engines (Claude) fails first Write to the file @@ -15,9 +15,11 @@ function main() { // Set the environment variable for subsequent steps core.exportVariable("GITHUB_AW_SAFE_OUTPUTS", outputFile); + core.exportVariable("GITHUB_AW_SAFE_OUTPUTS_DIR", safeOutputsDir); // Also set as step output for reference core.setOutput("output_file", outputFile); + core.setOutput("output_dir", safeOutputsDir); } main(); diff --git a/schemas/agent-output.json b/schemas/agent-output.json index fb9d2d88e4..721eb54b07 100644 --- a/schemas/agent-output.json +++ b/schemas/agent-output.json @@ -216,13 +216,9 @@ "type": "string", "description": "SHA256 hash of the file content", "minLength": 1 - }, - "content": { - "type": "string", - "description": "Base64 encoded file content" } }, - "required": ["type", "filename", "original_filename", "sha", "content"], + "required": ["type", "filename", "original_filename", "sha"], "additionalProperties": false }, "CreatePullRequestReviewCommentOutput": { From 7d2696f38376117f6a8036e4ebb604b934753d2a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 16 Sep 2025 11:54:30 +0000 Subject: [PATCH 10/12] Move files to nested subdirectory and use environment variables for paths Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/ci-doctor.lock.yml | 22 +++++++++++----- .github/workflows/dev.lock.yml | 29 ++++++++++++++------- pkg/workflow/claude_engine.go | 4 +++ pkg/workflow/codex_engine.go | 4 ++- pkg/workflow/custom_engine.go | 4 +++ pkg/workflow/js/push_to_orphaned_branch.cjs | 8 +++--- pkg/workflow/js/safe_outputs_mcp_server.cjs | 11 ++++---- pkg/workflow/js/setup_agent_output.cjs | 8 ++++-- 8 files changed, 62 insertions(+), 28 deletions(-) diff --git a/.github/workflows/ci-doctor.lock.yml b/.github/workflows/ci-doctor.lock.yml index 7aa0dabe0e..8a8cead868 100644 --- a/.github/workflows/ci-doctor.lock.yml +++ b/.github/workflows/ci-doctor.lock.yml @@ -59,18 +59,22 @@ jobs: const crypto = require("crypto"); // Create the safe outputs directory structure const safeOutputsDir = "/tmp/gh-aw/safe-outputs"; + const filesDir = `${safeOutputsDir}/files`; const outputFile = `${safeOutputsDir}/outputs.jsonl`; - // Ensure the safe outputs directory exists + // Ensure the safe outputs directory structure exists fs.mkdirSync(safeOutputsDir, { recursive: true }); + fs.mkdirSync(filesDir, { recursive: true }); // We don't create the file, as the name is sufficiently random // and some engines (Claude) fails first Write to the file // if it exists and has not been read. - // Set the environment variable for subsequent steps + // Set the environment variables for subsequent steps core.exportVariable("GITHUB_AW_SAFE_OUTPUTS", outputFile); core.exportVariable("GITHUB_AW_SAFE_OUTPUTS_DIR", safeOutputsDir); + core.exportVariable("GITHUB_AW_SAFE_OUTPUTS_FILES_DIR", filesDir); // Also set as step output for reference core.setOutput("output_file", outputFile); core.setOutput("output_dir", safeOutputsDir); + core.setOutput("files_dir", filesDir); } main(); - name: Setup Safe Outputs Collector MCP @@ -428,7 +432,6 @@ jobs: `File size ${Math.round(fileSizeBytes / 1024 / 1024)}MB exceeds 10MB limit` ); } - const base64Content = fileContent.toString("base64"); // Compute SHA256 hash of the file content const hash = crypto.createHash("sha256"); hash.update(fileContent); @@ -448,11 +451,12 @@ jobs: } } const shaFilename = fileSha + originalExtension; - // Copy file to safe outputs directory with SHA-based filename - const safeOutputsDir = process.env.GITHUB_AW_SAFE_OUTPUTS_DIR || "/tmp/gh-aw/safe-outputs"; - const targetFile = path.join(safeOutputsDir, shaFilename); + // Copy file to safe outputs files directory with SHA-based filename + const filesDir = process.env.GITHUB_AW_SAFE_OUTPUTS_FILES_DIR || + `${process.env.GITHUB_AW_SAFE_OUTPUTS_DIR || "/tmp/gh-aw/safe-outputs"}/files`; + const targetFile = path.join(filesDir, shaFilename); // Ensure directory exists - fs.mkdirSync(safeOutputsDir, { recursive: true }); + fs.mkdirSync(filesDir, { recursive: true }); // Copy the file fs.copyFileSync(filename, targetFile); // Create the output entry without base64 content (file is now copied to safe outputs dir) @@ -629,6 +633,8 @@ jobs: "args": ["/tmp/safe-outputs/mcp-server.cjs"], "env": { "GITHUB_AW_SAFE_OUTPUTS": "${{ env.GITHUB_AW_SAFE_OUTPUTS }}", + "GITHUB_AW_SAFE_OUTPUTS_DIR": "${{ steps.setup_agent_output.outputs.output_dir }}", + "GITHUB_AW_SAFE_OUTPUTS_FILES_DIR": "${{ steps.setup_agent_output.outputs.files_dir }}", "GITHUB_AW_SAFE_OUTPUTS_CONFIG": ${{ toJSON(env.GITHUB_AW_SAFE_OUTPUTS_CONFIG) }} } } @@ -952,6 +958,8 @@ jobs: DISABLE_BUG_COMMAND: "1" GITHUB_AW_PROMPT: /tmp/aw-prompts/prompt.txt GITHUB_AW_SAFE_OUTPUTS: ${{ env.GITHUB_AW_SAFE_OUTPUTS }} + GITHUB_AW_SAFE_OUTPUTS_DIR: ${{ steps.setup_agent_output.outputs.output_dir }} + GITHUB_AW_SAFE_OUTPUTS_FILES_DIR: ${{ steps.setup_agent_output.outputs.files_dir }} - name: Ensure log file exists if: always() run: | diff --git a/.github/workflows/dev.lock.yml b/.github/workflows/dev.lock.yml index b4204bf32a..dc6c22cc0b 100644 --- a/.github/workflows/dev.lock.yml +++ b/.github/workflows/dev.lock.yml @@ -238,18 +238,22 @@ jobs: const crypto = require("crypto"); // Create the safe outputs directory structure const safeOutputsDir = "/tmp/gh-aw/safe-outputs"; + const filesDir = `${safeOutputsDir}/files`; const outputFile = `${safeOutputsDir}/outputs.jsonl`; - // Ensure the safe outputs directory exists + // Ensure the safe outputs directory structure exists fs.mkdirSync(safeOutputsDir, { recursive: true }); + fs.mkdirSync(filesDir, { recursive: true }); // We don't create the file, as the name is sufficiently random // and some engines (Claude) fails first Write to the file // if it exists and has not been read. - // Set the environment variable for subsequent steps + // Set the environment variables for subsequent steps core.exportVariable("GITHUB_AW_SAFE_OUTPUTS", outputFile); core.exportVariable("GITHUB_AW_SAFE_OUTPUTS_DIR", safeOutputsDir); + core.exportVariable("GITHUB_AW_SAFE_OUTPUTS_FILES_DIR", filesDir); // Also set as step output for reference core.setOutput("output_file", outputFile); core.setOutput("output_dir", safeOutputsDir); + core.setOutput("files_dir", filesDir); } main(); - name: Setup Safe Outputs Collector MCP @@ -607,7 +611,6 @@ jobs: `File size ${Math.round(fileSizeBytes / 1024 / 1024)}MB exceeds 10MB limit` ); } - const base64Content = fileContent.toString("base64"); // Compute SHA256 hash of the file content const hash = crypto.createHash("sha256"); hash.update(fileContent); @@ -627,11 +630,12 @@ jobs: } } const shaFilename = fileSha + originalExtension; - // Copy file to safe outputs directory with SHA-based filename - const safeOutputsDir = process.env.GITHUB_AW_SAFE_OUTPUTS_DIR || "/tmp/gh-aw/safe-outputs"; - const targetFile = path.join(safeOutputsDir, shaFilename); + // Copy file to safe outputs files directory with SHA-based filename + const filesDir = process.env.GITHUB_AW_SAFE_OUTPUTS_FILES_DIR || + `${process.env.GITHUB_AW_SAFE_OUTPUTS_DIR || "/tmp/gh-aw/safe-outputs"}/files`; + const targetFile = path.join(filesDir, shaFilename); // Ensure directory exists - fs.mkdirSync(safeOutputsDir, { recursive: true }); + fs.mkdirSync(filesDir, { recursive: true }); // Copy the file fs.copyFileSync(filename, targetFile); // Create the output entry without base64 content (file is now copied to safe outputs dir) @@ -816,6 +820,8 @@ jobs: "args": ["/tmp/safe-outputs/mcp-server.cjs"], "env": { "GITHUB_AW_SAFE_OUTPUTS": "${{ env.GITHUB_AW_SAFE_OUTPUTS }}", + "GITHUB_AW_SAFE_OUTPUTS_DIR": "${{ steps.setup_agent_output.outputs.output_dir }}", + "GITHUB_AW_SAFE_OUTPUTS_FILES_DIR": "${{ steps.setup_agent_output.outputs.files_dir }}", "GITHUB_AW_SAFE_OUTPUTS_CONFIG": ${{ toJSON(env.GITHUB_AW_SAFE_OUTPUTS_CONFIG) }} } } @@ -981,6 +987,8 @@ jobs: DISABLE_BUG_COMMAND: "1" GITHUB_AW_PROMPT: /tmp/aw-prompts/prompt.txt GITHUB_AW_SAFE_OUTPUTS: ${{ env.GITHUB_AW_SAFE_OUTPUTS }} + GITHUB_AW_SAFE_OUTPUTS_DIR: ${{ steps.setup_agent_output.outputs.output_dir }} + GITHUB_AW_SAFE_OUTPUTS_FILES_DIR: ${{ steps.setup_agent_output.outputs.files_dir }} GITHUB_AW_SAFE_OUTPUTS_STAGED: "true" GITHUB_AW_MAX_TURNS: 5 - name: Ensure log file exists @@ -2636,9 +2644,10 @@ jobs: core.warning(`Skipping invalid item: ${JSON.stringify(item)}`); continue; } - // Find the file in the artifact directory (safe outputs dir) - const artifactDir = "/tmp/gh-aw/safe-outputs"; - const sourceFile = `${artifactDir}/${filename}`; + // Find the file in the artifact files directory + const filesDir = process.env.GITHUB_AW_SAFE_OUTPUTS_FILES_DIR || + `${process.env.GITHUB_AW_SAFE_OUTPUTS_DIR || "/tmp/gh-aw/safe-outputs"}/files`; + const sourceFile = `${filesDir}/${filename}`; if (!fs.existsSync(sourceFile)) { core.setFailed(`File not found in artifact: ${sourceFile}`); return; diff --git a/pkg/workflow/claude_engine.go b/pkg/workflow/claude_engine.go index 4aded7ebfe..10d3e0bbbf 100644 --- a/pkg/workflow/claude_engine.go +++ b/pkg/workflow/claude_engine.go @@ -185,6 +185,8 @@ func (e *ClaudeEngine) GetExecutionSteps(workflowData *WorkflowData, logFile str if workflowData.SafeOutputs != nil { stepLines = append(stepLines, " GITHUB_AW_SAFE_OUTPUTS: ${{ env.GITHUB_AW_SAFE_OUTPUTS }}") + stepLines = append(stepLines, " GITHUB_AW_SAFE_OUTPUTS_DIR: ${{ steps.setup_agent_output.outputs.output_dir }}") + stepLines = append(stepLines, " GITHUB_AW_SAFE_OUTPUTS_FILES_DIR: ${{ steps.setup_agent_output.outputs.files_dir }}") // Add staged flag if specified if workflowData.SafeOutputs.Staged != nil && *workflowData.SafeOutputs.Staged { @@ -565,6 +567,8 @@ func (e *ClaudeEngine) RenderMCPConfig(yaml *strings.Builder, tools map[string]a yaml.WriteString(" \"args\": [\"/tmp/safe-outputs/mcp-server.cjs\"],\n") yaml.WriteString(" \"env\": {\n") yaml.WriteString(" \"GITHUB_AW_SAFE_OUTPUTS\": \"${{ env.GITHUB_AW_SAFE_OUTPUTS }}\",\n") + yaml.WriteString(" \"GITHUB_AW_SAFE_OUTPUTS_DIR\": \"${{ steps.setup_agent_output.outputs.output_dir }}\",\n") + yaml.WriteString(" \"GITHUB_AW_SAFE_OUTPUTS_FILES_DIR\": \"${{ steps.setup_agent_output.outputs.files_dir }}\",\n") yaml.WriteString(" \"GITHUB_AW_SAFE_OUTPUTS_CONFIG\": ${{ toJSON(env.GITHUB_AW_SAFE_OUTPUTS_CONFIG) }}\n") yaml.WriteString(" }\n") serverCount++ diff --git a/pkg/workflow/codex_engine.go b/pkg/workflow/codex_engine.go index e61026fb6b..b19a8f0162 100644 --- a/pkg/workflow/codex_engine.go +++ b/pkg/workflow/codex_engine.go @@ -116,6 +116,8 @@ codex exec \ hasOutput := workflowData.SafeOutputs != nil if hasOutput { env["GITHUB_AW_SAFE_OUTPUTS"] = "${{ env.GITHUB_AW_SAFE_OUTPUTS }}" + env["GITHUB_AW_SAFE_OUTPUTS_DIR"] = "${{ steps.setup_agent_output.outputs.output_dir }}" + env["GITHUB_AW_SAFE_OUTPUTS_FILES_DIR"] = "${{ steps.setup_agent_output.outputs.files_dir }}" // Add staged flag if specified if workflowData.SafeOutputs.Staged != nil && *workflowData.SafeOutputs.Staged { @@ -229,7 +231,7 @@ func (e *CodexEngine) RenderMCPConfig(yaml *strings.Builder, tools map[string]an yaml.WriteString(" args = [\n") yaml.WriteString(" \"/tmp/safe-outputs/mcp-server.cjs\",\n") yaml.WriteString(" ]\n") - yaml.WriteString(" env = { \"GITHUB_AW_SAFE_OUTPUTS\" = \"${{ env.GITHUB_AW_SAFE_OUTPUTS }}\", \"GITHUB_AW_SAFE_OUTPUTS_CONFIG\" = ${{ toJSON(env.GITHUB_AW_SAFE_OUTPUTS_CONFIG) }} }\n") + yaml.WriteString(" env = { \"GITHUB_AW_SAFE_OUTPUTS\" = \"${{ env.GITHUB_AW_SAFE_OUTPUTS }}\", \"GITHUB_AW_SAFE_OUTPUTS_DIR\" = \"${{ steps.setup_agent_output.outputs.output_dir }}\", \"GITHUB_AW_SAFE_OUTPUTS_FILES_DIR\" = \"${{ steps.setup_agent_output.outputs.files_dir }}\", \"GITHUB_AW_SAFE_OUTPUTS_CONFIG\" = ${{ toJSON(env.GITHUB_AW_SAFE_OUTPUTS_CONFIG) }} }\n") } default: // Handle custom MCP tools (those with MCP-compatible type) diff --git a/pkg/workflow/custom_engine.go b/pkg/workflow/custom_engine.go index b956e65ca8..4c96978fd9 100644 --- a/pkg/workflow/custom_engine.go +++ b/pkg/workflow/custom_engine.go @@ -52,6 +52,8 @@ func (e *CustomEngine) GetExecutionSteps(workflowData *WorkflowData, logFile str // Add GITHUB_AW_SAFE_OUTPUTS if safe-outputs feature is used if workflowData.SafeOutputs != nil { envVars["GITHUB_AW_SAFE_OUTPUTS"] = "${{ env.GITHUB_AW_SAFE_OUTPUTS }}" + envVars["GITHUB_AW_SAFE_OUTPUTS_DIR"] = "${{ steps.setup_agent_output.outputs.output_dir }}" + envVars["GITHUB_AW_SAFE_OUTPUTS_FILES_DIR"] = "${{ steps.setup_agent_output.outputs.files_dir }}" // Add staged flag if specified if workflowData.SafeOutputs.Staged != nil && *workflowData.SafeOutputs.Staged { @@ -156,6 +158,8 @@ func (e *CustomEngine) RenderMCPConfig(yaml *strings.Builder, tools map[string]a yaml.WriteString(" \"args\": [\"/tmp/safe-outputs/mcp-server.cjs\"],\n") yaml.WriteString(" \"env\": {\n") yaml.WriteString(" \"GITHUB_AW_SAFE_OUTPUTS\": \"${{ env.GITHUB_AW_SAFE_OUTPUTS }}\",\n") + yaml.WriteString(" \"GITHUB_AW_SAFE_OUTPUTS_DIR\": \"${{ steps.setup_agent_output.outputs.output_dir }}\",\n") + yaml.WriteString(" \"GITHUB_AW_SAFE_OUTPUTS_FILES_DIR\": \"${{ steps.setup_agent_output.outputs.files_dir }}\",\n") yaml.WriteString(" \"GITHUB_AW_SAFE_OUTPUTS_CONFIG\": ${{ toJSON(env.GITHUB_AW_SAFE_OUTPUTS_CONFIG) }}\n") yaml.WriteString(" }\n") serverCount++ diff --git a/pkg/workflow/js/push_to_orphaned_branch.cjs b/pkg/workflow/js/push_to_orphaned_branch.cjs index 510f2af646..23fa45b689 100644 --- a/pkg/workflow/js/push_to_orphaned_branch.cjs +++ b/pkg/workflow/js/push_to_orphaned_branch.cjs @@ -96,9 +96,11 @@ if (isStaged) { continue; } - // Find the file in the artifact directory (safe outputs dir) - const artifactDir = "/tmp/gh-aw/safe-outputs"; - const sourceFile = `${artifactDir}/${filename}`; + // Find the file in the artifact files directory + const filesDir = + process.env.GITHUB_AW_SAFE_OUTPUTS_FILES_DIR || + `${process.env.GITHUB_AW_SAFE_OUTPUTS_DIR || "/tmp/gh-aw/safe-outputs"}/files`; + const sourceFile = `${filesDir}/${filename}`; if (!fs.existsSync(sourceFile)) { core.setFailed(`File not found in artifact: ${sourceFile}`); diff --git a/pkg/workflow/js/safe_outputs_mcp_server.cjs b/pkg/workflow/js/safe_outputs_mcp_server.cjs index 1fb9b18bd1..283161f437 100644 --- a/pkg/workflow/js/safe_outputs_mcp_server.cjs +++ b/pkg/workflow/js/safe_outputs_mcp_server.cjs @@ -390,13 +390,14 @@ const TOOLS = Object.fromEntries( const shaFilename = fileSha + originalExtension; - // Copy file to safe outputs directory with SHA-based filename - const safeOutputsDir = - process.env.GITHUB_AW_SAFE_OUTPUTS_DIR || "/tmp/gh-aw/safe-outputs"; - const targetFile = path.join(safeOutputsDir, shaFilename); + // Copy file to safe outputs files directory with SHA-based filename + const filesDir = + process.env.GITHUB_AW_SAFE_OUTPUTS_FILES_DIR || + `${process.env.GITHUB_AW_SAFE_OUTPUTS_DIR || "/tmp/gh-aw/safe-outputs"}/files`; + const targetFile = path.join(filesDir, shaFilename); // Ensure directory exists - fs.mkdirSync(safeOutputsDir, { recursive: true }); + fs.mkdirSync(filesDir, { recursive: true }); // Copy the file fs.copyFileSync(filename, targetFile); diff --git a/pkg/workflow/js/setup_agent_output.cjs b/pkg/workflow/js/setup_agent_output.cjs index 826aa502a8..b296ef5eae 100644 --- a/pkg/workflow/js/setup_agent_output.cjs +++ b/pkg/workflow/js/setup_agent_output.cjs @@ -4,22 +4,26 @@ function main() { // Create the safe outputs directory structure const safeOutputsDir = "/tmp/gh-aw/safe-outputs"; + const filesDir = `${safeOutputsDir}/files`; const outputFile = `${safeOutputsDir}/outputs.jsonl`; - // Ensure the safe outputs directory exists + // Ensure the safe outputs directory structure exists fs.mkdirSync(safeOutputsDir, { recursive: true }); + fs.mkdirSync(filesDir, { recursive: true }); // We don't create the file, as the name is sufficiently random // and some engines (Claude) fails first Write to the file // if it exists and has not been read. - // Set the environment variable for subsequent steps + // Set the environment variables for subsequent steps core.exportVariable("GITHUB_AW_SAFE_OUTPUTS", outputFile); core.exportVariable("GITHUB_AW_SAFE_OUTPUTS_DIR", safeOutputsDir); + core.exportVariable("GITHUB_AW_SAFE_OUTPUTS_FILES_DIR", filesDir); // Also set as step output for reference core.setOutput("output_file", outputFile); core.setOutput("output_dir", safeOutputsDir); + core.setOutput("files_dir", filesDir); } main(); From 6bd077d2ad3bfdb10f0c43e2e3d7c6b063806370 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 16 Sep 2025 12:28:27 +0000 Subject: [PATCH 11/12] Use environment variables instead of step outputs for consistent path references Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/ci-doctor.lock.yml | 13 +++++++------ .github/workflows/dev.lock.yml | 16 +++++++++------- pkg/workflow/claude_engine.go | 8 ++++---- pkg/workflow/codex_engine.go | 6 +++--- pkg/workflow/compiler.go | 2 +- pkg/workflow/custom_engine.go | 8 ++++---- 6 files changed, 28 insertions(+), 25 deletions(-) diff --git a/.github/workflows/ci-doctor.lock.yml b/.github/workflows/ci-doctor.lock.yml index 8a8cead868..8bd860ecd8 100644 --- a/.github/workflows/ci-doctor.lock.yml +++ b/.github/workflows/ci-doctor.lock.yml @@ -452,7 +452,8 @@ jobs: } const shaFilename = fileSha + originalExtension; // Copy file to safe outputs files directory with SHA-based filename - const filesDir = process.env.GITHUB_AW_SAFE_OUTPUTS_FILES_DIR || + const filesDir = + process.env.GITHUB_AW_SAFE_OUTPUTS_FILES_DIR || `${process.env.GITHUB_AW_SAFE_OUTPUTS_DIR || "/tmp/gh-aw/safe-outputs"}/files`; const targetFile = path.join(filesDir, shaFilename); // Ensure directory exists @@ -633,8 +634,8 @@ jobs: "args": ["/tmp/safe-outputs/mcp-server.cjs"], "env": { "GITHUB_AW_SAFE_OUTPUTS": "${{ env.GITHUB_AW_SAFE_OUTPUTS }}", - "GITHUB_AW_SAFE_OUTPUTS_DIR": "${{ steps.setup_agent_output.outputs.output_dir }}", - "GITHUB_AW_SAFE_OUTPUTS_FILES_DIR": "${{ steps.setup_agent_output.outputs.files_dir }}", + "GITHUB_AW_SAFE_OUTPUTS_DIR": "${{ env.GITHUB_AW_SAFE_OUTPUTS_DIR }}", + "GITHUB_AW_SAFE_OUTPUTS_FILES_DIR": "${{ env.GITHUB_AW_SAFE_OUTPUTS_FILES_DIR }}", "GITHUB_AW_SAFE_OUTPUTS_CONFIG": ${{ toJSON(env.GITHUB_AW_SAFE_OUTPUTS_CONFIG) }} } } @@ -958,8 +959,8 @@ jobs: DISABLE_BUG_COMMAND: "1" GITHUB_AW_PROMPT: /tmp/aw-prompts/prompt.txt GITHUB_AW_SAFE_OUTPUTS: ${{ env.GITHUB_AW_SAFE_OUTPUTS }} - GITHUB_AW_SAFE_OUTPUTS_DIR: ${{ steps.setup_agent_output.outputs.output_dir }} - GITHUB_AW_SAFE_OUTPUTS_FILES_DIR: ${{ steps.setup_agent_output.outputs.files_dir }} + GITHUB_AW_SAFE_OUTPUTS_DIR: ${{ env.GITHUB_AW_SAFE_OUTPUTS_DIR }} + GITHUB_AW_SAFE_OUTPUTS_FILES_DIR: ${{ env.GITHUB_AW_SAFE_OUTPUTS_FILES_DIR }} - name: Ensure log file exists if: always() run: | @@ -991,7 +992,7 @@ jobs: uses: actions/upload-artifact@v4 with: name: safe_output.jsonl - path: /tmp/gh-aw/safe-outputs/ + path: ${{ env.GITHUB_AW_SAFE_OUTPUTS_DIR }} if-no-files-found: warn - name: Ingest agent output id: collect_output diff --git a/.github/workflows/dev.lock.yml b/.github/workflows/dev.lock.yml index dc6c22cc0b..95fc64b9b8 100644 --- a/.github/workflows/dev.lock.yml +++ b/.github/workflows/dev.lock.yml @@ -631,7 +631,8 @@ jobs: } const shaFilename = fileSha + originalExtension; // Copy file to safe outputs files directory with SHA-based filename - const filesDir = process.env.GITHUB_AW_SAFE_OUTPUTS_FILES_DIR || + const filesDir = + process.env.GITHUB_AW_SAFE_OUTPUTS_FILES_DIR || `${process.env.GITHUB_AW_SAFE_OUTPUTS_DIR || "/tmp/gh-aw/safe-outputs"}/files`; const targetFile = path.join(filesDir, shaFilename); // Ensure directory exists @@ -820,8 +821,8 @@ jobs: "args": ["/tmp/safe-outputs/mcp-server.cjs"], "env": { "GITHUB_AW_SAFE_OUTPUTS": "${{ env.GITHUB_AW_SAFE_OUTPUTS }}", - "GITHUB_AW_SAFE_OUTPUTS_DIR": "${{ steps.setup_agent_output.outputs.output_dir }}", - "GITHUB_AW_SAFE_OUTPUTS_FILES_DIR": "${{ steps.setup_agent_output.outputs.files_dir }}", + "GITHUB_AW_SAFE_OUTPUTS_DIR": "${{ env.GITHUB_AW_SAFE_OUTPUTS_DIR }}", + "GITHUB_AW_SAFE_OUTPUTS_FILES_DIR": "${{ env.GITHUB_AW_SAFE_OUTPUTS_FILES_DIR }}", "GITHUB_AW_SAFE_OUTPUTS_CONFIG": ${{ toJSON(env.GITHUB_AW_SAFE_OUTPUTS_CONFIG) }} } } @@ -987,8 +988,8 @@ jobs: DISABLE_BUG_COMMAND: "1" GITHUB_AW_PROMPT: /tmp/aw-prompts/prompt.txt GITHUB_AW_SAFE_OUTPUTS: ${{ env.GITHUB_AW_SAFE_OUTPUTS }} - GITHUB_AW_SAFE_OUTPUTS_DIR: ${{ steps.setup_agent_output.outputs.output_dir }} - GITHUB_AW_SAFE_OUTPUTS_FILES_DIR: ${{ steps.setup_agent_output.outputs.files_dir }} + GITHUB_AW_SAFE_OUTPUTS_DIR: ${{ env.GITHUB_AW_SAFE_OUTPUTS_DIR }} + GITHUB_AW_SAFE_OUTPUTS_FILES_DIR: ${{ env.GITHUB_AW_SAFE_OUTPUTS_FILES_DIR }} GITHUB_AW_SAFE_OUTPUTS_STAGED: "true" GITHUB_AW_MAX_TURNS: 5 - name: Ensure log file exists @@ -1022,7 +1023,7 @@ jobs: uses: actions/upload-artifact@v4 with: name: safe_output.jsonl - path: /tmp/gh-aw/safe-outputs/ + path: ${{ env.GITHUB_AW_SAFE_OUTPUTS_DIR }} if-no-files-found: warn - name: Ingest agent output id: collect_output @@ -2645,7 +2646,8 @@ jobs: continue; } // Find the file in the artifact files directory - const filesDir = process.env.GITHUB_AW_SAFE_OUTPUTS_FILES_DIR || + const filesDir = + process.env.GITHUB_AW_SAFE_OUTPUTS_FILES_DIR || `${process.env.GITHUB_AW_SAFE_OUTPUTS_DIR || "/tmp/gh-aw/safe-outputs"}/files`; const sourceFile = `${filesDir}/${filename}`; if (!fs.existsSync(sourceFile)) { diff --git a/pkg/workflow/claude_engine.go b/pkg/workflow/claude_engine.go index 10d3e0bbbf..6e34b534d7 100644 --- a/pkg/workflow/claude_engine.go +++ b/pkg/workflow/claude_engine.go @@ -185,8 +185,8 @@ func (e *ClaudeEngine) GetExecutionSteps(workflowData *WorkflowData, logFile str if workflowData.SafeOutputs != nil { stepLines = append(stepLines, " GITHUB_AW_SAFE_OUTPUTS: ${{ env.GITHUB_AW_SAFE_OUTPUTS }}") - stepLines = append(stepLines, " GITHUB_AW_SAFE_OUTPUTS_DIR: ${{ steps.setup_agent_output.outputs.output_dir }}") - stepLines = append(stepLines, " GITHUB_AW_SAFE_OUTPUTS_FILES_DIR: ${{ steps.setup_agent_output.outputs.files_dir }}") + stepLines = append(stepLines, " GITHUB_AW_SAFE_OUTPUTS_DIR: ${{ env.GITHUB_AW_SAFE_OUTPUTS_DIR }}") + stepLines = append(stepLines, " GITHUB_AW_SAFE_OUTPUTS_FILES_DIR: ${{ env.GITHUB_AW_SAFE_OUTPUTS_FILES_DIR }}") // Add staged flag if specified if workflowData.SafeOutputs.Staged != nil && *workflowData.SafeOutputs.Staged { @@ -567,8 +567,8 @@ func (e *ClaudeEngine) RenderMCPConfig(yaml *strings.Builder, tools map[string]a yaml.WriteString(" \"args\": [\"/tmp/safe-outputs/mcp-server.cjs\"],\n") yaml.WriteString(" \"env\": {\n") yaml.WriteString(" \"GITHUB_AW_SAFE_OUTPUTS\": \"${{ env.GITHUB_AW_SAFE_OUTPUTS }}\",\n") - yaml.WriteString(" \"GITHUB_AW_SAFE_OUTPUTS_DIR\": \"${{ steps.setup_agent_output.outputs.output_dir }}\",\n") - yaml.WriteString(" \"GITHUB_AW_SAFE_OUTPUTS_FILES_DIR\": \"${{ steps.setup_agent_output.outputs.files_dir }}\",\n") + yaml.WriteString(" \"GITHUB_AW_SAFE_OUTPUTS_DIR\": \"${{ env.GITHUB_AW_SAFE_OUTPUTS_DIR }}\",\n") + yaml.WriteString(" \"GITHUB_AW_SAFE_OUTPUTS_FILES_DIR\": \"${{ env.GITHUB_AW_SAFE_OUTPUTS_FILES_DIR }}\",\n") yaml.WriteString(" \"GITHUB_AW_SAFE_OUTPUTS_CONFIG\": ${{ toJSON(env.GITHUB_AW_SAFE_OUTPUTS_CONFIG) }}\n") yaml.WriteString(" }\n") serverCount++ diff --git a/pkg/workflow/codex_engine.go b/pkg/workflow/codex_engine.go index b19a8f0162..5ef43c3570 100644 --- a/pkg/workflow/codex_engine.go +++ b/pkg/workflow/codex_engine.go @@ -116,8 +116,8 @@ codex exec \ hasOutput := workflowData.SafeOutputs != nil if hasOutput { env["GITHUB_AW_SAFE_OUTPUTS"] = "${{ env.GITHUB_AW_SAFE_OUTPUTS }}" - env["GITHUB_AW_SAFE_OUTPUTS_DIR"] = "${{ steps.setup_agent_output.outputs.output_dir }}" - env["GITHUB_AW_SAFE_OUTPUTS_FILES_DIR"] = "${{ steps.setup_agent_output.outputs.files_dir }}" + env["GITHUB_AW_SAFE_OUTPUTS_DIR"] = "${{ env.GITHUB_AW_SAFE_OUTPUTS_DIR }}" + env["GITHUB_AW_SAFE_OUTPUTS_FILES_DIR"] = "${{ env.GITHUB_AW_SAFE_OUTPUTS_FILES_DIR }}" // Add staged flag if specified if workflowData.SafeOutputs.Staged != nil && *workflowData.SafeOutputs.Staged { @@ -231,7 +231,7 @@ func (e *CodexEngine) RenderMCPConfig(yaml *strings.Builder, tools map[string]an yaml.WriteString(" args = [\n") yaml.WriteString(" \"/tmp/safe-outputs/mcp-server.cjs\",\n") yaml.WriteString(" ]\n") - yaml.WriteString(" env = { \"GITHUB_AW_SAFE_OUTPUTS\" = \"${{ env.GITHUB_AW_SAFE_OUTPUTS }}\", \"GITHUB_AW_SAFE_OUTPUTS_DIR\" = \"${{ steps.setup_agent_output.outputs.output_dir }}\", \"GITHUB_AW_SAFE_OUTPUTS_FILES_DIR\" = \"${{ steps.setup_agent_output.outputs.files_dir }}\", \"GITHUB_AW_SAFE_OUTPUTS_CONFIG\" = ${{ toJSON(env.GITHUB_AW_SAFE_OUTPUTS_CONFIG) }} }\n") + yaml.WriteString(" env = { \"GITHUB_AW_SAFE_OUTPUTS\" = \"${{ env.GITHUB_AW_SAFE_OUTPUTS }}\", \"GITHUB_AW_SAFE_OUTPUTS_DIR\" = \"${{ env.GITHUB_AW_SAFE_OUTPUTS_DIR }}\", \"GITHUB_AW_SAFE_OUTPUTS_FILES_DIR\" = \"${{ env.GITHUB_AW_SAFE_OUTPUTS_FILES_DIR }}\", \"GITHUB_AW_SAFE_OUTPUTS_CONFIG\" = ${{ toJSON(env.GITHUB_AW_SAFE_OUTPUTS_CONFIG) }} }\n") } default: // Handle custom MCP tools (those with MCP-compatible type) diff --git a/pkg/workflow/compiler.go b/pkg/workflow/compiler.go index bdba9f7115..bd2642124c 100644 --- a/pkg/workflow/compiler.go +++ b/pkg/workflow/compiler.go @@ -4558,7 +4558,7 @@ func (c *Compiler) generateOutputCollectionStep(yaml *strings.Builder, data *Wor yaml.WriteString(" uses: actions/upload-artifact@v4\n") yaml.WriteString(" with:\n") fmt.Fprintf(yaml, " name: %s\n", OutputArtifactName) - yaml.WriteString(" path: /tmp/gh-aw/safe-outputs/\n") + yaml.WriteString(" path: ${{ env.GITHUB_AW_SAFE_OUTPUTS_DIR }}\n") yaml.WriteString(" if-no-files-found: warn\n") yaml.WriteString(" - name: Ingest agent output\n") diff --git a/pkg/workflow/custom_engine.go b/pkg/workflow/custom_engine.go index 4c96978fd9..4c1a9b43d2 100644 --- a/pkg/workflow/custom_engine.go +++ b/pkg/workflow/custom_engine.go @@ -52,8 +52,8 @@ func (e *CustomEngine) GetExecutionSteps(workflowData *WorkflowData, logFile str // Add GITHUB_AW_SAFE_OUTPUTS if safe-outputs feature is used if workflowData.SafeOutputs != nil { envVars["GITHUB_AW_SAFE_OUTPUTS"] = "${{ env.GITHUB_AW_SAFE_OUTPUTS }}" - envVars["GITHUB_AW_SAFE_OUTPUTS_DIR"] = "${{ steps.setup_agent_output.outputs.output_dir }}" - envVars["GITHUB_AW_SAFE_OUTPUTS_FILES_DIR"] = "${{ steps.setup_agent_output.outputs.files_dir }}" + envVars["GITHUB_AW_SAFE_OUTPUTS_DIR"] = "${{ env.GITHUB_AW_SAFE_OUTPUTS_DIR }}" + envVars["GITHUB_AW_SAFE_OUTPUTS_FILES_DIR"] = "${{ env.GITHUB_AW_SAFE_OUTPUTS_FILES_DIR }}" // Add staged flag if specified if workflowData.SafeOutputs.Staged != nil && *workflowData.SafeOutputs.Staged { @@ -158,8 +158,8 @@ func (e *CustomEngine) RenderMCPConfig(yaml *strings.Builder, tools map[string]a yaml.WriteString(" \"args\": [\"/tmp/safe-outputs/mcp-server.cjs\"],\n") yaml.WriteString(" \"env\": {\n") yaml.WriteString(" \"GITHUB_AW_SAFE_OUTPUTS\": \"${{ env.GITHUB_AW_SAFE_OUTPUTS }}\",\n") - yaml.WriteString(" \"GITHUB_AW_SAFE_OUTPUTS_DIR\": \"${{ steps.setup_agent_output.outputs.output_dir }}\",\n") - yaml.WriteString(" \"GITHUB_AW_SAFE_OUTPUTS_FILES_DIR\": \"${{ steps.setup_agent_output.outputs.files_dir }}\",\n") + yaml.WriteString(" \"GITHUB_AW_SAFE_OUTPUTS_DIR\": \"${{ env.GITHUB_AW_SAFE_OUTPUTS_DIR }}\",\n") + yaml.WriteString(" \"GITHUB_AW_SAFE_OUTPUTS_FILES_DIR\": \"${{ env.GITHUB_AW_SAFE_OUTPUTS_FILES_DIR }}\",\n") yaml.WriteString(" \"GITHUB_AW_SAFE_OUTPUTS_CONFIG\": ${{ toJSON(env.GITHUB_AW_SAFE_OUTPUTS_CONFIG) }}\n") yaml.WriteString(" }\n") serverCount++ From f946c15000caa0893362aa25aa61586d1faf6aab Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 16 Sep 2025 12:49:29 +0000 Subject: [PATCH 12/12] Update filename to safe_outputs.jsonl and add environment variable validation Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/ci-doctor.lock.yml | 16 ++++++++++++---- .github/workflows/dev.lock.yml | 16 ++++++++++++---- pkg/workflow/js/safe_outputs_mcp_server.cjs | 15 ++++++++++++--- pkg/workflow/js/setup_agent_output.cjs | 2 +- 4 files changed, 37 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci-doctor.lock.yml b/.github/workflows/ci-doctor.lock.yml index 8bd860ecd8..af5283c022 100644 --- a/.github/workflows/ci-doctor.lock.yml +++ b/.github/workflows/ci-doctor.lock.yml @@ -60,7 +60,7 @@ jobs: // Create the safe outputs directory structure const safeOutputsDir = "/tmp/gh-aw/safe-outputs"; const filesDir = `${safeOutputsDir}/files`; - const outputFile = `${safeOutputsDir}/outputs.jsonl`; + const outputFile = `${safeOutputsDir}/safe_outputs.jsonl`; // Ensure the safe outputs directory structure exists fs.mkdirSync(safeOutputsDir, { recursive: true }); fs.mkdirSync(filesDir, { recursive: true }); @@ -91,6 +91,17 @@ jobs: const outputFile = process.env.GITHUB_AW_SAFE_OUTPUTS; if (!outputFile) throw new Error("GITHUB_AW_SAFE_OUTPUTS not set, no output file"); + // Validate required directory environment variables + const safeOutputsDir = process.env.GITHUB_AW_SAFE_OUTPUTS_DIR; + if (!safeOutputsDir) + throw new Error( + "GITHUB_AW_SAFE_OUTPUTS_DIR not set, no safe outputs directory" + ); + const filesDir = process.env.GITHUB_AW_SAFE_OUTPUTS_FILES_DIR; + if (!filesDir) + throw new Error( + "GITHUB_AW_SAFE_OUTPUTS_FILES_DIR not set, no files directory" + ); const SERVER_INFO = { name: "safe-outputs-mcp-server", version: "1.0.0" }; const debug = msg => process.stderr.write(`[${SERVER_INFO.name}] ${msg}\n`); function writeMessage(obj) { @@ -452,9 +463,6 @@ jobs: } const shaFilename = fileSha + originalExtension; // Copy file to safe outputs files directory with SHA-based filename - const filesDir = - process.env.GITHUB_AW_SAFE_OUTPUTS_FILES_DIR || - `${process.env.GITHUB_AW_SAFE_OUTPUTS_DIR || "/tmp/gh-aw/safe-outputs"}/files`; const targetFile = path.join(filesDir, shaFilename); // Ensure directory exists fs.mkdirSync(filesDir, { recursive: true }); diff --git a/.github/workflows/dev.lock.yml b/.github/workflows/dev.lock.yml index 95fc64b9b8..c20ba6d36a 100644 --- a/.github/workflows/dev.lock.yml +++ b/.github/workflows/dev.lock.yml @@ -239,7 +239,7 @@ jobs: // Create the safe outputs directory structure const safeOutputsDir = "/tmp/gh-aw/safe-outputs"; const filesDir = `${safeOutputsDir}/files`; - const outputFile = `${safeOutputsDir}/outputs.jsonl`; + const outputFile = `${safeOutputsDir}/safe_outputs.jsonl`; // Ensure the safe outputs directory structure exists fs.mkdirSync(safeOutputsDir, { recursive: true }); fs.mkdirSync(filesDir, { recursive: true }); @@ -270,6 +270,17 @@ jobs: const outputFile = process.env.GITHUB_AW_SAFE_OUTPUTS; if (!outputFile) throw new Error("GITHUB_AW_SAFE_OUTPUTS not set, no output file"); + // Validate required directory environment variables + const safeOutputsDir = process.env.GITHUB_AW_SAFE_OUTPUTS_DIR; + if (!safeOutputsDir) + throw new Error( + "GITHUB_AW_SAFE_OUTPUTS_DIR not set, no safe outputs directory" + ); + const filesDir = process.env.GITHUB_AW_SAFE_OUTPUTS_FILES_DIR; + if (!filesDir) + throw new Error( + "GITHUB_AW_SAFE_OUTPUTS_FILES_DIR not set, no files directory" + ); const SERVER_INFO = { name: "safe-outputs-mcp-server", version: "1.0.0" }; const debug = msg => process.stderr.write(`[${SERVER_INFO.name}] ${msg}\n`); function writeMessage(obj) { @@ -631,9 +642,6 @@ jobs: } const shaFilename = fileSha + originalExtension; // Copy file to safe outputs files directory with SHA-based filename - const filesDir = - process.env.GITHUB_AW_SAFE_OUTPUTS_FILES_DIR || - `${process.env.GITHUB_AW_SAFE_OUTPUTS_DIR || "/tmp/gh-aw/safe-outputs"}/files`; const targetFile = path.join(filesDir, shaFilename); // Ensure directory exists fs.mkdirSync(filesDir, { recursive: true }); diff --git a/pkg/workflow/js/safe_outputs_mcp_server.cjs b/pkg/workflow/js/safe_outputs_mcp_server.cjs index 283161f437..d44495f95c 100644 --- a/pkg/workflow/js/safe_outputs_mcp_server.cjs +++ b/pkg/workflow/js/safe_outputs_mcp_server.cjs @@ -6,6 +6,18 @@ const safeOutputsConfig = JSON.parse(configEnv); const outputFile = process.env.GITHUB_AW_SAFE_OUTPUTS; if (!outputFile) throw new Error("GITHUB_AW_SAFE_OUTPUTS not set, no output file"); + +// Validate required directory environment variables +const safeOutputsDir = process.env.GITHUB_AW_SAFE_OUTPUTS_DIR; +if (!safeOutputsDir) + throw new Error( + "GITHUB_AW_SAFE_OUTPUTS_DIR not set, no safe outputs directory" + ); +const filesDir = process.env.GITHUB_AW_SAFE_OUTPUTS_FILES_DIR; +if (!filesDir) + throw new Error( + "GITHUB_AW_SAFE_OUTPUTS_FILES_DIR not set, no files directory" + ); const SERVER_INFO = { name: "safe-outputs-mcp-server", version: "1.0.0" }; const debug = msg => process.stderr.write(`[${SERVER_INFO.name}] ${msg}\n`); function writeMessage(obj) { @@ -391,9 +403,6 @@ const TOOLS = Object.fromEntries( const shaFilename = fileSha + originalExtension; // Copy file to safe outputs files directory with SHA-based filename - const filesDir = - process.env.GITHUB_AW_SAFE_OUTPUTS_FILES_DIR || - `${process.env.GITHUB_AW_SAFE_OUTPUTS_DIR || "/tmp/gh-aw/safe-outputs"}/files`; const targetFile = path.join(filesDir, shaFilename); // Ensure directory exists diff --git a/pkg/workflow/js/setup_agent_output.cjs b/pkg/workflow/js/setup_agent_output.cjs index b296ef5eae..2407db6dfb 100644 --- a/pkg/workflow/js/setup_agent_output.cjs +++ b/pkg/workflow/js/setup_agent_output.cjs @@ -5,7 +5,7 @@ function main() { // Create the safe outputs directory structure const safeOutputsDir = "/tmp/gh-aw/safe-outputs"; const filesDir = `${safeOutputsDir}/files`; - const outputFile = `${safeOutputsDir}/outputs.jsonl`; + const outputFile = `${safeOutputsDir}/safe_outputs.jsonl`; // Ensure the safe outputs directory structure exists fs.mkdirSync(safeOutputsDir, { recursive: true });