diff --git a/.github/workflows/smoke-opencode.lock.yml b/.github/workflows/smoke-opencode.lock.yml new file mode 100644 index 00000000000..1887856b9bf --- /dev/null +++ b/.github/workflows/smoke-opencode.lock.yml @@ -0,0 +1,1386 @@ +# +# ___ _ _ +# / _ \ | | (_) +# | |_| | __ _ ___ _ __ | |_ _ ___ +# | _ |/ _` |/ _ \ '_ \| __| |/ __| +# | | | | (_| | __/ | | | |_| | (__ +# \_| |_/\__, |\___|_| |_|\__|_|\___| +# __/ | +# _ _ |___/ +# | | | | / _| | +# | | | | ___ _ __ _ __| |_| | _____ ____ +# | |/\| |/ _ \ '__| |/ /| _| |/ _ \ \ /\ / / ___| +# \ /\ / (_) | | | | ( | | | | (_) \ V V /\__ \ +# \/ \/ \___/|_| |_|\_\|_| |_|\___/ \_/\_/ |___/ +# +# This file was automatically generated by gh-aw. DO NOT EDIT. +# +# To update this file, edit the corresponding .md file and run: +# gh aw compile +# Not all edits will cause changes to this file. +# +# For more information: https://github.github.com/gh-aw/introduction/overview/ +# +# Smoke test workflow that validates OpenCode engine functionality twice daily +# +# Resolved workflow manifest: +# Imports: +# - shared/gh.md +# - shared/reporting.md +# +# gh-aw-metadata: {"schema_version":"v1","frontmatter_hash":"5aaf9357c6016ab92346db3d3c78918aa789c9c1040d0ba928469f0af3f9312f"} + +name: "Smoke OpenCode" +"on": + pull_request: + # names: # Label filtering applied via job conditions + # - smoke # Label filtering applied via job conditions + types: + - labeled + schedule: + - cron: "48 */12 * * *" + workflow_dispatch: null + +permissions: {} + +concurrency: + group: "gh-aw-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}" + cancel-in-progress: true + +run-name: "Smoke OpenCode" + +jobs: + activation: + needs: pre_activation + if: > + (needs.pre_activation.outputs.activated == 'true') && (((github.event_name != 'pull_request') || (github.event.pull_request.head.repo.id == github.repository_id)) && + ((github.event_name != 'pull_request') || ((github.event.action != 'labeled') || (github.event.label.name == 'smoke')))) + runs-on: ubuntu-slim + permissions: + contents: read + discussions: write + issues: write + pull-requests: write + outputs: + body: ${{ steps.sanitized.outputs.body }} + comment_id: ${{ steps.add-comment.outputs.comment-id }} + comment_repo: ${{ steps.add-comment.outputs.comment-repo }} + comment_url: ${{ steps.add-comment.outputs.comment-url }} + text: ${{ steps.sanitized.outputs.text }} + title: ${{ steps.sanitized.outputs.title }} + steps: + - name: Checkout actions folder + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + sparse-checkout: | + actions + persist-credentials: false + - name: Setup Scripts + uses: ./actions/setup + with: + destination: /opt/gh-aw/actions + - name: Validate context variables + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/validate_context_variables.cjs'); + await main(); + - name: Checkout .github and .agents folders + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + sparse-checkout: | + .github + .agents + fetch-depth: 1 + persist-credentials: false + - name: Check workflow file timestamps + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_WORKFLOW_FILE: "smoke-opencode.lock.yml" + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/check_workflow_timestamp_api.cjs'); + await main(); + - name: Compute current body text + id: sanitized + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/compute_text.cjs'); + await main(); + - name: Add comment with workflow run link + id: add-comment + if: github.event_name == 'issues' || github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment' || github.event_name == 'discussion' || github.event_name == 'discussion_comment' || (github.event_name == 'pull_request') && (github.event.pull_request.head.repo.id == github.repository_id) + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_WORKFLOW_NAME: "Smoke OpenCode" + GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e ⚡ *[{workflow_name}]({run_url}) — Powered by OpenCode*\",\"runStarted\":\"⚡ OpenCode initializing... [{workflow_name}]({run_url}) begins on this {event_type}...\",\"runSuccess\":\"🎯 [{workflow_name}]({run_url}) **MISSION COMPLETE!** OpenCode has delivered. ⚡\",\"runFailure\":\"⚠️ [{workflow_name}]({run_url}) {status}. OpenCode encountered unexpected challenges...\"}" + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/add_workflow_run_comment.cjs'); + await main(); + - name: Create prompt with built-in context + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} + GH_AW_GITHUB_ACTOR: ${{ github.actor }} + GH_AW_GITHUB_EVENT_COMMENT_ID: ${{ github.event.comment.id }} + GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: ${{ github.event.discussion.number }} + GH_AW_GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }} + GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }} + GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} + GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} + GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} + run: | + bash /opt/gh-aw/actions/create_prompt_first.sh + { + cat << 'GH_AW_PROMPT_EOF' + + GH_AW_PROMPT_EOF + cat "/opt/gh-aw/prompts/xpia.md" + cat "/opt/gh-aw/prompts/temp_folder_prompt.md" + cat "/opt/gh-aw/prompts/markdown.md" + cat "/opt/gh-aw/prompts/safe_outputs_prompt.md" + cat << 'GH_AW_PROMPT_EOF' + + Tools: add_comment, create_issue, add_labels, missing_tool, missing_data + + + The following GitHub context information is available for this workflow: + {{#if __GH_AW_GITHUB_ACTOR__ }} + - **actor**: __GH_AW_GITHUB_ACTOR__ + {{/if}} + {{#if __GH_AW_GITHUB_REPOSITORY__ }} + - **repository**: __GH_AW_GITHUB_REPOSITORY__ + {{/if}} + {{#if __GH_AW_GITHUB_WORKSPACE__ }} + - **workspace**: __GH_AW_GITHUB_WORKSPACE__ + {{/if}} + {{#if __GH_AW_GITHUB_EVENT_ISSUE_NUMBER__ }} + - **issue-number**: #__GH_AW_GITHUB_EVENT_ISSUE_NUMBER__ + {{/if}} + {{#if __GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER__ }} + - **discussion-number**: #__GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER__ + {{/if}} + {{#if __GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER__ }} + - **pull-request-number**: #__GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER__ + {{/if}} + {{#if __GH_AW_GITHUB_EVENT_COMMENT_ID__ }} + - **comment-id**: __GH_AW_GITHUB_EVENT_COMMENT_ID__ + {{/if}} + {{#if __GH_AW_GITHUB_RUN_ID__ }} + - **workflow-run-id**: __GH_AW_GITHUB_RUN_ID__ + {{/if}} + + + GH_AW_PROMPT_EOF + cat << 'GH_AW_PROMPT_EOF' + + GH_AW_PROMPT_EOF + cat << 'GH_AW_PROMPT_EOF' + {{#runtime-import .github/workflows/shared/gh.md}} + GH_AW_PROMPT_EOF + cat << 'GH_AW_PROMPT_EOF' + {{#runtime-import .github/workflows/shared/reporting.md}} + GH_AW_PROMPT_EOF + cat << 'GH_AW_PROMPT_EOF' + {{#runtime-import .github/workflows/smoke-opencode.md}} + GH_AW_PROMPT_EOF + } > "$GH_AW_PROMPT" + - name: Interpolate variables and render templates + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} + GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/interpolate_prompt.cjs'); + await main(); + - name: Substitute placeholders + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_GITHUB_ACTOR: ${{ github.actor }} + GH_AW_GITHUB_EVENT_COMMENT_ID: ${{ github.event.comment.id }} + GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: ${{ github.event.discussion.number }} + GH_AW_GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }} + GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }} + GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} + GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} + GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} + GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ACTIVATED: ${{ needs.pre_activation.outputs.activated }} + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + + const substitutePlaceholders = require('/opt/gh-aw/actions/substitute_placeholders.cjs'); + + // Call the substitution function + return await substitutePlaceholders({ + file: process.env.GH_AW_PROMPT, + substitutions: { + GH_AW_GITHUB_ACTOR: process.env.GH_AW_GITHUB_ACTOR, + GH_AW_GITHUB_EVENT_COMMENT_ID: process.env.GH_AW_GITHUB_EVENT_COMMENT_ID, + GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: process.env.GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER, + GH_AW_GITHUB_EVENT_ISSUE_NUMBER: process.env.GH_AW_GITHUB_EVENT_ISSUE_NUMBER, + GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: process.env.GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER, + GH_AW_GITHUB_REPOSITORY: process.env.GH_AW_GITHUB_REPOSITORY, + GH_AW_GITHUB_RUN_ID: process.env.GH_AW_GITHUB_RUN_ID, + GH_AW_GITHUB_WORKSPACE: process.env.GH_AW_GITHUB_WORKSPACE, + GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ACTIVATED: process.env.GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ACTIVATED + } + }); + - name: Validate prompt placeholders + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + run: bash /opt/gh-aw/actions/validate_prompt_placeholders.sh + - name: Print prompt + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + run: bash /opt/gh-aw/actions/print_prompt_summary.sh + - name: Upload prompt artifact + if: success() + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: prompt + path: /tmp/gh-aw/aw-prompts/prompt.txt + retention-days: 1 + + agent: + needs: activation + runs-on: ubuntu-latest + permissions: + contents: read + issues: read + pull-requests: read + env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + GH_AW_ASSETS_ALLOWED_EXTS: "" + GH_AW_ASSETS_BRANCH: "" + GH_AW_ASSETS_MAX_SIZE_KB: 0 + GH_AW_MCP_LOG_DIR: /tmp/gh-aw/mcp-logs/safeoutputs + GH_AW_SAFE_OUTPUTS: /opt/gh-aw/safeoutputs/outputs.jsonl + GH_AW_SAFE_OUTPUTS_CONFIG_PATH: /opt/gh-aw/safeoutputs/config.json + GH_AW_SAFE_OUTPUTS_TOOLS_PATH: /opt/gh-aw/safeoutputs/tools.json + GH_AW_WORKFLOW_ID_SANITIZED: smokeopencode + outputs: + checkout_pr_success: ${{ steps.checkout-pr.outputs.checkout_pr_success || 'true' }} + detection_conclusion: ${{ steps.detection_conclusion.outputs.conclusion }} + detection_success: ${{ steps.detection_conclusion.outputs.success }} + has_patch: ${{ steps.collect_output.outputs.has_patch }} + model: ${{ steps.generate_aw_info.outputs.model }} + output: ${{ steps.collect_output.outputs.output }} + output_types: ${{ steps.collect_output.outputs.output_types }} + secret_verification_result: ${{ steps.validate-secret.outputs.verification_result }} + steps: + - name: Checkout actions folder + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + sparse-checkout: | + actions + persist-credentials: false + - name: Setup Scripts + uses: ./actions/setup + with: + destination: /opt/gh-aw/actions + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - name: Create gh-aw temp directory + run: bash /opt/gh-aw/actions/create_gh_aw_tmp_dir.sh + - name: Configure Git credentials + env: + REPO_NAME: ${{ github.repository }} + SERVER_URL: ${{ github.server_url }} + run: | + git config --global user.email "github-actions[bot]@users.noreply.github.com" + git config --global user.name "github-actions[bot]" + git config --global am.keepcr true + # Re-authenticate git with GitHub token + SERVER_URL_STRIPPED="${SERVER_URL#https://}" + git remote set-url origin "https://x-access-token:${{ github.token }}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" + echo "Git configured with standard GitHub Actions identity" + - name: Checkout PR branch + id: checkout-pr + if: | + github.event.pull_request + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + with: + github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/checkout_pr_branch.cjs'); + await main(); + - name: Generate agentic run info + id: generate_aw_info + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const fs = require('fs'); + + const awInfo = { + engine_id: "opencode", + engine_name: "OpenCode", + model: "anthropic/claude-sonnet-4-20250514", + version: "", + agent_version: "", + workflow_name: "Smoke OpenCode", + experimental: true, + supports_tools_allowlist: false, + run_id: context.runId, + run_number: context.runNumber, + run_attempt: process.env.GITHUB_RUN_ATTEMPT, + repository: context.repo.owner + '/' + context.repo.repo, + ref: context.ref, + sha: context.sha, + actor: context.actor, + event_name: context.eventName, + staged: false, + allowed_domains: ["defaults","github"], + firewall_enabled: false, + awf_version: "", + awmg_version: "v0.1.5", + steps: { + firewall: "squid" + }, + created_at: new Date().toISOString() + }; + + // Write to /tmp/gh-aw directory to avoid inclusion in PR + const tmpPath = '/tmp/gh-aw/aw_info.json'; + fs.writeFileSync(tmpPath, JSON.stringify(awInfo, null, 2)); + console.log('Generated aw_info.json at:', tmpPath); + console.log(JSON.stringify(awInfo, null, 2)); + + // Set model as output for reuse in other steps/jobs + core.setOutput('model', awInfo.model); + - name: Validate ANTHROPIC_API_KEY secret + id: validate-secret + run: /opt/gh-aw/actions/validate_multi_secret.sh ANTHROPIC_API_KEY 'OpenCode CLI' https://opencode.ai/docs/get-started/ + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + - name: Setup Node.js + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + with: + node-version: '24' + package-manager-cache: false + - name: Install awf binary + run: bash /opt/gh-aw/actions/install_awf_binary.sh v0.23.0 + - name: Install OpenCode CLI + run: npm install -g --silent opencode-ai@1.2.14 + - name: Determine automatic lockdown mode for GitHub MCP Server + id: determine-automatic-lockdown + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }} + GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }} + with: + script: | + const determineAutomaticLockdown = require('/opt/gh-aw/actions/determine_automatic_lockdown.cjs'); + await determineAutomaticLockdown(github, context, core); + - name: Download container images + run: bash /opt/gh-aw/actions/download_docker_images.sh ghcr.io/github/gh-aw-firewall/agent:0.23.0 ghcr.io/github/gh-aw-firewall/api-proxy:0.23.0 ghcr.io/github/gh-aw-firewall/squid:0.23.0 ghcr.io/github/gh-aw-mcpg:v0.1.5 ghcr.io/github/github-mcp-server:v0.31.0 node:lts-alpine + - name: Write Safe Outputs Config + run: | + mkdir -p /opt/gh-aw/safeoutputs + mkdir -p /tmp/gh-aw/safeoutputs + mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs + cat > /opt/gh-aw/safeoutputs/config.json << 'GH_AW_SAFE_OUTPUTS_CONFIG_EOF' + {"add_comment":{"max":2},"add_labels":{"allowed":["smoke-opencode"],"max":3},"create_issue":{"expires":2,"max":1},"missing_data":{},"missing_tool":{},"noop":{"max":1}} + GH_AW_SAFE_OUTPUTS_CONFIG_EOF + cat > /opt/gh-aw/safeoutputs/tools.json << 'GH_AW_SAFE_OUTPUTS_TOOLS_EOF' + [ + { + "description": "Create a new GitHub issue for tracking bugs, feature requests, or tasks. Use this for actionable work items that need assignment, labeling, and status tracking. For reports, announcements, or status updates that don't require task tracking, use create_discussion instead. CONSTRAINTS: Maximum 1 issue(s) can be created. Labels [automation testing] will be automatically added.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "body": { + "description": "Detailed issue description in Markdown. Do NOT repeat the title as a heading since it already appears as the issue's h1. Include context, reproduction steps, or acceptance criteria as appropriate.", + "type": "string" + }, + "labels": { + "description": "Labels to categorize the issue (e.g., 'bug', 'enhancement'). Labels must exist in the repository.", + "items": { + "type": "string" + }, + "type": "array" + }, + "parent": { + "description": "Parent issue number for creating sub-issues. This is the numeric ID from the GitHub URL (e.g., 42 in github.com/owner/repo/issues/42). Can also be a temporary_id (e.g., 'aw_abc123', 'aw_Test123') from a previously created issue in the same workflow run.", + "type": [ + "number", + "string" + ] + }, + "temporary_id": { + "description": "Unique temporary identifier for referencing this issue before it's created. Format: 'aw_' followed by 3 to 8 alphanumeric characters (e.g., 'aw_abc1', 'aw_Test123'). Use '#aw_ID' in body text to reference other issues by their temporary_id; these are replaced with actual issue numbers after creation.", + "pattern": "^aw_[A-Za-z0-9]{3,8}$", + "type": "string" + }, + "title": { + "description": "Concise issue title summarizing the bug, feature, or task. The title appears as the main heading, so keep it brief and descriptive.", + "type": "string" + } + }, + "required": [ + "title", + "body" + ], + "type": "object" + }, + "name": "create_issue" + }, + { + "description": "Add a comment to an existing GitHub issue, pull request, or discussion. Use this to provide feedback, answer questions, or add information to an existing conversation. For creating new items, use create_issue, create_discussion, or create_pull_request instead. IMPORTANT: Comments are subject to validation constraints enforced by the MCP server - maximum 65536 characters for the complete comment (including footer which is added automatically), 10 mentions (@username), and 50 links. Exceeding these limits will result in an immediate error with specific guidance. NOTE: By default, this tool requires discussions:write permission. If your GitHub App lacks Discussions permission, set 'discussions: false' in the workflow's safe-outputs.add-comment configuration to exclude this permission. CONSTRAINTS: Maximum 2 comment(s) can be added.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "body": { + "description": "The comment text in Markdown format. This is the 'body' field - do not use 'comment_body' or other variations. Provide helpful, relevant information that adds value to the conversation. CONSTRAINTS: The complete comment (your body text + automatically added footer) must not exceed 65536 characters total. Maximum 10 mentions (@username), maximum 50 links (http/https URLs). A footer (~200-500 characters) is automatically appended with workflow attribution, so leave adequate space. If these limits are exceeded, the tool call will fail with a detailed error message indicating which constraint was violated.", + "type": "string" + }, + "item_number": { + "description": "The issue, pull request, or discussion number to comment on. This is the numeric ID from the GitHub URL (e.g., 123 in github.com/owner/repo/issues/123). If omitted, the tool auto-targets the issue, PR, or discussion that triggered this workflow. Auto-targeting only works for issue, pull_request, discussion, and comment event triggers — it does NOT work for schedule, workflow_dispatch, push, or workflow_run triggers. For those trigger types, always provide item_number explicitly, or the comment will be silently discarded.", + "type": "number" + } + }, + "required": [ + "body" + ], + "type": "object" + }, + "name": "add_comment" + }, + { + "description": "Add labels to an existing GitHub issue or pull request for categorization and filtering. Labels must already exist in the repository. For creating new issues with labels, use create_issue with the labels property instead. CONSTRAINTS: Only these labels are allowed: [smoke-opencode].", + "inputSchema": { + "additionalProperties": false, + "properties": { + "item_number": { + "description": "Issue or PR number to add labels to. This is the numeric ID from the GitHub URL (e.g., 456 in github.com/owner/repo/issues/456). If omitted, adds labels to the issue or PR that triggered this workflow. Only works for issue or pull_request event triggers. For schedule, workflow_dispatch, or other triggers, item_number is required — omitting it will silently skip the label operation.", + "type": "number" + }, + "labels": { + "description": "Label names to add (e.g., ['bug', 'priority-high']). Labels must exist in the repository.", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + }, + "name": "add_labels" + }, + { + "description": "Report that a tool or capability needed to complete the task is not available, or share any information you deem important about missing functionality or limitations. Use this when you cannot accomplish what was requested because the required functionality is missing or access is restricted.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "alternatives": { + "description": "Any workarounds, manual steps, or alternative approaches the user could take (max 256 characters).", + "type": "string" + }, + "reason": { + "description": "Explanation of why this tool is needed or what information you want to share about the limitation (max 256 characters).", + "type": "string" + }, + "tool": { + "description": "Optional: Name or description of the missing tool or capability (max 128 characters). Be specific about what functionality is needed.", + "type": "string" + } + }, + "required": [ + "reason" + ], + "type": "object" + }, + "name": "missing_tool" + }, + { + "description": "Log a transparency message when no significant actions are needed. Use this to confirm workflow completion and provide visibility when analysis is complete but no changes or outputs are required (e.g., 'No issues found', 'All checks passed'). This ensures the workflow produces human-visible output even when no other actions are taken.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "message": { + "description": "Status or completion message to log. Should explain what was analyzed and the outcome (e.g., 'Code review complete - no issues found', 'Analysis complete - all tests passing').", + "type": "string" + } + }, + "required": [ + "message" + ], + "type": "object" + }, + "name": "noop" + }, + { + "description": "Report that data or information needed to complete the task is not available. Use this when you cannot accomplish what was requested because required data, context, or information is missing.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "alternatives": { + "description": "Any workarounds, manual steps, or alternative approaches the user could take (max 256 characters).", + "type": "string" + }, + "context": { + "description": "Additional context about the missing data or where it should come from (max 256 characters).", + "type": "string" + }, + "data_type": { + "description": "Type or description of the missing data or information (max 128 characters). Be specific about what data is needed.", + "type": "string" + }, + "reason": { + "description": "Explanation of why this data is needed to complete the task (max 256 characters).", + "type": "string" + } + }, + "required": [], + "type": "object" + }, + "name": "missing_data" + } + ] + GH_AW_SAFE_OUTPUTS_TOOLS_EOF + cat > /opt/gh-aw/safeoutputs/validation.json << 'GH_AW_SAFE_OUTPUTS_VALIDATION_EOF' + { + "add_comment": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "item_number": { + "issueOrPRNumber": true + }, + "repo": { + "type": "string", + "maxLength": 256 + } + } + }, + "add_labels": { + "defaultMax": 5, + "fields": { + "item_number": { + "issueOrPRNumber": true + }, + "labels": { + "required": true, + "type": "array", + "itemType": "string", + "itemSanitize": true, + "itemMaxLength": 128 + }, + "repo": { + "type": "string", + "maxLength": 256 + } + } + }, + "create_issue": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "labels": { + "type": "array", + "itemType": "string", + "itemSanitize": true, + "itemMaxLength": 128 + }, + "parent": { + "issueOrPRNumber": true + }, + "repo": { + "type": "string", + "maxLength": 256 + }, + "temporary_id": { + "type": "string" + }, + "title": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "missing_data": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "context": { + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "data_type": { + "type": "string", + "sanitize": true, + "maxLength": 128 + }, + "reason": { + "type": "string", + "sanitize": true, + "maxLength": 256 + } + } + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + } + } + GH_AW_SAFE_OUTPUTS_VALIDATION_EOF + - name: Generate Safe Outputs MCP Server Config + id: safe-outputs-config + run: | + # Generate a secure random API key (360 bits of entropy, 40+ chars) + # Mask immediately to prevent timing vulnerabilities + API_KEY=$(openssl rand -base64 45 | tr -d '/+=') + echo "::add-mask::${API_KEY}" + + PORT=3001 + + # Set outputs for next steps + { + echo "safe_outputs_api_key=${API_KEY}" + echo "safe_outputs_port=${PORT}" + } >> "$GITHUB_OUTPUT" + + echo "Safe Outputs MCP server will run on port ${PORT}" + + - name: Start Safe Outputs MCP HTTP Server + id: safe-outputs-start + env: + DEBUG: '*' + GH_AW_SAFE_OUTPUTS_PORT: ${{ steps.safe-outputs-config.outputs.safe_outputs_port }} + GH_AW_SAFE_OUTPUTS_API_KEY: ${{ steps.safe-outputs-config.outputs.safe_outputs_api_key }} + GH_AW_SAFE_OUTPUTS_TOOLS_PATH: /opt/gh-aw/safeoutputs/tools.json + GH_AW_SAFE_OUTPUTS_CONFIG_PATH: /opt/gh-aw/safeoutputs/config.json + GH_AW_MCP_LOG_DIR: /tmp/gh-aw/mcp-logs/safeoutputs + run: | + # Environment variables are set above to prevent template injection + export DEBUG + export GH_AW_SAFE_OUTPUTS_PORT + export GH_AW_SAFE_OUTPUTS_API_KEY + export GH_AW_SAFE_OUTPUTS_TOOLS_PATH + export GH_AW_SAFE_OUTPUTS_CONFIG_PATH + export GH_AW_MCP_LOG_DIR + + bash /opt/gh-aw/actions/start_safe_outputs_server.sh + + - name: Setup Safe Inputs Config + run: | + mkdir -p /opt/gh-aw/safe-inputs/logs + cat > /opt/gh-aw/safe-inputs/tools.json << 'GH_AW_SAFE_INPUTS_TOOLS_EOF' + { + "serverName": "safeinputs", + "version": "1.0.0", + "logDir": "/opt/gh-aw/safe-inputs/logs", + "tools": [ + { + "name": "gh", + "description": "Execute any gh CLI command. This tool is accessible as 'safeinputs-gh'. Provide the full command after 'gh' (e.g., args: 'pr list --limit 5'). The tool will run: gh \u003cargs\u003e. Use single quotes ' for complex args to avoid shell interpretation issues.", + "inputSchema": { + "properties": { + "args": { + "description": "Arguments to pass to gh CLI (without the 'gh' prefix). Examples: 'pr list --limit 5', 'issue view 123', 'api repos/{owner}/{repo}'", + "type": "string" + } + }, + "required": [ + "args" + ], + "type": "object" + }, + "handler": "gh.sh", + "env": { + "GH_AW_GH_TOKEN": "GH_AW_GH_TOKEN", + "GH_DEBUG": "GH_DEBUG" + }, + "timeout": 60 + } + ] + } + GH_AW_SAFE_INPUTS_TOOLS_EOF + cat > /opt/gh-aw/safe-inputs/mcp-server.cjs << 'GH_AW_SAFE_INPUTS_SERVER_EOF' + const path = require("path"); + const { startHttpServer } = require("./safe_inputs_mcp_server_http.cjs"); + const configPath = path.join(__dirname, "tools.json"); + const port = parseInt(process.env.GH_AW_SAFE_INPUTS_PORT || "3000", 10); + const apiKey = process.env.GH_AW_SAFE_INPUTS_API_KEY || ""; + startHttpServer(configPath, { + port: port, + stateless: true, + logDir: "/opt/gh-aw/safe-inputs/logs" + }).catch(error => { + console.error("Failed to start safe-inputs HTTP server:", error); + process.exit(1); + }); + GH_AW_SAFE_INPUTS_SERVER_EOF + chmod +x /opt/gh-aw/safe-inputs/mcp-server.cjs + + - name: Setup Safe Inputs Tool Files + run: | + cat > /opt/gh-aw/safe-inputs/gh.sh << 'GH_AW_SAFE_INPUTS_SH_GH_EOF' + #!/bin/bash + # Auto-generated safe-input tool: gh + # Execute any gh CLI command. This tool is accessible as 'safeinputs-gh'. Provide the full command after 'gh' (e.g., args: 'pr list --limit 5'). The tool will run: gh . Use single quotes ' for complex args to avoid shell interpretation issues. + + set -euo pipefail + + echo "gh $INPUT_ARGS" + echo " token: ${GH_AW_GH_TOKEN:0:6}..." + GH_TOKEN="$GH_AW_GH_TOKEN" gh $INPUT_ARGS + + GH_AW_SAFE_INPUTS_SH_GH_EOF + chmod +x /opt/gh-aw/safe-inputs/gh.sh + + - name: Generate Safe Inputs MCP Server Config + id: safe-inputs-config + run: | + # Generate a secure random API key (360 bits of entropy, 40+ chars) + # Mask immediately to prevent timing vulnerabilities + API_KEY=$(openssl rand -base64 45 | tr -d '/+=') + echo "::add-mask::${API_KEY}" + + PORT=3000 + + # Set outputs for next steps + { + echo "safe_inputs_api_key=${API_KEY}" + echo "safe_inputs_port=${PORT}" + } >> "$GITHUB_OUTPUT" + + echo "Safe Inputs MCP server will run on port ${PORT}" + + - name: Start Safe Inputs MCP HTTP Server + id: safe-inputs-start + env: + DEBUG: '*' + GH_AW_SAFE_INPUTS_PORT: ${{ steps.safe-inputs-config.outputs.safe_inputs_port }} + GH_AW_SAFE_INPUTS_API_KEY: ${{ steps.safe-inputs-config.outputs.safe_inputs_api_key }} + GH_AW_GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_DEBUG: 1 + run: | + # Environment variables are set above to prevent template injection + export DEBUG + export GH_AW_SAFE_INPUTS_PORT + export GH_AW_SAFE_INPUTS_API_KEY + + bash /opt/gh-aw/actions/start_safe_inputs_server.sh + + - name: Start MCP Gateway + id: start-mcp-gateway + env: + GH_AW_GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_AW_SAFE_INPUTS_API_KEY: ${{ steps.safe-inputs-start.outputs.api_key }} + GH_AW_SAFE_INPUTS_PORT: ${{ steps.safe-inputs-start.outputs.port }} + GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} + GH_AW_SAFE_OUTPUTS_API_KEY: ${{ steps.safe-outputs-start.outputs.api_key }} + GH_AW_SAFE_OUTPUTS_PORT: ${{ steps.safe-outputs-start.outputs.port }} + GH_DEBUG: 1 + GITHUB_MCP_LOCKDOWN: ${{ steps.determine-automatic-lockdown.outputs.lockdown == 'true' && '1' || '0' }} + GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + run: | + set -eo pipefail + mkdir -p /tmp/gh-aw/mcp-config + + # Export gateway environment variables for MCP config and gateway script + export MCP_GATEWAY_PORT="80" + export MCP_GATEWAY_DOMAIN="host.docker.internal" + MCP_GATEWAY_API_KEY=$(openssl rand -base64 45 | tr -d '/+=') + echo "::add-mask::${MCP_GATEWAY_API_KEY}" + export MCP_GATEWAY_API_KEY + export MCP_GATEWAY_PAYLOAD_DIR="/tmp/gh-aw/mcp-payloads" + mkdir -p "${MCP_GATEWAY_PAYLOAD_DIR}" + export DEBUG="*" + + export GH_AW_ENGINE="opencode" + export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host -v /var/run/docker.sock:/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_LOCKDOWN -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e GH_AW_SAFE_INPUTS_PORT -e GH_AW_SAFE_INPUTS_API_KEY -e GH_AW_SAFE_OUTPUTS_PORT -e GH_AW_SAFE_OUTPUTS_API_KEY -e GH_AW_GH_TOKEN -e GH_DEBUG -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.1.5' + + cat << GH_AW_MCP_CONFIG_EOF | bash /opt/gh-aw/actions/start_mcp_gateway.sh + { + "mcpServers": { + "github": { + "container": "ghcr.io/github/github-mcp-server:v0.31.0", + "env": { + "GITHUB_LOCKDOWN_MODE": "$GITHUB_MCP_LOCKDOWN", + "GITHUB_PERSONAL_ACCESS_TOKEN": "$GITHUB_MCP_SERVER_TOKEN", + "GITHUB_READ_ONLY": "1", + "GITHUB_TOOLSETS": "context,repos,issues,pull_requests" + } + }, + "safeinputs": { + "type": "http", + "url": "http://host.docker.internal:$GH_AW_SAFE_INPUTS_PORT", + "headers": { + "Authorization": "$GH_AW_SAFE_INPUTS_API_KEY" + } + }, + "safeoutputs": { + "type": "http", + "url": "http://host.docker.internal:$GH_AW_SAFE_OUTPUTS_PORT", + "headers": { + "Authorization": "$GH_AW_SAFE_OUTPUTS_API_KEY" + } + } + }, + "gateway": { + "port": $MCP_GATEWAY_PORT, + "domain": "${MCP_GATEWAY_DOMAIN}", + "apiKey": "${MCP_GATEWAY_API_KEY}", + "payloadDir": "${MCP_GATEWAY_PAYLOAD_DIR}" + } + } + GH_AW_MCP_CONFIG_EOF + - name: Generate workflow overview + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const { generateWorkflowOverview } = require('/opt/gh-aw/actions/generate_workflow_overview.cjs'); + await generateWorkflowOverview(core); + - name: Download prompt artifact + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: prompt + path: /tmp/gh-aw/aw-prompts + - name: Clean git credentials + run: bash /opt/gh-aw/actions/clean_git_credentials.sh + - name: Write OpenCode configuration + run: | + mkdir -p "$GITHUB_WORKSPACE" + CONFIG="$GITHUB_WORKSPACE/opencode.jsonc" + BASE_CONFIG='{"agent":{"build":{"permissions":{"bash":"allow","edit":"allow","read":"allow","glob":"allow","grep":"allow","write":"allow","webfetch":"allow","websearch":"allow"}}}}' + if [ -f "$CONFIG" ]; then + MERGED=$(jq -n --argjson base "$BASE_CONFIG" --argjson existing "$(cat "$CONFIG")" '$existing * $base') + echo "$MERGED" > "$CONFIG" + else + echo "$BASE_CONFIG" > "$CONFIG" + fi + - name: Execute OpenCode CLI + id: agentic_execution + run: | + set -o pipefail + # shellcheck disable=SC1003 + sudo -E awf --env-all --container-workdir "${GITHUB_WORKSPACE}" --allow-domains "*.githubusercontent.com,api.anthropic.com,api.openai.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,codeload.github.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,generativelanguage.googleapis.com,github-cloud.githubusercontent.com,github-cloud.s3.amazonaws.com,github.githubassets.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,lfs.github.com,objects.githubusercontent.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com" --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --enable-host-access --image-tag 0.23.0 --skip-pull --enable-api-proxy \ + -- /bin/bash -c 'export PATH="$(find /opt/hostedtoolcache -maxdepth 4 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true && opencode run -q "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + ANTHROPIC_BASE_URL: http://host.docker.internal:10004 + GH_AW_MCP_CONFIG: ${{ github.workspace }}/opencode.jsonc + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} + GITHUB_WORKSPACE: ${{ github.workspace }} + NO_PROXY: localhost,127.0.0.1 + OPENCODE_MODEL: anthropic/claude-sonnet-4-20250514 + - name: Configure Git credentials + env: + REPO_NAME: ${{ github.repository }} + SERVER_URL: ${{ github.server_url }} + run: | + git config --global user.email "github-actions[bot]@users.noreply.github.com" + git config --global user.name "github-actions[bot]" + git config --global am.keepcr true + # Re-authenticate git with GitHub token + SERVER_URL_STRIPPED="${SERVER_URL#https://}" + git remote set-url origin "https://x-access-token:${{ github.token }}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" + echo "Git configured with standard GitHub Actions identity" + - name: Stop MCP Gateway + if: always() + continue-on-error: true + env: + MCP_GATEWAY_PORT: ${{ steps.start-mcp-gateway.outputs.gateway-port }} + MCP_GATEWAY_API_KEY: ${{ steps.start-mcp-gateway.outputs.gateway-api-key }} + GATEWAY_PID: ${{ steps.start-mcp-gateway.outputs.gateway-pid }} + run: | + bash /opt/gh-aw/actions/stop_mcp_gateway.sh "$GATEWAY_PID" + - name: Redact secrets in logs + if: always() + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/redact_secrets.cjs'); + await main(); + env: + GH_AW_SECRET_NAMES: 'ANTHROPIC_API_KEY,GH_AW_GITHUB_MCP_SERVER_TOKEN,GH_AW_GITHUB_TOKEN,GITHUB_TOKEN' + SECRET_ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + SECRET_GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }} + SECRET_GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }} + SECRET_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Upload Safe Outputs + if: always() + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: safe-output + path: ${{ env.GH_AW_SAFE_OUTPUTS }} + if-no-files-found: warn + - name: Ingest agent output + id: collect_output + if: always() + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} + GH_AW_ALLOWED_DOMAINS: "*.githubusercontent.com,api.anthropic.com,api.openai.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,codeload.github.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,generativelanguage.googleapis.com,github-cloud.githubusercontent.com,github-cloud.s3.amazonaws.com,github.githubassets.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,lfs.github.com,objects.githubusercontent.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com" + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_API_URL: ${{ github.api_url }} + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/collect_ndjson_output.cjs'); + await main(); + - name: Upload sanitized agent output + if: always() && env.GH_AW_AGENT_OUTPUT + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: agent-output + path: ${{ env.GH_AW_AGENT_OUTPUT }} + if-no-files-found: warn + - name: Parse Safe Inputs logs for step summary + if: always() + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/parse_safe_inputs_logs.cjs'); + await main(); + - name: Parse MCP Gateway logs for step summary + if: always() + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/parse_mcp_gateway_log.cjs'); + await main(); + - name: Upload agent artifacts + if: always() + continue-on-error: true + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: agent-artifacts + path: | + /tmp/gh-aw/aw-prompts/prompt.txt + /tmp/gh-aw/aw_info.json + /tmp/gh-aw/mcp-logs/ + /tmp/gh-aw/safe-inputs/logs/ + /tmp/gh-aw/agent-stdio.log + /tmp/gh-aw/agent/ + if-no-files-found: ignore + # --- Threat Detection (inline) --- + - name: Check if detection needed + id: detection_guard + if: always() + env: + OUTPUT_TYPES: ${{ steps.collect_output.outputs.output_types }} + HAS_PATCH: ${{ steps.collect_output.outputs.has_patch }} + run: | + if [[ -n "$OUTPUT_TYPES" || "$HAS_PATCH" == "true" ]]; then + echo "run_detection=true" >> "$GITHUB_OUTPUT" + echo "Detection will run: output_types=$OUTPUT_TYPES, has_patch=$HAS_PATCH" + else + echo "run_detection=false" >> "$GITHUB_OUTPUT" + echo "Detection skipped: no agent outputs or patches to analyze" + fi + - name: Clear MCP configuration for detection + if: always() && steps.detection_guard.outputs.run_detection == 'true' + run: | + rm -f /tmp/gh-aw/mcp-config/mcp-servers.json + rm -f /home/runner/.copilot/mcp-config.json + rm -f "$GITHUB_WORKSPACE/.gemini/settings.json" + - name: Prepare threat detection files + if: always() && steps.detection_guard.outputs.run_detection == 'true' + run: | + mkdir -p /tmp/gh-aw/threat-detection/aw-prompts + cp /tmp/gh-aw/aw-prompts/prompt.txt /tmp/gh-aw/threat-detection/aw-prompts/prompt.txt 2>/dev/null || true + cp /tmp/gh-aw/agent_output.json /tmp/gh-aw/threat-detection/agent_output.json 2>/dev/null || true + for f in /tmp/gh-aw/aw-*.patch; do + [ -f "$f" ] && cp "$f" /tmp/gh-aw/threat-detection/ 2>/dev/null || true + done + echo "Prepared threat detection files:" + ls -la /tmp/gh-aw/threat-detection/ 2>/dev/null || true + - name: Setup threat detection + if: always() && steps.detection_guard.outputs.run_detection == 'true' + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + WORKFLOW_NAME: "Smoke OpenCode" + WORKFLOW_DESCRIPTION: "Smoke test workflow that validates OpenCode engine functionality twice daily" + HAS_PATCH: ${{ steps.collect_output.outputs.has_patch }} + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/setup_threat_detection.cjs'); + await main(); + - name: Ensure threat-detection directory and log + if: always() && steps.detection_guard.outputs.run_detection == 'true' + run: | + mkdir -p /tmp/gh-aw/threat-detection + touch /tmp/gh-aw/threat-detection/detection.log + - name: Write OpenCode configuration + if: always() && steps.detection_guard.outputs.run_detection == 'true' + run: | + mkdir -p "$GITHUB_WORKSPACE" + CONFIG="$GITHUB_WORKSPACE/opencode.jsonc" + BASE_CONFIG='{"agent":{"build":{"permissions":{"bash":"allow","edit":"allow","read":"allow","glob":"allow","grep":"allow","write":"allow","webfetch":"allow","websearch":"allow"}}}}' + if [ -f "$CONFIG" ]; then + MERGED=$(jq -n --argjson base "$BASE_CONFIG" --argjson existing "$(cat "$CONFIG")" '$existing * $base') + echo "$MERGED" > "$CONFIG" + else + echo "$BASE_CONFIG" > "$CONFIG" + fi + - name: Execute OpenCode CLI + if: always() && steps.detection_guard.outputs.run_detection == 'true' + id: detection_agentic_execution + run: | + set -o pipefail + # shellcheck disable=SC1003 + sudo -E awf --env-all --container-workdir "${GITHUB_WORKSPACE}" --allow-domains "api.anthropic.com,api.openai.com,generativelanguage.googleapis.com,host.docker.internal,registry.npmjs.org" --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --enable-host-access --image-tag 0.23.0 --skip-pull --enable-api-proxy \ + -- /bin/bash -c 'export PATH="$(find /opt/hostedtoolcache -maxdepth 4 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true && opencode run -q "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"' 2>&1 | tee -a /tmp/gh-aw/threat-detection/detection.log + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + ANTHROPIC_BASE_URL: http://host.docker.internal:10004 + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GITHUB_WORKSPACE: ${{ github.workspace }} + NO_PROXY: localhost,127.0.0.1 + OPENCODE_MODEL: anthropic/claude-sonnet-4-20250514 + - name: Parse threat detection results + id: parse_detection_results + if: always() && steps.detection_guard.outputs.run_detection == 'true' + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/parse_threat_detection_results.cjs'); + await main(); + - name: Upload threat detection log + if: always() && steps.detection_guard.outputs.run_detection == 'true' + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: threat-detection.log + path: /tmp/gh-aw/threat-detection/detection.log + if-no-files-found: ignore + - name: Set detection conclusion + id: detection_conclusion + if: always() + env: + RUN_DETECTION: ${{ steps.detection_guard.outputs.run_detection }} + DETECTION_SUCCESS: ${{ steps.parse_detection_results.outputs.success }} + run: | + if [[ "$RUN_DETECTION" != "true" ]]; then + echo "conclusion=skipped" >> "$GITHUB_OUTPUT" + echo "success=true" >> "$GITHUB_OUTPUT" + echo "Detection was not needed, marking as skipped" + elif [[ "$DETECTION_SUCCESS" == "true" ]]; then + echo "conclusion=success" >> "$GITHUB_OUTPUT" + echo "success=true" >> "$GITHUB_OUTPUT" + echo "Detection passed successfully" + else + echo "conclusion=failure" >> "$GITHUB_OUTPUT" + echo "success=false" >> "$GITHUB_OUTPUT" + echo "Detection found issues" + fi + + conclusion: + needs: + - activation + - agent + - safe_outputs + if: (always()) && (needs.agent.result != 'skipped') + runs-on: ubuntu-slim + permissions: + contents: read + discussions: write + issues: write + pull-requests: write + outputs: + noop_message: ${{ steps.noop.outputs.noop_message }} + tools_reported: ${{ steps.missing_tool.outputs.tools_reported }} + total_count: ${{ steps.missing_tool.outputs.total_count }} + steps: + - name: Checkout actions folder + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + sparse-checkout: | + actions + persist-credentials: false + - name: Setup Scripts + uses: ./actions/setup + with: + destination: /opt/gh-aw/actions + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent-output + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable + run: | + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process No-Op Messages + id: noop + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} + GH_AW_NOOP_MAX: "1" + GH_AW_WORKFLOW_NAME: "Smoke OpenCode" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/noop.cjs'); + await main(); + - name: Record Missing Tool + id: missing_tool + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} + GH_AW_WORKFLOW_NAME: "Smoke OpenCode" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/missing_tool.cjs'); + await main(); + - name: Handle Agent Failure + id: handle_agent_failure + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} + GH_AW_WORKFLOW_NAME: "Smoke OpenCode" + GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} + GH_AW_WORKFLOW_ID: "smoke-opencode" + GH_AW_SECRET_VERIFICATION_RESULT: ${{ needs.agent.outputs.secret_verification_result }} + GH_AW_CHECKOUT_PR_SUCCESS: ${{ needs.agent.outputs.checkout_pr_success }} + GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e ⚡ *[{workflow_name}]({run_url}) — Powered by OpenCode*\",\"runStarted\":\"⚡ OpenCode initializing... [{workflow_name}]({run_url}) begins on this {event_type}...\",\"runSuccess\":\"🎯 [{workflow_name}]({run_url}) **MISSION COMPLETE!** OpenCode has delivered. ⚡\",\"runFailure\":\"⚠️ [{workflow_name}]({run_url}) {status}. OpenCode encountered unexpected challenges...\"}" + GH_AW_GROUP_REPORTS: "false" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/handle_agent_failure.cjs'); + await main(); + - name: Handle No-Op Message + id: handle_noop_message + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} + GH_AW_WORKFLOW_NAME: "Smoke OpenCode" + GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} + GH_AW_NOOP_MESSAGE: ${{ steps.noop.outputs.noop_message }} + GH_AW_NOOP_REPORT_AS_ISSUE: "true" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/handle_noop_message.cjs'); + await main(); + - name: Update reaction comment with completion status + id: conclusion + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} + GH_AW_COMMENT_ID: ${{ needs.activation.outputs.comment_id }} + GH_AW_COMMENT_REPO: ${{ needs.activation.outputs.comment_repo }} + GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + GH_AW_WORKFLOW_NAME: "Smoke OpenCode" + GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} + GH_AW_DETECTION_CONCLUSION: ${{ needs.agent.outputs.detection_conclusion }} + GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e ⚡ *[{workflow_name}]({run_url}) — Powered by OpenCode*\",\"runStarted\":\"⚡ OpenCode initializing... [{workflow_name}]({run_url}) begins on this {event_type}...\",\"runSuccess\":\"🎯 [{workflow_name}]({run_url}) **MISSION COMPLETE!** OpenCode has delivered. ⚡\",\"runFailure\":\"⚠️ [{workflow_name}]({run_url}) {status}. OpenCode encountered unexpected challenges...\"}" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/notify_comment_error.cjs'); + await main(); + + pre_activation: + if: > + ((github.event_name != 'pull_request') || (github.event.pull_request.head.repo.id == github.repository_id)) && + ((github.event_name != 'pull_request') || ((github.event.action != 'labeled') || (github.event.label.name == 'smoke'))) + runs-on: ubuntu-slim + permissions: + contents: read + discussions: write + issues: write + pull-requests: write + outputs: + activated: ${{ steps.check_membership.outputs.is_team_member == 'true' }} + matched_command: '' + steps: + - name: Checkout actions folder + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + sparse-checkout: | + actions + persist-credentials: false + - name: Setup Scripts + uses: ./actions/setup + with: + destination: /opt/gh-aw/actions + - name: Add eyes reaction for immediate feedback + id: react + if: github.event_name == 'issues' || github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment' || github.event_name == 'discussion' || github.event_name == 'discussion_comment' || (github.event_name == 'pull_request') && (github.event.pull_request.head.repo.id == github.repository_id) + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_REACTION: "eyes" + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/add_reaction.cjs'); + await main(); + - name: Check team membership for workflow + id: check_membership + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_REQUIRED_ROLES: admin,maintainer,write + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/check_membership.cjs'); + await main(); + + safe_outputs: + needs: agent + if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (needs.agent.outputs.detection_success == 'true') + runs-on: ubuntu-slim + permissions: + contents: read + discussions: write + issues: write + pull-requests: write + timeout-minutes: 15 + env: + GH_AW_ENGINE_ID: "opencode" + GH_AW_ENGINE_MODEL: "anthropic/claude-sonnet-4-20250514" + GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e ⚡ *[{workflow_name}]({run_url}) — Powered by OpenCode*\",\"runStarted\":\"⚡ OpenCode initializing... [{workflow_name}]({run_url}) begins on this {event_type}...\",\"runSuccess\":\"🎯 [{workflow_name}]({run_url}) **MISSION COMPLETE!** OpenCode has delivered. ⚡\",\"runFailure\":\"⚠️ [{workflow_name}]({run_url}) {status}. OpenCode encountered unexpected challenges...\"}" + GH_AW_WORKFLOW_ID: "smoke-opencode" + GH_AW_WORKFLOW_NAME: "Smoke OpenCode" + outputs: + code_push_failure_count: ${{ steps.process_safe_outputs.outputs.code_push_failure_count }} + code_push_failure_errors: ${{ steps.process_safe_outputs.outputs.code_push_failure_errors }} + create_discussion_error_count: ${{ steps.process_safe_outputs.outputs.create_discussion_error_count }} + create_discussion_errors: ${{ steps.process_safe_outputs.outputs.create_discussion_errors }} + process_safe_outputs_processed_count: ${{ steps.process_safe_outputs.outputs.processed_count }} + process_safe_outputs_temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} + steps: + - name: Checkout actions folder + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + sparse-checkout: | + actions + persist-credentials: false + - name: Setup Scripts + uses: ./actions/setup + with: + destination: /opt/gh-aw/actions + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent-output + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable + run: | + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process Safe Outputs + id: process_safe_outputs + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} + GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"add_comment\":{\"hide_older_comments\":true,\"max\":2},\"add_labels\":{\"allowed\":[\"smoke-opencode\"]},\"create_issue\":{\"close_older_issues\":true,\"expires\":2,\"labels\":[\"automation\",\"testing\"],\"max\":1},\"missing_data\":{},\"missing_tool\":{}}" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/safe_output_handler_manager.cjs'); + await main(); + - name: Upload safe output items manifest + if: always() + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: safe-output-items + path: /tmp/safe-output-items.jsonl + if-no-files-found: warn + diff --git a/.github/workflows/smoke-opencode.md b/.github/workflows/smoke-opencode.md new file mode 100644 index 00000000000..fa15abbd918 --- /dev/null +++ b/.github/workflows/smoke-opencode.md @@ -0,0 +1,80 @@ +--- +description: Smoke test workflow that validates OpenCode engine functionality twice daily +on: + schedule: every 12h + workflow_dispatch: + pull_request: + types: [labeled] + names: ["smoke"] + reaction: "eyes" + status-comment: true +permissions: + contents: read + issues: read + pull-requests: read +name: Smoke OpenCode +engine: + id: opencode + model: anthropic/claude-sonnet-4-20250514 +strict: true +imports: + - shared/gh.md + - shared/reporting.md +network: + allowed: + - defaults + - github +tools: + cache-memory: true + github: + toolsets: [repos, pull_requests] + edit: + bash: + - "*" + web-fetch: +safe-outputs: + add-comment: + hide-older-comments: true + max: 2 + create-issue: + expires: 2h + close-older-issues: true + labels: [automation, testing] + add-labels: + allowed: [smoke-opencode] + messages: + footer: "> ⚡ *[{workflow_name}]({run_url}) — Powered by OpenCode*" + run-started: "⚡ OpenCode initializing... [{workflow_name}]({run_url}) begins on this {event_type}..." + run-success: "🎯 [{workflow_name}]({run_url}) **MISSION COMPLETE!** OpenCode has delivered. ⚡" + run-failure: "⚠️ [{workflow_name}]({run_url}) {status}. OpenCode encountered unexpected challenges..." +timeout-minutes: 15 +--- + +# Smoke Test: OpenCode Engine Validation + +**CRITICAL EFFICIENCY REQUIREMENTS:** +- Keep ALL outputs extremely short and concise. Use single-line responses. +- NO verbose explanations or unnecessary context. +- Minimize file reading - only read what is absolutely necessary for the task. + +## Test Requirements + +1. **GitHub MCP Testing**: Use GitHub MCP tools to fetch details of exactly 2 merged pull requests from ${{ github.repository }} (title and number only) +2. **Web Fetch Testing**: Use the web-fetch MCP tool to fetch https://github.com and verify the response contains "GitHub" (do NOT use bash or playwright for this test - use the web-fetch MCP tool directly) +3. **File Writing Testing**: Create a test file `/tmp/gh-aw/agent/smoke-test-opencode-${{ github.run_id }}.txt` with content "Smoke test passed for OpenCode at $(date)" (create the directory if it doesn't exist) +4. **Bash Tool Testing**: Execute bash commands to verify file creation was successful (use `cat` to read the file back) +5. **Build gh-aw**: Run `GOCACHE=/tmp/go-cache GOMODCACHE=/tmp/go-mod make build` to verify the agent can successfully build the gh-aw project. If the command fails, mark this test as ❌ and report the failure. + +## Output + +Add a **very brief** comment (max 5-10 lines) to the current pull request with: +- ✅ or ❌ for each test result +- Overall status: PASS or FAIL + +If all tests pass, use the `add_labels` safe-output tool to add the label `smoke-opencode` to the pull request. + +**Important**: If no action is needed after completing your analysis, you **MUST** call the `noop` safe-output tool with a brief explanation. Failing to call any safe-output tool is the most common cause of safe-output workflow failures. + +```json +{"noop": {"message": "No action needed: [brief explanation of what was analyzed and why]"}} +``` diff --git a/actions/setup/sh/convert_gateway_config_opencode.sh b/actions/setup/sh/convert_gateway_config_opencode.sh new file mode 100755 index 00000000000..b623323610b --- /dev/null +++ b/actions/setup/sh/convert_gateway_config_opencode.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash +# Convert MCP Gateway Configuration to OpenCode Format +# This script converts the gateway's standard HTTP-based MCP configuration +# to the JSON format expected by OpenCode (opencode.jsonc) +# +# OpenCode reads MCP server configuration from opencode.jsonc: +# - Project: ./opencode.jsonc (used here) +# - Global: ~/.config/opencode/opencode.json +# +# See: https://opencode.ai/docs/mcp-servers/ + +set -e + +# Required environment variables: +# - MCP_GATEWAY_OUTPUT: Path to gateway output configuration file +# - MCP_GATEWAY_DOMAIN: Domain to use for MCP server URLs (e.g., host.docker.internal) +# - MCP_GATEWAY_PORT: Port for MCP gateway (e.g., 80) +# - GITHUB_WORKSPACE: Workspace directory for project-level config + +if [ -z "$MCP_GATEWAY_OUTPUT" ]; then + echo "ERROR: MCP_GATEWAY_OUTPUT environment variable is required" + exit 1 +fi + +if [ ! -f "$MCP_GATEWAY_OUTPUT" ]; then + echo "ERROR: Gateway output file not found: $MCP_GATEWAY_OUTPUT" + exit 1 +fi + +if [ -z "$MCP_GATEWAY_DOMAIN" ]; then + echo "ERROR: MCP_GATEWAY_DOMAIN environment variable is required" + exit 1 +fi + +if [ -z "$MCP_GATEWAY_PORT" ]; then + echo "ERROR: MCP_GATEWAY_PORT environment variable is required" + exit 1 +fi + +if [ -z "$GITHUB_WORKSPACE" ]; then + echo "ERROR: GITHUB_WORKSPACE environment variable is required" + exit 1 +fi + +echo "Converting gateway configuration to OpenCode format..." +echo "Input: $MCP_GATEWAY_OUTPUT" +echo "Target domain: $MCP_GATEWAY_DOMAIN:$MCP_GATEWAY_PORT" + +# Convert gateway output to OpenCode opencode.jsonc format +# Gateway format: +# { +# "mcpServers": { +# "server-name": { +# "type": "http", +# "url": "http://domain:port/mcp/server-name", +# "headers": { +# "Authorization": "apiKey" +# } +# } +# } +# } +# +# OpenCode format: +# { +# "mcp": { +# "server-name": { +# "type": "remote", +# "enabled": true, +# "url": "http://domain:port/mcp/server-name", +# "headers": { +# "Authorization": "apiKey" +# } +# } +# } +# } +# +# The main differences: +# 1. Top-level key is "mcp" not "mcpServers" +# 2. Server type is "remote" not "http" +# 3. Has "enabled": true field +# 4. Remove "tools" field (Copilot-specific) +# 5. URLs must use the correct domain (host.docker.internal) for container access + +# Build the correct URL prefix using the configured domain and port +URL_PREFIX="http://${MCP_GATEWAY_DOMAIN}:${MCP_GATEWAY_PORT}" + +OPENCODE_CONFIG_FILE="${GITHUB_WORKSPACE}/opencode.jsonc" + +# Build the MCP section from gateway output +MCP_SECTION=$(jq --arg urlPrefix "$URL_PREFIX" ' + .mcpServers | with_entries( + .value |= { + "type": "remote", + "enabled": true, + "url": (.url | sub("^http://[^/]+/mcp/"; $urlPrefix + "/mcp/")), + "headers": .headers + } + ) +' "$MCP_GATEWAY_OUTPUT") + +# Merge into existing opencode.jsonc or create new one +if [ -f "$OPENCODE_CONFIG_FILE" ]; then + echo "Merging MCP config into existing opencode.jsonc..." + jq --argjson mcpSection "$MCP_SECTION" '.mcp = (.mcp // {}) * $mcpSection' "$OPENCODE_CONFIG_FILE" > "${OPENCODE_CONFIG_FILE}.tmp" + mv "${OPENCODE_CONFIG_FILE}.tmp" "$OPENCODE_CONFIG_FILE" +else + echo "Creating new opencode.jsonc..." + jq -n --argjson mcpSection "$MCP_SECTION" '{"mcp": $mcpSection}' > "$OPENCODE_CONFIG_FILE" +fi + +echo "OpenCode configuration written to $OPENCODE_CONFIG_FILE" +echo "" +echo "Converted configuration:" +cat "$OPENCODE_CONFIG_FILE" diff --git a/actions/setup/sh/start_mcp_gateway.sh b/actions/setup/sh/start_mcp_gateway.sh index b9ab5753f99..5c4c57e1eb4 100755 --- a/actions/setup/sh/start_mcp_gateway.sh +++ b/actions/setup/sh/start_mcp_gateway.sh @@ -369,6 +369,10 @@ case "$ENGINE_TYPE" in echo "Using Gemini converter..." bash /opt/gh-aw/actions/convert_gateway_config_gemini.sh ;; + opencode) + echo "Using OpenCode converter..." + bash /opt/gh-aw/actions/convert_gateway_config_opencode.sh + ;; *) echo "No agent-specific converter found for engine: $ENGINE_TYPE" echo "Using gateway output directly" diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index 738b64c5d77..8434bf30dfc 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -182,6 +182,7 @@ export default defineConfig({ { label: 'GitHub Actions Primer', link: '/guides/github-actions-primer/' }, { label: 'Reusing Workflows', link: '/guides/packaging-imports/' }, { label: 'Self-Hosted Runners', link: '/guides/self-hosted-runners/' }, + { label: 'OpenCode Engine', link: '/guides/opencode/' }, { label: 'Using MCPs', link: '/guides/mcps/' }, { label: 'Web Search', link: '/guides/web-search/' }, ], diff --git a/docs/src/content/docs/guides/opencode.md b/docs/src/content/docs/guides/opencode.md new file mode 100644 index 00000000000..3f51c6575c8 --- /dev/null +++ b/docs/src/content/docs/guides/opencode.md @@ -0,0 +1,460 @@ +--- +title: Using OpenCode Engine +description: Guide to using the OpenCode engine with GitHub Agentic Workflows — a provider-agnostic, BYOK AI coding agent supporting 75+ models from Anthropic, OpenAI, Google, Groq, and more. +sidebar: + order: 8 +--- + +[OpenCode](https://opencode.ai/) is an open-source, provider-agnostic AI coding agent for the terminal. Unlike other engines that are locked to a single AI provider, OpenCode follows a **Bring Your Own Key (BYOK)** model — you can use models from Anthropic, OpenAI, Google, Groq, Mistral, DeepSeek, xAI, and many others. + +> [!NOTE] +> The OpenCode engine is **experimental**. It is functional and smoke-tested, but may have rough edges compared to the more established Claude or Copilot engines. Please report issues if you encounter them. + +## Quick Start + +The simplest OpenCode workflow uses Anthropic (the default provider) and requires only an `ANTHROPIC_API_KEY` secret: + +```aw wrap +--- +on: + issues: + types: [opened] + +permissions: + contents: read + issues: write + +engine: opencode + +tools: + github: + toolsets: [repos, issues] + edit: + bash: true +--- + +Analyze the newly opened issue and add a comment with a summary of what the issue is requesting. +``` + +To compile and use this workflow: + +1. Add the `ANTHROPIC_API_KEY` secret to your repository (see [Authentication](/gh-aw/reference/auth/)) +2. Save the workflow as `.github/workflows/my-workflow.md` +3. Run `gh aw compile .github/workflows/my-workflow.md` + +## Authentication and Providers + +### Default: Anthropic + +By default, OpenCode uses Anthropic as its AI provider. You only need one secret: + +- **`ANTHROPIC_API_KEY`** — Your Anthropic API key + +```yaml wrap +engine: opencode +``` + +This is equivalent to: + +```yaml wrap +engine: + id: opencode + model: anthropic/claude-sonnet-4-20250514 +``` + +### Using Other Providers + +OpenCode supports many providers through the `engine.env` field. Specify the model in `provider/model` format and pass the appropriate API key: + +#### OpenAI + +```yaml wrap +engine: + id: opencode + model: openai/gpt-4.1 + env: + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} +``` + +Required secret: `OPENAI_API_KEY` + +#### Google (Gemini) + +```yaml wrap +engine: + id: opencode + model: google/gemini-2.5-pro + env: + GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }} +``` + +Required secret: `GOOGLE_API_KEY` + +#### Groq + +```yaml wrap +engine: + id: opencode + model: groq/llama-4-scout-17b-16e-instruct + env: + GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }} +``` + +Required secret: `GROQ_API_KEY` + +#### Mistral + +```yaml wrap +engine: + id: opencode + model: mistral/mistral-large-latest + env: + MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }} +``` + +Required secret: `MISTRAL_API_KEY` + +#### DeepSeek + +```yaml wrap +engine: + id: opencode + model: deepseek/deepseek-chat + env: + DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }} +``` + +Required secret: `DEEPSEEK_API_KEY` + +#### xAI (Grok) + +```yaml wrap +engine: + id: opencode + model: xai/grok-3 + env: + XAI_API_KEY: ${{ secrets.XAI_API_KEY }} +``` + +Required secret: `XAI_API_KEY` + +### Model Format + +Models are specified in `provider/model` format. The provider prefix determines which API endpoint and credentials are used: + +| Provider | Prefix | Example Model | API Key Secret | +|----------|--------|---------------|----------------| +| Anthropic | `anthropic/` | `anthropic/claude-sonnet-4-20250514` | `ANTHROPIC_API_KEY` | +| OpenAI | `openai/` | `openai/gpt-4.1` | `OPENAI_API_KEY` | +| Google | `google/` | `google/gemini-2.5-pro` | `GOOGLE_API_KEY` | +| Groq | `groq/` | `groq/llama-4-scout-17b-16e-instruct` | `GROQ_API_KEY` | +| Mistral | `mistral/` | `mistral/mistral-large-latest` | `MISTRAL_API_KEY` | +| DeepSeek | `deepseek/` | `deepseek/deepseek-chat` | `DEEPSEEK_API_KEY` | +| xAI | `xai/` | `xai/grok-3` | `XAI_API_KEY` | + +If no model is specified, OpenCode defaults to Anthropic. + +## Network Security + +### How It Works + +When the firewall is enabled (the default), all API calls from the OpenCode agent are proxied through a secure network layer. This provides two key security properties: + +1. **Credential isolation** — API keys are never exposed inside the agent's execution environment. A separate proxy service injects authentication headers, so even a compromised agent cannot read your API keys. +2. **Domain allowlisting** — Only approved network domains are accessible. All other traffic is blocked at the network level. + +This happens automatically. You do not need to configure any proxy settings. + +### Dynamic Domain Allowlists + +The compiler automatically adds the appropriate API domains based on your model's provider prefix. For example, `model: openai/gpt-4.1` automatically allows `api.openai.com`. + +The following domains are always allowed for OpenCode workflows: + +| Domain | Purpose | +|--------|---------| +| `host.docker.internal` | Internal MCP gateway and API proxy access | +| `registry.npmjs.org` | OpenCode CLI npm package downloads | + +Provider-specific domains added automatically: + +| Provider | Domain | +|----------|--------| +| Anthropic (default) | `api.anthropic.com` | +| OpenAI | `api.openai.com` | +| Google | `generativelanguage.googleapis.com` | +| Groq | `api.groq.com` | +| Mistral | `api.mistral.ai` | +| DeepSeek | `api.deepseek.com` | +| xAI | `api.x.ai` | + +### Adding Custom Domains + +Use the `network` field to allow additional domains your workflow needs: + +```yaml wrap +engine: + id: opencode + model: anthropic/claude-sonnet-4-20250514 + +network: + allowed: + - defaults # Basic infrastructure + - python # Python/PyPI ecosystem + - "api.example.com" # Custom domain +``` + +See [Network Permissions](/gh-aw/reference/network/) for the full reference on ecosystem identifiers and domain configuration. + +## MCP Server Support + +OpenCode has full support for the [Model Context Protocol](/gh-aw/reference/glossary/#mcp-model-context-protocol) (MCP) through the MCP Gateway. This allows your workflow to connect the agent to GitHub APIs, web fetching, and custom tools. + +### GitHub Tools + +```aw wrap +--- +on: + issues: + types: [opened] + +permissions: + contents: read + issues: write + pull-requests: read + +engine: + id: opencode + model: anthropic/claude-sonnet-4-20250514 + +tools: + github: + toolsets: [repos, issues, pull_requests] + edit: + bash: true +--- + +Analyze this issue and provide a detailed response. +``` + +### Web Fetch + +```yaml wrap +tools: + web-fetch: + github: + toolsets: [repos] +``` + +### Custom MCP Servers + +OpenCode supports all MCP server types (stdio, container, HTTP). See [Using MCPs](/gh-aw/guides/mcps/) for complete documentation on configuring custom MCP servers. + +```yaml wrap +tools: + github: + toolsets: [repos, issues] + edit: + bash: true + +mcp-servers: + microsoftdocs: + url: "https://learn.microsoft.com/api/mcp" + allowed: ["*"] +``` + +## Extended Configuration + +### Pinning the OpenCode Version + +```yaml wrap +engine: + id: opencode + version: "1.2.14" +``` + +### Custom CLI Command + +```yaml wrap +engine: + id: opencode + command: /usr/local/bin/opencode-custom +``` + +When a custom `command` is specified, the automatic installation step is skipped. + +### Custom Environment Variables + +```yaml wrap +engine: + id: opencode + env: + DEBUG_MODE: "true" + CUSTOM_SETTING: "value" +``` + +### Command-Line Arguments + +```yaml wrap +engine: + id: opencode + args: ["--verbose"] +``` + +## Example Workflows + +### Simple Issue Triage + +```aw wrap +--- +on: + issues: + types: [opened] + reaction: "eyes" + +permissions: + contents: read + issues: write + +engine: opencode + +tools: + github: + toolsets: [repos, issues] + bash: true + +safe-outputs: + add-comment: + max: 1 + add-labels: + allowed: [bug, feature, question, documentation] +--- + +Analyze the issue and: +1. Determine the category (bug, feature, question, or documentation) +2. Add the appropriate label +3. Post a brief comment summarizing your analysis +``` + +### Multi-Provider Code Review (OpenAI) + +```aw wrap +--- +on: + pull_request: + types: [opened, synchronize] + reaction: "eyes" + +permissions: + contents: read + pull-requests: write + +engine: + id: opencode + model: openai/gpt-4.1 + env: + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + +tools: + github: + toolsets: [repos, pull_requests] + edit: + bash: true + +safe-outputs: + add-comment: + max: 1 +--- + +Review the pull request changes and provide constructive feedback. +Focus on code quality, potential bugs, and adherence to best practices. +``` + +### Workflow with MCP Tools and Web Access + +```aw wrap +--- +on: + issues: + types: [labeled] + names: ["research"] + reaction: "rocket" + +permissions: + contents: read + issues: write + +engine: + id: opencode + model: anthropic/claude-sonnet-4-20250514 + +tools: + github: + toolsets: [repos, issues] + web-fetch: + edit: + bash: true + +network: + allowed: + - defaults + - github + - node + +safe-outputs: + add-comment: + max: 2 +--- + +Research the topic described in this issue: +1. Use web-fetch to gather relevant information +2. Analyze the findings +3. Post a summary comment with your research results and recommendations +``` + +## Comparison with Other Engines + +| Feature | OpenCode | Claude | Copilot | Codex | Gemini | +|---------|----------|--------|---------|-------|--------| +| **Provider** | Any (BYOK) | Anthropic | GitHub/OpenAI | OpenAI | Google | +| **Default Secret** | `ANTHROPIC_API_KEY` | `ANTHROPIC_API_KEY` | `COPILOT_GITHUB_TOKEN` | `OPENAI_API_KEY` | `GEMINI_API_KEY` | +| **MCP Support** | Yes | Yes | Yes | No | Yes | +| **Open Source** | Yes | No | No | Yes | No | +| **Model Selection** | `provider/model` format | Fixed | Configurable | Fixed | Fixed | +| **Status** | Experimental | Stable | Stable | Stable | Stable | +| **Headless Mode** | `opencode run` | `claude -p` | `copilot` | `codex --full-auto` | `gemini` | +| **Custom Agents** | Via config | Via `CLAUDE.md` | Via `.agent.md` | N/A | N/A | + +**When to use OpenCode:** + +- You want to use models from multiple providers without changing engines +- You need an open-source agent for auditability +- You want BYOK flexibility to switch models easily +- You are evaluating different AI providers for your workflows + +**When to use other engines:** + +- **Claude** — Best integration with Anthropic models, most mature for complex coding tasks +- **Copilot** — Default choice, tightly integrated with GitHub, no external API key needed for Copilot subscribers +- **Codex** — Best for OpenAI-native workflows, built-in sandboxing +- **Gemini** — Best for Google Cloud-integrated workflows + +## Known Limitations + +1. **Experimental status** — The OpenCode engine integration is under active development. Behavior may change between releases. + +2. **Single-provider API proxy** — The API proxy currently routes all requests through the default provider (Anthropic). When using a non-Anthropic provider (e.g., `openai/gpt-4.1`), the provider's API endpoint is added to the domain allowlist, but credential injection through the proxy is only available for the default provider. Non-default providers must pass their API key via `engine.env`. + +3. **Permissions auto-approval** — OpenCode's tool permissions are automatically set to `allow` in CI mode to prevent the agent from hanging on permission prompts. This is expected behavior for headless execution. + +4. **`NO_PROXY` configuration** — OpenCode uses an internal client-server architecture. The `NO_PROXY=localhost,127.0.0.1` environment variable is automatically configured to prevent internal traffic from being intercepted by the network firewall. You do not need to set this yourself. + +## Related Documentation + +- [AI Engines](/gh-aw/reference/engines/) — Overview of all available engines +- [Authentication](/gh-aw/reference/auth/) — Secret configuration for all engines +- [Network Permissions](/gh-aw/reference/network/) — Domain allowlists and firewall configuration +- [Using MCPs](/gh-aw/guides/mcps/) — MCP server configuration +- [Tools](/gh-aw/reference/tools/) — Tool configuration reference +- [Frontmatter](/gh-aw/reference/frontmatter/) — Complete frontmatter reference +- [OpenCode Documentation](https://opencode.ai/docs/) — Official OpenCode documentation diff --git a/docs/src/content/docs/reference/auth.mdx b/docs/src/content/docs/reference/auth.mdx index d876cea7c82..95a1d87b8f5 100644 --- a/docs/src/content/docs/reference/auth.mdx +++ b/docs/src/content/docs/reference/auth.mdx @@ -14,10 +14,11 @@ This page describes authentication settings for GitHub Agentic Workflows. You will need one of the following GitHub Actions secrets configured in your repository to authenticate the AI engine you choose: -- **Copilot** – Add [`COPILOT_GITHUB_TOKEN`](#copilot_github_token) -- **Claude by Anthropic** – Add [`ANTHROPIC_API_KEY`](#anthropic_api_key) -- **Codex by OpenAI** – Add [`OPENAI_API_KEY`](#openai_api_key) -- **Gemini by Google** – Add [`GEMINI_API_KEY`](#gemini_api_key) +- **Copilot** -- Add [`COPILOT_GITHUB_TOKEN`](#copilot_github_token) +- **Claude by Anthropic** -- Add [`ANTHROPIC_API_KEY`](#anthropic_api_key) +- **Codex by OpenAI** -- Add [`OPENAI_API_KEY`](#openai_api_key) +- **Gemini by Google** -- Add [`GEMINI_API_KEY`](#gemini_api_key) +- **OpenCode** -- Add [`ANTHROPIC_API_KEY`](#anthropic_api_key) (default provider), or a provider-specific key via `engine.env` (see [OpenCode Engine Guide](/gh-aw/guides/opencode/#authentication-and-providers)) Most workflows will run without any additional secrets or additional authentication. diff --git a/docs/src/content/docs/reference/engines.md b/docs/src/content/docs/reference/engines.md index 2cdf3cb3b20..6830a3b6229 100644 --- a/docs/src/content/docs/reference/engines.md +++ b/docs/src/content/docs/reference/engines.md @@ -1,6 +1,6 @@ --- title: AI Engines (aka Coding Agents) -description: Complete guide to AI engines (coding agents) usable with GitHub Agentic Workflows, including Copilot, Claude, Codex, and Gemini with their specific configuration options. +description: Complete guide to AI engines (coding agents) usable with GitHub Agentic Workflows, including Copilot, Claude, Codex, Gemini, and OpenCode with their specific configuration options. sidebar: order: 600 --- @@ -13,6 +13,7 @@ GitHub Agentic Workflows use [AI Engines](/gh-aw/reference/glossary/#engine) (no - [**Claude by Anthropic (Claude Code)**](#using-claude-by-anthropic-claude-code) - [**OpenAI Codex**](#using-openai-codex) - [**Google Gemini CLI**](#using-google-gemini-cli) +- [**OpenCode**](#using-opencode) (experimental) ## Using Copilot CLI @@ -64,6 +65,34 @@ To use [Google Gemini CLI](https://github.com/google-gemini/gemini-cli): 2. Configure the `GEMINI_API_KEY` secret. See [Authentication: GEMINI_API_KEY](/gh-aw/reference/auth/#gemini_api_key) for setup instructions. +## Using OpenCode + +> **Experimental**: The OpenCode engine integration is under active development and may change between releases. + +[OpenCode](https://opencode.ai/) is an open-source, provider-agnostic AI coding agent that supports 75+ models via Bring Your Own Key (BYOK). Unlike other engines, OpenCode is not locked to a single AI provider. + +To use OpenCode with GitHub Agentic Workflows: + +1. Request the use of the OpenCode engine in your workflow frontmatter: + + ```yaml wrap + engine: opencode + ``` + +2. Configure the `ANTHROPIC_API_KEY` secret (default provider). See [Authentication: ANTHROPIC_API_KEY](/gh-aw/reference/auth/#anthropic_api_key) for setup instructions. + +To use a different AI provider, specify the model in `provider/model` format and pass the appropriate API key via `engine.env`: + +```yaml wrap +engine: + id: opencode + model: openai/gpt-4.1 + env: + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} +``` + +Supported providers include Anthropic, OpenAI, Google, Groq, Mistral, DeepSeek, and xAI. See the [OpenCode Engine Guide](/gh-aw/guides/opencode/) for full provider configuration, MCP support, network security details, and example workflows. + ## Extended Coding Agent Configuration Workflows can specify extended configuration for the coding agent: diff --git a/docs/src/content/docs/reference/network.md b/docs/src/content/docs/reference/network.md index 8985209f66b..de671678286 100644 --- a/docs/src/content/docs/reference/network.md +++ b/docs/src/content/docs/reference/network.md @@ -7,7 +7,7 @@ sidebar: Control network access for AI engines using the top-level `network` field to specify which domains and services your agentic workflows can access during execution. -> **Note**: Network permissions are supported by all four engines: Copilot, Claude, Codex, and Gemini (via the AWF firewall). See the [Implementation](#implementation) section for engine-specific details. +> **Note**: Network permissions are supported by all five engines: Copilot, Claude, Codex, Gemini, and OpenCode (via the AWF firewall). See the [Implementation](#implementation) section for engine-specific details. If no `network:` permission is specified, it defaults to `network: defaults` which allows access to basic infrastructure domains (certificates, JSON schema, Ubuntu, common package mirrors, Microsoft sources). @@ -209,9 +209,9 @@ When enabled, AWF: - Logs all network activity for audit purposes - Blocks access to domains not explicitly allowed -### Claude, Codex, and Gemini Engines +### Claude, Codex, Gemini, and OpenCode Engines -The Claude, Codex, and Gemini engines use the same AWF firewall as the Copilot engine. Configure network permissions using the same `network.allowed` / `network.blocked` fields: +The Claude, Codex, Gemini, and OpenCode engines use the same AWF firewall as the Copilot engine. Configure network permissions using the same `network.allowed` / `network.blocked` fields: ```yaml wrap # Claude @@ -234,9 +234,18 @@ network: allowed: - defaults - node + +# OpenCode +engine: + id: opencode + model: anthropic/claude-sonnet-4-20250514 +network: + allowed: + - defaults + - github ``` -Each engine also has a built-in default domain list for its CLI authentication and operation. See [`domains.go`](https://github.com/github/gh-aw/blob/main/pkg/workflow/domains.go) for the full lists. +Each engine also has a built-in default domain list for its CLI authentication and operation. The OpenCode engine dynamically adds provider-specific API domains based on the model prefix (e.g., `openai/gpt-4.1` adds `api.openai.com`). See [`domains.go`](https://github.com/github/gh-aw/blob/main/pkg/workflow/domains.go) for the full lists and the [OpenCode Engine Guide](/gh-aw/guides/opencode/#network-security) for details on dynamic domain resolution. ### Firewall Log Level diff --git a/pkg/cli/completions_test.go b/pkg/cli/completions_test.go index 824a71d1d79..4214717795a 100644 --- a/pkg/cli/completions_test.go +++ b/pkg/cli/completions_test.go @@ -240,7 +240,7 @@ func TestCompleteEngineNames(t *testing.T) { { name: "empty prefix returns all engines", toComplete: "", - wantLen: 4, // copilot, claude, codex, gemini + wantLen: 5, // copilot, claude, codex, gemini, opencode }, { name: "c prefix returns claude, codex, copilot", diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go index 4a5c334f1d2..f280ba7d226 100644 --- a/pkg/constants/constants.go +++ b/pkg/constants/constants.go @@ -306,6 +306,9 @@ const ( // GeminiLLMGatewayPort is the port for the Gemini LLM gateway GeminiLLMGatewayPort = 10003 + + // OpenCodeLLMGatewayPort is the port for the OpenCode LLM gateway + OpenCodeLLMGatewayPort = 10004 ) // DefaultMCPRegistryURL is the default MCP registry URL. @@ -352,6 +355,10 @@ const ( EnvVarModelDetectionCodex = "GH_AW_MODEL_DETECTION_CODEX" // EnvVarModelDetectionGemini configures the default Gemini model for detection EnvVarModelDetectionGemini = "GH_AW_MODEL_DETECTION_GEMINI" + // EnvVarModelAgentOpenCode configures the default OpenCode model for agent execution + EnvVarModelAgentOpenCode = "GH_AW_MODEL_AGENT_OPENCODE" + // EnvVarModelDetectionOpenCode configures the default OpenCode model for detection + EnvVarModelDetectionOpenCode = "GH_AW_MODEL_DETECTION_OPENCODE" // CopilotCLIModelEnvVar is the native environment variable name supported by the Copilot CLI // for selecting the model. Setting this env var is equivalent to passing --model to the CLI. @@ -365,6 +372,10 @@ const ( // for selecting the model. Setting this env var is equivalent to passing --model to the CLI. GeminiCLIModelEnvVar = "GEMINI_MODEL" + // OpenCodeCLIModelEnvVar is the native environment variable name for OpenCode model selection. + // OpenCode uses provider/model format (e.g., "anthropic/claude-sonnet-4-20250514"). + OpenCodeCLIModelEnvVar = "OPENCODE_MODEL" + // Common environment variable names used across all engines // EnvVarPrompt is the path to the workflow prompt file @@ -395,6 +406,9 @@ const DefaultCodexVersion Version = "0.104.0" // DefaultGeminiVersion is the default version of the Google Gemini CLI const DefaultGeminiVersion Version = "0.29.0" +// DefaultOpenCodeVersion is the default version of the OpenCode CLI +const DefaultOpenCodeVersion Version = "1.2.14" + // DefaultGitHubMCPServerVersion is the default version of the GitHub MCP server Docker image const DefaultGitHubMCPServerVersion Version = "v0.31.0" @@ -728,11 +742,13 @@ const ( CodexEngine EngineName = "codex" // GeminiEngine is the Google Gemini engine identifier GeminiEngine EngineName = "gemini" + // OpenCodeEngine is the OpenCode engine identifier + OpenCodeEngine EngineName = "opencode" ) // AgenticEngines lists all supported agentic engine names // Note: This remains a string slice for backward compatibility with existing code -var AgenticEngines = []string{string(ClaudeEngine), string(CodexEngine), string(CopilotEngine)} +var AgenticEngines = []string{string(ClaudeEngine), string(CodexEngine), string(CopilotEngine), string(OpenCodeEngine)} // EngineOption represents a selectable AI engine with its display metadata and secret configuration type EngineOption struct { @@ -774,6 +790,15 @@ var EngineOptions = []EngineOption{ KeyURL: "https://platform.openai.com/api-keys", WhenNeeded: "Codex/OpenAI engine workflows", }, + { + Value: string(OpenCodeEngine), + Label: "OpenCode", + Description: "OpenCode multi-provider AI coding agent (BYOK)", + SecretName: "ANTHROPIC_API_KEY", + AlternativeSecrets: []string{"OPENAI_API_KEY", "GOOGLE_API_KEY"}, + KeyURL: "https://opencode.ai/docs/get-started/", + WhenNeeded: "OpenCode engine workflows (default: Anthropic provider)", + }, } // SystemSecretSpec describes a system-level secret that is not engine-specific diff --git a/pkg/constants/constants_test.go b/pkg/constants/constants_test.go index 10ef06fabce..462ca2731b2 100644 --- a/pkg/constants/constants_test.go +++ b/pkg/constants/constants_test.go @@ -83,7 +83,7 @@ func TestAgenticEngines(t *testing.T) { t.Error("AgenticEngines should not be empty") } - expectedEngines := []string{"claude", "codex", "copilot"} + expectedEngines := []string{"claude", "codex", "copilot", "opencode"} if len(AgenticEngines) != len(expectedEngines) { t.Errorf("AgenticEngines length = %d, want %d", len(AgenticEngines), len(expectedEngines)) } diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 7ea42dff526..2f76bce174e 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -7428,8 +7428,8 @@ "oneOf": [ { "type": "string", - "enum": ["claude", "codex", "copilot", "gemini"], - "description": "Simple engine name: 'claude' (default, Claude Code), 'copilot' (GitHub Copilot CLI), 'codex' (OpenAI Codex CLI), or 'gemini' (Google Gemini CLI)" + "enum": ["claude", "codex", "copilot", "gemini", "opencode"], + "description": "Simple engine name: 'claude' (default, Claude Code), 'copilot' (GitHub Copilot CLI), 'codex' (OpenAI Codex CLI), 'gemini' (Google Gemini CLI), or 'opencode' (OpenCode multi-provider AI coding agent)" }, { "type": "object", @@ -7437,8 +7437,8 @@ "properties": { "id": { "type": "string", - "enum": ["claude", "codex", "copilot", "gemini"], - "description": "AI engine identifier: 'claude' (Claude Code), 'codex' (OpenAI Codex CLI), 'copilot' (GitHub Copilot CLI), or 'gemini' (Google Gemini CLI)" + "enum": ["claude", "codex", "copilot", "gemini", "opencode"], + "description": "AI engine identifier: 'claude' (Claude Code), 'codex' (OpenAI Codex CLI), 'copilot' (GitHub Copilot CLI), 'gemini' (Google Gemini CLI), or 'opencode' (OpenCode multi-provider AI coding agent)" }, "version": { "type": ["string", "number"], diff --git a/pkg/workflow/agentic_engine.go b/pkg/workflow/agentic_engine.go index aa53184f675..1a303f8831e 100644 --- a/pkg/workflow/agentic_engine.go +++ b/pkg/workflow/agentic_engine.go @@ -354,6 +354,7 @@ func NewEngineRegistry() *EngineRegistry { registry.Register(NewCodexEngine()) registry.Register(NewCopilotEngine()) registry.Register(NewGeminiEngine()) + registry.Register(NewOpenCodeEngine()) agenticEngineLog.Printf("Registered %d engines", len(registry.engines)) return registry diff --git a/pkg/workflow/domains.go b/pkg/workflow/domains.go index d8c5492612e..9ba03962b2e 100644 --- a/pkg/workflow/domains.go +++ b/pkg/workflow/domains.go @@ -110,6 +110,70 @@ var GeminiDefaultDomains = []string{ "registry.npmjs.org", } +// OpenCodeBaseDefaultDomains are the default domains required for OpenCode CLI operation. +// OpenCode is BYOK (any provider), so provider-specific domains are added dynamically +// based on the model prefix via GetOpenCodeDefaultDomains(). +var OpenCodeBaseDefaultDomains = []string{ + "host.docker.internal", // MCP gateway / API proxy access + "registry.npmjs.org", // npm package downloads +} + +// openCodeProviderDomains maps provider prefixes to their API domains. +// Used by extractProviderFromModel() and GetOpenCodeDefaultDomains(). +var openCodeProviderDomains = map[string]string{ + "anthropic": "api.anthropic.com", + "openai": "api.openai.com", + "google": "generativelanguage.googleapis.com", + "groq": "api.groq.com", + "mistral": "api.mistral.ai", + "deepseek": "api.deepseek.com", + "xai": "api.x.ai", +} + +// OpenCodeDefaultDomains are the default domains required for OpenCode CLI operation. +// Includes the three most common provider API endpoints plus infrastructure domains. +var OpenCodeDefaultDomains = []string{ + "api.anthropic.com", // Default provider (Anthropic) + "api.openai.com", // OpenAI provider + "generativelanguage.googleapis.com", // Google/Gemini provider + "host.docker.internal", // MCP gateway / API proxy access + "registry.npmjs.org", // npm package downloads +} + +// extractProviderFromModel extracts the provider name from an OpenCode model string. +// OpenCode uses "provider/model" format (e.g., "anthropic/claude-sonnet-4-20250514"). +// Returns the provider prefix, or "anthropic" as default if no slash is found. +func extractProviderFromModel(model string) string { + if model == "" { + return "anthropic" + } + parts := strings.SplitN(model, "/", 2) + if len(parts) < 2 { + return "anthropic" + } + return strings.ToLower(parts[0]) +} + +// GetOpenCodeDefaultDomains returns the default domains for OpenCode based on the model provider. +// It starts with OpenCodeBaseDefaultDomains and adds the provider-specific API domain. +func GetOpenCodeDefaultDomains(model string) []string { + provider := extractProviderFromModel(model) + domains := make([]string, 0, len(OpenCodeBaseDefaultDomains)+1) + domains = append(domains, OpenCodeBaseDefaultDomains...) + + if domain, ok := openCodeProviderDomains[provider]; ok { + domains = append(domains, domain) + } + + return domains +} + +// GetOpenCodeAllowedDomainsWithToolsAndRuntimes merges OpenCode default domains with NetworkPermissions, HTTP MCP server domains, and runtime ecosystem domains +// Returns a deduplicated, sorted, comma-separated string suitable for AWF's --allow-domains flag +func GetOpenCodeAllowedDomainsWithToolsAndRuntimes(network *NetworkPermissions, tools map[string]any, runtimes map[string]any) string { + return GetAllowedDomainsForEngine(constants.OpenCodeEngine, network, tools, runtimes) +} + // PlaywrightDomains are the domains required for Playwright browser downloads // These domains are needed when Playwright MCP server initializes in the Docker container var PlaywrightDomains = []string{ @@ -524,10 +588,11 @@ func mergeDomainsWithNetworkToolsAndRuntimes(defaultDomains []string, network *N // engineDefaultDomains maps each engine to its default required domains. // Add new engines here to avoid adding new engine-specific domain functions. var engineDefaultDomains = map[constants.EngineName][]string{ - constants.CopilotEngine: CopilotDefaultDomains, - constants.ClaudeEngine: ClaudeDefaultDomains, - constants.CodexEngine: CodexDefaultDomains, - constants.GeminiEngine: GeminiDefaultDomains, + constants.CopilotEngine: CopilotDefaultDomains, + constants.ClaudeEngine: ClaudeDefaultDomains, + constants.CodexEngine: CodexDefaultDomains, + constants.GeminiEngine: GeminiDefaultDomains, + constants.OpenCodeEngine: OpenCodeDefaultDomains, } // GetAllowedDomainsForEngine merges the engine's default domains with NetworkPermissions, @@ -695,6 +760,8 @@ func (c *Compiler) computeAllowedDomainsForSanitization(data *WorkflowData) stri return GetClaudeAllowedDomainsWithToolsAndRuntimes(data.NetworkPermissions, data.Tools, data.Runtimes) case "gemini": return GetGeminiAllowedDomainsWithToolsAndRuntimes(data.NetworkPermissions, data.Tools, data.Runtimes) + case "opencode": + return GetOpenCodeAllowedDomainsWithToolsAndRuntimes(data.NetworkPermissions, data.Tools, data.Runtimes) default: // For other engines, use network permissions only domains := GetAllowedDomains(data.NetworkPermissions) diff --git a/pkg/workflow/opencode_engine.go b/pkg/workflow/opencode_engine.go new file mode 100644 index 00000000000..395ee5fc1b0 --- /dev/null +++ b/pkg/workflow/opencode_engine.go @@ -0,0 +1,311 @@ +package workflow + +import ( + "fmt" + "maps" + "strings" + + "github.com/github/gh-aw/pkg/constants" + "github.com/github/gh-aw/pkg/logger" +) + +var opencodeLog = logger.New("workflow:opencode_engine") + +// OpenCodeEngine represents the OpenCode CLI agentic engine. +// OpenCode is a provider-agnostic, open-source AI coding agent that supports +// 75+ models via BYOK (Bring Your Own Key). +type OpenCodeEngine struct { + BaseEngine +} + +func NewOpenCodeEngine() *OpenCodeEngine { + return &OpenCodeEngine{ + BaseEngine: BaseEngine{ + id: "opencode", + displayName: "OpenCode", + description: "OpenCode CLI with headless mode and multi-provider LLM support", + experimental: true, // Start as experimental until smoke tests pass consistently + supportsToolsAllowlist: false, // OpenCode manages its own tool permissions via opencode.jsonc + supportsMaxTurns: false, // No --max-turns flag in opencode run + supportsWebFetch: false, // Has built-in webfetch but not exposed via gh-aw neutral tools yet + supportsWebSearch: false, // Has built-in websearch but not exposed via gh-aw neutral tools yet + supportsFirewall: true, // Supports AWF network sandboxing + supportsPlugins: false, + supportsLLMGateway: true, // Supports LLM gateway on port 10004 + }, + } +} + +// SupportsLLMGateway returns the LLM gateway port for OpenCode engine +func (e *OpenCodeEngine) SupportsLLMGateway() int { + return constants.OpenCodeLLMGatewayPort +} + +// GetModelEnvVarName returns the native environment variable name that the OpenCode CLI uses +// for model selection. Setting OPENCODE_MODEL is equivalent to passing --model to the CLI. +func (e *OpenCodeEngine) GetModelEnvVarName() string { + return constants.OpenCodeCLIModelEnvVar +} + +// GetRequiredSecretNames returns the list of secrets required by the OpenCode engine. +// This includes ANTHROPIC_API_KEY as the default provider, plus any additional +// provider API keys from engine.env, and MCP/GitHub secrets as needed. +func (e *OpenCodeEngine) GetRequiredSecretNames(workflowData *WorkflowData) []string { + opencodeLog.Print("Collecting required secrets for OpenCode engine") + secrets := []string{"ANTHROPIC_API_KEY"} // Default provider + + // Allow additional provider API keys from engine.env overrides + if workflowData.EngineConfig != nil && len(workflowData.EngineConfig.Env) > 0 { + for key := range workflowData.EngineConfig.Env { + if strings.HasSuffix(key, "_API_KEY") || strings.HasSuffix(key, "_KEY") { + secrets = append(secrets, key) + } + } + } + + // Add MCP gateway API key if MCP servers are present + if HasMCPServers(workflowData) { + opencodeLog.Print("Adding MCP_GATEWAY_API_KEY secret") + secrets = append(secrets, "MCP_GATEWAY_API_KEY") + } + + // Add GitHub token for GitHub MCP server if present + if hasGitHubTool(workflowData.ParsedTools) { + opencodeLog.Print("Adding GITHUB_MCP_SERVER_TOKEN secret") + secrets = append(secrets, "GITHUB_MCP_SERVER_TOKEN") + } + + // Add HTTP MCP header secret names + headerSecrets := collectHTTPMCPHeaderSecrets(workflowData.Tools) + for varName := range headerSecrets { + secrets = append(secrets, varName) + } + if len(headerSecrets) > 0 { + opencodeLog.Printf("Added %d HTTP MCP header secrets", len(headerSecrets)) + } + + // Add safe-inputs secret names + if IsSafeInputsEnabled(workflowData.SafeInputs, workflowData) { + safeInputsSecrets := collectSafeInputsSecrets(workflowData.SafeInputs) + for varName := range safeInputsSecrets { + secrets = append(secrets, varName) + } + if len(safeInputsSecrets) > 0 { + opencodeLog.Printf("Added %d safe-inputs secrets", len(safeInputsSecrets)) + } + } + + return secrets +} + +// GetInstallationSteps returns the GitHub Actions steps needed to install OpenCode CLI +func (e *OpenCodeEngine) GetInstallationSteps(workflowData *WorkflowData) []GitHubActionStep { + opencodeLog.Printf("Generating installation steps for OpenCode engine: workflow=%s", workflowData.Name) + + // Skip installation if custom command is specified + if workflowData.EngineConfig != nil && workflowData.EngineConfig.Command != "" { + opencodeLog.Printf("Skipping installation steps: custom command specified (%s)", workflowData.EngineConfig.Command) + return []GitHubActionStep{} + } + + var steps []GitHubActionStep + + // Define engine configuration for shared validation + config := EngineInstallConfig{ + Secrets: []string{"ANTHROPIC_API_KEY"}, + DocsURL: "https://opencode.ai/docs/get-started/", + NpmPackage: "opencode-ai", + Version: string(constants.DefaultOpenCodeVersion), + Name: "OpenCode CLI", + CliName: "opencode", + InstallStepName: "Install OpenCode CLI", + } + + // Add secret validation step + secretValidation := GenerateMultiSecretValidationStep( + config.Secrets, + config.Name, + config.DocsURL, + getEngineEnvOverrides(workflowData), + ) + steps = append(steps, secretValidation) + + // Determine OpenCode version + opencodeVersion := config.Version + if workflowData.EngineConfig != nil && workflowData.EngineConfig.Version != "" { + opencodeVersion = workflowData.EngineConfig.Version + } + + // Add Node.js setup step first (before sandbox installation) + npmSteps := GenerateNpmInstallSteps( + config.NpmPackage, + opencodeVersion, + config.InstallStepName, + config.CliName, + true, // Include Node.js setup + ) + + if len(npmSteps) > 0 { + steps = append(steps, npmSteps[0]) // Setup Node.js step + } + + // Add AWF installation if firewall is enabled + if isFirewallEnabled(workflowData) { + firewallConfig := getFirewallConfig(workflowData) + agentConfig := getAgentConfig(workflowData) + var awfVersion string + if firewallConfig != nil { + awfVersion = firewallConfig.Version + } + + awfInstall := generateAWFInstallationStep(awfVersion, agentConfig) + if len(awfInstall) > 0 { + steps = append(steps, awfInstall) + } + } + + // Add OpenCode CLI installation step after sandbox installation + if len(npmSteps) > 1 { + steps = append(steps, npmSteps[1:]...) + } + + return steps +} + +// GetDeclaredOutputFiles returns the output files that OpenCode may produce. +func (e *OpenCodeEngine) GetDeclaredOutputFiles() []string { + return []string{} +} + +// GetExecutionSteps returns the GitHub Actions steps for executing OpenCode +func (e *OpenCodeEngine) GetExecutionSteps(workflowData *WorkflowData, logFile string) []GitHubActionStep { + opencodeLog.Printf("Generating execution steps for OpenCode engine: workflow=%s, firewall=%v", + workflowData.Name, isFirewallEnabled(workflowData)) + + var steps []GitHubActionStep + + // Step 1: Write opencode.jsonc config (permissions) + configStep := e.generateOpenCodeConfigStep(workflowData) + steps = append(steps, configStep) + + // Step 2: Build CLI arguments + var opencodeArgs []string + + modelConfigured := workflowData.EngineConfig != nil && workflowData.EngineConfig.Model != "" + + // Quiet mode for CI (suppress spinner) + opencodeArgs = append(opencodeArgs, "-q") + + // Prompt from file + opencodeArgs = append(opencodeArgs, "\"$(cat /tmp/gh-aw/aw-prompts/prompt.txt)\"") + + // Build command name + commandName := "opencode" + if workflowData.EngineConfig != nil && workflowData.EngineConfig.Command != "" { + commandName = workflowData.EngineConfig.Command + } + opencodeCommand := fmt.Sprintf("%s run %s", commandName, shellJoinArgs(opencodeArgs)) + + // AWF wrapping + firewallEnabled := isFirewallEnabled(workflowData) + var command string + if firewallEnabled { + allowedDomains := GetOpenCodeAllowedDomainsWithToolsAndRuntimes( + workflowData.NetworkPermissions, + workflowData.Tools, + workflowData.Runtimes, + ) + + npmPathSetup := GetNpmBinPathSetup() + opencodeCommandWithPath := fmt.Sprintf("%s && %s", npmPathSetup, opencodeCommand) + + llmGatewayPort := e.SupportsLLMGateway() + usesAPIProxy := llmGatewayPort > 0 + + command = BuildAWFCommand(AWFCommandConfig{ + EngineName: "opencode", + EngineCommand: opencodeCommandWithPath, + LogFile: logFile, + WorkflowData: workflowData, + UsesTTY: false, + UsesAPIProxy: usesAPIProxy, + AllowedDomains: allowedDomains, + }) + } else { + command = fmt.Sprintf("set -o pipefail\n%s 2>&1 | tee -a %s", opencodeCommand, logFile) + } + + // Environment variables + env := map[string]string{ + "ANTHROPIC_API_KEY": "${{ secrets.ANTHROPIC_API_KEY }}", + "GH_AW_PROMPT": "/tmp/gh-aw/aw-prompts/prompt.txt", + "GITHUB_WORKSPACE": "${{ github.workspace }}", + "NO_PROXY": "localhost,127.0.0.1", + } + + // MCP config path + if HasMCPServers(workflowData) { + env["GH_AW_MCP_CONFIG"] = "${{ github.workspace }}/opencode.jsonc" + } + + // LLM gateway base URL override (default Anthropic) + if firewallEnabled { + env["ANTHROPIC_BASE_URL"] = fmt.Sprintf("http://host.docker.internal:%d", + constants.OpenCodeLLMGatewayPort) + } + + // Safe outputs env + applySafeOutputEnvToMap(env, workflowData) + + // Model env var (only when explicitly configured) + if modelConfigured { + opencodeLog.Printf("Setting %s env var for model: %s", + constants.OpenCodeCLIModelEnvVar, workflowData.EngineConfig.Model) + env[constants.OpenCodeCLIModelEnvVar] = workflowData.EngineConfig.Model + } + + // Custom env from engine config (allows provider override) + if workflowData.EngineConfig != nil && len(workflowData.EngineConfig.Env) > 0 { + maps.Copy(env, workflowData.EngineConfig.Env) + } + + // Agent config env + agentConfig := getAgentConfig(workflowData) + if agentConfig != nil && len(agentConfig.Env) > 0 { + maps.Copy(env, agentConfig.Env) + } + + // Build execution step + stepLines := []string{ + " - name: Execute OpenCode CLI", + " id: agentic_execution", + } + allowedSecrets := e.GetRequiredSecretNames(workflowData) + filteredEnv := FilterEnvForSecrets(env, allowedSecrets) + stepLines = FormatStepWithCommandAndEnv(stepLines, command, filteredEnv) + + steps = append(steps, GitHubActionStep(stepLines)) + return steps +} + +// generateOpenCodeConfigStep writes opencode.jsonc with all permissions set to allow +// to prevent CI hanging on permission prompts. +func (e *OpenCodeEngine) generateOpenCodeConfigStep(_ *WorkflowData) GitHubActionStep { + // Build the config JSON with all permissions set to allow + configJSON := `{"agent":{"build":{"permissions":{"bash":"allow","edit":"allow","read":"allow","glob":"allow","grep":"allow","write":"allow","webfetch":"allow","websearch":"allow"}}}}` + + // Shell command to write or merge the config + command := fmt.Sprintf(`mkdir -p "$GITHUB_WORKSPACE" +CONFIG="$GITHUB_WORKSPACE/opencode.jsonc" +BASE_CONFIG='%s' +if [ -f "$CONFIG" ]; then + MERGED=$(jq -n --argjson base "$BASE_CONFIG" --argjson existing "$(cat "$CONFIG")" '$existing * $base') + echo "$MERGED" > "$CONFIG" +else + echo "$BASE_CONFIG" > "$CONFIG" +fi`, configJSON) + + stepLines := []string{" - name: Write OpenCode configuration"} + stepLines = FormatStepWithCommandAndEnv(stepLines, command, nil) + return GitHubActionStep(stepLines) +} diff --git a/pkg/workflow/opencode_engine_test.go b/pkg/workflow/opencode_engine_test.go new file mode 100644 index 00000000000..8290877d4d5 --- /dev/null +++ b/pkg/workflow/opencode_engine_test.go @@ -0,0 +1,369 @@ +//go:build !integration + +package workflow + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestOpenCodeEngine(t *testing.T) { + engine := NewOpenCodeEngine() + + t.Run("engine identity", func(t *testing.T) { + assert.Equal(t, "opencode", engine.GetID(), "Engine ID should be 'opencode'") + assert.Equal(t, "OpenCode", engine.GetDisplayName(), "Display name should be 'OpenCode'") + assert.NotEmpty(t, engine.GetDescription(), "Description should not be empty") + assert.True(t, engine.IsExperimental(), "OpenCode engine should be experimental") + }) + + t.Run("capabilities", func(t *testing.T) { + assert.False(t, engine.SupportsToolsAllowlist(), "Should not support tools allowlist") + assert.False(t, engine.SupportsMaxTurns(), "Should not support max turns") + assert.False(t, engine.SupportsWebFetch(), "Should not support built-in web fetch") + assert.False(t, engine.SupportsWebSearch(), "Should not support built-in web search") + assert.True(t, engine.SupportsFirewall(), "Should support firewall/AWF") + assert.False(t, engine.SupportsPlugins(), "Should not support plugins") + assert.Equal(t, 10004, engine.SupportsLLMGateway(), "Should support LLM gateway on port 10004") + }) + + t.Run("model env var name", func(t *testing.T) { + assert.Equal(t, "OPENCODE_MODEL", engine.GetModelEnvVarName(), "Should return OPENCODE_MODEL") + }) + + t.Run("required secrets basic", func(t *testing.T) { + workflowData := &WorkflowData{ + Name: "test", + ParsedTools: &ToolsConfig{}, + Tools: map[string]any{}, + } + secrets := engine.GetRequiredSecretNames(workflowData) + assert.Contains(t, secrets, "ANTHROPIC_API_KEY", "Should require ANTHROPIC_API_KEY") + }) + + t.Run("required secrets with MCP servers", func(t *testing.T) { + workflowData := &WorkflowData{ + Name: "test", + ParsedTools: &ToolsConfig{ + GitHub: &GitHubToolConfig{}, + }, + Tools: map[string]any{ + "github": map[string]any{}, + }, + } + secrets := engine.GetRequiredSecretNames(workflowData) + assert.Contains(t, secrets, "ANTHROPIC_API_KEY", "Should require ANTHROPIC_API_KEY") + assert.Contains(t, secrets, "MCP_GATEWAY_API_KEY", "Should require MCP_GATEWAY_API_KEY when MCP servers present") + assert.Contains(t, secrets, "GITHUB_MCP_SERVER_TOKEN", "Should require GITHUB_MCP_SERVER_TOKEN for GitHub tool") + }) + + t.Run("required secrets with env override", func(t *testing.T) { + workflowData := &WorkflowData{ + Name: "test", + ParsedTools: &ToolsConfig{}, + Tools: map[string]any{}, + EngineConfig: &EngineConfig{ + Env: map[string]string{ + "OPENAI_API_KEY": "${{ secrets.OPENAI_API_KEY }}", + }, + }, + } + secrets := engine.GetRequiredSecretNames(workflowData) + assert.Contains(t, secrets, "ANTHROPIC_API_KEY", "Should still require ANTHROPIC_API_KEY") + assert.Contains(t, secrets, "OPENAI_API_KEY", "Should add OPENAI_API_KEY from engine.env") + }) + + t.Run("declared output files", func(t *testing.T) { + outputFiles := engine.GetDeclaredOutputFiles() + assert.Empty(t, outputFiles, "Should have no declared output files") + }) +} + +func TestOpenCodeEngineInstallation(t *testing.T) { + engine := NewOpenCodeEngine() + + t.Run("standard installation", func(t *testing.T) { + workflowData := &WorkflowData{ + Name: "test-workflow", + } + + steps := engine.GetInstallationSteps(workflowData) + require.NotEmpty(t, steps, "Should generate installation steps") + + // Should have at least: Secret validation + Node.js setup + Install OpenCode + assert.GreaterOrEqual(t, len(steps), 3, "Should have at least 3 installation steps") + + // Verify first step is secret validation + if len(steps) > 0 && len(steps[0]) > 0 { + stepContent := strings.Join(steps[0], "\n") + assert.Contains(t, stepContent, "Validate ANTHROPIC_API_KEY secret", "First step should validate ANTHROPIC_API_KEY") + } + + // Verify second step is Node.js setup + if len(steps) > 1 && len(steps[1]) > 0 { + stepContent := strings.Join(steps[1], "\n") + assert.Contains(t, stepContent, "Setup Node.js", "Second step should setup Node.js") + } + + // Verify third step is Install OpenCode CLI + if len(steps) > 2 && len(steps[2]) > 0 { + stepContent := strings.Join(steps[2], "\n") + assert.Contains(t, stepContent, "Install OpenCode CLI", "Third step should install OpenCode CLI") + assert.Contains(t, stepContent, "opencode-ai", "Should install opencode-ai package") + } + }) + + t.Run("custom command skips installation", func(t *testing.T) { + workflowData := &WorkflowData{ + Name: "test-workflow", + EngineConfig: &EngineConfig{ + Command: "/custom/opencode", + }, + } + + steps := engine.GetInstallationSteps(workflowData) + assert.Empty(t, steps, "Should skip installation when custom command is specified") + }) + + t.Run("with firewall", func(t *testing.T) { + workflowData := &WorkflowData{ + Name: "test-workflow", + NetworkPermissions: &NetworkPermissions{ + Allowed: []string{"defaults"}, + Firewall: &FirewallConfig{ + Enabled: true, + }, + }, + } + + steps := engine.GetInstallationSteps(workflowData) + require.NotEmpty(t, steps, "Should generate installation steps") + + // Should include AWF installation step + hasAWFInstall := false + for _, step := range steps { + stepContent := strings.Join(step, "\n") + if strings.Contains(stepContent, "awf") || strings.Contains(stepContent, "firewall") { + hasAWFInstall = true + break + } + } + assert.True(t, hasAWFInstall, "Should include AWF installation step when firewall is enabled") + }) +} + +func TestOpenCodeEngineExecution(t *testing.T) { + engine := NewOpenCodeEngine() + + t.Run("basic execution", func(t *testing.T) { + workflowData := &WorkflowData{ + Name: "test-workflow", + } + + steps := engine.GetExecutionSteps(workflowData, "/tmp/test.log") + require.Len(t, steps, 2, "Should generate config step and execution step") + + // steps[0] = Write OpenCode config, steps[1] = Execute OpenCode CLI + stepContent := strings.Join(steps[1], "\n") + + assert.Contains(t, stepContent, "name: Execute OpenCode CLI", "Should have correct step name") + assert.Contains(t, stepContent, "id: agentic_execution", "Should have agentic_execution ID") + assert.Contains(t, stepContent, "opencode run", "Should invoke opencode run command") + assert.Contains(t, stepContent, "-q", "Should include quiet flag") + assert.Contains(t, stepContent, `"$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"`, "Should include prompt argument") + assert.Contains(t, stepContent, "/tmp/test.log", "Should include log file") + assert.Contains(t, stepContent, "ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}", "Should set ANTHROPIC_API_KEY env var") + assert.Contains(t, stepContent, "NO_PROXY: localhost,127.0.0.1", "Should set NO_PROXY env var") + }) + + t.Run("with model", func(t *testing.T) { + workflowData := &WorkflowData{ + Name: "test-workflow", + EngineConfig: &EngineConfig{ + Model: "anthropic/claude-sonnet-4-20250514", + }, + } + + steps := engine.GetExecutionSteps(workflowData, "/tmp/test.log") + require.Len(t, steps, 2, "Should generate config step and execution step") + + stepContent := strings.Join(steps[1], "\n") + + // Model is passed via the native OPENCODE_MODEL env var + assert.Contains(t, stepContent, "OPENCODE_MODEL: anthropic/claude-sonnet-4-20250514", "Should set OPENCODE_MODEL env var") + }) + + t.Run("without model no model env var", func(t *testing.T) { + workflowData := &WorkflowData{ + Name: "test-workflow", + } + + steps := engine.GetExecutionSteps(workflowData, "/tmp/test.log") + require.Len(t, steps, 2, "Should generate config step and execution step") + + stepContent := strings.Join(steps[1], "\n") + + assert.NotContains(t, stepContent, "OPENCODE_MODEL", "Should not include OPENCODE_MODEL when model is unconfigured") + }) + + t.Run("with MCP servers", func(t *testing.T) { + workflowData := &WorkflowData{ + Name: "test-workflow", + ParsedTools: &ToolsConfig{ + GitHub: &GitHubToolConfig{}, + }, + Tools: map[string]any{ + "github": map[string]any{}, + }, + } + + steps := engine.GetExecutionSteps(workflowData, "/tmp/test.log") + require.Len(t, steps, 2, "Should generate config step and execution step") + + stepContent := strings.Join(steps[1], "\n") + + assert.Contains(t, stepContent, "GH_AW_MCP_CONFIG: ${{ github.workspace }}/opencode.jsonc", "Should set MCP config env var") + }) + + t.Run("with custom command", func(t *testing.T) { + workflowData := &WorkflowData{ + Name: "test-workflow", + EngineConfig: &EngineConfig{ + Command: "/custom/opencode", + }, + } + + steps := engine.GetExecutionSteps(workflowData, "/tmp/test.log") + require.Len(t, steps, 2, "Should generate config step and execution step") + + stepContent := strings.Join(steps[1], "\n") + + assert.Contains(t, stepContent, "/custom/opencode", "Should use custom command") + }) + + t.Run("engine env overrides default token expression", func(t *testing.T) { + workflowData := &WorkflowData{ + Name: "test-workflow", + EngineConfig: &EngineConfig{ + Env: map[string]string{ + "ANTHROPIC_API_KEY": "${{ secrets.MY_ORG_ANTHROPIC_KEY }}", + }, + }, + } + + steps := engine.GetExecutionSteps(workflowData, "/tmp/test.log") + require.Len(t, steps, 2, "Should generate config step and execution step") + + stepContent := strings.Join(steps[1], "\n") + + // The user-provided value should override the default token expression + assert.Contains(t, stepContent, "ANTHROPIC_API_KEY: ${{ secrets.MY_ORG_ANTHROPIC_KEY }}", "engine.env should override the default ANTHROPIC_API_KEY expression") + assert.NotContains(t, stepContent, "ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}", "Default ANTHROPIC_API_KEY expression should be replaced by engine.env") + }) + + t.Run("engine env adds custom non-secret env vars", func(t *testing.T) { + workflowData := &WorkflowData{ + Name: "test-workflow", + EngineConfig: &EngineConfig{ + Env: map[string]string{ + "CUSTOM_VAR": "custom-value", + }, + }, + } + + steps := engine.GetExecutionSteps(workflowData, "/tmp/test.log") + require.Len(t, steps, 2, "Should generate config step and execution step") + + stepContent := strings.Join(steps[1], "\n") + + assert.Contains(t, stepContent, "CUSTOM_VAR: custom-value", "engine.env non-secret vars should be included") + }) + + t.Run("config step is first", func(t *testing.T) { + workflowData := &WorkflowData{ + Name: "test-workflow", + } + + steps := engine.GetExecutionSteps(workflowData, "/tmp/test.log") + require.Len(t, steps, 2, "Should generate config step and execution step") + + configContent := strings.Join(steps[0], "\n") + execContent := strings.Join(steps[1], "\n") + + assert.Contains(t, configContent, "Write OpenCode configuration", "First step should be Write OpenCode configuration") + assert.Contains(t, configContent, "opencode.jsonc", "Config step should reference opencode.jsonc") + assert.Contains(t, configContent, "permissions", "Config step should set permissions") + assert.Contains(t, execContent, "Execute OpenCode CLI", "Second step should be Execute OpenCode CLI") + }) +} + +func TestOpenCodeEngineFirewallIntegration(t *testing.T) { + engine := NewOpenCodeEngine() + + t.Run("firewall enabled", func(t *testing.T) { + workflowData := &WorkflowData{ + Name: "test-workflow", + NetworkPermissions: &NetworkPermissions{ + Allowed: []string{"defaults"}, + Firewall: &FirewallConfig{ + Enabled: true, + }, + }, + } + + steps := engine.GetExecutionSteps(workflowData, "/tmp/test.log") + require.Len(t, steps, 2, "Should generate config step and execution step") + + stepContent := strings.Join(steps[1], "\n") + + // Should use AWF command + assert.Contains(t, stepContent, "awf", "Should use AWF when firewall is enabled") + assert.Contains(t, stepContent, "--allow-domains", "Should include allow-domains flag") + assert.Contains(t, stepContent, "--enable-api-proxy", "Should include --enable-api-proxy flag") + assert.Contains(t, stepContent, "ANTHROPIC_BASE_URL: http://host.docker.internal:10004", "Should set ANTHROPIC_BASE_URL to LLM gateway URL") + }) + + t.Run("firewall disabled", func(t *testing.T) { + workflowData := &WorkflowData{ + Name: "test-workflow", + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{ + Enabled: false, + }, + }, + } + + steps := engine.GetExecutionSteps(workflowData, "/tmp/test.log") + require.Len(t, steps, 2, "Should generate config step and execution step") + + stepContent := strings.Join(steps[1], "\n") + + // Should use simple command without AWF + assert.Contains(t, stepContent, "set -o pipefail", "Should use simple command with pipefail") + assert.NotContains(t, stepContent, "awf", "Should not use AWF when firewall is disabled") + assert.NotContains(t, stepContent, "ANTHROPIC_BASE_URL", "Should not set ANTHROPIC_BASE_URL when firewall is disabled") + }) +} + +func TestExtractProviderFromModel(t *testing.T) { + t.Run("standard provider/model format", func(t *testing.T) { + assert.Equal(t, "anthropic", extractProviderFromModel("anthropic/claude-sonnet-4-20250514")) + assert.Equal(t, "openai", extractProviderFromModel("openai/gpt-4.1")) + assert.Equal(t, "google", extractProviderFromModel("google/gemini-2.5-pro")) + }) + + t.Run("empty model defaults to anthropic", func(t *testing.T) { + assert.Equal(t, "anthropic", extractProviderFromModel("")) + }) + + t.Run("no slash defaults to anthropic", func(t *testing.T) { + assert.Equal(t, "anthropic", extractProviderFromModel("claude-sonnet-4-20250514")) + }) + + t.Run("case insensitive provider", func(t *testing.T) { + assert.Equal(t, "openai", extractProviderFromModel("OpenAI/gpt-4.1")) + }) +} diff --git a/pkg/workflow/opencode_mcp.go b/pkg/workflow/opencode_mcp.go new file mode 100644 index 00000000000..01a5b7d6a9f --- /dev/null +++ b/pkg/workflow/opencode_mcp.go @@ -0,0 +1,71 @@ +package workflow + +import ( + "strings" + + "github.com/github/gh-aw/pkg/logger" +) + +var opencodeMCPLog = logger.New("workflow:opencode_mcp") + +// RenderMCPConfig renders MCP server configuration for OpenCode CLI +func (e *OpenCodeEngine) RenderMCPConfig(yaml *strings.Builder, tools map[string]any, mcpTools []string, workflowData *WorkflowData) error { + opencodeMCPLog.Printf("Rendering MCP config for OpenCode: tool_count=%d, mcp_tool_count=%d", len(tools), len(mcpTools)) + + // Create unified renderer with OpenCode-specific options + createRenderer := func(isLast bool) *MCPConfigRendererUnified { + return NewMCPConfigRenderer(MCPRendererOptions{ + IncludeCopilotFields: false, + InlineArgs: false, + Format: "json", // OpenCode uses JSON format + IsLast: isLast, + ActionMode: GetActionModeFromWorkflowData(workflowData), + }) + } + + // Use shared JSON MCP config renderer + return RenderJSONMCPConfig(yaml, tools, mcpTools, workflowData, JSONMCPConfigOptions{ + ConfigPath: "/tmp/gh-aw/mcp-config/mcp-servers.json", + GatewayConfig: buildMCPGatewayConfig(workflowData), + Renderers: MCPToolRenderers{ + RenderGitHub: func(yaml *strings.Builder, githubTool any, isLast bool, workflowData *WorkflowData) { + renderer := createRenderer(isLast) + renderer.RenderGitHubMCP(yaml, githubTool, workflowData) + }, + RenderPlaywright: func(yaml *strings.Builder, playwrightTool any, isLast bool) { + renderer := createRenderer(isLast) + renderer.RenderPlaywrightMCP(yaml, playwrightTool) + }, + RenderSerena: func(yaml *strings.Builder, serenaTool any, isLast bool) { + renderer := createRenderer(isLast) + renderer.RenderSerenaMCP(yaml, serenaTool) + }, + RenderCacheMemory: e.renderCacheMemoryMCPConfig, + RenderAgenticWorkflows: func(yaml *strings.Builder, isLast bool) { + renderer := createRenderer(isLast) + renderer.RenderAgenticWorkflowsMCP(yaml) + }, + RenderSafeOutputs: func(yaml *strings.Builder, isLast bool, workflowData *WorkflowData) { + renderer := createRenderer(isLast) + renderer.RenderSafeOutputsMCP(yaml, workflowData) + }, + RenderSafeInputs: func(yaml *strings.Builder, safeInputs *SafeInputsConfig, isLast bool) { + renderer := createRenderer(isLast) + renderer.RenderSafeInputsMCP(yaml, safeInputs, workflowData) + }, + RenderWebFetch: func(yaml *strings.Builder, isLast bool) { + renderMCPFetchServerConfig(yaml, "json", " ", isLast, false) + }, + RenderCustomMCPConfig: func(yaml *strings.Builder, toolName string, toolConfig map[string]any, isLast bool) error { + return renderCustomMCPConfigWrapperWithContext(yaml, toolName, toolConfig, isLast, workflowData) + }, + }, + }) +} + +// renderCacheMemoryMCPConfig handles cache-memory configuration for OpenCode +func (e *OpenCodeEngine) renderCacheMemoryMCPConfig(yaml *strings.Builder, isLast bool, workflowData *WorkflowData) { + // Cache-memory is a simple file share, not an MCP server + // No MCP configuration needed + opencodeMCPLog.Print("Cache-memory tool detected, no MCP config needed") +}