diff --git a/.github/workflows/daily-team-status.lock.yml b/.github/workflows/daily-team-status.lock.yml index 0266dc0290d..8437d7d22a2 100644 --- a/.github/workflows/daily-team-status.lock.yml +++ b/.github/workflows/daily-team-status.lock.yml @@ -58,7 +58,7 @@ jobs: comment_repo: "" steps: - name: Setup Activation Scripts - uses: githubnext/gh-aw/actions/setup-activation@6ccc3d3 + uses: githubnext/gh-aw/actions/setup-activation@0d13659-dirty with: destination: /tmp/gh-aw/actions/activation - name: Check workflow file timestamps @@ -6473,7 +6473,7 @@ jobs: activated: ${{ steps.check_stop_time.outputs.stop_time_ok == 'true' }} steps: - name: Setup Activation Scripts - uses: githubnext/gh-aw/actions/setup-activation@6ccc3d3 + uses: githubnext/gh-aw/actions/setup-activation@0d13659-dirty with: destination: /tmp/gh-aw/actions/activation - name: Check stop-time limit diff --git a/.gitignore b/.gitignore index d6c4bd452e3..7c8ef90e4b9 100644 --- a/.gitignore +++ b/.gitignore @@ -118,4 +118,7 @@ test-runs/ gosec-report.json gosec-results.sarif govulncheck-results.sarif -trivy-results.sarif \ No newline at end of file +trivy-results.sarif + +# Generated action files +actions/setup-safe-outputs/js/ diff --git a/actions/setup-safe-outputs/README.md b/actions/setup-safe-outputs/README.md index d751077a268..c17527289eb 100644 --- a/actions/setup-safe-outputs/README.md +++ b/actions/setup-safe-outputs/README.md @@ -47,10 +47,10 @@ steps: ## Development -This action is built from source files in `src/` using the build tooling: +This action uses a bash script to copy JavaScript files from the `js/` directory. The files are generated during the build process: ```bash make actions-build ``` -The build process embeds all required JavaScript files into the bundled `index.js`. +The build process copies all required JavaScript files from `pkg/workflow/js/` to `actions/setup-safe-outputs/js/`, and the bash script (`copy-files.sh`) copies them to the destination at runtime. diff --git a/actions/setup-safe-outputs/action.yml b/actions/setup-safe-outputs/action.yml index da2e38429fb..bcf1ad8f614 100644 --- a/actions/setup-safe-outputs/action.yml +++ b/actions/setup-safe-outputs/action.yml @@ -13,8 +13,13 @@ outputs: description: 'Number of files copied' runs: - using: 'node20' - main: 'index.js' + using: 'composite' + steps: + - name: Copy safe-outputs files + shell: bash + run: ${{ github.action_path }}/copy-files.sh + env: + INPUT_DESTINATION: ${{ inputs.destination }} branding: icon: 'copy' diff --git a/actions/setup-safe-outputs/copy-files.sh b/actions/setup-safe-outputs/copy-files.sh new file mode 100755 index 00000000000..39fd9e6e07c --- /dev/null +++ b/actions/setup-safe-outputs/copy-files.sh @@ -0,0 +1,48 @@ +#!/bin/bash +# Safe Outputs Copy Action +# Copies safe-outputs MCP server files to the agent environment + +set -e + +# Get destination from input or use default +DESTINATION="${INPUT_DESTINATION:-/tmp/gh-aw/safeoutputs}" + +echo "Copying safe-outputs files to ${DESTINATION}" + +# Create destination directory if it doesn't exist +mkdir -p "${DESTINATION}" +echo "Created directory: ${DESTINATION}" + +# Define the list of files to copy +FILES=( + "safe_outputs_mcp_server.cjs" + "safe_outputs_bootstrap.cjs" + "safe_outputs_tools_loader.cjs" + "safe_outputs_config.cjs" + "safe_outputs_handlers.cjs" + "safe_outputs_tools.json" + "mcp_server_core.cjs" + "mcp_logger.cjs" + "messages.cjs" +) + +# Source directory for the JavaScript files +# When running in GitHub Actions, these files are in the workflow/js directory +SOURCE_DIR="${GITHUB_ACTION_PATH}/js" + +FILE_COUNT=0 + +# Copy each file +for file in "${FILES[@]}"; do + if [ -f "${SOURCE_DIR}/${file}" ]; then + cp "${SOURCE_DIR}/${file}" "${DESTINATION}/${file}" + echo "Copied: ${file}" + FILE_COUNT=$((FILE_COUNT + 1)) + else + echo "Warning: File not found: ${SOURCE_DIR}/${file}" + fi +done + +# Set output +echo "files-copied=${FILE_COUNT}" >> "${GITHUB_OUTPUT}" +echo "✓ Successfully copied ${FILE_COUNT} files" diff --git a/actions/setup-safe-outputs/index.js b/actions/setup-safe-outputs/index.js deleted file mode 100644 index 0ee26ddc569..00000000000 --- a/actions/setup-safe-outputs/index.js +++ /dev/null @@ -1,51 +0,0 @@ -// Safe Outputs Copy Action -// Copies safe-outputs MCP server files to the agent environment - -const core = require('@actions/core'); -const fs = require('fs'); -const path = require('path'); - -// Embedded safe-outputs files will be inserted here during build -const FILES = { - "mcp_logger.cjs": "// @ts-check\n/// \u003creference types=\"@actions/github-script\" /\u003e\n\n/**\n * MCP Logger Utility\n *\n * This module provides logger creation utilities for MCP servers.\n * It creates logger objects with debug and debugError methods that write\n * timestamped messages to stderr.\n *\n * Usage:\n * const { createLogger } = require(\"./mcp_logger.cjs\");\n * const logger = createLogger(\"my-server\");\n * logger.debug(\"Server started\");\n * logger.debugError(\"Error: \", new Error(\"Something went wrong\"));\n */\n\n/**\n * Create a logger object with debug and debugError methods\n * @param {string} serverName - Name to include in log messages\n * @returns {Object} Logger object with debug and debugError methods\n */\nfunction createLogger(serverName) {\n const logger = {\n /**\n * Log a debug message to stderr with timestamp\n * @param {string} msg - Message to log\n */\n debug: msg =\u003e {\n const timestamp = new Date().toISOString();\n process.stderr.write(`[] [] \\n`);\n },\n\n /**\n * Log an error with optional stack trace\n * @param {string} prefix - Prefix for the error message\n * @param {Error|string|any} error - Error object or message\n */\n debugError: (prefix, error) =\u003e {\n const errorMessage = error instanceof Error ? error.message : String(error);\n logger.debug(``);\n if (error instanceof Error \u0026\u0026 error.stack) {\n logger.debug(`Stack trace: ${error.stack}`);\n }\n },\n };\n\n return logger;\n}\n\nmodule.exports = {\n createLogger,\n};\n", - "mcp_server_core.cjs": "// @ts-check\n/// \u003creference types=\"@actions/github-script\" /\u003e\n\n/**\n * MCP Server Core Module\n *\n * This module provides a reusable API for creating MCP (Model Context Protocol) servers.\n * It handles JSON-RPC 2.0 message parsing, tool registration, and server lifecycle.\n *\n * Usage:\n * const { createServer, registerTool, start } = require(\"./mcp_server_core.cjs\");\n *\n * const server = createServer({ name: \"my-server\", version: \"1.0.0\" });\n * registerTool(server, {\n * name: \"my_tool\",\n * description: \"A tool\",\n * inputSchema: { type: \"object\", properties: {} },\n * handler: (args) =\u003e ({ content: [{ type: \"text\", text: \"result\" }] })\n * });\n * start(server);\n */\n\nconst fs = require(\"fs\");\nconst path = require(\"path\");\n\nconst { ReadBuffer } = require(\"./read_buffer.cjs\");\nconst { validateRequiredFields } = require(\"./safe_inputs_validation.cjs\");\n\nconst encoder = new TextEncoder();\n\n/**\n * @typedef {Object} ServerInfo\n * @property {string} name - Server name\n * @property {string} version - Server version\n */\n\n/**\n * @typedef {Object} Tool\n * @property {string} name - Tool name\n * @property {string} description - Tool description\n * @property {Object} inputSchema - JSON Schema for tool inputs\n * @property {Function} [handler] - Tool handler function\n * @property {string} [handlerPath] - Optional file path to handler module (original path from config)\n * @property {number} [timeout] - Timeout in seconds for tool execution (default: 60)\n */\n\n/**\n * @typedef {Object} MCPServer\n * @property {ServerInfo} serverInfo - Server information\n * @property {Object\u003cstring, Tool\u003e} tools - Registered tools\n * @property {Function} debug - Debug logging function\n * @property {Function} debugError - Debug logging function for errors (extracts message from Error objects)\n * @property {Function} writeMessage - Write message to stdout\n * @property {Function} replyResult - Send a result response\n * @property {Function} replyError - Send an error response\n * @property {ReadBuffer} readBuffer - Message buffer\n * @property {string} [logDir] - Optional log directory\n * @property {string} [logFilePath] - Optional log file path\n * @property {boolean} logFileInitialized - Whether log file has been initialized\n */\n\n/**\n * Initialize log file for the server\n * @param {MCPServer} server - The MCP server instance\n */\nfunction initLogFile(server) {\n if (server.logFileInitialized || !server.logDir || !server.logFilePath) return;\n try {\n if (!fs.existsSync(server.logDir)) {\n fs.mkdirSync(server.logDir, { recursive: true });\n }\n // Initialize/truncate log file with header\n const timestamp = new Date().toISOString();\n fs.writeFileSync(server.logFilePath, `# ${server.serverInfo.name} MCP Server Log\\n# Started: \\n# Version: ${server.serverInfo.version}\\n\\n`);\n server.logFileInitialized = true;\n } catch {\n // Silently ignore errors - logging to stderr will still work\n }\n}\n\n/**\n * Create a debug function for the server\n * @param {MCPServer} server - The MCP server instance\n * @returns {Function} Debug function\n */\nfunction createDebugFunction(server) {\n return msg =\u003e {\n const timestamp = new Date().toISOString();\n const formattedMsg = `[] [${server.serverInfo.name}] \\n`;\n\n // Always write to stderr\n process.stderr.write(formattedMsg);\n\n // Also write to log file if log directory is set (initialize on first use)\n if (server.logDir \u0026\u0026 server.logFilePath) {\n if (!server.logFileInitialized) {\n initLogFile(server);\n }\n if (server.logFileInitialized) {\n try {\n fs.appendFileSync(server.logFilePath, formattedMsg);\n } catch {\n // Silently ignore file write errors - stderr logging still works\n }\n }\n }\n };\n}\n\n/**\n * Create a debugError function for the server that handles error casting\n * @param {MCPServer} server - The MCP server instance\n * @returns {Function} Debug error function that extracts message from Error objects\n */\nfunction createDebugErrorFunction(server) {\n return (prefix, error) =\u003e {\n const errorMessage = error instanceof Error ? error.message : String(error);\n server.debug(``);\n if (error instanceof Error \u0026\u0026 error.stack) {\n server.debug(`Stack trace: ${error.stack}`);\n }\n };\n}\n\n/**\n * Create a writeMessage function for the server\n * @param {MCPServer} server - The MCP server instance\n * @returns {Function} Write message function\n */\nfunction createWriteMessageFunction(server) {\n return obj =\u003e {\n const json = JSON.stringify(obj);\n server.debug(`send: `);\n const message = json + \"\\n\";\n const bytes = encoder.encode(message);\n fs.writeSync(1, bytes);\n };\n}\n\n/**\n * Create a replyResult function for the server\n * @param {MCPServer} server - The MCP server instance\n * @returns {Function} Reply result function\n */\nfunction createReplyResultFunction(server) {\n return (id, result) =\u003e {\n if (id === undefined || id === null) return; // notification\n const res = { jsonrpc: \"2.0\", id, result };\n server.writeMessage(res);\n };\n}\n\n/**\n * Create a replyError function for the server\n * @param {MCPServer} server - The MCP server instance\n * @returns {Function} Reply error function\n */\nfunction createReplyErrorFunction(server) {\n return (id, code, message) =\u003e {\n // Don't send error responses for notifications (id is null/undefined)\n if (id === undefined || id === null) {\n server.debug(`Error for notification: `);\n return;\n }\n\n const error = { code, message };\n const res = {\n jsonrpc: \"2.0\",\n id,\n error,\n };\n server.writeMessage(res);\n };\n}\n\n/**\n * Create a new MCP server instance\n * @param {ServerInfo} serverInfo - Server information (name and version)\n * @param {Object} [options] - Optional server configuration\n * @param {string} [options.logDir] - Directory for log file (optional)\n * @returns {MCPServer} The MCP server instance\n */\nfunction createServer(serverInfo, options = {}) {\n const logDir = options.logDir || undefined;\n const logFilePath = logDir ? path.join(logDir, \"server.log\") : undefined;\n\n /** @type {MCPServer} */\n const server = {\n serverInfo,\n tools: {},\n debug: () =\u003e {}, // placeholder\n debugError: () =\u003e {}, // placeholder\n writeMessage: () =\u003e {}, // placeholder\n replyResult: () =\u003e {}, // placeholder\n replyError: () =\u003e {}, // placeholder\n readBuffer: new ReadBuffer(),\n logDir,\n logFilePath,\n logFileInitialized: false,\n };\n\n // Initialize functions with references to server\n server.debug = createDebugFunction(server);\n server.debugError = createDebugErrorFunction(server);\n server.writeMessage = createWriteMessageFunction(server);\n server.replyResult = createReplyResultFunction(server);\n server.replyError = createReplyErrorFunction(server);\n\n return server;\n}\n\n/**\n * Create a wrapped handler function that normalizes results to MCP format.\n * Extracted to avoid creating closures with excessive scope in loadToolHandlers.\n *\n * @param {MCPServer} server - The MCP server instance for logging\n * @param {string} toolName - Name of the tool for logging purposes\n * @param {Function} handlerFn - The original handler function to wrap\n * @returns {Function} Wrapped async handler function\n */\nfunction createWrappedHandler(server, toolName, handlerFn) {\n return async args =\u003e {\n server.debug(` [] Invoking handler with args: ${JSON.stringify(args)}`);\n\n try {\n // Call the handler (may be sync or async)\n const result = await Promise.resolve(handlerFn(args));\n server.debug(` [] Handler returned result type: ${typeof result}`);\n\n // If the result is already in MCP format (has content array), return as-is\n if (result \u0026\u0026 typeof result === \"object\" \u0026\u0026 Array.isArray(result.content)) {\n server.debug(` [] Result is already in MCP format`);\n return result;\n }\n\n // Otherwise, serialize the result to text\n // Use try-catch for serialization to handle circular references and non-serializable values\n let serializedResult;\n try {\n serializedResult = JSON.stringify(result);\n } catch (serializationError) {\n server.debugError(` [] Serialization error: `, serializationError);\n // Fall back to String() for non-serializable values\n serializedResult = String(result);\n }\n server.debug(` [] Serialized result: ${serializedResult.substring(0, 200)}${serializedResult.length \u003e 200 ? \"...\" : \"\"}`);\n\n return {\n content: [\n {\n type: \"text\",\n text: serializedResult,\n },\n ],\n };\n } catch (error) {\n server.debugError(` [] Handler threw error: `, error);\n throw error;\n }\n };\n}\n\n/**\n * Load handler functions from file paths specified in tools configuration.\n * This function iterates through tools and loads handler modules based on file extension:\n *\n * For JavaScript handlers (.js, .cjs, .mjs):\n * - Uses require() to load the module\n * - Handler must export a function as default export\n * - Handler signature: async function handler(args: Record\u003cstring, unknown\u003e): Promise\u003cunknown\u003e\n *\n * For Shell script handlers (.sh):\n * - Uses GitHub Actions convention for passing inputs/outputs\n * - Inputs are passed as environment variables prefixed with INPUT_ (uppercased)\n * - Outputs are read from GITHUB_OUTPUT file (key=value format per line)\n * - Returns: { stdout, stderr, outputs }\n *\n * For Python script handlers (.py):\n * - Uses GitHub Actions convention for passing inputs/outputs\n * - Inputs are passed as environment variables prefixed with INPUT_ (uppercased)\n * - Outputs are read from GITHUB_OUTPUT file (key=value format per line)\n * - Executed using python3 command\n * - Returns: { stdout, stderr, outputs }\n *\n * SECURITY NOTE: Handler paths are loaded from tools.json configuration file,\n * which should be controlled by the server administrator. When basePath is provided,\n * relative paths are resolved within it, preventing directory traversal outside\n * the intended directory. Absolute paths bypass this validation but are still\n * logged for auditing purposes.\n *\n * @param {MCPServer} server - The MCP server instance for logging\n * @param {Array\u003cObject\u003e} tools - Array of tool configurations from tools.json\n * @param {string} [basePath] - Optional base path for resolving relative handler paths.\n * When provided, relative paths are validated to be within this directory.\n * @returns {Array\u003cObject\u003e} The tools array with loaded handlers attached\n */\nfunction loadToolHandlers(server, tools, basePath) {\n server.debug(`Loading tool handlers...`);\n server.debug(` Total tools to process: ${tools.length}`);\n server.debug(` Base path: ${basePath || \"(not specified)\"}`);\n\n let loadedCount = 0;\n let skippedCount = 0;\n let errorCount = 0;\n\n for (const tool of tools) {\n const toolName = tool.name || \"(unnamed)\";\n\n // Check if tool has a handler path specified\n if (!tool.handler) {\n server.debug(` [] No handler path specified, skipping handler load`);\n skippedCount++;\n continue;\n }\n\n const handlerPath = tool.handler;\n server.debug(` [] Handler path specified: `);\n\n // Resolve the handler path\n let resolvedPath = handlerPath;\n if (basePath \u0026\u0026 !path.isAbsolute(handlerPath)) {\n resolvedPath = path.resolve(basePath, handlerPath);\n server.debug(` [] Resolved relative path to: `);\n\n // Security validation: Ensure resolved path is within basePath to prevent directory traversal\n const normalizedBase = path.resolve(basePath);\n const normalizedResolved = path.resolve(resolvedPath);\n if (!normalizedResolved.startsWith(normalizedBase + path.sep) \u0026\u0026 normalizedResolved !== normalizedBase) {\n server.debug(` [] ERROR: Handler path escapes base directory: is not within `);\n errorCount++;\n continue;\n }\n } else if (path.isAbsolute(handlerPath)) {\n server.debug(` [] Using absolute path (bypasses basePath validation): `);\n }\n\n // Store the original handler path for reference\n tool.handlerPath = handlerPath;\n\n try {\n server.debug(` [] Loading handler from: `);\n\n // Check if file exists before loading\n if (!fs.existsSync(resolvedPath)) {\n server.debug(` [] ERROR: Handler file does not exist: `);\n errorCount++;\n continue;\n }\n\n // Detect handler type by file extension\n const ext = path.extname(resolvedPath).toLowerCase();\n server.debug(` [] Handler file extension: `);\n\n if (ext === \".sh\") {\n // Shell script handler - use GitHub Actions convention\n server.debug(` [] Detected shell script handler`);\n\n // Make sure the script is executable (on Unix-like systems)\n try {\n fs.accessSync(resolvedPath, fs.constants.X_OK);\n server.debug(` [] Shell script is executable`);\n } catch {\n // Try to make it executable\n try {\n fs.chmodSync(resolvedPath, 0o755);\n server.debug(` [] Made shell script executable`);\n } catch (chmodError) {\n server.debugError(` [] Warning: Could not make shell script executable: `, chmodError);\n // Continue anyway - it might work depending on the shell\n }\n }\n\n // Lazy-load shell handler module\n const { createShellHandler } = require(\"./mcp_handler_shell.cjs\");\n const timeout = tool.timeout || 60; // Default to 60 seconds if not specified\n tool.handler = createShellHandler(server, toolName, resolvedPath, timeout);\n\n loadedCount++;\n server.debug(` [] Shell handler created successfully with timeout: s`);\n } else if (ext === \".py\") {\n // Python script handler - use GitHub Actions convention\n server.debug(` [] Detected Python script handler`);\n\n // Make sure the script is executable (on Unix-like systems)\n try {\n fs.accessSync(resolvedPath, fs.constants.X_OK);\n server.debug(` [] Python script is executable`);\n } catch {\n // Try to make it executable\n try {\n fs.chmodSync(resolvedPath, 0o755);\n server.debug(` [] Made Python script executable`);\n } catch (chmodError) {\n server.debugError(` [] Warning: Could not make Python script executable: `, chmodError);\n // Continue anyway - python3 will be called explicitly\n }\n }\n\n // Lazy-load Python handler module\n const { createPythonHandler } = require(\"./mcp_handler_python.cjs\");\n const timeout = tool.timeout || 60; // Default to 60 seconds if not specified\n tool.handler = createPythonHandler(server, toolName, resolvedPath, timeout);\n\n loadedCount++;\n server.debug(` [] Python handler created successfully with timeout: s`);\n } else {\n // JavaScript/CommonJS handler - use require()\n server.debug(` [] Loading JavaScript handler module`);\n\n // Load the handler module\n const handlerModule = require(resolvedPath);\n server.debug(` [] Handler module loaded successfully`);\n server.debug(` [] Module type: ${typeof handlerModule}`);\n\n // Get the handler function (support default export patterns)\n let handlerFn = handlerModule;\n\n // Handle ES module default export pattern (module.default)\n if (handlerModule \u0026\u0026 typeof handlerModule === \"object\" \u0026\u0026 typeof handlerModule.default === \"function\") {\n handlerFn = handlerModule.default;\n server.debug(` [] Using module.default export`);\n }\n\n // Validate that the handler is a function\n if (typeof handlerFn !== \"function\") {\n server.debug(` [] ERROR: Handler is not a function, got: ${typeof handlerFn}`);\n server.debug(` [] Module keys: ${Object.keys(handlerModule || {}).join(\", \") || \"(none)\"}`);\n errorCount++;\n continue;\n }\n\n server.debug(` [] Handler function validated successfully`);\n server.debug(` [] Handler function name: ${handlerFn.name || \"(anonymous)\"}`);\n\n // Wrap the handler using the separate function to avoid bloating the closure\n tool.handler = createWrappedHandler(server, toolName, handlerFn);\n\n loadedCount++;\n server.debug(` [] JavaScript handler loaded and wrapped successfully`);\n }\n } catch (error) {\n server.debugError(` [] ERROR loading handler: `, error);\n errorCount++;\n }\n }\n\n server.debug(`Handler loading complete:`);\n server.debug(` Loaded: `);\n server.debug(` Skipped (no handler path): `);\n server.debug(` Errors: `);\n\n return tools;\n}\n\n/**\n * Register a tool with the server\n * @param {MCPServer} server - The MCP server instance\n * @param {Tool} tool - The tool to register\n */\nfunction registerTool(server, tool) {\n const normalizedName = normalizeTool(tool.name);\n server.tools[normalizedName] = {\n ...tool,\n name: normalizedName,\n };\n server.debug(`Registered tool: `);\n}\n\n/**\n * Normalize a tool name (convert dashes to underscores, lowercase)\n * @param {string} name - The tool name to normalize\n * @returns {string} Normalized tool name\n */\nfunction normalizeTool(name) {\n return name.replace(/-/g, \"_\").toLowerCase();\n}\n\n/**\n * Handle an incoming JSON-RPC request and return a response (for HTTP transport)\n * This function is compatible with the MCPServer class's handleRequest method.\n * @param {MCPServer} server - The MCP server instance\n * @param {Object} request - The incoming JSON-RPC request\n * @param {Function} [defaultHandler] - Default handler for tools without a handler\n * @returns {Promise\u003cObject|null\u003e} JSON-RPC response object, or null for notifications\n */\nasync function handleRequest(server, request, defaultHandler) {\n const { id, method, params } = request;\n\n try {\n // Handle notifications per JSON-RPC 2.0 spec:\n // Requests without id field are notifications (no response)\n // Note: id can be null for valid requests, so we check for field presence with \"in\" operator\n if (!(\"id\" in request)) {\n // No id field - this is a notification (no response)\n return null;\n }\n\n let result;\n\n if (method === \"initialize\") {\n const protocolVersion = params?.protocolVersion || \"2024-11-05\";\n result = {\n protocolVersion,\n serverInfo: server.serverInfo,\n capabilities: {\n tools: {},\n },\n };\n } else if (method === \"ping\") {\n result = {};\n } else if (method === \"tools/list\") {\n const list = [];\n Object.values(server.tools).forEach(tool =\u003e {\n const toolDef = {\n name: tool.name,\n description: tool.description,\n inputSchema: tool.inputSchema,\n };\n list.push(toolDef);\n });\n result = { tools: list };\n } else if (method === \"tools/call\") {\n const name = params?.name;\n const args = params?.arguments ?? {};\n if (!name || typeof name !== \"string\") {\n throw {\n code: -32602,\n message: \"Invalid params: 'name' must be a string\",\n };\n }\n const tool = server.tools[normalizeTool(name)];\n if (!tool) {\n throw {\n code: -32602,\n message: `Tool '' not found`,\n };\n }\n\n // Use tool handler, or default handler, or error\n let handler = tool.handler;\n if (!handler \u0026\u0026 defaultHandler) {\n handler = defaultHandler(tool.name);\n }\n if (!handler) {\n throw {\n code: -32603,\n message: `No handler for tool: `,\n };\n }\n\n const missing = validateRequiredFields(args, tool.inputSchema);\n if (missing.length) {\n throw {\n code: -32602,\n message: `Invalid arguments: missing or empty ${missing.map(m =\u003e `''`).join(\", \")}`,\n };\n }\n\n // Call handler and await the result (supports both sync and async handlers)\n const handlerResult = await Promise.resolve(handler(args));\n const content = handlerResult \u0026\u0026 handlerResult.content ? handlerResult.content : [];\n result = { content, isError: false };\n } else if (/^notifications\\//.test(method)) {\n // Notifications don't need a response\n return null;\n } else {\n throw {\n code: -32601,\n message: `Method not found: `,\n };\n }\n\n return {\n jsonrpc: \"2.0\",\n id,\n result,\n };\n } catch (error) {\n /** @type {any} */\n const err = error;\n return {\n jsonrpc: \"2.0\",\n id,\n error: {\n code: err.code || -32603,\n message: err.message || \"Internal error\",\n },\n };\n }\n}\n\n/**\n * Handle an incoming JSON-RPC message (for stdio transport)\n * @param {MCPServer} server - The MCP server instance\n * @param {Object} req - The incoming request\n * @param {Function} [defaultHandler] - Default handler for tools without a handler\n * @returns {Promise\u003cvoid\u003e}\n */\nasync function handleMessage(server, req, defaultHandler) {\n // Validate basic JSON-RPC structure\n if (!req || typeof req !== \"object\") {\n server.debug(`Invalid message: not an object`);\n return;\n }\n\n if (req.jsonrpc !== \"2.0\") {\n server.debug(`Invalid message: missing or invalid jsonrpc field`);\n return;\n }\n\n const { id, method, params } = req;\n\n // Validate method field\n if (!method || typeof method !== \"string\") {\n server.replyError(id, -32600, \"Invalid Request: method must be a string\");\n return;\n }\n\n try {\n if (method === \"initialize\") {\n const clientInfo = params?.clientInfo ?? {};\n server.debug(`client info: ${JSON.stringify(clientInfo)}`);\n const protocolVersion = params?.protocolVersion ?? undefined;\n const result = {\n serverInfo: server.serverInfo,\n ...(protocolVersion ? { protocolVersion } : {}),\n capabilities: {\n tools: {},\n },\n };\n server.replyResult(id, result);\n } else if (method === \"tools/list\") {\n const list = [];\n Object.values(server.tools).forEach(tool =\u003e {\n const toolDef = {\n name: tool.name,\n description: tool.description,\n inputSchema: tool.inputSchema,\n };\n list.push(toolDef);\n });\n server.replyResult(id, { tools: list });\n } else if (method === \"tools/call\") {\n const name = params?.name;\n const args = params?.arguments ?? {};\n if (!name || typeof name !== \"string\") {\n server.replyError(id, -32602, \"Invalid params: 'name' must be a string\");\n return;\n }\n const tool = server.tools[normalizeTool(name)];\n if (!tool) {\n server.replyError(id, -32601, `Tool not found: (${normalizeTool(name)})`);\n return;\n }\n\n // Use tool handler, or default handler, or error\n let handler = tool.handler;\n if (!handler \u0026\u0026 defaultHandler) {\n handler = defaultHandler(tool.name);\n }\n if (!handler) {\n server.replyError(id, -32603, `No handler for tool: `);\n return;\n }\n\n const missing = validateRequiredFields(args, tool.inputSchema);\n if (missing.length) {\n server.replyError(id, -32602, `Invalid arguments: missing or empty ${missing.map(m =\u003e `''`).join(\", \")}`);\n return;\n }\n\n // Call handler and await the result (supports both sync and async handlers)\n server.debug(`Calling handler for tool: `);\n const result = await Promise.resolve(handler(args));\n server.debug(`Handler returned for tool: `);\n const content = result \u0026\u0026 result.content ? result.content : [];\n server.replyResult(id, { content, isError: false });\n } else if (/^notifications\\//.test(method)) {\n server.debug(`ignore `);\n } else {\n server.replyError(id, -32601, `Method not found: `);\n }\n } catch (e) {\n server.replyError(id, -32603, e instanceof Error ? e.message : String(e));\n }\n}\n\n/**\n * Process the read buffer and handle messages\n * @param {MCPServer} server - The MCP server instance\n * @param {Function} [defaultHandler] - Default handler for tools without a handler\n * @returns {Promise\u003cvoid\u003e}\n */\nasync function processReadBuffer(server, defaultHandler) {\n while (true) {\n try {\n const message = server.readBuffer.readMessage();\n if (!message) {\n break;\n }\n server.debug(`recv: ${JSON.stringify(message)}`);\n await handleMessage(server, message, defaultHandler);\n } catch (error) {\n // For parse errors, we can't know the request id, so we shouldn't send a response\n // according to JSON-RPC spec. Just log the error.\n server.debug(`Parse error: ${error instanceof Error ? error.message : String(error)}`);\n }\n }\n}\n\n/**\n * Start the MCP server on stdio\n * @param {MCPServer} server - The MCP server instance\n * @param {Object} [options] - Start options\n * @param {Function} [options.defaultHandler] - Default handler for tools without a handler\n */\nfunction start(server, options = {}) {\n const { defaultHandler } = options;\n\n server.debug(`v${server.serverInfo.version} ready on stdio`);\n server.debug(` tools: ${Object.keys(server.tools).join(\", \")}`);\n\n if (!Object.keys(server.tools).length) {\n throw new Error(\"No tools registered\");\n }\n\n const onData = async chunk =\u003e {\n server.readBuffer.append(chunk);\n await processReadBuffer(server, defaultHandler);\n };\n\n process.stdin.on(\"data\", onData);\n process.stdin.on(\"error\", err =\u003e server.debug(`stdin error: `));\n process.stdin.resume();\n server.debug(`listening...`);\n}\n\nmodule.exports = {\n createServer,\n registerTool,\n normalizeTool,\n handleRequest,\n handleMessage,\n processReadBuffer,\n start,\n loadToolHandlers,\n};\n", - "messages.cjs": "// @ts-check\n/// \u003creference types=\"@actions/github-script\" /\u003e\n\n/**\n * Safe Output Messages Module (Barrel File)\n *\n * This module re-exports all message functions from the modular message files.\n * It provides backward compatibility for existing code that imports from messages.cjs.\n *\n * For new code, prefer importing directly from the specific modules:\n * - ./messages_core.cjs - Core utilities (getMessages, renderTemplate, toSnakeCase)\n * - ./messages_footer.cjs - Footer messages (getFooterMessage, getFooterInstallMessage, generateFooterWithMessages)\n * - ./messages_staged.cjs - Staged mode messages (getStagedTitle, getStagedDescription)\n * - ./messages_run_status.cjs - Run status messages (getRunStartedMessage, getRunSuccessMessage, getRunFailureMessage)\n * - ./messages_close_discussion.cjs - Close discussion messages (getCloseOlderDiscussionMessage)\n *\n * Supported placeholders:\n * - {workflow_name} - Name of the workflow\n * - {run_url} - URL to the workflow run\n * - {workflow_source} - Source specification (owner/repo/path@ref)\n * - {workflow_source_url} - GitHub URL for the workflow source\n * - {triggering_number} - Issue/PR/Discussion number that triggered this workflow\n * - {operation} - Operation name (for staged mode titles/descriptions)\n * - {event_type} - Event type description (for run-started messages)\n * - {status} - Workflow status text (for run-failure messages)\n *\n * Both camelCase and snake_case placeholder formats are supported.\n */\n\n// Re-export core utilities\nconst { getMessages, renderTemplate } = require(\"./messages_core.cjs\");\n\n// Re-export footer messages\nconst { getFooterMessage, getFooterInstallMessage, generateFooterWithMessages, generateXMLMarker } = require(\"./messages_footer.cjs\");\n\n// Re-export staged mode messages\nconst { getStagedTitle, getStagedDescription } = require(\"./messages_staged.cjs\");\n\n// Re-export run status messages\nconst { getRunStartedMessage, getRunSuccessMessage, getRunFailureMessage } = require(\"./messages_run_status.cjs\");\n\n// Re-export close discussion messages\nconst { getCloseOlderDiscussionMessage } = require(\"./messages_close_discussion.cjs\");\n\nmodule.exports = {\n getMessages,\n renderTemplate,\n getFooterMessage,\n getFooterInstallMessage,\n generateFooterWithMessages,\n generateXMLMarker,\n getStagedTitle,\n getStagedDescription,\n getRunStartedMessage,\n getRunSuccessMessage,\n getRunFailureMessage,\n getCloseOlderDiscussionMessage,\n};\n", - "safe_outputs_bootstrap.cjs": "// @ts-check\n\n/**\n * Safe Outputs Bootstrap Module\n *\n * This module provides shared bootstrap logic for safe-outputs MCP server.\n * It handles configuration loading, tools loading, and cleanup that is\n * common initialization logic.\n *\n * Usage:\n * const { bootstrapSafeOutputsServer } = require(\"./safe_outputs_bootstrap.cjs\");\n * const { config, outputFile, tools } = bootstrapSafeOutputsServer(server);\n */\n\nconst fs = require(\"fs\");\nconst { loadConfig } = require(\"./safe_outputs_config.cjs\");\nconst { loadTools } = require(\"./safe_outputs_tools_loader.cjs\");\n\n/**\n * @typedef {Object} Logger\n * @property {Function} debug - Debug logging function\n * @property {Function} debugError - Error logging function\n */\n\n/**\n * @typedef {Object} BootstrapResult\n * @property {Object} config - Loaded configuration\n * @property {string} outputFile - Path to the output file\n * @property {Array} tools - Loaded tool definitions\n */\n\n/**\n * Bootstrap a safe-outputs server by loading configuration and tools.\n * This function performs the common initialization steps.\n *\n * @param {Logger} logger - Logger instance for debug messages\n * @returns {BootstrapResult} Configuration, output file path, and loaded tools\n */\nfunction bootstrapSafeOutputsServer(logger) {\n // Load configuration\n logger.debug(\"Loading safe-outputs configuration\");\n const { config, outputFile } = loadConfig(logger);\n\n // Load tools\n logger.debug(\"Loading safe-outputs tools\");\n const tools = loadTools(logger);\n\n return { config, outputFile, tools };\n}\n\n/**\n * Delete the configuration file to ensure no secrets remain on disk.\n * This should be called after the server has been configured and started.\n *\n * @param {Logger} logger - Logger instance for debug messages\n */\nfunction cleanupConfigFile(logger) {\n const configPath = process.env.GH_AW_SAFE_OUTPUTS_CONFIG_PATH || \"/tmp/gh-aw/safeoutputs/config.json\";\n\n try {\n if (fs.existsSync(configPath)) {\n fs.unlinkSync(configPath);\n logger.debug(`Deleted configuration file: `);\n }\n } catch (error) {\n logger.debugError(\"Warning: Could not delete configuration file: \", error);\n // Continue anyway - the server is already running\n }\n}\n\nmodule.exports = {\n bootstrapSafeOutputsServer,\n cleanupConfigFile,\n};\n", - "safe_outputs_config.cjs": "// @ts-check\n\nconst fs = require(\"fs\");\nconst path = require(\"path\");\n\n/**\n * Load and process safe outputs configuration\n * @param {Object} server - The MCP server instance for logging\n * @returns {Object} An object containing the processed config and output file path\n */\nfunction loadConfig(server) {\n // Read configuration from file\n const configPath = process.env.GH_AW_SAFE_OUTPUTS_CONFIG_PATH || \"/tmp/gh-aw/safeoutputs/config.json\";\n let safeOutputsConfigRaw;\n\n server.debug(`Reading config from file: `);\n\n try {\n if (fs.existsSync(configPath)) {\n server.debug(`Config file exists at: `);\n const configFileContent = fs.readFileSync(configPath, \"utf8\");\n server.debug(`Config file content length: ${configFileContent.length} characters`);\n // Don't log raw content to avoid exposing sensitive configuration data\n server.debug(`Config file read successfully, attempting to parse JSON`);\n safeOutputsConfigRaw = JSON.parse(configFileContent);\n server.debug(`Successfully parsed config from file with ${Object.keys(safeOutputsConfigRaw).length} configuration keys`);\n } else {\n server.debug(`Config file does not exist at: `);\n server.debug(`Using minimal default configuration`);\n safeOutputsConfigRaw = {};\n }\n } catch (error) {\n server.debug(`Error reading config file: ${error instanceof Error ? error.message : String(error)}`);\n server.debug(`Falling back to empty configuration`);\n safeOutputsConfigRaw = {};\n }\n\n const safeOutputsConfig = Object.fromEntries(Object.entries(safeOutputsConfigRaw).map(([k, v]) =\u003e [k.replace(/-/g, \"_\"), v]));\n server.debug(`Final processed config: ${JSON.stringify(safeOutputsConfig)}`);\n\n // Handle GH_AW_SAFE_OUTPUTS with default fallback\n const outputFile = process.env.GH_AW_SAFE_OUTPUTS || \"/tmp/gh-aw/safeoutputs/outputs.jsonl\";\n if (!process.env.GH_AW_SAFE_OUTPUTS) {\n server.debug(`GH_AW_SAFE_OUTPUTS not set, using default: `);\n }\n // Always ensure the directory exists, regardless of whether env var is set\n const outputDir = path.dirname(outputFile);\n if (!fs.existsSync(outputDir)) {\n server.debug(`Creating output directory: `);\n fs.mkdirSync(outputDir, { recursive: true });\n }\n\n return {\n config: safeOutputsConfig,\n outputFile: outputFile,\n };\n}\n\nmodule.exports = { loadConfig };\n", - "safe_outputs_handlers.cjs": "// @ts-check\n\nconst fs = require(\"fs\");\nconst path = require(\"path\");\nconst crypto = require(\"crypto\");\n\nconst { normalizeBranchName } = require(\"./normalize_branch_name.cjs\");\nconst { estimateTokens } = require(\"./estimate_tokens.cjs\");\nconst { writeLargeContentToFile } = require(\"./write_large_content_to_file.cjs\");\nconst { getCurrentBranch } = require(\"./get_current_branch.cjs\");\nconst { getBaseBranch } = require(\"./get_base_branch.cjs\");\nconst { generateGitPatch } = require(\"./generate_git_patch.cjs\");\n\n/**\n * Create handlers for safe output tools\n * @param {Object} server - The MCP server instance for logging\n * @param {Function} appendSafeOutput - Function to append entries to the output file\n * @param {Object} [config] - Optional configuration object with safe output settings\n * @returns {Object} An object containing all handler functions\n */\nfunction createHandlers(server, appendSafeOutput, config = {}) {\n /**\n * Default handler for safe output tools\n * @param {string} type - The tool type\n * @returns {Function} Handler function\n */\n const defaultHandler = type =\u003e args =\u003e {\n const entry = { ...(args || {}), type };\n\n // Check if any field in the entry has content exceeding 16000 tokens\n let largeContent = null;\n let largeFieldName = null;\n const TOKEN_THRESHOLD = 16000;\n\n for (const [key, value] of Object.entries(entry)) {\n if (typeof value === \"string\") {\n const tokens = estimateTokens(value);\n if (tokens \u003e TOKEN_THRESHOLD) {\n largeContent = value;\n largeFieldName = key;\n server.debug(`Field '' has tokens (exceeds )`);\n break;\n }\n }\n }\n\n if (largeContent \u0026\u0026 largeFieldName) {\n // Write large content to file\n const fileInfo = writeLargeContentToFile(largeContent);\n\n // Replace large field with file reference\n entry[largeFieldName] = `[Content too large, saved to file: ${fileInfo.filename}]`;\n\n // Append modified entry to safe outputs\n appendSafeOutput(entry);\n\n // Return file info to the agent\n return {\n content: [\n {\n type: \"text\",\n text: JSON.stringify(fileInfo),\n },\n ],\n };\n }\n\n // Normal case - no large content\n appendSafeOutput(entry);\n return {\n content: [\n {\n type: \"text\",\n text: JSON.stringify({ result: \"success\" }),\n },\n ],\n };\n };\n\n /**\n * Handler for upload_asset tool\n */\n const uploadAssetHandler = args =\u003e {\n const branchName = process.env.GH_AW_ASSETS_BRANCH;\n if (!branchName) throw new Error(\"GH_AW_ASSETS_BRANCH not set\");\n\n // Normalize the branch name to ensure it's a valid git branch name\n const normalizedBranchName = normalizeBranchName(branchName);\n\n const { path: filePath } = args;\n\n // Validate file path is within allowed directories\n const absolutePath = path.resolve(filePath);\n const workspaceDir = process.env.GITHUB_WORKSPACE || process.cwd();\n const tmpDir = \"/tmp\";\n\n const isInWorkspace = absolutePath.startsWith(path.resolve(workspaceDir));\n const isInTmp = absolutePath.startsWith(tmpDir);\n\n if (!isInWorkspace \u0026\u0026 !isInTmp) {\n throw new Error(`File path must be within workspace directory () or /tmp directory. ` + `Provided path: (resolved to: )`);\n }\n\n // Validate file exists\n if (!fs.existsSync(filePath)) {\n throw new Error(`File not found: `);\n }\n\n // Get file stats\n const stats = fs.statSync(filePath);\n const sizeBytes = stats.size;\n const sizeKB = Math.ceil(sizeBytes / 1024);\n\n // Check file size - read from environment variable if available\n const maxSizeKB = process.env.GH_AW_ASSETS_MAX_SIZE_KB ? parseInt(process.env.GH_AW_ASSETS_MAX_SIZE_KB, 10) : 10240; // Default 10MB\n if (sizeKB \u003e maxSizeKB) {\n throw new Error(`File size KB exceeds maximum allowed size KB`);\n }\n\n // Check file extension - read from environment variable if available\n const ext = path.extname(filePath).toLowerCase();\n const allowedExts = process.env.GH_AW_ASSETS_ALLOWED_EXTS\n ? process.env.GH_AW_ASSETS_ALLOWED_EXTS.split(\",\").map(ext =\u003e ext.trim())\n : [\n // Default set as specified in problem statement\n \".png\",\n \".jpg\",\n \".jpeg\",\n ];\n\n if (!allowedExts.includes(ext)) {\n throw new Error(`File extension '' is not allowed. Allowed extensions: ${allowedExts.join(\", \")}`);\n }\n\n // Create assets directory\n const assetsDir = \"/tmp/gh-aw/safeoutputs/assets\";\n if (!fs.existsSync(assetsDir)) {\n fs.mkdirSync(assetsDir, { recursive: true });\n }\n\n // Read file and compute hash\n const fileContent = fs.readFileSync(filePath);\n const sha = crypto.createHash(\"sha256\").update(fileContent).digest(\"hex\");\n\n // Extract filename and extension\n const fileName = path.basename(filePath);\n const fileExt = path.extname(fileName).toLowerCase();\n\n // Copy file to assets directory with original name\n const targetPath = path.join(assetsDir, fileName);\n fs.copyFileSync(filePath, targetPath);\n\n // Generate target filename as sha + extension (lowercased)\n const targetFileName = (sha + fileExt).toLowerCase();\n\n const githubServer = process.env.GITHUB_SERVER_URL || \"https://github.com\";\n const repo = process.env.GITHUB_REPOSITORY || \"owner/repo\";\n const url = `${githubServer.replace(\"github.com\", \"raw.githubusercontent.com\")}///`;\n\n // Create entry for safe outputs\n const entry = {\n type: \"upload_asset\",\n path: filePath,\n fileName: fileName,\n sha: sha,\n size: sizeBytes,\n url: url,\n targetFileName: targetFileName,\n };\n\n appendSafeOutput(entry);\n\n return {\n content: [\n {\n type: \"text\",\n text: JSON.stringify({ result: url }),\n },\n ],\n };\n };\n\n /**\n * Handler for create_pull_request tool\n * Resolves the current branch if branch is not provided or is the base branch\n * Generates git patch for the changes (unless allow-empty is true)\n */\n const createPullRequestHandler = args =\u003e {\n const entry = { ...args, type: \"create_pull_request\" };\n const baseBranch = getBaseBranch();\n\n // If branch is not provided, is empty, or equals the base branch, use the current branch from git\n // This handles cases where the agent incorrectly passes the base branch instead of the working branch\n if (!entry.branch || entry.branch.trim() === \"\" || entry.branch === baseBranch) {\n const detectedBranch = getCurrentBranch();\n\n if (entry.branch === baseBranch) {\n server.debug(`Branch equals base branch (), detecting actual working branch: `);\n } else {\n server.debug(`Using current branch for create_pull_request: `);\n }\n\n entry.branch = detectedBranch;\n }\n\n // Check if allow-empty is enabled in configuration\n const allowEmpty = config.create_pull_request?.allow_empty === true;\n\n if (allowEmpty) {\n server.debug(`allow-empty is enabled for create_pull_request - skipping patch generation`);\n // Append the safe output entry without generating a patch\n appendSafeOutput(entry);\n return {\n content: [\n {\n type: \"text\",\n text: JSON.stringify({\n result: \"success\",\n message: \"Pull request prepared (allow-empty mode - no patch generated)\",\n branch: entry.branch,\n }),\n },\n ],\n };\n }\n\n // Generate git patch\n server.debug(`Generating patch for create_pull_request with branch: ${entry.branch}`);\n const patchResult = generateGitPatch(entry.branch);\n\n if (!patchResult.success) {\n // Patch generation failed or patch is empty\n const errorMsg = patchResult.error || \"Failed to generate patch\";\n server.debug(`Patch generation failed: `);\n throw new Error(errorMsg);\n }\n\n // prettier-ignore\n server.debug(`Patch generated successfully: ${patchResult.patchPath} (${patchResult.patchSize} bytes, ${patchResult.patchLines} lines)`);\n\n appendSafeOutput(entry);\n return {\n content: [\n {\n type: \"text\",\n text: JSON.stringify({\n result: \"success\",\n patch: {\n path: patchResult.patchPath,\n size: patchResult.patchSize,\n lines: patchResult.patchLines,\n },\n }),\n },\n ],\n };\n };\n\n /**\n * Handler for push_to_pull_request_branch tool\n * Resolves the current branch if branch is not provided or is the base branch\n * Generates git patch for the changes\n */\n const pushToPullRequestBranchHandler = args =\u003e {\n const entry = { ...args, type: \"push_to_pull_request_branch\" };\n const baseBranch = getBaseBranch();\n\n // If branch is not provided, is empty, or equals the base branch, use the current branch from git\n // This handles cases where the agent incorrectly passes the base branch instead of the working branch\n if (!entry.branch || entry.branch.trim() === \"\" || entry.branch === baseBranch) {\n const detectedBranch = getCurrentBranch();\n\n if (entry.branch === baseBranch) {\n server.debug(`Branch equals base branch (), detecting actual working branch: `);\n } else {\n server.debug(`Using current branch for push_to_pull_request_branch: `);\n }\n\n entry.branch = detectedBranch;\n }\n\n // Generate git patch\n server.debug(`Generating patch for push_to_pull_request_branch with branch: ${entry.branch}`);\n const patchResult = generateGitPatch(entry.branch);\n\n if (!patchResult.success) {\n // Patch generation failed or patch is empty\n const errorMsg = patchResult.error || \"Failed to generate patch\";\n server.debug(`Patch generation failed: `);\n throw new Error(errorMsg);\n }\n\n // prettier-ignore\n server.debug(`Patch generated successfully: ${patchResult.patchPath} (${patchResult.patchSize} bytes, ${patchResult.patchLines} lines)`);\n\n appendSafeOutput(entry);\n return {\n content: [\n {\n type: \"text\",\n text: JSON.stringify({\n result: \"success\",\n patch: {\n path: patchResult.patchPath,\n size: patchResult.patchSize,\n lines: patchResult.patchLines,\n },\n }),\n },\n ],\n };\n };\n\n return {\n defaultHandler,\n uploadAssetHandler,\n createPullRequestHandler,\n pushToPullRequestBranchHandler,\n };\n}\n\nmodule.exports = { createHandlers };\n", - "safe_outputs_mcp_server.cjs": "// @ts-check\n\n// Safe Outputs MCP Server Module\n//\n// This module provides a reusable MCP server for safe-outputs configuration.\n// It uses the mcp_server_core module for JSON-RPC handling and tool registration.\n//\n// Usage:\n// node safe_outputs_mcp_server.cjs\n//\n// Or as a module:\n// const server = require(\"./safe_outputs_mcp_server.cjs\");\n// server.startSafeOutputsServer();\n\nconst { createServer, registerTool, normalizeTool, start } = require(\"./mcp_server_core.cjs\");\nconst { createAppendFunction } = require(\"./safe_outputs_append.cjs\");\nconst { createHandlers } = require(\"./safe_outputs_handlers.cjs\");\nconst { attachHandlers, registerPredefinedTools, registerDynamicTools } = require(\"./safe_outputs_tools_loader.cjs\");\nconst { bootstrapSafeOutputsServer, cleanupConfigFile } = require(\"./safe_outputs_bootstrap.cjs\");\n\n/**\n * Start the safe-outputs MCP server\n * @param {Object} [options] - Additional options\n * @param {string} [options.logDir] - Override log directory\n * @param {boolean} [options.skipCleanup] - Skip deletion of config file (useful for testing)\n */\nfunction startSafeOutputsServer(options = {}) {\n // Server info for safe outputs MCP server\n const SERVER_INFO = { name: \"safeoutputs\", version: \"1.0.0\" };\n\n // Create the server instance with optional log directory\n const MCP_LOG_DIR = options.logDir || process.env.GH_AW_MCP_LOG_DIR;\n const server = createServer(SERVER_INFO, { logDir: MCP_LOG_DIR });\n\n // Bootstrap: load configuration and tools using shared logic\n const { config: safeOutputsConfig, outputFile, tools: ALL_TOOLS } = bootstrapSafeOutputsServer(server);\n\n // Create append function\n const appendSafeOutput = createAppendFunction(outputFile);\n\n // Create handlers with configuration\n const handlers = createHandlers(server, appendSafeOutput, safeOutputsConfig);\n const { defaultHandler } = handlers;\n\n // Attach handlers to tools\n const toolsWithHandlers = attachHandlers(ALL_TOOLS, handlers);\n\n server.debug(` output file: `);\n server.debug(` config: ${JSON.stringify(safeOutputsConfig)}`);\n\n // Register predefined tools that are enabled in configuration\n registerPredefinedTools(server, toolsWithHandlers, safeOutputsConfig, registerTool, normalizeTool);\n\n // Add safe-jobs as dynamic tools\n registerDynamicTools(server, toolsWithHandlers, safeOutputsConfig, outputFile, registerTool, normalizeTool);\n\n server.debug(` tools: ${Object.keys(server.tools).join(\", \")}`);\n if (!Object.keys(server.tools).length) throw new Error(\"No tools enabled in configuration\");\n\n // Note: We do NOT cleanup the config file here because it's needed by the ingestion\n // phase (collect_ndjson_output.cjs) that runs after the MCP server completes.\n // The config file only contains schema information (no secrets), so it's safe to leave.\n\n // Start the server with the default handler\n start(server, { defaultHandler });\n}\n\n// If run directly, start the server\nif (require.main === module) {\n try {\n startSafeOutputsServer();\n } catch (error) {\n console.error(`Error starting safe-outputs server: ${error instanceof Error ? error.message : String(error)}`);\n process.exit(1);\n }\n}\n\nmodule.exports = {\n startSafeOutputsServer,\n};\n", - "safe_outputs_tools.json": "[\n {\n \"name\": \"create_issue\",\n \"description\": \"Create a new GitHub issue for tracking bugs, feature requests, or tasks. Use this for actionable work items that need assignment, labeling, and status tracking. For reports, announcements, or status updates that don't require task tracking, use create_discussion instead.\",\n \"inputSchema\": {\n \"type\": \"object\",\n \"required\": [\"title\", \"body\"],\n \"properties\": {\n \"title\": {\n \"type\": \"string\",\n \"description\": \"Concise issue title summarizing the bug, feature, or task. The title appears as the main heading, so keep it brief and descriptive.\"\n },\n \"body\": {\n \"type\": \"string\",\n \"description\": \"Detailed issue description in Markdown. Do NOT repeat the title as a heading since it already appears as the issue's h1. Include context, reproduction steps, or acceptance criteria as appropriate.\"\n },\n \"labels\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n },\n \"description\": \"Labels to categorize the issue (e.g., 'bug', 'enhancement'). Labels must exist in the repository.\"\n },\n \"parent\": {\n \"type\": [\"number\", \"string\"],\n \"description\": \"Parent issue number for creating sub-issues. Can be a real issue number (e.g., 42) or a temporary_id (e.g., 'aw_abc123def456') from a previously created issue in the same workflow run.\"\n },\n \"temporary_id\": {\n \"type\": \"string\",\n \"description\": \"Unique temporary identifier for referencing this issue before it's created. Format: 'aw_' followed by 12 hex characters (e.g., 'aw_abc123def456'). Use '#aw_ID' in body text to reference other issues by their temporary_id; these are replaced with actual issue numbers after creation.\"\n }\n },\n \"additionalProperties\": false\n }\n },\n {\n \"name\": \"create_agent_task\",\n \"description\": \"Create a GitHub Copilot agent task to delegate coding work. Use this when you need another Copilot agent to implement code changes, fix bugs, or complete development tasks. The task becomes a new issue that triggers the Copilot coding agent. For non-coding tasks or manual work items, use create_issue instead.\",\n \"inputSchema\": {\n \"type\": \"object\",\n \"required\": [\"body\"],\n \"properties\": {\n \"body\": {\n \"type\": \"string\",\n \"description\": \"Clear, detailed task description for the Copilot agent. Include specific files to modify, expected behavior, acceptance criteria, and any constraints. The description should be actionable and self-contained.\"\n }\n },\n \"additionalProperties\": false\n }\n },\n {\n \"name\": \"create_discussion\",\n \"description\": \"Create a GitHub discussion for announcements, Q\u0026A, reports, status updates, or community conversations. Use this for content that benefits from threaded replies, doesn't require task tracking, or serves as documentation. For actionable work items that need assignment and status tracking, use create_issue instead.\",\n \"inputSchema\": {\n \"type\": \"object\",\n \"required\": [\"title\", \"body\"],\n \"properties\": {\n \"title\": {\n \"type\": \"string\",\n \"description\": \"Concise discussion title summarizing the topic. The title appears as the main heading, so keep it brief and descriptive.\"\n },\n \"body\": {\n \"type\": \"string\",\n \"description\": \"Discussion content in Markdown. Do NOT repeat the title as a heading since it already appears as the discussion's h1. Include all relevant context, findings, or questions.\"\n },\n \"category\": {\n \"type\": \"string\",\n \"description\": \"Discussion category by name (e.g., 'General'), slug (e.g., 'general'), or ID. If omitted, uses the first available category. Category must exist in the repository.\"\n }\n },\n \"additionalProperties\": false\n }\n },\n {\n \"name\": \"close_discussion\",\n \"description\": \"Close a GitHub discussion with a resolution comment and optional reason. Use this to mark discussions as resolved, answered, or no longer needed. The closing comment should explain why the discussion is being closed.\",\n \"inputSchema\": {\n \"type\": \"object\",\n \"required\": [\"body\"],\n \"properties\": {\n \"body\": {\n \"type\": \"string\",\n \"description\": \"Closing comment explaining why the discussion is being closed and summarizing any resolution or conclusion.\"\n },\n \"reason\": {\n \"type\": \"string\",\n \"enum\": [\"RESOLVED\", \"DUPLICATE\", \"OUTDATED\", \"ANSWERED\"],\n \"description\": \"Resolution reason: RESOLVED (issue addressed), DUPLICATE (discussed elsewhere), OUTDATED (no longer relevant), or ANSWERED (question answered).\"\n },\n \"discussion_number\": {\n \"type\": [\"number\", \"string\"],\n \"description\": \"Discussion number to close. If omitted, closes the discussion that triggered this workflow (requires a discussion event trigger).\"\n }\n },\n \"additionalProperties\": false\n }\n },\n {\n \"name\": \"close_issue\",\n \"description\": \"Close a GitHub issue with a closing comment. Use this when work is complete, the issue is no longer relevant, or it's a duplicate. The closing comment should explain the resolution or reason for closing.\",\n \"inputSchema\": {\n \"type\": \"object\",\n \"required\": [\"body\"],\n \"properties\": {\n \"body\": {\n \"type\": \"string\",\n \"description\": \"Closing comment explaining why the issue is being closed and summarizing any resolution, workaround, or conclusion.\"\n },\n \"issue_number\": {\n \"type\": [\"number\", \"string\"],\n \"description\": \"Issue number to close. If omitted, closes the issue that triggered this workflow (requires an issue event trigger).\"\n }\n },\n \"additionalProperties\": false\n }\n },\n {\n \"name\": \"close_pull_request\",\n \"description\": \"Close a pull request WITHOUT merging, adding a closing comment. Use this for PRs that should be abandoned, superseded, or closed for other reasons. The closing comment should explain why the PR is being closed. This does NOT merge the changes.\",\n \"inputSchema\": {\n \"type\": \"object\",\n \"required\": [\"body\"],\n \"properties\": {\n \"body\": {\n \"type\": \"string\",\n \"description\": \"Closing comment explaining why the PR is being closed without merging (e.g., superseded by another PR, no longer needed, approach rejected).\"\n },\n \"pull_request_number\": {\n \"type\": [\"number\", \"string\"],\n \"description\": \"Pull request number to close. If omitted, closes the PR that triggered this workflow (requires a pull_request event trigger).\"\n }\n },\n \"additionalProperties\": false\n }\n },\n {\n \"name\": \"add_comment\",\n \"description\": \"Add a comment to an existing GitHub issue, pull request, or discussion. Use this to provide feedback, answer questions, or add information to an existing conversation. For creating new items, use create_issue, create_discussion, or create_pull_request instead.\",\n \"inputSchema\": {\n \"type\": \"object\",\n \"required\": [\"body\", \"item_number\"],\n \"properties\": {\n \"body\": {\n \"type\": \"string\",\n \"description\": \"Comment content in Markdown. Provide helpful, relevant information that adds value to the conversation.\"\n },\n \"item_number\": {\n \"type\": \"number\",\n \"description\": \"The issue, pull request, or discussion number to comment on. Must be a valid existing item in the repository.\"\n }\n },\n \"additionalProperties\": false\n }\n },\n {\n \"name\": \"create_pull_request\",\n \"description\": \"Create a new GitHub pull request to propose code changes. Use this after making file edits to submit them for review and merging. The PR will be created from the current branch with your committed changes. For code review comments on an existing PR, use create_pull_request_review_comment instead.\",\n \"inputSchema\": {\n \"type\": \"object\",\n \"required\": [\"title\", \"body\"],\n \"properties\": {\n \"title\": {\n \"type\": \"string\",\n \"description\": \"Concise PR title describing the changes. Follow repository conventions (e.g., conventional commits). The title appears as the main heading.\"\n },\n \"body\": {\n \"type\": \"string\",\n \"description\": \"Detailed PR description in Markdown. Include what changes were made, why, testing notes, and any breaking changes. Do NOT repeat the title as a heading.\"\n },\n \"branch\": {\n \"type\": \"string\",\n \"description\": \"Source branch name containing the changes. If omitted, uses the current working branch.\"\n },\n \"labels\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n },\n \"description\": \"Labels to categorize the PR (e.g., 'enhancement', 'bugfix'). Labels must exist in the repository.\"\n }\n },\n \"additionalProperties\": false\n }\n },\n {\n \"name\": \"create_pull_request_review_comment\",\n \"description\": \"Create a review comment on a specific line of code in a pull request. Use this for inline code review feedback, suggestions, or questions about specific code changes. For general PR comments not tied to specific lines, use add_comment instead.\",\n \"inputSchema\": {\n \"type\": \"object\",\n \"required\": [\"path\", \"line\", \"body\"],\n \"properties\": {\n \"path\": {\n \"type\": \"string\",\n \"description\": \"File path relative to the repository root (e.g., 'src/auth/login.js'). Must be a file that was changed in the PR.\"\n },\n \"line\": {\n \"type\": [\"number\", \"string\"],\n \"description\": \"Line number for the comment. For single-line comments, this is the target line. For multi-line comments, this is the ending line.\"\n },\n \"body\": {\n \"type\": \"string\",\n \"description\": \"Review comment content in Markdown. Provide specific, actionable feedback about the code at this location.\"\n },\n \"start_line\": {\n \"type\": [\"number\", \"string\"],\n \"description\": \"Starting line number for multi-line comments. When set, the comment spans from start_line to line. Omit for single-line comments.\"\n },\n \"side\": {\n \"type\": \"string\",\n \"enum\": [\"LEFT\", \"RIGHT\"],\n \"description\": \"Side of the diff to comment on: RIGHT for the new version (additions), LEFT for the old version (deletions). Defaults to RIGHT.\"\n }\n },\n \"additionalProperties\": false\n }\n },\n {\n \"name\": \"create_code_scanning_alert\",\n \"description\": \"Create a code scanning alert for security vulnerabilities, code quality issues, or other findings. Alerts appear in the repository's Security tab and integrate with GitHub's security features. Use this for automated security analysis results.\",\n \"inputSchema\": {\n \"type\": \"object\",\n \"required\": [\"file\", \"line\", \"severity\", \"message\"],\n \"properties\": {\n \"file\": {\n \"type\": \"string\",\n \"description\": \"File path relative to the repository root where the issue was found (e.g., 'src/auth/password.js').\"\n },\n \"line\": {\n \"type\": [\"number\", \"string\"],\n \"description\": \"Line number where the issue was found in the file.\"\n },\n \"severity\": {\n \"type\": \"string\",\n \"enum\": [\"error\", \"warning\", \"info\", \"note\"],\n \"description\": \"Alert severity level: 'error' (critical security issues), 'warning' (potential problems), 'info' (informational), or 'note' (minor observations).\"\n },\n \"message\": {\n \"type\": \"string\",\n \"description\": \"Clear description of the security issue or finding. Include what's wrong and ideally how to fix it.\"\n },\n \"column\": {\n \"type\": [\"number\", \"string\"],\n \"description\": \"Column number for more precise location of the issue within the line.\"\n },\n \"ruleIdSuffix\": {\n \"type\": \"string\",\n \"description\": \"Suffix to append to the rule ID for categorizing different types of findings (e.g., 'sql-injection', 'xss').\"\n }\n },\n \"additionalProperties\": false\n }\n },\n {\n \"name\": \"add_labels\",\n \"description\": \"Add labels to an existing GitHub issue or pull request for categorization and filtering. Labels must already exist in the repository. For creating new issues with labels, use create_issue with the labels property instead.\",\n \"inputSchema\": {\n \"type\": \"object\",\n \"required\": [\"labels\"],\n \"properties\": {\n \"labels\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n },\n \"description\": \"Label names to add (e.g., ['bug', 'priority-high']). Labels must exist in the repository.\"\n },\n \"item_number\": {\n \"type\": \"number\",\n \"description\": \"Issue or PR number to add labels to. If omitted, adds labels to the item that triggered this workflow.\"\n }\n },\n \"additionalProperties\": false\n }\n },\n {\n \"name\": \"add_reviewer\",\n \"description\": \"Add reviewers to a GitHub pull request. Reviewers receive notifications and can approve or request changes. Use 'copilot' as a reviewer name to request the Copilot PR review bot.\",\n \"inputSchema\": {\n \"type\": \"object\",\n \"required\": [\"reviewers\"],\n \"properties\": {\n \"reviewers\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n },\n \"description\": \"GitHub usernames to add as reviewers (e.g., ['octocat', 'copilot']). Users must have access to the repository.\"\n },\n \"pull_request_number\": {\n \"type\": [\"number\", \"string\"],\n \"description\": \"Pull request number to add reviewers to. If omitted, adds reviewers to the PR that triggered this workflow.\"\n }\n },\n \"additionalProperties\": false\n }\n },\n {\n \"name\": \"assign_milestone\",\n \"description\": \"Assign an issue to a milestone for release planning and progress tracking. Milestones must exist in the repository before assignment.\",\n \"inputSchema\": {\n \"type\": \"object\",\n \"required\": [\"issue_number\", \"milestone_number\"],\n \"properties\": {\n \"issue_number\": {\n \"type\": [\"number\", \"string\"],\n \"description\": \"Issue number to assign to the milestone.\"\n },\n \"milestone_number\": {\n \"type\": [\"number\", \"string\"],\n \"description\": \"Milestone number (not title) to assign the issue to. Find milestone numbers in the repository's Milestones page.\"\n }\n },\n \"additionalProperties\": false\n }\n },\n {\n \"name\": \"assign_to_agent\",\n \"description\": \"Assign the GitHub Copilot coding agent to work on an issue. The agent will analyze the issue and attempt to implement a solution, creating a pull request when complete. Use this to delegate coding tasks to Copilot.\",\n \"inputSchema\": {\n \"type\": \"object\",\n \"required\": [\"issue_number\"],\n \"properties\": {\n \"issue_number\": {\n \"type\": [\"number\", \"string\"],\n \"description\": \"Issue number to assign the Copilot agent to. The issue should contain clear, actionable requirements.\"\n },\n \"agent\": {\n \"type\": \"string\",\n \"description\": \"Agent identifier to assign. Defaults to 'copilot' (the Copilot coding agent) if not specified.\"\n }\n },\n \"additionalProperties\": false\n }\n },\n {\n \"name\": \"assign_to_user\",\n \"description\": \"Assign one or more GitHub users to an issue. Use this to delegate work to specific team members. Users must have access to the repository.\",\n \"inputSchema\": {\n \"type\": \"object\",\n \"required\": [\"issue_number\"],\n \"properties\": {\n \"issue_number\": {\n \"type\": [\"number\", \"string\"],\n \"description\": \"Issue number to assign users to. If omitted, assigns to the issue that triggered this workflow.\"\n },\n \"assignees\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n },\n \"description\": \"GitHub usernames to assign to the issue (e.g., ['octocat', 'mona']). Users must have access to the repository.\"\n },\n \"assignee\": {\n \"type\": \"string\",\n \"description\": \"Single GitHub username to assign. Use 'assignees' array for multiple users.\"\n }\n },\n \"additionalProperties\": false\n }\n },\n {\n \"name\": \"update_issue\",\n \"description\": \"Update an existing GitHub issue's status, title, or body. Use this to modify issue properties after creation. Only the fields you specify will be updated; other fields remain unchanged.\",\n \"inputSchema\": {\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"enum\": [\"open\", \"closed\"],\n \"description\": \"New issue status: 'open' to reopen a closed issue, 'closed' to close an open issue.\"\n },\n \"title\": {\n \"type\": \"string\",\n \"description\": \"New issue title to replace the existing title.\"\n },\n \"body\": {\n \"type\": \"string\",\n \"description\": \"New issue body to replace the existing content. Use Markdown formatting.\"\n },\n \"issue_number\": {\n \"type\": [\"number\", \"string\"],\n \"description\": \"Issue number to update. Required when the workflow target is '*' (any issue).\"\n }\n },\n \"additionalProperties\": false\n }\n },\n {\n \"name\": \"update_pull_request\",\n \"description\": \"Update an existing GitHub pull request's title or body. Supports replacing, appending to, or prepending content to the body. Title is always replaced. Only the fields you specify will be updated; other fields remain unchanged.\",\n \"inputSchema\": {\n \"type\": \"object\",\n \"properties\": {\n \"title\": {\n \"type\": \"string\",\n \"description\": \"New pull request title to replace the existing title.\"\n },\n \"body\": {\n \"type\": \"string\",\n \"description\": \"Pull request body content in Markdown. For 'replace', this becomes the entire body. For 'append'/'prepend', this is added with a separator.\"\n },\n \"operation\": {\n \"type\": \"string\",\n \"enum\": [\"replace\", \"append\", \"prepend\"],\n \"description\": \"How to update the PR body: 'replace' (default - completely overwrite), 'append' (add to end with separator), or 'prepend' (add to start with separator). Title is always replaced.\"\n },\n \"pull_request_number\": {\n \"type\": [\"number\", \"string\"],\n \"description\": \"Pull request number to update. Required when the workflow target is '*' (any PR).\"\n }\n },\n \"additionalProperties\": false\n }\n },\n {\n \"name\": \"push_to_pull_request_branch\",\n \"description\": \"Push committed changes to a pull request's branch. Use this to add follow-up commits to an existing PR, such as addressing review feedback or fixing issues. Changes must be committed locally before calling this tool.\",\n \"inputSchema\": {\n \"type\": \"object\",\n \"required\": [\"message\"],\n \"properties\": {\n \"branch\": {\n \"type\": \"string\",\n \"description\": \"Branch name to push changes from. If omitted, uses the current working branch. Only specify if you need to push from a different branch.\"\n },\n \"message\": {\n \"type\": \"string\",\n \"description\": \"Commit message describing the changes. Follow repository commit message conventions (e.g., conventional commits).\"\n },\n \"pull_request_number\": {\n \"type\": [\"number\", \"string\"],\n \"description\": \"Pull request number to push changes to. Required when the workflow target is '*' (any PR).\"\n }\n },\n \"additionalProperties\": false\n }\n },\n {\n \"name\": \"upload_asset\",\n \"description\": \"Upload a file as a URL-addressable asset that can be referenced in issues, PRs, or comments. The file is stored on an orphaned git branch and returns a permanent URL. Use this for images, diagrams, or other files that need to be embedded in GitHub content.\",\n \"inputSchema\": {\n \"type\": \"object\",\n \"required\": [\"path\"],\n \"properties\": {\n \"path\": {\n \"type\": \"string\",\n \"description\": \"Absolute file path to upload (e.g., '/tmp/chart.png'). Must be under the workspace or /tmp directory. By default, only image files (.png, .jpg, .jpeg) are allowed; other file types require workflow configuration.\"\n }\n },\n \"additionalProperties\": false\n }\n },\n {\n \"name\": \"update_release\",\n \"description\": \"Update a GitHub release description by replacing, appending to, or prepending to the existing content. Use this to add release notes, changelogs, or additional information to an existing release.\",\n \"inputSchema\": {\n \"type\": \"object\",\n \"required\": [\"tag\", \"operation\", \"body\"],\n \"properties\": {\n \"tag\": {\n \"type\": \"string\",\n \"description\": \"Release tag name (e.g., 'v1.0.0'). REQUIRED - must be provided explicitly as the tag cannot always be inferred from event context.\"\n },\n \"operation\": {\n \"type\": \"string\",\n \"enum\": [\"replace\", \"append\", \"prepend\"],\n \"description\": \"How to update the release body: 'replace' (completely overwrite), 'append' (add to end with separator), or 'prepend' (add to start with separator).\"\n },\n \"body\": {\n \"type\": \"string\",\n \"description\": \"Release body content in Markdown. For 'replace', this becomes the entire release body. For 'append'/'prepend', this is added with a separator.\"\n }\n },\n \"additionalProperties\": false\n }\n },\n {\n \"name\": \"missing_tool\",\n \"description\": \"Report that a tool or capability needed to complete the task is not available. Use this when you cannot accomplish what was requested because the required functionality is missing or access is restricted.\",\n \"inputSchema\": {\n \"type\": \"object\",\n \"required\": [\"tool\", \"reason\"],\n \"properties\": {\n \"tool\": {\n \"type\": \"string\",\n \"description\": \"Name or description of the missing tool or capability (max 128 characters). Be specific about what functionality is needed.\"\n },\n \"reason\": {\n \"type\": \"string\",\n \"description\": \"Explanation of why this tool is needed to complete the task (max 256 characters).\"\n },\n \"alternatives\": {\n \"type\": \"string\",\n \"description\": \"Any workarounds, manual steps, or alternative approaches the user could take (max 256 characters).\"\n }\n },\n \"additionalProperties\": false\n }\n },\n {\n \"name\": \"noop\",\n \"description\": \"Log a transparency message when no significant actions are needed. Use this to confirm workflow completion and provide visibility when analysis is complete but no changes or outputs are required (e.g., 'No issues found', 'All checks passed'). This ensures the workflow produces human-visible output even when no other actions are taken.\",\n \"inputSchema\": {\n \"type\": \"object\",\n \"required\": [\"message\"],\n \"properties\": {\n \"message\": {\n \"type\": \"string\",\n \"description\": \"Status or completion message to log. Should explain what was analyzed and the outcome (e.g., 'Code review complete - no issues found', 'Analysis complete - all tests passing').\"\n }\n },\n \"additionalProperties\": false\n }\n },\n {\n \"name\": \"link_sub_issue\",\n \"description\": \"Link an issue as a sub-issue of a parent issue. Use this to establish parent-child relationships between issues for better organization and tracking of related work items.\",\n \"inputSchema\": {\n \"type\": \"object\",\n \"required\": [\"parent_issue_number\", \"sub_issue_number\"],\n \"properties\": {\n \"parent_issue_number\": {\n \"type\": [\"number\", \"string\"],\n \"description\": \"The parent issue number to link the sub-issue to.\"\n },\n \"sub_issue_number\": {\n \"type\": [\"number\", \"string\"],\n \"description\": \"The issue number to link as a sub-issue of the parent.\"\n }\n },\n \"additionalProperties\": false\n }\n },\n {\n \"name\": \"hide_comment\",\n \"description\": \"Hide a comment on a GitHub issue, pull request, or discussion. This collapses the comment and marks it as spam, abuse, off-topic, outdated, or resolved. Use this for inappropriate, off-topic, or outdated comments. The comment_id must be a GraphQL node ID (string like 'IC_kwDOABCD123456'), not a numeric REST API comment ID.\",\n \"inputSchema\": {\n \"type\": \"object\",\n \"required\": [\"comment_id\"],\n \"properties\": {\n \"comment_id\": {\n \"type\": \"string\",\n \"description\": \"GraphQL node ID of the comment to hide (e.g., 'IC_kwDOABCD123456'). This is the GraphQL node ID, not the numeric comment ID from REST API. Can be obtained from GraphQL queries or comment API responses.\"\n },\n \"reason\": {\n \"type\": \"string\",\n \"enum\": [\"SPAM\", \"ABUSE\", \"OFF_TOPIC\", \"OUTDATED\", \"RESOLVED\"],\n \"description\": \"Optional reason for hiding the comment. Defaults to SPAM if not provided. Valid values: SPAM (spam content), ABUSE (abusive/harassment content), OFF_TOPIC (not relevant to discussion), OUTDATED (no longer applicable), RESOLVED (issue/question has been resolved).\"\n }\n },\n \"additionalProperties\": false\n }\n },\n {\n \"name\": \"update_project\",\n \"description\": \"Add or update items in GitHub Projects v2 boards. Can add issues/PRs to a project and update custom field values. Requires the project URL, content type (issue or pull_request), and content number. Use campaign_id to group related items.\",\n \"inputSchema\": {\n \"type\": \"object\",\n \"required\": [\"project\", \"content_type\", \"content_number\"],\n \"properties\": {\n \"project\": {\n \"type\": \"string\",\n \"pattern\": \"^https://github\\\\.com/(orgs|users)/[^/]+/projects/\\\\d+$\",\n \"description\": \"Full GitHub project URL (e.g., 'https://github.com/orgs/myorg/projects/42' or 'https://github.com/users/username/projects/5'). Project names or numbers alone are NOT accepted.\"\n },\n \"content_type\": {\n \"type\": \"string\",\n \"enum\": [\"issue\", \"pull_request\"],\n \"description\": \"Type of content to add to the project. Must be either 'issue' or 'pull_request'.\"\n },\n \"content_number\": {\n \"type\": \"number\",\n \"description\": \"Issue or pull request number to add to the project (e.g., 123 for issue #123).\"\n },\n \"fields\": {\n \"type\": \"object\",\n \"description\": \"Custom field values to set on the project item (e.g., {'Status': 'In Progress', 'Priority': 'High'}). Field names must match custom fields defined in the project.\"\n },\n \"campaign_id\": {\n \"type\": \"string\",\n \"description\": \"Campaign identifier to group related project items. Used to track items created by the same campaign or workflow run.\"\n },\n \"create_if_missing\": {\n \"type\": \"boolean\",\n \"description\": \"Whether to create the project if it doesn't exist. Defaults to false. Requires projects:write permission when true.\"\n }\n },\n \"additionalProperties\": false\n }\n }\n]\n", - "safe_outputs_tools_loader.cjs": "// @ts-check\n\nconst fs = require(\"fs\");\n\n/**\n * Load tools from tools.json file\n * @param {Object} server - The MCP server instance for logging\n * @returns {Array} Array of tool definitions\n */\nfunction loadTools(server) {\n const toolsPath = process.env.GH_AW_SAFE_OUTPUTS_TOOLS_PATH || \"/tmp/gh-aw/safeoutputs/tools.json\";\n let ALL_TOOLS = [];\n\n server.debug(`Reading tools from file: `);\n\n try {\n if (fs.existsSync(toolsPath)) {\n server.debug(`Tools file exists at: `);\n const toolsFileContent = fs.readFileSync(toolsPath, \"utf8\");\n server.debug(`Tools file content length: ${toolsFileContent.length} characters`);\n server.debug(`Tools file read successfully, attempting to parse JSON`);\n ALL_TOOLS = JSON.parse(toolsFileContent);\n server.debug(`Successfully parsed ${ALL_TOOLS.length} tools from file`);\n } else {\n server.debug(`Tools file does not exist at: `);\n server.debug(`Using empty tools array`);\n ALL_TOOLS = [];\n }\n } catch (error) {\n server.debug(`Error reading tools file: ${error instanceof Error ? error.message : String(error)}`);\n server.debug(`Falling back to empty tools array`);\n ALL_TOOLS = [];\n }\n\n return ALL_TOOLS;\n}\n\n/**\n * Attach handlers to tools\n * @param {Array} tools - Array of tool definitions\n * @param {Object} handlers - Object containing handler functions\n * @returns {Array} Tools with handlers attached\n */\nfunction attachHandlers(tools, handlers) {\n tools.forEach(tool =\u003e {\n if (tool.name === \"create_pull_request\") {\n tool.handler = handlers.createPullRequestHandler;\n } else if (tool.name === \"push_to_pull_request_branch\") {\n tool.handler = handlers.pushToPullRequestBranchHandler;\n } else if (tool.name === \"upload_asset\") {\n tool.handler = handlers.uploadAssetHandler;\n }\n });\n return tools;\n}\n\n/**\n * Register predefined tools based on configuration\n * @param {Object} server - The MCP server instance\n * @param {Array} tools - Array of tool definitions\n * @param {Object} config - Safe outputs configuration\n * @param {Function} registerTool - Function to register a tool\n * @param {Function} normalizeTool - Function to normalize tool names\n */\nfunction registerPredefinedTools(server, tools, config, registerTool, normalizeTool) {\n tools.forEach(tool =\u003e {\n if (Object.keys(config).find(configKey =\u003e normalizeTool(configKey) === tool.name)) {\n registerTool(server, tool);\n }\n });\n}\n\n/**\n * Register dynamic safe-job tools based on configuration\n * @param {Object} server - The MCP server instance\n * @param {Array} tools - Array of predefined tool definitions\n * @param {Object} config - Safe outputs configuration\n * @param {string} outputFile - Path to the output file\n * @param {Function} registerTool - Function to register a tool\n * @param {Function} normalizeTool - Function to normalize tool names\n */\nfunction registerDynamicTools(server, tools, config, outputFile, registerTool, normalizeTool) {\n Object.keys(config).forEach(configKey =\u003e {\n const normalizedKey = normalizeTool(configKey);\n\n // Skip if it's already a predefined tool\n if (server.tools[normalizedKey]) {\n return;\n }\n\n // Check if this is a safe-job (not in ALL_TOOLS)\n if (!tools.find(t =\u003e t.name === normalizedKey)) {\n const jobConfig = config[configKey];\n\n // Create a dynamic tool for this safe-job\n const dynamicTool = {\n name: normalizedKey,\n description: jobConfig \u0026\u0026 jobConfig.description ? jobConfig.description : `Custom safe-job: `,\n inputSchema: {\n type: \"object\",\n properties: {},\n additionalProperties: true, // Allow any properties for flexibility\n },\n handler: args =\u003e {\n // Create a generic safe-job output entry\n const entry = {\n type: normalizedKey,\n ...args,\n };\n\n // Write the entry to the output file in JSONL format\n // CRITICAL: Use JSON.stringify WITHOUT formatting parameters for JSONL format\n // Each entry must be on a single line, followed by a newline character\n const entryJSON = JSON.stringify(entry);\n fs.appendFileSync(outputFile, entryJSON + \"\\n\");\n\n // Use output from safe-job config if available\n const outputText = jobConfig \u0026\u0026 jobConfig.output ? jobConfig.output : `Safe-job '' executed successfully with arguments: ${JSON.stringify(args)}`;\n\n return {\n content: [\n {\n type: \"text\",\n text: JSON.stringify({ result: outputText }),\n },\n ],\n };\n },\n };\n\n // Add input schema based on job configuration if available\n if (jobConfig \u0026\u0026 jobConfig.inputs) {\n dynamicTool.inputSchema.properties = {};\n dynamicTool.inputSchema.required = [];\n\n Object.keys(jobConfig.inputs).forEach(inputName =\u003e {\n const inputDef = jobConfig.inputs[inputName];\n const propSchema = {\n type: inputDef.type || \"string\",\n description: inputDef.description || `Input parameter: `,\n };\n\n if (inputDef.options \u0026\u0026 Array.isArray(inputDef.options)) {\n propSchema.enum = inputDef.options;\n }\n\n dynamicTool.inputSchema.properties[inputName] = propSchema;\n\n if (inputDef.required) {\n dynamicTool.inputSchema.required.push(inputName);\n }\n });\n }\n\n registerTool(server, dynamicTool);\n }\n });\n}\n\nmodule.exports = {\n loadTools,\n attachHandlers,\n registerPredefinedTools,\n registerDynamicTools,\n};\n" - }; - -async function run() { - try { - const destination = core.getInput('destination') || '/tmp/gh-aw/safeoutputs'; - - core.info(`Copying safe-outputs files to ${destination}`); - - // Create destination directory if it doesn't exist - if (!fs.existsSync(destination)) { - fs.mkdirSync(destination, { recursive: true }); - core.info(`Created directory: ${destination}`); - } - - let fileCount = 0; - - // Copy each embedded file - for (const [filename, content] of Object.entries(FILES)) { - const filePath = path.join(destination, filename); - fs.writeFileSync(filePath, content, 'utf8'); - core.info(`Copied: ${filename}`); - fileCount++; - } - - core.setOutput('files-copied', fileCount.toString()); - core.info(`✓ Successfully copied ${fileCount} files`); - - } catch (error) { - core.setFailed(`Action failed: ${error.message}`); - } -} - -run(); diff --git a/actions/setup-safe-outputs/src/index.js b/actions/setup-safe-outputs/src/index.js deleted file mode 100644 index 1b91680935e..00000000000 --- a/actions/setup-safe-outputs/src/index.js +++ /dev/null @@ -1,43 +0,0 @@ -// Safe Outputs Copy Action -// Copies safe-outputs MCP server files to the agent environment - -const core = require('@actions/core'); -const fs = require('fs'); -const path = require('path'); - -// Embedded safe-outputs files will be inserted here during build -const FILES = { - // This will be populated by the build script -}; - -async function run() { - try { - const destination = core.getInput('destination') || '/tmp/gh-aw/safeoutputs'; - - core.info(`Copying safe-outputs files to ${destination}`); - - // Create destination directory if it doesn't exist - if (!fs.existsSync(destination)) { - fs.mkdirSync(destination, { recursive: true }); - core.info(`Created directory: ${destination}`); - } - - let fileCount = 0; - - // Copy each embedded file - for (const [filename, content] of Object.entries(FILES)) { - const filePath = path.join(destination, filename); - fs.writeFileSync(filePath, content, 'utf8'); - core.info(`Copied: ${filename}`); - fileCount++; - } - - core.setOutput('files-copied', fileCount.toString()); - core.info(`✓ Successfully copied ${fileCount} files`); - - } catch (error) { - core.setFailed(`Action failed: ${error.message}`); - } -} - -run(); diff --git a/pkg/cli/actions_build_command.go b/pkg/cli/actions_build_command.go index 362c0769925..f59ddac4a57 100644 --- a/pkg/cli/actions_build_command.go +++ b/pkg/cli/actions_build_command.go @@ -105,13 +105,28 @@ func ActionsCleanCommand() error { cleanedCount := 0 for _, actionName := range actionDirs { - indexPath := filepath.Join(actionsDir, actionName, "index.js") - if _, err := os.Stat(indexPath); err == nil { - if err := os.Remove(indexPath); err != nil { - return fmt.Errorf("failed to remove %s: %w", indexPath, err) + // Clean index.js for actions that use it + if actionName != "setup-safe-outputs" { + indexPath := filepath.Join(actionsDir, actionName, "index.js") + if _, err := os.Stat(indexPath); err == nil { + if err := os.Remove(indexPath); err != nil { + return fmt.Errorf("failed to remove %s: %w", indexPath, err) + } + fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf(" ✓ Removed %s/index.js", actionName))) + cleanedCount++ + } + } + + // Clean js/ directory for setup-safe-outputs + if actionName == "setup-safe-outputs" { + jsDir := filepath.Join(actionsDir, actionName, "js") + if _, err := os.Stat(jsDir); err == nil { + if err := os.RemoveAll(jsDir); err != nil { + return fmt.Errorf("failed to remove %s: %w", jsDir, err) + } + fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf(" ✓ Removed %s/js/", actionName))) + cleanedCount++ } - fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf(" ✓ Removed %s/index.js", actionName))) - cleanedCount++ } } @@ -164,9 +179,12 @@ func validateActionYml(actionPath string) error { } } - // Check that it's a node20 action - if !strings.Contains(contentStr, "using: 'node20'") && !strings.Contains(contentStr, "using: \"node20\"") { - return fmt.Errorf("action must use 'node20' runtime") + // Check that it's either a node20 or composite action + isNode20 := strings.Contains(contentStr, "using: 'node20'") || strings.Contains(contentStr, "using: \"node20\"") + isComposite := strings.Contains(contentStr, "using: 'composite'") || strings.Contains(contentStr, "using: \"composite\"") + + if !isNode20 && !isComposite { + return fmt.Errorf("action must use either 'node20' or 'composite' runtime") } return nil @@ -179,8 +197,6 @@ func buildAction(actionsDir, actionName string) error { fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("\n📦 Building action: %s", actionName))) actionPath := filepath.Join(actionsDir, actionName) - srcPath := filepath.Join(actionPath, "src", "index.js") - outputPath := filepath.Join(actionPath, "index.js") // Validate action.yml fmt.Fprintln(os.Stderr, console.FormatInfoMessage(" ✓ Validating action.yml")) @@ -188,6 +204,14 @@ func buildAction(actionsDir, actionName string) error { return err } + // Special handling for setup-safe-outputs: copy files instead of embedding + if actionName == "setup-safe-outputs" { + return buildSetupSafeOutputsAction(actionsDir, actionName) + } + + srcPath := filepath.Join(actionPath, "src", "index.js") + outputPath := filepath.Join(actionPath, "index.js") + // Check if source file exists if _, err := os.Stat(srcPath); os.IsNotExist(err) { return fmt.Errorf("source file not found: %s", srcPath) @@ -243,6 +267,43 @@ func buildAction(actionsDir, actionName string) error { return nil } +// buildSetupSafeOutputsAction builds the setup-safe-outputs action by copying JavaScript files +func buildSetupSafeOutputsAction(actionsDir, actionName string) error { + actionPath := filepath.Join(actionsDir, actionName) + jsDir := filepath.Join(actionPath, "js") + + // Get dependencies for this action + dependencies := getActionDependencies(actionName) + fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf(" ✓ Found %d dependencies", len(dependencies)))) + + // Get all JavaScript sources + sources := workflow.GetJavaScriptSources() + + // Create js directory if it doesn't exist + if err := os.MkdirAll(jsDir, 0755); err != nil { + return fmt.Errorf("failed to create js directory: %w", err) + } + + // Copy each dependency file to the js directory + copiedCount := 0 + for _, dep := range dependencies { + if content, ok := sources[dep]; ok { + destPath := filepath.Join(jsDir, dep) + if err := os.WriteFile(destPath, []byte(content), 0644); err != nil { + return fmt.Errorf("failed to write %s: %w", dep, err) + } + fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf(" - %s", dep))) + copiedCount++ + } else { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf(" ⚠ Warning: Could not find %s", dep))) + } + } + + fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf(" ✓ Copied %d files to js/", copiedCount))) + + return nil +} + // getActionDependencies returns the list of JavaScript dependencies for an action // This mapping defines which files from pkg/workflow/js/ are needed for each action func getActionDependencies(actionName string) []string { diff --git a/pkg/workflow/compiler_activation_jobs.go b/pkg/workflow/compiler_activation_jobs.go index aa6b1f75abf..59ebc85102e 100644 --- a/pkg/workflow/compiler_activation_jobs.go +++ b/pkg/workflow/compiler_activation_jobs.go @@ -34,7 +34,7 @@ func (c *Compiler) buildPreActivationJob(data *WorkflowData, needsPermissionChec steps = append(steps, " sparse-checkout: |\n") steps = append(steps, " actions\n") } - + steps = append(steps, " - name: Setup Activation Scripts\n") steps = append(steps, fmt.Sprintf(" uses: %s\n", setupActivationActionRef)) steps = append(steps, " with:\n") @@ -347,7 +347,7 @@ func (c *Compiler) buildActivationJob(data *WorkflowData, preActivationJobCreate steps = append(steps, " sparse-checkout: |\n") steps = append(steps, " actions\n") } - + steps = append(steps, " - name: Setup Activation Scripts\n") steps = append(steps, fmt.Sprintf(" uses: %s\n", setupActivationActionRef)) steps = append(steps, " with:\n")