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