diff --git a/.github/workflows/agentics-maintenance.yml b/.github/workflows/agentics-maintenance.yml
index 8c142f3ab1a..6eb35db87c3 100644
--- a/.github/workflows/agentics-maintenance.yml
+++ b/.github/workflows/agentics-maintenance.yml
@@ -281,7 +281,7 @@ jobs:
validate_workflows:
if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.operation == 'validate' && !github.event.repository.fork }}
- runs-on: ubuntu-slim
+ runs-on: ubuntu-latest
permissions:
contents: read
issues: write
diff --git a/.github/workflows/smoke-opencode.lock.yml b/.github/workflows/smoke-opencode.lock.yml
new file mode 100644
index 00000000000..53748d9e0d0
--- /dev/null
+++ b/.github/workflows/smoke-opencode.lock.yml
@@ -0,0 +1,1474 @@
+# gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"90d6bb73c1c7142d563c0e7052bdd0fb3a5101b92d6cc7115e6486f70c6fef1b","strict":true,"agent_id":"opencode","agent_model":"anthropic/claude-sonnet-4-20250514"}
+# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"de0fac2e4500dabe0009e67214ff5f5447ce83dd","version":"v6.0.2"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"373c709c69115d41ff229c7e5df9f8788daa9553","version":"v9"},{"repo":"actions/setup-node","sha":"53b83947a5a98c8d113130e565377fae1a50d02f","version":"v6.3.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.25.18","digest":"sha256:c77e8c26bab6c39e8568d8e2f8c17015944849a8cbcdfb4bd9725d8893725ca2","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.25.18@sha256:c77e8c26bab6c39e8568d8e2f8c17015944849a8cbcdfb4bd9725d8893725ca2"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.25.18","digest":"sha256:d16a40a3ca6e989896d0cef9f31b9412bb1fcc8755bafcafb95012ae1078539b","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.25.18@sha256:d16a40a3ca6e989896d0cef9f31b9412bb1fcc8755bafcafb95012ae1078539b"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.25.18","digest":"sha256:eb102afcfbae26ffcec016adebb74d3be7b0a5bf376ba306599cdf3effbe288e","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.25.18@sha256:eb102afcfbae26ffcec016adebb74d3be7b0a5bf376ba306599cdf3effbe288e"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.2.17","digest":"sha256:a6dec6ec535a11c565d982afa2f98589805ed0598862b9ea9d3c751fc71afae8","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.2.17@sha256:a6dec6ec535a11c565d982afa2f98589805ed0598862b9ea9d3c751fc71afae8"},{"image":"ghcr.io/github/github-mcp-server:v0.32.0","digest":"sha256:2763823c63bcca718ce53850a1d7fcf2f501ec84028394f1b63ce7e9f4f9be28","pinned_image":"ghcr.io/github/github-mcp-server:v0.32.0@sha256:2763823c63bcca718ce53850a1d7fcf2f501ec84028394f1b63ce7e9f4f9be28"},{"image":"node:lts-alpine","digest":"sha256:01743339035a5c3c11a373cd7c83aeab6ed1457b55da6a69e014a95ac4e4700b","pinned_image":"node:lts-alpine@sha256:01743339035a5c3c11a373cd7c83aeab6ed1457b55da6a69e014a95ac4e4700b"}]}
+# ___ _ _
+# / _ \ | | (_)
+# | |_| | __ _ ___ _ __ | |_ _ ___
+# | _ |/ _` |/ _ \ '_ \| __| |/ __|
+# | | | | (_| | __/ | | | |_| | (__
+# \_| |_/\__, |\___|_| |_|\__|_|\___|
+# __/ |
+# _ _ |___/
+# | | | | / _| |
+# | | | | ___ _ __ _ __| |_| | _____ ____
+# | |/\| |/ _ \ '__| |/ /| _| |/ _ \ \ /\ / / ___|
+# \ /\ / (_) | | | | ( | | | | (_) \ 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
+#
+# Secrets used:
+# - COPILOT_GITHUB_TOKEN
+# - GH_AW_GITHUB_MCP_SERVER_TOKEN
+# - GH_AW_GITHUB_TOKEN
+# - GITHUB_TOKEN
+#
+# Custom actions used:
+# - actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+# - actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+# - actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9
+# - actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
+# - actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+#
+# Container images used:
+# - ghcr.io/github/gh-aw-firewall/agent:0.25.18@sha256:c77e8c26bab6c39e8568d8e2f8c17015944849a8cbcdfb4bd9725d8893725ca2
+# - ghcr.io/github/gh-aw-firewall/api-proxy:0.25.18@sha256:d16a40a3ca6e989896d0cef9f31b9412bb1fcc8755bafcafb95012ae1078539b
+# - ghcr.io/github/gh-aw-firewall/squid:0.25.18@sha256:eb102afcfbae26ffcec016adebb74d3be7b0a5bf376ba306599cdf3effbe288e
+# - ghcr.io/github/gh-aw-mcpg:v0.2.17@sha256:a6dec6ec535a11c565d982afa2f98589805ed0598862b9ea9d3c751fc71afae8
+# - ghcr.io/github/github-mcp-server:v0.32.0@sha256:2763823c63bcca718ce53850a1d7fcf2f501ec84028394f1b63ce7e9f4f9be28
+# - node:lts-alpine@sha256:01743339035a5c3c11a373cd7c83aeab6ed1457b55da6a69e014a95ac4e4700b
+
+name: "Smoke OpenCode"
+"on":
+ pull_request:
+ # names: # Label filtering applied via job conditions
+ # - smoke # Label filtering applied via job conditions
+ types:
+ - labeled
+ schedule:
+ - cron: "23 */12 * * *"
+ workflow_dispatch:
+ inputs:
+ aw_context:
+ default: ""
+ description: Agent caller context (used internally by Agentic Workflows).
+ required: false
+ type: string
+
+permissions: {}
+
+concurrency:
+ group: "gh-aw-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref || github.run_id }}"
+ 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:
+ actions: read
+ 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 }}
+ lockdown_check_failed: ${{ steps.generate_aw_info.outputs.lockdown_check_failed == 'true' }}
+ model: ${{ steps.generate_aw_info.outputs.model }}
+ secret_verification_result: ${{ steps.validate-secret.outputs.verification_result }}
+ setup-trace-id: ${{ steps.setup.outputs.trace-id }}
+ stale_lock_file_failed: ${{ steps.check-lock-file.outputs.stale_lock_file_failed == 'true' }}
+ text: ${{ steps.sanitized.outputs.text }}
+ title: ${{ steps.sanitized.outputs.title }}
+ steps:
+ - name: Checkout actions folder
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ repository: github/gh-aw
+ sparse-checkout: |
+ actions
+ persist-credentials: false
+ - name: Setup Scripts
+ id: setup
+ uses: ./actions/setup
+ with:
+ destination: ${{ runner.temp }}/gh-aw/actions
+ job-name: ${{ github.job }}
+ trace-id: ${{ needs.pre_activation.outputs.setup-trace-id }}
+ - name: Generate agentic run info
+ id: generate_aw_info
+ env:
+ GH_AW_INFO_ENGINE_ID: "opencode"
+ GH_AW_INFO_ENGINE_NAME: "OpenCode"
+ GH_AW_INFO_MODEL: "anthropic/claude-sonnet-4-20250514"
+ GH_AW_INFO_VERSION: ""
+ GH_AW_INFO_AGENT_VERSION: ""
+ GH_AW_INFO_WORKFLOW_NAME: "Smoke OpenCode"
+ GH_AW_INFO_EXPERIMENTAL: "true"
+ GH_AW_INFO_SUPPORTS_TOOLS_ALLOWLIST: "false"
+ GH_AW_INFO_STAGED: "false"
+ GH_AW_INFO_ALLOWED_DOMAINS: '["defaults","github"]'
+ GH_AW_INFO_FIREWALL_ENABLED: "false"
+ GH_AW_INFO_AWF_VERSION: ""
+ GH_AW_INFO_AWMG_VERSION: ""
+ GH_AW_INFO_FIREWALL_TYPE: "squid"
+ GH_AW_COMPILED_STRICT: "true"
+ uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9
+ with:
+ script: |
+ const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io, getOctokit);
+ const { main } = require('${{ runner.temp }}/gh-aw/actions/generate_aw_info.cjs');
+ await main(core, context);
+ - 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@373c709c69115d41ff229c7e5df9f8788daa9553 # v9
+ env:
+ GH_AW_REACTION: "eyes"
+ with:
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+ script: |
+ const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io, getOctokit);
+ const { main } = require('${{ runner.temp }}/gh-aw/actions/add_reaction.cjs');
+ await main();
+ - name: Validate COPILOT_GITHUB_TOKEN secret
+ id: validate-secret
+ run: bash "${RUNNER_TEMP}/gh-aw/actions/validate_multi_secret.sh" COPILOT_GITHUB_TOKEN 'OpenCode CLI' https://github.github.com/gh-aw/reference/engines/#opencode
+ env:
+ COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
+ - name: Checkout .github and .agents folders
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
+ sparse-checkout: |
+ .github
+ .agents
+ actions/setup
+ sparse-checkout-cone-mode: true
+ fetch-depth: 1
+ - name: Check workflow lock file
+ id: check-lock-file
+ uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9
+ env:
+ GH_AW_WORKFLOW_FILE: "smoke-opencode.lock.yml"
+ GH_AW_CONTEXT_WORKFLOW_REF: "${{ github.workflow_ref }}"
+ with:
+ script: |
+ const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io, getOctokit);
+ const { main } = require('${{ runner.temp }}/gh-aw/actions/check_workflow_timestamp_api.cjs');
+ await main();
+ - name: Compute current body text
+ id: sanitized
+ uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9
+ with:
+ script: |
+ const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io, getOctokit);
+ const { main } = require('${{ runner.temp }}/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@373c709c69115d41ff229c7e5df9f8788daa9553 # v9
+ 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('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io, getOctokit);
+ const { main } = require('${{ runner.temp }}/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: ${{ runner.temp }}/gh-aw/safeoutputs/outputs.jsonl
+ 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 }}
+ # poutine:ignore untrusted_checkout_exec
+ run: |
+ bash "${RUNNER_TEMP}/gh-aw/actions/create_prompt_first.sh"
+ {
+ cat << 'GH_AW_PROMPT_fe052794c19d07fe_EOF'
+
+ GH_AW_PROMPT_fe052794c19d07fe_EOF
+ cat "${RUNNER_TEMP}/gh-aw/prompts/xpia.md"
+ cat "${RUNNER_TEMP}/gh-aw/prompts/temp_folder_prompt.md"
+ cat "${RUNNER_TEMP}/gh-aw/prompts/markdown.md"
+ cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_prompt.md"
+ cat << 'GH_AW_PROMPT_fe052794c19d07fe_EOF'
+
+ Tools: add_comment(max:2), create_issue, add_labels, missing_tool, missing_data, noop
+
+
+ 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_fe052794c19d07fe_EOF
+ cat "${RUNNER_TEMP}/gh-aw/prompts/github_mcp_tools_with_safeoutputs_prompt.md"
+ cat << 'GH_AW_PROMPT_fe052794c19d07fe_EOF'
+
+ {{#runtime-import .github/workflows/shared/gh.md}}
+ {{#runtime-import .github/workflows/shared/reporting.md}}
+ {{#runtime-import .github/workflows/smoke-opencode.md}}
+ GH_AW_PROMPT_fe052794c19d07fe_EOF
+ } > "$GH_AW_PROMPT"
+ - name: Interpolate variables and render templates
+ uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9
+ 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('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io, getOctokit);
+ const { main } = require('${{ runner.temp }}/gh-aw/actions/interpolate_prompt.cjs');
+ await main();
+ - name: Substitute placeholders
+ uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9
+ 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('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io, getOctokit);
+
+ const substitutePlaceholders = require('${{ runner.temp }}/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
+ # poutine:ignore untrusted_checkout_exec
+ run: bash "${RUNNER_TEMP}/gh-aw/actions/validate_prompt_placeholders.sh"
+ - name: Print prompt
+ env:
+ GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
+ # poutine:ignore untrusted_checkout_exec
+ run: bash "${RUNNER_TEMP}/gh-aw/actions/print_prompt_summary.sh"
+ - name: Upload activation artifact
+ if: success()
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: activation
+ path: |
+ /tmp/gh-aw/aw_info.json
+ /tmp/gh-aw/aw-prompts/prompt.txt
+ /tmp/gh-aw/github_rate_limits.jsonl
+ if-no-files-found: ignore
+ 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_WORKFLOW_ID_SANITIZED: smokeopencode
+ outputs:
+ checkout_pr_success: ${{ steps.checkout-pr.outputs.checkout_pr_success || 'true' }}
+ effective_tokens: ${{ steps.parse-mcp-gateway.outputs.effective_tokens }}
+ has_patch: ${{ steps.collect_output.outputs.has_patch }}
+ model: ${{ needs.activation.outputs.model }}
+ output: ${{ steps.collect_output.outputs.output }}
+ output_types: ${{ steps.collect_output.outputs.output_types }}
+ setup-trace-id: ${{ steps.setup.outputs.trace-id }}
+ steps:
+ - name: Checkout actions folder
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ repository: github/gh-aw
+ sparse-checkout: |
+ actions
+ persist-credentials: false
+ - name: Setup Scripts
+ id: setup
+ uses: ./actions/setup
+ with:
+ destination: ${{ runner.temp }}/gh-aw/actions
+ job-name: ${{ github.job }}
+ trace-id: ${{ needs.activation.outputs.setup-trace-id }}
+ - name: Set runtime paths
+ id: set-runtime-paths
+ run: |
+ {
+ echo "GH_AW_SAFE_OUTPUTS=${RUNNER_TEMP}/gh-aw/safeoutputs/outputs.jsonl"
+ echo "GH_AW_SAFE_OUTPUTS_CONFIG_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/config.json"
+ echo "GH_AW_SAFE_OUTPUTS_TOOLS_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/tools.json"
+ } >> "$GITHUB_OUTPUT"
+ - name: Checkout repository
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
+ - name: Create gh-aw temp directory
+ run: bash "${RUNNER_TEMP}/gh-aw/actions/create_gh_aw_tmp_dir.sh"
+ - name: Configure gh CLI for GitHub Enterprise
+ run: bash "${RUNNER_TEMP}/gh-aw/actions/configure_gh_for_ghe.sh"
+ env:
+ GH_TOKEN: ${{ github.token }}
+ - name: Configure Git credentials
+ env:
+ REPO_NAME: ${{ github.repository }}
+ SERVER_URL: ${{ github.server_url }}
+ GITHUB_TOKEN: ${{ github.token }}
+ 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 || github.event.issue.pull_request
+ uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9
+ 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('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io, getOctokit);
+ const { main } = require('${{ runner.temp }}/gh-aw/actions/checkout_pr_branch.cjs');
+ await main();
+ - name: Setup Node.js
+ uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
+ with:
+ node-version: '24'
+ package-manager-cache: false
+ - name: Install AWF binary
+ run: bash "${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh" v0.25.18
+ - name: Install OpenCode CLI
+ run: npm install --ignore-scripts -g opencode-ai@1.2.14
+ - name: Determine automatic lockdown mode for GitHub MCP Server
+ id: determine-automatic-lockdown
+ uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9
+ 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('${{ runner.temp }}/gh-aw/actions/determine_automatic_lockdown.cjs');
+ await determineAutomaticLockdown(github, context, core);
+ - name: Download container images
+ run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/gh-aw-firewall/agent:0.25.18@sha256:c77e8c26bab6c39e8568d8e2f8c17015944849a8cbcdfb4bd9725d8893725ca2 ghcr.io/github/gh-aw-firewall/api-proxy:0.25.18@sha256:d16a40a3ca6e989896d0cef9f31b9412bb1fcc8755bafcafb95012ae1078539b ghcr.io/github/gh-aw-firewall/squid:0.25.18@sha256:eb102afcfbae26ffcec016adebb74d3be7b0a5bf376ba306599cdf3effbe288e ghcr.io/github/gh-aw-mcpg:v0.2.17@sha256:a6dec6ec535a11c565d982afa2f98589805ed0598862b9ea9d3c751fc71afae8 ghcr.io/github/github-mcp-server:v0.32.0@sha256:2763823c63bcca718ce53850a1d7fcf2f501ec84028394f1b63ce7e9f4f9be28 node:lts-alpine@sha256:01743339035a5c3c11a373cd7c83aeab6ed1457b55da6a69e014a95ac4e4700b
+ - name: Write Safe Outputs Config
+ run: |
+ mkdir -p "${RUNNER_TEMP}/gh-aw/safeoutputs"
+ mkdir -p /tmp/gh-aw/safeoutputs
+ mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs
+ cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_75bdf39d0826f932_EOF'
+ {"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},"create_report_incomplete_issue":{},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"true"},"report_incomplete":{}}
+ GH_AW_SAFE_OUTPUTS_CONFIG_75bdf39d0826f932_EOF
+ - name: Write Safe Outputs Tools
+ env:
+ GH_AW_TOOLS_META_JSON: |
+ {
+ "description_suffixes": {
+ "add_comment": " CONSTRAINTS: Maximum 2 comment(s) can be added.",
+ "add_labels": " CONSTRAINTS: Only these labels are allowed: [\"smoke-opencode\"].",
+ "create_issue": " CONSTRAINTS: Maximum 1 issue(s) can be created. Labels [\"automation\" \"testing\"] will be automatically added."
+ },
+ "repo_params": {},
+ "dynamic_tools": []
+ }
+ GH_AW_VALIDATION_JSON: |
+ {
+ "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": {
+ "issueNumberOrTemporaryId": 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
+ }
+ }
+ },
+ "report_incomplete": {
+ "defaultMax": 5,
+ "fields": {
+ "details": {
+ "type": "string",
+ "sanitize": true,
+ "maxLength": 65000
+ },
+ "reason": {
+ "required": true,
+ "type": "string",
+ "sanitize": true,
+ "maxLength": 1024
+ }
+ }
+ }
+ }
+ uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9
+ with:
+ script: |
+ const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io, getOctokit);
+ const { main } = require('${{ runner.temp }}/gh-aw/actions/generate_safe_outputs_tools.cjs');
+ await main();
+ - 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: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }}
+ 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: ${{ runner.temp }}/gh-aw/safeoutputs/tools.json
+ GH_AW_SAFE_OUTPUTS_CONFIG_PATH: ${{ runner.temp }}/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
+ 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 "${RUNNER_TEMP}/gh-aw/actions/start_safe_outputs_server.sh"
+
+ - name: Write MCP Scripts Config
+ run: |
+ mkdir -p "${RUNNER_TEMP}/gh-aw/mcp-scripts/logs"
+ cat > "${RUNNER_TEMP}/gh-aw/mcp-scripts/tools.json" << 'GH_AW_MCP_SCRIPTS_TOOLS_f2595bda28945f2f_EOF'
+ {
+ "serverName": "mcpscripts",
+ "version": "1.0.0",
+ "logDir": "${RUNNER_TEMP}/gh-aw/mcp-scripts/logs",
+ "tools": [
+ {
+ "name": "gh",
+ "description": "Execute any gh CLI command. This tool is accessible as 'mcpscripts-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_MCP_SCRIPTS_TOOLS_f2595bda28945f2f_EOF
+ cat > "${RUNNER_TEMP}/gh-aw/mcp-scripts/mcp-server.cjs" << 'GH_AW_MCP_SCRIPTS_SERVER_d8b2dba15c2ea27d_EOF'
+ const path = require("path");
+ const { startHttpServer } = require("./mcp_scripts_mcp_server_http.cjs");
+ const configPath = path.join(__dirname, "tools.json");
+ const port = parseInt(process.env.GH_AW_MCP_SCRIPTS_PORT || "3000", 10);
+ const apiKey = process.env.GH_AW_MCP_SCRIPTS_API_KEY || "";
+ startHttpServer(configPath, {
+ port: port,
+ stateless: true,
+ logDir: "${RUNNER_TEMP}/gh-aw/mcp-scripts/logs"
+ }).catch(error => {
+ console.error("Failed to start mcp-scripts HTTP server:", error);
+ process.exit(1);
+ });
+ GH_AW_MCP_SCRIPTS_SERVER_d8b2dba15c2ea27d_EOF
+ chmod +x "${RUNNER_TEMP}/gh-aw/mcp-scripts/mcp-server.cjs"
+
+ - name: Write MCP Scripts Tool Files
+ run: |
+ cat > "${RUNNER_TEMP}/gh-aw/mcp-scripts/gh.sh" << 'GH_AW_MCP_SCRIPTS_SH_GH_9a43c3187ba32caa_EOF'
+ #!/bin/bash
+ # Auto-generated mcp-script tool: gh
+ # Execute any gh CLI command. This tool is accessible as 'mcpscripts-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_MCP_SCRIPTS_SH_GH_9a43c3187ba32caa_EOF
+ chmod +x "${RUNNER_TEMP}/gh-aw/mcp-scripts/gh.sh"
+
+ - name: Generate MCP Scripts Server Config
+ id: mcp-scripts-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 "mcp_scripts_api_key=${API_KEY}"
+ echo "mcp_scripts_port=${PORT}"
+ } >> "$GITHUB_OUTPUT"
+
+ echo "MCP Scripts server will run on port ${PORT}"
+
+ - name: Start MCP Scripts HTTP Server
+ id: mcp-scripts-start
+ env:
+ DEBUG: '*'
+ GH_AW_MCP_SCRIPTS_PORT: ${{ steps.mcp-scripts-config.outputs.mcp_scripts_port }}
+ GH_AW_MCP_SCRIPTS_API_KEY: ${{ steps.mcp-scripts-config.outputs.mcp_scripts_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_MCP_SCRIPTS_PORT
+ export GH_AW_MCP_SCRIPTS_API_KEY
+
+ bash "${RUNNER_TEMP}/gh-aw/actions/start_mcp_scripts_server.sh"
+
+ - name: Start MCP Gateway
+ id: start-mcp-gateway
+ env:
+ GH_AW_GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ GH_AW_MCP_SCRIPTS_API_KEY: ${{ steps.mcp-scripts-start.outputs.api_key }}
+ GH_AW_MCP_SCRIPTS_PORT: ${{ steps.mcp-scripts-start.outputs.port }}
+ GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.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_GUARD_MIN_INTEGRITY: ${{ steps.determine-automatic-lockdown.outputs.min_integrity }}
+ GITHUB_MCP_GUARD_REPOS: ${{ steps.determine-automatic-lockdown.outputs.repos }}
+ 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 MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD="524288"
+ 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 MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -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_GUARD_MIN_INTEGRITY -e GITHUB_MCP_GUARD_REPOS -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_MCP_SCRIPTS_PORT -e GH_AW_MCP_SCRIPTS_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.2.17'
+
+ cat << GH_AW_MCP_CONFIG_a7fef14f0f7e1f01_EOF | bash "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.sh"
+ {
+ "mcpServers": {
+ "github": {
+ "container": "ghcr.io/github/github-mcp-server:v0.32.0",
+ "env": {
+ "GITHUB_HOST": "$GITHUB_SERVER_URL",
+ "GITHUB_PERSONAL_ACCESS_TOKEN": "$GITHUB_MCP_SERVER_TOKEN",
+ "GITHUB_READ_ONLY": "1",
+ "GITHUB_TOOLSETS": "context,repos,issues,pull_requests"
+ },
+ "guard-policies": {
+ "allow-only": {
+ "min-integrity": "$GITHUB_MCP_GUARD_MIN_INTEGRITY",
+ "repos": "$GITHUB_MCP_GUARD_REPOS"
+ }
+ }
+ },
+ "mcpscripts": {
+ "type": "http",
+ "url": "http://host.docker.internal:$GH_AW_MCP_SCRIPTS_PORT",
+ "headers": {
+ "Authorization": "$GH_AW_MCP_SCRIPTS_API_KEY"
+ },
+ "guard-policies": {
+ "write-sink": {
+ "accept": [
+ "*"
+ ]
+ }
+ }
+ },
+ "safeoutputs": {
+ "type": "http",
+ "url": "http://host.docker.internal:$GH_AW_SAFE_OUTPUTS_PORT",
+ "headers": {
+ "Authorization": "$GH_AW_SAFE_OUTPUTS_API_KEY"
+ },
+ "guard-policies": {
+ "write-sink": {
+ "accept": [
+ "*"
+ ]
+ }
+ }
+ }
+ },
+ "gateway": {
+ "port": $MCP_GATEWAY_PORT,
+ "domain": "${MCP_GATEWAY_DOMAIN}",
+ "apiKey": "${MCP_GATEWAY_API_KEY}",
+ "payloadDir": "${MCP_GATEWAY_PAYLOAD_DIR}"
+ }
+ }
+ GH_AW_MCP_CONFIG_a7fef14f0f7e1f01_EOF
+ - name: Download activation artifact
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ with:
+ name: activation
+ path: /tmp/gh-aw
+ - name: Clean git credentials
+ continue-on-error: true
+ run: bash "${RUNNER_TEMP}/gh-aw/actions/clean_git_credentials.sh"
+ - name: Write OpenCode configuration
+ run: |
+ umask 077
+ 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
+ chmod 600 "$CONFIG"
+ - name: Execute OpenCode CLI
+ id: agentic_execution
+ run: |
+ set -o pipefail
+ (umask 177 && touch /tmp/gh-aw/agent-stdio.log)
+ # shellcheck disable=SC1003
+ sudo -E awf --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" --env-all --allow-domains '*.githubusercontent.com,api.anthropic.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,docs.github.com,github-cloud.githubusercontent.com,github-cloud.s3.amazonaws.com,github.blog,github.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,opencode.ai,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,www.googleapis.com' --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --audit-dir /tmp/gh-aw/sandbox/firewall/audit --enable-host-access --image-tag 0.25.18 --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 --print-logs --log-level DEBUG "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log
+ env:
+ GH_AW_MCP_CONFIG: ${{ github.workspace }}/opencode.jsonc
+ GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
+ GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }}
+ GITHUB_WORKSPACE: ${{ github.workspace }}
+ NO_PROXY: localhost,127.0.0.1
+ OPENAI_API_KEY: ${{ secrets.COPILOT_GITHUB_TOKEN }}
+ OPENAI_BASE_URL: http://host.docker.internal:10004
+ OPENCODE_MODEL: anthropic/claude-sonnet-4-20250514
+ - name: Configure Git credentials
+ env:
+ REPO_NAME: ${{ github.repository }}
+ SERVER_URL: ${{ github.server_url }}
+ GITHUB_TOKEN: ${{ github.token }}
+ 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 "${RUNNER_TEMP}/gh-aw/actions/stop_mcp_gateway.sh" "$GATEWAY_PID"
+ - name: Redact secrets in logs
+ if: always()
+ uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9
+ with:
+ script: |
+ const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io, getOctokit);
+ const { main } = require('${{ runner.temp }}/gh-aw/actions/redact_secrets.cjs');
+ await main();
+ env:
+ GH_AW_SECRET_NAMES: 'COPILOT_GITHUB_TOKEN,GH_AW_GITHUB_MCP_SERVER_TOKEN,GH_AW_GITHUB_TOKEN,GITHUB_TOKEN'
+ SECRET_COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
+ 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: Append agent step summary
+ if: always()
+ run: bash "${RUNNER_TEMP}/gh-aw/actions/append_agent_step_summary.sh"
+ - name: Copy Safe Outputs
+ if: always()
+ env:
+ GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }}
+ run: |
+ mkdir -p /tmp/gh-aw
+ cp "$GH_AW_SAFE_OUTPUTS" /tmp/gh-aw/safeoutputs.jsonl 2>/dev/null || true
+ - name: Ingest agent output
+ id: collect_output
+ if: always()
+ uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9
+ env:
+ GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }}
+ GH_AW_ALLOWED_DOMAINS: "*.githubusercontent.com,api.anthropic.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,docs.github.com,github-cloud.githubusercontent.com,github-cloud.s3.amazonaws.com,github.blog,github.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,opencode.ai,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,www.googleapis.com"
+ GITHUB_SERVER_URL: ${{ github.server_url }}
+ GITHUB_API_URL: ${{ github.api_url }}
+ with:
+ script: |
+ const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io, getOctokit);
+ const { main } = require('${{ runner.temp }}/gh-aw/actions/collect_ndjson_output.cjs');
+ await main();
+ - name: Parse MCP Scripts logs for step summary
+ if: always()
+ uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9
+ with:
+ script: |
+ const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io, getOctokit);
+ const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_mcp_scripts_logs.cjs');
+ await main();
+ - name: Parse MCP Gateway logs for step summary
+ if: always()
+ id: parse-mcp-gateway
+ uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9
+ with:
+ script: |
+ const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io, getOctokit);
+ const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_mcp_gateway_log.cjs');
+ await main();
+ - name: Print firewall logs
+ if: always()
+ continue-on-error: true
+ env:
+ AWF_LOGS_DIR: /tmp/gh-aw/sandbox/firewall/logs
+ run: |
+ # Fix permissions on firewall logs so they can be uploaded as artifacts
+ # AWF runs with sudo, creating files owned by root
+ sudo chmod -R a+r /tmp/gh-aw/sandbox/firewall/logs 2>/dev/null || true
+ # Only run awf logs summary if awf command exists (it may not be installed if workflow failed before install step)
+ if command -v awf &> /dev/null; then
+ awf logs summary | tee -a "$GITHUB_STEP_SUMMARY"
+ else
+ echo 'AWF binary not installed, skipping firewall log summary'
+ fi
+ - name: Parse token usage for step summary
+ if: always()
+ continue-on-error: true
+ uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9
+ with:
+ script: |
+ const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io, getOctokit);
+ const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_token_usage.cjs');
+ await main();
+ - name: Write agent output placeholder if missing
+ if: always()
+ run: |
+ if [ ! -f /tmp/gh-aw/agent_output.json ]; then
+ echo '{"items":[]}' > /tmp/gh-aw/agent_output.json
+ fi
+ - name: Upload agent artifacts
+ if: always()
+ continue-on-error: true
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: agent
+ path: |
+ /tmp/gh-aw/aw-prompts/prompt.txt
+ /tmp/gh-aw/mcp-logs/
+ /tmp/gh-aw/mcp-scripts/logs/
+ /tmp/gh-aw/agent_usage.json
+ /tmp/gh-aw/agent-stdio.log
+ /tmp/gh-aw/agent/
+ /tmp/gh-aw/github_rate_limits.jsonl
+ /tmp/gh-aw/safeoutputs.jsonl
+ /tmp/gh-aw/agent_output.json
+ /tmp/gh-aw/aw-*.patch
+ /tmp/gh-aw/aw-*.bundle
+ /tmp/gh-aw/sandbox/firewall/logs/
+ /tmp/gh-aw/sandbox/firewall/audit/
+ if-no-files-found: ignore
+
+ conclusion:
+ needs:
+ - activation
+ - agent
+ - detection
+ - safe_outputs
+ if: >
+ always() && (needs.agent.result != 'skipped' || needs.activation.outputs.lockdown_check_failed == 'true' ||
+ needs.activation.outputs.stale_lock_file_failed == 'true')
+ runs-on: ubuntu-slim
+ permissions:
+ contents: read
+ discussions: write
+ issues: write
+ pull-requests: write
+ concurrency:
+ group: "gh-aw-conclusion-smoke-opencode"
+ cancel-in-progress: false
+ outputs:
+ incomplete_count: ${{ steps.report_incomplete.outputs.incomplete_count }}
+ 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:
+ repository: github/gh-aw
+ sparse-checkout: |
+ actions
+ persist-credentials: false
+ - name: Setup Scripts
+ id: setup
+ uses: ./actions/setup
+ with:
+ destination: ${{ runner.temp }}/gh-aw/actions
+ job-name: ${{ github.job }}
+ trace-id: ${{ needs.activation.outputs.setup-trace-id }}
+ - name: Download agent output artifact
+ id: download-agent-output
+ continue-on-error: true
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ with:
+ name: agent
+ path: /tmp/gh-aw/
+ - name: Setup agent output environment variable
+ id: setup-agent-output-env
+ if: steps.download-agent-output.outcome == 'success'
+ run: |
+ mkdir -p /tmp/gh-aw/
+ find "/tmp/gh-aw/" -type f -print
+ echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT"
+ - name: Process no-op messages
+ id: noop
+ uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9
+ env:
+ GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }}
+ GH_AW_NOOP_MAX: "1"
+ 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_REPORT_AS_ISSUE: "true"
+ with:
+ github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
+ script: |
+ const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io, getOctokit);
+ const { main } = require('${{ runner.temp }}/gh-aw/actions/handle_noop_message.cjs');
+ await main();
+ - name: Log detection run
+ id: detection_runs
+ uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9
+ env:
+ GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.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_DETECTION_CONCLUSION: ${{ needs.detection.outputs.detection_conclusion }}
+ GH_AW_DETECTION_REASON: ${{ needs.detection.outputs.detection_reason }}
+ with:
+ github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
+ script: |
+ const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io, getOctokit);
+ const { main } = require('${{ runner.temp }}/gh-aw/actions/handle_detection_runs.cjs');
+ await main();
+ - name: Record missing tool
+ id: missing_tool
+ uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9
+ env:
+ GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }}
+ GH_AW_MISSING_TOOL_CREATE_ISSUE: "true"
+ GH_AW_WORKFLOW_NAME: "Smoke OpenCode"
+ with:
+ github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
+ script: |
+ const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io, getOctokit);
+ const { main } = require('${{ runner.temp }}/gh-aw/actions/missing_tool.cjs');
+ await main();
+ - name: Record incomplete
+ id: report_incomplete
+ uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9
+ env:
+ GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }}
+ GH_AW_REPORT_INCOMPLETE_CREATE_ISSUE: "true"
+ GH_AW_WORKFLOW_NAME: "Smoke OpenCode"
+ with:
+ github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
+ script: |
+ const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io, getOctokit);
+ const { main } = require('${{ runner.temp }}/gh-aw/actions/report_incomplete_handler.cjs');
+ await main();
+ - name: Handle agent failure
+ id: handle_agent_failure
+ if: always()
+ uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9
+ env:
+ GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.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_ENGINE_ID: "opencode"
+ GH_AW_SECRET_VERIFICATION_RESULT: ${{ needs.activation.outputs.secret_verification_result }}
+ GH_AW_CHECKOUT_PR_SUCCESS: ${{ needs.agent.outputs.checkout_pr_success }}
+ GH_AW_LOCKDOWN_CHECK_FAILED: ${{ needs.activation.outputs.lockdown_check_failed }}
+ GH_AW_STALE_LOCK_FILE_FAILED: ${{ needs.activation.outputs.stale_lock_file_failed }}
+ 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"
+ GH_AW_FAILURE_REPORT_AS_ISSUE: "true"
+ GH_AW_TIMEOUT_MINUTES: "15"
+ with:
+ github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
+ script: |
+ const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io, getOctokit);
+ const { main } = require('${{ runner.temp }}/gh-aw/actions/handle_agent_failure.cjs');
+ await main();
+ - name: Update reaction comment with completion status
+ id: conclusion
+ uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9
+ env:
+ GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.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.detection.outputs.detection_conclusion }}
+ GH_AW_DETECTION_REASON: ${{ needs.detection.outputs.detection_reason }}
+ 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('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io, getOctokit);
+ const { main } = require('${{ runner.temp }}/gh-aw/actions/notify_comment_error.cjs');
+ await main();
+
+ detection:
+ needs:
+ - activation
+ - agent
+ if: >
+ always() && needs.agent.result != 'skipped' && (needs.agent.outputs.output_types != '' || needs.agent.outputs.has_patch == 'true')
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ outputs:
+ detection_conclusion: ${{ steps.detection_conclusion.outputs.conclusion }}
+ detection_reason: ${{ steps.detection_conclusion.outputs.reason }}
+ detection_success: ${{ steps.detection_conclusion.outputs.success }}
+ steps:
+ - name: Checkout actions folder
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ repository: github/gh-aw
+ sparse-checkout: |
+ actions
+ persist-credentials: false
+ - name: Setup Scripts
+ id: setup
+ uses: ./actions/setup
+ with:
+ destination: ${{ runner.temp }}/gh-aw/actions
+ job-name: ${{ github.job }}
+ trace-id: ${{ needs.activation.outputs.setup-trace-id }}
+ - name: Download agent output artifact
+ id: download-agent-output
+ continue-on-error: true
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ with:
+ name: agent
+ path: /tmp/gh-aw/
+ - name: Setup agent output environment variable
+ id: setup-agent-output-env
+ if: steps.download-agent-output.outcome == 'success'
+ run: |
+ mkdir -p /tmp/gh-aw/
+ find "/tmp/gh-aw/" -type f -print
+ echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT"
+ - name: Checkout repository for patch context
+ if: needs.agent.outputs.has_patch == 'true'
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
+ # --- Threat Detection ---
+ - name: Clean stale firewall files from agent artifact
+ run: |
+ rm -rf /tmp/gh-aw/sandbox/firewall/logs
+ rm -rf /tmp/gh-aw/sandbox/firewall/audit
+ - name: Download container images
+ run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/gh-aw-firewall/agent:0.25.18@sha256:c77e8c26bab6c39e8568d8e2f8c17015944849a8cbcdfb4bd9725d8893725ca2 ghcr.io/github/gh-aw-firewall/api-proxy:0.25.18@sha256:d16a40a3ca6e989896d0cef9f31b9412bb1fcc8755bafcafb95012ae1078539b ghcr.io/github/gh-aw-firewall/squid:0.25.18@sha256:eb102afcfbae26ffcec016adebb74d3be7b0a5bf376ba306599cdf3effbe288e
+ - name: Check if detection needed
+ id: detection_guard
+ if: always()
+ env:
+ OUTPUT_TYPES: ${{ needs.agent.outputs.output_types }}
+ HAS_PATCH: ${{ needs.agent.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
+ for f in /tmp/gh-aw/aw-*.bundle; 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@373c709c69115d41ff229c7e5df9f8788daa9553 # v9
+ env:
+ WORKFLOW_NAME: "Smoke OpenCode"
+ WORKFLOW_DESCRIPTION: "Smoke test workflow that validates OpenCode engine functionality twice daily"
+ HAS_PATCH: ${{ needs.agent.outputs.has_patch }}
+ with:
+ script: |
+ const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io, getOctokit);
+ const { main } = require('${{ runner.temp }}/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: Setup Node.js
+ uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
+ with:
+ node-version: '24'
+ package-manager-cache: false
+ - name: Install AWF binary
+ run: bash "${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh" v0.25.18
+ - name: Install OpenCode CLI
+ run: npm install --ignore-scripts -g opencode-ai@1.2.14
+ - name: Write OpenCode configuration
+ if: always() && steps.detection_guard.outputs.run_detection == 'true'
+ run: |
+ umask 077
+ 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
+ chmod 600 "$CONFIG"
+ - name: Execute OpenCode CLI
+ if: always() && steps.detection_guard.outputs.run_detection == 'true'
+ id: detection_agentic_execution
+ run: |
+ set -o pipefail
+ (umask 177 && touch /tmp/gh-aw/threat-detection/detection.log)
+ # shellcheck disable=SC1003
+ sudo -E awf --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" --env-all --allow-domains api.anthropic.com,host.docker.internal,opencode.ai,registry.npmjs.org --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --audit-dir /tmp/gh-aw/sandbox/firewall/audit --enable-host-access --image-tag 0.25.18 --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 --print-logs --log-level DEBUG "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"' 2>&1 | tee -a /tmp/gh-aw/threat-detection/detection.log
+ env:
+ GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
+ GITHUB_WORKSPACE: ${{ github.workspace }}
+ NO_PROXY: localhost,127.0.0.1
+ OPENAI_API_KEY: ${{ secrets.COPILOT_GITHUB_TOKEN }}
+ OPENAI_BASE_URL: http://host.docker.internal:10004
+ OPENCODE_MODEL: anthropic/claude-sonnet-4-20250514
+ - name: Upload threat detection log
+ if: always() && steps.detection_guard.outputs.run_detection == 'true'
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: detection
+ path: /tmp/gh-aw/threat-detection/detection.log
+ if-no-files-found: ignore
+ - name: Parse and conclude threat detection
+ id: detection_conclusion
+ if: always()
+ uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9
+ env:
+ RUN_DETECTION: ${{ steps.detection_guard.outputs.run_detection }}
+ GH_AW_DETECTION_CONTINUE_ON_ERROR: "true"
+ with:
+ script: |
+ const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io, getOctokit);
+ const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_threat_detection_results.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
+ outputs:
+ activated: ${{ steps.check_membership.outputs.is_team_member == 'true' }}
+ matched_command: ''
+ setup-trace-id: ${{ steps.setup.outputs.trace-id }}
+ steps:
+ - name: Checkout actions folder
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ repository: github/gh-aw
+ sparse-checkout: |
+ actions
+ persist-credentials: false
+ - name: Setup Scripts
+ id: setup
+ uses: ./actions/setup
+ with:
+ destination: ${{ runner.temp }}/gh-aw/actions
+ job-name: ${{ github.job }}
+ - name: Check team membership for workflow
+ id: check_membership
+ uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9
+ env:
+ GH_AW_REQUIRED_ROLES: "admin,maintainer,write"
+ with:
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+ script: |
+ const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io, getOctokit);
+ const { main } = require('${{ runner.temp }}/gh-aw/actions/check_membership.cjs');
+ await main();
+
+ safe_outputs:
+ needs:
+ - activation
+ - agent
+ - detection
+ if: (!cancelled()) && needs.agent.result != 'skipped' && needs.detection.result == 'success'
+ runs-on: ubuntu-slim
+ permissions:
+ contents: read
+ discussions: write
+ issues: write
+ pull-requests: write
+ timeout-minutes: 15
+ env:
+ GH_AW_CALLER_WORKFLOW_ID: "${{ github.repository }}/smoke-opencode"
+ GH_AW_DETECTION_CONCLUSION: ${{ needs.detection.outputs.detection_conclusion }}
+ GH_AW_DETECTION_REASON: ${{ needs.detection.outputs.detection_reason }}
+ GH_AW_EFFECTIVE_TOKENS: ${{ needs.agent.outputs.effective_tokens }}
+ 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 }}
+ comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }}
+ comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }}
+ create_discussion_error_count: ${{ steps.process_safe_outputs.outputs.create_discussion_error_count }}
+ create_discussion_errors: ${{ steps.process_safe_outputs.outputs.create_discussion_errors }}
+ created_issue_number: ${{ steps.process_safe_outputs.outputs.created_issue_number }}
+ created_issue_url: ${{ steps.process_safe_outputs.outputs.created_issue_url }}
+ 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:
+ repository: github/gh-aw
+ sparse-checkout: |
+ actions
+ persist-credentials: false
+ - name: Setup Scripts
+ id: setup
+ uses: ./actions/setup
+ with:
+ destination: ${{ runner.temp }}/gh-aw/actions
+ job-name: ${{ github.job }}
+ trace-id: ${{ needs.activation.outputs.setup-trace-id }}
+ - name: Download agent output artifact
+ id: download-agent-output
+ continue-on-error: true
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ with:
+ name: agent
+ path: /tmp/gh-aw/
+ - name: Setup agent output environment variable
+ id: setup-agent-output-env
+ if: steps.download-agent-output.outcome == 'success'
+ run: |
+ mkdir -p /tmp/gh-aw/
+ find "/tmp/gh-aw/" -type f -print
+ echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT"
+ - name: Configure GH_HOST for enterprise compatibility
+ id: ghes-host-config
+ shell: bash
+ run: |
+ # Derive GH_HOST from GITHUB_SERVER_URL so the gh CLI targets the correct
+ # GitHub instance (GHES/GHEC). On github.com this is a harmless no-op.
+ GH_HOST="${GITHUB_SERVER_URL#https://}"
+ GH_HOST="${GH_HOST#http://}"
+ echo "GH_HOST=${GH_HOST}" >> "$GITHUB_ENV"
+ - name: Process Safe Outputs
+ id: process_safe_outputs
+ uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9
+ env:
+ GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }}
+ GH_AW_ALLOWED_DOMAINS: "*.githubusercontent.com,api.anthropic.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,docs.github.com,github-cloud.githubusercontent.com,github-cloud.s3.amazonaws.com,github.blog,github.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,opencode.ai,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,www.googleapis.com"
+ GITHUB_SERVER_URL: ${{ github.server_url }}
+ GITHUB_API_URL: ${{ github.api_url }}
+ 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},\"create_report_incomplete_issue\":{},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"true\"},\"report_incomplete\":{}}"
+ with:
+ github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
+ script: |
+ const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io, getOctokit);
+ const { main } = require('${{ runner.temp }}/gh-aw/actions/safe_output_handler_manager.cjs');
+ await main();
+ - name: Upload Safe Outputs Items
+ if: always()
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: safe-outputs-items
+ path: |
+ /tmp/gh-aw/safe-output-items.jsonl
+ /tmp/gh-aw/temporary-id-map.json
+ if-no-files-found: ignore
+
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 100644
index 00000000000..eaf3368d01a
--- /dev/null
+++ b/actions/setup/sh/convert_gateway_config_opencode.sh
@@ -0,0 +1,121 @@
+#!/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 -euo pipefail
+
+# Restrict permissions so credential-bearing files are not world-readable.
+# umask 077 ensures new files are created with mode 0600 (owner-only read/write)
+# even before a subsequent chmod, which would leave credential-bearing files
+# world-readable (mode 0644) with a typical umask of 022.
+umask 077
+
+# 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"
+chmod 600 "$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 979fb86aa4d..2829c05080e 100755
--- a/actions/setup/sh/start_mcp_gateway.sh
+++ b/actions/setup/sh/start_mcp_gateway.sh
@@ -400,6 +400,10 @@ case "$ENGINE_TYPE" in
echo "Using Gemini converter..."
bash ${RUNNER_TEMP}/gh-aw/actions/convert_gateway_config_gemini.sh
;;
+ opencode)
+ echo "Using OpenCode converter..."
+ bash ${RUNNER_TEMP}/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/adr/25830-opencode-engine-integration.md b/docs/adr/25830-opencode-engine-integration.md
new file mode 100644
index 00000000000..b5bd66cd6b0
--- /dev/null
+++ b/docs/adr/25830-opencode-engine-integration.md
@@ -0,0 +1,98 @@
+# ADR-25830: Add OpenCode as a Provider-Agnostic BYOK Agentic Engine
+
+**Date**: 2026-04-11
+**Status**: Draft
+**Deciders**: pelikhan, Copilot
+
+---
+
+## Part 1 — Narrative (Human-Friendly)
+
+### Context
+
+gh-aw supports several first-party agentic engines (Copilot, Claude, Codex, Gemini) that each bind to a single AI provider and require a corresponding vendor API key. Users who want to run models from multiple providers — or who prefer open-source tooling — have no path today without writing a fully custom engine. OpenCode is a provider-agnostic, open-source AI coding agent (BYOK — Bring Your Own Key) that supports 75+ models via a unified CLI interface using a `provider/model` format (e.g., `anthropic/claude-sonnet-4-20250514`). Because each provider's API endpoint is different, adding OpenCode also introduces a new challenge: the network firewall allowlist cannot be a static list and must be computed dynamically from the selected model provider at compile time.
+
+### Decision
+
+We will integrate OpenCode as a fifth built-in agentic engine (`id: "opencode"`) following the existing `BaseEngine` pattern used by Claude, Codex, and Gemini. The engine is installed from npm (`opencode-ai@1.2.14`), runs in headless mode via `opencode run`, and communicates with the LLM gateway proxy on a dedicated port (10004). Provider-specific API domains for the firewall allowlist are resolved at compile time by parsing the `provider/model` string prefix; the default provider is Anthropic. All tool permissions inside the OpenCode sandbox are pre-set to `allow` via an `opencode.jsonc` config file written before execution, which prevents the CI runner from hanging on interactive permission prompts.
+
+### Alternatives Considered
+
+#### Alternative 1: Custom engine wrapper via `engine.command`
+
+Users can already specify `engine.command: opencode run` as a custom command override in the workflow frontmatter, which lets them invoke OpenCode without any first-class engine support. This avoids adding engine code but forces every user to manually specify the install steps, configure the `opencode.jsonc` permissions file, and manage firewall domains themselves. For a community-maintained open-source tool with growing adoption, first-class support provides substantially better UX with correct defaults out of the box.
+
+#### Alternative 2: Extend an existing engine (e.g., Claude) with multi-provider model routing
+
+Rather than adding a new engine, the Claude engine could be extended to accept `openai/` or `google/` model prefixes and route them to alternative providers through the LLM gateway. This avoids maintaining a separate engine abstraction but conflates two distinct CLIs (Claude Code CLI vs. OpenCode CLI) under the same engine ID, creating confusion for end users and making the firewall and installation logic more complex. OpenCode has its own installation artifact, config format (`opencode.jsonc`), and binary — they are genuinely different engines, not model variants.
+
+#### Alternative 3: Static multi-provider domain allowlist
+
+Instead of parsing the model string to derive the firewall domain at compile time, include all known provider API endpoints in `OpenCodeDefaultDomains` statically. This is simpler but violates the principle of least privilege: a workflow using only the Anthropic provider would unnecessarily have `api.openai.com` and `generativelanguage.googleapis.com` in its allowlist. The current implementation includes only the three most common providers in the static default (`OpenCodeDefaultDomains`) as a broad fallback, while `GetOpenCodeDefaultDomains(model)` provides a narrower per-provider list when a model is explicitly configured.
+
+### Consequences
+
+#### Positive
+- Users can run any of 75+ models from multiple providers (Anthropic, OpenAI, Google, Groq, Mistral, DeepSeek, xAI) through a single engine selector.
+- The BYOK model removes dependency on GitHub Copilot entitlements; any user with a direct provider API key can run agentic workflows.
+- Dynamic per-provider domain resolution keeps firewall allowlists as narrow as possible given the selected model.
+- The existing `BaseEngine` and engine registry patterns are reused without modification, keeping the diff small and coherent.
+
+#### Negative
+- The engine is marked `experimental: true` until smoke tests pass consistently; production readiness is deferred.
+- OpenCode does not yet support `--max-turns` or gh-aw's neutral web-search tool abstraction (`supportsMaxTurns: false`, `supportsWebSearch: false`), limiting parity with other engines.
+- The `openCodeProviderDomains` map in `domains.go` must be manually kept in sync as OpenCode adds or removes supported providers; there is no automated drift detection.
+- Pre-setting all permissions to `allow` in `opencode.jsonc` disables OpenCode's interactive safety guardrails in CI. This is intentional (CI can't answer prompts) but means the agent runs with elevated tool permissions inside the sandbox.
+
+#### Neutral
+- A separate LLM gateway port (10004) is allocated for OpenCode, distinct from other engines. This adds one more well-known port constant to `pkg/constants/version_constants.go`.
+- The MCP config integration follows the same `renderStandardJSONMCPConfig` path as other JSON-based engines; no new MCP config format is introduced.
+- 22 unit tests cover the new engine (identity, capabilities, secrets, installation, execution, firewall, and provider extraction). These are co-located with other engine tests in `pkg/workflow/`.
+
+---
+
+## Part 2 — Normative Specification (RFC 2119)
+
+> The key words **MUST**, **MUST NOT**, **REQUIRED**, **SHALL**, **SHALL NOT**, **SHOULD**, **SHOULD NOT**, **RECOMMENDED**, **MAY**, and **OPTIONAL** in this section are to be interpreted as described in [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119).
+
+### Engine Registration
+
+1. The OpenCode engine **MUST** be registered in `NewEngineRegistry()` under the identifier `"opencode"`.
+2. The OpenCode engine **MUST** implement the `AgenticEngine` interface via `BaseEngine` embedding, consistent with all other built-in engines.
+3. The OpenCode engine **MUST** be included in `AgenticEngines` and `EngineOptions` so that tooling that enumerates built-in engines discovers it automatically.
+
+### Installation
+
+1. The engine **MUST** install the OpenCode CLI from npm using the pinned package version defined by `DefaultOpenCodeVersion` in `pkg/constants/version_constants.go`.
+2. The engine **MUST** skip installation steps when `engine.command` is explicitly overridden in the workflow configuration.
+3. The engine **SHOULD** use `BuildStandardNpmEngineInstallSteps` to generate installation steps so that any future changes to the standard npm install pattern apply automatically.
+
+### Execution
+
+1. The engine **MUST** write an `opencode.jsonc` configuration file to `$GITHUB_WORKSPACE` before executing the agent, with all tool permissions (`bash`, `edit`, `read`, `glob`, `grep`, `write`, `webfetch`, `websearch`) set to `"allow"`.
+2. The engine **MUST** merge the permissions config with any existing `opencode.jsonc` found in the workspace (using `jq` deep merge), rather than unconditionally overwriting it.
+3. The engine **MUST** invoke OpenCode via `opencode run ` in headless mode, passing `--print-logs` and `--log-level DEBUG` for CI observability.
+4. The engine **MUST** route LLM API calls through the local gateway proxy at port `OpenCodeLLMGatewayPort` (10004) by setting `ANTHROPIC_BASE_URL` when the firewall is enabled.
+5. The engine **MUST NOT** pass `--max-turns` to the OpenCode CLI, as that flag is not supported.
+
+### Firewall Domain Allowlisting
+
+1. When a model is explicitly configured in `engine.model`, the compiler **MUST** call `GetOpenCodeDefaultDomains(model)` to resolve provider-specific API domains from the `provider/model` prefix.
+2. The `extractProviderFromModel` function **MUST** parse the model string by splitting on the first `/` character and returning the left-hand token, lowercased.
+3. When no `/` separator is found in the model string, `extractProviderFromModel` **MUST** return `"anthropic"` as the default provider.
+4. The `openCodeProviderDomains` map **MUST** be the single source of truth for mapping provider names to their API hostnames; callers **MUST NOT** hardcode provider domain strings outside this map.
+5. The `engineDefaultDomains` map in `domains.go` **MUST** include an entry for `constants.OpenCodeEngine` to ensure `GetAllowedDomainsForEngine` works correctly for the OpenCode engine.
+
+### Secret Collection
+
+1. The engine **MUST** include `ANTHROPIC_API_KEY` in the required secret list as the default provider secret.
+2. The engine **MUST** include additional secrets from `engine.env` whose key names end in `_API_KEY` or `_KEY`, to support non-default provider configurations.
+3. The engine **MUST** collect common MCP secrets via `collectCommonMCPSecrets` and HTTP MCP header secrets via `collectHTTPMCPHeaderSecrets`, consistent with other engines.
+
+### Conformance
+
+An implementation is considered conformant with this ADR if it satisfies all **MUST** and **MUST NOT** requirements above. Specifically: the OpenCode engine **MUST** be registered, install via npm at a pinned version, write a complete permissions config before execution, invoke `opencode run` in headless mode, and resolve firewall domains dynamically from the model provider prefix. Failure to meet any **MUST** or **MUST NOT** requirement constitutes non-conformance.
+
+---
+
+*ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.*
diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go
index 1b1b034bbb0..a441f86009a 100644
--- a/pkg/constants/constants.go
+++ b/pkg/constants/constants.go
@@ -100,6 +100,9 @@ const (
// GeminiLLMGatewayPort is the port for the Gemini LLM gateway
GeminiLLMGatewayPort = 10003
+
+ // OpenCodeLLMGatewayPort is the port for the OpenCode LLM gateway
+ OpenCodeLLMGatewayPort = 10004
)
// DefaultGitHubLockdown is the default value for the GitHub MCP server lockdown setting.
diff --git a/pkg/constants/constants_test.go b/pkg/constants/constants_test.go
index 2116f627a2d..4dcfa523923 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", "gemini"}
+ expectedEngines := []string{"claude", "codex", "copilot", "gemini", "opencode"}
if len(AgenticEngines) != len(expectedEngines) {
t.Errorf("AgenticEngines length = %d, want %d", len(AgenticEngines), len(expectedEngines))
}
diff --git a/pkg/constants/engine_constants.go b/pkg/constants/engine_constants.go
index 55c92e764fe..d54bf7335e6 100644
--- a/pkg/constants/engine_constants.go
+++ b/pkg/constants/engine_constants.go
@@ -20,6 +20,8 @@ const (
CodexEngine EngineName = "codex"
// GeminiEngine is the Google Gemini engine identifier
GeminiEngine EngineName = "gemini"
+ // OpenCodeEngine is the OpenCode engine identifier
+ OpenCodeEngine EngineName = "opencode"
// DefaultEngine is the default agentic engine used when no engine is explicitly specified.
// Currently defaults to CopilotEngine.
@@ -30,7 +32,7 @@ const (
// Deprecated: Use workflow.NewEngineCatalog(workflow.NewEngineRegistry()).IDs() for a
// catalog-derived list. This slice is maintained for backward compatibility and must
// stay in sync with the built-in engines registered in NewEngineCatalog.
-var AgenticEngines = []string{string(ClaudeEngine), string(CodexEngine), string(CopilotEngine), string(GeminiEngine)}
+var AgenticEngines = []string{string(ClaudeEngine), string(CodexEngine), string(CopilotEngine), string(GeminiEngine), string(OpenCodeEngine)}
// EngineOption represents a selectable AI engine with its display metadata and secret configuration
type EngineOption struct {
@@ -83,6 +85,15 @@ var EngineOptions = []EngineOption{
KeyURL: "https://aistudio.google.com/app/apikey",
WhenNeeded: "Gemini engine workflows",
},
+ {
+ Value: string(OpenCodeEngine),
+ Label: "OpenCode",
+ Description: "OpenCode multi-provider AI coding agent (BYOK)",
+ SecretName: "COPILOT_GITHUB_TOKEN",
+ AlternativeSecrets: []string{"ANTHROPIC_API_KEY", "GOOGLE_API_KEY"},
+ KeyURL: "https://opencode.ai/docs/get-started/",
+ WhenNeeded: "OpenCode engine workflows (default: Copilot routing)",
+ },
}
// SystemSecretSpec describes a system-level secret that is not engine-specific
@@ -177,6 +188,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.
@@ -198,6 +213,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
diff --git a/pkg/constants/version_constants.go b/pkg/constants/version_constants.go
index 2d4aefe25d2..ef683183b61 100644
--- a/pkg/constants/version_constants.go
+++ b/pkg/constants/version_constants.go
@@ -47,6 +47,9 @@ const DefaultCodexVersion Version = "0.118.0"
// DefaultGeminiVersion is the default version of the Google Gemini CLI
const DefaultGeminiVersion Version = "0.37.1"
+// 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.32.0"
diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json
index 92ae5a4c222..6296ba532fe 100644
--- a/pkg/parser/schemas/main_workflow_schema.json
+++ b/pkg/parser/schemas/main_workflow_schema.json
@@ -9121,7 +9121,7 @@
"oneOf": [
{
"type": "string",
- "description": "Engine name: built-in ('claude', 'codex', 'copilot', 'gemini') or a named catalog entry"
+ "description": "Engine name: built-in ('claude', 'codex', 'copilot', 'gemini', 'opencode') or a named catalog entry"
},
{
"type": "object",
@@ -9129,7 +9129,7 @@
"properties": {
"id": {
"type": "string",
- "description": "AI engine identifier: built-in ('claude', 'codex', 'copilot', 'gemini') or a named catalog entry"
+ "description": "AI engine identifier: built-in ('claude', 'codex', 'copilot', 'gemini', 'opencode') or a named catalog entry"
},
"version": {
"type": ["string", "number"],
@@ -9290,8 +9290,8 @@
"properties": {
"id": {
"type": "string",
- "description": "Runtime adapter identifier (e.g. 'codex', 'claude', 'copilot', 'gemini')",
- "examples": ["codex", "claude", "copilot", "gemini"]
+ "description": "Runtime adapter identifier (e.g. 'codex', 'claude', 'copilot', 'gemini', 'opencode')",
+ "examples": ["codex", "claude", "copilot", "gemini", "opencode"]
},
"version": {
"type": ["string", "number"],
diff --git a/pkg/workflow/agentic_engine.go b/pkg/workflow/agentic_engine.go
index e8c8dbc8182..4ac0e38665f 100644
--- a/pkg/workflow/agentic_engine.go
+++ b/pkg/workflow/agentic_engine.go
@@ -440,6 +440,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/data/engines/opencode.md b/pkg/workflow/data/engines/opencode.md
new file mode 100644
index 00000000000..d5a2aad0f9a
--- /dev/null
+++ b/pkg/workflow/data/engines/opencode.md
@@ -0,0 +1,16 @@
+---
+engine:
+ id: opencode
+ display-name: OpenCode
+ description: OpenCode CLI with headless mode and multi-provider LLM support
+ runtime-id: opencode
+ provider:
+ name: opencode
+ auth:
+ - role: api-key
+ secret: COPILOT_GITHUB_TOKEN
+---
+
+
diff --git a/pkg/workflow/domains.go b/pkg/workflow/domains.go
index ef98b2cc561..7deb1a74066 100644
--- a/pkg/workflow/domains.go
+++ b/pkg/workflow/domains.go
@@ -110,6 +110,75 @@ 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
+ "opencode.ai", // OpenCode telemetry/config (required for startup)
+ "registry.npmjs.org", // npm package downloads
+}
+
+// openCodeProviderDomains maps provider prefixes to their API domains.
+// Used by extractProviderFromModel() and GetOpenCodeDefaultDomains().
+var openCodeProviderDomains = map[string]string{
+ "copilot": "api.githubcopilot.com",
+ "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 static default domains for backward compatibility.
+// The dynamic path (GetOpenCodeDefaultDomains) resolves provider-specific domains
+// based on the model prefix and uses OpenCodeBaseDefaultDomains as the base.
+var OpenCodeDefaultDomains = []string{
+ "api.githubcopilot.com", // Default provider (Copilot routing)
+ "api.openai.com", // Direct OpenAI provider access
+ "generativelanguage.googleapis.com", // Google/Gemini provider
+ "host.docker.internal", // MCP gateway / API proxy access
+ "opencode.ai", // OpenCode telemetry/config (required for startup)
+ "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 "copilot" as default if no slash is found.
+func extractProviderFromModel(model string) string {
+ if model == "" {
+ return "copilot"
+ }
+ parts := strings.SplitN(model, "/", 2)
+ if len(parts) < 2 {
+ return "copilot"
+ }
+ 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.
+// Pass the selected model (e.g. "anthropic/claude-sonnet-4-20250514") so provider-specific
+// API domains are included. Returns a deduplicated, sorted, comma-separated string suitable for AWF's --allow-domains flag.
+func GetOpenCodeAllowedDomainsWithToolsAndRuntimes(model string, network *NetworkPermissions, tools map[string]any, runtimes map[string]any) string {
+ return GetAllowedDomainsForEngineWithModel(constants.OpenCodeEngine, model, 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{
@@ -544,8 +613,9 @@ func mergeDomainsWithNetworkToolsAndRuntimes(defaultDomains []string, network *N
return strings.Join(domains, ",")
}
-// engineDefaultDomains maps each engine to its default required domains.
-// Add new engines here to avoid adding new engine-specific domain functions.
+// engineDefaultDomains maps each engine to its static default required domains.
+// Engines with model-specific defaults (for example, OpenCode) are resolved in
+// getDefaultDomainsForEngine instead of being stored directly in this map.
var engineDefaultDomains = map[constants.EngineName][]string{
constants.CopilotEngine: CopilotDefaultDomains,
constants.ClaudeEngine: ClaudeDefaultDomains,
@@ -553,12 +623,36 @@ var engineDefaultDomains = map[constants.EngineName][]string{
constants.GeminiEngine: GeminiDefaultDomains,
}
+// getDefaultDomainsForEngine returns the engine's default required domains.
+// OpenCode domains are model/provider-specific, so they must be resolved via
+// GetOpenCodeDefaultDomains(model) rather than the static engineDefaultDomains map.
+// Falls back to an empty default domain list for unknown engines.
+func getDefaultDomainsForEngine(engine constants.EngineName, model string) []string {
+ if engine == constants.OpenCodeEngine {
+ return GetOpenCodeDefaultDomains(model)
+ }
+
+ return engineDefaultDomains[engine]
+}
+
+// GetAllowedDomainsForEngineWithModel merges the engine's default domains with
+// NetworkPermissions, HTTP MCP server domains, and runtime ecosystem domains.
+// For engines with model/provider-specific defaults (such as OpenCode), pass the
+// selected model so the correct default domains are included.
+// Returns a deduplicated, sorted, comma-separated string suitable for AWF's
+// --allow-domains flag.
+func GetAllowedDomainsForEngineWithModel(engine constants.EngineName, model string, network *NetworkPermissions, tools map[string]any, runtimes map[string]any) string {
+ return mergeDomainsWithNetworkToolsAndRuntimes(getDefaultDomainsForEngine(engine, model), network, tools, runtimes)
+}
+
// GetAllowedDomainsForEngine merges the engine's 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.
// Falls back to an empty default domain list for unknown engines.
+// For model/provider-specific engines such as OpenCode, prefer
+// GetAllowedDomainsForEngineWithModel so provider domains are included.
func GetAllowedDomainsForEngine(engine constants.EngineName, network *NetworkPermissions, tools map[string]any, runtimes map[string]any) string {
- return mergeDomainsWithNetworkToolsAndRuntimes(engineDefaultDomains[engine], network, tools, runtimes)
+ return GetAllowedDomainsForEngineWithModel(engine, "", network, tools, runtimes)
}
// GetCopilotAllowedDomainsWithToolsAndRuntimes merges Copilot default domains with NetworkPermissions, HTTP MCP server domains, and runtime ecosystem domains
@@ -739,6 +833,12 @@ func (c *Compiler) computeAllowedDomainsForSanitization(data *WorkflowData) stri
base = GetClaudeAllowedDomainsWithToolsAndRuntimes(data.NetworkPermissions, data.Tools, data.Runtimes)
case "gemini":
base = GetGeminiAllowedDomainsWithToolsAndRuntimes(data.NetworkPermissions, data.Tools, data.Runtimes)
+ case "opencode":
+ model := ""
+ if data.EngineConfig != nil {
+ model = data.EngineConfig.Model
+ }
+ base = GetOpenCodeAllowedDomainsWithToolsAndRuntimes(model, data.NetworkPermissions, data.Tools, data.Runtimes)
default:
// For other engines, use network permissions only
domains := GetAllowedDomains(data.NetworkPermissions)
diff --git a/pkg/workflow/engine_catalog_test.go b/pkg/workflow/engine_catalog_test.go
index 9dac2715123..65407e77867 100644
--- a/pkg/workflow/engine_catalog_test.go
+++ b/pkg/workflow/engine_catalog_test.go
@@ -21,7 +21,7 @@ func TestEngineCatalog_IDs(t *testing.T) {
require.NotEmpty(t, ids, "IDs() should return a non-empty list")
// Verify all built-in engines are present
- expectedIDs := []string{"claude", "codex", "copilot", "gemini"}
+ expectedIDs := []string{"claude", "codex", "copilot", "gemini", "opencode"}
assert.Equal(t, expectedIDs, ids, "IDs() should return all built-in engines in sorted order")
// Verify the list is sorted
@@ -76,13 +76,13 @@ func engineSchemaOneOfVariants(t *testing.T) []map[string]any {
return variants
}
-// TestEngineCatalog_BuiltInsPresent verifies that the four built-in engines are always
+// TestEngineCatalog_BuiltInsPresent verifies that the five built-in engines are always
// registered in the catalog with stable IDs.
func TestEngineCatalog_BuiltInsPresent(t *testing.T) {
registry := NewEngineRegistry()
catalog := NewEngineCatalog(registry)
- expected := []string{"claude", "codex", "copilot", "gemini"}
+ expected := []string{"claude", "codex", "copilot", "gemini", "opencode"}
catalogIDs := catalog.IDs()
for _, id := range expected {
assert.Contains(t, catalogIDs, id,
diff --git a/pkg/workflow/engine_definition.go b/pkg/workflow/engine_definition.go
index 51d929bd1a5..855e4fed1c6 100644
--- a/pkg/workflow/engine_definition.go
+++ b/pkg/workflow/engine_definition.go
@@ -15,7 +15,7 @@
//
// # Built-in Engines
//
-// NewEngineCatalog registers the four built-in engines: claude, codex, copilot, gemini.
+// NewEngineCatalog registers the five built-in engines: claude, codex, copilot, gemini, opencode.
// Each EngineDefinition carries the engine's RuntimeID which maps to the corresponding
// CodingAgentEngine registered in the EngineRegistry.
//
@@ -181,7 +181,7 @@ type ResolvedEngineTarget struct {
}
// NewEngineCatalog creates an EngineCatalog that wraps the given EngineRegistry and
-// pre-registers the four built-in engine definitions (claude, codex, copilot, gemini)
+// pre-registers the five built-in engine definitions (claude, codex, copilot, gemini, opencode)
// loaded from the embedded Markdown files in data/engines/*.md.
func NewEngineCatalog(registry *EngineRegistry) *EngineCatalog {
catalog := &EngineCatalog{
diff --git a/pkg/workflow/engine_definition_test.go b/pkg/workflow/engine_definition_test.go
index 9d060807c23..9611bb22d6a 100644
--- a/pkg/workflow/engine_definition_test.go
+++ b/pkg/workflow/engine_definition_test.go
@@ -10,7 +10,7 @@ import (
"github.com/stretchr/testify/require"
)
-// TestNewEngineCatalog_BuiltIns checks that all four built-in engines are registered
+// TestNewEngineCatalog_BuiltIns checks that all five built-in engines are registered
// and resolve to the expected runtime adapters.
func TestNewEngineCatalog_BuiltIns(t *testing.T) {
registry := NewEngineRegistry()
@@ -25,6 +25,7 @@ func TestNewEngineCatalog_BuiltIns(t *testing.T) {
{"codex", "Codex", "openai"},
{"copilot", "GitHub Copilot CLI", "github"},
{"gemini", "Google Gemini CLI", "google"},
+ {"opencode", "OpenCode", "opencode"},
}
for _, tt := range tests {
diff --git a/pkg/workflow/opencode_engine.go b/pkg/workflow/opencode_engine.go
new file mode 100644
index 00000000000..ffbc0f38098
--- /dev/null
+++ b/pkg/workflow/opencode_engine.go
@@ -0,0 +1,280 @@
+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
+ supportsWebSearch: false, // Has built-in websearch but not exposed via gh-aw neutral tools yet
+ llmGatewayPort: constants.OpenCodeLLMGatewayPort, // 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.
+// By default, OpenCode routes through the Copilot API using COPILOT_GITHUB_TOKEN
+// (or ${{ github.token }} when copilot-requests feature is enabled).
+// Additional provider API keys can be added via engine.env overrides.
+func (e *OpenCodeEngine) GetRequiredSecretNames(workflowData *WorkflowData) []string {
+ opencodeLog.Print("Collecting required secrets for OpenCode engine")
+ var secrets []string
+
+ // Default: Copilot routing via COPILOT_GITHUB_TOKEN.
+ // When copilot-requests feature is enabled, no secret is needed (uses github.token).
+ if !isFeatureEnabled(constants.CopilotRequestsFeatureFlag, workflowData) {
+ secrets = append(secrets, "COPILOT_GITHUB_TOKEN")
+ }
+
+ // 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 common MCP secrets (MCP_GATEWAY_API_KEY if MCP servers present, mcp-scripts secrets)
+ secrets = append(secrets, collectCommonMCPSecrets(workflowData)...)
+
+ // 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))
+ }
+
+ 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{}
+ }
+
+ npmSteps := BuildStandardNpmEngineInstallSteps(
+ "opencode-ai",
+ string(constants.DefaultOpenCodeVersion),
+ "Install OpenCode CLI",
+ "opencode",
+ workflowData,
+ )
+ return BuildNpmEngineInstallStepsWithAWF(npmSteps, workflowData)
+}
+
+// GetSecretValidationStep returns the secret validation step for the OpenCode engine.
+// Returns an empty step if copilot-requests feature is enabled (uses GitHub Actions token).
+func (e *OpenCodeEngine) GetSecretValidationStep(workflowData *WorkflowData) GitHubActionStep {
+ if isFeatureEnabled(constants.CopilotRequestsFeatureFlag, workflowData) {
+ opencodeLog.Print("Skipping secret validation step: copilot-requests feature enabled, using GitHub Actions token")
+ return GitHubActionStep{}
+ }
+ return BuildDefaultSecretValidationStep(
+ workflowData,
+ []string{"COPILOT_GITHUB_TOKEN"},
+ "OpenCode CLI",
+ "https://github.github.com/gh-aw/reference/engines/#opencode",
+ )
+}
+
+// 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 != ""
+
+ // Enable verbose logging for debugging in CI
+ opencodeArgs = append(opencodeArgs, "--print-logs")
+ opencodeArgs = append(opencodeArgs, "--log-level", "DEBUG")
+
+ // Prompt from file (positional argument to `opencode run`).
+ // Keep this outside shellJoinArgs so command substitution expands at runtime.
+ promptArg := "\"$(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 %s", commandName, shellJoinArgs(opencodeArgs), promptArg)
+
+ // AWF wrapping
+ firewallEnabled := isFirewallEnabled(workflowData)
+ var command string
+ if firewallEnabled {
+ // Resolve model for provider-specific domain allowlisting
+ model := ""
+ if modelConfigured {
+ model = workflowData.EngineConfig.Model
+ }
+ allowedDomains := GetOpenCodeAllowedDomainsWithToolsAndRuntimes(
+ model,
+ workflowData.NetworkPermissions,
+ workflowData.Tools,
+ workflowData.Runtimes,
+ )
+
+ npmPathSetup := GetNpmBinPathSetup()
+ opencodeCommandWithPath := fmt.Sprintf("%s && %s", npmPathSetup, opencodeCommand)
+
+ command = BuildAWFCommand(AWFCommandConfig{
+ EngineName: "opencode",
+ EngineCommand: opencodeCommandWithPath,
+ LogFile: logFile,
+ WorkflowData: workflowData,
+ UsesTTY: false,
+ AllowedDomains: allowedDomains,
+ })
+ } else {
+ command = fmt.Sprintf("set -o pipefail\n%s 2>&1 | tee -a %s", opencodeCommand, logFile)
+ }
+
+ // Environment variables — default to Copilot routing (OpenAI-compatible API).
+ // OPENAI_API_KEY is set from COPILOT_GITHUB_TOKEN (or github.token with copilot-requests).
+ // #nosec G101 -- These are NOT hardcoded credentials. They are GitHub Actions expression templates
+ // that the runtime replaces with actual values.
+ var openaiAPIKey string
+ useCopilotRequests := isFeatureEnabled(constants.CopilotRequestsFeatureFlag, workflowData)
+ if useCopilotRequests {
+ openaiAPIKey = "${{ github.token }}"
+ opencodeLog.Print("Using GitHub Actions token as OPENAI_API_KEY (copilot-requests feature enabled)")
+ } else {
+ openaiAPIKey = "${{ secrets.COPILOT_GITHUB_TOKEN }}"
+ }
+
+ env := map[string]string{
+ "OPENAI_API_KEY": openaiAPIKey,
+ "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 Copilot routing via OpenAI-compatible endpoint)
+ if firewallEnabled {
+ env["OPENAI_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 with restrictive permissions
+ command := fmt.Sprintf(`umask 077
+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
+chmod 600 "$CONFIG"`, 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..a9fa33980a6
--- /dev/null
+++ b/pkg/workflow/opencode_engine_test.go
@@ -0,0 +1,395 @@
+//go:build !integration
+
+package workflow
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/github/gh-aw/pkg/constants"
+ "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.SupportsWebSearch(), "Should not support built-in web search")
+ assert.Equal(t, constants.OpenCodeLLMGatewayPort, 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, "COPILOT_GITHUB_TOKEN", "Should require COPILOT_GITHUB_TOKEN for Copilot routing")
+ })
+
+ t.Run("required secrets with copilot-requests feature", func(t *testing.T) {
+ workflowData := &WorkflowData{
+ Name: "test",
+ ParsedTools: &ToolsConfig{},
+ Tools: map[string]any{},
+ Features: map[string]any{
+ "copilot-requests": true,
+ },
+ }
+ secrets := engine.GetRequiredSecretNames(workflowData)
+ assert.NotContains(t, secrets, "COPILOT_GITHUB_TOKEN", "Should not require COPILOT_GITHUB_TOKEN when copilot-requests is enabled")
+ })
+
+ 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, "COPILOT_GITHUB_TOKEN", "Should require COPILOT_GITHUB_TOKEN for Copilot routing")
+ 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{
+ "ANTHROPIC_API_KEY": "${{ secrets.ANTHROPIC_API_KEY }}",
+ },
+ },
+ }
+ secrets := engine.GetRequiredSecretNames(workflowData)
+ assert.Contains(t, secrets, "COPILOT_GITHUB_TOKEN", "Should still require COPILOT_GITHUB_TOKEN for Copilot routing")
+ assert.Contains(t, secrets, "ANTHROPIC_API_KEY", "Should add ANTHROPIC_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")
+ })
+
+ t.Run("secret validation step without copilot-requests", func(t *testing.T) {
+ workflowData := &WorkflowData{
+ Name: "test",
+ }
+ step := engine.GetSecretValidationStep(workflowData)
+ stepContent := strings.Join(step, "\n")
+ assert.Contains(t, stepContent, "COPILOT_GITHUB_TOKEN", "Should validate COPILOT_GITHUB_TOKEN")
+ })
+
+ t.Run("secret validation step with copilot-requests", func(t *testing.T) {
+ workflowData := &WorkflowData{
+ Name: "test",
+ Features: map[string]any{
+ "copilot-requests": true,
+ },
+ }
+ step := engine.GetSecretValidationStep(workflowData)
+ assert.Empty(t, step, "Should skip secret validation when copilot-requests is enabled")
+ })
+}
+
+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: Node.js setup + Install OpenCode
+ assert.GreaterOrEqual(t, len(steps), 2, "Should have at least 2 installation steps")
+ })
+
+ 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, `"$(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, "OPENAI_API_KEY: ${{ secrets.COPILOT_GITHUB_TOKEN }}", "Should set OPENAI_API_KEY from COPILOT_GITHUB_TOKEN")
+ assert.Contains(t, stepContent, "NO_PROXY: localhost,127.0.0.1", "Should set NO_PROXY env var")
+ })
+
+ t.Run("basic execution with copilot-requests", func(t *testing.T) {
+ workflowData := &WorkflowData{
+ Name: "test-workflow",
+ Features: map[string]any{
+ "copilot-requests": 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")
+ assert.Contains(t, stepContent, "OPENAI_API_KEY: ${{ github.token }}", "Should set OPENAI_API_KEY from github.token when copilot-requests is enabled")
+ })
+
+ 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{
+ "OPENAI_API_KEY": "${{ secrets.MY_ORG_OPENAI_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, "OPENAI_API_KEY: ${{ secrets.MY_ORG_OPENAI_KEY }}", "engine.env should override the default OPENAI_API_KEY expression")
+ assert.NotContains(t, stepContent, "OPENAI_API_KEY: ${{ secrets.COPILOT_GITHUB_TOKEN }}", "Default COPILOT_GITHUB_TOKEN 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, "OPENAI_BASE_URL: http://host.docker.internal:10004", "Should set OPENAI_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, "OPENAI_BASE_URL", "Should not set OPENAI_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 copilot", func(t *testing.T) {
+ assert.Equal(t, "copilot", extractProviderFromModel(""))
+ })
+
+ t.Run("no slash defaults to copilot", func(t *testing.T) {
+ assert.Equal(t, "copilot", 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..c0f521a3dea
--- /dev/null
+++ b/pkg/workflow/opencode_mcp.go
@@ -0,0 +1,21 @@
+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(sb *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))
+
+ // OpenCode uses JSON format without Copilot-specific fields and multi-line args
+ return renderStandardJSONMCPConfig(sb, tools, mcpTools, workflowData,
+ "/tmp/gh-aw/mcp-config/mcp-servers.json", false, false,
+ func(builder *strings.Builder, toolName string, toolConfig map[string]any, isLast bool) error {
+ return renderCustomMCPConfigWrapperWithContext(builder, toolName, toolConfig, isLast, workflowData)
+ }, nil)
+}