From c471c9c1110e1024e7e18395729530e2e8e2868b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 5 Dec 2025 16:14:23 +0000 Subject: [PATCH 1/7] Initial plan From aa25c66ad3f06b2b170ac0e0b64e6212dccb634e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 5 Dec 2025 16:20:07 +0000 Subject: [PATCH 2/7] Initial plan for adding mounts field to sandbox.agent configuration Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../copilot-pr-merged-report.lock.yml | 188 ++++++++++++++++++ .../daily-performance-summary.lock.yml | 188 ++++++++++++++++++ .../workflows/daily-workflow-updater.lock.yml | 33 +-- .github/workflows/dev.lock.yml | 188 ++++++++++++++++++ .github/workflows/release.lock.yml | 2 +- .../workflows/test-python-safe-input.lock.yml | 188 ++++++++++++++++++ 6 files changed, 772 insertions(+), 15 deletions(-) diff --git a/.github/workflows/copilot-pr-merged-report.lock.yml b/.github/workflows/copilot-pr-merged-report.lock.yml index 82b257a267b..94801a0bf22 100644 --- a/.github/workflows/copilot-pr-merged-report.lock.yml +++ b/.github/workflows/copilot-pr-merged-report.lock.yml @@ -2516,6 +2516,194 @@ jobs: createToolConfig, }; EOF_SAFE_INPUTS_SERVER + cat > /tmp/gh-aw/safe-inputs/safe_inputs_mcp_server_http.cjs << 'EOF_SAFE_INPUTS_SERVER_HTTP' + const path = require("path"); + const http = require("http"); + const { randomUUID } = require("crypto"); + const { McpServer } = require("@modelcontextprotocol/sdk/server/mcp.js"); + const { StreamableHTTPServerTransport } = require("@modelcontextprotocol/sdk/server/streamableHttp.js"); + const { loadConfig } = require("./safe_inputs_config_loader.cjs"); + const { loadToolHandlers } = require("./mcp_server_core.cjs"); + const { validateRequiredFields } = require("./safe_inputs_validation.cjs"); + function createMCPServer(configPath, options = {}) { + const config = loadConfig(configPath); + const basePath = path.dirname(configPath); + const serverName = config.serverName || "safeinputs"; + const version = config.version || "1.0.0"; + const server = new McpServer( + { + name: serverName, + version: version, + }, + { + capabilities: { + tools: {}, + }, + } + ); + const logger = { + debug: msg => { + const timestamp = new Date().toISOString(); + process.stderr.write(`[${timestamp}] [${serverName}] ${msg}\n`); + }, + debugError: (prefix, error) => { + const errorMessage = error instanceof Error ? error.message : String(error); + logger.debug(`${prefix}${errorMessage}`); + if (error instanceof Error && error.stack) { + logger.debug(`${prefix}Stack trace: ${error.stack}`); + } + }, + }; + logger.debug(`Loading safe-inputs configuration from: ${configPath}`); + logger.debug(`Base path for handlers: ${basePath}`); + logger.debug(`Tools to load: ${config.tools.length}`); + const tempServer = { debug: logger.debug, debugError: logger.debugError }; + const tools = loadToolHandlers(tempServer, config.tools, basePath); + for (const tool of tools) { + if (!tool.handler) { + logger.debug(`Skipping tool ${tool.name} - no handler loaded`); + continue; + } + logger.debug(`Registering tool: ${tool.name}`); + server.tool(tool.name, tool.description || "", tool.inputSchema || { type: "object", properties: {} }, async args => { + logger.debug(`Calling handler for tool: ${tool.name}`); + const missing = validateRequiredFields(args, tool.inputSchema); + if (missing.length) { + throw new Error(`Invalid arguments: missing or empty ${missing.map(m => `'${m}'`).join(", ")}`); + } + const result = await Promise.resolve(tool.handler(args)); + logger.debug(`Handler returned for tool: ${tool.name}`); + const content = result && result.content ? result.content : []; + return { content, isError: false }; + }); + } + return { server, config, logger }; + } + async function startHttpServer(configPath, options = {}) { + const port = options.port || 3000; + const stateless = options.stateless || false; + const { server, config, logger } = createMCPServer(configPath, { logDir: options.logDir }); + logger.debug(`Starting HTTP server on port ${port}`); + logger.debug(`Mode: ${stateless ? "stateless" : "stateful"}`); + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: stateless ? undefined : () => randomUUID(), + enableJsonResponse: true, + enableDnsRebindingProtection: false, + }); + await server.connect(transport); + const httpServer = http.createServer(async (req, res) => { + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); + res.setHeader("Access-Control-Allow-Headers", "Content-Type, Accept"); + if (req.method === "OPTIONS") { + res.writeHead(200); + res.end(); + return; + } + if (req.method !== "POST") { + res.writeHead(405, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Method not allowed" })); + return; + } + try { + let body = null; + if (req.method === "POST") { + const chunks = []; + for await (const chunk of req) { + chunks.push(chunk); + } + const bodyStr = Buffer.concat(chunks).toString(); + try { + body = bodyStr ? JSON.parse(bodyStr) : null; + } catch (parseError) { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + jsonrpc: "2.0", + error: { + code: -32700, + message: "Parse error: Invalid JSON in request body", + }, + id: null, + }) + ); + return; + } + } + await transport.handleRequest(req, res, body); + } catch (error) { + logger.debugError("Error handling request: ", error); + if (!res.headersSent) { + res.writeHead(500, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + jsonrpc: "2.0", + error: { + code: -32603, + message: error instanceof Error ? error.message : String(error), + }, + id: null, + }) + ); + } + } + }); + httpServer.listen(port, () => { + logger.debug(`HTTP server listening on http://localhost:${port}`); + logger.debug(`MCP endpoint: POST http://localhost:${port}/`); + logger.debug(`Server name: ${config.serverName || "safeinputs"}`); + logger.debug(`Server version: ${config.version || "1.0.0"}`); + logger.debug(`Tools available: ${config.tools.length}`); + }); + process.on("SIGINT", () => { + logger.debug("Received SIGINT, shutting down..."); + httpServer.close(() => { + logger.debug("HTTP server closed"); + process.exit(0); + }); + }); + process.on("SIGTERM", () => { + logger.debug("Received SIGTERM, shutting down..."); + httpServer.close(() => { + logger.debug("HTTP server closed"); + process.exit(0); + }); + }); + return httpServer; + } + if (require.main === module) { + const args = process.argv.slice(2); + if (args.length < 1) { + console.error("Usage: node safe_inputs_mcp_server_http.cjs [--port ] [--stateless] [--log-dir ]"); + process.exit(1); + } + const configPath = args[0]; + const options = { + port: 3000, + stateless: false, + logDir: undefined, + }; + for (let i = 1; i < args.length; i++) { + if (args[i] === "--port" && args[i + 1]) { + options.port = parseInt(args[i + 1], 10); + i++; + } else if (args[i] === "--stateless") { + options.stateless = true; + } else if (args[i] === "--log-dir" && args[i + 1]) { + options.logDir = args[i + 1]; + i++; + } + } + startHttpServer(configPath, options).catch(error => { + console.error(`Error starting HTTP server: ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); + }); + } + module.exports = { + startHttpServer, + createMCPServer, + }; + EOF_SAFE_INPUTS_SERVER_HTTP cat > /tmp/gh-aw/safe-inputs/tools.json << 'EOF_TOOLS_JSON' { "serverName": "safeinputs", diff --git a/.github/workflows/daily-performance-summary.lock.yml b/.github/workflows/daily-performance-summary.lock.yml index 0a9a490ba11..3b1bbb132e8 100644 --- a/.github/workflows/daily-performance-summary.lock.yml +++ b/.github/workflows/daily-performance-summary.lock.yml @@ -3099,6 +3099,194 @@ jobs: createToolConfig, }; EOF_SAFE_INPUTS_SERVER + cat > /tmp/gh-aw/safe-inputs/safe_inputs_mcp_server_http.cjs << 'EOF_SAFE_INPUTS_SERVER_HTTP' + const path = require("path"); + const http = require("http"); + const { randomUUID } = require("crypto"); + const { McpServer } = require("@modelcontextprotocol/sdk/server/mcp.js"); + const { StreamableHTTPServerTransport } = require("@modelcontextprotocol/sdk/server/streamableHttp.js"); + const { loadConfig } = require("./safe_inputs_config_loader.cjs"); + const { loadToolHandlers } = require("./mcp_server_core.cjs"); + const { validateRequiredFields } = require("./safe_inputs_validation.cjs"); + function createMCPServer(configPath, options = {}) { + const config = loadConfig(configPath); + const basePath = path.dirname(configPath); + const serverName = config.serverName || "safeinputs"; + const version = config.version || "1.0.0"; + const server = new McpServer( + { + name: serverName, + version: version, + }, + { + capabilities: { + tools: {}, + }, + } + ); + const logger = { + debug: msg => { + const timestamp = new Date().toISOString(); + process.stderr.write(`[${timestamp}] [${serverName}] ${msg}\n`); + }, + debugError: (prefix, error) => { + const errorMessage = error instanceof Error ? error.message : String(error); + logger.debug(`${prefix}${errorMessage}`); + if (error instanceof Error && error.stack) { + logger.debug(`${prefix}Stack trace: ${error.stack}`); + } + }, + }; + logger.debug(`Loading safe-inputs configuration from: ${configPath}`); + logger.debug(`Base path for handlers: ${basePath}`); + logger.debug(`Tools to load: ${config.tools.length}`); + const tempServer = { debug: logger.debug, debugError: logger.debugError }; + const tools = loadToolHandlers(tempServer, config.tools, basePath); + for (const tool of tools) { + if (!tool.handler) { + logger.debug(`Skipping tool ${tool.name} - no handler loaded`); + continue; + } + logger.debug(`Registering tool: ${tool.name}`); + server.tool(tool.name, tool.description || "", tool.inputSchema || { type: "object", properties: {} }, async args => { + logger.debug(`Calling handler for tool: ${tool.name}`); + const missing = validateRequiredFields(args, tool.inputSchema); + if (missing.length) { + throw new Error(`Invalid arguments: missing or empty ${missing.map(m => `'${m}'`).join(", ")}`); + } + const result = await Promise.resolve(tool.handler(args)); + logger.debug(`Handler returned for tool: ${tool.name}`); + const content = result && result.content ? result.content : []; + return { content, isError: false }; + }); + } + return { server, config, logger }; + } + async function startHttpServer(configPath, options = {}) { + const port = options.port || 3000; + const stateless = options.stateless || false; + const { server, config, logger } = createMCPServer(configPath, { logDir: options.logDir }); + logger.debug(`Starting HTTP server on port ${port}`); + logger.debug(`Mode: ${stateless ? "stateless" : "stateful"}`); + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: stateless ? undefined : () => randomUUID(), + enableJsonResponse: true, + enableDnsRebindingProtection: false, + }); + await server.connect(transport); + const httpServer = http.createServer(async (req, res) => { + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); + res.setHeader("Access-Control-Allow-Headers", "Content-Type, Accept"); + if (req.method === "OPTIONS") { + res.writeHead(200); + res.end(); + return; + } + if (req.method !== "POST") { + res.writeHead(405, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Method not allowed" })); + return; + } + try { + let body = null; + if (req.method === "POST") { + const chunks = []; + for await (const chunk of req) { + chunks.push(chunk); + } + const bodyStr = Buffer.concat(chunks).toString(); + try { + body = bodyStr ? JSON.parse(bodyStr) : null; + } catch (parseError) { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + jsonrpc: "2.0", + error: { + code: -32700, + message: "Parse error: Invalid JSON in request body", + }, + id: null, + }) + ); + return; + } + } + await transport.handleRequest(req, res, body); + } catch (error) { + logger.debugError("Error handling request: ", error); + if (!res.headersSent) { + res.writeHead(500, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + jsonrpc: "2.0", + error: { + code: -32603, + message: error instanceof Error ? error.message : String(error), + }, + id: null, + }) + ); + } + } + }); + httpServer.listen(port, () => { + logger.debug(`HTTP server listening on http://localhost:${port}`); + logger.debug(`MCP endpoint: POST http://localhost:${port}/`); + logger.debug(`Server name: ${config.serverName || "safeinputs"}`); + logger.debug(`Server version: ${config.version || "1.0.0"}`); + logger.debug(`Tools available: ${config.tools.length}`); + }); + process.on("SIGINT", () => { + logger.debug("Received SIGINT, shutting down..."); + httpServer.close(() => { + logger.debug("HTTP server closed"); + process.exit(0); + }); + }); + process.on("SIGTERM", () => { + logger.debug("Received SIGTERM, shutting down..."); + httpServer.close(() => { + logger.debug("HTTP server closed"); + process.exit(0); + }); + }); + return httpServer; + } + if (require.main === module) { + const args = process.argv.slice(2); + if (args.length < 1) { + console.error("Usage: node safe_inputs_mcp_server_http.cjs [--port ] [--stateless] [--log-dir ]"); + process.exit(1); + } + const configPath = args[0]; + const options = { + port: 3000, + stateless: false, + logDir: undefined, + }; + for (let i = 1; i < args.length; i++) { + if (args[i] === "--port" && args[i + 1]) { + options.port = parseInt(args[i + 1], 10); + i++; + } else if (args[i] === "--stateless") { + options.stateless = true; + } else if (args[i] === "--log-dir" && args[i + 1]) { + options.logDir = args[i + 1]; + i++; + } + } + startHttpServer(configPath, options).catch(error => { + console.error(`Error starting HTTP server: ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); + }); + } + module.exports = { + startHttpServer, + createMCPServer, + }; + EOF_SAFE_INPUTS_SERVER_HTTP cat > /tmp/gh-aw/safe-inputs/tools.json << 'EOF_TOOLS_JSON' { "serverName": "safeinputs", diff --git a/.github/workflows/daily-workflow-updater.lock.yml b/.github/workflows/daily-workflow-updater.lock.yml index 8bca9adf467..c53595e209c 100644 --- a/.github/workflows/daily-workflow-updater.lock.yml +++ b/.github/workflows/daily-workflow-updater.lock.yml @@ -481,7 +481,7 @@ jobs: which awf awf --version - name: Install GitHub Copilot CLI - run: npm install -g @github/copilot@0.0.366 + run: npm install -g @github/copilot@0.0.367 - name: Downloading container images run: | set -e @@ -670,6 +670,17 @@ jobs: } } } + function validateRequiredFields(args, inputSchema) { + const requiredFields = inputSchema && Array.isArray(inputSchema.required) ? inputSchema.required : []; + if (!requiredFields.length) { + return []; + } + const missing = requiredFields.filter(f => { + const value = args[f]; + return value === undefined || value === null || (typeof value === "string" && value.trim() === ""); + }); + return missing; + } const encoder = new TextEncoder(); function initLogFile(server) { if (server.logFileInitialized || !server.logDir || !server.logFilePath) return; @@ -1123,16 +1134,10 @@ jobs: server.replyError(id, -32603, `No handler for tool: ${name}`); return; } - const requiredFields = tool.inputSchema && Array.isArray(tool.inputSchema.required) ? tool.inputSchema.required : []; - if (requiredFields.length) { - const missing = requiredFields.filter(f => { - const value = args[f]; - return value === undefined || value === null || (typeof value === "string" && value.trim() === ""); - }); - if (missing.length) { - server.replyError(id, -32602, `Invalid arguments: missing or empty ${missing.map(m => `'${m}'`).join(", ")}`); - return; - } + const missing = validateRequiredFields(args, tool.inputSchema); + if (missing.length) { + server.replyError(id, -32602, `Invalid arguments: missing or empty ${missing.map(m => `'${m}'`).join(", ")}`); + return; } server.debug(`Calling handler for tool: ${name}`); const result = await Promise.resolve(handler(args)); @@ -1779,7 +1784,7 @@ jobs: engine_name: "GitHub Copilot CLI", model: "", version: "", - agent_version: "0.0.366", + agent_version: "0.0.367", workflow_name: "Daily Workflow Updater", experimental: false, supports_tools_allowlist: true, @@ -2256,7 +2261,7 @@ jobs: run: | set -o pipefail sudo -E awf --env-all --container-workdir "${GITHUB_WORKSPACE}" --mount /tmp:/tmp:rw --mount "${GITHUB_WORKSPACE}:${GITHUB_WORKSPACE}:rw" --mount /usr/bin/date:/usr/bin/date:ro --mount /usr/bin/gh:/usr/bin/gh:ro --mount /usr/bin/yq:/usr/bin/yq:ro --allow-domains '*.githubusercontent.com,api.enterprise.githubcopilot.com,api.github.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,codeload.github.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github-cloud.githubusercontent.com,github-cloud.s3.amazonaws.com,github.com,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,lfs.github.com,objects.githubusercontent.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com' --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs \ - -- npx -y @github/copilot@0.0.366 --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --add-dir "${GITHUB_WORKSPACE}" --disable-builtin-mcps --allow-tool github --allow-tool safeoutputs --allow-tool 'shell(cat)' --allow-tool 'shell(date)' --allow-tool 'shell(echo)' --allow-tool 'shell(gh aw update --verbose)' --allow-tool 'shell(git add .github/aw/actions-lock.json)' --allow-tool 'shell(git add:*)' --allow-tool 'shell(git branch:*)' --allow-tool 'shell(git checkout:*)' --allow-tool 'shell(git commit)' --allow-tool 'shell(git commit:*)' --allow-tool 'shell(git diff .github/aw/actions-lock.json)' --allow-tool 'shell(git merge:*)' --allow-tool 'shell(git push)' --allow-tool 'shell(git rm:*)' --allow-tool 'shell(git status)' --allow-tool 'shell(git switch:*)' --allow-tool 'shell(grep)' --allow-tool 'shell(head)' --allow-tool 'shell(ls)' --allow-tool 'shell(pwd)' --allow-tool 'shell(sort)' --allow-tool 'shell(tail)' --allow-tool 'shell(uniq)' --allow-tool 'shell(wc)' --allow-tool 'shell(yq)' --allow-tool write --allow-all-paths --prompt "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)" \ + -- npx -y @github/copilot@0.0.367 --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --add-dir "${GITHUB_WORKSPACE}" --disable-builtin-mcps --allow-tool github --allow-tool safeoutputs --allow-tool 'shell(cat)' --allow-tool 'shell(date)' --allow-tool 'shell(echo)' --allow-tool 'shell(gh aw update --verbose)' --allow-tool 'shell(git add .github/aw/actions-lock.json)' --allow-tool 'shell(git add:*)' --allow-tool 'shell(git branch:*)' --allow-tool 'shell(git checkout:*)' --allow-tool 'shell(git commit)' --allow-tool 'shell(git commit:*)' --allow-tool 'shell(git diff .github/aw/actions-lock.json)' --allow-tool 'shell(git merge:*)' --allow-tool 'shell(git push)' --allow-tool 'shell(git rm:*)' --allow-tool 'shell(git status)' --allow-tool 'shell(git switch:*)' --allow-tool 'shell(grep)' --allow-tool 'shell(head)' --allow-tool 'shell(ls)' --allow-tool 'shell(pwd)' --allow-tool 'shell(sort)' --allow-tool 'shell(tail)' --allow-tool 'shell(uniq)' --allow-tool 'shell(wc)' --allow-tool 'shell(yq)' --allow-tool write --allow-all-paths --prompt "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)" \ 2>&1 | tee /tmp/gh-aw/agent-stdio.log env: COPILOT_AGENT_RUNNER_TYPE: STANDALONE @@ -6442,7 +6447,7 @@ jobs: node-version: '24' package-manager-cache: false - name: Install GitHub Copilot CLI - run: npm install -g @github/copilot@0.0.366 + run: npm install -g @github/copilot@0.0.367 - name: Execute GitHub Copilot CLI id: agentic_execution # Copilot CLI tool arguments (sorted): diff --git a/.github/workflows/dev.lock.yml b/.github/workflows/dev.lock.yml index 6762c000a88..044a8fe63e2 100644 --- a/.github/workflows/dev.lock.yml +++ b/.github/workflows/dev.lock.yml @@ -1056,6 +1056,194 @@ jobs: createToolConfig, }; EOF_SAFE_INPUTS_SERVER + cat > /tmp/gh-aw/safe-inputs/safe_inputs_mcp_server_http.cjs << 'EOF_SAFE_INPUTS_SERVER_HTTP' + const path = require("path"); + const http = require("http"); + const { randomUUID } = require("crypto"); + const { McpServer } = require("@modelcontextprotocol/sdk/server/mcp.js"); + const { StreamableHTTPServerTransport } = require("@modelcontextprotocol/sdk/server/streamableHttp.js"); + const { loadConfig } = require("./safe_inputs_config_loader.cjs"); + const { loadToolHandlers } = require("./mcp_server_core.cjs"); + const { validateRequiredFields } = require("./safe_inputs_validation.cjs"); + function createMCPServer(configPath, options = {}) { + const config = loadConfig(configPath); + const basePath = path.dirname(configPath); + const serverName = config.serverName || "safeinputs"; + const version = config.version || "1.0.0"; + const server = new McpServer( + { + name: serverName, + version: version, + }, + { + capabilities: { + tools: {}, + }, + } + ); + const logger = { + debug: msg => { + const timestamp = new Date().toISOString(); + process.stderr.write(`[${timestamp}] [${serverName}] ${msg}\n`); + }, + debugError: (prefix, error) => { + const errorMessage = error instanceof Error ? error.message : String(error); + logger.debug(`${prefix}${errorMessage}`); + if (error instanceof Error && error.stack) { + logger.debug(`${prefix}Stack trace: ${error.stack}`); + } + }, + }; + logger.debug(`Loading safe-inputs configuration from: ${configPath}`); + logger.debug(`Base path for handlers: ${basePath}`); + logger.debug(`Tools to load: ${config.tools.length}`); + const tempServer = { debug: logger.debug, debugError: logger.debugError }; + const tools = loadToolHandlers(tempServer, config.tools, basePath); + for (const tool of tools) { + if (!tool.handler) { + logger.debug(`Skipping tool ${tool.name} - no handler loaded`); + continue; + } + logger.debug(`Registering tool: ${tool.name}`); + server.tool(tool.name, tool.description || "", tool.inputSchema || { type: "object", properties: {} }, async args => { + logger.debug(`Calling handler for tool: ${tool.name}`); + const missing = validateRequiredFields(args, tool.inputSchema); + if (missing.length) { + throw new Error(`Invalid arguments: missing or empty ${missing.map(m => `'${m}'`).join(", ")}`); + } + const result = await Promise.resolve(tool.handler(args)); + logger.debug(`Handler returned for tool: ${tool.name}`); + const content = result && result.content ? result.content : []; + return { content, isError: false }; + }); + } + return { server, config, logger }; + } + async function startHttpServer(configPath, options = {}) { + const port = options.port || 3000; + const stateless = options.stateless || false; + const { server, config, logger } = createMCPServer(configPath, { logDir: options.logDir }); + logger.debug(`Starting HTTP server on port ${port}`); + logger.debug(`Mode: ${stateless ? "stateless" : "stateful"}`); + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: stateless ? undefined : () => randomUUID(), + enableJsonResponse: true, + enableDnsRebindingProtection: false, + }); + await server.connect(transport); + const httpServer = http.createServer(async (req, res) => { + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); + res.setHeader("Access-Control-Allow-Headers", "Content-Type, Accept"); + if (req.method === "OPTIONS") { + res.writeHead(200); + res.end(); + return; + } + if (req.method !== "POST") { + res.writeHead(405, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Method not allowed" })); + return; + } + try { + let body = null; + if (req.method === "POST") { + const chunks = []; + for await (const chunk of req) { + chunks.push(chunk); + } + const bodyStr = Buffer.concat(chunks).toString(); + try { + body = bodyStr ? JSON.parse(bodyStr) : null; + } catch (parseError) { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + jsonrpc: "2.0", + error: { + code: -32700, + message: "Parse error: Invalid JSON in request body", + }, + id: null, + }) + ); + return; + } + } + await transport.handleRequest(req, res, body); + } catch (error) { + logger.debugError("Error handling request: ", error); + if (!res.headersSent) { + res.writeHead(500, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + jsonrpc: "2.0", + error: { + code: -32603, + message: error instanceof Error ? error.message : String(error), + }, + id: null, + }) + ); + } + } + }); + httpServer.listen(port, () => { + logger.debug(`HTTP server listening on http://localhost:${port}`); + logger.debug(`MCP endpoint: POST http://localhost:${port}/`); + logger.debug(`Server name: ${config.serverName || "safeinputs"}`); + logger.debug(`Server version: ${config.version || "1.0.0"}`); + logger.debug(`Tools available: ${config.tools.length}`); + }); + process.on("SIGINT", () => { + logger.debug("Received SIGINT, shutting down..."); + httpServer.close(() => { + logger.debug("HTTP server closed"); + process.exit(0); + }); + }); + process.on("SIGTERM", () => { + logger.debug("Received SIGTERM, shutting down..."); + httpServer.close(() => { + logger.debug("HTTP server closed"); + process.exit(0); + }); + }); + return httpServer; + } + if (require.main === module) { + const args = process.argv.slice(2); + if (args.length < 1) { + console.error("Usage: node safe_inputs_mcp_server_http.cjs [--port ] [--stateless] [--log-dir ]"); + process.exit(1); + } + const configPath = args[0]; + const options = { + port: 3000, + stateless: false, + logDir: undefined, + }; + for (let i = 1; i < args.length; i++) { + if (args[i] === "--port" && args[i + 1]) { + options.port = parseInt(args[i + 1], 10); + i++; + } else if (args[i] === "--stateless") { + options.stateless = true; + } else if (args[i] === "--log-dir" && args[i + 1]) { + options.logDir = args[i + 1]; + i++; + } + } + startHttpServer(configPath, options).catch(error => { + console.error(`Error starting HTTP server: ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); + }); + } + module.exports = { + startHttpServer, + createMCPServer, + }; + EOF_SAFE_INPUTS_SERVER_HTTP cat > /tmp/gh-aw/safe-inputs/tools.json << 'EOF_TOOLS_JSON' { "serverName": "safeinputs", diff --git a/.github/workflows/release.lock.yml b/.github/workflows/release.lock.yml index cc1b79b3b27..dfde9652038 100644 --- a/.github/workflows/release.lock.yml +++ b/.github/workflows/release.lock.yml @@ -5989,7 +5989,7 @@ jobs: format: cyclonedx-json output-file: sbom.cdx.json - name: Upload SBOM artifacts - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 with: name: sbom-artifacts path: | diff --git a/.github/workflows/test-python-safe-input.lock.yml b/.github/workflows/test-python-safe-input.lock.yml index df62c445d8e..2e42c748b87 100644 --- a/.github/workflows/test-python-safe-input.lock.yml +++ b/.github/workflows/test-python-safe-input.lock.yml @@ -2288,6 +2288,194 @@ jobs: createToolConfig, }; EOF_SAFE_INPUTS_SERVER + cat > /tmp/gh-aw/safe-inputs/safe_inputs_mcp_server_http.cjs << 'EOF_SAFE_INPUTS_SERVER_HTTP' + const path = require("path"); + const http = require("http"); + const { randomUUID } = require("crypto"); + const { McpServer } = require("@modelcontextprotocol/sdk/server/mcp.js"); + const { StreamableHTTPServerTransport } = require("@modelcontextprotocol/sdk/server/streamableHttp.js"); + const { loadConfig } = require("./safe_inputs_config_loader.cjs"); + const { loadToolHandlers } = require("./mcp_server_core.cjs"); + const { validateRequiredFields } = require("./safe_inputs_validation.cjs"); + function createMCPServer(configPath, options = {}) { + const config = loadConfig(configPath); + const basePath = path.dirname(configPath); + const serverName = config.serverName || "safeinputs"; + const version = config.version || "1.0.0"; + const server = new McpServer( + { + name: serverName, + version: version, + }, + { + capabilities: { + tools: {}, + }, + } + ); + const logger = { + debug: msg => { + const timestamp = new Date().toISOString(); + process.stderr.write(`[${timestamp}] [${serverName}] ${msg}\n`); + }, + debugError: (prefix, error) => { + const errorMessage = error instanceof Error ? error.message : String(error); + logger.debug(`${prefix}${errorMessage}`); + if (error instanceof Error && error.stack) { + logger.debug(`${prefix}Stack trace: ${error.stack}`); + } + }, + }; + logger.debug(`Loading safe-inputs configuration from: ${configPath}`); + logger.debug(`Base path for handlers: ${basePath}`); + logger.debug(`Tools to load: ${config.tools.length}`); + const tempServer = { debug: logger.debug, debugError: logger.debugError }; + const tools = loadToolHandlers(tempServer, config.tools, basePath); + for (const tool of tools) { + if (!tool.handler) { + logger.debug(`Skipping tool ${tool.name} - no handler loaded`); + continue; + } + logger.debug(`Registering tool: ${tool.name}`); + server.tool(tool.name, tool.description || "", tool.inputSchema || { type: "object", properties: {} }, async args => { + logger.debug(`Calling handler for tool: ${tool.name}`); + const missing = validateRequiredFields(args, tool.inputSchema); + if (missing.length) { + throw new Error(`Invalid arguments: missing or empty ${missing.map(m => `'${m}'`).join(", ")}`); + } + const result = await Promise.resolve(tool.handler(args)); + logger.debug(`Handler returned for tool: ${tool.name}`); + const content = result && result.content ? result.content : []; + return { content, isError: false }; + }); + } + return { server, config, logger }; + } + async function startHttpServer(configPath, options = {}) { + const port = options.port || 3000; + const stateless = options.stateless || false; + const { server, config, logger } = createMCPServer(configPath, { logDir: options.logDir }); + logger.debug(`Starting HTTP server on port ${port}`); + logger.debug(`Mode: ${stateless ? "stateless" : "stateful"}`); + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: stateless ? undefined : () => randomUUID(), + enableJsonResponse: true, + enableDnsRebindingProtection: false, + }); + await server.connect(transport); + const httpServer = http.createServer(async (req, res) => { + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); + res.setHeader("Access-Control-Allow-Headers", "Content-Type, Accept"); + if (req.method === "OPTIONS") { + res.writeHead(200); + res.end(); + return; + } + if (req.method !== "POST") { + res.writeHead(405, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Method not allowed" })); + return; + } + try { + let body = null; + if (req.method === "POST") { + const chunks = []; + for await (const chunk of req) { + chunks.push(chunk); + } + const bodyStr = Buffer.concat(chunks).toString(); + try { + body = bodyStr ? JSON.parse(bodyStr) : null; + } catch (parseError) { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + jsonrpc: "2.0", + error: { + code: -32700, + message: "Parse error: Invalid JSON in request body", + }, + id: null, + }) + ); + return; + } + } + await transport.handleRequest(req, res, body); + } catch (error) { + logger.debugError("Error handling request: ", error); + if (!res.headersSent) { + res.writeHead(500, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + jsonrpc: "2.0", + error: { + code: -32603, + message: error instanceof Error ? error.message : String(error), + }, + id: null, + }) + ); + } + } + }); + httpServer.listen(port, () => { + logger.debug(`HTTP server listening on http://localhost:${port}`); + logger.debug(`MCP endpoint: POST http://localhost:${port}/`); + logger.debug(`Server name: ${config.serverName || "safeinputs"}`); + logger.debug(`Server version: ${config.version || "1.0.0"}`); + logger.debug(`Tools available: ${config.tools.length}`); + }); + process.on("SIGINT", () => { + logger.debug("Received SIGINT, shutting down..."); + httpServer.close(() => { + logger.debug("HTTP server closed"); + process.exit(0); + }); + }); + process.on("SIGTERM", () => { + logger.debug("Received SIGTERM, shutting down..."); + httpServer.close(() => { + logger.debug("HTTP server closed"); + process.exit(0); + }); + }); + return httpServer; + } + if (require.main === module) { + const args = process.argv.slice(2); + if (args.length < 1) { + console.error("Usage: node safe_inputs_mcp_server_http.cjs [--port ] [--stateless] [--log-dir ]"); + process.exit(1); + } + const configPath = args[0]; + const options = { + port: 3000, + stateless: false, + logDir: undefined, + }; + for (let i = 1; i < args.length; i++) { + if (args[i] === "--port" && args[i + 1]) { + options.port = parseInt(args[i + 1], 10); + i++; + } else if (args[i] === "--stateless") { + options.stateless = true; + } else if (args[i] === "--log-dir" && args[i + 1]) { + options.logDir = args[i + 1]; + i++; + } + } + startHttpServer(configPath, options).catch(error => { + console.error(`Error starting HTTP server: ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); + }); + } + module.exports = { + startHttpServer, + createMCPServer, + }; + EOF_SAFE_INPUTS_SERVER_HTTP cat > /tmp/gh-aw/safe-inputs/tools.json << 'EOF_TOOLS_JSON' { "serverName": "safeinputs", From 0fca12154f41477f48cc4d5ffa571a05b9ddd7f5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 5 Dec 2025 16:37:42 +0000 Subject: [PATCH 3/7] Changes before error encountered Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/cli/workflows/test-custom-mounts.md | 16 + pkg/parser/schemas/main_workflow_schema.json | 12 + pkg/workflow/copilot_engine.go | 8 + pkg/workflow/sandbox.go | 45 +++ pkg/workflow/sandbox_mounts_test.go | 312 +++++++++++++++++++ 5 files changed, 393 insertions(+) create mode 100644 pkg/cli/workflows/test-custom-mounts.md create mode 100644 pkg/workflow/sandbox_mounts_test.go diff --git a/pkg/cli/workflows/test-custom-mounts.md b/pkg/cli/workflows/test-custom-mounts.md new file mode 100644 index 00000000000..f775766478d --- /dev/null +++ b/pkg/cli/workflows/test-custom-mounts.md @@ -0,0 +1,16 @@ +--- +name: Test Custom Mounts +on: workflow_dispatch +engine: copilot +sandbox: + agent: + id: awf + mounts: + - "/host/data:/data:ro" + - "/usr/local/bin/custom-tool:/usr/local/bin/custom-tool:ro" +network: + allowed: + - defaults +--- + +Test workflow to verify custom mounts configuration. diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 0509ae2d877..23905fd08e6 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -1792,6 +1792,18 @@ "type": "string" } }, + "mounts": { + "type": "array", + "description": "Container mounts to add when using AWF. Each mount is specified using Docker mount syntax: 'source:destination:mode' where mode can be 'ro' (read-only) or 'rw' (read-write). Example: '/host/path:/container/path:ro'", + "items": { + "type": "string", + "pattern": "^[^:]+:[^:]+:(ro|rw)$", + "description": "Mount specification in format 'source:destination:mode'" + }, + "examples": [ + ["/host/data:/data:ro", "/usr/local/bin/custom-tool:/usr/local/bin/custom-tool:ro"] + ] + }, "config": { "type": "object", "description": "Custom Sandbox Runtime configuration (only applies when type is 'srt'). Note: Network configuration is controlled by the top-level 'network' field, not here.", diff --git a/pkg/workflow/copilot_engine.go b/pkg/workflow/copilot_engine.go index 9cf3b4ab193..4d12e949a12 100644 --- a/pkg/workflow/copilot_engine.go +++ b/pkg/workflow/copilot_engine.go @@ -356,6 +356,14 @@ func (e *CopilotEngine) GetExecutionSteps(workflowData *WorkflowData, logFile st awfArgs = append(awfArgs, "--mount", "/usr/bin/yq:/usr/bin/yq:ro") copilotLog.Print("Added gh CLI binary mount to AWF container") + // Add custom mounts from agent config if specified + if agentConfig != nil && len(agentConfig.Mounts) > 0 { + for _, mount := range agentConfig.Mounts { + awfArgs = append(awfArgs, "--mount", mount) + } + copilotLog.Printf("Added %d custom mounts from agent config", len(agentConfig.Mounts)) + } + awfArgs = append(awfArgs, "--allow-domains", allowedDomains) awfArgs = append(awfArgs, "--log-level", awfLogLevel) awfArgs = append(awfArgs, "--proxy-logs-dir", "/tmp/gh-aw/sandbox/firewall/logs") diff --git a/pkg/workflow/sandbox.go b/pkg/workflow/sandbox.go index c5b63f3bc4e..be07c825b2d 100644 --- a/pkg/workflow/sandbox.go +++ b/pkg/workflow/sandbox.go @@ -3,6 +3,7 @@ package workflow import ( "encoding/json" "fmt" + "strings" "github.com/githubnext/gh-aw/pkg/logger" ) @@ -41,6 +42,7 @@ type AgentSandboxConfig struct { Command string `yaml:"command,omitempty"` // Custom command to replace AWF or SRT installation Args []string `yaml:"args,omitempty"` // Additional arguments to append to the command Env map[string]string `yaml:"env,omitempty"` // Environment variables to set on the step + Mounts []string `yaml:"mounts,omitempty"` // Container mounts to add for AWF (format: "source:dest:mode") } // SandboxRuntimeConfig represents the Anthropic Sandbox Runtime configuration @@ -226,6 +228,41 @@ func generateSRTConfigJSON(workflowData *WorkflowData) (string, error) { return string(jsonBytes), nil } +// validateMountsSyntax validates that mount strings follow the correct syntax +// Expected format: "source:destination:mode" where mode is either "ro" or "rw" +func validateMountsSyntax(mounts []string) error { + for i, mount := range mounts { + // Split the mount string by colons + parts := strings.Split(mount, ":") + + // Must have exactly 3 parts: source, destination, mode + if len(parts) != 3 { + return fmt.Errorf("invalid mount syntax at index %d: '%s'. Expected format: 'source:destination:mode' (e.g., '/host/path:/container/path:ro')", i, mount) + } + + source := parts[0] + dest := parts[1] + mode := parts[2] + + // Validate that source and destination are not empty + if source == "" { + return fmt.Errorf("invalid mount at index %d: source path is empty in '%s'", i, mount) + } + if dest == "" { + return fmt.Errorf("invalid mount at index %d: destination path is empty in '%s'", i, mount) + } + + // Validate mode is either "ro" or "rw" + if mode != "ro" && mode != "rw" { + return fmt.Errorf("invalid mount at index %d: mode must be 'ro' (read-only) or 'rw' (read-write), got '%s' in '%s'", i, mode, mount) + } + + sandboxLog.Printf("Validated mount %d: source=%s, dest=%s, mode=%s", i, source, dest, mode) + } + + return nil +} + // validateSandboxConfig validates the sandbox configuration // Returns an error if the configuration is invalid func validateSandboxConfig(workflowData *WorkflowData) error { @@ -235,6 +272,14 @@ func validateSandboxConfig(workflowData *WorkflowData) error { sandboxConfig := workflowData.SandboxConfig + // Validate mounts syntax if specified + agentConfig := getAgentConfig(workflowData) + if agentConfig != nil && len(agentConfig.Mounts) > 0 { + if err := validateMountsSyntax(agentConfig.Mounts); err != nil { + return err + } + } + // Validate that SRT is only used with Copilot engine if isSRTEnabled(workflowData) { // Check if the sandbox-runtime feature flag is enabled diff --git a/pkg/workflow/sandbox_mounts_test.go b/pkg/workflow/sandbox_mounts_test.go new file mode 100644 index 00000000000..fac1c152110 --- /dev/null +++ b/pkg/workflow/sandbox_mounts_test.go @@ -0,0 +1,312 @@ +package workflow + +import ( + "strings" + "testing" +) + +// TestValidateMountsSyntax tests the mount syntax validation function +func TestValidateMountsSyntax(t *testing.T) { + tests := []struct { + name string + mounts []string + wantErr bool + errMsg string + }{ + { + name: "valid read-only mount", + mounts: []string{"/host/path:/container/path:ro"}, + wantErr: false, + }, + { + name: "valid read-write mount", + mounts: []string{"/host/path:/container/path:rw"}, + wantErr: false, + }, + { + name: "multiple valid mounts", + mounts: []string{ + "/host/data:/data:ro", + "/usr/local/bin/tool:/usr/local/bin/tool:ro", + "/tmp/cache:/cache:rw", + }, + wantErr: false, + }, + { + name: "empty mounts list", + mounts: []string{}, + wantErr: false, + }, + { + name: "invalid mount - too few parts", + mounts: []string{"/host/path:/container/path"}, + wantErr: true, + errMsg: "invalid mount syntax", + }, + { + name: "invalid mount - too many parts", + mounts: []string{"/host/path:/container/path:ro:extra"}, + wantErr: true, + errMsg: "invalid mount syntax", + }, + { + name: "invalid mount - empty source", + mounts: []string{":/container/path:ro"}, + wantErr: true, + errMsg: "source path is empty", + }, + { + name: "invalid mount - empty destination", + mounts: []string{"/host/path::ro"}, + wantErr: true, + errMsg: "destination path is empty", + }, + { + name: "invalid mount - invalid mode", + mounts: []string{"/host/path:/container/path:xyz"}, + wantErr: true, + errMsg: "mode must be 'ro' (read-only) or 'rw' (read-write)", + }, + { + name: "invalid mount - uppercase mode", + mounts: []string{"/host/path:/container/path:RO"}, + wantErr: true, + errMsg: "mode must be 'ro' (read-only) or 'rw' (read-write)", + }, + { + name: "mixed valid and invalid mounts", + mounts: []string{ + "/host/data:/data:ro", + "/invalid:mount", + }, + wantErr: true, + errMsg: "invalid mount syntax at index 1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateMountsSyntax(tt.mounts) + + if tt.wantErr && err == nil { + t.Errorf("validateMountsSyntax() expected error but got none") + } + + if !tt.wantErr && err != nil { + t.Errorf("validateMountsSyntax() unexpected error: %v", err) + } + + if tt.wantErr && err != nil && tt.errMsg != "" { + if !strings.Contains(err.Error(), tt.errMsg) { + t.Errorf("validateMountsSyntax() error message = %v, want to contain %v", err.Error(), tt.errMsg) + } + } + }) + } +} + +// TestSandboxConfigWithMounts tests that sandbox configuration with mounts is validated +func TestSandboxConfigWithMounts(t *testing.T) { + tests := []struct { + name string + data *WorkflowData + wantErr bool + errMsg string + }{ + { + name: "valid mounts in agent config", + data: &WorkflowData{ + Name: "test-workflow", + EngineConfig: &EngineConfig{ + ID: "copilot", + }, + SandboxConfig: &SandboxConfig{ + Agent: &AgentSandboxConfig{ + ID: "awf", + Mounts: []string{ + "/host/data:/data:ro", + "/usr/local/bin/tool:/usr/local/bin/tool:ro", + }, + }, + }, + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{ + Enabled: true, + }, + }, + }, + wantErr: false, + }, + { + name: "no mounts in agent config", + data: &WorkflowData{ + Name: "test-workflow", + EngineConfig: &EngineConfig{ + ID: "copilot", + }, + SandboxConfig: &SandboxConfig{ + Agent: &AgentSandboxConfig{ + ID: "awf", + }, + }, + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{ + Enabled: true, + }, + }, + }, + wantErr: false, + }, + { + name: "invalid mount syntax in agent config", + data: &WorkflowData{ + Name: "test-workflow", + EngineConfig: &EngineConfig{ + ID: "copilot", + }, + SandboxConfig: &SandboxConfig{ + Agent: &AgentSandboxConfig{ + ID: "awf", + Mounts: []string{ + "/host/data:/data:ro", + "/invalid:mount", // Invalid - only 2 parts + }, + }, + }, + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{ + Enabled: true, + }, + }, + }, + wantErr: true, + errMsg: "invalid mount syntax", + }, + { + name: "invalid mode in mount", + data: &WorkflowData{ + Name: "test-workflow", + EngineConfig: &EngineConfig{ + ID: "copilot", + }, + SandboxConfig: &SandboxConfig{ + Agent: &AgentSandboxConfig{ + ID: "awf", + Mounts: []string{ + "/host/data:/data:invalid", + }, + }, + }, + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{ + Enabled: true, + }, + }, + }, + wantErr: true, + errMsg: "mode must be 'ro' (read-only) or 'rw' (read-write)", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateSandboxConfig(tt.data) + + if tt.wantErr && err == nil { + t.Errorf("validateSandboxConfig() expected error but got none") + } + + if !tt.wantErr && err != nil { + t.Errorf("validateSandboxConfig() unexpected error: %v", err) + } + + if tt.wantErr && err != nil && tt.errMsg != "" { + if !strings.Contains(err.Error(), tt.errMsg) { + t.Errorf("validateSandboxConfig() error message = %v, want to contain %v", err.Error(), tt.errMsg) + } + } + }) + } +} + +// TestCopilotEngineWithCustomMounts tests that custom mounts are included in AWF command +func TestCopilotEngineWithCustomMounts(t *testing.T) { + t.Run("custom mounts are included in AWF command", func(t *testing.T) { + workflowData := &WorkflowData{ + Name: "test-workflow", + EngineConfig: &EngineConfig{ + ID: "copilot", + }, + SandboxConfig: &SandboxConfig{ + Agent: &AgentSandboxConfig{ + ID: "awf", + Mounts: []string{ + "/host/data:/data:ro", + "/usr/local/bin/custom-tool:/usr/local/bin/custom-tool:ro", + }, + }, + }, + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{ + Enabled: true, + }, + }, + } + + engine := NewCopilotEngine() + steps := engine.GetExecutionSteps(workflowData, "test.log") + + if len(steps) == 0 { + t.Fatal("Expected at least one execution step") + } + + stepContent := strings.Join(steps[0], "\n") + + // Check that custom mounts are included + if !strings.Contains(stepContent, "--mount /host/data:/data:ro") { + t.Error("Expected command to contain custom mount '--mount /host/data:/data:ro'") + } + + if !strings.Contains(stepContent, "--mount /usr/local/bin/custom-tool:/usr/local/bin/custom-tool:ro") { + t.Error("Expected command to contain custom mount '--mount /usr/local/bin/custom-tool:/usr/local/bin/custom-tool:ro'") + } + + // Verify standard mounts are still present + if !strings.Contains(stepContent, "--mount /tmp:/tmp:rw") { + t.Error("Expected command to still contain standard mount '--mount /tmp:/tmp:rw'") + } + }) + + t.Run("no custom mounts when not specified", func(t *testing.T) { + workflowData := &WorkflowData{ + Name: "test-workflow", + EngineConfig: &EngineConfig{ + ID: "copilot", + }, + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{ + Enabled: true, + }, + }, + } + + engine := NewCopilotEngine() + steps := engine.GetExecutionSteps(workflowData, "test.log") + + if len(steps) == 0 { + t.Fatal("Expected at least one execution step") + } + + stepContent := strings.Join(steps[0], "\n") + + // Verify standard mounts are present + if !strings.Contains(stepContent, "--mount /tmp:/tmp:rw") { + t.Error("Expected command to contain standard mount '--mount /tmp:/tmp:rw'") + } + + // Custom mount should not be present + if strings.Contains(stepContent, "--mount /host/data:/data:ro") { + t.Error("Did not expect custom mount in output when not configured") + } + }) +} From 149d7692f2572c2bc649c5b0cdc4262e41959404 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 5 Dec 2025 17:39:43 +0000 Subject: [PATCH 4/7] Add mounts field extraction to frontmatter parser - completes mounts implementation Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/release.lock.yml | 2 +- pkg/parser/schemas/main_workflow_schema.json | 4 +--- pkg/workflow/frontmatter_extraction.go | 11 +++++++++++ pkg/workflow/sandbox.go | 12 ++++++------ pkg/workflow/sandbox_mounts_test.go | 12 ++++++------ 5 files changed, 25 insertions(+), 16 deletions(-) diff --git a/.github/workflows/release.lock.yml b/.github/workflows/release.lock.yml index dfde9652038..cc1b79b3b27 100644 --- a/.github/workflows/release.lock.yml +++ b/.github/workflows/release.lock.yml @@ -5989,7 +5989,7 @@ jobs: format: cyclonedx-json output-file: sbom.cdx.json - name: Upload SBOM artifacts - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: sbom-artifacts path: | diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 23905fd08e6..965bb393caa 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -1800,9 +1800,7 @@ "pattern": "^[^:]+:[^:]+:(ro|rw)$", "description": "Mount specification in format 'source:destination:mode'" }, - "examples": [ - ["/host/data:/data:ro", "/usr/local/bin/custom-tool:/usr/local/bin/custom-tool:ro"] - ] + "examples": [["/host/data:/data:ro", "/usr/local/bin/custom-tool:/usr/local/bin/custom-tool:ro"]] }, "config": { "type": "object", diff --git a/pkg/workflow/frontmatter_extraction.go b/pkg/workflow/frontmatter_extraction.go index 53946d2f615..e86fd0f1a41 100644 --- a/pkg/workflow/frontmatter_extraction.go +++ b/pkg/workflow/frontmatter_extraction.go @@ -838,6 +838,17 @@ func (c *Compiler) extractAgentSandboxConfig(agentVal any) *AgentSandboxConfig { } } + // Extract mounts (container mounts for AWF) + if mountsVal, hasMounts := agentObj["mounts"]; hasMounts { + if mountsSlice, ok := mountsVal.([]any); ok { + for _, mount := range mountsSlice { + if mountStr, ok := mount.(string); ok { + agentConfig.Mounts = append(agentConfig.Mounts, mountStr) + } + } + } + } + return agentConfig } diff --git a/pkg/workflow/sandbox.go b/pkg/workflow/sandbox.go index be07c825b2d..93b536efee3 100644 --- a/pkg/workflow/sandbox.go +++ b/pkg/workflow/sandbox.go @@ -234,16 +234,16 @@ func validateMountsSyntax(mounts []string) error { for i, mount := range mounts { // Split the mount string by colons parts := strings.Split(mount, ":") - + // Must have exactly 3 parts: source, destination, mode if len(parts) != 3 { return fmt.Errorf("invalid mount syntax at index %d: '%s'. Expected format: 'source:destination:mode' (e.g., '/host/path:/container/path:ro')", i, mount) } - + source := parts[0] dest := parts[1] mode := parts[2] - + // Validate that source and destination are not empty if source == "" { return fmt.Errorf("invalid mount at index %d: source path is empty in '%s'", i, mount) @@ -251,15 +251,15 @@ func validateMountsSyntax(mounts []string) error { if dest == "" { return fmt.Errorf("invalid mount at index %d: destination path is empty in '%s'", i, mount) } - + // Validate mode is either "ro" or "rw" if mode != "ro" && mode != "rw" { return fmt.Errorf("invalid mount at index %d: mode must be 'ro' (read-only) or 'rw' (read-write), got '%s' in '%s'", i, mode, mount) } - + sandboxLog.Printf("Validated mount %d: source=%s, dest=%s, mode=%s", i, source, dest, mode) } - + return nil } diff --git a/pkg/workflow/sandbox_mounts_test.go b/pkg/workflow/sandbox_mounts_test.go index fac1c152110..8678acc56de 100644 --- a/pkg/workflow/sandbox_mounts_test.go +++ b/pkg/workflow/sandbox_mounts_test.go @@ -87,15 +87,15 @@ func TestValidateMountsSyntax(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { err := validateMountsSyntax(tt.mounts) - + if tt.wantErr && err == nil { t.Errorf("validateMountsSyntax() expected error but got none") } - + if !tt.wantErr && err != nil { t.Errorf("validateMountsSyntax() unexpected error: %v", err) } - + if tt.wantErr && err != nil && tt.errMsg != "" { if !strings.Contains(err.Error(), tt.errMsg) { t.Errorf("validateMountsSyntax() error message = %v, want to contain %v", err.Error(), tt.errMsg) @@ -211,15 +211,15 @@ func TestSandboxConfigWithMounts(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { err := validateSandboxConfig(tt.data) - + if tt.wantErr && err == nil { t.Errorf("validateSandboxConfig() expected error but got none") } - + if !tt.wantErr && err != nil { t.Errorf("validateSandboxConfig() unexpected error: %v", err) } - + if tt.wantErr && err != nil && tt.errMsg != "" { if !strings.Contains(err.Error(), tt.errMsg) { t.Errorf("validateSandboxConfig() error message = %v, want to contain %v", err.Error(), tt.errMsg) From 82704140d3830027a32b19e9b35e111d22c8d748 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 5 Dec 2025 17:47:34 +0000 Subject: [PATCH 5/7] Add mounts documentation to sandbox reference guide Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/release.lock.yml | 2 +- docs/src/content/docs/reference/sandbox.md | 25 ++++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.lock.yml b/.github/workflows/release.lock.yml index cc1b79b3b27..dfde9652038 100644 --- a/.github/workflows/release.lock.yml +++ b/.github/workflows/release.lock.yml @@ -5989,7 +5989,7 @@ jobs: format: cyclonedx-json output-file: sbom.cdx.json - name: Upload SBOM artifacts - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 with: name: sbom-artifacts path: | diff --git a/docs/src/content/docs/reference/sandbox.md b/docs/src/content/docs/reference/sandbox.md index c7b932558bc..39b77e656e0 100644 --- a/docs/src/content/docs/reference/sandbox.md +++ b/docs/src/content/docs/reference/sandbox.md @@ -119,12 +119,37 @@ sandbox: DEBUG_LEVEL: "verbose" ``` +##### Custom Mounts + +Add custom container mounts to make host paths available inside the AWF container: + +```yaml wrap +sandbox: + agent: + id: awf + mounts: + - "/host/data:/data:ro" + - "/usr/local/bin/custom-tool:/usr/local/bin/custom-tool:ro" + - "/tmp/cache:/cache:rw" +``` + +Mount syntax follows Docker's format: `source:destination:mode` +- `source`: Path on the host system +- `destination`: Path inside the container +- `mode`: Either `ro` (read-only) or `rw` (read-write) + +Custom mounts are useful for: +- Providing access to datasets or configuration files +- Making custom tools available in the container +- Sharing cache directories between host and container + | Field | Type | Description | |-------|------|-------------| | `id` | `string` | Agent identifier: `awf` or `srt` | | `command` | `string` | Custom command to replace AWF binary installation | | `args` | `string[]` | Additional arguments appended to the command | | `env` | `object` | Environment variables set on the execution step | +| `mounts` | `string[]` | Container mounts using syntax `source:destination:mode` | When `command` is specified, the standard AWF installation is skipped and your custom command is used instead. From 8808c24d9a1730797d307c56bcc3cf6a6094aedb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 5 Dec 2025 21:12:47 +0000 Subject: [PATCH 6/7] Merge main and recompile workflows Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/daily-team-status.lock.yml | 12 ++++++------ .github/workflows/release.lock.yml | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/daily-team-status.lock.yml b/.github/workflows/daily-team-status.lock.yml index 7183d1331d8..aff54b163e7 100644 --- a/.github/workflows/daily-team-status.lock.yml +++ b/.github/workflows/daily-team-status.lock.yml @@ -2242,8 +2242,7 @@ jobs: with: script: | async function main() { - const path = require("path"); - const crypto = require("crypto"); + const path = require("path"); const redactedDomains = []; function getRedactedDomains() { return [...redactedDomains]; @@ -2438,6 +2437,7 @@ jobs: return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, (match, action, ref) => `\`${action} #${ref}\``); } } + const crypto = require("crypto"); const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; function generateTemporaryId() { return "aw_" + crypto.randomBytes(6).toString("hex"); @@ -3854,8 +3854,8 @@ jobs: return lines.join("\n"); } function runLogParser(options) { - const fs = require("fs"); - const path = require("path"); + const fs = require("fs"); + const path = require("path"); const { parseLog, parserName, supportsDirectories = false } = options; try { const logPath = process.env.GH_AW_AGENT_OUTPUT; @@ -4467,9 +4467,9 @@ jobs: function main() { - const fs = require("fs"); + const fs = require("fs"); - const path = require("path"); + const path = require("path"); try { diff --git a/.github/workflows/release.lock.yml b/.github/workflows/release.lock.yml index c4fb5534d39..42223a57b58 100644 --- a/.github/workflows/release.lock.yml +++ b/.github/workflows/release.lock.yml @@ -5985,7 +5985,7 @@ jobs: format: cyclonedx-json output-file: sbom.cdx.json - name: Upload SBOM artifacts - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: sbom-artifacts path: | From 16cb6fb718feeb1b5286686e75b87d68424d4bc3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 5 Dec 2025 21:25:50 +0000 Subject: [PATCH 7/7] Sort custom mounts alphabetically for consistent output Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/workflow/copilot_engine.go | 9 +++- pkg/workflow/sandbox_mounts_test.go | 66 +++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/pkg/workflow/copilot_engine.go b/pkg/workflow/copilot_engine.go index 4d12e949a12..f327a260adf 100644 --- a/pkg/workflow/copilot_engine.go +++ b/pkg/workflow/copilot_engine.go @@ -358,10 +358,15 @@ func (e *CopilotEngine) GetExecutionSteps(workflowData *WorkflowData, logFile st // Add custom mounts from agent config if specified if agentConfig != nil && len(agentConfig.Mounts) > 0 { - for _, mount := range agentConfig.Mounts { + // Sort mounts for consistent output + sortedMounts := make([]string, len(agentConfig.Mounts)) + copy(sortedMounts, agentConfig.Mounts) + sort.Strings(sortedMounts) + + for _, mount := range sortedMounts { awfArgs = append(awfArgs, "--mount", mount) } - copilotLog.Printf("Added %d custom mounts from agent config", len(agentConfig.Mounts)) + copilotLog.Printf("Added %d custom mounts from agent config", len(sortedMounts)) } awfArgs = append(awfArgs, "--allow-domains", allowedDomains) diff --git a/pkg/workflow/sandbox_mounts_test.go b/pkg/workflow/sandbox_mounts_test.go index 8678acc56de..764a523d6c9 100644 --- a/pkg/workflow/sandbox_mounts_test.go +++ b/pkg/workflow/sandbox_mounts_test.go @@ -309,4 +309,70 @@ func TestCopilotEngineWithCustomMounts(t *testing.T) { t.Error("Did not expect custom mount in output when not configured") } }) + + t.Run("custom mounts are sorted alphabetically", func(t *testing.T) { + workflowData := &WorkflowData{ + Name: "test-workflow", + EngineConfig: &EngineConfig{ + ID: "copilot", + }, + SandboxConfig: &SandboxConfig{ + Agent: &AgentSandboxConfig{ + ID: "awf", + Mounts: []string{ + "/var/log:/logs:ro", + "/data:/data:rw", + "/usr/bin/tool:/usr/bin/tool:ro", + "/etc/config:/etc/config:ro", + }, + }, + }, + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{ + Enabled: true, + }, + }, + } + + engine := NewCopilotEngine() + steps := engine.GetExecutionSteps(workflowData, "test.log") + + if len(steps) == 0 { + t.Fatal("Expected at least one execution step") + } + + stepContent := strings.Join(steps[0], "\n") + + // Find the positions of each mount in the output + dataPos := strings.Index(stepContent, "--mount /data:/data:rw") + etcPos := strings.Index(stepContent, "--mount /etc/config:/etc/config:ro") + usrPos := strings.Index(stepContent, "--mount /usr/bin/tool:/usr/bin/tool:ro") + varPos := strings.Index(stepContent, "--mount /var/log:/logs:ro") + + // Verify all mounts are present + if dataPos == -1 { + t.Error("Expected to find mount '/data:/data:rw'") + } + if etcPos == -1 { + t.Error("Expected to find mount '/etc/config:/etc/config:ro'") + } + if usrPos == -1 { + t.Error("Expected to find mount '/usr/bin/tool:/usr/bin/tool:ro'") + } + if varPos == -1 { + t.Error("Expected to find mount '/var/log:/logs:ro'") + } + + // Verify mounts are in alphabetical order + // Expected order: /data, /etc, /usr, /var + if dataPos != -1 && etcPos != -1 && dataPos >= etcPos { + t.Error("Expected '/data:/data:rw' to appear before '/etc/config:/etc/config:ro'") + } + if etcPos != -1 && usrPos != -1 && etcPos >= usrPos { + t.Error("Expected '/etc/config:/etc/config:ro' to appear before '/usr/bin/tool:/usr/bin/tool:ro'") + } + if usrPos != -1 && varPos != -1 && usrPos >= varPos { + t.Error("Expected '/usr/bin/tool:/usr/bin/tool:ro' to appear before '/var/log:/logs:ro'") + } + }) }