From 80aea5f969fb68e109804a7a6f09757037466e34 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 29 Nov 2025 17:37:48 +0000 Subject: [PATCH 01/11] Initial plan From 0b7537efd3ab9993a0b9232fb592c70d82bb8f66 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 29 Nov 2025 17:55:05 +0000 Subject: [PATCH 02/11] Changes before error encountered Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/constants/constants.go | 3 + pkg/parser/schemas/main_workflow_schema.json | 117 +++++ pkg/workflow/compiler.go | 4 + pkg/workflow/copilot_engine.go | 2 +- pkg/workflow/safe_inputs.go | 490 +++++++++++++++++++ 5 files changed, 615 insertions(+), 1 deletion(-) create mode 100644 pkg/workflow/safe_inputs.go diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go index 9e96d94862..db361c9d83 100644 --- a/pkg/constants/constants.go +++ b/pkg/constants/constants.go @@ -193,6 +193,9 @@ const AgentOutputArtifactName = "agent_output.json" // SafeOutputsMCPServerID is the identifier for the safe-outputs MCP server const SafeOutputsMCPServerID = "safeoutputs" +// SafeInputsMCPServerID is the identifier for the safe-inputs MCP server +const SafeInputsMCPServerID = "safeinputs" + // Step IDs for pre-activation job const CheckMembershipStepID = "check_membership" const CheckStopTimeStepID = "check_stop_time" diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 35f8634ef9..e2a6d8c484 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -4058,6 +4058,123 @@ "description": "Enable strict mode validation for enhanced security and compliance. Strict mode enforces: (1) Write Permissions - refuses contents:write, issues:write, pull-requests:write; requires safe-outputs instead, (2) Network Configuration - requires explicit network configuration with no wildcard '*' in allowed domains, (3) Action Pinning - enforces actions pinned to commit SHAs instead of tags/branches, (4) MCP Network - requires network configuration for custom MCP servers with containers, (5) Deprecated Fields - refuses deprecated frontmatter fields. Can be enabled per-workflow via 'strict: true' in frontmatter, or disabled via 'strict: false'. CLI flag takes precedence over frontmatter (gh aw compile --strict enforces strict mode). Defaults to true. See: https://githubnext.github.io/gh-aw/reference/frontmatter/#strict-mode-strict", "examples": [true, false] }, + "safe-inputs": { + "type": "object", + "description": "Safe inputs configuration for defining custom lightweight MCP tools as JavaScript or shell scripts. Tools are mounted in an MCP server and have access to secrets specified by the user. Only one of 'script' (JavaScript) or 'run' (shell) must be specified per tool.", + "patternProperties": { + "^[a-z][a-z0-9_-]*$": { + "type": "object", + "description": "Custom tool definition. The key is the tool name (lowercase alphanumeric with dashes/underscores).", + "required": ["description"], + "properties": { + "description": { + "type": "string", + "description": "Tool description that explains what the tool does. This is required and will be shown to the AI agent." + }, + "inputs": { + "type": "object", + "description": "Optional input parameters for the tool using workflow syntax. Each property defines an input with its type and description.", + "additionalProperties": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["string", "number", "boolean", "array", "object"], + "default": "string", + "description": "The JSON schema type of the input parameter." + }, + "description": { + "type": "string", + "description": "Description of the input parameter." + }, + "required": { + "type": "boolean", + "default": false, + "description": "Whether this input is required." + }, + "default": { + "description": "Default value for the input parameter." + } + }, + "additionalProperties": false + } + }, + "script": { + "type": "string", + "description": "JavaScript implementation (CommonJS format). The script receives input parameters as a JSON object and should return a result. Cannot be used together with 'run'." + }, + "run": { + "type": "string", + "description": "Shell script implementation. The script receives input parameters as environment variables (JSON-encoded for complex types). Cannot be used together with 'script'." + }, + "env": { + "type": "object", + "description": "Environment variables to pass to the tool, typically for secrets. Use ${{ secrets.NAME }} syntax.", + "additionalProperties": { + "type": "string" + }, + "examples": [ + { + "GH_TOKEN": "${{ secrets.GITHUB_TOKEN }}", + "API_KEY": "${{ secrets.MY_API_KEY }}" + } + ] + } + }, + "additionalProperties": false, + "oneOf": [ + { + "required": ["script"], + "not": { "required": ["run"] } + }, + { + "required": ["run"], + "not": { "required": ["script"] } + } + ] + } + }, + "additionalProperties": false, + "examples": [ + { + "search-issues": { + "description": "Search GitHub issues using the GitHub API", + "inputs": { + "query": { + "type": "string", + "description": "Search query for issues", + "required": true + }, + "limit": { + "type": "number", + "description": "Maximum number of results", + "default": 10 + } + }, + "script": "const { Octokit } = require('@octokit/rest');\nconst octokit = new Octokit({ auth: process.env.GH_TOKEN });\nconst result = await octokit.search.issuesAndPullRequests({ q: inputs.query, per_page: inputs.limit });\nreturn result.data.items;", + "env": { + "GH_TOKEN": "${{ secrets.GITHUB_TOKEN }}" + } + } + }, + { + "run-linter": { + "description": "Run a custom linter on the codebase", + "inputs": { + "path": { + "type": "string", + "description": "Path to lint", + "default": "." + } + }, + "run": "eslint $INPUT_PATH --format json", + "env": { + "INPUT_PATH": "${{ inputs.path }}" + } + } + } + ] + }, "runtimes": { "type": "object", "description": "Runtime environment version overrides. Allows customizing runtime versions (e.g., Node.js, Python) or defining new runtimes. Runtimes from imported shared workflows are also merged.", diff --git a/pkg/workflow/compiler.go b/pkg/workflow/compiler.go index 4ecf95a853..50129503a7 100644 --- a/pkg/workflow/compiler.go +++ b/pkg/workflow/compiler.go @@ -239,6 +239,7 @@ type WorkflowData struct { NetworkPermissions *NetworkPermissions // parsed network permissions SandboxConfig *SandboxConfig // parsed sandbox configuration (AWF or SRT) SafeOutputs *SafeOutputsConfig // output configuration for automatic output routes + SafeInputs *SafeInputsConfig // safe-inputs configuration for custom MCP tools Roles []string // permission levels required to trigger workflow CacheMemoryConfig *CacheMemoryConfig // parsed cache-memory configuration SafetyPrompt bool // whether to include XPIA safety prompt (default true) @@ -1257,6 +1258,9 @@ func (c *Compiler) ParseWorkflowFile(markdownPath string) (*WorkflowData, error) // Use the already extracted output configuration workflowData.SafeOutputs = safeOutputs + // Extract safe-inputs configuration + workflowData.SafeInputs = c.extractSafeInputsConfig(result.Frontmatter) + // Extract safe-jobs from safe-outputs.jobs location topSafeJobs := extractSafeJobsFromFrontmatter(result.Frontmatter) diff --git a/pkg/workflow/copilot_engine.go b/pkg/workflow/copilot_engine.go index 78a3ccadc8..442bcd533a 100644 --- a/pkg/workflow/copilot_engine.go +++ b/pkg/workflow/copilot_engine.go @@ -668,7 +668,7 @@ func (e *CopilotEngine) GetLogFileForParsing() string { } // computeCopilotToolArguments generates Copilot CLI tool permission arguments from workflow tools configuration -func (e *CopilotEngine) computeCopilotToolArguments(tools map[string]any, safeOutputs *SafeOutputsConfig) []string { +func (e *CopilotEngine) computeCopilotToolArguments(tools map[string]any, safeOutputs *SafeOutputsConfig, safeInputs *SafeInputsConfig) []string { if tools == nil { tools = make(map[string]any) } diff --git a/pkg/workflow/safe_inputs.go b/pkg/workflow/safe_inputs.go new file mode 100644 index 0000000000..3e1a1065c3 --- /dev/null +++ b/pkg/workflow/safe_inputs.go @@ -0,0 +1,490 @@ +package workflow + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/githubnext/gh-aw/pkg/constants" + "github.com/githubnext/gh-aw/pkg/logger" +) + +var safeInputsLog = logger.New("workflow:safe_inputs") + +// SafeInputsConfig holds the configuration for safe-inputs custom tools +type SafeInputsConfig struct { + Tools map[string]*SafeInputToolConfig +} + +// SafeInputToolConfig holds the configuration for a single safe-input tool +type SafeInputToolConfig struct { + Name string // Tool name (key from the config) + Description string // Required: tool description + Inputs map[string]*SafeInputParam // Optional: input parameters + Script string // JavaScript implementation (mutually exclusive with Run) + Run string // Shell script implementation (mutually exclusive with Script) + Env map[string]string // Environment variables (typically for secrets) +} + +// SafeInputParam holds the configuration for a tool input parameter +type SafeInputParam struct { + Type string // JSON schema type (string, number, boolean, array, object) + Description string // Description of the parameter + Required bool // Whether the parameter is required + Default any // Default value +} + +// HasSafeInputs checks if safe-inputs are configured +func HasSafeInputs(safeInputs *SafeInputsConfig) bool { + return safeInputs != nil && len(safeInputs.Tools) > 0 +} + +// extractSafeInputsConfig extracts safe-inputs configuration from frontmatter +func (c *Compiler) extractSafeInputsConfig(frontmatter map[string]any) *SafeInputsConfig { + safeInputsLog.Print("Extracting safe-inputs configuration from frontmatter") + + safeInputs, exists := frontmatter["safe-inputs"] + if !exists { + return nil + } + + safeInputsMap, ok := safeInputs.(map[string]any) + if !ok { + return nil + } + + config := &SafeInputsConfig{ + Tools: make(map[string]*SafeInputToolConfig), + } + + for toolName, toolValue := range safeInputsMap { + toolMap, ok := toolValue.(map[string]any) + if !ok { + continue + } + + toolConfig := &SafeInputToolConfig{ + Name: toolName, + Inputs: make(map[string]*SafeInputParam), + Env: make(map[string]string), + } + + // Parse description (required) + if desc, exists := toolMap["description"]; exists { + if descStr, ok := desc.(string); ok { + toolConfig.Description = descStr + } + } + + // Parse inputs (optional) + if inputs, exists := toolMap["inputs"]; exists { + if inputsMap, ok := inputs.(map[string]any); ok { + for paramName, paramValue := range inputsMap { + if paramMap, ok := paramValue.(map[string]any); ok { + param := &SafeInputParam{ + Type: "string", // default type + } + + if t, exists := paramMap["type"]; exists { + if tStr, ok := t.(string); ok { + param.Type = tStr + } + } + + if desc, exists := paramMap["description"]; exists { + if descStr, ok := desc.(string); ok { + param.Description = descStr + } + } + + if req, exists := paramMap["required"]; exists { + if reqBool, ok := req.(bool); ok { + param.Required = reqBool + } + } + + if def, exists := paramMap["default"]; exists { + param.Default = def + } + + toolConfig.Inputs[paramName] = param + } + } + } + } + + // Parse script (JavaScript implementation) + if script, exists := toolMap["script"]; exists { + if scriptStr, ok := script.(string); ok { + toolConfig.Script = scriptStr + } + } + + // Parse run (shell script implementation) + if run, exists := toolMap["run"]; exists { + if runStr, ok := run.(string); ok { + toolConfig.Run = runStr + } + } + + // Parse env (environment variables) + if env, exists := toolMap["env"]; exists { + if envMap, ok := env.(map[string]any); ok { + for envName, envValue := range envMap { + if envStr, ok := envValue.(string); ok { + toolConfig.Env[envName] = envStr + } + } + } + } + + config.Tools[toolName] = toolConfig + } + + if len(config.Tools) == 0 { + return nil + } + + safeInputsLog.Printf("Extracted %d safe-input tools", len(config.Tools)) + return config +} + +// SafeInputsDirectory is the directory where safe-inputs files are generated +const SafeInputsDirectory = "/tmp/gh-aw/safe-inputs" + +// generateSafeInputsFiles generates the MCP server and tool files for safe-inputs +func (c *Compiler) generateSafeInputsFiles(safeInputs *SafeInputsConfig) error { + if safeInputs == nil || len(safeInputs.Tools) == 0 { + return nil + } + + safeInputsLog.Printf("Generating safe-inputs files for %d tools", len(safeInputs.Tools)) + + // Create the safe-inputs directory + if err := os.MkdirAll(SafeInputsDirectory, 0755); err != nil { + return fmt.Errorf("failed to create safe-inputs directory: %w", err) + } + + // Generate tools configuration JSON + toolsConfig, err := c.generateToolsConfig(safeInputs) + if err != nil { + return fmt.Errorf("failed to generate tools config: %w", err) + } + + // Write tools configuration + toolsConfigPath := filepath.Join(SafeInputsDirectory, "tools.json") + if err := os.WriteFile(toolsConfigPath, []byte(toolsConfig), 0644); err != nil { + return fmt.Errorf("failed to write tools config: %w", err) + } + + // Generate individual tool files + for toolName, toolConfig := range safeInputs.Tools { + if toolConfig.Script != "" { + // JavaScript tool + toolPath := filepath.Join(SafeInputsDirectory, toolName+".cjs") + toolScript := c.generateJavaScriptTool(toolConfig) + if err := os.WriteFile(toolPath, []byte(toolScript), 0644); err != nil { + return fmt.Errorf("failed to write JavaScript tool %s: %w", toolName, err) + } + } else if toolConfig.Run != "" { + // Shell script tool + toolPath := filepath.Join(SafeInputsDirectory, toolName+".sh") + toolScript := c.generateShellTool(toolConfig) + if err := os.WriteFile(toolPath, []byte(toolScript), 0755); err != nil { + return fmt.Errorf("failed to write shell tool %s: %w", toolName, err) + } + } + } + + // Generate MCP server + mcpServerPath := filepath.Join(SafeInputsDirectory, "mcp-server.cjs") + mcpServerScript := c.generateSafeInputsMCPServer(safeInputs) + if err := os.WriteFile(mcpServerPath, []byte(mcpServerScript), 0644); err != nil { + return fmt.Errorf("failed to write MCP server: %w", err) + } + + safeInputsLog.Print("Safe-inputs files generated successfully") + return nil +} + +// generateToolsConfig generates the JSON configuration for all tools +func (c *Compiler) generateToolsConfig(safeInputs *SafeInputsConfig) (string, error) { + type ToolInputSchema struct { + Type string `json:"type"` + Description string `json:"description,omitempty"` + Default any `json:"default,omitempty"` + } + + type ToolConfig struct { + Name string `json:"name"` + Description string `json:"description"` + Type string `json:"type"` // "javascript" or "shell" + InputSchema map[string]ToolInputSchema `json:"inputSchema"` + Required []string `json:"required,omitempty"` + } + + tools := make(map[string]ToolConfig) + + for toolName, toolConfig := range safeInputs.Tools { + tc := ToolConfig{ + Name: toolName, + Description: toolConfig.Description, + InputSchema: make(map[string]ToolInputSchema), + } + + if toolConfig.Script != "" { + tc.Type = "javascript" + } else { + tc.Type = "shell" + } + + var required []string + for paramName, param := range toolConfig.Inputs { + tc.InputSchema[paramName] = ToolInputSchema{ + Type: param.Type, + Description: param.Description, + Default: param.Default, + } + if param.Required { + required = append(required, paramName) + } + } + sort.Strings(required) + tc.Required = required + + tools[toolName] = tc + } + + data, err := json.MarshalIndent(tools, "", " ") + if err != nil { + return "", err + } + + return string(data), nil +} + +// generateJavaScriptTool generates the JavaScript wrapper for a tool +func (c *Compiler) generateJavaScriptTool(toolConfig *SafeInputToolConfig) string { + var sb strings.Builder + + sb.WriteString("// @ts-check\n") + sb.WriteString("// Auto-generated safe-input tool: " + toolConfig.Name + "\n\n") + sb.WriteString("/**\n") + sb.WriteString(" * " + toolConfig.Description + "\n") + sb.WriteString(" * @param {Object} inputs - Input parameters\n") + for paramName, param := range toolConfig.Inputs { + sb.WriteString(fmt.Sprintf(" * @param {%s} inputs.%s - %s\n", param.Type, paramName, param.Description)) + } + sb.WriteString(" * @returns {Promise} Tool result\n") + sb.WriteString(" */\n") + sb.WriteString("async function execute(inputs) {\n") + sb.WriteString(" " + strings.ReplaceAll(toolConfig.Script, "\n", "\n ") + "\n") + sb.WriteString("}\n\n") + sb.WriteString("module.exports = { execute };\n") + + return sb.String() +} + +// generateShellTool generates the shell script wrapper for a tool +func (c *Compiler) generateShellTool(toolConfig *SafeInputToolConfig) string { + var sb strings.Builder + + sb.WriteString("#!/bin/bash\n") + sb.WriteString("# Auto-generated safe-input tool: " + toolConfig.Name + "\n") + sb.WriteString("# " + toolConfig.Description + "\n\n") + sb.WriteString("set -euo pipefail\n\n") + sb.WriteString(toolConfig.Run + "\n") + + return sb.String() +} + +// generateSafeInputsMCPServer generates the MCP server JavaScript for safe-inputs +func (c *Compiler) generateSafeInputsMCPServer(safeInputs *SafeInputsConfig) string { + var sb strings.Builder + + sb.WriteString(`// @ts-check +// Auto-generated safe-inputs MCP server + +const fs = require("fs"); +const path = require("path"); +const { execFile } = require("child_process"); +const { promisify } = require("util"); + +const execFileAsync = promisify(execFile); + +const { createServer, registerTool, start } = require("./mcp_server_core.cjs"); + +// Server info for safe inputs MCP server +const SERVER_INFO = { name: "safeinputs", version: "1.0.0" }; + +// Create the server instance +const MCP_LOG_DIR = process.env.GH_AW_MCP_LOG_DIR; +const server = createServer(SERVER_INFO, { logDir: MCP_LOG_DIR }); + +// Load tools configuration +const toolsConfigPath = path.join(__dirname, "tools.json"); +let toolsConfig = {}; + +try { + if (fs.existsSync(toolsConfigPath)) { + toolsConfig = JSON.parse(fs.readFileSync(toolsConfigPath, "utf8")); + server.debug("Loaded tools config: " + JSON.stringify(Object.keys(toolsConfig))); + } +} catch (error) { + server.debug("Error loading tools config: " + (error instanceof Error ? error.message : String(error))); +} + +`) + + // Register each tool + for toolName, toolConfig := range safeInputs.Tools { + sb.WriteString(fmt.Sprintf("// Register tool: %s\n", toolName)) + + // Build input schema + inputSchema := map[string]any{ + "type": "object", + "properties": make(map[string]any), + } + + props := inputSchema["properties"].(map[string]any) + var required []string + + for paramName, param := range toolConfig.Inputs { + props[paramName] = map[string]any{ + "type": param.Type, + "description": param.Description, + } + if param.Default != nil { + props[paramName].(map[string]any)["default"] = param.Default + } + if param.Required { + required = append(required, paramName) + } + } + + sort.Strings(required) + if len(required) > 0 { + inputSchema["required"] = required + } + + inputSchemaJSON, _ := json.Marshal(inputSchema) + + if toolConfig.Script != "" { + sb.WriteString(fmt.Sprintf(`registerTool(server, { + name: %q, + description: %q, + inputSchema: %s, + handler: async (args) => { + try { + const toolModule = require("./%s.cjs"); + const result = await toolModule.execute(args || {}); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (error) { + return { content: [{ type: "text", text: "Error: " + (error instanceof Error ? error.message : String(error)) }], isError: true }; + } + } +}); + +`, toolName, toolConfig.Description, string(inputSchemaJSON), toolName)) + } else { + sb.WriteString(fmt.Sprintf(`registerTool(server, { + name: %q, + description: %q, + inputSchema: %s, + handler: async (args) => { + try { + // Set input parameters as environment variables + const env = { ...process.env }; +`, toolName, toolConfig.Description, string(inputSchemaJSON))) + + for paramName := range toolConfig.Inputs { + sb.WriteString(fmt.Sprintf(` if (args && args.%s !== undefined) { + env["INPUT_%s"] = typeof args.%s === "object" ? JSON.stringify(args.%s) : String(args.%s); + } +`, paramName, strings.ToUpper(paramName), paramName, paramName, paramName)) + } + + sb.WriteString(fmt.Sprintf(` + const scriptPath = path.join(__dirname, "%s.sh"); + const { stdout, stderr } = await execFileAsync("bash", [scriptPath], { env }); + const output = stdout + (stderr ? "\nStderr: " + stderr : ""); + return { content: [{ type: "text", text: output }] }; + } catch (error) { + return { content: [{ type: "text", text: "Error: " + (error instanceof Error ? error.message : String(error)) }], isError: true }; + } + } +}); + +`, toolName)) + } + } + + sb.WriteString(`// Start the MCP server +start(server); +`) + + return sb.String() +} + +// getSafeInputsEnvVars returns the list of environment variables needed for safe-inputs +func getSafeInputsEnvVars(safeInputs *SafeInputsConfig) []string { + envVars := []string{} + seen := make(map[string]bool) + + if safeInputs == nil { + return envVars + } + + for _, toolConfig := range safeInputs.Tools { + for envName := range toolConfig.Env { + if !seen[envName] { + envVars = append(envVars, envName) + seen[envName] = true + } + } + } + + sort.Strings(envVars) + return envVars +} + +// collectSafeInputsSecrets collects all secrets from safe-inputs configuration +func collectSafeInputsSecrets(safeInputs *SafeInputsConfig) map[string]string { + secrets := make(map[string]string) + + if safeInputs == nil { + return secrets + } + + for _, toolConfig := range safeInputs.Tools { + for envName, envValue := range toolConfig.Env { + secrets[envName] = envValue + } + } + + return secrets +} + +// renderSafeInputsMCPConfig generates the Safe Inputs MCP server configuration +func renderSafeInputsMCPConfig(yaml *strings.Builder, safeInputs *SafeInputsConfig, isLast bool) { + safeInputsLog.Print("Rendering Safe Inputs MCP configuration") + renderSafeInputsMCPConfigWithOptions(yaml, safeInputs, isLast, false) +} + +// renderSafeInputsMCPConfigWithOptions generates the Safe Inputs MCP server configuration with engine-specific options +func renderSafeInputsMCPConfigWithOptions(yaml *strings.Builder, safeInputs *SafeInputsConfig, isLast bool, includeCopilotFields bool) { + envVars := getSafeInputsEnvVars(safeInputs) + + renderBuiltinMCPServerBlock( + yaml, + constants.SafeInputsMCPServerID, + "node", + []string{SafeInputsDirectory + "/mcp-server.cjs"}, + envVars, + isLast, + includeCopilotFields, + ) +} From 1e1e43bdaf41cd39bd91293d3d8f141236d7326b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 29 Nov 2025 19:20:41 +0000 Subject: [PATCH 03/11] Integrate safe-inputs MCP server into workflow compilation Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../docs/reference/frontmatter-full.md | 8 + .../bash_defaults_consistency_test.go | 2 +- pkg/workflow/claude_mcp.go | 4 + pkg/workflow/codex_engine.go | 6 + pkg/workflow/copilot_engine.go | 17 +- pkg/workflow/copilot_engine_test.go | 4 +- .../copilot_git_commands_integration_test.go | 2 +- pkg/workflow/custom_engine.go | 4 + pkg/workflow/mcp_renderer.go | 38 ++ pkg/workflow/mcp_servers.go | 75 ++++ pkg/workflow/safe_inputs.go | 371 ++++++++---------- 11 files changed, 313 insertions(+), 218 deletions(-) diff --git a/docs/src/content/docs/reference/frontmatter-full.md b/docs/src/content/docs/reference/frontmatter-full.md index e96cc6088a..985db81ad7 100644 --- a/docs/src/content/docs/reference/frontmatter-full.md +++ b/docs/src/content/docs/reference/frontmatter-full.md @@ -2444,6 +2444,14 @@ roles: [] # (optional) strict: true +# Safe inputs configuration for defining custom lightweight MCP tools as +# JavaScript or shell scripts. Tools are mounted in an MCP server and have access +# to secrets specified by the user. Only one of 'script' (JavaScript) or 'run' +# (shell) must be specified per tool. +# (optional) +safe-inputs: + {} + # Runtime environment version overrides. Allows customizing runtime versions # (e.g., Node.js, Python) or defining new runtimes. Runtimes from imported shared # workflows are also merged. diff --git a/pkg/workflow/bash_defaults_consistency_test.go b/pkg/workflow/bash_defaults_consistency_test.go index 1bf79cca65..e68700ed76 100644 --- a/pkg/workflow/bash_defaults_consistency_test.go +++ b/pkg/workflow/bash_defaults_consistency_test.go @@ -95,7 +95,7 @@ func TestBashDefaultsConsistency(t *testing.T) { // Get results from both engines claudeResult := claudeEngine.computeAllowedClaudeToolsString(claudeTools, tt.safeOutputs, cacheMemoryConfig) - copilotResult := copilotEngine.computeCopilotToolArguments(copilotTools, tt.safeOutputs) + copilotResult := copilotEngine.computeCopilotToolArguments(copilotTools, tt.safeOutputs, nil) t.Logf("Claude tools after defaults: %+v", claudeTools) t.Logf("Copilot tools after defaults: %+v", copilotTools) diff --git a/pkg/workflow/claude_mcp.go b/pkg/workflow/claude_mcp.go index 8f111b8be8..8834922842 100644 --- a/pkg/workflow/claude_mcp.go +++ b/pkg/workflow/claude_mcp.go @@ -48,6 +48,10 @@ func (e *ClaudeEngine) RenderMCPConfig(yaml *strings.Builder, tools map[string]a renderer := createRenderer(isLast) renderer.RenderSafeOutputsMCP(yaml) }, + RenderSafeInputs: func(yaml *strings.Builder, safeInputs *SafeInputsConfig, isLast bool) { + renderer := createRenderer(isLast) + renderer.RenderSafeInputsMCP(yaml, safeInputs) + }, RenderWebFetch: func(yaml *strings.Builder, isLast bool) { renderMCPFetchServerConfig(yaml, "json", " ", isLast, false) }, diff --git a/pkg/workflow/codex_engine.go b/pkg/workflow/codex_engine.go index f048e4219a..876febe0f6 100644 --- a/pkg/workflow/codex_engine.go +++ b/pkg/workflow/codex_engine.go @@ -376,6 +376,12 @@ func (e *CodexEngine) RenderMCPConfig(yaml *strings.Builder, tools map[string]an if hasSafeOutputs { renderer.RenderSafeOutputsMCP(yaml) } + case "safe-inputs": + // Add safe-inputs MCP server if safe-inputs are configured + hasSafeInputs := workflowData != nil && HasSafeInputs(workflowData.SafeInputs) + if hasSafeInputs { + renderer.RenderSafeInputsMCP(yaml, workflowData.SafeInputs) + } case "web-fetch": renderMCPFetchServerConfig(yaml, "toml", " ", false, false) default: diff --git a/pkg/workflow/copilot_engine.go b/pkg/workflow/copilot_engine.go index 442bcd533a..4dbda7b523 100644 --- a/pkg/workflow/copilot_engine.go +++ b/pkg/workflow/copilot_engine.go @@ -188,7 +188,7 @@ func (e *CopilotEngine) GetExecutionSteps(workflowData *WorkflowData, logFile st } // Add tool permission arguments based on configuration - toolArgs := e.computeCopilotToolArguments(workflowData.Tools, workflowData.SafeOutputs) + toolArgs := e.computeCopilotToolArguments(workflowData.Tools, workflowData.SafeOutputs, workflowData.SafeInputs) if len(toolArgs) > 0 { copilotLog.Printf("Adding %d tool permission arguments", len(toolArgs)) } @@ -420,7 +420,7 @@ COPILOT_CLI_INSTRUCTION="$(cat /tmp/gh-aw/aw-prompts/prompt.txt)" stepLines = append(stepLines, " id: agentic_execution") // Add tool arguments comment before the run section - toolArgsComment := e.generateCopilotToolArgumentsComment(workflowData.Tools, workflowData.SafeOutputs, " ") + toolArgsComment := e.generateCopilotToolArgumentsComment(workflowData.Tools, workflowData.SafeOutputs, workflowData.SafeInputs, " ") if toolArgsComment != "" { // Split the comment into lines and add each line commentLines := strings.Split(strings.TrimSuffix(toolArgsComment, "\n"), "\n") @@ -518,6 +518,10 @@ func (e *CopilotEngine) RenderMCPConfig(yaml *strings.Builder, tools map[string] renderer := createRenderer(isLast) renderer.RenderSafeOutputsMCP(yaml) }, + RenderSafeInputs: func(yaml *strings.Builder, safeInputs *SafeInputsConfig, isLast bool) { + renderer := createRenderer(isLast) + renderer.RenderSafeInputsMCP(yaml, safeInputs) + }, RenderWebFetch: func(yaml *strings.Builder, isLast bool) { renderMCPFetchServerConfig(yaml, "json", " ", isLast, true) }, @@ -717,6 +721,11 @@ func (e *CopilotEngine) computeCopilotToolArguments(tools map[string]any, safeOu args = append(args, "--allow-tool", constants.SafeOutputsMCPServerID) } + // Handle safe_inputs MCP server - allow the server if safe inputs are configured + if HasSafeInputs(safeInputs) { + args = append(args, "--allow-tool", constants.SafeInputsMCPServerID) + } + // Built-in tool names that should be skipped when processing MCP servers // Note: GitHub is NOT included here because it needs MCP configuration in CLI mode // Note: web-fetch is NOT included here because it may be an MCP server for engines without native support @@ -810,8 +819,8 @@ func (e *CopilotEngine) computeCopilotToolArguments(tools map[string]any, safeOu } // generateCopilotToolArgumentsComment generates a multi-line comment showing each tool argument -func (e *CopilotEngine) generateCopilotToolArgumentsComment(tools map[string]any, safeOutputs *SafeOutputsConfig, indent string) string { - toolArgs := e.computeCopilotToolArguments(tools, safeOutputs) +func (e *CopilotEngine) generateCopilotToolArgumentsComment(tools map[string]any, safeOutputs *SafeOutputsConfig, safeInputs *SafeInputsConfig, indent string) string { + toolArgs := e.computeCopilotToolArguments(tools, safeOutputs, safeInputs) if len(toolArgs) == 0 { return "" } diff --git a/pkg/workflow/copilot_engine_test.go b/pkg/workflow/copilot_engine_test.go index b4b5a81788..ba02e4ab30 100644 --- a/pkg/workflow/copilot_engine_test.go +++ b/pkg/workflow/copilot_engine_test.go @@ -391,7 +391,7 @@ func TestCopilotEngineComputeToolArguments(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := engine.computeCopilotToolArguments(tt.tools, tt.safeOutputs) + result := engine.computeCopilotToolArguments(tt.tools, tt.safeOutputs, nil) if len(result) != len(tt.expected) { t.Errorf("Expected %d arguments, got %d: %v", len(tt.expected), len(result), result) @@ -443,7 +443,7 @@ func TestCopilotEngineGenerateToolArgumentsComment(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := engine.generateCopilotToolArgumentsComment(tt.tools, tt.safeOutputs, tt.indent) + result := engine.generateCopilotToolArgumentsComment(tt.tools, tt.safeOutputs, nil, tt.indent) if result != tt.expected { t.Errorf("Expected comment:\n%s\nGot:\n%s", tt.expected, result) diff --git a/pkg/workflow/copilot_git_commands_integration_test.go b/pkg/workflow/copilot_git_commands_integration_test.go index d5940304e4..d38b512b5f 100644 --- a/pkg/workflow/copilot_git_commands_integration_test.go +++ b/pkg/workflow/copilot_git_commands_integration_test.go @@ -129,7 +129,7 @@ func (c *Compiler) parseCopilotWorkflowMarkdownContentWithToolArgs(content strin SafeOutputs: safeOutputs, AI: "copilot", } - allowedToolArgs := engine.computeCopilotToolArguments(topTools, safeOutputs) + allowedToolArgs := engine.computeCopilotToolArguments(topTools, safeOutputs, nil) return workflowData, allowedToolArgs, nil } diff --git a/pkg/workflow/custom_engine.go b/pkg/workflow/custom_engine.go index 7babe51ca2..d2e04204cc 100644 --- a/pkg/workflow/custom_engine.go +++ b/pkg/workflow/custom_engine.go @@ -185,6 +185,10 @@ func (e *CustomEngine) RenderMCPConfig(yaml *strings.Builder, tools map[string]a renderer := createRenderer(isLast) renderer.RenderSafeOutputsMCP(yaml) }, + RenderSafeInputs: func(yaml *strings.Builder, safeInputs *SafeInputsConfig, isLast bool) { + renderer := createRenderer(isLast) + renderer.RenderSafeInputsMCP(yaml, safeInputs) + }, RenderWebFetch: func(yaml *strings.Builder, isLast bool) { renderMCPFetchServerConfig(yaml, "json", " ", isLast, false) }, diff --git a/pkg/workflow/mcp_renderer.go b/pkg/workflow/mcp_renderer.go index 09909edcb1..2a1d1f73e0 100644 --- a/pkg/workflow/mcp_renderer.go +++ b/pkg/workflow/mcp_renderer.go @@ -205,6 +205,39 @@ func (r *MCPConfigRendererUnified) renderSafeOutputsTOML(yaml *strings.Builder) yaml.WriteString(" env_vars = [\"GH_AW_SAFE_OUTPUTS\", \"GH_AW_ASSETS_BRANCH\", \"GH_AW_ASSETS_MAX_SIZE_KB\", \"GH_AW_ASSETS_ALLOWED_EXTS\", \"GITHUB_REPOSITORY\", \"GITHUB_SERVER_URL\"]\n") } +// RenderSafeInputsMCP generates the Safe Inputs MCP server configuration +func (r *MCPConfigRendererUnified) RenderSafeInputsMCP(yaml *strings.Builder, safeInputs *SafeInputsConfig) { + mcpRendererLog.Printf("Rendering Safe Inputs MCP: format=%s", r.options.Format) + + if r.options.Format == "toml" { + r.renderSafeInputsTOML(yaml, safeInputs) + return + } + + // JSON format + renderSafeInputsMCPConfigWithOptions(yaml, safeInputs, r.options.IsLast, r.options.IncludeCopilotFields) +} + +// renderSafeInputsTOML generates Safe Inputs MCP configuration in TOML format +func (r *MCPConfigRendererUnified) renderSafeInputsTOML(yaml *strings.Builder, safeInputs *SafeInputsConfig) { + yaml.WriteString(" \n") + yaml.WriteString(" [mcp_servers." + constants.SafeInputsMCPServerID + "]\n") + yaml.WriteString(" command = \"node\"\n") + yaml.WriteString(" args = [\n") + yaml.WriteString(" \"/tmp/gh-aw/safe-inputs/mcp-server.cjs\",\n") + yaml.WriteString(" ]\n") + // Add environment variables from safe-inputs config + envVars := getSafeInputsEnvVars(safeInputs) + yaml.WriteString(" env_vars = [") + for i, envVar := range envVars { + if i > 0 { + yaml.WriteString(", ") + } + yaml.WriteString("\"" + envVar + "\"") + } + yaml.WriteString("]\n") +} + // RenderAgenticWorkflowsMCP generates the Agentic Workflows MCP server configuration func (r *MCPConfigRendererUnified) RenderAgenticWorkflowsMCP(yaml *strings.Builder) { mcpRendererLog.Printf("Rendering Agentic Workflows MCP: format=%s", r.options.Format) @@ -361,6 +394,7 @@ type MCPToolRenderers struct { RenderCacheMemory func(yaml *strings.Builder, isLast bool, workflowData *WorkflowData) RenderAgenticWorkflows func(yaml *strings.Builder, isLast bool) RenderSafeOutputs func(yaml *strings.Builder, isLast bool) + RenderSafeInputs func(yaml *strings.Builder, safeInputs *SafeInputsConfig, isLast bool) RenderWebFetch func(yaml *strings.Builder, isLast bool) RenderCustomMCPConfig RenderCustomMCPToolConfigHandler } @@ -617,6 +651,10 @@ func RenderJSONMCPConfig( options.Renderers.RenderAgenticWorkflows(yaml, isLast) case "safe-outputs": options.Renderers.RenderSafeOutputs(yaml, isLast) + case "safe-inputs": + if options.Renderers.RenderSafeInputs != nil { + options.Renderers.RenderSafeInputs(yaml, workflowData.SafeInputs, isLast) + } case "web-fetch": options.Renderers.RenderWebFetch(yaml, isLast) default: diff --git a/pkg/workflow/mcp_servers.go b/pkg/workflow/mcp_servers.go index 4842a6437c..3d36ffd25f 100644 --- a/pkg/workflow/mcp_servers.go +++ b/pkg/workflow/mcp_servers.go @@ -38,6 +38,11 @@ func HasMCPServers(workflowData *WorkflowData) bool { return true } + // Check if safe-inputs is configured (adds safe-inputs MCP server) + if HasSafeInputs(workflowData.SafeInputs) { + return true + } + return false } @@ -71,6 +76,11 @@ func (c *Compiler) generateMCPSetup(yaml *strings.Builder, tools map[string]any, mcpTools = append(mcpTools, "safe-outputs") } + // Check if safe-inputs is configured and add to MCP tools + if HasSafeInputs(workflowData.SafeInputs) { + mcpTools = append(mcpTools, "safe-inputs") + } + // Generate safe-outputs configuration once to avoid duplicate computation var safeOutputConfig string if HasSafeOutputsEnabled(workflowData.SafeOutputs) { @@ -178,6 +188,45 @@ func (c *Compiler) generateMCPSetup(yaml *strings.Builder, tools map[string]any, yaml.WriteString(" \n") } + // Write safe-inputs MCP server if configured + if HasSafeInputs(workflowData.SafeInputs) { + yaml.WriteString(" - name: Setup Safe Inputs MCP\n") + yaml.WriteString(" run: |\n") + yaml.WriteString(" mkdir -p /tmp/gh-aw/safe-inputs\n") + + // Generate the MCP server for safe-inputs + safeInputsMCPServer := generateSafeInputsMCPServerScript(workflowData.SafeInputs) + yaml.WriteString(" cat > /tmp/gh-aw/safe-inputs/mcp-server.cjs << 'EOFSI'\n") + for _, line := range FormatJavaScriptForYAML(safeInputsMCPServer) { + yaml.WriteString(line) + } + yaml.WriteString(" EOFSI\n") + yaml.WriteString(" chmod +x /tmp/gh-aw/safe-inputs/mcp-server.cjs\n") + + // Generate individual tool files + for toolName, toolConfig := range workflowData.SafeInputs.Tools { + if toolConfig.Script != "" { + // JavaScript tool + toolScript := generateSafeInputJavaScriptToolScript(toolConfig) + yaml.WriteString(fmt.Sprintf(" cat > /tmp/gh-aw/safe-inputs/%s.cjs << 'EOFJS_%s'\n", toolName, toolName)) + for _, line := range FormatJavaScriptForYAML(toolScript) { + yaml.WriteString(line) + } + yaml.WriteString(fmt.Sprintf(" EOFJS_%s\n", toolName)) + } else if toolConfig.Run != "" { + // Shell script tool + toolScript := generateSafeInputShellToolScript(toolConfig) + yaml.WriteString(fmt.Sprintf(" cat > /tmp/gh-aw/safe-inputs/%s.sh << 'EOFSH_%s'\n", toolName, toolName)) + for _, line := range strings.Split(toolScript, "\n") { + yaml.WriteString(" " + line + "\n") + } + yaml.WriteString(fmt.Sprintf(" EOFSH_%s\n", toolName)) + yaml.WriteString(fmt.Sprintf(" chmod +x /tmp/gh-aw/safe-inputs/%s.sh\n", toolName)) + } + } + yaml.WriteString(" \n") + } + // Use the engine's RenderMCPConfig method yaml.WriteString(" - name: Setup MCPs\n") @@ -185,6 +234,7 @@ func (c *Compiler) generateMCPSetup(yaml *strings.Builder, tools map[string]any, needsEnvBlock := false hasGitHub := false hasSafeOutputs := false + hasSafeInputs := false hasPlaywright := false var playwrightAllowedDomainsSecrets map[string]string // Note: hasAgenticWorkflows is already declared earlier in this function @@ -198,6 +248,13 @@ func (c *Compiler) generateMCPSetup(yaml *strings.Builder, tools map[string]any, hasSafeOutputs = true needsEnvBlock = true } + if toolName == "safe-inputs" { + hasSafeInputs = true + safeInputsSecrets := collectSafeInputsSecrets(workflowData.SafeInputs) + if len(safeInputsSecrets) > 0 { + needsEnvBlock = true + } + } if toolName == "agentic-workflows" { needsEnvBlock = true } @@ -237,6 +294,24 @@ func (c *Compiler) generateMCPSetup(yaml *strings.Builder, tools map[string]any, } } + // Add safe-inputs env vars if present (for secrets passthrough) + if hasSafeInputs { + safeInputsSecrets := collectSafeInputsSecrets(workflowData.SafeInputs) + if len(safeInputsSecrets) > 0 { + // Sort env var names for consistent output + envVarNames := make([]string, 0, len(safeInputsSecrets)) + for envVarName := range safeInputsSecrets { + envVarNames = append(envVarNames, envVarName) + } + sort.Strings(envVarNames) + + for _, envVarName := range envVarNames { + secretExpr := safeInputsSecrets[envVarName] + yaml.WriteString(fmt.Sprintf(" %s: %s\n", envVarName, secretExpr)) + } + } + } + // Add GITHUB_TOKEN for agentic-workflows if present if hasAgenticWorkflows { yaml.WriteString(" GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n") diff --git a/pkg/workflow/safe_inputs.go b/pkg/workflow/safe_inputs.go index 3e1a1065c3..b8cad8e364 100644 --- a/pkg/workflow/safe_inputs.go +++ b/pkg/workflow/safe_inputs.go @@ -3,8 +3,6 @@ package workflow import ( "encoding/json" "fmt" - "os" - "path/filepath" "sort" "strings" @@ -155,156 +153,11 @@ func (c *Compiler) extractSafeInputsConfig(frontmatter map[string]any) *SafeInpu // SafeInputsDirectory is the directory where safe-inputs files are generated const SafeInputsDirectory = "/tmp/gh-aw/safe-inputs" -// generateSafeInputsFiles generates the MCP server and tool files for safe-inputs -func (c *Compiler) generateSafeInputsFiles(safeInputs *SafeInputsConfig) error { - if safeInputs == nil || len(safeInputs.Tools) == 0 { - return nil - } - - safeInputsLog.Printf("Generating safe-inputs files for %d tools", len(safeInputs.Tools)) - - // Create the safe-inputs directory - if err := os.MkdirAll(SafeInputsDirectory, 0755); err != nil { - return fmt.Errorf("failed to create safe-inputs directory: %w", err) - } - - // Generate tools configuration JSON - toolsConfig, err := c.generateToolsConfig(safeInputs) - if err != nil { - return fmt.Errorf("failed to generate tools config: %w", err) - } - - // Write tools configuration - toolsConfigPath := filepath.Join(SafeInputsDirectory, "tools.json") - if err := os.WriteFile(toolsConfigPath, []byte(toolsConfig), 0644); err != nil { - return fmt.Errorf("failed to write tools config: %w", err) - } - - // Generate individual tool files - for toolName, toolConfig := range safeInputs.Tools { - if toolConfig.Script != "" { - // JavaScript tool - toolPath := filepath.Join(SafeInputsDirectory, toolName+".cjs") - toolScript := c.generateJavaScriptTool(toolConfig) - if err := os.WriteFile(toolPath, []byte(toolScript), 0644); err != nil { - return fmt.Errorf("failed to write JavaScript tool %s: %w", toolName, err) - } - } else if toolConfig.Run != "" { - // Shell script tool - toolPath := filepath.Join(SafeInputsDirectory, toolName+".sh") - toolScript := c.generateShellTool(toolConfig) - if err := os.WriteFile(toolPath, []byte(toolScript), 0755); err != nil { - return fmt.Errorf("failed to write shell tool %s: %w", toolName, err) - } - } - } - - // Generate MCP server - mcpServerPath := filepath.Join(SafeInputsDirectory, "mcp-server.cjs") - mcpServerScript := c.generateSafeInputsMCPServer(safeInputs) - if err := os.WriteFile(mcpServerPath, []byte(mcpServerScript), 0644); err != nil { - return fmt.Errorf("failed to write MCP server: %w", err) - } - - safeInputsLog.Print("Safe-inputs files generated successfully") - return nil -} - -// generateToolsConfig generates the JSON configuration for all tools -func (c *Compiler) generateToolsConfig(safeInputs *SafeInputsConfig) (string, error) { - type ToolInputSchema struct { - Type string `json:"type"` - Description string `json:"description,omitempty"` - Default any `json:"default,omitempty"` - } - - type ToolConfig struct { - Name string `json:"name"` - Description string `json:"description"` - Type string `json:"type"` // "javascript" or "shell" - InputSchema map[string]ToolInputSchema `json:"inputSchema"` - Required []string `json:"required,omitempty"` - } - - tools := make(map[string]ToolConfig) - - for toolName, toolConfig := range safeInputs.Tools { - tc := ToolConfig{ - Name: toolName, - Description: toolConfig.Description, - InputSchema: make(map[string]ToolInputSchema), - } - - if toolConfig.Script != "" { - tc.Type = "javascript" - } else { - tc.Type = "shell" - } - - var required []string - for paramName, param := range toolConfig.Inputs { - tc.InputSchema[paramName] = ToolInputSchema{ - Type: param.Type, - Description: param.Description, - Default: param.Default, - } - if param.Required { - required = append(required, paramName) - } - } - sort.Strings(required) - tc.Required = required - - tools[toolName] = tc - } - - data, err := json.MarshalIndent(tools, "", " ") - if err != nil { - return "", err - } - - return string(data), nil -} - -// generateJavaScriptTool generates the JavaScript wrapper for a tool -func (c *Compiler) generateJavaScriptTool(toolConfig *SafeInputToolConfig) string { - var sb strings.Builder - - sb.WriteString("// @ts-check\n") - sb.WriteString("// Auto-generated safe-input tool: " + toolConfig.Name + "\n\n") - sb.WriteString("/**\n") - sb.WriteString(" * " + toolConfig.Description + "\n") - sb.WriteString(" * @param {Object} inputs - Input parameters\n") - for paramName, param := range toolConfig.Inputs { - sb.WriteString(fmt.Sprintf(" * @param {%s} inputs.%s - %s\n", param.Type, paramName, param.Description)) - } - sb.WriteString(" * @returns {Promise} Tool result\n") - sb.WriteString(" */\n") - sb.WriteString("async function execute(inputs) {\n") - sb.WriteString(" " + strings.ReplaceAll(toolConfig.Script, "\n", "\n ") + "\n") - sb.WriteString("}\n\n") - sb.WriteString("module.exports = { execute };\n") - - return sb.String() -} - -// generateShellTool generates the shell script wrapper for a tool -func (c *Compiler) generateShellTool(toolConfig *SafeInputToolConfig) string { - var sb strings.Builder - - sb.WriteString("#!/bin/bash\n") - sb.WriteString("# Auto-generated safe-input tool: " + toolConfig.Name + "\n") - sb.WriteString("# " + toolConfig.Description + "\n\n") - sb.WriteString("set -euo pipefail\n\n") - sb.WriteString(toolConfig.Run + "\n") - - return sb.String() -} - -// generateSafeInputsMCPServer generates the MCP server JavaScript for safe-inputs -func (c *Compiler) generateSafeInputsMCPServer(safeInputs *SafeInputsConfig) string { +// generateSafeInputsMCPServerScript generates a self-contained MCP server for safe-inputs +func generateSafeInputsMCPServerScript(safeInputs *SafeInputsConfig) string { var sb strings.Builder + // Write the MCP server core inline (simplified version for safe-inputs) sb.WriteString(`// @ts-check // Auto-generated safe-inputs MCP server @@ -315,26 +168,55 @@ const { promisify } = require("util"); const execFileAsync = promisify(execFile); -const { createServer, registerTool, start } = require("./mcp_server_core.cjs"); +// Simple ReadBuffer implementation for JSON-RPC parsing +class ReadBuffer { + constructor() { + this.buffer = Buffer.alloc(0); + } + append(chunk) { + this.buffer = Buffer.concat([this.buffer, chunk]); + } + readMessage() { + const headerEndIndex = this.buffer.indexOf("\r\n\r\n"); + if (headerEndIndex === -1) return null; + const header = this.buffer.slice(0, headerEndIndex).toString(); + const match = header.match(/Content-Length:\s*(\d+)/i); + if (!match) return null; + const contentLength = parseInt(match[1], 10); + const messageStart = headerEndIndex + 4; + if (this.buffer.length < messageStart + contentLength) return null; + const content = this.buffer.slice(messageStart, messageStart + contentLength).toString(); + this.buffer = this.buffer.slice(messageStart + contentLength); + return JSON.parse(content); + } +} -// Server info for safe inputs MCP server -const SERVER_INFO = { name: "safeinputs", version: "1.0.0" }; +// Create MCP server +const serverInfo = { name: "safeinputs", version: "1.0.0" }; +const tools = {}; +const readBuffer = new ReadBuffer(); -// Create the server instance -const MCP_LOG_DIR = process.env.GH_AW_MCP_LOG_DIR; -const server = createServer(SERVER_INFO, { logDir: MCP_LOG_DIR }); +function debug(msg) { + const timestamp = new Date().toISOString(); + process.stderr.write("[" + timestamp + "] [safeinputs] " + msg + "\n"); +} -// Load tools configuration -const toolsConfigPath = path.join(__dirname, "tools.json"); -let toolsConfig = {}; +function writeMessage(message) { + const json = JSON.stringify(message); + const header = "Content-Length: " + Buffer.byteLength(json) + "\r\n\r\n"; + process.stdout.write(header + json); +} -try { - if (fs.existsSync(toolsConfigPath)) { - toolsConfig = JSON.parse(fs.readFileSync(toolsConfigPath, "utf8")); - server.debug("Loaded tools config: " + JSON.stringify(Object.keys(toolsConfig))); - } -} catch (error) { - server.debug("Error loading tools config: " + (error instanceof Error ? error.message : String(error))); +function replyResult(id, result) { + writeMessage({ jsonrpc: "2.0", id, result }); +} + +function replyError(id, code, message) { + writeMessage({ jsonrpc: "2.0", id, error: { code, message } }); +} + +function registerTool(name, description, inputSchema, handler) { + tools[name] = { name, description, inputSchema, handler }; } `) @@ -342,16 +224,16 @@ try { // Register each tool for toolName, toolConfig := range safeInputs.Tools { sb.WriteString(fmt.Sprintf("// Register tool: %s\n", toolName)) - + // Build input schema inputSchema := map[string]any{ "type": "object", "properties": make(map[string]any), } - + props := inputSchema["properties"].(map[string]any) var required []string - + for paramName, param := range toolConfig.Inputs { props[paramName] = map[string]any{ "type": param.Type, @@ -364,57 +246,47 @@ try { required = append(required, paramName) } } - + sort.Strings(required) if len(required) > 0 { inputSchema["required"] = required } - + inputSchemaJSON, _ := json.Marshal(inputSchema) - + if toolConfig.Script != "" { - sb.WriteString(fmt.Sprintf(`registerTool(server, { - name: %q, - description: %q, - inputSchema: %s, - handler: async (args) => { - try { - const toolModule = require("./%s.cjs"); - const result = await toolModule.execute(args || {}); - return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; - } catch (error) { - return { content: [{ type: "text", text: "Error: " + (error instanceof Error ? error.message : String(error)) }], isError: true }; - } + sb.WriteString(fmt.Sprintf(`registerTool(%q, %q, %s, async (args) => { + try { + const toolModule = require("./%s.cjs"); + const result = await toolModule.execute(args || {}); + return { content: [{ type: "text", text: typeof result === "string" ? result : JSON.stringify(result, null, 2) }] }; + } catch (error) { + return { content: [{ type: "text", text: "Error: " + (error instanceof Error ? error.message : String(error)) }], isError: true }; } }); `, toolName, toolConfig.Description, string(inputSchemaJSON), toolName)) } else { - sb.WriteString(fmt.Sprintf(`registerTool(server, { - name: %q, - description: %q, - inputSchema: %s, - handler: async (args) => { - try { - // Set input parameters as environment variables - const env = { ...process.env }; + sb.WriteString(fmt.Sprintf(`registerTool(%q, %q, %s, async (args) => { + try { + // Set input parameters as environment variables + const env = { ...process.env }; `, toolName, toolConfig.Description, string(inputSchemaJSON))) for paramName := range toolConfig.Inputs { - sb.WriteString(fmt.Sprintf(` if (args && args.%s !== undefined) { - env["INPUT_%s"] = typeof args.%s === "object" ? JSON.stringify(args.%s) : String(args.%s); - } + sb.WriteString(fmt.Sprintf(` if (args && args.%s !== undefined) { + env["INPUT_%s"] = typeof args.%s === "object" ? JSON.stringify(args.%s) : String(args.%s); + } `, paramName, strings.ToUpper(paramName), paramName, paramName, paramName)) } sb.WriteString(fmt.Sprintf(` - const scriptPath = path.join(__dirname, "%s.sh"); - const { stdout, stderr } = await execFileAsync("bash", [scriptPath], { env }); - const output = stdout + (stderr ? "\nStderr: " + stderr : ""); - return { content: [{ type: "text", text: output }] }; - } catch (error) { - return { content: [{ type: "text", text: "Error: " + (error instanceof Error ? error.message : String(error)) }], isError: true }; - } + const scriptPath = path.join(__dirname, "%s.sh"); + const { stdout, stderr } = await execFileAsync("bash", [scriptPath], { env }); + const output = stdout + (stderr ? "\nStderr: " + stderr : ""); + return { content: [{ type: "text", text: output }] }; + } catch (error) { + return { content: [{ type: "text", text: "Error: " + (error instanceof Error ? error.message : String(error)) }], isError: true }; } }); @@ -422,13 +294,98 @@ try { } } - sb.WriteString(`// Start the MCP server -start(server); + // Add message handler and start + sb.WriteString(`// Handle incoming messages +async function handleMessage(message) { + if (message.method === "initialize") { + debug("Received initialize request"); + replyResult(message.id, { + protocolVersion: "2024-11-05", + capabilities: { tools: {} }, + serverInfo + }); + } else if (message.method === "notifications/initialized") { + debug("Client initialized"); + } else if (message.method === "tools/list") { + debug("Received tools/list request"); + const toolList = Object.values(tools).map(t => ({ + name: t.name, + description: t.description, + inputSchema: t.inputSchema + })); + replyResult(message.id, { tools: toolList }); + } else if (message.method === "tools/call") { + const toolName = message.params?.name; + const toolArgs = message.params?.arguments || {}; + debug("Received tools/call for: " + toolName); + const tool = tools[toolName]; + if (!tool) { + replyError(message.id, -32601, "Unknown tool: " + toolName); + return; + } + try { + const result = await tool.handler(toolArgs); + replyResult(message.id, result); + } catch (error) { + replyError(message.id, -32603, error instanceof Error ? error.message : String(error)); + } + } else { + debug("Unknown method: " + message.method); + if (message.id !== undefined) { + replyError(message.id, -32601, "Method not found"); + } + } +} + +// Start server +debug("Starting safe-inputs MCP server"); +process.stdin.on("data", async (chunk) => { + readBuffer.append(chunk); + let message; + while ((message = readBuffer.readMessage()) !== null) { + await handleMessage(message); + } +}); `) return sb.String() } +// generateSafeInputJavaScriptToolScript generates the JavaScript tool file for a safe-input tool +func generateSafeInputJavaScriptToolScript(toolConfig *SafeInputToolConfig) string { + var sb strings.Builder + + sb.WriteString("// @ts-check\n") + sb.WriteString("// Auto-generated safe-input tool: " + toolConfig.Name + "\n\n") + sb.WriteString("/**\n") + sb.WriteString(" * " + toolConfig.Description + "\n") + sb.WriteString(" * @param {Object} inputs - Input parameters\n") + for paramName, param := range toolConfig.Inputs { + sb.WriteString(fmt.Sprintf(" * @param {%s} inputs.%s - %s\n", param.Type, paramName, param.Description)) + } + sb.WriteString(" * @returns {Promise} Tool result\n") + sb.WriteString(" */\n") + sb.WriteString("async function execute(inputs) {\n") + sb.WriteString(" " + strings.ReplaceAll(toolConfig.Script, "\n", "\n ") + "\n") + sb.WriteString("}\n\n") + sb.WriteString("module.exports = { execute };\n") + + return sb.String() +} + +// generateSafeInputShellToolScript generates the shell script for a safe-input tool +func generateSafeInputShellToolScript(toolConfig *SafeInputToolConfig) string { + var sb strings.Builder + + sb.WriteString("#!/bin/bash\n") + sb.WriteString("# Auto-generated safe-input tool: " + toolConfig.Name + "\n") + sb.WriteString("# " + toolConfig.Description + "\n\n") + sb.WriteString("set -euo pipefail\n\n") + sb.WriteString(toolConfig.Run + "\n") + + return sb.String() +} + // getSafeInputsEnvVars returns the list of environment variables needed for safe-inputs func getSafeInputsEnvVars(safeInputs *SafeInputsConfig) []string { envVars := []string{} @@ -468,12 +425,6 @@ func collectSafeInputsSecrets(safeInputs *SafeInputsConfig) map[string]string { return secrets } -// renderSafeInputsMCPConfig generates the Safe Inputs MCP server configuration -func renderSafeInputsMCPConfig(yaml *strings.Builder, safeInputs *SafeInputsConfig, isLast bool) { - safeInputsLog.Print("Rendering Safe Inputs MCP configuration") - renderSafeInputsMCPConfigWithOptions(yaml, safeInputs, isLast, false) -} - // renderSafeInputsMCPConfigWithOptions generates the Safe Inputs MCP server configuration with engine-specific options func renderSafeInputsMCPConfigWithOptions(yaml *strings.Builder, safeInputs *SafeInputsConfig, isLast bool, includeCopilotFields bool) { envVars := getSafeInputsEnvVars(safeInputs) From ebd4b78d9df2b36f13f4255241507473aa4932c9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 29 Nov 2025 19:29:22 +0000 Subject: [PATCH 04/11] Add unit tests for safe-inputs parsing and generation Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/workflow/safe_inputs.go | 107 +++++++++ pkg/workflow/safe_inputs_test.go | 387 +++++++++++++++++++++++++++++++ 2 files changed, 494 insertions(+) create mode 100644 pkg/workflow/safe_inputs_test.go diff --git a/pkg/workflow/safe_inputs.go b/pkg/workflow/safe_inputs.go index b8cad8e364..3c2073731c 100644 --- a/pkg/workflow/safe_inputs.go +++ b/pkg/workflow/safe_inputs.go @@ -40,6 +40,113 @@ func HasSafeInputs(safeInputs *SafeInputsConfig) bool { return safeInputs != nil && len(safeInputs.Tools) > 0 } +// ParseSafeInputs parses safe-inputs configuration from frontmatter (standalone function for testing) +func ParseSafeInputs(frontmatter map[string]any) *SafeInputsConfig { + if frontmatter == nil { + return nil + } + + safeInputs, exists := frontmatter["safe-inputs"] + if !exists { + return nil + } + + safeInputsMap, ok := safeInputs.(map[string]any) + if !ok { + return nil + } + + config := &SafeInputsConfig{ + Tools: make(map[string]*SafeInputToolConfig), + } + + for toolName, toolValue := range safeInputsMap { + toolMap, ok := toolValue.(map[string]any) + if !ok { + continue + } + + toolConfig := &SafeInputToolConfig{ + Name: toolName, + Inputs: make(map[string]*SafeInputParam), + Env: make(map[string]string), + } + + // Parse description (required) + if desc, exists := toolMap["description"]; exists { + if descStr, ok := desc.(string); ok { + toolConfig.Description = descStr + } + } + + // Parse inputs (optional) + if inputs, exists := toolMap["inputs"]; exists { + if inputsMap, ok := inputs.(map[string]any); ok { + for paramName, paramValue := range inputsMap { + if paramMap, ok := paramValue.(map[string]any); ok { + param := &SafeInputParam{ + Type: "string", // default type + } + + if t, exists := paramMap["type"]; exists { + if tStr, ok := t.(string); ok { + param.Type = tStr + } + } + + if desc, exists := paramMap["description"]; exists { + if descStr, ok := desc.(string); ok { + param.Description = descStr + } + } + + if def, exists := paramMap["default"]; exists { + param.Default = def + } + + if req, exists := paramMap["required"]; exists { + if reqBool, ok := req.(bool); ok { + param.Required = reqBool + } + } + + toolConfig.Inputs[paramName] = param + } + } + } + } + + // Parse script (for JavaScript tools) + if script, exists := toolMap["script"]; exists { + if scriptStr, ok := script.(string); ok { + toolConfig.Script = scriptStr + } + } + + // Parse run (for shell tools) + if run, exists := toolMap["run"]; exists { + if runStr, ok := run.(string); ok { + toolConfig.Run = runStr + } + } + + // Parse env (for secrets) + if env, exists := toolMap["env"]; exists { + if envMap, ok := env.(map[string]any); ok { + for envName, envValue := range envMap { + if envStr, ok := envValue.(string); ok { + toolConfig.Env[envName] = envStr + } + } + } + } + + config.Tools[toolName] = toolConfig + } + + return config +} + // extractSafeInputsConfig extracts safe-inputs configuration from frontmatter func (c *Compiler) extractSafeInputsConfig(frontmatter map[string]any) *SafeInputsConfig { safeInputsLog.Print("Extracting safe-inputs configuration from frontmatter") diff --git a/pkg/workflow/safe_inputs_test.go b/pkg/workflow/safe_inputs_test.go new file mode 100644 index 0000000000..9f5d08b21c --- /dev/null +++ b/pkg/workflow/safe_inputs_test.go @@ -0,0 +1,387 @@ +package workflow + +import ( + "strings" + "testing" +) + +func TestParseSafeInputs(t *testing.T) { + tests := []struct { + name string + frontmatter map[string]any + expectedTools int + expectedNil bool + }{ + { + name: "nil frontmatter", + frontmatter: nil, + expectedNil: true, + }, + { + name: "empty frontmatter", + frontmatter: map[string]any{}, + expectedNil: true, + }, + { + name: "single javascript tool", + frontmatter: map[string]any{ + "safe-inputs": map[string]any{ + "search-issues": map[string]any{ + "description": "Search for issues", + "script": "return 'hello';", + "inputs": map[string]any{ + "query": map[string]any{ + "type": "string", + "description": "Search query", + "required": true, + }, + }, + }, + }, + }, + expectedTools: 1, + }, + { + name: "single shell tool", + frontmatter: map[string]any{ + "safe-inputs": map[string]any{ + "echo-message": map[string]any{ + "description": "Echo a message", + "run": "echo $INPUT_MESSAGE", + "inputs": map[string]any{ + "message": map[string]any{ + "type": "string", + "description": "Message to echo", + "default": "Hello", + }, + }, + }, + }, + }, + expectedTools: 1, + }, + { + name: "multiple tools", + frontmatter: map[string]any{ + "safe-inputs": map[string]any{ + "tool1": map[string]any{ + "description": "Tool 1", + "script": "return 1;", + }, + "tool2": map[string]any{ + "description": "Tool 2", + "run": "echo 2", + }, + }, + }, + expectedTools: 2, + }, + { + name: "tool with env secrets", + frontmatter: map[string]any{ + "safe-inputs": map[string]any{ + "api-call": map[string]any{ + "description": "Call API", + "script": "return fetch(url);", + "env": map[string]any{ + "API_KEY": "${{ secrets.API_KEY }}", + }, + }, + }, + }, + expectedTools: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ParseSafeInputs(tt.frontmatter) + + if tt.expectedNil { + if result != nil { + t.Errorf("Expected nil, got %+v", result) + } + return + } + + if result == nil { + t.Error("Expected non-nil result") + return + } + + if len(result.Tools) != tt.expectedTools { + t.Errorf("Expected %d tools, got %d", tt.expectedTools, len(result.Tools)) + } + }) + } +} + +func TestHasSafeInputs(t *testing.T) { + tests := []struct { + name string + config *SafeInputsConfig + expected bool + }{ + { + name: "nil config", + config: nil, + expected: false, + }, + { + name: "empty tools", + config: &SafeInputsConfig{Tools: map[string]*SafeInputToolConfig{}}, + expected: false, + }, + { + name: "with tools", + config: &SafeInputsConfig{ + Tools: map[string]*SafeInputToolConfig{ + "test": {Name: "test", Description: "Test tool"}, + }, + }, + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := HasSafeInputs(tt.config) + if result != tt.expected { + t.Errorf("Expected %v, got %v", tt.expected, result) + } + }) + } +} + +func TestGetSafeInputsEnvVars(t *testing.T) { + tests := []struct { + name string + config *SafeInputsConfig + expectedLen int + contains []string + }{ + { + name: "nil config", + config: nil, + expectedLen: 0, + }, + { + name: "tool with env", + config: &SafeInputsConfig{ + Tools: map[string]*SafeInputToolConfig{ + "test": { + Name: "test", + Env: map[string]string{ + "API_KEY": "${{ secrets.API_KEY }}", + "TOKEN": "${{ secrets.TOKEN }}", + }, + }, + }, + }, + expectedLen: 2, + contains: []string{"API_KEY", "TOKEN"}, + }, + { + name: "multiple tools with shared env", + config: &SafeInputsConfig{ + Tools: map[string]*SafeInputToolConfig{ + "tool1": { + Name: "tool1", + Env: map[string]string{"API_KEY": "key1"}, + }, + "tool2": { + Name: "tool2", + Env: map[string]string{"API_KEY": "key2"}, + }, + }, + }, + expectedLen: 1, // Should deduplicate + contains: []string{"API_KEY"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := getSafeInputsEnvVars(tt.config) + + if len(result) != tt.expectedLen { + t.Errorf("Expected %d env vars, got %d: %v", tt.expectedLen, len(result), result) + } + + for _, expected := range tt.contains { + found := false + for _, v := range result { + if v == expected { + found = true + break + } + } + if !found { + t.Errorf("Expected to contain %s, got %v", expected, result) + } + } + }) + } +} + +func TestCollectSafeInputsSecrets(t *testing.T) { + tests := []struct { + name string + config *SafeInputsConfig + expectedLen int + }{ + { + name: "nil config", + config: nil, + expectedLen: 0, + }, + { + name: "tool with secrets", + config: &SafeInputsConfig{ + Tools: map[string]*SafeInputToolConfig{ + "test": { + Name: "test", + Env: map[string]string{ + "API_KEY": "${{ secrets.API_KEY }}", + }, + }, + }, + }, + expectedLen: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := collectSafeInputsSecrets(tt.config) + + if len(result) != tt.expectedLen { + t.Errorf("Expected %d secrets, got %d", tt.expectedLen, len(result)) + } + }) + } +} + +func TestGenerateSafeInputsMCPServerScript(t *testing.T) { + config := &SafeInputsConfig{ + Tools: map[string]*SafeInputToolConfig{ + "search-issues": { + Name: "search-issues", + Description: "Search for issues in the repository", + Script: "return 'hello';", + Inputs: map[string]*SafeInputParam{ + "query": { + Type: "string", + Description: "Search query", + Required: true, + }, + }, + }, + "echo-message": { + Name: "echo-message", + Description: "Echo a message", + Run: "echo $INPUT_MESSAGE", + Inputs: map[string]*SafeInputParam{ + "message": { + Type: "string", + Description: "Message to echo", + Default: "Hello", + }, + }, + }, + }, + } + + script := generateSafeInputsMCPServerScript(config) + + // Check for basic MCP server structure + if !strings.Contains(script, "safeinputs") { + t.Error("Script should contain server name 'safeinputs'") + } + + // Check for tool registration + if !strings.Contains(script, `registerTool("search-issues"`) { + t.Error("Script should register search-issues tool") + } + + if !strings.Contains(script, `registerTool("echo-message"`) { + t.Error("Script should register echo-message tool") + } + + // Check for JavaScript tool handler + if !strings.Contains(script, "search-issues.cjs") { + t.Error("Script should reference JavaScript tool file") + } + + // Check for shell tool handler + if !strings.Contains(script, "echo-message.sh") { + t.Error("Script should reference shell script file") + } + + // Check for MCP methods + if !strings.Contains(script, "tools/list") { + t.Error("Script should handle tools/list method") + } + + if !strings.Contains(script, "tools/call") { + t.Error("Script should handle tools/call method") + } +} + +func TestGenerateSafeInputJavaScriptToolScript(t *testing.T) { + config := &SafeInputToolConfig{ + Name: "test-tool", + Description: "A test tool", + Script: "return inputs.value * 2;", + Inputs: map[string]*SafeInputParam{ + "value": { + Type: "number", + Description: "Value to double", + }, + }, + } + + script := generateSafeInputJavaScriptToolScript(config) + + if !strings.Contains(script, "test-tool") { + t.Error("Script should contain tool name") + } + + if !strings.Contains(script, "A test tool") { + t.Error("Script should contain description") + } + + if !strings.Contains(script, "return inputs.value * 2;") { + t.Error("Script should contain the tool script") + } + + if !strings.Contains(script, "module.exports") { + t.Error("Script should export execute function") + } +} + +func TestGenerateSafeInputShellToolScript(t *testing.T) { + config := &SafeInputToolConfig{ + Name: "test-shell", + Description: "A shell test tool", + Run: "echo $INPUT_MESSAGE", + } + + script := generateSafeInputShellToolScript(config) + + if !strings.Contains(script, "#!/bin/bash") { + t.Error("Script should have bash shebang") + } + + if !strings.Contains(script, "test-shell") { + t.Error("Script should contain tool name") + } + + if !strings.Contains(script, "set -euo pipefail") { + t.Error("Script should have strict mode") + } + + if !strings.Contains(script, "echo $INPUT_MESSAGE") { + t.Error("Script should contain the run command") + } +} From 8e365642105562085d0f1a1cf712479478faff25 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 29 Nov 2025 19:36:22 +0000 Subject: [PATCH 05/11] Address code review: sanitize parameter names in JavaScript code generation Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/workflow/safe_inputs.go | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/pkg/workflow/safe_inputs.go b/pkg/workflow/safe_inputs.go index 3c2073731c..d0909178fa 100644 --- a/pkg/workflow/safe_inputs.go +++ b/pkg/workflow/safe_inputs.go @@ -12,6 +12,25 @@ import ( var safeInputsLog = logger.New("workflow:safe_inputs") +// sanitizeParameterName converts a parameter name to a safe JavaScript identifier +// by replacing non-alphanumeric characters with underscores +func sanitizeParameterName(name string) string { + // Replace dashes and other non-alphanumeric chars with underscores + result := strings.Map(func(r rune) rune { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '$' { + return r + } + return '_' + }, name) + + // Ensure it doesn't start with a number + if len(result) > 0 && result[0] >= '0' && result[0] <= '9' { + result = "_" + result + } + + return result +} + // SafeInputsConfig holds the configuration for safe-inputs custom tools type SafeInputsConfig struct { Tools map[string]*SafeInputToolConfig @@ -381,10 +400,12 @@ function registerTool(name, description, inputSchema, handler) { `, toolName, toolConfig.Description, string(inputSchemaJSON))) for paramName := range toolConfig.Inputs { - sb.WriteString(fmt.Sprintf(` if (args && args.%s !== undefined) { - env["INPUT_%s"] = typeof args.%s === "object" ? JSON.stringify(args.%s) : String(args.%s); + // Use bracket notation for safer property access + safeEnvName := strings.ToUpper(sanitizeParameterName(paramName)) + sb.WriteString(fmt.Sprintf(` if (args && args[%q] !== undefined) { + env["INPUT_%s"] = typeof args[%q] === "object" ? JSON.stringify(args[%q]) : String(args[%q]); } -`, paramName, strings.ToUpper(paramName), paramName, paramName, paramName)) +`, paramName, safeEnvName, paramName, paramName, paramName)) } sb.WriteString(fmt.Sprintf(` From 63e918c7002a21c26611428b6249abf414a5eb1e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 29 Nov 2025 20:25:22 +0000 Subject: [PATCH 06/11] Add safe-inputs import support and create PR data fetch shared workflow Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/dev.lock.yml | 193 +++++++++++++++++- .github/workflows/dev.md | 10 +- .../workflows/shared/pr-data-safe-input.md | 87 ++++++++ pkg/parser/frontmatter.go | 14 ++ pkg/parser/schemas/included_file_schema.json | 57 ++++++ pkg/workflow/compiler.go | 5 + pkg/workflow/safe_inputs.go | 111 ++++++++++ 7 files changed, 474 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/shared/pr-data-safe-input.md diff --git a/.github/workflows/dev.lock.yml b/.github/workflows/dev.lock.yml index 8c893a0615..cca9450d42 100644 --- a/.github/workflows/dev.lock.yml +++ b/.github/workflows/dev.lock.yml @@ -35,6 +35,8 @@ # issues: read # pull-requests: read # discussions: read +# imports: +# - shared/pr-data-safe-input.md # tools: # bash: ["*"] # edit: @@ -44,6 +46,10 @@ # assign-to-agent: # ``` # +# Resolved workflow manifest: +# Imports: +# - shared/pr-data-safe-input.md +# # Job Dependency Graph: # ```mermaid # graph LR @@ -63,7 +69,13 @@ # # Original Prompt: # ```markdown -# Assign the most recent unassigned issue to the agent. +# Use the `fetch-pr-data` tool to fetch Copilot agent PRs from this repository using `search: "head:copilot/"`. Then compute basic PR statistics: +# - Total number of Copilot PRs in the last 30 days +# - Number of merged vs closed vs open PRs +# - Average time from PR creation to merge (for merged PRs) +# - Most active day of the week for PR creation +# +# Present the statistics in a clear summary. # ``` # # Pinned GitHub Actions: @@ -1491,10 +1503,172 @@ jobs: EOF chmod +x /tmp/gh-aw/safeoutputs/mcp-server.cjs + - name: Setup Safe Inputs MCP + run: | + mkdir -p /tmp/gh-aw/safe-inputs + cat > /tmp/gh-aw/safe-inputs/mcp-server.cjs << 'EOFSI' + const fs = require("fs"); + const path = require("path"); + const { execFile } = require("child_process"); + const { promisify } = require("util"); + const execFileAsync = promisify(execFile); + class ReadBuffer { + constructor() { + this.buffer = Buffer.alloc(0); + } + append(chunk) { + this.buffer = Buffer.concat([this.buffer, chunk]); + } + readMessage() { + const headerEndIndex = this.buffer.indexOf("\r\n\r\n"); + if (headerEndIndex === -1) return null; + const header = this.buffer.slice(0, headerEndIndex).toString(); + const match = header.match(/Content-Length:\s*(\d+)/i); + if (!match) return null; + const contentLength = parseInt(match[1], 10); + const messageStart = headerEndIndex + 4; + if (this.buffer.length < messageStart + contentLength) return null; + const content = this.buffer.slice(messageStart, messageStart + contentLength).toString(); + this.buffer = this.buffer.slice(messageStart + contentLength); + return JSON.parse(content); + } + } + const serverInfo = { name: "safeinputs", version: "1.0.0" }; + const tools = {}; + const readBuffer = new ReadBuffer(); + function debug(msg) { + const timestamp = new Date().toISOString(); + process.stderr.write("[" + timestamp + "] [safeinputs] " + msg + "\n"); + } + function writeMessage(message) { + const json = JSON.stringify(message); + const header = "Content-Length: " + Buffer.byteLength(json) + "\r\n\r\n"; + process.stdout.write(header + json); + } + function replyResult(id, result) { + writeMessage({ jsonrpc: "2.0", id, result }); + } + function replyError(id, code, message) { + writeMessage({ jsonrpc: "2.0", id, error: { code, message } }); + } + function registerTool(name, description, inputSchema, handler) { + tools[name] = { name, description, inputSchema, handler }; + } + registerTool("fetch-pr-data", "Fetches pull request data from GitHub using gh CLI. Returns JSON array of PRs with fields: number, title, author, headRefName, createdAt, state, url, body, labels, updatedAt, closedAt, mergedAt", {"properties":{"days":{"default":30,"description":"Number of days to look back (default: 30)","type":"number"},"limit":{"default":100,"description":"Maximum number of PRs to fetch (default: 100)","type":"number"},"repo":{"description":"Repository in owner/repo format (defaults to current repository)","type":"string"},"search":{"description":"Search query for filtering PRs (e.g., 'head:copilot/' for Copilot PRs)","type":"string"},"state":{"default":"all","description":"PR state filter: open, closed, merged, or all (default: all)","type":"string"}},"type":"object"}, async (args) => { + try { + const env = { ...process.env }; + if (args && args["days"] !== undefined) { + env["INPUT_DAYS"] = typeof args["days"] === "object" ? JSON.stringify(args["days"]) : String(args["days"]); + } + if (args && args["limit"] !== undefined) { + env["INPUT_LIMIT"] = typeof args["limit"] === "object" ? JSON.stringify(args["limit"]) : String(args["limit"]); + } + if (args && args["repo"] !== undefined) { + env["INPUT_REPO"] = typeof args["repo"] === "object" ? JSON.stringify(args["repo"]) : String(args["repo"]); + } + if (args && args["search"] !== undefined) { + env["INPUT_SEARCH"] = typeof args["search"] === "object" ? JSON.stringify(args["search"]) : String(args["search"]); + } + if (args && args["state"] !== undefined) { + env["INPUT_STATE"] = typeof args["state"] === "object" ? JSON.stringify(args["state"]) : String(args["state"]); + } + const scriptPath = path.join(__dirname, "fetch-pr-data.sh"); + const { stdout, stderr } = await execFileAsync("bash", [scriptPath], { env }); + const output = stdout + (stderr ? "\nStderr: " + stderr : ""); + return { content: [{ type: "text", text: output }] }; + } catch (error) { + return { content: [{ type: "text", text: "Error: " + (error instanceof Error ? error.message : String(error)) }], isError: true }; + } + }); + async function handleMessage(message) { + if (message.method === "initialize") { + debug("Received initialize request"); + replyResult(message.id, { + protocolVersion: "2024-11-05", + capabilities: { tools: {} }, + serverInfo + }); + } else if (message.method === "notifications/initialized") { + debug("Client initialized"); + } else if (message.method === "tools/list") { + debug("Received tools/list request"); + const toolList = Object.values(tools).map(t => ({ + name: t.name, + description: t.description, + inputSchema: t.inputSchema + })); + replyResult(message.id, { tools: toolList }); + } else if (message.method === "tools/call") { + const toolName = message.params?.name; + const toolArgs = message.params?.arguments || {}; + debug("Received tools/call for: " + toolName); + const tool = tools[toolName]; + if (!tool) { + replyError(message.id, -32601, "Unknown tool: " + toolName); + return; + } + try { + const result = await tool.handler(toolArgs); + replyResult(message.id, result); + } catch (error) { + replyError(message.id, -32603, error instanceof Error ? error.message : String(error)); + } + } else { + debug("Unknown method: " + message.method); + if (message.id !== undefined) { + replyError(message.id, -32601, "Method not found"); + } + } + } + debug("Starting safe-inputs MCP server"); + process.stdin.on("data", async (chunk) => { + readBuffer.append(chunk); + let message; + while ((message = readBuffer.readMessage()) !== null) { + await handleMessage(message); + } + }); + EOFSI + chmod +x /tmp/gh-aw/safe-inputs/mcp-server.cjs + cat > /tmp/gh-aw/safe-inputs/fetch-pr-data.sh << 'EOFSH_fetch-pr-data' + #!/bin/bash + # Auto-generated safe-input tool: fetch-pr-data + # Fetches pull request data from GitHub using gh CLI. Returns JSON array of PRs with fields: number, title, author, headRefName, createdAt, state, url, body, labels, updatedAt, closedAt, mergedAt + + set -euo pipefail + + # Fetch PR data using gh CLI + REPO="${INPUT_REPO:-$GITHUB_REPOSITORY}" + STATE="${INPUT_STATE:-all}" + LIMIT="${INPUT_LIMIT:-100}" + DAYS="${INPUT_DAYS:-30}" + SEARCH="${INPUT_SEARCH:-}" + + # Calculate date N days ago (cross-platform) + DATE_AGO=$(date -d "${DAYS} days ago" '+%Y-%m-%d' 2>/dev/null || date -v-${DAYS}d '+%Y-%m-%d') + + # Build search query + QUERY="created:>=${DATE_AGO}" + if [ -n "$SEARCH" ]; then + QUERY="${SEARCH} ${QUERY}" + fi + + # Fetch PRs + gh pr list --repo "$REPO" \ + --search "$QUERY" \ + --state "$STATE" \ + --json number,title,author,headRefName,createdAt,state,url,body,labels,updatedAt,closedAt,mergedAt \ + --limit "$LIMIT" + + + EOFSH_fetch-pr-data + chmod +x /tmp/gh-aw/safe-inputs/fetch-pr-data.sh + - name: Setup MCPs env: GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | mkdir -p /tmp/gh-aw/mcp-config cat > /tmp/gh-aw/mcp-config/mcp-servers.json << EOF @@ -1518,6 +1692,13 @@ jobs: "GITHUB_PERSONAL_ACCESS_TOKEN": "$GITHUB_MCP_SERVER_TOKEN" } }, + "safeinputs": { + "command": "node", + "args": ["/tmp/gh-aw/safe-inputs/mcp-server.cjs"], + "env": { + "GH_TOKEN": "$GH_TOKEN" + } + }, "safeoutputs": { "command": "node", "args": ["/tmp/gh-aw/safeoutputs/mcp-server.cjs"], @@ -1620,7 +1801,15 @@ jobs: PROMPT_DIR="$(dirname "$GH_AW_PROMPT")" mkdir -p "$PROMPT_DIR" cat << 'PROMPT_EOF' | envsubst > "$GH_AW_PROMPT" - Assign the most recent unassigned issue to the agent. + + + Use the `fetch-pr-data` tool to fetch Copilot agent PRs from this repository using `search: "head:copilot/"`. Then compute basic PR statistics: + - Total number of Copilot PRs in the last 30 days + - Number of merged vs closed vs open PRs + - Average time from PR creation to merge (for merged PRs) + - Most active day of the week for PR creation + + Present the statistics in a clear summary. PROMPT_EOF - name: Append XPIA security instructions to prompt diff --git a/.github/workflows/dev.md b/.github/workflows/dev.md index 93ad0f7831..3c995e4044 100644 --- a/.github/workflows/dev.md +++ b/.github/workflows/dev.md @@ -12,6 +12,8 @@ permissions: issues: read pull-requests: read discussions: read +imports: + - shared/pr-data-safe-input.md tools: bash: ["*"] edit: @@ -20,4 +22,10 @@ tools: safe-outputs: assign-to-agent: --- -Assign the most recent unassigned issue to the agent. \ No newline at end of file +Use the `fetch-pr-data` tool to fetch Copilot agent PRs from this repository using `search: "head:copilot/"`. Then compute basic PR statistics: +- Total number of Copilot PRs in the last 30 days +- Number of merged vs closed vs open PRs +- Average time from PR creation to merge (for merged PRs) +- Most active day of the week for PR creation + +Present the statistics in a clear summary. \ No newline at end of file diff --git a/.github/workflows/shared/pr-data-safe-input.md b/.github/workflows/shared/pr-data-safe-input.md new file mode 100644 index 0000000000..2c6bf9050f --- /dev/null +++ b/.github/workflows/shared/pr-data-safe-input.md @@ -0,0 +1,87 @@ +--- +safe-inputs: + fetch-pr-data: + description: "Fetches pull request data from GitHub using gh CLI. Returns JSON array of PRs with fields: number, title, author, headRefName, createdAt, state, url, body, labels, updatedAt, closedAt, mergedAt" + inputs: + repo: + type: string + description: "Repository in owner/repo format (defaults to current repository)" + required: false + search: + type: string + description: "Search query for filtering PRs (e.g., 'head:copilot/' for Copilot PRs)" + required: false + state: + type: string + description: "PR state filter: open, closed, merged, or all (default: all)" + default: "all" + limit: + type: number + description: "Maximum number of PRs to fetch (default: 100)" + default: 100 + days: + type: number + description: "Number of days to look back (default: 30)" + default: 30 + run: | + # Fetch PR data using gh CLI + REPO="${INPUT_REPO:-$GITHUB_REPOSITORY}" + STATE="${INPUT_STATE:-all}" + LIMIT="${INPUT_LIMIT:-100}" + DAYS="${INPUT_DAYS:-30}" + SEARCH="${INPUT_SEARCH:-}" + + # Calculate date N days ago (cross-platform) + DATE_AGO=$(date -d "${DAYS} days ago" '+%Y-%m-%d' 2>/dev/null || date -v-${DAYS}d '+%Y-%m-%d') + + # Build search query + QUERY="created:>=${DATE_AGO}" + if [ -n "$SEARCH" ]; then + QUERY="${SEARCH} ${QUERY}" + fi + + # Fetch PRs + gh pr list --repo "$REPO" \ + --search "$QUERY" \ + --state "$STATE" \ + --json number,title,author,headRefName,createdAt,state,url,body,labels,updatedAt,closedAt,mergedAt \ + --limit "$LIMIT" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} +--- + diff --git a/pkg/parser/frontmatter.go b/pkg/parser/frontmatter.go index 9430932bbc..b1b4835c9e 100644 --- a/pkg/parser/frontmatter.go +++ b/pkg/parser/frontmatter.go @@ -84,6 +84,7 @@ type ImportsResult struct { MergedMCPServers string // Merged mcp-servers configuration from all imports MergedEngines []string // Merged engine configurations from all imports MergedSafeOutputs []string // Merged safe-outputs configurations from all imports + MergedSafeInputs []string // Merged safe-inputs configurations from all imports MergedMarkdown string // Merged markdown content from all imports MergedSteps string // Merged steps configuration from all imports MergedRuntimes string // Merged runtimes configuration from all imports @@ -208,6 +209,7 @@ func ProcessImportsFromFrontmatterWithManifest(frontmatter map[string]any, baseD var secretMaskingBuilder strings.Builder var engines []string var safeOutputs []string + var safeInputs []string var agentFile string // Track custom agent file importInputs := make(map[string]any) // Aggregated input values from all imports @@ -402,6 +404,12 @@ func ProcessImportsFromFrontmatterWithManifest(frontmatter map[string]any, baseD safeOutputs = append(safeOutputs, safeOutputsContent) } + // Extract safe-inputs from imported file + safeInputsContent, err := extractSafeInputsFromContent(string(content)) + if err == nil && safeInputsContent != "" && safeInputsContent != "{}" { + safeInputs = append(safeInputs, safeInputsContent) + } + // Extract steps from imported file stepsContent, err := extractStepsFromContent(string(content)) if err == nil && stepsContent != "" { @@ -446,6 +454,7 @@ func ProcessImportsFromFrontmatterWithManifest(frontmatter map[string]any, baseD MergedMCPServers: mcpServersBuilder.String(), MergedEngines: engines, MergedSafeOutputs: safeOutputs, + MergedSafeInputs: safeInputs, MergedMarkdown: markdownBuilder.String(), MergedSteps: stepsBuilder.String(), MergedRuntimes: runtimesBuilder.String(), @@ -709,6 +718,11 @@ func extractSafeOutputsFromContent(content string) (string, error) { return extractFrontmatterField(content, "safe-outputs", "{}") } +// extractSafeInputsFromContent extracts safe-inputs section from frontmatter as JSON string +func extractSafeInputsFromContent(content string) (string, error) { + return extractFrontmatterField(content, "safe-inputs", "{}") +} + // extractMCPServersFromContent extracts mcp-servers section from frontmatter as JSON string func extractMCPServersFromContent(content string) (string, error) { return extractFrontmatterField(content, "mcp-servers", "{}") diff --git a/pkg/parser/schemas/included_file_schema.json b/pkg/parser/schemas/included_file_schema.json index 8f7629ab0d..bbc6c87bf3 100644 --- a/pkg/parser/schemas/included_file_schema.json +++ b/pkg/parser/schemas/included_file_schema.json @@ -322,6 +322,63 @@ }, "additionalProperties": false }, + "safe-inputs": { + "type": "object", + "description": "Safe inputs configuration for custom MCP tools defined as JavaScript or shell scripts. Tools are mounted in an MCP server and have access to secrets specified in the env field.", + "additionalProperties": { + "type": "object", + "description": "Tool definition for a safe-input custom tool", + "properties": { + "description": { + "type": "string", + "description": "Required description of what the tool does. This is shown to the AI agent." + }, + "inputs": { + "type": "object", + "description": "Input parameters for the tool, using workflow_dispatch input syntax", + "additionalProperties": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["string", "number", "boolean", "array", "object"], + "description": "JSON schema type for the input parameter" + }, + "description": { + "type": "string", + "description": "Description of the input parameter" + }, + "required": { + "type": "boolean", + "description": "Whether the input is required" + }, + "default": { + "description": "Default value for the input" + } + }, + "additionalProperties": false + } + }, + "script": { + "type": "string", + "description": "JavaScript implementation (CommonJS). The script should export an execute function. Mutually exclusive with 'run'." + }, + "run": { + "type": "string", + "description": "Shell script implementation. Input parameters are available as INPUT_ environment variables. Mutually exclusive with 'script'." + }, + "env": { + "type": "object", + "description": "Environment variables for the tool, typically used for passing secrets", + "additionalProperties": { + "type": "string" + } + } + }, + "required": ["description"], + "additionalProperties": false + } + }, "secret-masking": { "type": "object", "description": "Secret masking configuration to be merged with main workflow", diff --git a/pkg/workflow/compiler.go b/pkg/workflow/compiler.go index 50129503a7..0dc1e9daa4 100644 --- a/pkg/workflow/compiler.go +++ b/pkg/workflow/compiler.go @@ -1261,6 +1261,11 @@ func (c *Compiler) ParseWorkflowFile(markdownPath string) (*WorkflowData, error) // Extract safe-inputs configuration workflowData.SafeInputs = c.extractSafeInputsConfig(result.Frontmatter) + // Merge safe-inputs from imports + if len(importsResult.MergedSafeInputs) > 0 { + workflowData.SafeInputs = c.mergeSafeInputs(workflowData.SafeInputs, importsResult.MergedSafeInputs) + } + // Extract safe-jobs from safe-outputs.jobs location topSafeJobs := extractSafeJobsFromFrontmatter(result.Frontmatter) diff --git a/pkg/workflow/safe_inputs.go b/pkg/workflow/safe_inputs.go index d0909178fa..b746f27354 100644 --- a/pkg/workflow/safe_inputs.go +++ b/pkg/workflow/safe_inputs.go @@ -567,3 +567,114 @@ func renderSafeInputsMCPConfigWithOptions(yaml *strings.Builder, safeInputs *Saf includeCopilotFields, ) } + +// mergeSafeInputs merges safe-inputs configuration from imports into the main configuration +func (c *Compiler) mergeSafeInputs(main *SafeInputsConfig, importedConfigs []string) *SafeInputsConfig { + if main == nil { + main = &SafeInputsConfig{ + Tools: make(map[string]*SafeInputToolConfig), + } + } + + for _, configJSON := range importedConfigs { + if configJSON == "" || configJSON == "{}" { + continue + } + + // Parse the imported JSON config + var importedMap map[string]any + if err := json.Unmarshal([]byte(configJSON), &importedMap); err != nil { + safeInputsLog.Printf("Warning: failed to parse imported safe-inputs config: %v", err) + continue + } + + // Merge each tool from the imported config + for toolName, toolValue := range importedMap { + // Skip if tool already exists in main config (main takes precedence) + if _, exists := main.Tools[toolName]; exists { + safeInputsLog.Printf("Skipping imported tool '%s' - already defined in main config", toolName) + continue + } + + toolMap, ok := toolValue.(map[string]any) + if !ok { + continue + } + + toolConfig := &SafeInputToolConfig{ + Name: toolName, + Inputs: make(map[string]*SafeInputParam), + Env: make(map[string]string), + } + + // Parse description + if desc, exists := toolMap["description"]; exists { + if descStr, ok := desc.(string); ok { + toolConfig.Description = descStr + } + } + + // Parse inputs + if inputs, exists := toolMap["inputs"]; exists { + if inputsMap, ok := inputs.(map[string]any); ok { + for paramName, paramValue := range inputsMap { + if paramMap, ok := paramValue.(map[string]any); ok { + param := &SafeInputParam{ + Type: "string", + } + if t, exists := paramMap["type"]; exists { + if tStr, ok := t.(string); ok { + param.Type = tStr + } + } + if desc, exists := paramMap["description"]; exists { + if descStr, ok := desc.(string); ok { + param.Description = descStr + } + } + if req, exists := paramMap["required"]; exists { + if reqBool, ok := req.(bool); ok { + param.Required = reqBool + } + } + if def, exists := paramMap["default"]; exists { + param.Default = def + } + toolConfig.Inputs[paramName] = param + } + } + } + } + + // Parse script + if script, exists := toolMap["script"]; exists { + if scriptStr, ok := script.(string); ok { + toolConfig.Script = scriptStr + } + } + + // Parse run + if run, exists := toolMap["run"]; exists { + if runStr, ok := run.(string); ok { + toolConfig.Run = runStr + } + } + + // Parse env + if env, exists := toolMap["env"]; exists { + if envMap, ok := env.(map[string]any); ok { + for envName, envValue := range envMap { + if envStr, ok := envValue.(string); ok { + toolConfig.Env[envName] = envStr + } + } + } + } + + main.Tools[toolName] = toolConfig + safeInputsLog.Printf("Merged imported safe-input tool: %s", toolName) + } + } + + return main +} From 91c65f4eb358b8e1e44dfdc9f424ae3f6ac08ced Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 29 Nov 2025 23:36:44 +0000 Subject: [PATCH 07/11] Add large output handling for safe-inputs (write to file if > 500 chars) Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/dev.lock.yml | 39 ++++++++++++++++++++++++++--- pkg/workflow/safe_inputs.go | 43 ++++++++++++++++++++++++++++++-- pkg/workflow/safe_inputs_test.go | 13 ++++++++++ 3 files changed, 89 insertions(+), 6 deletions(-) diff --git a/.github/workflows/dev.lock.yml b/.github/workflows/dev.lock.yml index cca9450d42..992663fb5c 100644 --- a/.github/workflows/dev.lock.yml +++ b/.github/workflows/dev.lock.yml @@ -1557,9 +1557,6 @@ jobs: registerTool("fetch-pr-data", "Fetches pull request data from GitHub using gh CLI. Returns JSON array of PRs with fields: number, title, author, headRefName, createdAt, state, url, body, labels, updatedAt, closedAt, mergedAt", {"properties":{"days":{"default":30,"description":"Number of days to look back (default: 30)","type":"number"},"limit":{"default":100,"description":"Maximum number of PRs to fetch (default: 100)","type":"number"},"repo":{"description":"Repository in owner/repo format (defaults to current repository)","type":"string"},"search":{"description":"Search query for filtering PRs (e.g., 'head:copilot/' for Copilot PRs)","type":"string"},"state":{"default":"all","description":"PR state filter: open, closed, merged, or all (default: all)","type":"string"}},"type":"object"}, async (args) => { try { const env = { ...process.env }; - if (args && args["days"] !== undefined) { - env["INPUT_DAYS"] = typeof args["days"] === "object" ? JSON.stringify(args["days"]) : String(args["days"]); - } if (args && args["limit"] !== undefined) { env["INPUT_LIMIT"] = typeof args["limit"] === "object" ? JSON.stringify(args["limit"]) : String(args["limit"]); } @@ -1572,6 +1569,9 @@ jobs: if (args && args["state"] !== undefined) { env["INPUT_STATE"] = typeof args["state"] === "object" ? JSON.stringify(args["state"]) : String(args["state"]); } + if (args && args["days"] !== undefined) { + env["INPUT_DAYS"] = typeof args["days"] === "object" ? JSON.stringify(args["days"]) : String(args["days"]); + } const scriptPath = path.join(__dirname, "fetch-pr-data.sh"); const { stdout, stderr } = await execFileAsync("bash", [scriptPath], { env }); const output = stdout + (stderr ? "\nStderr: " + stderr : ""); @@ -1580,6 +1580,36 @@ jobs: return { content: [{ type: "text", text: "Error: " + (error instanceof Error ? error.message : String(error)) }], isError: true }; } }); + const LARGE_OUTPUT_THRESHOLD = 500; + const CALLS_DIR = "/tmp/gh-aw/safe-inputs/calls"; + let callCounter = 0; + function ensureCallsDir() { + if (!fs.existsSync(CALLS_DIR)) { + fs.mkdirSync(CALLS_DIR, { recursive: true }); + } + } + function handleLargeOutput(result) { + if (!result || !result.content || !Array.isArray(result.content)) { + return result; + } + const processedContent = result.content.map((item) => { + if (item.type === "text" && typeof item.text === "string" && item.text.length > LARGE_OUTPUT_THRESHOLD) { + ensureCallsDir(); + callCounter++; + const timestamp = Date.now(); + const filename = "call_" + timestamp + "_" + callCounter + ".txt"; + const filepath = path.join(CALLS_DIR, filename); + fs.writeFileSync(filepath, item.text, "utf8"); + debug("Large output (" + item.text.length + " chars) written to: " + filepath); + return { + type: "text", + text: "[Output written to file: " + filepath + " (" + item.text.length + " chars)]" + }; + } + return item; + }); + return { ...result, content: processedContent }; + } async function handleMessage(message) { if (message.method === "initialize") { debug("Received initialize request"); @@ -1609,7 +1639,8 @@ jobs: } try { const result = await tool.handler(toolArgs); - replyResult(message.id, result); + const processedResult = handleLargeOutput(result); + replyResult(message.id, processedResult); } catch (error) { replyError(message.id, -32603, error instanceof Error ? error.message : String(error)); } diff --git a/pkg/workflow/safe_inputs.go b/pkg/workflow/safe_inputs.go index b746f27354..6ee7e69dbb 100644 --- a/pkg/workflow/safe_inputs.go +++ b/pkg/workflow/safe_inputs.go @@ -423,7 +423,45 @@ function registerTool(name, description, inputSchema, handler) { } // Add message handler and start - sb.WriteString(`// Handle incoming messages + sb.WriteString(`// Large output handling constants +const LARGE_OUTPUT_THRESHOLD = 500; +const CALLS_DIR = "/tmp/gh-aw/safe-inputs/calls"; +let callCounter = 0; + +// Ensure calls directory exists +function ensureCallsDir() { + if (!fs.existsSync(CALLS_DIR)) { + fs.mkdirSync(CALLS_DIR, { recursive: true }); + } +} + +// Handle large output by writing to file +function handleLargeOutput(result) { + if (!result || !result.content || !Array.isArray(result.content)) { + return result; + } + + const processedContent = result.content.map((item) => { + if (item.type === "text" && typeof item.text === "string" && item.text.length > LARGE_OUTPUT_THRESHOLD) { + ensureCallsDir(); + callCounter++; + const timestamp = Date.now(); + const filename = "call_" + timestamp + "_" + callCounter + ".txt"; + const filepath = path.join(CALLS_DIR, filename); + fs.writeFileSync(filepath, item.text, "utf8"); + debug("Large output (" + item.text.length + " chars) written to: " + filepath); + return { + type: "text", + text: "[Output written to file: " + filepath + " (" + item.text.length + " chars)]" + }; + } + return item; + }); + + return { ...result, content: processedContent }; +} + +// Handle incoming messages async function handleMessage(message) { if (message.method === "initialize") { debug("Received initialize request"); @@ -453,7 +491,8 @@ async function handleMessage(message) { } try { const result = await tool.handler(toolArgs); - replyResult(message.id, result); + const processedResult = handleLargeOutput(result); + replyResult(message.id, processedResult); } catch (error) { replyError(message.id, -32603, error instanceof Error ? error.message : String(error)); } diff --git a/pkg/workflow/safe_inputs_test.go b/pkg/workflow/safe_inputs_test.go index 9f5d08b21c..14abfdef18 100644 --- a/pkg/workflow/safe_inputs_test.go +++ b/pkg/workflow/safe_inputs_test.go @@ -326,6 +326,19 @@ func TestGenerateSafeInputsMCPServerScript(t *testing.T) { if !strings.Contains(script, "tools/call") { t.Error("Script should handle tools/call method") } + + // Check for large output handling + if !strings.Contains(script, "LARGE_OUTPUT_THRESHOLD") { + t.Error("Script should contain large output threshold constant") + } + + if !strings.Contains(script, "/tmp/gh-aw/safe-inputs/calls") { + t.Error("Script should contain calls directory path") + } + + if !strings.Contains(script, "handleLargeOutput") { + t.Error("Script should contain handleLargeOutput function") + } } func TestGenerateSafeInputJavaScriptToolScript(t *testing.T) { From 6adf6bd6178660aa64d556314e9f4d1d00e387d9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 30 Nov 2025 00:44:08 +0000 Subject: [PATCH 08/11] Add structured response for large tool outputs with file path, size, and JSON schema preview Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/dev.lock.yml | 43 +++++++++++++++++++++++------ pkg/workflow/safe_inputs.go | 46 ++++++++++++++++++++++++++++---- pkg/workflow/safe_inputs_test.go | 22 +++++++++++++++ 3 files changed, 98 insertions(+), 13 deletions(-) diff --git a/.github/workflows/dev.lock.yml b/.github/workflows/dev.lock.yml index 992663fb5c..bdae772ffd 100644 --- a/.github/workflows/dev.lock.yml +++ b/.github/workflows/dev.lock.yml @@ -1557,6 +1557,9 @@ jobs: registerTool("fetch-pr-data", "Fetches pull request data from GitHub using gh CLI. Returns JSON array of PRs with fields: number, title, author, headRefName, createdAt, state, url, body, labels, updatedAt, closedAt, mergedAt", {"properties":{"days":{"default":30,"description":"Number of days to look back (default: 30)","type":"number"},"limit":{"default":100,"description":"Maximum number of PRs to fetch (default: 100)","type":"number"},"repo":{"description":"Repository in owner/repo format (defaults to current repository)","type":"string"},"search":{"description":"Search query for filtering PRs (e.g., 'head:copilot/' for Copilot PRs)","type":"string"},"state":{"default":"all","description":"PR state filter: open, closed, merged, or all (default: all)","type":"string"}},"type":"object"}, async (args) => { try { const env = { ...process.env }; + if (args && args["days"] !== undefined) { + env["INPUT_DAYS"] = typeof args["days"] === "object" ? JSON.stringify(args["days"]) : String(args["days"]); + } if (args && args["limit"] !== undefined) { env["INPUT_LIMIT"] = typeof args["limit"] === "object" ? JSON.stringify(args["limit"]) : String(args["limit"]); } @@ -1569,9 +1572,6 @@ jobs: if (args && args["state"] !== undefined) { env["INPUT_STATE"] = typeof args["state"] === "object" ? JSON.stringify(args["state"]) : String(args["state"]); } - if (args && args["days"] !== undefined) { - env["INPUT_DAYS"] = typeof args["days"] === "object" ? JSON.stringify(args["days"]) : String(args["days"]); - } const scriptPath = path.join(__dirname, "fetch-pr-data.sh"); const { stdout, stderr } = await execFileAsync("bash", [scriptPath], { env }); const output = stdout + (stderr ? "\nStderr: " + stderr : ""); @@ -1588,11 +1588,23 @@ jobs: fs.mkdirSync(CALLS_DIR, { recursive: true }); } } - function handleLargeOutput(result) { + async function extractJsonSchema(filepath) { + try { + const { stdout } = await execFileAsync("jq", [ + "-r", + "if type == \"array\" then {type: \"array\", length: length, items_schema: (first | if type == \"object\" then (keys | map({(.): \"...\"}) | add) else type end)} elif type == \"object\" then (keys | map({(.): \"...\"}) | add) else {type: type} end", + filepath + ], { timeout: 5000 }); + return stdout.trim(); + } catch (error) { + return null; + } + } + async function handleLargeOutput(result) { if (!result || !result.content || !Array.isArray(result.content)) { return result; } - const processedContent = result.content.map((item) => { + const processedContent = await Promise.all(result.content.map(async (item) => { if (item.type === "text" && typeof item.text === "string" && item.text.length > LARGE_OUTPUT_THRESHOLD) { ensureCallsDir(); callCounter++; @@ -1600,14 +1612,29 @@ jobs: const filename = "call_" + timestamp + "_" + callCounter + ".txt"; const filepath = path.join(CALLS_DIR, filename); fs.writeFileSync(filepath, item.text, "utf8"); - debug("Large output (" + item.text.length + " chars) written to: " + filepath); + const fileSize = item.text.length; + debug("Large output (" + fileSize + " chars) written to: " + filepath); + let structuredResponse = { + status: "output_saved_to_file", + file_path: filepath, + file_size_bytes: fileSize, + file_size_chars: fileSize, + message: "Output was too large and has been saved to a file. Read the file to access the full content." + }; + if (item.text.trim().startsWith("{") || item.text.trim().startsWith("[")) { + const schema = await extractJsonSchema(filepath); + if (schema) { + structuredResponse.json_schema_preview = schema; + structuredResponse.message += " JSON structure preview is provided below."; + } + } return { type: "text", - text: "[Output written to file: " + filepath + " (" + item.text.length + " chars)]" + text: JSON.stringify(structuredResponse, null, 2) }; } return item; - }); + })); return { ...result, content: processedContent }; } async function handleMessage(message) { diff --git a/pkg/workflow/safe_inputs.go b/pkg/workflow/safe_inputs.go index 6ee7e69dbb..73084000e0 100644 --- a/pkg/workflow/safe_inputs.go +++ b/pkg/workflow/safe_inputs.go @@ -435,13 +435,29 @@ function ensureCallsDir() { } } +// Attempt to extract JSON schema using jq +async function extractJsonSchema(filepath) { + try { + // Try to extract a simplified JSON schema showing structure + const { stdout } = await execFileAsync("jq", [ + "-r", + "if type == \"array\" then {type: \"array\", length: length, items_schema: (first | if type == \"object\" then (keys | map({(.): \"...\"}) | add) else type end)} elif type == \"object\" then (keys | map({(.): \"...\"}) | add) else {type: type} end", + filepath + ], { timeout: 5000 }); + return stdout.trim(); + } catch (error) { + // jq not available or failed - that's okay + return null; + } +} + // Handle large output by writing to file -function handleLargeOutput(result) { +async function handleLargeOutput(result) { if (!result || !result.content || !Array.isArray(result.content)) { return result; } - const processedContent = result.content.map((item) => { + const processedContent = await Promise.all(result.content.map(async (item) => { if (item.type === "text" && typeof item.text === "string" && item.text.length > LARGE_OUTPUT_THRESHOLD) { ensureCallsDir(); callCounter++; @@ -449,14 +465,34 @@ function handleLargeOutput(result) { const filename = "call_" + timestamp + "_" + callCounter + ".txt"; const filepath = path.join(CALLS_DIR, filename); fs.writeFileSync(filepath, item.text, "utf8"); - debug("Large output (" + item.text.length + " chars) written to: " + filepath); + const fileSize = item.text.length; + debug("Large output (" + fileSize + " chars) written to: " + filepath); + + // Build structured response + let structuredResponse = { + status: "output_saved_to_file", + file_path: filepath, + file_size_bytes: fileSize, + file_size_chars: fileSize, + message: "Output was too large and has been saved to a file. Read the file to access the full content." + }; + + // Attempt to extract JSON schema if output looks like JSON + if (item.text.trim().startsWith("{") || item.text.trim().startsWith("[")) { + const schema = await extractJsonSchema(filepath); + if (schema) { + structuredResponse.json_schema_preview = schema; + structuredResponse.message += " JSON structure preview is provided below."; + } + } + return { type: "text", - text: "[Output written to file: " + filepath + " (" + item.text.length + " chars)]" + text: JSON.stringify(structuredResponse, null, 2) }; } return item; - }); + })); return { ...result, content: processedContent }; } diff --git a/pkg/workflow/safe_inputs_test.go b/pkg/workflow/safe_inputs_test.go index 14abfdef18..da423d0180 100644 --- a/pkg/workflow/safe_inputs_test.go +++ b/pkg/workflow/safe_inputs_test.go @@ -339,6 +339,28 @@ func TestGenerateSafeInputsMCPServerScript(t *testing.T) { if !strings.Contains(script, "handleLargeOutput") { t.Error("Script should contain handleLargeOutput function") } + + // Check for structured response fields + if !strings.Contains(script, "status") { + t.Error("Script should contain status field in structured response") + } + + if !strings.Contains(script, "file_path") { + t.Error("Script should contain file_path field in structured response") + } + + if !strings.Contains(script, "file_size_bytes") { + t.Error("Script should contain file_size_bytes field in structured response") + } + + // Check for JSON schema extraction with jq + if !strings.Contains(script, "extractJsonSchema") { + t.Error("Script should contain extractJsonSchema function") + } + + if !strings.Contains(script, "json_schema_preview") { + t.Error("Script should contain json_schema_preview field for JSON output") + } } func TestGenerateSafeInputJavaScriptToolScript(t *testing.T) { From b749805cbabe8e967c7e2dd4f2ed673e0f467d02 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 30 Nov 2025 01:33:38 +0000 Subject: [PATCH 09/11] Auto-wrap user JS code in function with destructured inputs; update dev.md to use codex with JS test cases Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/dev.lock.yml | 865 ++++++++++++++++++--------------- .github/workflows/dev.md | 41 +- pkg/workflow/safe_inputs.go | 21 + 3 files changed, 544 insertions(+), 383 deletions(-) diff --git a/.github/workflows/dev.lock.yml b/.github/workflows/dev.lock.yml index bdae772ffd..3f26109b26 100644 --- a/.github/workflows/dev.lock.yml +++ b/.github/workflows/dev.lock.yml @@ -28,8 +28,8 @@ # description: Test workflow for development and experimentation purposes # timeout-minutes: 5 # strict: false -# # Using experimental Claude engine for testing -# engine: claude +# # Using Codex engine for better error messages +# engine: codex # permissions: # contents: read # issues: read @@ -44,6 +44,37 @@ # toolsets: [default, repos, issues, discussions] # safe-outputs: # assign-to-agent: +# safe-inputs: +# test-js-math: +# description: "Test JavaScript math operations" +# inputs: +# a: +# type: number +# description: "First number" +# required: true +# b: +# type: number +# description: "Second number" +# required: true +# script: | +# // Users can write simple code without exports +# const sum = a + b; +# const product = a * b; +# return { sum, product, inputs: { a, b } }; +# test-js-string: +# description: "Test JavaScript string operations" +# inputs: +# text: +# type: string +# description: "Input text" +# required: true +# script: | +# // Simple string manipulation +# return { +# original: text, +# uppercase: text.toUpperCase(), +# length: text.length +# }; # ``` # # Resolved workflow manifest: @@ -75,7 +106,11 @@ # - Average time from PR creation to merge (for merged PRs) # - Most active day of the week for PR creation # -# Present the statistics in a clear summary. +# Also test the JavaScript safe-inputs tools: +# 1. Call `test-js-math` with a=5 and b=3 to verify math operations work +# 2. Call `test-js-string` with text="Hello World" to verify string operations work +# +# Present the statistics and test results in a clear summary. # ``` # # Pinned GitHub Actions: @@ -211,7 +246,7 @@ jobs: issues: read pull-requests: read concurrency: - group: "gh-aw-claude-${{ github.workflow }}" + group: "gh-aw-codex-${{ github.workflow }}" env: GH_AW_MCP_LOG_DIR: /tmp/gh-aw/mcp-logs/safeoutputs GH_AW_SAFE_OUTPUTS: /tmp/gh-aw/safeoutputs/outputs.jsonl @@ -277,138 +312,29 @@ jobs: main().catch(error => { core.setFailed(error instanceof Error ? error.message : String(error)); }); - - name: Validate CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_API_KEY secret + - name: Validate CODEX_API_KEY or OPENAI_API_KEY secret run: | - if [ -z "$CLAUDE_CODE_OAUTH_TOKEN" ] && [ -z "$ANTHROPIC_API_KEY" ]; then - echo "Error: Neither CLAUDE_CODE_OAUTH_TOKEN nor ANTHROPIC_API_KEY secret is set" - echo "The Claude Code engine requires either CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_API_KEY secret to be configured." + if [ -z "$CODEX_API_KEY" ] && [ -z "$OPENAI_API_KEY" ]; then + echo "Error: Neither CODEX_API_KEY nor OPENAI_API_KEY secret is set" + echo "The Codex engine requires either CODEX_API_KEY or OPENAI_API_KEY secret to be configured." echo "Please configure one of these secrets in your repository settings." - echo "Documentation: https://githubnext.github.io/gh-aw/reference/engines/#anthropic-claude-code" + echo "Documentation: https://githubnext.github.io/gh-aw/reference/engines/#openai-codex" exit 1 fi - if [ -n "$CLAUDE_CODE_OAUTH_TOKEN" ]; then - echo "CLAUDE_CODE_OAUTH_TOKEN secret is configured" + if [ -n "$CODEX_API_KEY" ]; then + echo "CODEX_API_KEY secret is configured" else - echo "ANTHROPIC_API_KEY secret is configured (using as fallback for CLAUDE_CODE_OAUTH_TOKEN)" + echo "OPENAI_API_KEY secret is configured (using as fallback for CODEX_API_KEY)" fi env: - CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + CODEX_API_KEY: ${{ secrets.CODEX_API_KEY }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} - name: Setup Node.js uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6 with: node-version: '24' - - name: Install Claude Code CLI - run: npm install -g @anthropic-ai/claude-code@2.0.55 - - name: Generate Claude Settings - run: | - mkdir -p /tmp/gh-aw/.claude - cat > /tmp/gh-aw/.claude/settings.json << 'EOF' - { - "hooks": { - "PreToolUse": [ - { - "matcher": "WebFetch|WebSearch", - "hooks": [ - { - "type": "command", - "command": ".claude/hooks/network_permissions.py" - } - ] - } - ] - } - } - EOF - - name: Generate Network Permissions Hook - run: | - mkdir -p .claude/hooks - cat > .claude/hooks/network_permissions.py << 'EOF' - #!/usr/bin/env python3 - """ - Network permissions validator for Claude Code engine. - Generated by gh-aw from workflow-level network configuration. - """ - - import json - import sys - import urllib.parse - import re - - # Domain allow-list (populated during generation) - # JSON string is safely parsed using json.loads() to eliminate quoting vulnerabilities - ALLOWED_DOMAINS = json.loads('''["crl3.digicert.com","crl4.digicert.com","ocsp.digicert.com","ts-crl.ws.symantec.com","ts-ocsp.ws.symantec.com","crl.geotrust.com","ocsp.geotrust.com","crl.thawte.com","ocsp.thawte.com","crl.verisign.com","ocsp.verisign.com","crl.globalsign.com","ocsp.globalsign.com","crls.ssl.com","ocsp.ssl.com","crl.identrust.com","ocsp.identrust.com","crl.sectigo.com","ocsp.sectigo.com","crl.usertrust.com","ocsp.usertrust.com","s.symcb.com","s.symcd.com","json-schema.org","json.schemastore.org","archive.ubuntu.com","security.ubuntu.com","ppa.launchpad.net","keyserver.ubuntu.com","azure.archive.ubuntu.com","api.snapcraft.io","packagecloud.io","packages.cloud.google.com","packages.microsoft.com"]''') - - def extract_domain(url_or_query): - """Extract domain from URL or search query.""" - if not url_or_query: - return None - - if url_or_query.startswith(('http://', 'https://')): - return urllib.parse.urlparse(url_or_query).netloc.lower() - - # Check for domain patterns in search queries - match = re.search(r'site:([a-zA-Z0-9.-]+\.[a-zA-Z]{2,})', url_or_query) - if match: - return match.group(1).lower() - - return None - - def is_domain_allowed(domain): - """Check if domain is allowed.""" - if not domain: - # If no domain detected, allow only if not under deny-all policy - return bool(ALLOWED_DOMAINS) # False if empty list (deny-all), True if has domains - - # Empty allowed domains means deny all - if not ALLOWED_DOMAINS: - return False - - for pattern in ALLOWED_DOMAINS: - regex = pattern.replace('.', r'\.').replace('*', '.*') - if re.match(f'^{regex}$', domain): - return True - return False - - # Main logic - try: - data = json.load(sys.stdin) - tool_name = data.get('tool_name', '') - tool_input = data.get('tool_input', {}) - - if tool_name not in ['WebFetch', 'WebSearch']: - sys.exit(0) # Allow other tools - - target = tool_input.get('url') or tool_input.get('query', '') - domain = extract_domain(target) - - # For WebSearch, apply domain restrictions consistently - # If no domain detected in search query, check if restrictions are in place - if tool_name == 'WebSearch' and not domain: - # Since this hook is only generated when network permissions are configured, - # empty ALLOWED_DOMAINS means deny-all policy - if not ALLOWED_DOMAINS: # Empty list means deny all - print(f"Network access blocked: deny-all policy in effect", file=sys.stderr) - print(f"No domains are allowed for WebSearch", file=sys.stderr) - sys.exit(2) # Block under deny-all policy - else: - print(f"Network access blocked for web-search: no specific domain detected", file=sys.stderr) - print(f"Allowed domains: {', '.join(ALLOWED_DOMAINS)}", file=sys.stderr) - sys.exit(2) # Block general searches when domain allowlist is configured - - if not is_domain_allowed(domain): - print(f"Network access blocked for domain: {domain}", file=sys.stderr) - print(f"Allowed domains: {', '.join(ALLOWED_DOMAINS)}", file=sys.stderr) - sys.exit(2) # Block with feedback to Claude - - sys.exit(0) # Allow - - except Exception as e: - print(f"Network validation error: {e}", file=sys.stderr) - sys.exit(2) # Block on errors - - EOF - chmod +x .claude/hooks/network_permissions.py + - name: Install Codex + run: npm install -g @openai/codex@0.63.0 - name: Downloading container images run: | set -e @@ -1554,15 +1480,27 @@ jobs: function registerTool(name, description, inputSchema, handler) { tools[name] = { name, description, inputSchema, handler }; } + registerTool("test-js-math", "Test JavaScript math operations", {"properties":{"a":{"description":"First number","type":"number"},"b":{"description":"Second number","type":"number"}},"required":["a","b"],"type":"object"}, async (args) => { + try { + const toolModule = require("./test-js-math.cjs"); + const result = await toolModule.execute(args || {}); + return { content: [{ type: "text", text: typeof result === "string" ? result : JSON.stringify(result, null, 2) }] }; + } catch (error) { + return { content: [{ type: "text", text: "Error: " + (error instanceof Error ? error.message : String(error)) }], isError: true }; + } + }); + registerTool("test-js-string", "Test JavaScript string operations", {"properties":{"text":{"description":"Input text","type":"string"}},"required":["text"],"type":"object"}, async (args) => { + try { + const toolModule = require("./test-js-string.cjs"); + const result = await toolModule.execute(args || {}); + return { content: [{ type: "text", text: typeof result === "string" ? result : JSON.stringify(result, null, 2) }] }; + } catch (error) { + return { content: [{ type: "text", text: "Error: " + (error instanceof Error ? error.message : String(error)) }], isError: true }; + } + }); registerTool("fetch-pr-data", "Fetches pull request data from GitHub using gh CLI. Returns JSON array of PRs with fields: number, title, author, headRefName, createdAt, state, url, body, labels, updatedAt, closedAt, mergedAt", {"properties":{"days":{"default":30,"description":"Number of days to look back (default: 30)","type":"number"},"limit":{"default":100,"description":"Maximum number of PRs to fetch (default: 100)","type":"number"},"repo":{"description":"Repository in owner/repo format (defaults to current repository)","type":"string"},"search":{"description":"Search query for filtering PRs (e.g., 'head:copilot/' for Copilot PRs)","type":"string"},"state":{"default":"all","description":"PR state filter: open, closed, merged, or all (default: all)","type":"string"}},"type":"object"}, async (args) => { try { const env = { ...process.env }; - if (args && args["days"] !== undefined) { - env["INPUT_DAYS"] = typeof args["days"] === "object" ? JSON.stringify(args["days"]) : String(args["days"]); - } - if (args && args["limit"] !== undefined) { - env["INPUT_LIMIT"] = typeof args["limit"] === "object" ? JSON.stringify(args["limit"]) : String(args["limit"]); - } if (args && args["repo"] !== undefined) { env["INPUT_REPO"] = typeof args["repo"] === "object" ? JSON.stringify(args["repo"]) : String(args["repo"]); } @@ -1572,6 +1510,12 @@ jobs: if (args && args["state"] !== undefined) { env["INPUT_STATE"] = typeof args["state"] === "object" ? JSON.stringify(args["state"]) : String(args["state"]); } + if (args && args["days"] !== undefined) { + env["INPUT_DAYS"] = typeof args["days"] === "object" ? JSON.stringify(args["days"]) : String(args["days"]); + } + if (args && args["limit"] !== undefined) { + env["INPUT_LIMIT"] = typeof args["limit"] === "object" ? JSON.stringify(args["limit"]) : String(args["limit"]); + } const scriptPath = path.join(__dirname, "fetch-pr-data.sh"); const { stdout, stderr } = await execFileAsync("bash", [scriptPath], { env }); const output = stdout + (stderr ? "\nStderr: " + stderr : ""); @@ -1688,6 +1632,26 @@ jobs: }); EOFSI chmod +x /tmp/gh-aw/safe-inputs/mcp-server.cjs + cat > /tmp/gh-aw/safe-inputs/test-js-math.cjs << 'EOFJS_test-js-math' + async function execute(inputs) { + const { a, b } = inputs || {}; + const sum = a + b; + const product = a * b; + return { sum, product, inputs: { a, b } }; + } + module.exports = { execute }; + EOFJS_test-js-math + cat > /tmp/gh-aw/safe-inputs/test-js-string.cjs << 'EOFJS_test-js-string' + async function execute(inputs) { + const { text } = inputs || {}; + return { + original: text, + uppercase: text.toUpperCase(), + length: text.length + }; + } + module.exports = { execute }; + EOFJS_test-js-string cat > /tmp/gh-aw/safe-inputs/fetch-pr-data.sh << 'EOFSH_fetch-pr-data' #!/bin/bash # Auto-generated safe-input tool: fetch-pr-data @@ -1729,48 +1693,46 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | mkdir -p /tmp/gh-aw/mcp-config - cat > /tmp/gh-aw/mcp-config/mcp-servers.json << EOF - { - "mcpServers": { - "github": { - "command": "docker", - "args": [ - "run", - "-i", - "--rm", - "-e", - "GITHUB_PERSONAL_ACCESS_TOKEN", - "-e", - "GITHUB_READ_ONLY=1", - "-e", - "GITHUB_TOOLSETS=default,repos,issues,discussions", - "ghcr.io/github/github-mcp-server:v0.23.0" - ], - "env": { - "GITHUB_PERSONAL_ACCESS_TOKEN": "$GITHUB_MCP_SERVER_TOKEN" - } - }, - "safeinputs": { - "command": "node", - "args": ["/tmp/gh-aw/safe-inputs/mcp-server.cjs"], - "env": { - "GH_TOKEN": "$GH_TOKEN" - } - }, - "safeoutputs": { - "command": "node", - "args": ["/tmp/gh-aw/safeoutputs/mcp-server.cjs"], - "env": { - "GH_AW_SAFE_OUTPUTS": "$GH_AW_SAFE_OUTPUTS", - "GH_AW_ASSETS_BRANCH": "$GH_AW_ASSETS_BRANCH", - "GH_AW_ASSETS_MAX_SIZE_KB": "$GH_AW_ASSETS_MAX_SIZE_KB", - "GH_AW_ASSETS_ALLOWED_EXTS": "$GH_AW_ASSETS_ALLOWED_EXTS", - "GITHUB_REPOSITORY": "$GITHUB_REPOSITORY", - "GITHUB_SERVER_URL": "$GITHUB_SERVER_URL" - } - } - } - } + cat > /tmp/gh-aw/mcp-config/config.toml << EOF + [history] + persistence = "none" + + [shell_environment_policy] + inherit = "core" + include_only = ["CODEX_API_KEY", "GH_AW_ASSETS_ALLOWED_EXTS", "GH_AW_ASSETS_BRANCH", "GH_AW_ASSETS_MAX_SIZE_KB", "GH_AW_SAFE_OUTPUTS", "GITHUB_PERSONAL_ACCESS_TOKEN", "GITHUB_REPOSITORY", "GITHUB_SERVER_URL", "HOME", "OPENAI_API_KEY", "PATH"] + + [mcp_servers.github] + user_agent = "dev" + startup_timeout_sec = 120 + tool_timeout_sec = 60 + command = "docker" + args = [ + "run", + "-i", + "--rm", + "-e", + "GITHUB_PERSONAL_ACCESS_TOKEN", + "-e", + "GITHUB_READ_ONLY=1", + "-e", + "GITHUB_TOOLSETS=default,repos,issues,discussions", + "ghcr.io/github/github-mcp-server:v0.23.0" + ] + env_vars = ["GITHUB_PERSONAL_ACCESS_TOKEN"] + + [mcp_servers.safeinputs] + command = "node" + args = [ + "/tmp/gh-aw/safe-inputs/mcp-server.cjs", + ] + env_vars = ["GH_TOKEN"] + + [mcp_servers.safeoutputs] + command = "node" + args = [ + "/tmp/gh-aw/safeoutputs/mcp-server.cjs", + ] + env_vars = ["GH_AW_SAFE_OUTPUTS", "GH_AW_ASSETS_BRANCH", "GH_AW_ASSETS_MAX_SIZE_KB", "GH_AW_ASSETS_ALLOWED_EXTS", "GITHUB_REPOSITORY", "GITHUB_SERVER_URL"] EOF - name: Generate agentic run info uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 @@ -1779,11 +1741,11 @@ jobs: const fs = require('fs'); const awInfo = { - engine_id: "claude", - engine_name: "Claude Code", + engine_id: "codex", + engine_name: "Codex", model: "", version: "", - agent_version: "2.0.55", + agent_version: "0.63.0", workflow_name: "Dev", experimental: true, supports_tools_allowlist: true, @@ -1867,7 +1829,11 @@ jobs: - Average time from PR creation to merge (for merged PRs) - Most active day of the week for PR creation - Present the statistics in a clear summary. + Also test the JavaScript safe-inputs tools: + 1. Call `test-js-math` with a=5 and b=3 to verify math operations work + 2. Call `test-js-string` with text="Hello World" to verify string operations work + + Present the statistics and test results in a clear summary. PROMPT_EOF - name: Append XPIA security instructions to prompt @@ -2075,100 +2041,23 @@ jobs: name: aw_info.json path: /tmp/gh-aw/aw_info.json if-no-files-found: warn - - name: Execute Claude Code CLI - id: agentic_execution - # Allowed tools (sorted): - # - Bash - # - BashOutput - # - Edit - # - ExitPlanMode - # - Glob - # - Grep - # - KillBash - # - LS - # - MultiEdit - # - NotebookEdit - # - 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_job_logs - # - mcp__github__get_label - # - mcp__github__get_latest_release - # - 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_review_comments - # - mcp__github__get_pull_request_reviews - # - mcp__github__get_pull_request_status - # - mcp__github__get_release_by_tag - # - 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__issue_read - # - 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_issue_types - # - mcp__github__list_issues - # - mcp__github__list_label - # - mcp__github__list_notifications - # - mcp__github__list_pull_requests - # - mcp__github__list_releases - # - mcp__github__list_secret_scanning_alerts - # - mcp__github__list_starred_repositories - # - 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__pull_request_read - # - 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 - timeout-minutes: 5 + - name: Run Codex run: | set -o pipefail - # Execute Claude Code CLI with prompt from file - claude --print --mcp-config /tmp/gh-aw/mcp-config/mcp-servers.json --allowed-tools Bash,BashOutput,Edit,ExitPlanMode,Glob,Grep,KillBash,LS,MultiEdit,NotebookEdit,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_job_logs,mcp__github__get_label,mcp__github__get_latest_release,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_review_comments,mcp__github__get_pull_request_reviews,mcp__github__get_pull_request_status,mcp__github__get_release_by_tag,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__issue_read,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_issue_types,mcp__github__list_issues,mcp__github__list_label,mcp__github__list_notifications,mcp__github__list_pull_requests,mcp__github__list_releases,mcp__github__list_secret_scanning_alerts,mcp__github__list_starred_repositories,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__pull_request_read,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 stream-json --settings /tmp/gh-aw/.claude/settings.json "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)" 2>&1 | tee /tmp/gh-aw/agent-stdio.log + INSTRUCTION="$(cat "$GH_AW_PROMPT")" + mkdir -p "$CODEX_HOME/logs" + codex exec --full-auto --skip-git-repo-check "$INSTRUCTION" 2>&1 | tee /tmp/gh-aw/agent-stdio.log env: - ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - DISABLE_TELEMETRY: "1" - DISABLE_ERROR_REPORTING: "1" - DISABLE_BUG_COMMAND: "1" + CODEX_API_KEY: ${{ secrets.CODEX_API_KEY || secrets.OPENAI_API_KEY }} + CODEX_HOME: /tmp/gh-aw/mcp-config + GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + GH_AW_MCP_CONFIG: /tmp/gh-aw/mcp-config/config.toml GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - GH_AW_MCP_CONFIG: /tmp/gh-aw/mcp-config/mcp-servers.json - MCP_TIMEOUT: "120000" - MCP_TOOL_TIMEOUT: "60000" - BASH_DEFAULT_TIMEOUT_MS: "60000" - BASH_MAX_TIMEOUT_MS: "60000" GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} - - name: Clean up network proxy hook files - if: always() - run: | - rm -rf .claude/hooks/network_permissions.py || true - rm -rf .claude/hooks || true - rm -rf .claude || true + GITHUB_PERSONAL_ACCESS_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + GITHUB_STEP_SUMMARY: ${{ env.GITHUB_STEP_SUMMARY }} + OPENAI_API_KEY: ${{ secrets.CODEX_API_KEY || secrets.OPENAI_API_KEY }} + RUST_LOG: trace,hyper_util=info,mio=info,reqwest=info,os_info=info,codex_otel=warn,codex_core=debug,ocodex_exec=debug - name: Redact secrets in logs if: always() uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 @@ -2280,11 +2169,11 @@ jobs: } await main(); env: - GH_AW_SECRET_NAMES: 'ANTHROPIC_API_KEY,CLAUDE_CODE_OAUTH_TOKEN,GH_AW_GITHUB_TOKEN,GITHUB_TOKEN' - SECRET_ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - SECRET_CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + GH_AW_SECRET_NAMES: 'CODEX_API_KEY,GH_AW_GITHUB_TOKEN,GITHUB_TOKEN,OPENAI_API_KEY' + SECRET_CODEX_API_KEY: ${{ secrets.CODEX_API_KEY }} SECRET_GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }} SECRET_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SECRET_OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} - name: Upload Safe Outputs if: always() uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 @@ -3185,6 +3074,14 @@ jobs: name: agent_output.json path: ${{ env.GH_AW_AGENT_OUTPUT }} if-no-files-found: warn + - name: Upload engine output files + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 + with: + name: agent_outputs + path: | + /tmp/gh-aw/mcp-config/logs/ + /tmp/gh-aw/redacted-urls.log + if-no-files-found: ignore - name: Upload MCP logs if: always() uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 @@ -3731,85 +3628,313 @@ jobs: } function main() { runLogParser({ - parseLog: parseClaudeLog, - parserName: "Claude", + parseLog: parseCodexLog, + parserName: "Codex", supportsDirectories: false, }); } - function parseClaudeLog(logContent) { + function extractMCPInitialization(lines) { + const mcpServers = new Map(); + let serverCount = 0; + let connectedCount = 0; + let availableTools = []; + for (const line of lines) { + if (line.includes("Initializing MCP servers") || (line.includes("mcp") && line.includes("init"))) { + } + const countMatch = line.match(/Found (\d+) MCP servers? in configuration/i); + if (countMatch) { + serverCount = parseInt(countMatch[1]); + } + const connectingMatch = line.match(/Connecting to MCP server[:\s]+['"]?(\w+)['"]?/i); + if (connectingMatch) { + const serverName = connectingMatch[1]; + if (!mcpServers.has(serverName)) { + mcpServers.set(serverName, { name: serverName, status: "connecting" }); + } + } + const connectedMatch = line.match(/MCP server ['"](\w+)['"] connected successfully/i); + if (connectedMatch) { + const serverName = connectedMatch[1]; + mcpServers.set(serverName, { name: serverName, status: "connected" }); + connectedCount++; + } + const failedMatch = line.match(/Failed to connect to MCP server ['"](\w+)['"][:]\s*(.+)/i); + if (failedMatch) { + const serverName = failedMatch[1]; + const error = failedMatch[2].trim(); + mcpServers.set(serverName, { name: serverName, status: "failed", error }); + } + const initFailedMatch = line.match(/MCP server ['"](\w+)['"] initialization failed/i); + if (initFailedMatch) { + const serverName = initFailedMatch[1]; + const existing = mcpServers.get(serverName); + if (existing && existing.status !== "failed") { + mcpServers.set(serverName, { name: serverName, status: "failed", error: "Initialization failed" }); + } + } + const toolsMatch = line.match(/Available tools:\s*(.+)/i); + if (toolsMatch) { + const toolsStr = toolsMatch[1]; + availableTools = toolsStr + .split(",") + .map(t => t.trim()) + .filter(t => t.length > 0); + } + } + let markdown = ""; + const hasInfo = mcpServers.size > 0 || availableTools.length > 0; + if (mcpServers.size > 0) { + markdown += "**MCP Servers:**\n"; + const servers = Array.from(mcpServers.values()); + const connected = servers.filter(s => s.status === "connected"); + const failed = servers.filter(s => s.status === "failed"); + markdown += `- Total: ${servers.length}${serverCount > 0 && servers.length !== serverCount ? ` (configured: ${serverCount})` : ""}\n`; + markdown += `- Connected: ${connected.length}\n`; + if (failed.length > 0) { + markdown += `- Failed: ${failed.length}\n`; + } + markdown += "\n"; + for (const server of servers) { + const statusIcon = server.status === "connected" ? "✅" : server.status === "failed" ? "❌" : "⏳"; + markdown += `- ${statusIcon} **${server.name}** (${server.status})`; + if (server.error) { + markdown += `\n - Error: ${server.error}`; + } + markdown += "\n"; + } + markdown += "\n"; + } + if (availableTools.length > 0) { + markdown += "**Available MCP Tools:**\n"; + markdown += `- Total: ${availableTools.length} tools\n`; + markdown += `- Tools: ${availableTools.slice(0, 10).join(", ")}${availableTools.length > 10 ? ", ..." : ""}\n\n`; + } + return { + hasInfo, + markdown, + servers: Array.from(mcpServers.values()), + }; + } + function parseCodexLog(logContent) { try { - const logEntries = parseLogEntries(logContent); - if (!logEntries) { - return { - markdown: "## Agent Log Summary\n\nLog format not recognized as Claude JSON array or JSONL.\n", - mcpFailures: [], - maxTurnsHit: false, - }; + const lines = logContent.split("\n"); + const LOOKAHEAD_WINDOW = 50; + let markdown = ""; + const mcpInfo = extractMCPInitialization(lines); + if (mcpInfo.hasInfo) { + markdown += "## 🚀 Initialization\n\n"; + markdown += mcpInfo.markdown; + } + markdown += "## 🤖 Reasoning\n\n"; + let inThinkingSection = false; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if ( + line.includes("OpenAI Codex") || + line.startsWith("--------") || + line.includes("workdir:") || + line.includes("model:") || + line.includes("provider:") || + line.includes("approval:") || + line.includes("sandbox:") || + line.includes("reasoning effort:") || + line.includes("reasoning summaries:") || + line.includes("tokens used:") || + line.includes("DEBUG codex") || + line.includes("INFO codex") || + line.match(/^\d{4}-\d{2}-\d{2}T[\d:.]+Z\s+(DEBUG|INFO|WARN|ERROR)/) + ) { + continue; + } + if (line.trim() === "thinking") { + inThinkingSection = true; + continue; + } + const toolMatch = line.match(/^tool\s+(\w+)\.(\w+)\(/); + if (toolMatch) { + inThinkingSection = false; + const server = toolMatch[1]; + const toolName = toolMatch[2]; + let statusIcon = "❓"; + for (let j = i + 1; j < Math.min(i + LOOKAHEAD_WINDOW, lines.length); j++) { + const nextLine = lines[j]; + if (nextLine.includes(`${server}.${toolName}(`) && nextLine.includes("success in")) { + statusIcon = "✅"; + break; + } else if (nextLine.includes(`${server}.${toolName}(`) && (nextLine.includes("failed in") || nextLine.includes("error"))) { + statusIcon = "❌"; + break; + } + } + markdown += `${statusIcon} ${server}::${toolName}(...)\n\n`; + continue; + } + if (inThinkingSection && line.trim().length > 20 && !line.match(/^\d{4}-\d{2}-\d{2}T/)) { + const trimmed = line.trim(); + markdown += `${trimmed}\n\n`; + } } - const mcpFailures = []; - const conversationResult = generateConversationMarkdown(logEntries, { - formatToolCallback: (toolUse, toolResult) => formatToolUse(toolUse, toolResult, { includeDetailedParameters: false }), - formatInitCallback: initEntry => { - const result = formatInitializationSummary(initEntry, { - includeSlashCommands: true, - mcpFailureCallback: server => { - const errorDetails = []; - if (server.error) { - errorDetails.push(`**Error:** ${server.error}`); - } - if (server.stderr) { - const maxStderrLength = 500; - const stderr = server.stderr.length > maxStderrLength ? server.stderr.substring(0, maxStderrLength) + "..." : server.stderr; - errorDetails.push(`**Stderr:** \`${stderr}\``); - } - if (server.exitCode !== undefined && server.exitCode !== null) { - errorDetails.push(`**Exit Code:** ${server.exitCode}`); - } - if (server.command) { - errorDetails.push(`**Command:** \`${server.command}\``); - } - if (server.message) { - errorDetails.push(`**Message:** ${server.message}`); - } - if (server.reason) { - errorDetails.push(`**Reason:** ${server.reason}`); + markdown += "## 🤖 Commands and Tools\n\n"; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const toolMatch = line.match(/^\[.*?\]\s+tool\s+(\w+)\.(\w+)\((.+)\)/) || line.match(/ToolCall:\s+(\w+)__(\w+)\s+(\{.+\})/); + const bashMatch = line.match(/^\[.*?\]\s+exec\s+bash\s+-lc\s+'([^']+)'/); + if (toolMatch) { + const server = toolMatch[1]; + const toolName = toolMatch[2]; + const params = toolMatch[3]; + let statusIcon = "❓"; + let response = ""; + let isError = false; + for (let j = i + 1; j < Math.min(i + LOOKAHEAD_WINDOW, lines.length); j++) { + const nextLine = lines[j]; + if (nextLine.includes(`${server}.${toolName}(`) && (nextLine.includes("success in") || nextLine.includes("failed in"))) { + isError = nextLine.includes("failed in"); + statusIcon = isError ? "❌" : "✅"; + let jsonLines = []; + let braceCount = 0; + let inJson = false; + for (let k = j + 1; k < Math.min(j + 30, lines.length); k++) { + const respLine = lines[k]; + if (respLine.includes("tool ") || respLine.includes("ToolCall:") || respLine.includes("tokens used")) { + break; + } + for (const char of respLine) { + if (char === "{") { + braceCount++; + inJson = true; + } else if (char === "}") { + braceCount--; + } + } + if (inJson) { + jsonLines.push(respLine); + } + if (inJson && braceCount === 0) { + break; + } } - if (errorDetails.length > 0) { - return errorDetails.map(detail => ` - ${detail}\n`).join(""); + response = jsonLines.join("\n"); + break; + } + } + markdown += formatCodexToolCall(server, toolName, params, response, statusIcon); + } else if (bashMatch) { + const command = bashMatch[1]; + let statusIcon = "❓"; + let response = ""; + let isError = false; + for (let j = i + 1; j < Math.min(i + LOOKAHEAD_WINDOW, lines.length); j++) { + const nextLine = lines[j]; + if (nextLine.includes("bash -lc") && (nextLine.includes("succeeded in") || nextLine.includes("failed in"))) { + isError = nextLine.includes("failed in"); + statusIcon = isError ? "❌" : "✅"; + let responseLines = []; + for (let k = j + 1; k < Math.min(j + 20, lines.length); k++) { + const respLine = lines[k]; + if ( + respLine.includes("tool ") || + respLine.includes("exec ") || + respLine.includes("ToolCall:") || + respLine.includes("tokens used") || + respLine.includes("thinking") + ) { + break; + } + responseLines.push(respLine); } - return ""; - }, - }); - if (result.mcpFailures) { - mcpFailures.push(...result.mcpFailures); + response = responseLines.join("\n").trim(); + break; + } } - return result; - }, - }); - let markdown = conversationResult.markdown; - const lastEntry = logEntries[logEntries.length - 1]; - markdown += generateInformationSection(lastEntry); - let maxTurnsHit = false; - const maxTurns = process.env.GH_AW_MAX_TURNS; - if (maxTurns && lastEntry && lastEntry.num_turns) { - const configuredMaxTurns = parseInt(maxTurns, 10); - if (!isNaN(configuredMaxTurns) && lastEntry.num_turns >= configuredMaxTurns) { - maxTurnsHit = true; + markdown += formatCodexBashCall(command, response, statusIcon); } } - return { markdown, mcpFailures, maxTurnsHit }; + markdown += "\n## 📊 Information\n\n"; + let totalTokens = 0; + const tokenCountMatches = logContent.matchAll(/total_tokens:\s*(\d+)/g); + for (const match of tokenCountMatches) { + const tokens = parseInt(match[1]); + totalTokens = Math.max(totalTokens, tokens); + } + const finalTokensMatch = logContent.match(/tokens used\n([\d,]+)/); + if (finalTokensMatch) { + totalTokens = parseInt(finalTokensMatch[1].replace(/,/g, "")); + } + if (totalTokens > 0) { + markdown += `**Total Tokens Used:** ${totalTokens.toLocaleString()}\n\n`; + } + const toolCalls = (logContent.match(/ToolCall:\s+\w+__\w+/g) || []).length; + if (toolCalls > 0) { + markdown += `**Tool Calls:** ${toolCalls}\n\n`; + } + return markdown; } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - return { - markdown: `## Agent Log Summary\n\nError parsing Claude log (tried both JSON array and JSONL formats): ${errorMessage}\n`, - mcpFailures: [], - maxTurnsHit: false, - }; + core.error(`Error parsing Codex log: ${error}`); + return "## 🤖 Commands and Tools\n\nError parsing log content.\n\n## 🤖 Reasoning\n\nUnable to parse reasoning from log.\n\n"; + } + } + function formatCodexToolCall(server, toolName, params, response, statusIcon) { + const totalTokens = estimateTokens(params) + estimateTokens(response); + let metadata = ""; + if (totalTokens > 0) { + metadata = `~${totalTokens}t`; + } + const summary = `${server}::${toolName}`; + const sections = []; + if (params && params.trim()) { + sections.push({ + label: "Parameters", + content: params, + language: "json", + }); + } + if (response && response.trim()) { + sections.push({ + label: "Response", + content: response, + language: "json", + }); + } + return formatToolCallAsDetails({ + summary, + statusIcon, + metadata, + sections, + }); + } + function formatCodexBashCall(command, response, statusIcon) { + const totalTokens = estimateTokens(command) + estimateTokens(response); + let metadata = ""; + if (totalTokens > 0) { + metadata = `~${totalTokens}t`; } + const summary = `bash: ${truncateString(command, 60)}`; + const sections = []; + sections.push({ + label: "Command", + content: command, + language: "bash", + }); + if (response && response.trim()) { + sections.push({ + label: "Output", + content: response, + }); + } + return formatToolCallAsDetails({ + summary, + statusIcon, + metadata, + sections, + }); } if (typeof module !== "undefined" && module.exports) { module.exports = { - parseClaudeLog, + parseCodexLog, + formatCodexToolCall, + formatCodexBashCall, + extractMCPInitialization, }; } main(); @@ -3825,7 +3950,7 @@ jobs: uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: /tmp/gh-aw/agent-stdio.log - GH_AW_ERROR_PATTERNS: "[{\"id\":\"\",\"pattern\":\"::(error)(?:\\\\s+[^:]*)?::(.+)\",\"level_group\":1,\"message_group\":2,\"description\":\"GitHub Actions workflow command - error\"},{\"id\":\"\",\"pattern\":\"::(warning)(?:\\\\s+[^:]*)?::(.+)\",\"level_group\":1,\"message_group\":2,\"description\":\"GitHub Actions workflow command - warning\"},{\"id\":\"\",\"pattern\":\"::(notice)(?:\\\\s+[^:]*)?::(.+)\",\"level_group\":1,\"message_group\":2,\"description\":\"GitHub Actions workflow command - notice\"},{\"id\":\"\",\"pattern\":\"(ERROR|Error):\\\\s+(.+)\",\"level_group\":1,\"message_group\":2,\"description\":\"Generic ERROR messages\"},{\"id\":\"\",\"pattern\":\"(WARNING|Warning):\\\\s+(.+)\",\"level_group\":1,\"message_group\":2,\"description\":\"Generic WARNING messages\"}]" + GH_AW_ERROR_PATTERNS: "[{\"id\":\"\",\"pattern\":\"::(error)(?:\\\\s+[^:]*)?::(.+)\",\"level_group\":1,\"message_group\":2,\"description\":\"GitHub Actions workflow command - error\"},{\"id\":\"\",\"pattern\":\"::(warning)(?:\\\\s+[^:]*)?::(.+)\",\"level_group\":1,\"message_group\":2,\"description\":\"GitHub Actions workflow command - warning\"},{\"id\":\"\",\"pattern\":\"::(notice)(?:\\\\s+[^:]*)?::(.+)\",\"level_group\":1,\"message_group\":2,\"description\":\"GitHub Actions workflow command - notice\"},{\"id\":\"\",\"pattern\":\"(ERROR|Error):\\\\s+(.+)\",\"level_group\":1,\"message_group\":2,\"description\":\"Generic ERROR messages\"},{\"id\":\"\",\"pattern\":\"(WARNING|Warning):\\\\s+(.+)\",\"level_group\":1,\"message_group\":2,\"description\":\"Generic WARNING messages\"},{\"id\":\"\",\"pattern\":\"(\\\\d{4}-\\\\d{2}-\\\\d{2}T[\\\\d:.]+Z)\\\\s+(ERROR)\\\\s+(.+)\",\"level_group\":2,\"message_group\":3,\"description\":\"Codex ERROR messages with timestamp\"},{\"id\":\"\",\"pattern\":\"(\\\\d{4}-\\\\d{2}-\\\\d{2}T[\\\\d:.]+Z)\\\\s+(WARN|WARNING)\\\\s+(.+)\",\"level_group\":2,\"message_group\":3,\"description\":\"Codex warning messages with timestamp\"}]" with: script: | function main() { @@ -4093,7 +4218,7 @@ jobs: GH_AW_AGENT_MAX_COUNT: 1 GH_AW_AGENT_TOKEN: ${{ secrets.GH_AW_AGENT_TOKEN || secrets.GH_AW_GITHUB_TOKEN }} GH_AW_WORKFLOW_NAME: "Dev" - GH_AW_ENGINE_ID: "claude" + GH_AW_ENGINE_ID: "codex" with: github-token: ${{ secrets.COPILOT_GITHUB_TOKEN || secrets.COPILOT_CLI_TOKEN || secrets.GH_AW_COPILOT_TOKEN || secrets.GH_AW_GITHUB_TOKEN }} script: | @@ -5129,7 +5254,7 @@ jobs: runs-on: ubuntu-latest permissions: {} concurrency: - group: "gh-aw-claude-${{ github.workflow }}" + group: "gh-aw-codex-${{ github.workflow }}" timeout-minutes: 10 outputs: success: ${{ steps.parse_results.outputs.success }} @@ -5267,65 +5392,45 @@ jobs: run: | mkdir -p /tmp/gh-aw/threat-detection touch /tmp/gh-aw/threat-detection/detection.log - - name: Validate CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_API_KEY secret + - name: Validate CODEX_API_KEY or OPENAI_API_KEY secret run: | - if [ -z "$CLAUDE_CODE_OAUTH_TOKEN" ] && [ -z "$ANTHROPIC_API_KEY" ]; then - echo "Error: Neither CLAUDE_CODE_OAUTH_TOKEN nor ANTHROPIC_API_KEY secret is set" - echo "The Claude Code engine requires either CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_API_KEY secret to be configured." + if [ -z "$CODEX_API_KEY" ] && [ -z "$OPENAI_API_KEY" ]; then + echo "Error: Neither CODEX_API_KEY nor OPENAI_API_KEY secret is set" + echo "The Codex engine requires either CODEX_API_KEY or OPENAI_API_KEY secret to be configured." echo "Please configure one of these secrets in your repository settings." - echo "Documentation: https://githubnext.github.io/gh-aw/reference/engines/#anthropic-claude-code" + echo "Documentation: https://githubnext.github.io/gh-aw/reference/engines/#openai-codex" exit 1 fi - if [ -n "$CLAUDE_CODE_OAUTH_TOKEN" ]; then - echo "CLAUDE_CODE_OAUTH_TOKEN secret is configured" + if [ -n "$CODEX_API_KEY" ]; then + echo "CODEX_API_KEY secret is configured" else - echo "ANTHROPIC_API_KEY secret is configured (using as fallback for CLAUDE_CODE_OAUTH_TOKEN)" + echo "OPENAI_API_KEY secret is configured (using as fallback for CODEX_API_KEY)" fi env: - CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + CODEX_API_KEY: ${{ secrets.CODEX_API_KEY }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} - name: Setup Node.js uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6 with: node-version: '24' - - name: Install Claude Code CLI - run: npm install -g @anthropic-ai/claude-code@2.0.55 - - name: Execute Claude Code CLI - id: agentic_execution - # Allowed tools (sorted): - # - Bash(cat) - # - Bash(grep) - # - Bash(head) - # - Bash(jq) - # - Bash(ls) - # - Bash(tail) - # - Bash(wc) - # - BashOutput - # - ExitPlanMode - # - Glob - # - Grep - # - KillBash - # - LS - # - NotebookRead - # - Read - # - Task - # - TodoWrite - timeout-minutes: 20 + - name: Install Codex + run: npm install -g @openai/codex@0.63.0 + - name: Run Codex run: | set -o pipefail - # Execute Claude Code CLI with prompt from file - claude --print --allowed-tools 'Bash(cat),Bash(grep),Bash(head),Bash(jq),Bash(ls),Bash(tail),Bash(wc),BashOutput,ExitPlanMode,Glob,Grep,KillBash,LS,NotebookRead,Read,Task,TodoWrite' --debug --verbose --permission-mode bypassPermissions --output-format stream-json "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)" 2>&1 | tee /tmp/gh-aw/threat-detection/detection.log + INSTRUCTION="$(cat "$GH_AW_PROMPT")" + mkdir -p "$CODEX_HOME/logs" + codex exec --full-auto --skip-git-repo-check "$INSTRUCTION" 2>&1 | tee /tmp/gh-aw/threat-detection/detection.log env: - ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - DISABLE_TELEMETRY: "1" - DISABLE_ERROR_REPORTING: "1" - DISABLE_BUG_COMMAND: "1" + CODEX_API_KEY: ${{ secrets.CODEX_API_KEY || secrets.OPENAI_API_KEY }} + CODEX_HOME: /tmp/gh-aw/mcp-config + GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + GH_AW_MCP_CONFIG: /tmp/gh-aw/mcp-config/config.toml GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - MCP_TIMEOUT: "120000" - MCP_TOOL_TIMEOUT: "60000" - BASH_DEFAULT_TIMEOUT_MS: "60000" - BASH_MAX_TIMEOUT_MS: "60000" + GITHUB_PERSONAL_ACCESS_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + GITHUB_STEP_SUMMARY: ${{ env.GITHUB_STEP_SUMMARY }} + OPENAI_API_KEY: ${{ secrets.CODEX_API_KEY || secrets.OPENAI_API_KEY }} + RUST_LOG: trace,hyper_util=info,mio=info,reqwest=info,os_info=info,codex_otel=warn,codex_core=debug,ocodex_exec=debug - name: Parse threat detection results id: parse_results uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 diff --git a/.github/workflows/dev.md b/.github/workflows/dev.md index 3c995e4044..1710cb9e6d 100644 --- a/.github/workflows/dev.md +++ b/.github/workflows/dev.md @@ -5,8 +5,8 @@ name: Dev description: Test workflow for development and experimentation purposes timeout-minutes: 5 strict: false -# Using experimental Claude engine for testing -engine: claude +# Using Codex engine for better error messages +engine: codex permissions: contents: read issues: read @@ -21,6 +21,37 @@ tools: toolsets: [default, repos, issues, discussions] safe-outputs: assign-to-agent: +safe-inputs: + test-js-math: + description: "Test JavaScript math operations" + inputs: + a: + type: number + description: "First number" + required: true + b: + type: number + description: "Second number" + required: true + script: | + // Users can write simple code without exports + const sum = a + b; + const product = a * b; + return { sum, product, inputs: { a, b } }; + test-js-string: + description: "Test JavaScript string operations" + inputs: + text: + type: string + description: "Input text" + required: true + script: | + // Simple string manipulation + return { + original: text, + uppercase: text.toUpperCase(), + length: text.length + }; --- Use the `fetch-pr-data` tool to fetch Copilot agent PRs from this repository using `search: "head:copilot/"`. Then compute basic PR statistics: - Total number of Copilot PRs in the last 30 days @@ -28,4 +59,8 @@ Use the `fetch-pr-data` tool to fetch Copilot agent PRs from this repository usi - Average time from PR creation to merge (for merged PRs) - Most active day of the week for PR creation -Present the statistics in a clear summary. \ No newline at end of file +Also test the JavaScript safe-inputs tools: +1. Call `test-js-math` with a=5 and b=3 to verify math operations work +2. Call `test-js-string` with text="Hello World" to verify string operations work + +Present the statistics and test results in a clear summary. \ No newline at end of file diff --git a/pkg/workflow/safe_inputs.go b/pkg/workflow/safe_inputs.go index 73084000e0..d0d0b44eb2 100644 --- a/pkg/workflow/safe_inputs.go +++ b/pkg/workflow/safe_inputs.go @@ -555,6 +555,9 @@ process.stdin.on("data", async (chunk) => { } // generateSafeInputJavaScriptToolScript generates the JavaScript tool file for a safe-input tool +// The user's script code is automatically wrapped in a function with module.exports, +// so users can write simple code without worrying about exports. +// Input parameters are destructured and available as local variables. func generateSafeInputJavaScriptToolScript(toolConfig *SafeInputToolConfig) string { var sb strings.Builder @@ -569,6 +572,24 @@ func generateSafeInputJavaScriptToolScript(toolConfig *SafeInputToolConfig) stri sb.WriteString(" * @returns {Promise} Tool result\n") sb.WriteString(" */\n") sb.WriteString("async function execute(inputs) {\n") + + // Destructure inputs to make parameters available as local variables + if len(toolConfig.Inputs) > 0 { + var paramNames []string + for paramName := range toolConfig.Inputs { + safeName := sanitizeParameterName(paramName) + if safeName != paramName { + // If sanitized, use alias + paramNames = append(paramNames, fmt.Sprintf("%s: %s", paramName, safeName)) + } else { + paramNames = append(paramNames, paramName) + } + } + sort.Strings(paramNames) + sb.WriteString(fmt.Sprintf(" const { %s } = inputs || {};\n\n", strings.Join(paramNames, ", "))) + } + + // Indent the user's script code sb.WriteString(" " + strings.ReplaceAll(toolConfig.Script, "\n", "\n ") + "\n") sb.WriteString("}\n\n") sb.WriteString("module.exports = { execute };\n") From 55fa0ab3e29e5eb9d51c78975f72c32be2c9fbf2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 30 Nov 2025 13:53:02 +0000 Subject: [PATCH 10/11] Add safe-inputs documentation page Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/dev.lock.yml | 60 +-- .../src/content/docs/reference/safe-inputs.md | 387 ++++++++++++++++++ 2 files changed, 417 insertions(+), 30 deletions(-) create mode 100644 docs/src/content/docs/reference/safe-inputs.md diff --git a/.github/workflows/dev.lock.yml b/.github/workflows/dev.lock.yml index 3f26109b26..884669627c 100644 --- a/.github/workflows/dev.lock.yml +++ b/.github/workflows/dev.lock.yml @@ -1480,18 +1480,18 @@ jobs: function registerTool(name, description, inputSchema, handler) { tools[name] = { name, description, inputSchema, handler }; } - registerTool("test-js-math", "Test JavaScript math operations", {"properties":{"a":{"description":"First number","type":"number"},"b":{"description":"Second number","type":"number"}},"required":["a","b"],"type":"object"}, async (args) => { + registerTool("test-js-string", "Test JavaScript string operations", {"properties":{"text":{"description":"Input text","type":"string"}},"required":["text"],"type":"object"}, async (args) => { try { - const toolModule = require("./test-js-math.cjs"); + const toolModule = require("./test-js-string.cjs"); const result = await toolModule.execute(args || {}); return { content: [{ type: "text", text: typeof result === "string" ? result : JSON.stringify(result, null, 2) }] }; } catch (error) { return { content: [{ type: "text", text: "Error: " + (error instanceof Error ? error.message : String(error)) }], isError: true }; } }); - registerTool("test-js-string", "Test JavaScript string operations", {"properties":{"text":{"description":"Input text","type":"string"}},"required":["text"],"type":"object"}, async (args) => { + registerTool("test-js-math", "Test JavaScript math operations", {"properties":{"a":{"description":"First number","type":"number"},"b":{"description":"Second number","type":"number"}},"required":["a","b"],"type":"object"}, async (args) => { try { - const toolModule = require("./test-js-string.cjs"); + const toolModule = require("./test-js-math.cjs"); const result = await toolModule.execute(args || {}); return { content: [{ type: "text", text: typeof result === "string" ? result : JSON.stringify(result, null, 2) }] }; } catch (error) { @@ -1501,6 +1501,12 @@ jobs: registerTool("fetch-pr-data", "Fetches pull request data from GitHub using gh CLI. Returns JSON array of PRs with fields: number, title, author, headRefName, createdAt, state, url, body, labels, updatedAt, closedAt, mergedAt", {"properties":{"days":{"default":30,"description":"Number of days to look back (default: 30)","type":"number"},"limit":{"default":100,"description":"Maximum number of PRs to fetch (default: 100)","type":"number"},"repo":{"description":"Repository in owner/repo format (defaults to current repository)","type":"string"},"search":{"description":"Search query for filtering PRs (e.g., 'head:copilot/' for Copilot PRs)","type":"string"},"state":{"default":"all","description":"PR state filter: open, closed, merged, or all (default: all)","type":"string"}},"type":"object"}, async (args) => { try { const env = { ...process.env }; + if (args && args["days"] !== undefined) { + env["INPUT_DAYS"] = typeof args["days"] === "object" ? JSON.stringify(args["days"]) : String(args["days"]); + } + if (args && args["limit"] !== undefined) { + env["INPUT_LIMIT"] = typeof args["limit"] === "object" ? JSON.stringify(args["limit"]) : String(args["limit"]); + } if (args && args["repo"] !== undefined) { env["INPUT_REPO"] = typeof args["repo"] === "object" ? JSON.stringify(args["repo"]) : String(args["repo"]); } @@ -1510,12 +1516,6 @@ jobs: if (args && args["state"] !== undefined) { env["INPUT_STATE"] = typeof args["state"] === "object" ? JSON.stringify(args["state"]) : String(args["state"]); } - if (args && args["days"] !== undefined) { - env["INPUT_DAYS"] = typeof args["days"] === "object" ? JSON.stringify(args["days"]) : String(args["days"]); - } - if (args && args["limit"] !== undefined) { - env["INPUT_LIMIT"] = typeof args["limit"] === "object" ? JSON.stringify(args["limit"]) : String(args["limit"]); - } const scriptPath = path.join(__dirname, "fetch-pr-data.sh"); const { stdout, stderr } = await execFileAsync("bash", [scriptPath], { env }); const output = stdout + (stderr ? "\nStderr: " + stderr : ""); @@ -1632,26 +1632,6 @@ jobs: }); EOFSI chmod +x /tmp/gh-aw/safe-inputs/mcp-server.cjs - cat > /tmp/gh-aw/safe-inputs/test-js-math.cjs << 'EOFJS_test-js-math' - async function execute(inputs) { - const { a, b } = inputs || {}; - const sum = a + b; - const product = a * b; - return { sum, product, inputs: { a, b } }; - } - module.exports = { execute }; - EOFJS_test-js-math - cat > /tmp/gh-aw/safe-inputs/test-js-string.cjs << 'EOFJS_test-js-string' - async function execute(inputs) { - const { text } = inputs || {}; - return { - original: text, - uppercase: text.toUpperCase(), - length: text.length - }; - } - module.exports = { execute }; - EOFJS_test-js-string cat > /tmp/gh-aw/safe-inputs/fetch-pr-data.sh << 'EOFSH_fetch-pr-data' #!/bin/bash # Auto-generated safe-input tool: fetch-pr-data @@ -1685,6 +1665,26 @@ jobs: EOFSH_fetch-pr-data chmod +x /tmp/gh-aw/safe-inputs/fetch-pr-data.sh + cat > /tmp/gh-aw/safe-inputs/test-js-string.cjs << 'EOFJS_test-js-string' + async function execute(inputs) { + const { text } = inputs || {}; + return { + original: text, + uppercase: text.toUpperCase(), + length: text.length + }; + } + module.exports = { execute }; + EOFJS_test-js-string + cat > /tmp/gh-aw/safe-inputs/test-js-math.cjs << 'EOFJS_test-js-math' + async function execute(inputs) { + const { a, b } = inputs || {}; + const sum = a + b; + const product = a * b; + return { sum, product, inputs: { a, b } }; + } + module.exports = { execute }; + EOFJS_test-js-math - name: Setup MCPs env: diff --git a/docs/src/content/docs/reference/safe-inputs.md b/docs/src/content/docs/reference/safe-inputs.md new file mode 100644 index 0000000000..72b83f7e84 --- /dev/null +++ b/docs/src/content/docs/reference/safe-inputs.md @@ -0,0 +1,387 @@ +--- +title: Safe Inputs +description: Define custom MCP tools inline as JavaScript or shell scripts with secret access, providing lightweight tool creation without external dependencies. +sidebar: + order: 750 +--- + +The `safe-inputs:` element allows you to define custom MCP (Model Context Protocol) tools directly in your workflow frontmatter using JavaScript or shell scripts. These tools are generated at runtime and mounted as an MCP server, giving your agent access to custom functionality with controlled secret access. + +## Quick Start + +```yaml wrap +safe-inputs: + greet-user: + description: "Greet a user by name" + inputs: + name: + type: string + required: true + script: | + return { message: `Hello, ${name}!` }; +``` + +The agent can now call `greet-user` with a `name` parameter. + +## Tool Definition + +Each safe-input tool requires a unique name and configuration: + +```yaml wrap +safe-inputs: + tool-name: + description: "What the tool does" # Required + inputs: # Optional parameters + param1: + type: string + required: true + description: "Parameter description" + param2: + type: number + default: 10 + script: | # JavaScript implementation + // Your code here + env: # Environment variables + API_KEY: "${{ secrets.API_KEY }}" +``` + +### Required Fields + +- **`description:`** - Human-readable description of what the tool does. This is shown to the agent for tool selection. + +### Implementation Options + +Choose one implementation method: + +- **`script:`** - JavaScript (CommonJS) code +- **`run:`** - Shell script + +You cannot use both `script:` and `run:` in the same tool. + +## JavaScript Tools (`script:`) + +JavaScript tools are automatically wrapped in an async function with destructured inputs. Write simple code without worrying about exports: + +```yaml wrap +safe-inputs: + calculate-sum: + description: "Add two numbers" + inputs: + a: + type: number + required: true + b: + type: number + required: true + script: | + const result = a + b; + return { sum: result }; +``` + +### Generated Code Structure + +Your script is wrapped automatically: + +```javascript +async function execute(inputs) { + const { a, b } = inputs || {}; + + // Your code here + const result = a + b; + return { sum: result }; +} +module.exports = { execute }; +``` + +### Accessing Environment Variables + +Access secrets via `process.env`: + +```yaml wrap +safe-inputs: + fetch-data: + description: "Fetch data from API" + inputs: + endpoint: + type: string + required: true + script: | + const apiKey = process.env.API_KEY; + const response = await fetch(`https://api.example.com/${endpoint}`, { + headers: { Authorization: `Bearer ${apiKey}` } + }); + return await response.json(); + env: + API_KEY: "${{ secrets.API_KEY }}" +``` + +### Async Operations + +Scripts are async by default. Use `await` freely: + +```yaml wrap +safe-inputs: + slow-operation: + description: "Perform async operation" + script: | + await new Promise(resolve => setTimeout(resolve, 1000)); + return { status: "completed" }; +``` + +## Shell Tools (`run:`) + +Shell scripts execute in bash with input parameters as environment variables: + +```yaml wrap +safe-inputs: + list-prs: + description: "List pull requests" + inputs: + repo: + type: string + required: true + state: + type: string + default: "open" + run: | + gh pr list --repo "$INPUT_REPO" --state "$INPUT_STATE" --json number,title + env: + GH_TOKEN: "${{ secrets.GITHUB_TOKEN }}" +``` + +### Input Variable Naming + +Input parameters are converted to environment variables: +- `repo` → `INPUT_REPO` +- `state` → `INPUT_STATE` +- `my-param` → `INPUT_MY_PARAM` + +### Using gh CLI + +Shell scripts can use the GitHub CLI when `GH_TOKEN` is provided: + +```yaml wrap +safe-inputs: + search-issues: + description: "Search issues in a repository" + inputs: + query: + type: string + required: true + run: | + gh issue list --search "$INPUT_QUERY" --json number,title,state + env: + GH_TOKEN: "${{ secrets.GITHUB_TOKEN }}" +``` + +## Input Parameters + +Define typed parameters with validation: + +```yaml wrap +safe-inputs: + example-tool: + description: "Example with all input options" + inputs: + required-param: + type: string + required: true + description: "This parameter is required" + optional-param: + type: number + default: 42 + description: "This has a default value" + choice-param: + type: string + enum: ["option1", "option2", "option3"] + description: "Limited to specific values" +``` + +### Supported Types + +- `string` - Text values +- `number` - Numeric values +- `boolean` - True/false values +- `array` - List of values +- `object` - Structured data + +### Validation Options + +- `required: true` - Parameter must be provided +- `default: value` - Default if not provided +- `enum: [...]` - Restrict to specific values +- `description: "..."` - Help text for the agent + +## Environment Variables (`env:`) + +Pass secrets and configuration to tools: + +```yaml wrap +safe-inputs: + secure-tool: + description: "Tool with multiple secrets" + script: | + const { API_KEY, API_SECRET } = process.env; + // Use secrets... + env: + API_KEY: "${{ secrets.SERVICE_API_KEY }}" + API_SECRET: "${{ secrets.SERVICE_API_SECRET }}" + CUSTOM_VAR: "static-value" +``` + +Environment variables are: +- Passed securely to the MCP server process +- Available in JavaScript via `process.env` +- Available in shell via `$VAR_NAME` +- Masked in logs when using `${{ secrets.* }}` + +## Large Output Handling + +When tool output exceeds 500 characters, it's automatically saved to a file: + +```json +{ + "status": "output_saved_to_file", + "file_path": "/tmp/gh-aw/safe-inputs/calls/call_1732831234567_1.txt", + "file_size_bytes": 2500, + "file_size_chars": 2500, + "message": "Output was too large. Read the file for full content.", + "json_schema_preview": "{\"type\": \"array\", \"length\": 50, ...}" +} +``` + +The agent receives: +- File path to read the full output +- File size information +- JSON schema preview (if output is valid JSON) + +## Importing Safe Inputs + +Import tools from shared workflows: + +```yaml wrap +imports: + - shared/github-tools.md +``` + +**Shared workflow (`shared/github-tools.md`):** + +```yaml wrap +--- +safe-inputs: + fetch-pr-data: + description: "Fetch PR data from GitHub" + inputs: + repo: + type: string + search: + type: string + run: | + gh pr list --repo "$INPUT_REPO" --search "$INPUT_SEARCH" --json number,title,state + env: + GH_TOKEN: "${{ secrets.GITHUB_TOKEN }}" +--- +``` + +Tools from imported workflows are merged with local definitions. Local tools take precedence on name conflicts. + +## Complete Example + +A workflow using multiple safe-input tools: + +```yaml wrap +--- +on: workflow_dispatch +engine: copilot +imports: + - shared/pr-data-safe-input.md +safe-inputs: + analyze-text: + description: "Analyze text and return statistics" + inputs: + text: + type: string + required: true + script: | + const words = text.split(/\s+/).filter(w => w.length > 0); + const chars = text.length; + const sentences = text.split(/[.!?]+/).filter(s => s.trim().length > 0); + return { + word_count: words.length, + char_count: chars, + sentence_count: sentences.length, + avg_word_length: (chars / words.length).toFixed(2) + }; + + format-date: + description: "Format a date string" + inputs: + date: + type: string + required: true + format: + type: string + default: "ISO" + enum: ["ISO", "US", "EU"] + script: | + const d = new Date(date); + switch (format) { + case "US": return { formatted: d.toLocaleDateString("en-US") }; + case "EU": return { formatted: d.toLocaleDateString("en-GB") }; + default: return { formatted: d.toISOString() }; + } +safe-outputs: + create-discussion: + category: "General" +--- + +# Text Analysis Workflow + +Analyze provided text and create a discussion with the results. + +Use the `analyze-text` tool to get text statistics. +Use the `fetch-pr-data` tool to get PR information if needed. +``` + +## Security Considerations + +- **Secret Isolation**: Each tool only receives the secrets specified in its `env:` field +- **Process Isolation**: Tools run in separate processes, isolated from the main workflow +- **Output Sanitization**: Large outputs are saved to files to prevent context overflow +- **No Arbitrary Execution**: Only predefined tools are available to the agent + +## Comparison with Other Options + +| Feature | Safe Inputs | Custom MCP Servers | Bash Tool | +|---------|-------------|-------------------|-----------| +| Setup | Inline in frontmatter | External service | Simple commands | +| Languages | JavaScript, Shell | Any language | Shell only | +| Secret Access | Controlled via `env:` | Full access | Workflow env | +| Isolation | Process-level | Service-level | None | +| Best For | Custom logic | Complex integrations | Simple commands | + +## Troubleshooting + +### Tool Not Found + +Ensure the tool name in `safe-inputs:` matches exactly what the agent calls. + +### Script Errors + +Check the workflow logs for JavaScript syntax errors. The MCP server logs detailed error messages. + +### Secret Not Available + +Verify the secret name in `env:` matches a secret in your repository or organization. + +### Large Output Issues + +If outputs are truncated, the agent should read the file path provided in the response. + +## Related Documentation + +- [Tools](/gh-aw/reference/tools/) - Other tool configuration options +- [Imports](/gh-aw/reference/imports/) - Importing shared workflows +- [Safe Outputs](/gh-aw/reference/safe-outputs/) - Automated post-workflow actions +- [MCPs](/gh-aw/guides/mcps/) - External MCP server integration +- [Custom Safe Output Jobs](/gh-aw/guides/custom-safe-outputs/) - Post-workflow custom jobs From 22c6abc17896c1fbed41878f0c89d0e294ef8cf6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 30 Nov 2025 14:43:55 +0000 Subject: [PATCH 11/11] Format, lint, and fix all tests Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/dev.lock.yml | 36 +++++++++++++++++----------------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/.github/workflows/dev.lock.yml b/.github/workflows/dev.lock.yml index 884669627c..36d90658ac 100644 --- a/.github/workflows/dev.lock.yml +++ b/.github/workflows/dev.lock.yml @@ -1480,18 +1480,18 @@ jobs: function registerTool(name, description, inputSchema, handler) { tools[name] = { name, description, inputSchema, handler }; } - registerTool("test-js-string", "Test JavaScript string operations", {"properties":{"text":{"description":"Input text","type":"string"}},"required":["text"],"type":"object"}, async (args) => { + registerTool("test-js-math", "Test JavaScript math operations", {"properties":{"a":{"description":"First number","type":"number"},"b":{"description":"Second number","type":"number"}},"required":["a","b"],"type":"object"}, async (args) => { try { - const toolModule = require("./test-js-string.cjs"); + const toolModule = require("./test-js-math.cjs"); const result = await toolModule.execute(args || {}); return { content: [{ type: "text", text: typeof result === "string" ? result : JSON.stringify(result, null, 2) }] }; } catch (error) { return { content: [{ type: "text", text: "Error: " + (error instanceof Error ? error.message : String(error)) }], isError: true }; } }); - registerTool("test-js-math", "Test JavaScript math operations", {"properties":{"a":{"description":"First number","type":"number"},"b":{"description":"Second number","type":"number"}},"required":["a","b"],"type":"object"}, async (args) => { + registerTool("test-js-string", "Test JavaScript string operations", {"properties":{"text":{"description":"Input text","type":"string"}},"required":["text"],"type":"object"}, async (args) => { try { - const toolModule = require("./test-js-math.cjs"); + const toolModule = require("./test-js-string.cjs"); const result = await toolModule.execute(args || {}); return { content: [{ type: "text", text: typeof result === "string" ? result : JSON.stringify(result, null, 2) }] }; } catch (error) { @@ -1501,9 +1501,6 @@ jobs: registerTool("fetch-pr-data", "Fetches pull request data from GitHub using gh CLI. Returns JSON array of PRs with fields: number, title, author, headRefName, createdAt, state, url, body, labels, updatedAt, closedAt, mergedAt", {"properties":{"days":{"default":30,"description":"Number of days to look back (default: 30)","type":"number"},"limit":{"default":100,"description":"Maximum number of PRs to fetch (default: 100)","type":"number"},"repo":{"description":"Repository in owner/repo format (defaults to current repository)","type":"string"},"search":{"description":"Search query for filtering PRs (e.g., 'head:copilot/' for Copilot PRs)","type":"string"},"state":{"default":"all","description":"PR state filter: open, closed, merged, or all (default: all)","type":"string"}},"type":"object"}, async (args) => { try { const env = { ...process.env }; - if (args && args["days"] !== undefined) { - env["INPUT_DAYS"] = typeof args["days"] === "object" ? JSON.stringify(args["days"]) : String(args["days"]); - } if (args && args["limit"] !== undefined) { env["INPUT_LIMIT"] = typeof args["limit"] === "object" ? JSON.stringify(args["limit"]) : String(args["limit"]); } @@ -1516,6 +1513,9 @@ jobs: if (args && args["state"] !== undefined) { env["INPUT_STATE"] = typeof args["state"] === "object" ? JSON.stringify(args["state"]) : String(args["state"]); } + if (args && args["days"] !== undefined) { + env["INPUT_DAYS"] = typeof args["days"] === "object" ? JSON.stringify(args["days"]) : String(args["days"]); + } const scriptPath = path.join(__dirname, "fetch-pr-data.sh"); const { stdout, stderr } = await execFileAsync("bash", [scriptPath], { env }); const output = stdout + (stderr ? "\nStderr: " + stderr : ""); @@ -1632,6 +1632,17 @@ jobs: }); EOFSI chmod +x /tmp/gh-aw/safe-inputs/mcp-server.cjs + cat > /tmp/gh-aw/safe-inputs/test-js-string.cjs << 'EOFJS_test-js-string' + async function execute(inputs) { + const { text } = inputs || {}; + return { + original: text, + uppercase: text.toUpperCase(), + length: text.length + }; + } + module.exports = { execute }; + EOFJS_test-js-string cat > /tmp/gh-aw/safe-inputs/fetch-pr-data.sh << 'EOFSH_fetch-pr-data' #!/bin/bash # Auto-generated safe-input tool: fetch-pr-data @@ -1665,17 +1676,6 @@ jobs: EOFSH_fetch-pr-data chmod +x /tmp/gh-aw/safe-inputs/fetch-pr-data.sh - cat > /tmp/gh-aw/safe-inputs/test-js-string.cjs << 'EOFJS_test-js-string' - async function execute(inputs) { - const { text } = inputs || {}; - return { - original: text, - uppercase: text.toUpperCase(), - length: text.length - }; - } - module.exports = { execute }; - EOFJS_test-js-string cat > /tmp/gh-aw/safe-inputs/test-js-math.cjs << 'EOFJS_test-js-math' async function execute(inputs) { const { a, b } = inputs || {};