diff --git a/.github/workflows/smoke-openclaw.lock.yml b/.github/workflows/smoke-openclaw.lock.yml
new file mode 100644
index 00000000000..11c0074ab6c
--- /dev/null
+++ b/.github/workflows/smoke-openclaw.lock.yml
@@ -0,0 +1,1727 @@
+#
+# ___ _ _
+# / _ \ | | (_)
+# | |_| | __ _ ___ _ __ | |_ _ ___
+# | _ |/ _` |/ _ \ '_ \| __| |/ __|
+# | | | | (_| | __/ | | | |_| | (__
+# \_| |_/\__, |\___|_| |_|\__|_|\___|
+# __/ |
+# _ _ |___/
+# | | | | / _| |
+# | | | | ___ _ __ _ __| |_| | _____ ____
+# | |/\| |/ _ \ '__| |/ /| _| |/ _ \ \ /\ / / ___|
+# \ /\ / (_) | | | | ( | | | | (_) \ 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 OpenClaw engine functionality
+#
+# Resolved workflow manifest:
+# Imports:
+# - shared/gh.md
+# - shared/github-queries-safe-input.md
+#
+# frontmatter-hash: aed021a91e4917e402d720e60d1392f1490efeb08d350d1f3f631bca1956d8d0
+
+name: "Smoke OpenClaw"
+"on":
+ pull_request:
+ # names: # Label filtering applied via job conditions
+ # - smoke # Label filtering applied via job conditions
+ types:
+ - labeled
+ schedule:
+ - cron: "38 20 * * *"
+ workflow_dispatch: null
+
+permissions: {}
+
+concurrency:
+ group: "gh-aw-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}"
+ cancel-in-progress: true
+
+run-name: "Smoke OpenClaw"
+
+jobs:
+ activation:
+ needs: pre_activation
+ if: >
+ (needs.pre_activation.outputs.activated == 'true') && (((github.event_name != 'pull_request') || (github.event.pull_request.head.repo.id == github.repository_id)) &&
+ ((github.event_name != 'pull_request') || ((github.event.action != 'labeled') || (github.event.label.name == 'smoke'))))
+ runs-on: ubuntu-slim
+ permissions:
+ contents: read
+ discussions: write
+ issues: write
+ pull-requests: write
+ outputs:
+ comment_id: ${{ steps.add-comment.outputs.comment-id }}
+ comment_repo: ${{ steps.add-comment.outputs.comment-repo }}
+ comment_url: ${{ steps.add-comment.outputs.comment-url }}
+ steps:
+ - name: Checkout actions folder
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ sparse-checkout: |
+ actions
+ persist-credentials: false
+ - name: Setup Scripts
+ uses: ./actions/setup
+ with:
+ destination: /opt/gh-aw/actions
+ - name: Checkout .github and .agents folders
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ sparse-checkout: |
+ .github
+ .agents
+ fetch-depth: 1
+ persist-credentials: false
+ - name: Check workflow file timestamps
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ env:
+ GH_AW_WORKFLOW_FILE: "smoke-openclaw.lock.yml"
+ with:
+ script: |
+ const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io);
+ const { main } = require('/opt/gh-aw/actions/check_workflow_timestamp_api.cjs');
+ await main();
+ - name: Add comment with workflow run link
+ id: add-comment
+ if: github.event_name == 'issues' || github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment' || github.event_name == 'discussion' || github.event_name == 'discussion_comment' || (github.event_name == 'pull_request') && (github.event.pull_request.head.repo.id == github.repository_id)
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ env:
+ GH_AW_WORKFLOW_NAME: "Smoke OpenClaw"
+ GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e 🦀 *[Mission Complete] — Powered by [{workflow_name}]({run_url})*\",\"runStarted\":\"🦀 **DEPLOY!** [{workflow_name}]({run_url}) activating for this {event_type}! *[Claw engaged...]*\",\"runSuccess\":\"🎯 **TARGET ACQUIRED** — [{workflow_name}]({run_url}) **ALL CLEAR!** Systems nominal! ✨\",\"runFailure\":\"⚠️ **RETREAT...** [{workflow_name}]({run_url}) {status}! The claw missed its target...\"}"
+ with:
+ script: |
+ const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io);
+ const { main } = require('/opt/gh-aw/actions/add_workflow_run_comment.cjs');
+ await main();
+ - name: Create prompt with built-in context
+ env:
+ GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
+ GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }}
+ GH_AW_GITHUB_ACTOR: ${{ github.actor }}
+ GH_AW_GITHUB_EVENT_COMMENT_ID: ${{ github.event.comment.id }}
+ GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: ${{ github.event.discussion.number }}
+ GH_AW_GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }}
+ GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }}
+ GH_AW_GITHUB_REPOSITORY: ${{ github.repository }}
+ GH_AW_GITHUB_RUN_ID: ${{ github.run_id }}
+ GH_AW_GITHUB_SERVER_URL: ${{ github.server_url }}
+ GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }}
+ run: |
+ bash /opt/gh-aw/actions/create_prompt_first.sh
+ cat << 'GH_AW_PROMPT_EOF' > "$GH_AW_PROMPT"
+
+ GH_AW_PROMPT_EOF
+ cat "/opt/gh-aw/prompts/xpia.md" >> "$GH_AW_PROMPT"
+ cat "/opt/gh-aw/prompts/temp_folder_prompt.md" >> "$GH_AW_PROMPT"
+ cat "/opt/gh-aw/prompts/markdown.md" >> "$GH_AW_PROMPT"
+ cat << 'GH_AW_PROMPT_EOF' >> "$GH_AW_PROMPT"
+
+ GitHub API Access Instructions
+
+ The gh CLI is NOT authenticated. Do NOT use gh commands for GitHub operations.
+
+
+ To create or modify GitHub resources (issues, discussions, pull requests, etc.), you MUST call the appropriate safe output tool. Simply writing content will NOT work - the workflow requires actual tool calls.
+
+ Temporary IDs: Some safe output tools support a temporary ID field (usually named temporary_id) so you can reference newly-created items elsewhere in the SAME agent output (for example, using #aw_abc1 in a later body).
+
+ **IMPORTANT - temporary_id format rules:**
+ - If you DON'T need to reference the item later, OMIT the temporary_id field entirely (it will be auto-generated if needed)
+ - If you DO need cross-references/chaining, you MUST match this EXACT validation regex: /^aw_[A-Za-z0-9]{3,8}$/i
+ - Format: aw_ prefix followed by 3 to 8 alphanumeric characters (A-Z, a-z, 0-9, case-insensitive)
+ - Valid alphanumeric characters: ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789
+ - INVALID examples: aw_ab (too short), aw_123456789 (too long), aw_test-id (contains hyphen), aw_id_123 (contains underscore)
+ - VALID examples: aw_abc, aw_abc1, aw_Test123, aw_A1B2C3D4, aw_12345678
+ - To generate valid IDs: use 3-8 random alphanumeric characters or omit the field to let the system auto-generate
+
+ Do NOT invent other aw_* formats — downstream steps will reject them with validation errors matching against /^aw_[A-Za-z0-9]{3,8}$/i.
+
+ Discover available tools from the safeoutputs MCP server.
+
+ **Critical**: Tool calls write structured data that downstream jobs process. Without tool calls, follow-up actions will be skipped.
+
+ **Note**: If you made no other safe output tool calls during this workflow execution, call the "noop" tool to provide a status message indicating completion or that no actions were needed.
+
+
+
+ The following GitHub context information is available for this workflow:
+ {{#if __GH_AW_GITHUB_ACTOR__ }}
+ - **actor**: __GH_AW_GITHUB_ACTOR__
+ {{/if}}
+ {{#if __GH_AW_GITHUB_REPOSITORY__ }}
+ - **repository**: __GH_AW_GITHUB_REPOSITORY__
+ {{/if}}
+ {{#if __GH_AW_GITHUB_WORKSPACE__ }}
+ - **workspace**: __GH_AW_GITHUB_WORKSPACE__
+ {{/if}}
+ {{#if __GH_AW_GITHUB_EVENT_ISSUE_NUMBER__ }}
+ - **issue-number**: #__GH_AW_GITHUB_EVENT_ISSUE_NUMBER__
+ {{/if}}
+ {{#if __GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER__ }}
+ - **discussion-number**: #__GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER__
+ {{/if}}
+ {{#if __GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER__ }}
+ - **pull-request-number**: #__GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER__
+ {{/if}}
+ {{#if __GH_AW_GITHUB_EVENT_COMMENT_ID__ }}
+ - **comment-id**: __GH_AW_GITHUB_EVENT_COMMENT_ID__
+ {{/if}}
+ {{#if __GH_AW_GITHUB_RUN_ID__ }}
+ - **workflow-run-id**: __GH_AW_GITHUB_RUN_ID__
+ {{/if}}
+
+
+ GH_AW_PROMPT_EOF
+ cat << 'GH_AW_PROMPT_EOF' >> "$GH_AW_PROMPT"
+
+ GH_AW_PROMPT_EOF
+ cat << 'GH_AW_PROMPT_EOF' >> "$GH_AW_PROMPT"
+ {{#runtime-import .github/workflows/shared/gh.md}}
+ GH_AW_PROMPT_EOF
+ cat << 'GH_AW_PROMPT_EOF' >> "$GH_AW_PROMPT"
+ {{#runtime-import .github/workflows/shared/github-queries-safe-input.md}}
+ GH_AW_PROMPT_EOF
+ cat << 'GH_AW_PROMPT_EOF' >> "$GH_AW_PROMPT"
+ {{#runtime-import .github/workflows/smoke-openclaw.md}}
+ GH_AW_PROMPT_EOF
+ - name: Interpolate variables and render templates
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ env:
+ GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
+ GH_AW_GITHUB_REPOSITORY: ${{ github.repository }}
+ GH_AW_GITHUB_RUN_ID: ${{ github.run_id }}
+ GH_AW_GITHUB_SERVER_URL: ${{ github.server_url }}
+ with:
+ script: |
+ const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io);
+ const { main } = require('/opt/gh-aw/actions/interpolate_prompt.cjs');
+ await main();
+ - name: Substitute placeholders
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ env:
+ GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
+ GH_AW_GITHUB_ACTOR: ${{ github.actor }}
+ GH_AW_GITHUB_EVENT_COMMENT_ID: ${{ github.event.comment.id }}
+ GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: ${{ github.event.discussion.number }}
+ GH_AW_GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }}
+ GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }}
+ GH_AW_GITHUB_REPOSITORY: ${{ github.repository }}
+ GH_AW_GITHUB_RUN_ID: ${{ github.run_id }}
+ GH_AW_GITHUB_SERVER_URL: ${{ github.server_url }}
+ GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }}
+ with:
+ script: |
+ const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io);
+
+ const substitutePlaceholders = require('/opt/gh-aw/actions/substitute_placeholders.cjs');
+
+ // Call the substitution function
+ return await substitutePlaceholders({
+ file: process.env.GH_AW_PROMPT,
+ substitutions: {
+ GH_AW_GITHUB_ACTOR: process.env.GH_AW_GITHUB_ACTOR,
+ GH_AW_GITHUB_EVENT_COMMENT_ID: process.env.GH_AW_GITHUB_EVENT_COMMENT_ID,
+ GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: process.env.GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER,
+ GH_AW_GITHUB_EVENT_ISSUE_NUMBER: process.env.GH_AW_GITHUB_EVENT_ISSUE_NUMBER,
+ GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: process.env.GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER,
+ GH_AW_GITHUB_REPOSITORY: process.env.GH_AW_GITHUB_REPOSITORY,
+ GH_AW_GITHUB_RUN_ID: process.env.GH_AW_GITHUB_RUN_ID,
+ GH_AW_GITHUB_SERVER_URL: process.env.GH_AW_GITHUB_SERVER_URL,
+ GH_AW_GITHUB_WORKSPACE: process.env.GH_AW_GITHUB_WORKSPACE
+ }
+ });
+ - name: Validate prompt placeholders
+ env:
+ GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
+ run: bash /opt/gh-aw/actions/validate_prompt_placeholders.sh
+ - name: Print prompt
+ env:
+ GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
+ run: bash /opt/gh-aw/actions/print_prompt_summary.sh
+ - name: Upload prompt artifact
+ if: success()
+ uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
+ with:
+ name: prompt
+ path: /tmp/gh-aw/aw-prompts/prompt.txt
+ retention-days: 1
+
+ agent:
+ needs: activation
+ runs-on: ubuntu-latest
+ permissions:
+ actions: read
+ contents: read
+ discussions: read
+ issues: read
+ pull-requests: read
+ env:
+ DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
+ GH_AW_ASSETS_ALLOWED_EXTS: ""
+ GH_AW_ASSETS_BRANCH: ""
+ GH_AW_ASSETS_MAX_SIZE_KB: 0
+ GH_AW_MCP_LOG_DIR: /tmp/gh-aw/mcp-logs/safeoutputs
+ GH_AW_SAFE_OUTPUTS: /opt/gh-aw/safeoutputs/outputs.jsonl
+ GH_AW_SAFE_OUTPUTS_CONFIG_PATH: /opt/gh-aw/safeoutputs/config.json
+ GH_AW_SAFE_OUTPUTS_TOOLS_PATH: /opt/gh-aw/safeoutputs/tools.json
+ GH_AW_WORKFLOW_ID_SANITIZED: smokeopenclaw
+ outputs:
+ checkout_pr_success: ${{ steps.checkout-pr.outputs.checkout_pr_success || 'true' }}
+ has_patch: ${{ steps.collect_output.outputs.has_patch }}
+ model: ${{ steps.generate_aw_info.outputs.model }}
+ output: ${{ steps.collect_output.outputs.output }}
+ output_types: ${{ steps.collect_output.outputs.output_types }}
+ secret_verification_result: ${{ steps.validate-secret.outputs.verification_result }}
+ steps:
+ - name: Checkout actions folder
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ sparse-checkout: |
+ actions
+ persist-credentials: false
+ - name: Setup Scripts
+ uses: ./actions/setup
+ with:
+ destination: /opt/gh-aw/actions
+ - name: Checkout repository
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
+ - name: Create gh-aw temp directory
+ run: bash /opt/gh-aw/actions/create_gh_aw_tmp_dir.sh
+ - name: Configure Git credentials
+ env:
+ REPO_NAME: ${{ github.repository }}
+ SERVER_URL: ${{ github.server_url }}
+ run: |
+ git config --global user.email "github-actions[bot]@users.noreply.github.com"
+ git config --global user.name "github-actions[bot]"
+ # Re-authenticate git with GitHub token
+ SERVER_URL_STRIPPED="${SERVER_URL#https://}"
+ git remote set-url origin "https://x-access-token:${{ github.token }}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git"
+ echo "Git configured with standard GitHub Actions identity"
+ - name: Checkout PR branch
+ id: checkout-pr
+ if: |
+ github.event.pull_request
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ env:
+ GH_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
+ with:
+ github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
+ script: |
+ const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io);
+ const { main } = require('/opt/gh-aw/actions/checkout_pr_branch.cjs');
+ await main();
+ - name: Generate agentic run info
+ id: generate_aw_info
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ with:
+ script: |
+ const fs = require('fs');
+
+ const awInfo = {
+ engine_id: "openclaw",
+ engine_name: "OpenClaw",
+ model: process.env.GH_AW_MODEL_AGENT_CUSTOM || "",
+ version: "",
+ agent_version: "",
+ workflow_name: "Smoke OpenClaw",
+ experimental: true,
+ supports_tools_allowlist: false,
+ run_id: context.runId,
+ run_number: context.runNumber,
+ run_attempt: process.env.GITHUB_RUN_ATTEMPT,
+ repository: context.repo.owner + '/' + context.repo.repo,
+ ref: context.ref,
+ sha: context.sha,
+ actor: context.actor,
+ event_name: context.eventName,
+ staged: false,
+ allowed_domains: ["defaults","github"],
+ firewall_enabled: true,
+ awf_version: "v0.19.1",
+ awmg_version: "v0.1.4",
+ steps: {
+ firewall: "squid"
+ },
+ created_at: new Date().toISOString()
+ };
+
+ // Write to /tmp/gh-aw directory to avoid inclusion in PR
+ const tmpPath = '/tmp/gh-aw/aw_info.json';
+ fs.writeFileSync(tmpPath, JSON.stringify(awInfo, null, 2));
+ console.log('Generated aw_info.json at:', tmpPath);
+ console.log(JSON.stringify(awInfo, null, 2));
+
+ // Set model as output for reuse in other steps/jobs
+ core.setOutput('model', awInfo.model);
+ - name: Validate OPENCLAW_API_KEY or ANTHROPIC_API_KEY secret
+ id: validate-secret
+ run: /opt/gh-aw/actions/validate_multi_secret.sh OPENCLAW_API_KEY ANTHROPIC_API_KEY OpenClaw https://github.github.com/gh-aw/reference/engines/#openclaw
+ env:
+ OPENCLAW_API_KEY: ${{ secrets.OPENCLAW_API_KEY }}
+ ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
+ - name: Setup Node.js
+ uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
+ with:
+ node-version: '24'
+ package-manager-cache: false
+ - name: Install OpenClaw
+ run: npm install -g --silent openclaw@2026.2.15
+ - name: Install awf binary
+ run: bash /opt/gh-aw/actions/install_awf_binary.sh v0.19.1
+ - name: Determine automatic lockdown mode for GitHub MCP Server
+ id: determine-automatic-lockdown
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ env:
+ GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }}
+ GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }}
+ with:
+ script: |
+ const determineAutomaticLockdown = require('/opt/gh-aw/actions/determine_automatic_lockdown.cjs');
+ await determineAutomaticLockdown(github, context, core);
+ - name: Download container images
+ run: bash /opt/gh-aw/actions/download_docker_images.sh ghcr.io/github/gh-aw-firewall/agent:0.19.1 ghcr.io/github/gh-aw-firewall/squid:0.19.1 ghcr.io/github/gh-aw-mcpg:v0.1.4 ghcr.io/github/github-mcp-server:v0.30.3 node:lts-alpine
+ - name: Write Safe Outputs Config
+ run: |
+ mkdir -p /opt/gh-aw/safeoutputs
+ mkdir -p /tmp/gh-aw/safeoutputs
+ mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs
+ cat > /opt/gh-aw/safeoutputs/config.json << 'GH_AW_SAFE_OUTPUTS_CONFIG_EOF'
+ {"add_comment":{"max":2},"add_labels":{"allowed":["smoke-openclaw"],"max":3},"create_issue":{"expires":2,"group":true,"max":1},"missing_data":{},"missing_tool":{},"noop":{"max":1}}
+ GH_AW_SAFE_OUTPUTS_CONFIG_EOF
+ cat > /opt/gh-aw/safeoutputs/tools.json << 'GH_AW_SAFE_OUTPUTS_TOOLS_EOF'
+ [
+ {
+ "description": "Create a new GitHub issue for tracking bugs, feature requests, or tasks. Use this for actionable work items that need assignment, labeling, and status tracking. For reports, announcements, or status updates that don't require task tracking, use create_discussion instead. CONSTRAINTS: Maximum 1 issue(s) can be created.",
+ "inputSchema": {
+ "additionalProperties": false,
+ "properties": {
+ "body": {
+ "description": "Detailed issue description in Markdown. Do NOT repeat the title as a heading since it already appears as the issue's h1. Include context, reproduction steps, or acceptance criteria as appropriate.",
+ "type": "string"
+ },
+ "labels": {
+ "description": "Labels to categorize the issue (e.g., 'bug', 'enhancement'). Labels must exist in the repository.",
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "parent": {
+ "description": "Parent issue number for creating sub-issues. This is the numeric ID from the GitHub URL (e.g., 42 in github.com/owner/repo/issues/42). Can also be a temporary_id (e.g., 'aw_abc123', 'aw_Test123') from a previously created issue in the same workflow run.",
+ "type": [
+ "number",
+ "string"
+ ]
+ },
+ "temporary_id": {
+ "description": "Unique temporary identifier for referencing this issue before it's created. Format: 'aw_' followed by 3 to 8 alphanumeric characters (e.g., 'aw_abc1', 'aw_Test123'). Use '#aw_ID' in body text to reference other issues by their temporary_id; these are replaced with actual issue numbers after creation.",
+ "pattern": "^aw_[A-Za-z0-9]{3,8}$",
+ "type": "string"
+ },
+ "title": {
+ "description": "Concise issue title summarizing the bug, feature, or task. The title appears as the main heading, so keep it brief and descriptive.",
+ "type": "string"
+ }
+ },
+ "required": [
+ "title",
+ "body"
+ ],
+ "type": "object"
+ },
+ "name": "create_issue"
+ },
+ {
+ "description": "Add a comment to an existing GitHub issue, pull request, or discussion. Use this to provide feedback, answer questions, or add information to an existing conversation. For creating new items, use create_issue, create_discussion, or create_pull_request instead. IMPORTANT: Comments are subject to validation constraints enforced by the MCP server - maximum 65536 characters for the complete comment (including footer which is added automatically), 10 mentions (@username), and 50 links. Exceeding these limits will result in an immediate error with specific guidance. CONSTRAINTS: Maximum 2 comment(s) can be added.",
+ "inputSchema": {
+ "additionalProperties": false,
+ "properties": {
+ "body": {
+ "description": "The comment text in Markdown format. This is the 'body' field - do not use 'comment_body' or other variations. Provide helpful, relevant information that adds value to the conversation. CONSTRAINTS: The complete comment (your body text + automatically added footer) must not exceed 65536 characters total. Maximum 10 mentions (@username), maximum 50 links (http/https URLs). A footer (~200-500 characters) is automatically appended with workflow attribution, so leave adequate space. If these limits are exceeded, the tool call will fail with a detailed error message indicating which constraint was violated.",
+ "type": "string"
+ },
+ "item_number": {
+ "description": "The issue, pull request, or discussion number to comment on. This is the numeric ID from the GitHub URL (e.g., 123 in github.com/owner/repo/issues/123). If omitted, the tool will attempt to resolve the target from the current workflow context (triggering issue, PR, or discussion).",
+ "type": "number"
+ }
+ },
+ "required": [
+ "body"
+ ],
+ "type": "object"
+ },
+ "name": "add_comment"
+ },
+ {
+ "description": "Add labels to an existing GitHub issue or pull request for categorization and filtering. Labels must already exist in the repository. For creating new issues with labels, use create_issue with the labels property instead. CONSTRAINTS: Only these labels are allowed: [smoke-openclaw].",
+ "inputSchema": {
+ "additionalProperties": false,
+ "properties": {
+ "item_number": {
+ "description": "Issue or PR number to add labels to. This is the numeric ID from the GitHub URL (e.g., 456 in github.com/owner/repo/issues/456). If omitted, adds labels to the item that triggered this workflow.",
+ "type": "number"
+ },
+ "labels": {
+ "description": "Label names to add (e.g., ['bug', 'priority-high']). Labels must exist in the repository.",
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ }
+ },
+ "type": "object"
+ },
+ "name": "add_labels"
+ },
+ {
+ "description": "Report that a tool or capability needed to complete the task is not available, or share any information you deem important about missing functionality or limitations. Use this when you cannot accomplish what was requested because the required functionality is missing or access is restricted.",
+ "inputSchema": {
+ "additionalProperties": false,
+ "properties": {
+ "alternatives": {
+ "description": "Any workarounds, manual steps, or alternative approaches the user could take (max 256 characters).",
+ "type": "string"
+ },
+ "reason": {
+ "description": "Explanation of why this tool is needed or what information you want to share about the limitation (max 256 characters).",
+ "type": "string"
+ },
+ "tool": {
+ "description": "Optional: Name or description of the missing tool or capability (max 128 characters). Be specific about what functionality is needed.",
+ "type": "string"
+ }
+ },
+ "required": [
+ "reason"
+ ],
+ "type": "object"
+ },
+ "name": "missing_tool"
+ },
+ {
+ "description": "Log a transparency message when no significant actions are needed. Use this to confirm workflow completion and provide visibility when analysis is complete but no changes or outputs are required (e.g., 'No issues found', 'All checks passed'). This ensures the workflow produces human-visible output even when no other actions are taken.",
+ "inputSchema": {
+ "additionalProperties": false,
+ "properties": {
+ "message": {
+ "description": "Status or completion message to log. Should explain what was analyzed and the outcome (e.g., 'Code review complete - no issues found', 'Analysis complete - all tests passing').",
+ "type": "string"
+ }
+ },
+ "required": [
+ "message"
+ ],
+ "type": "object"
+ },
+ "name": "noop"
+ },
+ {
+ "description": "Report that data or information needed to complete the task is not available. Use this when you cannot accomplish what was requested because required data, context, or information is missing.",
+ "inputSchema": {
+ "additionalProperties": false,
+ "properties": {
+ "alternatives": {
+ "description": "Any workarounds, manual steps, or alternative approaches the user could take (max 256 characters).",
+ "type": "string"
+ },
+ "context": {
+ "description": "Additional context about the missing data or where it should come from (max 256 characters).",
+ "type": "string"
+ },
+ "data_type": {
+ "description": "Type or description of the missing data or information (max 128 characters). Be specific about what data is needed.",
+ "type": "string"
+ },
+ "reason": {
+ "description": "Explanation of why this data is needed to complete the task (max 256 characters).",
+ "type": "string"
+ }
+ },
+ "required": [],
+ "type": "object"
+ },
+ "name": "missing_data"
+ }
+ ]
+ GH_AW_SAFE_OUTPUTS_TOOLS_EOF
+ cat > /opt/gh-aw/safeoutputs/validation.json << 'GH_AW_SAFE_OUTPUTS_VALIDATION_EOF'
+ {
+ "add_comment": {
+ "defaultMax": 1,
+ "fields": {
+ "body": {
+ "required": true,
+ "type": "string",
+ "sanitize": true,
+ "maxLength": 65000
+ },
+ "item_number": {
+ "issueOrPRNumber": true
+ }
+ }
+ },
+ "add_labels": {
+ "defaultMax": 5,
+ "fields": {
+ "item_number": {
+ "issueOrPRNumber": true
+ },
+ "labels": {
+ "required": true,
+ "type": "array",
+ "itemType": "string",
+ "itemSanitize": true,
+ "itemMaxLength": 128
+ }
+ }
+ },
+ "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_tool": {
+ "defaultMax": 20,
+ "fields": {
+ "alternatives": {
+ "type": "string",
+ "sanitize": true,
+ "maxLength": 512
+ },
+ "reason": {
+ "required": true,
+ "type": "string",
+ "sanitize": true,
+ "maxLength": 256
+ },
+ "tool": {
+ "type": "string",
+ "sanitize": true,
+ "maxLength": 128
+ }
+ }
+ },
+ "noop": {
+ "defaultMax": 1,
+ "fields": {
+ "message": {
+ "required": true,
+ "type": "string",
+ "sanitize": true,
+ "maxLength": 65000
+ }
+ }
+ }
+ }
+ GH_AW_SAFE_OUTPUTS_VALIDATION_EOF
+ - name: Generate Safe Outputs MCP Server Config
+ id: safe-outputs-config
+ run: |
+ # Generate a secure random API key (360 bits of entropy, 40+ chars)
+ # Mask immediately to prevent timing vulnerabilities
+ API_KEY=$(openssl rand -base64 45 | tr -d '/+=')
+ echo "::add-mask::${API_KEY}"
+
+ PORT=3001
+
+ # Set outputs for next steps
+ {
+ echo "safe_outputs_api_key=${API_KEY}"
+ echo "safe_outputs_port=${PORT}"
+ } >> "$GITHUB_OUTPUT"
+
+ echo "Safe Outputs MCP server will run on port ${PORT}"
+
+ - name: Start Safe Outputs MCP HTTP Server
+ id: safe-outputs-start
+ env:
+ DEBUG: '*'
+ GH_AW_SAFE_OUTPUTS_PORT: ${{ steps.safe-outputs-config.outputs.safe_outputs_port }}
+ GH_AW_SAFE_OUTPUTS_API_KEY: ${{ steps.safe-outputs-config.outputs.safe_outputs_api_key }}
+ GH_AW_SAFE_OUTPUTS_TOOLS_PATH: /opt/gh-aw/safeoutputs/tools.json
+ GH_AW_SAFE_OUTPUTS_CONFIG_PATH: /opt/gh-aw/safeoutputs/config.json
+ GH_AW_MCP_LOG_DIR: /tmp/gh-aw/mcp-logs/safeoutputs
+ run: |
+ # Environment variables are set above to prevent template injection
+ export DEBUG
+ export GH_AW_SAFE_OUTPUTS_PORT
+ export GH_AW_SAFE_OUTPUTS_API_KEY
+ export GH_AW_SAFE_OUTPUTS_TOOLS_PATH
+ export GH_AW_SAFE_OUTPUTS_CONFIG_PATH
+ export GH_AW_MCP_LOG_DIR
+
+ bash /opt/gh-aw/actions/start_safe_outputs_server.sh
+
+ - name: Setup Safe Inputs Config
+ run: |
+ mkdir -p /opt/gh-aw/safe-inputs/logs
+ cat > /opt/gh-aw/safe-inputs/tools.json << 'GH_AW_SAFE_INPUTS_TOOLS_EOF'
+ {
+ "serverName": "safeinputs",
+ "version": "1.0.0",
+ "logDir": "/opt/gh-aw/safe-inputs/logs",
+ "tools": [
+ {
+ "name": "gh",
+ "description": "Execute any gh CLI command. This tool is accessible as 'safeinputs-gh'. Provide the full command after 'gh' (e.g., args: 'pr list --limit 5'). The tool will run: gh \u003cargs\u003e. Use single quotes ' for complex args to avoid shell interpretation issues.",
+ "inputSchema": {
+ "properties": {
+ "args": {
+ "description": "Arguments to pass to gh CLI (without the 'gh' prefix). Examples: 'pr list --limit 5', 'issue view 123', 'api repos/{owner}/{repo}'",
+ "type": "string"
+ }
+ },
+ "required": [
+ "args"
+ ],
+ "type": "object"
+ },
+ "handler": "gh.sh",
+ "env": {
+ "GH_AW_GH_TOKEN": "GH_AW_GH_TOKEN",
+ "GH_DEBUG": "GH_DEBUG"
+ },
+ "timeout": 60
+ },
+ {
+ "name": "github-discussion-query",
+ "description": "Query GitHub discussions with jq filtering support. Without --jq, returns schema and data size info. Use --jq '.' to get all data, or specific jq expressions to filter.",
+ "inputSchema": {
+ "properties": {
+ "jq": {
+ "description": "jq filter expression to apply to output. If not provided, returns schema info instead of full data.",
+ "type": "string"
+ },
+ "limit": {
+ "description": "Maximum number of discussions to fetch (default: 30)",
+ "type": "number"
+ },
+ "repo": {
+ "description": "Repository in owner/repo format (defaults to current repository)",
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "handler": "github-discussion-query.sh",
+ "env": {
+ "GH_TOKEN": "GH_TOKEN"
+ },
+ "timeout": 60
+ },
+ {
+ "name": "github-issue-query",
+ "description": "Query GitHub issues with jq filtering support. Without --jq, returns schema and data size info. Use --jq '.' to get all data, or specific jq expressions to filter.",
+ "inputSchema": {
+ "properties": {
+ "jq": {
+ "description": "jq filter expression to apply to output. If not provided, returns schema info instead of full data.",
+ "type": "string"
+ },
+ "limit": {
+ "description": "Maximum number of issues to fetch (default: 30)",
+ "type": "number"
+ },
+ "repo": {
+ "description": "Repository in owner/repo format (defaults to current repository)",
+ "type": "string"
+ },
+ "state": {
+ "description": "Issue state: open, closed, all (default: open)",
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "handler": "github-issue-query.sh",
+ "env": {
+ "GH_TOKEN": "GH_TOKEN"
+ },
+ "timeout": 60
+ },
+ {
+ "name": "github-pr-query",
+ "description": "Query GitHub pull requests with jq filtering support. Without --jq, returns schema and data size info. Use --jq '.' to get all data, or specific jq expressions to filter.",
+ "inputSchema": {
+ "properties": {
+ "jq": {
+ "description": "jq filter expression to apply to output. If not provided, returns schema info instead of full data.",
+ "type": "string"
+ },
+ "limit": {
+ "description": "Maximum number of PRs to fetch (default: 30)",
+ "type": "number"
+ },
+ "repo": {
+ "description": "Repository in owner/repo format (defaults to current repository)",
+ "type": "string"
+ },
+ "state": {
+ "description": "PR state: open, closed, merged, all (default: open)",
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "handler": "github-pr-query.sh",
+ "env": {
+ "GH_TOKEN": "GH_TOKEN"
+ },
+ "timeout": 60
+ }
+ ]
+ }
+ GH_AW_SAFE_INPUTS_TOOLS_EOF
+ cat > /opt/gh-aw/safe-inputs/mcp-server.cjs << 'GH_AW_SAFE_INPUTS_SERVER_EOF'
+ const path = require("path");
+ const { startHttpServer } = require("./safe_inputs_mcp_server_http.cjs");
+ const configPath = path.join(__dirname, "tools.json");
+ const port = parseInt(process.env.GH_AW_SAFE_INPUTS_PORT || "3000", 10);
+ const apiKey = process.env.GH_AW_SAFE_INPUTS_API_KEY || "";
+ startHttpServer(configPath, {
+ port: port,
+ stateless: true,
+ logDir: "/opt/gh-aw/safe-inputs/logs"
+ }).catch(error => {
+ console.error("Failed to start safe-inputs HTTP server:", error);
+ process.exit(1);
+ });
+ GH_AW_SAFE_INPUTS_SERVER_EOF
+ chmod +x /opt/gh-aw/safe-inputs/mcp-server.cjs
+
+ - name: Setup Safe Inputs Tool Files
+ run: |
+ cat > /opt/gh-aw/safe-inputs/gh.sh << 'GH_AW_SAFE_INPUTS_SH_GH_EOF'
+ #!/bin/bash
+ # Auto-generated safe-input tool: gh
+ # Execute any gh CLI command. This tool is accessible as 'safeinputs-gh'. Provide the full command after 'gh' (e.g., args: 'pr list --limit 5'). The tool will run: gh . Use single quotes ' for complex args to avoid shell interpretation issues.
+
+ set -euo pipefail
+
+ echo "gh $INPUT_ARGS"
+ echo " token: ${GH_AW_GH_TOKEN:0:6}..."
+ GH_TOKEN="$GH_AW_GH_TOKEN" gh $INPUT_ARGS
+
+ GH_AW_SAFE_INPUTS_SH_GH_EOF
+ chmod +x /opt/gh-aw/safe-inputs/gh.sh
+ cat > /opt/gh-aw/safe-inputs/github-discussion-query.sh << 'GH_AW_SAFE_INPUTS_SH_GITHUB-DISCUSSION-QUERY_EOF'
+ #!/bin/bash
+ # Auto-generated safe-input tool: github-discussion-query
+ # Query GitHub discussions with jq filtering support. Without --jq, returns schema and data size info. Use --jq '.' to get all data, or specific jq expressions to filter.
+
+ set -euo pipefail
+
+ set -e
+
+ # Default values
+ REPO="${INPUT_REPO:-}"
+ LIMIT="${INPUT_LIMIT:-30}"
+ JQ_FILTER="${INPUT_JQ:-}"
+
+ # Parse repository owner and name
+ if [[ -n "$REPO" ]]; then
+ OWNER=$(echo "$REPO" | cut -d'/' -f1)
+ NAME=$(echo "$REPO" | cut -d'/' -f2)
+ else
+ # Get current repository from GitHub context
+ OWNER="${GITHUB_REPOSITORY_OWNER:-}"
+ NAME=$(echo "${GITHUB_REPOSITORY:-}" | cut -d'/' -f2)
+ fi
+
+ # Validate owner and name
+ if [[ -z "$OWNER" || -z "$NAME" ]]; then
+ echo "Error: Could not determine repository owner and name" >&2
+ exit 1
+ fi
+
+ # Build GraphQL query for discussions
+ GRAPHQL_QUERY=$(cat < /opt/gh-aw/safe-inputs/github-issue-query.sh << 'GH_AW_SAFE_INPUTS_SH_GITHUB-ISSUE-QUERY_EOF'
+ #!/bin/bash
+ # Auto-generated safe-input tool: github-issue-query
+ # Query GitHub issues with jq filtering support. Without --jq, returns schema and data size info. Use --jq '.' to get all data, or specific jq expressions to filter.
+
+ set -euo pipefail
+
+ set -e
+
+ # Default values
+ REPO="${INPUT_REPO:-}"
+ STATE="${INPUT_STATE:-open}"
+ LIMIT="${INPUT_LIMIT:-30}"
+ JQ_FILTER="${INPUT_JQ:-}"
+
+ # JSON fields to fetch
+ JSON_FIELDS="number,title,state,author,createdAt,updatedAt,closedAt,body,labels,assignees,comments,milestone,url"
+
+ # Build and execute gh command
+ if [[ -n "$REPO" ]]; then
+ OUTPUT=$(gh issue list --state "$STATE" --limit "$LIMIT" --json "$JSON_FIELDS" --repo "$REPO")
+ else
+ OUTPUT=$(gh issue list --state "$STATE" --limit "$LIMIT" --json "$JSON_FIELDS")
+ fi
+
+ # Apply jq filter if specified
+ if [[ -n "$JQ_FILTER" ]]; then
+ jq "$JQ_FILTER" <<< "$OUTPUT"
+ else
+ # Return schema and size instead of full data
+ ITEM_COUNT=$(jq 'length' <<< "$OUTPUT")
+ DATA_SIZE=${#OUTPUT}
+
+ # Validate values are numeric
+ if ! [[ "$ITEM_COUNT" =~ ^[0-9]+$ ]]; then
+ ITEM_COUNT=0
+ fi
+ if ! [[ "$DATA_SIZE" =~ ^[0-9]+$ ]]; then
+ DATA_SIZE=0
+ fi
+
+ cat << EOF
+ {
+ "message": "No --jq filter provided. Use --jq to filter and retrieve data.",
+ "item_count": $ITEM_COUNT,
+ "data_size_bytes": $DATA_SIZE,
+ "schema": {
+ "type": "array",
+ "description": "Array of issue objects",
+ "item_fields": {
+ "number": "integer - Issue number",
+ "title": "string - Issue title",
+ "state": "string - Issue state (OPEN, CLOSED)",
+ "author": "object - Author info with login field",
+ "createdAt": "string - ISO timestamp of creation",
+ "updatedAt": "string - ISO timestamp of last update",
+ "closedAt": "string|null - ISO timestamp of close",
+ "body": "string - Issue body content",
+ "labels": "array - Array of label objects with name field",
+ "assignees": "array - Array of assignee objects with login field",
+ "comments": "object - Comments info with totalCount field",
+ "milestone": "object|null - Milestone info with title field",
+ "url": "string - Issue URL"
+ }
+ },
+ "suggested_queries": [
+ {"description": "Get all data", "query": "."},
+ {"description": "Get issue numbers and titles", "query": ".[] | {number, title}"},
+ {"description": "Get open issues only", "query": ".[] | select(.state == \"OPEN\")"},
+ {"description": "Get issues by author", "query": ".[] | select(.author.login == \"USERNAME\")"},
+ {"description": "Get issues with label", "query": ".[] | select(.labels | map(.name) | index(\"bug\"))"},
+ {"description": "Get issues with many comments", "query": ".[] | select(.comments.totalCount > 5) | {number, title, comments: .comments.totalCount}"},
+ {"description": "Count by state", "query": "group_by(.state) | map({state: .[0].state, count: length})"}
+ ]
+ }
+ EOF
+ fi
+
+
+ GH_AW_SAFE_INPUTS_SH_GITHUB-ISSUE-QUERY_EOF
+ chmod +x /opt/gh-aw/safe-inputs/github-issue-query.sh
+ cat > /opt/gh-aw/safe-inputs/github-pr-query.sh << 'GH_AW_SAFE_INPUTS_SH_GITHUB-PR-QUERY_EOF'
+ #!/bin/bash
+ # Auto-generated safe-input tool: github-pr-query
+ # Query GitHub pull requests with jq filtering support. Without --jq, returns schema and data size info. Use --jq '.' to get all data, or specific jq expressions to filter.
+
+ set -euo pipefail
+
+ set -e
+
+ # Default values
+ REPO="${INPUT_REPO:-}"
+ STATE="${INPUT_STATE:-open}"
+ LIMIT="${INPUT_LIMIT:-30}"
+ JQ_FILTER="${INPUT_JQ:-}"
+
+ # JSON fields to fetch
+ JSON_FIELDS="number,title,state,author,createdAt,updatedAt,mergedAt,closedAt,headRefName,baseRefName,isDraft,reviewDecision,additions,deletions,changedFiles,labels,assignees,reviewRequests,url"
+
+ # Build and execute gh command
+ if [[ -n "$REPO" ]]; then
+ OUTPUT=$(gh pr list --state "$STATE" --limit "$LIMIT" --json "$JSON_FIELDS" --repo "$REPO")
+ else
+ OUTPUT=$(gh pr list --state "$STATE" --limit "$LIMIT" --json "$JSON_FIELDS")
+ fi
+
+ # Apply jq filter if specified
+ if [[ -n "$JQ_FILTER" ]]; then
+ jq "$JQ_FILTER" <<< "$OUTPUT"
+ else
+ # Return schema and size instead of full data
+ ITEM_COUNT=$(jq 'length' <<< "$OUTPUT")
+ DATA_SIZE=${#OUTPUT}
+
+ # Validate values are numeric
+ if ! [[ "$ITEM_COUNT" =~ ^[0-9]+$ ]]; then
+ ITEM_COUNT=0
+ fi
+ if ! [[ "$DATA_SIZE" =~ ^[0-9]+$ ]]; then
+ DATA_SIZE=0
+ fi
+
+ cat << EOF
+ {
+ "message": "No --jq filter provided. Use --jq to filter and retrieve data.",
+ "item_count": $ITEM_COUNT,
+ "data_size_bytes": $DATA_SIZE,
+ "schema": {
+ "type": "array",
+ "description": "Array of pull request objects",
+ "item_fields": {
+ "number": "integer - PR number",
+ "title": "string - PR title",
+ "state": "string - PR state (OPEN, CLOSED, MERGED)",
+ "author": "object - Author info with login field",
+ "createdAt": "string - ISO timestamp of creation",
+ "updatedAt": "string - ISO timestamp of last update",
+ "mergedAt": "string|null - ISO timestamp of merge",
+ "closedAt": "string|null - ISO timestamp of close",
+ "headRefName": "string - Source branch name",
+ "baseRefName": "string - Target branch name",
+ "isDraft": "boolean - Whether PR is a draft",
+ "reviewDecision": "string|null - Review decision (APPROVED, CHANGES_REQUESTED, REVIEW_REQUIRED)",
+ "additions": "integer - Lines added",
+ "deletions": "integer - Lines deleted",
+ "changedFiles": "integer - Number of files changed",
+ "labels": "array - Array of label objects with name field",
+ "assignees": "array - Array of assignee objects with login field",
+ "reviewRequests": "array - Array of review request objects",
+ "url": "string - PR URL"
+ }
+ },
+ "suggested_queries": [
+ {"description": "Get all data", "query": "."},
+ {"description": "Get PR numbers and titles", "query": ".[] | {number, title}"},
+ {"description": "Get open PRs only", "query": ".[] | select(.state == \"OPEN\")"},
+ {"description": "Get merged PRs", "query": ".[] | select(.mergedAt != null)"},
+ {"description": "Get PRs by author", "query": ".[] | select(.author.login == \"USERNAME\")"},
+ {"description": "Get large PRs", "query": ".[] | select(.changedFiles > 10) | {number, title, changedFiles}"},
+ {"description": "Count by state", "query": "group_by(.state) | map({state: .[0].state, count: length})"}
+ ]
+ }
+ EOF
+ fi
+
+
+ GH_AW_SAFE_INPUTS_SH_GITHUB-PR-QUERY_EOF
+ chmod +x /opt/gh-aw/safe-inputs/github-pr-query.sh
+
+ - name: Generate Safe Inputs MCP Server Config
+ id: safe-inputs-config
+ run: |
+ # Generate a secure random API key (360 bits of entropy, 40+ chars)
+ # Mask immediately to prevent timing vulnerabilities
+ API_KEY=$(openssl rand -base64 45 | tr -d '/+=')
+ echo "::add-mask::${API_KEY}"
+
+ PORT=3000
+
+ # Set outputs for next steps
+ {
+ echo "safe_inputs_api_key=${API_KEY}"
+ echo "safe_inputs_port=${PORT}"
+ } >> "$GITHUB_OUTPUT"
+
+ echo "Safe Inputs MCP server will run on port ${PORT}"
+
+ - name: Start Safe Inputs MCP HTTP Server
+ id: safe-inputs-start
+ env:
+ DEBUG: '*'
+ GH_AW_SAFE_INPUTS_PORT: ${{ steps.safe-inputs-config.outputs.safe_inputs_port }}
+ GH_AW_SAFE_INPUTS_API_KEY: ${{ steps.safe-inputs-config.outputs.safe_inputs_api_key }}
+ GH_AW_GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ GH_DEBUG: 1
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ # Environment variables are set above to prevent template injection
+ export DEBUG
+ export GH_AW_SAFE_INPUTS_PORT
+ export GH_AW_SAFE_INPUTS_API_KEY
+
+ bash /opt/gh-aw/actions/start_safe_inputs_server.sh
+
+ - name: Start MCP Gateway
+ id: start-mcp-gateway
+ env:
+ GH_AW_GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ GH_AW_SAFE_INPUTS_API_KEY: ${{ steps.safe-inputs-start.outputs.api_key }}
+ GH_AW_SAFE_INPUTS_PORT: ${{ steps.safe-inputs-start.outputs.port }}
+ GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }}
+ GH_AW_SAFE_OUTPUTS_API_KEY: ${{ steps.safe-outputs-start.outputs.api_key }}
+ GH_AW_SAFE_OUTPUTS_PORT: ${{ steps.safe-outputs-start.outputs.port }}
+ GH_DEBUG: 1
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ GITHUB_MCP_LOCKDOWN: ${{ steps.determine-automatic-lockdown.outputs.lockdown == 'true' && '1' || '0' }}
+ GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
+ run: |
+ set -eo pipefail
+ mkdir -p /tmp/gh-aw/mcp-config
+
+ # Export gateway environment variables for MCP config and gateway script
+ export MCP_GATEWAY_PORT="80"
+ export MCP_GATEWAY_DOMAIN="host.docker.internal"
+ MCP_GATEWAY_API_KEY=$(openssl rand -base64 45 | tr -d '/+=')
+ echo "::add-mask::${MCP_GATEWAY_API_KEY}"
+ export MCP_GATEWAY_API_KEY
+ export MCP_GATEWAY_PAYLOAD_DIR="/tmp/gh-aw/mcp-payloads"
+ mkdir -p "${MCP_GATEWAY_PAYLOAD_DIR}"
+ export DEBUG="*"
+
+ export GH_AW_ENGINE="openclaw"
+ export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host -v /var/run/docker.sock:/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_LOCKDOWN -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e GH_AW_SAFE_INPUTS_PORT -e GH_AW_SAFE_INPUTS_API_KEY -e GH_AW_SAFE_OUTPUTS_PORT -e GH_AW_SAFE_OUTPUTS_API_KEY -e GH_AW_GH_TOKEN -e GH_DEBUG -e GH_TOKEN -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.1.4'
+
+ cat << GH_AW_MCP_CONFIG_EOF | bash /opt/gh-aw/actions/start_mcp_gateway.sh
+ {
+ "mcpServers": {
+ "github": {
+ "container": "ghcr.io/github/github-mcp-server:v0.30.3",
+ "env": {
+ "GITHUB_LOCKDOWN_MODE": "$GITHUB_MCP_LOCKDOWN",
+ "GITHUB_PERSONAL_ACCESS_TOKEN": "$GITHUB_MCP_SERVER_TOKEN",
+ "GITHUB_READ_ONLY": "1",
+ "GITHUB_TOOLSETS": "context,repos,issues,pull_requests"
+ }
+ },
+ "safeinputs": {
+ "type": "http",
+ "url": "http://host.docker.internal:$GH_AW_SAFE_INPUTS_PORT",
+ "headers": {
+ "Authorization": "$GH_AW_SAFE_INPUTS_API_KEY"
+ }
+ },
+ "safeoutputs": {
+ "type": "http",
+ "url": "http://host.docker.internal:$GH_AW_SAFE_OUTPUTS_PORT",
+ "headers": {
+ "Authorization": "$GH_AW_SAFE_OUTPUTS_API_KEY"
+ }
+ }
+ },
+ "gateway": {
+ "port": $MCP_GATEWAY_PORT,
+ "domain": "${MCP_GATEWAY_DOMAIN}",
+ "apiKey": "${MCP_GATEWAY_API_KEY}",
+ "payloadDir": "${MCP_GATEWAY_PAYLOAD_DIR}"
+ }
+ }
+ GH_AW_MCP_CONFIG_EOF
+ - name: Generate workflow overview
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ with:
+ script: |
+ const { generateWorkflowOverview } = require('/opt/gh-aw/actions/generate_workflow_overview.cjs');
+ await generateWorkflowOverview(core);
+ - name: Download prompt artifact
+ uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6
+ with:
+ name: prompt
+ path: /tmp/gh-aw/aw-prompts
+ - name: Clean git credentials
+ run: bash /opt/gh-aw/actions/clean_git_credentials.sh
+ - name: Run OpenClaw
+ timeout-minutes: 15
+ run: |
+ set -o pipefail
+ mkdir -p "$OPENCLAW_STATE_DIR"
+ sudo -E awf --env-all --container-workdir "${GITHUB_WORKSPACE}" --allow-domains '*.githubusercontent.com,api.anthropic.com,api.openai.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,codeload.github.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github-cloud.githubusercontent.com,github-cloud.s3.amazonaws.com,github.githubassets.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,lfs.github.com,objects.githubusercontent.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com' --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --enable-host-access --image-tag 0.19.1 --skip-pull \
+ -- /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 && INSTRUCTION="$(cat /tmp/gh-aw/aw-prompts/prompt.txt)" && openclaw agent --local --json --no-color --timeout 1200 --mcp-config /tmp/gh-aw/mcp-config/mcp-servers.json --message "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"${GH_AW_MODEL_AGENT_OPENCLAW:+ --agent "$GH_AW_MODEL_AGENT_OPENCLAW"}' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log
+ env:
+ ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
+ DISABLE_TELEMETRY: 1
+ GH_AW_MCP_CONFIG: /tmp/gh-aw/mcp-config/mcp-servers.json
+ GH_AW_MODEL_AGENT_OPENCLAW: ${{ vars.GH_AW_MODEL_AGENT_OPENCLAW || '' }}
+ GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
+ GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }}
+ GH_DEBUG: 1
+ GITHUB_WORKSPACE: ${{ github.workspace }}
+ OPENCLAW_API_KEY: ${{ secrets.OPENCLAW_API_KEY }}
+ OPENCLAW_STATE_DIR: /tmp/gh-aw/openclaw-state
+ - name: Configure Git credentials
+ env:
+ REPO_NAME: ${{ github.repository }}
+ SERVER_URL: ${{ github.server_url }}
+ run: |
+ git config --global user.email "github-actions[bot]@users.noreply.github.com"
+ git config --global user.name "github-actions[bot]"
+ # Re-authenticate git with GitHub token
+ SERVER_URL_STRIPPED="${SERVER_URL#https://}"
+ git remote set-url origin "https://x-access-token:${{ github.token }}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git"
+ echo "Git configured with standard GitHub Actions identity"
+ - name: Stop MCP Gateway
+ if: always()
+ continue-on-error: true
+ env:
+ MCP_GATEWAY_PORT: ${{ steps.start-mcp-gateway.outputs.gateway-port }}
+ MCP_GATEWAY_API_KEY: ${{ steps.start-mcp-gateway.outputs.gateway-api-key }}
+ GATEWAY_PID: ${{ steps.start-mcp-gateway.outputs.gateway-pid }}
+ run: |
+ bash /opt/gh-aw/actions/stop_mcp_gateway.sh "$GATEWAY_PID"
+ - name: Redact secrets in logs
+ if: always()
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ with:
+ script: |
+ const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io);
+ const { main } = require('/opt/gh-aw/actions/redact_secrets.cjs');
+ await main();
+ env:
+ GH_AW_SECRET_NAMES: 'ANTHROPIC_API_KEY,GH_AW_GITHUB_MCP_SERVER_TOKEN,GH_AW_GITHUB_TOKEN,GITHUB_TOKEN,OPENCLAW_API_KEY'
+ SECRET_ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
+ SECRET_GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }}
+ SECRET_GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }}
+ SECRET_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ SECRET_OPENCLAW_API_KEY: ${{ secrets.OPENCLAW_API_KEY }}
+ - name: Upload Safe Outputs
+ if: always()
+ uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
+ with:
+ name: safe-output
+ path: ${{ env.GH_AW_SAFE_OUTPUTS }}
+ if-no-files-found: warn
+ - name: Ingest agent output
+ id: collect_output
+ if: always()
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ env:
+ GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }}
+ GH_AW_ALLOWED_DOMAINS: "*.githubusercontent.com,api.anthropic.com,api.openai.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,codeload.github.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github-cloud.githubusercontent.com,github-cloud.s3.amazonaws.com,github.githubassets.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,lfs.github.com,objects.githubusercontent.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com"
+ GITHUB_SERVER_URL: ${{ github.server_url }}
+ GITHUB_API_URL: ${{ github.api_url }}
+ with:
+ script: |
+ const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io);
+ const { main } = require('/opt/gh-aw/actions/collect_ndjson_output.cjs');
+ await main();
+ - name: Upload sanitized agent output
+ if: always() && env.GH_AW_AGENT_OUTPUT
+ uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
+ with:
+ name: agent-output
+ path: ${{ env.GH_AW_AGENT_OUTPUT }}
+ if-no-files-found: warn
+ - name: Parse agent logs for step summary
+ if: always()
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ env:
+ GH_AW_AGENT_OUTPUT: /tmp/gh-aw/agent-stdio.log
+ with:
+ script: |
+ const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io);
+ const { main } = require('/opt/gh-aw/actions/parse_openclaw_log.cjs');
+ await main();
+ - name: Parse safe-inputs logs for step summary
+ if: always()
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ with:
+ script: |
+ const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io);
+ const { main } = require('/opt/gh-aw/actions/parse_safe_inputs_logs.cjs');
+ await main();
+ - name: Parse MCP Gateway logs for step summary
+ if: always()
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ with:
+ script: |
+ const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io);
+ const { main } = require('/opt/gh-aw/actions/parse_mcp_gateway_log.cjs');
+ await main();
+ - name: Upload agent artifacts
+ if: always()
+ continue-on-error: true
+ uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
+ with:
+ name: agent-artifacts
+ path: |
+ /tmp/gh-aw/aw-prompts/prompt.txt
+ /tmp/gh-aw/aw_info.json
+ /tmp/gh-aw/mcp-logs/
+ /tmp/gh-aw/safe-inputs/logs/
+ /tmp/gh-aw/agent-stdio.log
+ /tmp/gh-aw/agent/
+ if-no-files-found: ignore
+
+ conclusion:
+ needs:
+ - activation
+ - agent
+ - detection
+ - safe_outputs
+ if: (always()) && (needs.agent.result != 'skipped')
+ runs-on: ubuntu-slim
+ permissions:
+ contents: read
+ discussions: write
+ issues: write
+ pull-requests: write
+ outputs:
+ noop_message: ${{ steps.noop.outputs.noop_message }}
+ tools_reported: ${{ steps.missing_tool.outputs.tools_reported }}
+ total_count: ${{ steps.missing_tool.outputs.total_count }}
+ steps:
+ - name: Checkout actions folder
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ sparse-checkout: |
+ actions
+ persist-credentials: false
+ - name: Setup Scripts
+ uses: ./actions/setup
+ with:
+ destination: /opt/gh-aw/actions
+ - name: Download agent output artifact
+ continue-on-error: true
+ uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6
+ with:
+ name: agent-output
+ path: /tmp/gh-aw/safeoutputs/
+ - name: Setup agent output environment variable
+ run: |
+ mkdir -p /tmp/gh-aw/safeoutputs/
+ find "/tmp/gh-aw/safeoutputs/" -type f -print
+ echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV"
+ - name: Process No-Op Messages
+ id: noop
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ env:
+ GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }}
+ GH_AW_NOOP_MAX: 1
+ GH_AW_WORKFLOW_NAME: "Smoke OpenClaw"
+ with:
+ github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
+ script: |
+ const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io);
+ const { main } = require('/opt/gh-aw/actions/noop.cjs');
+ await main();
+ - name: Record Missing Tool
+ id: missing_tool
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ env:
+ GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }}
+ GH_AW_WORKFLOW_NAME: "Smoke OpenClaw"
+ with:
+ github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
+ script: |
+ const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io);
+ const { main } = require('/opt/gh-aw/actions/missing_tool.cjs');
+ await main();
+ - name: Handle Agent Failure
+ id: handle_agent_failure
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ env:
+ GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }}
+ GH_AW_WORKFLOW_NAME: "Smoke OpenClaw"
+ 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-openclaw"
+ GH_AW_SECRET_VERIFICATION_RESULT: ${{ needs.agent.outputs.secret_verification_result }}
+ GH_AW_CHECKOUT_PR_SUCCESS: ${{ needs.agent.outputs.checkout_pr_success }}
+ GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e 🦀 *[Mission Complete] — Powered by [{workflow_name}]({run_url})*\",\"runStarted\":\"🦀 **DEPLOY!** [{workflow_name}]({run_url}) activating for this {event_type}! *[Claw engaged...]*\",\"runSuccess\":\"🎯 **TARGET ACQUIRED** — [{workflow_name}]({run_url}) **ALL CLEAR!** Systems nominal! ✨\",\"runFailure\":\"⚠️ **RETREAT...** [{workflow_name}]({run_url}) {status}! The claw missed its target...\"}"
+ with:
+ github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
+ script: |
+ const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io);
+ const { main } = require('/opt/gh-aw/actions/handle_agent_failure.cjs');
+ await main();
+ - name: Handle No-Op Message
+ id: handle_noop_message
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ env:
+ GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }}
+ GH_AW_WORKFLOW_NAME: "Smoke OpenClaw"
+ GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
+ GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }}
+ GH_AW_NOOP_MESSAGE: ${{ steps.noop.outputs.noop_message }}
+ GH_AW_NOOP_REPORT_AS_ISSUE: "true"
+ with:
+ github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
+ script: |
+ const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io);
+ const { main } = require('/opt/gh-aw/actions/handle_noop_message.cjs');
+ await main();
+ - name: Update reaction comment with completion status
+ id: conclusion
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ env:
+ GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }}
+ GH_AW_COMMENT_ID: ${{ needs.activation.outputs.comment_id }}
+ GH_AW_COMMENT_REPO: ${{ needs.activation.outputs.comment_repo }}
+ GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
+ GH_AW_WORKFLOW_NAME: "Smoke OpenClaw"
+ GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }}
+ GH_AW_DETECTION_CONCLUSION: ${{ needs.detection.result }}
+ GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e 🦀 *[Mission Complete] — Powered by [{workflow_name}]({run_url})*\",\"runStarted\":\"🦀 **DEPLOY!** [{workflow_name}]({run_url}) activating for this {event_type}! *[Claw engaged...]*\",\"runSuccess\":\"🎯 **TARGET ACQUIRED** — [{workflow_name}]({run_url}) **ALL CLEAR!** Systems nominal! ✨\",\"runFailure\":\"⚠️ **RETREAT...** [{workflow_name}]({run_url}) {status}! The claw missed its target...\"}"
+ with:
+ github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
+ script: |
+ const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io);
+ const { main } = require('/opt/gh-aw/actions/notify_comment_error.cjs');
+ await main();
+
+ detection:
+ needs: agent
+ if: needs.agent.outputs.output_types != '' || needs.agent.outputs.has_patch == 'true'
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ timeout-minutes: 10
+ outputs:
+ success: ${{ steps.parse_results.outputs.success }}
+ steps:
+ - name: Checkout actions folder
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ sparse-checkout: |
+ actions
+ persist-credentials: false
+ - name: Setup Scripts
+ uses: ./actions/setup
+ with:
+ destination: /opt/gh-aw/actions
+ - name: Download agent artifacts
+ continue-on-error: true
+ uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6
+ with:
+ name: agent-artifacts
+ path: /tmp/gh-aw/threat-detection/
+ - name: Download agent output artifact
+ continue-on-error: true
+ uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6
+ with:
+ name: agent-output
+ path: /tmp/gh-aw/threat-detection/
+ - name: Echo agent output types
+ env:
+ AGENT_OUTPUT_TYPES: ${{ needs.agent.outputs.output_types }}
+ run: |
+ echo "Agent output-types: $AGENT_OUTPUT_TYPES"
+ - name: Setup threat detection
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ env:
+ WORKFLOW_NAME: "Smoke OpenClaw"
+ WORKFLOW_DESCRIPTION: "Smoke test workflow that validates OpenClaw engine functionality"
+ HAS_PATCH: ${{ needs.agent.outputs.has_patch }}
+ with:
+ script: |
+ const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io);
+ const { main } = require('/opt/gh-aw/actions/setup_threat_detection.cjs');
+ await main();
+ - name: Ensure threat-detection directory and log
+ run: |
+ mkdir -p /tmp/gh-aw/threat-detection
+ touch /tmp/gh-aw/threat-detection/detection.log
+ - name: Validate OPENCLAW_API_KEY or ANTHROPIC_API_KEY secret
+ id: validate-secret
+ run: /opt/gh-aw/actions/validate_multi_secret.sh OPENCLAW_API_KEY ANTHROPIC_API_KEY OpenClaw https://github.github.com/gh-aw/reference/engines/#openclaw
+ env:
+ OPENCLAW_API_KEY: ${{ secrets.OPENCLAW_API_KEY }}
+ ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
+ - name: Setup Node.js
+ uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
+ with:
+ node-version: '24'
+ package-manager-cache: false
+ - name: Install OpenClaw
+ run: npm install -g --silent openclaw@2026.2.15
+ - name: Run OpenClaw
+ timeout-minutes: 20
+ run: |
+ set -o pipefail
+ INSTRUCTION="$(cat "$GH_AW_PROMPT")"
+ mkdir -p "$OPENCLAW_STATE_DIR"
+ openclaw agent --local --json --no-color --timeout 1200 --message "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"${GH_AW_MODEL_DETECTION_OPENCLAW:+ --agent "$GH_AW_MODEL_DETECTION_OPENCLAW"} 2>&1 | tee /tmp/gh-aw/threat-detection/detection.log
+ env:
+ ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
+ DISABLE_TELEMETRY: 1
+ GH_AW_MODEL_DETECTION_OPENCLAW: ${{ vars.GH_AW_MODEL_DETECTION_OPENCLAW || '' }}
+ GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
+ GITHUB_WORKSPACE: ${{ github.workspace }}
+ OPENCLAW_API_KEY: ${{ secrets.OPENCLAW_API_KEY }}
+ OPENCLAW_STATE_DIR: /tmp/gh-aw/openclaw-state
+ - name: Parse threat detection results
+ id: parse_results
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ with:
+ script: |
+ const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io);
+ const { main } = require('/opt/gh-aw/actions/parse_threat_detection_results.cjs');
+ await main();
+ - name: Upload threat detection log
+ if: always()
+ uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
+ with:
+ name: threat-detection.log
+ path: /tmp/gh-aw/threat-detection/detection.log
+ if-no-files-found: ignore
+
+ pre_activation:
+ if: >
+ ((github.event_name != 'pull_request') || (github.event.pull_request.head.repo.id == github.repository_id)) &&
+ ((github.event_name != 'pull_request') || ((github.event.action != 'labeled') || (github.event.label.name == 'smoke')))
+ runs-on: ubuntu-slim
+ permissions:
+ contents: read
+ discussions: write
+ issues: write
+ pull-requests: write
+ outputs:
+ activated: ${{ steps.check_membership.outputs.is_team_member == 'true' }}
+ steps:
+ - name: Checkout actions folder
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ sparse-checkout: |
+ actions
+ persist-credentials: false
+ - name: Setup Scripts
+ uses: ./actions/setup
+ with:
+ destination: /opt/gh-aw/actions
+ - name: Add rocket reaction for immediate feedback
+ id: react
+ if: github.event_name == 'issues' || github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment' || github.event_name == 'discussion' || github.event_name == 'discussion_comment' || (github.event_name == 'pull_request') && (github.event.pull_request.head.repo.id == github.repository_id)
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ env:
+ GH_AW_REACTION: "rocket"
+ with:
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+ script: |
+ const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io);
+ const { main } = require('/opt/gh-aw/actions/add_reaction.cjs');
+ await main();
+ - name: Check team membership for workflow
+ id: check_membership
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ env:
+ GH_AW_REQUIRED_ROLES: admin,maintainer,write
+ with:
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+ script: |
+ const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io);
+ const { main } = require('/opt/gh-aw/actions/check_membership.cjs');
+ await main();
+
+ safe_outputs:
+ needs:
+ - agent
+ - detection
+ if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (needs.detection.outputs.success == 'true')
+ runs-on: ubuntu-slim
+ permissions:
+ contents: read
+ discussions: write
+ issues: write
+ pull-requests: write
+ timeout-minutes: 15
+ env:
+ GH_AW_ENGINE_ID: "openclaw"
+ GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e 🦀 *[Mission Complete] — Powered by [{workflow_name}]({run_url})*\",\"runStarted\":\"🦀 **DEPLOY!** [{workflow_name}]({run_url}) activating for this {event_type}! *[Claw engaged...]*\",\"runSuccess\":\"🎯 **TARGET ACQUIRED** — [{workflow_name}]({run_url}) **ALL CLEAR!** Systems nominal! ✨\",\"runFailure\":\"⚠️ **RETREAT...** [{workflow_name}]({run_url}) {status}! The claw missed its target...\"}"
+ GH_AW_WORKFLOW_ID: "smoke-openclaw"
+ GH_AW_WORKFLOW_NAME: "Smoke OpenClaw"
+ outputs:
+ create_discussion_error_count: ${{ steps.process_safe_outputs.outputs.create_discussion_error_count }}
+ create_discussion_errors: ${{ steps.process_safe_outputs.outputs.create_discussion_errors }}
+ process_safe_outputs_processed_count: ${{ steps.process_safe_outputs.outputs.processed_count }}
+ process_safe_outputs_temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }}
+ steps:
+ - name: Checkout actions folder
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ sparse-checkout: |
+ actions
+ persist-credentials: false
+ - name: Setup Scripts
+ uses: ./actions/setup
+ with:
+ destination: /opt/gh-aw/actions
+ - name: Download agent output artifact
+ continue-on-error: true
+ uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6
+ with:
+ name: agent-output
+ path: /tmp/gh-aw/safeoutputs/
+ - name: Setup agent output environment variable
+ run: |
+ mkdir -p /tmp/gh-aw/safeoutputs/
+ find "/tmp/gh-aw/safeoutputs/" -type f -print
+ echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV"
+ - name: Process Safe Outputs
+ id: process_safe_outputs
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ env:
+ GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }}
+ GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"add_comment\":{\"hide_older_comments\":true,\"max\":2},\"add_labels\":{\"allowed\":[\"smoke-openclaw\"]},\"create_issue\":{\"close_older_issues\":true,\"expires\":2,\"group\":true,\"max\":1},\"missing_data\":{},\"missing_tool\":{}}"
+ with:
+ github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
+ script: |
+ const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io);
+ const { main } = require('/opt/gh-aw/actions/safe_output_handler_manager.cjs');
+ await main();
+
diff --git a/.github/workflows/smoke-openclaw.md b/.github/workflows/smoke-openclaw.md
new file mode 100644
index 00000000000..8c92263cd70
--- /dev/null
+++ b/.github/workflows/smoke-openclaw.md
@@ -0,0 +1,88 @@
+---
+description: Smoke test workflow that validates OpenClaw engine functionality
+on:
+ schedule: daily
+ workflow_dispatch:
+ pull_request:
+ types: [labeled]
+ names: ["smoke"]
+ reaction: "rocket"
+ status-comment: true
+permissions:
+ contents: read
+ issues: read
+ pull-requests: read
+ discussions: read
+ actions: read
+
+name: Smoke OpenClaw
+engine:
+ id: openclaw
+strict: true
+features:
+ experimental-engines: true
+imports:
+ - shared/gh.md
+ - shared/github-queries-safe-input.md
+sandbox:
+ mcp:
+ container: "ghcr.io/github/gh-aw-mcpg"
+network:
+ allowed:
+ - defaults
+ - github
+tools:
+ github:
+ toolsets: [repos, pull_requests]
+ bash:
+ - "*"
+safe-outputs:
+ add-comment:
+ hide-older-comments: true
+ max: 2
+ create-issue:
+ expires: 2h
+ group: true
+ close-older-issues: true
+ add-labels:
+ allowed: [smoke-openclaw]
+ messages:
+ footer: "> 🦀 *[Mission Complete] — Powered by [{workflow_name}]({run_url})*"
+ run-started: "🦀 **DEPLOY!** [{workflow_name}]({run_url}) activating for this {event_type}! *[Claw engaged...]*"
+ run-success: "🎯 **TARGET ACQUIRED** — [{workflow_name}]({run_url}) **ALL CLEAR!** Systems nominal! ✨"
+ run-failure: "⚠️ **RETREAT...** [{workflow_name}]({run_url}) {status}! The claw missed its target..."
+timeout-minutes: 15
+---
+
+# Smoke Test: OpenClaw Engine Validation
+
+**IMPORTANT: Keep all outputs extremely short and concise. Use single-line responses where possible. No verbose explanations.**
+
+## Test Requirements
+
+1. **GitHub MCP Testing**: Review the last 2 merged pull requests in ${{ github.repository }}
+2. **Safe Inputs GH CLI Testing**: Use the `safeinputs-gh` tool to query 2 pull requests from ${{ github.repository }} (use args: "pr list --repo ${{ github.repository }} --limit 2 --json number,title,author")
+3. **File Writing Testing**: Create a test file `/tmp/gh-aw/agent/smoke-test-openclaw-${{ github.run_id }}.txt` with content "Smoke test passed for OpenClaw 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. **Discussion Interaction Testing**:
+ - Use the `github-discussion-query` safe-input tool with params: `limit=1, jq=".[0]"` to get the latest discussion from ${{ github.repository }}
+ - Extract the discussion number from the result (e.g., if the result is `{"number": 123, "title": "...", ...}`, extract 123)
+ - Use the `add_comment` tool with `discussion_number: ` to add a brief comment stating that the OpenClaw smoke test agent was here
+
+## Output
+
+1. **Create an issue** with a summary of the smoke test run:
+ - Title: "Smoke Test: OpenClaw - ${{ github.run_id }}"
+ - Body should include:
+ - Test results (✅ or ❌ for each test)
+ - Overall status: PASS or FAIL
+ - Run URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
+ - Timestamp
+
+2. 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
+
+3. Use the `add_comment` tool to add a brief comment to the latest discussion (using the `discussion_number` you extracted in step 5)
+
+If all tests pass, add the label `smoke-openclaw` to the pull request.
diff --git a/actions/setup/js/parse_openclaw_log.cjs b/actions/setup/js/parse_openclaw_log.cjs
new file mode 100644
index 00000000000..5ecbd187ff5
--- /dev/null
+++ b/actions/setup/js/parse_openclaw_log.cjs
@@ -0,0 +1,243 @@
+// @ts-check
+///
+
+const { createEngineLogParser, truncateString, estimateTokens, formatToolCallAsDetails } = require("./log_parser_shared.cjs");
+
+const main = createEngineLogParser({
+ parserName: "OpenClaw",
+ parseFunction: parseOpenClawLog,
+ supportsDirectories: false,
+});
+
+/**
+ * Parse OpenClaw log content and format as markdown
+ * OpenClaw's --json flag produces structured JSON output (one JSON object per line)
+ * @param {string} logContent - The raw log content to parse
+ * @returns {{markdown: string, logEntries: Array, mcpFailures: Array, maxTurnsHit: boolean}} Parsed log data
+ */
+function parseOpenClawLog(logContent) {
+ if (!logContent) {
+ return {
+ markdown: "## Agent Log Summary\n\nNo log content provided.\n\n",
+ logEntries: [],
+ mcpFailures: [],
+ maxTurnsHit: false,
+ };
+ }
+
+ const lines = logContent.split("\n");
+ let markdown = "";
+ const logEntries = [];
+ const mcpFailures = [];
+ let toolCallCount = 0;
+ let totalTokens = 0;
+
+ markdown += "## 🤖 Reasoning\n\n";
+
+ // Parse each line as potential JSON
+ for (const line of lines) {
+ const trimmed = line.trim();
+ if (!trimmed) continue;
+
+ // Try to parse as JSON
+ let entry;
+ try {
+ entry = JSON.parse(trimmed);
+ } catch {
+ // Not JSON - could be plain text output
+ // Skip metadata/debug lines
+ if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
+ continue; // Malformed JSON, skip
+ }
+ // Include non-JSON text in reasoning if substantive
+ if (trimmed.length > 20 && !trimmed.match(/^\d{4}-\d{2}-\d{2}T/)) {
+ markdown += `${trimmed}\n\n`;
+ }
+ continue;
+ }
+
+ // Process structured JSON entries
+ if (!entry || typeof entry !== "object") continue;
+
+ const entryType = entry.type || entry.event || "";
+
+ switch (entryType) {
+ case "message":
+ case "thinking":
+ case "reasoning": {
+ const content = entry.content || entry.text || entry.message || "";
+ if (content && content.length > 10) {
+ markdown += `${truncateString(content, 500)}\n\n`;
+ logEntries.push({
+ type: "assistant",
+ message: {
+ content: [{ type: "text", text: content }],
+ },
+ });
+ }
+ break;
+ }
+
+ case "tool_call":
+ case "tool_use": {
+ toolCallCount++;
+ const toolName = entry.name || entry.tool || entry.function || "unknown";
+ const params = entry.input || entry.arguments || entry.params || {};
+ const paramsStr = typeof params === "string" ? params : JSON.stringify(params, null, 2);
+
+ markdown += formatOpenClawToolCall(toolName, paramsStr, "", "⏳");
+
+ const toolUseId = `tool_${logEntries.length}`;
+ logEntries.push({
+ type: "assistant",
+ message: {
+ content: [
+ {
+ type: "tool_use",
+ id: toolUseId,
+ name: toolName,
+ input: typeof params === "string" ? { params } : params,
+ },
+ ],
+ },
+ });
+ break;
+ }
+
+ case "tool_result": {
+ const result = entry.result || entry.output || entry.content || "";
+ const resultStr = typeof result === "string" ? result : JSON.stringify(result, null, 2);
+ const isError = entry.is_error || entry.error || false;
+ const toolUseId = `tool_result_${logEntries.length}`;
+
+ logEntries.push({
+ type: "user",
+ message: {
+ content: [
+ {
+ type: "tool_result",
+ tool_use_id: toolUseId,
+ content: resultStr,
+ is_error: isError,
+ },
+ ],
+ },
+ });
+ break;
+ }
+
+ case "error": {
+ const errorMsg = entry.message || entry.error || JSON.stringify(entry);
+ markdown += `❌ **Error:** ${truncateString(errorMsg, 200)}\n\n`;
+ break;
+ }
+
+ case "mcp_init":
+ case "mcp_connected": {
+ const serverName = entry.server || entry.name || "";
+ if (serverName) {
+ markdown += `✅ MCP Server connected: **${serverName}**\n\n`;
+ }
+ break;
+ }
+
+ case "mcp_error":
+ case "mcp_failed": {
+ const serverName = entry.server || entry.name || "";
+ const error = entry.error || entry.message || "";
+ if (serverName) {
+ mcpFailures.push(serverName);
+ markdown += `❌ MCP Server failed: **${serverName}** - ${error}\n\n`;
+ }
+ break;
+ }
+
+ case "usage":
+ case "token_usage": {
+ const tokens = entry.total_tokens || entry.tokens || 0;
+ if (tokens > totalTokens) {
+ totalTokens = tokens;
+ }
+ break;
+ }
+
+ default:
+ // Skip unknown entry types
+ break;
+ }
+ }
+
+ // Add commands and tools section
+ markdown += "## 🤖 Commands and Tools\n\n";
+ if (toolCallCount > 0) {
+ markdown += `**Tool Calls:** ${toolCallCount}\n\n`;
+ } else {
+ markdown += "No tool calls detected.\n\n";
+ }
+
+ // Add information section
+ markdown += "## 📊 Information\n\n";
+ if (totalTokens > 0) {
+ markdown += `**Total Tokens Used:** ${totalTokens.toLocaleString()}\n\n`;
+ }
+
+ return {
+ markdown,
+ logEntries,
+ mcpFailures,
+ maxTurnsHit: false, // OpenClaw uses timeout, not max-turns
+ };
+}
+
+/**
+ * Format an OpenClaw tool call with HTML details
+ * @param {string} toolName - The tool name
+ * @param {string} params - The parameters as JSON string
+ * @param {string} response - The response as JSON string
+ * @param {string} statusIcon - The status icon
+ * @returns {string} Formatted HTML details string
+ */
+function formatOpenClawToolCall(toolName, params, response, statusIcon) {
+ const totalTokens = estimateTokens(params) + estimateTokens(response);
+
+ let metadata = "";
+ if (totalTokens > 0) {
+ metadata = `~${totalTokens}t`;
+ }
+
+ const summary = `${toolName}`;
+
+ const sections = [];
+
+ if (params && params.trim()) {
+ sections.push({
+ label: "Parameters",
+ content: params,
+ language: "json",
+ });
+ }
+
+ if (response && response.trim()) {
+ sections.push({
+ label: "Response",
+ content: response,
+ language: "json",
+ });
+ }
+
+ return formatToolCallAsDetails({
+ summary,
+ statusIcon,
+ metadata,
+ sections,
+ });
+}
+
+// Export for testing
+if (typeof module !== "undefined" && module.exports) {
+ module.exports = {
+ main,
+ parseOpenClawLog,
+ formatOpenClawToolCall,
+ };
+}
diff --git a/actions/setup/js/parse_openclaw_log.test.cjs b/actions/setup/js/parse_openclaw_log.test.cjs
new file mode 100644
index 00000000000..eae5bf9e13e
--- /dev/null
+++ b/actions/setup/js/parse_openclaw_log.test.cjs
@@ -0,0 +1,420 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
+import fs from "fs";
+import path from "path";
+
+describe("parse_openclaw_log.cjs", () => {
+ let mockCore, originalConsole, originalProcess;
+ let main, parseOpenClawLog, formatOpenClawToolCall;
+
+ beforeEach(async () => {
+ originalConsole = global.console;
+ originalProcess = { ...process };
+ global.console = { log: vi.fn(), error: vi.fn() };
+
+ mockCore = {
+ debug: vi.fn(),
+ info: vi.fn(),
+ notice: vi.fn(),
+ warning: vi.fn(),
+ error: vi.fn(),
+ setFailed: vi.fn(),
+ setOutput: vi.fn(),
+ exportVariable: vi.fn(),
+ setSecret: vi.fn(),
+ getInput: vi.fn(),
+ getBooleanInput: vi.fn(),
+ getMultilineInput: vi.fn(),
+ getState: vi.fn(),
+ saveState: vi.fn(),
+ startGroup: vi.fn(),
+ endGroup: vi.fn(),
+ group: vi.fn(),
+ addPath: vi.fn(),
+ setCommandEcho: vi.fn(),
+ isDebug: vi.fn().mockReturnValue(false),
+ getIDToken: vi.fn(),
+ toPlatformPath: vi.fn(),
+ toPosixPath: vi.fn(),
+ toWin32Path: vi.fn(),
+ summary: { addRaw: vi.fn().mockReturnThis(), write: vi.fn().mockResolvedValue() },
+ };
+
+ global.core = mockCore;
+
+ // Import the module to get the exported functions
+ const module = await import("./parse_openclaw_log.cjs?" + Date.now());
+ main = module.main;
+ parseOpenClawLog = module.parseOpenClawLog;
+ formatOpenClawToolCall = module.formatOpenClawToolCall;
+ });
+
+ afterEach(() => {
+ delete process.env.GH_AW_AGENT_OUTPUT;
+ global.console = originalConsole;
+ process.env = originalProcess.env;
+ delete global.core;
+ });
+
+ const runScript = async logContent => {
+ const tempFile = path.join(process.cwd(), `test_openclaw_log_${Date.now()}.txt`);
+ fs.writeFileSync(tempFile, logContent);
+ process.env.GH_AW_AGENT_OUTPUT = tempFile;
+ try {
+ await main();
+ } finally {
+ if (fs.existsSync(tempFile)) {
+ fs.unlinkSync(tempFile);
+ }
+ }
+ };
+
+ describe("parseOpenClawLog function", () => {
+ it("should handle empty log content", () => {
+ const result = parseOpenClawLog("");
+ expect(result.markdown).toContain("No log content provided");
+ expect(result.logEntries).toEqual([]);
+ expect(result.mcpFailures).toEqual([]);
+ expect(result.maxTurnsHit).toBe(false);
+ });
+
+ it("should handle null log content", () => {
+ const result = parseOpenClawLog(null);
+ expect(result.markdown).toContain("No log content provided");
+ expect(result.logEntries).toEqual([]);
+ });
+
+ it("should parse tool_call JSON entries", () => {
+ const logContent = '{"type":"tool_call","name":"bash","input":{"command":"echo hello"}}\n{"type":"tool_result","result":"hello"}';
+ const result = parseOpenClawLog(logContent);
+
+ expect(result.markdown).toContain("bash");
+ expect(result.markdown).toContain("Tool Calls");
+ expect(result.logEntries.length).toBeGreaterThan(0);
+ });
+
+ it("should parse tool_use JSON entries", () => {
+ const logContent = '{"type":"tool_use","name":"github_search","input":{"query":"test"}}';
+ const result = parseOpenClawLog(logContent);
+
+ expect(result.markdown).toContain("github_search");
+ expect(result.markdown).toContain("Tool Calls:** 1");
+ });
+
+ it("should parse message/reasoning entries", () => {
+ const logContent = '{"type":"message","content":"I will analyze the repository structure to understand the codebase."}';
+ const result = parseOpenClawLog(logContent);
+
+ expect(result.markdown).toContain("analyze the repository");
+ expect(result.logEntries.length).toBe(1);
+ expect(result.logEntries[0].type).toBe("assistant");
+ });
+
+ it("should parse thinking entries", () => {
+ const logContent = '{"type":"thinking","text":"Let me consider the approach carefully before proceeding."}';
+ const result = parseOpenClawLog(logContent);
+
+ expect(result.markdown).toContain("consider the approach");
+ });
+
+ it("should parse reasoning entries", () => {
+ const logContent = '{"type":"reasoning","message":"The best approach is to use the existing API."}';
+ const result = parseOpenClawLog(logContent);
+
+ expect(result.markdown).toContain("best approach");
+ });
+
+ it("should skip short messages (10 chars or less)", () => {
+ const logContent = '{"type":"message","content":"ok"}';
+ const result = parseOpenClawLog(logContent);
+
+ // Short messages are skipped
+ expect(result.logEntries.length).toBe(0);
+ });
+
+ it("should handle error entries", () => {
+ const logContent = '{"type":"error","message":"Connection timed out after 30s"}';
+ const result = parseOpenClawLog(logContent);
+
+ expect(result.markdown).toContain("Error");
+ expect(result.markdown).toContain("Connection timed out");
+ });
+
+ it("should handle error entries with error field", () => {
+ const logContent = '{"type":"error","error":"Invalid API key"}';
+ const result = parseOpenClawLog(logContent);
+
+ expect(result.markdown).toContain("Invalid API key");
+ });
+
+ it("should track MCP server connections", () => {
+ const logContent = '{"type":"mcp_connected","server":"github","name":"github"}\n{"type":"mcp_init","name":"safeoutputs"}';
+ const result = parseOpenClawLog(logContent);
+
+ expect(result.markdown).toContain("MCP Server connected");
+ expect(result.markdown).toContain("github");
+ expect(result.mcpFailures).toEqual([]);
+ });
+
+ it("should track MCP server failures", () => {
+ const logContent = '{"type":"mcp_failed","name":"broken_server","error":"Connection refused"}\n{"type":"mcp_error","server":"another_broken","error":"Timeout"}';
+ const result = parseOpenClawLog(logContent);
+
+ expect(result.markdown).toContain("MCP Server failed");
+ expect(result.markdown).toContain("broken_server");
+ expect(result.mcpFailures).toContain("broken_server");
+ expect(result.mcpFailures).toContain("another_broken");
+ });
+
+ it("should track token usage", () => {
+ const logContent = '{"type":"usage","total_tokens":1500}\n{"type":"token_usage","tokens":2000}';
+ const result = parseOpenClawLog(logContent);
+
+ expect(result.markdown).toContain("Total Tokens Used");
+ expect(result.markdown).toContain("2,000");
+ });
+
+ it("should use highest token count", () => {
+ const logContent = '{"type":"usage","total_tokens":500}\n{"type":"usage","total_tokens":1000}\n{"type":"usage","total_tokens":800}';
+ const result = parseOpenClawLog(logContent);
+
+ expect(result.markdown).toContain("1,000");
+ });
+
+ it("should handle tool_result entries", () => {
+ const logContent = '{"type":"tool_call","name":"bash","input":{"command":"ls"}}\n{"type":"tool_result","result":"file1.txt\\nfile2.txt"}';
+ const result = parseOpenClawLog(logContent);
+
+ expect(result.logEntries.length).toBe(2);
+ expect(result.logEntries[1].type).toBe("user");
+ expect(result.logEntries[1].message.content[0].type).toBe("tool_result");
+ });
+
+ it("should handle tool_result with error flag", () => {
+ const logContent = '{"type":"tool_result","result":"Permission denied","is_error":true}';
+ const result = parseOpenClawLog(logContent);
+
+ const toolResultEntry = result.logEntries.find(e => e.type === "user");
+ expect(toolResultEntry.message.content[0].is_error).toBe(true);
+ });
+
+ it("should handle tool_result with output field", () => {
+ const logContent = '{"type":"tool_result","output":"some output data"}';
+ const result = parseOpenClawLog(logContent);
+
+ expect(result.logEntries.length).toBe(1);
+ expect(result.logEntries[0].message.content[0].content).toBe("some output data");
+ });
+
+ it("should handle tool_result with object result", () => {
+ const logContent = '{"type":"tool_result","result":{"status":"ok","data":[1,2,3]}}';
+ const result = parseOpenClawLog(logContent);
+
+ expect(result.logEntries.length).toBe(1);
+ const content = result.logEntries[0].message.content[0].content;
+ expect(content).toContain("status");
+ });
+
+ it("should skip malformed JSON lines that start with { or [", () => {
+ const logContent = '{broken json\n[also broken\n{"type":"message","content":"This is a valid longer message entry"}';
+ const result = parseOpenClawLog(logContent);
+
+ // Only the valid JSON should be parsed
+ expect(result.logEntries.length).toBe(1);
+ });
+
+ it("should include substantive non-JSON text in reasoning", () => {
+ const logContent = 'This is a substantive text output from the agent that should be included\n{"type":"message","content":"And here is a JSON message with more content"}';
+ const result = parseOpenClawLog(logContent);
+
+ expect(result.markdown).toContain("substantive text output");
+ });
+
+ it("should skip timestamp-like non-JSON lines", () => {
+ const logContent = '2024-01-15T10:30:00Z some log message\n{"type":"message","content":"This is the actual content from the agent"}';
+ const result = parseOpenClawLog(logContent);
+
+ expect(result.markdown).not.toContain("2024-01-15");
+ });
+
+ it("should skip short non-JSON lines (20 chars or less)", () => {
+ const logContent = 'debug info\n{"type":"message","content":"Longer actual content from the agent for testing"}';
+ const result = parseOpenClawLog(logContent);
+
+ expect(result.markdown).not.toContain("debug info");
+ });
+
+ it("should handle multiple tool calls and count them", () => {
+ const logContent = ['{"type":"tool_call","name":"bash","input":{"command":"ls"}}', '{"type":"tool_call","name":"github_search","input":{"query":"test"}}', '{"type":"tool_call","name":"bash","input":{"command":"cat file.txt"}}'].join(
+ "\n"
+ );
+ const result = parseOpenClawLog(logContent);
+
+ expect(result.markdown).toContain("Tool Calls:** 3");
+ });
+
+ it("should show no tool calls message when none are present", () => {
+ const logContent = '{"type":"message","content":"I analyzed the situation and came to a conclusion without tools"}';
+ const result = parseOpenClawLog(logContent);
+
+ expect(result.markdown).toContain("No tool calls detected");
+ });
+
+ it("should handle tool_call with function field for name", () => {
+ const logContent = '{"type":"tool_call","function":"my_function","arguments":{"arg1":"value1"}}';
+ const result = parseOpenClawLog(logContent);
+
+ expect(result.markdown).toContain("my_function");
+ });
+
+ it("should handle tool_call with tool field for name", () => {
+ const logContent = '{"type":"tool_call","tool":"my_tool","params":{"key":"value"}}';
+ const result = parseOpenClawLog(logContent);
+
+ expect(result.markdown).toContain("my_tool");
+ });
+
+ it("should handle tool_call with string params", () => {
+ const logContent = '{"type":"tool_call","name":"eval","input":"print(42)"}';
+ const result = parseOpenClawLog(logContent);
+
+ expect(result.logEntries.length).toBe(1);
+ const toolUse = result.logEntries[0].message.content[0];
+ expect(toolUse.input).toEqual({ params: "print(42)" });
+ });
+
+ it("should handle unknown entry types gracefully", () => {
+ const logContent = '{"type":"unknown_type","data":"some data"}\n{"type":"message","content":"This is valid content from the agent"}';
+ const result = parseOpenClawLog(logContent);
+
+ // Should not crash, just skip unknown types
+ expect(result.logEntries.length).toBe(1);
+ });
+
+ it("should handle entry with event field instead of type", () => {
+ const logContent = '{"event":"tool_call","name":"test_tool","input":{"x":1}}';
+ const result = parseOpenClawLog(logContent);
+
+ expect(result.markdown).toContain("test_tool");
+ });
+
+ it("should always return maxTurnsHit as false", () => {
+ const logContent = '{"type":"message","content":"OpenClaw uses timeout-based execution, not turn-based limiting"}';
+ const result = parseOpenClawLog(logContent);
+
+ // OpenClaw uses timeout, not max-turns
+ expect(result.maxTurnsHit).toBe(false);
+ });
+ });
+
+ describe("formatOpenClawToolCall function", () => {
+ it("should format a basic tool call", () => {
+ const result = formatOpenClawToolCall("bash", '{"command":"ls -la"}', "", "⏳");
+
+ expect(result).toContain("bash");
+ expect(result).toContain("Parameters");
+ });
+
+ it("should include response when provided", () => {
+ const result = formatOpenClawToolCall("test_tool", '{"arg":"value"}', '{"result":"ok"}', "✅");
+
+ expect(result).toContain("test_tool");
+ expect(result).toContain("Parameters");
+ expect(result).toContain("Response");
+ });
+
+ it("should include token estimate", () => {
+ const result = formatOpenClawToolCall("tool", '{"data":"test"}', "", "⏳");
+
+ expect(result).toContain("~");
+ expect(result).toContain("t");
+ });
+
+ it("should handle empty params", () => {
+ const result = formatOpenClawToolCall("tool", "", "", "✅");
+
+ // No parameters section when params is empty
+ expect(result).not.toContain("Parameters");
+ });
+ });
+
+ describe("main function integration", () => {
+ it("should handle valid log file with tool calls", async () => {
+ const validLog = [
+ '{"type":"message","content":"I will analyze the repository and find relevant files for you."}',
+ '{"type":"tool_call","name":"bash","input":{"command":"ls -la"}}',
+ '{"type":"tool_result","result":"file1.txt\\nfile2.txt"}',
+ '{"type":"usage","total_tokens":500}',
+ ].join("\n");
+
+ await runScript(validLog);
+
+ expect(mockCore.summary.addRaw).toHaveBeenCalled();
+ expect(mockCore.summary.write).toHaveBeenCalled();
+ expect(mockCore.setFailed).not.toHaveBeenCalled();
+ });
+
+ it("should handle log with MCP failures", async () => {
+ const logWithFailures = ['{"type":"mcp_failed","name":"broken_server","error":"Connection refused"}', '{"type":"message","content":"I encountered an error connecting to the MCP server."}'].join("\n");
+
+ await runScript(logWithFailures);
+
+ expect(mockCore.summary.addRaw).toHaveBeenCalled();
+ expect(mockCore.summary.write).toHaveBeenCalled();
+ expect(mockCore.setFailed).toHaveBeenCalledWith("MCP server(s) failed to launch: broken_server");
+ });
+
+ it("should handle multiple MCP failures", async () => {
+ const logWithMultipleFailures = ['{"type":"mcp_failed","name":"server1","error":"Timeout"}', '{"type":"mcp_error","name":"server2","error":"Auth failed"}'].join("\n");
+
+ await runScript(logWithMultipleFailures);
+
+ expect(mockCore.setFailed).toHaveBeenCalledWith("MCP server(s) failed to launch: server1, server2");
+ });
+
+ it("should handle missing log file", async () => {
+ process.env.GH_AW_AGENT_OUTPUT = "/nonexistent/file.log";
+ await main();
+ expect(mockCore.info).toHaveBeenCalledWith("Log path not found: /nonexistent/file.log");
+ expect(mockCore.setFailed).not.toHaveBeenCalled();
+ });
+
+ it("should handle missing environment variable", async () => {
+ delete process.env.GH_AW_AGENT_OUTPUT;
+ await main();
+ expect(mockCore.info).toHaveBeenCalledWith("No agent log file specified");
+ expect(mockCore.setFailed).not.toHaveBeenCalled();
+ });
+
+ it("should handle log with only non-JSON content", async () => {
+ await runScript("plain text output from the agent");
+
+ // OpenClaw parser should handle plain text gracefully (unlike Claude which requires structured entries)
+ expect(mockCore.summary.addRaw).toHaveBeenCalled();
+ expect(mockCore.summary.write).toHaveBeenCalled();
+ });
+
+ it("should handle mixed JSON and non-JSON content", async () => {
+ const mixedLog = [
+ "Starting OpenClaw agent execution...",
+ '{"type":"message","content":"I will help you with the task at hand by analyzing the code."}',
+ "Processing request...",
+ '{"type":"tool_call","name":"bash","input":{"command":"echo test"}}',
+ '{"type":"tool_result","result":"test"}',
+ "Completed successfully",
+ ].join("\n");
+
+ await runScript(mixedLog);
+
+ expect(mockCore.summary.addRaw).toHaveBeenCalled();
+ expect(mockCore.summary.write).toHaveBeenCalled();
+ expect(mockCore.setFailed).not.toHaveBeenCalled();
+ });
+
+ it("should handle empty log file", async () => {
+ await runScript("");
+
+ expect(mockCore.summary.addRaw).toHaveBeenCalled();
+ expect(mockCore.summary.write).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/pkg/cli/completions_test.go b/pkg/cli/completions_test.go
index 2bc41e79713..12125f0718d 100644
--- a/pkg/cli/completions_test.go
+++ b/pkg/cli/completions_test.go
@@ -245,7 +245,7 @@ func TestCompleteEngineNames(t *testing.T) {
{
name: "empty prefix returns all engines",
toComplete: "",
- wantLen: 5, // copilot, copilot-sdk, claude, codex, custom
+ wantLen: 6, // copilot, copilot-sdk, claude, codex, custom, openclaw
},
{
name: "c prefix returns claude, codex, copilot, copilot-sdk, custom",
diff --git a/pkg/cli/workflows/test-openclaw-code-quality.md b/pkg/cli/workflows/test-openclaw-code-quality.md
new file mode 100644
index 00000000000..97bfbcede00
--- /dev/null
+++ b/pkg/cli/workflows/test-openclaw-code-quality.md
@@ -0,0 +1,38 @@
+---
+on:
+ workflow_dispatch:
+permissions:
+ contents: read
+ issues: read
+ pull-requests: read
+engine:
+ id: openclaw
+ model: default-agent
+network:
+ allowed:
+ - defaults
+ - github
+ - node
+tools:
+ github:
+ bash:
+ - "*"
+---
+
+# OpenClaw Code Quality Analysis
+
+Analyze repository code quality and provide a summary.
+
+## Instructions
+
+1. Explore the repository structure to understand the project layout
+2. Run available linting or static analysis tools if present (check package.json, Makefile, etc.)
+3. Review a sample of source files for:
+ - Code organization and modularity
+ - Error handling patterns
+ - Test coverage presence
+ - Documentation quality
+4. Produce a brief summary of findings covering:
+ - Overall code health assessment
+ - Areas that could benefit from improvement
+ - Positive patterns observed
diff --git a/pkg/cli/workflows/test-openclaw-issue-triage.md b/pkg/cli/workflows/test-openclaw-issue-triage.md
new file mode 100644
index 00000000000..930eb7f0f93
--- /dev/null
+++ b/pkg/cli/workflows/test-openclaw-issue-triage.md
@@ -0,0 +1,39 @@
+---
+on:
+ issues:
+ types: [opened]
+permissions:
+ contents: read
+ issues: read
+ pull-requests: read
+engine:
+ id: openclaw
+ args: ["--thinking", "high"]
+network:
+ allowed:
+ - defaults
+ - github
+tools:
+ github:
+safe-outputs:
+ add-comment:
+ max: 1
+ add-labels:
+ allowed: [bug, enhancement, question, documentation, good-first-issue]
+---
+
+# OpenClaw Issue Triage
+
+Analyze newly opened issues and help categorize them.
+
+## Instructions
+
+1. Read the issue title and body carefully
+2. Determine the issue type:
+ - **bug**: Something is broken or not working as expected
+ - **enhancement**: A request for new functionality
+ - **question**: A question about usage or behavior
+ - **documentation**: Missing or incorrect documentation
+ - **good-first-issue**: Simple issues suitable for new contributors
+3. Apply the appropriate label(s) to the issue
+4. Post a brief comment acknowledging the issue and confirming the categorization
diff --git a/pkg/cli/workflows/test-openclaw-review.md b/pkg/cli/workflows/test-openclaw-review.md
new file mode 100644
index 00000000000..afc0d4045d9
--- /dev/null
+++ b/pkg/cli/workflows/test-openclaw-review.md
@@ -0,0 +1,37 @@
+---
+on:
+ pull_request:
+ types: [opened, synchronize]
+permissions:
+ contents: read
+ pull-requests: read
+ issues: read
+engine: openclaw
+network:
+ allowed:
+ - defaults
+ - github
+tools:
+ github:
+safe-outputs:
+ add-comment:
+ max: 1
+ hide-older-comments: true
+---
+
+# OpenClaw PR Review
+
+Review the pull request changes and post a summary comment.
+
+## Instructions
+
+1. Read the pull request diff to understand what changed
+2. Analyze the changes for:
+ - Code quality and correctness
+ - Potential bugs or issues
+ - Missing test coverage
+ - Style and convention adherence
+3. Post a concise review comment on the pull request summarizing:
+ - What the PR does (1-2 sentences)
+ - Key observations or concerns (bullet points)
+ - Suggested improvements if any
diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go
index 4ac77463f75..b10e09a414c 100644
--- a/pkg/constants/constants.go
+++ b/pkg/constants/constants.go
@@ -340,6 +340,10 @@ const (
EnvVarModelDetectionClaude = "GH_AW_MODEL_DETECTION_CLAUDE"
// EnvVarModelDetectionCodex configures the default Codex model for detection
EnvVarModelDetectionCodex = "GH_AW_MODEL_DETECTION_CODEX"
+ // EnvVarModelAgentOpenClaw configures the default OpenClaw model for agent execution
+ EnvVarModelAgentOpenClaw = "GH_AW_MODEL_AGENT_OPENCLAW"
+ // EnvVarModelDetectionOpenClaw configures the default OpenClaw model for detection
+ EnvVarModelDetectionOpenClaw = "GH_AW_MODEL_DETECTION_OPENCLAW"
// Common environment variable names used across all engines
@@ -368,6 +372,9 @@ const (
// DefaultCodexVersion is the default version of the OpenAI Codex CLI
const DefaultCodexVersion Version = "0.101.0"
+// DefaultOpenClawVersion is the default version of the OpenClaw CLI
+const DefaultOpenClawVersion Version = "2026.2.15"
+
// DefaultGitHubMCPServerVersion is the default version of the GitHub MCP server Docker image
const DefaultGitHubMCPServerVersion Version = "v0.30.3"
@@ -679,11 +686,13 @@ const (
CodexEngine EngineName = "codex"
// CustomEngine is the custom engine identifier
CustomEngine EngineName = "custom"
+ // OpenClawEngine is the OpenClaw engine identifier
+ OpenClawEngine EngineName = "openclaw"
)
// AgenticEngines lists all supported agentic engine names
// Note: This remains a string slice for backward compatibility with existing code
-var AgenticEngines = []string{string(ClaudeEngine), string(CodexEngine), string(CopilotEngine), string(CopilotSDKEngine)}
+var AgenticEngines = []string{string(ClaudeEngine), string(CodexEngine), string(CopilotEngine), string(CopilotSDKEngine), string(OpenClawEngine)}
// EngineOption represents a selectable AI engine with its display metadata and secret configuration
type EngineOption struct {
@@ -702,6 +711,7 @@ var EngineOptions = []EngineOption{
{string(CopilotSDKEngine), "GitHub Copilot SDK", "GitHub Copilot SDK with headless mode", "COPILOT_GITHUB_TOKEN", nil, "", ""},
{string(ClaudeEngine), "Claude", "Anthropic Claude Code coding agent", "ANTHROPIC_API_KEY", []string{"CLAUDE_CODE_OAUTH_TOKEN"}, "", "https://console.anthropic.com/settings/keys"},
{string(CodexEngine), "Codex", "OpenAI Codex/GPT engine", "OPENAI_API_KEY", []string{"CODEX_API_KEY"}, "", "https://platform.openai.com/api-keys"},
+ {string(OpenClawEngine), "OpenClaw", "OpenClaw agent platform with ACP support", "OPENCLAW_API_KEY", []string{"ANTHROPIC_API_KEY"}, "", ""},
}
// GetEngineOption returns the EngineOption for the given engine value, or nil if not found
diff --git a/pkg/constants/constants_test.go b/pkg/constants/constants_test.go
index 07388267b3e..e1ae2ec6893 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", "copilot-sdk"}
+ expectedEngines := []string{"claude", "codex", "copilot", "copilot-sdk", "openclaw"}
if len(AgenticEngines) != len(expectedEngines) {
t.Errorf("AgenticEngines length = %d, want %d", len(AgenticEngines), len(expectedEngines))
}
diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json
index 455d13da753..995f0121f63 100644
--- a/pkg/parser/schemas/main_workflow_schema.json
+++ b/pkg/parser/schemas/main_workflow_schema.json
@@ -6760,8 +6760,8 @@
"oneOf": [
{
"type": "string",
- "enum": ["claude", "codex", "copilot", "copilot-sdk", "custom"],
- "description": "Simple engine name: 'claude' (default, Claude Code), 'copilot' (GitHub Copilot CLI), 'copilot-sdk' (GitHub Copilot SDK), 'codex' (OpenAI Codex CLI), or 'custom' (user-defined steps)"
+ "enum": ["claude", "codex", "copilot", "copilot-sdk", "custom", "openclaw"],
+ "description": "Simple engine name: 'claude' (default, Claude Code), 'copilot' (GitHub Copilot CLI), 'copilot-sdk' (GitHub Copilot SDK), 'codex' (OpenAI Codex CLI), 'openclaw' (OpenClaw agent platform), or 'custom' (user-defined steps)"
},
{
"type": "object",
@@ -6769,8 +6769,8 @@
"properties": {
"id": {
"type": "string",
- "enum": ["claude", "codex", "custom", "copilot", "copilot-sdk"],
- "description": "AI engine identifier: 'claude' (Claude Code), 'codex' (OpenAI Codex CLI), 'copilot' (GitHub Copilot CLI), 'copilot-sdk' (GitHub Copilot SDK), or 'custom' (user-defined GitHub Actions steps)"
+ "enum": ["claude", "codex", "custom", "copilot", "copilot-sdk", "openclaw"],
+ "description": "AI engine identifier: 'claude' (Claude Code), 'codex' (OpenAI Codex CLI), 'copilot' (GitHub Copilot CLI), 'copilot-sdk' (GitHub Copilot SDK), 'openclaw' (OpenClaw agent platform), or 'custom' (user-defined GitHub Actions steps)"
},
"version": {
"type": ["string", "number"],
diff --git a/pkg/workflow/agentic_engine.go b/pkg/workflow/agentic_engine.go
index 72241b111fa..3156bd7c2c1 100644
--- a/pkg/workflow/agentic_engine.go
+++ b/pkg/workflow/agentic_engine.go
@@ -309,6 +309,7 @@ func NewEngineRegistry() *EngineRegistry {
registry.Register(NewCopilotEngine())
registry.Register(NewCopilotSDKEngine())
registry.Register(NewCustomEngine())
+ registry.Register(NewOpenClawEngine())
agenticEngineLog.Printf("Registered %d engines", len(registry.engines))
return registry
diff --git a/pkg/workflow/agentic_engine_test.go b/pkg/workflow/agentic_engine_test.go
index acd1723565f..46bd1f1d08a 100644
--- a/pkg/workflow/agentic_engine_test.go
+++ b/pkg/workflow/agentic_engine_test.go
@@ -11,8 +11,8 @@ func TestEngineRegistry(t *testing.T) {
// Test that built-in engines are registered
supportedEngines := registry.GetSupportedEngines()
- if len(supportedEngines) != 5 {
- t.Errorf("Expected 5 supported engines, got %d", len(supportedEngines))
+ if len(supportedEngines) != 6 {
+ t.Errorf("Expected 6 supported engines, got %d", len(supportedEngines))
}
// Test getting engines by ID
@@ -118,7 +118,7 @@ func TestEngineRegistryCustomEngine(t *testing.T) {
// Test that supported engines list is updated
supportedEngines := registry.GetSupportedEngines()
- if len(supportedEngines) != 6 {
- t.Errorf("Expected 6 supported engines after adding test-custom, got %d", len(supportedEngines))
+ if len(supportedEngines) != 7 {
+ t.Errorf("Expected 7 supported engines after adding test-custom, got %d", len(supportedEngines))
}
}
diff --git a/pkg/workflow/compiler_openclaw_test.go b/pkg/workflow/compiler_openclaw_test.go
new file mode 100644
index 00000000000..28d5db3d72e
--- /dev/null
+++ b/pkg/workflow/compiler_openclaw_test.go
@@ -0,0 +1,355 @@
+//go:build integration
+
+package workflow
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/github/gh-aw/pkg/stringutil"
+ "github.com/github/gh-aw/pkg/testutil"
+)
+
+func TestCompileOpenClawBasicWorkflow(t *testing.T) {
+ tmpDir := testutil.TempDir(t, "openclaw-compile-test")
+
+ testContent := `---
+on: push
+timeout-minutes: 10
+permissions:
+ contents: read
+ issues: write
+ pull-requests: read
+engine: openclaw
+strict: false
+features:
+ dangerous-permissions-write: true
+ experimental-engines: true
+tools:
+ github:
+ allowed: [list_issues, create_issue]
+ bash: ["echo", "ls"]
+---
+
+# OpenClaw Test Workflow
+
+This is a test workflow using the OpenClaw engine.
+`
+
+ testFile := filepath.Join(tmpDir, "openclaw-basic.md")
+ if err := os.WriteFile(testFile, []byte(testContent), 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ compiler := NewCompiler()
+ err := compiler.CompileWorkflow(testFile)
+ if err != nil {
+ t.Fatalf("Expected OpenClaw workflow to compile successfully, got error: %v", err)
+ }
+
+ // Verify lock file was created
+ lockFile := stringutil.MarkdownToLockFile(testFile)
+ lockContent, err := os.ReadFile(lockFile)
+ if err != nil {
+ t.Fatalf("Failed to read lock file: %v", err)
+ }
+
+ lockStr := string(lockContent)
+
+ // Verify the workflow name is present
+ if !strings.Contains(lockStr, "OpenClaw Test Workflow") {
+ t.Error("Expected workflow name 'OpenClaw Test Workflow' in lock file")
+ }
+
+ // Verify OpenClaw installation step
+ if !strings.Contains(lockStr, "Install OpenClaw") {
+ t.Error("Expected 'Install OpenClaw' step in lock file")
+ }
+
+ // Verify OpenClaw execution step
+ if !strings.Contains(lockStr, "Run OpenClaw") {
+ t.Error("Expected 'Run OpenClaw' step in lock file")
+ }
+
+ // Verify openclaw CLI command is present
+ if !strings.Contains(lockStr, "openclaw") {
+ t.Error("Expected 'openclaw' command in lock file")
+ }
+
+ // Verify agent subcommand
+ if !strings.Contains(lockStr, "agent") {
+ t.Error("Expected 'agent' subcommand in lock file")
+ }
+
+ // Verify key CLI flags
+ if !strings.Contains(lockStr, "--local") {
+ t.Error("Expected '--local' flag in lock file")
+ }
+
+ if !strings.Contains(lockStr, "--json") {
+ t.Error("Expected '--json' flag in lock file")
+ }
+
+ if !strings.Contains(lockStr, "--no-color") {
+ t.Error("Expected '--no-color' flag in lock file")
+ }
+
+ // Verify environment variables
+ if !strings.Contains(lockStr, "OPENCLAW_API_KEY") {
+ t.Error("Expected OPENCLAW_API_KEY in lock file")
+ }
+
+ if !strings.Contains(lockStr, "ANTHROPIC_API_KEY") {
+ t.Error("Expected ANTHROPIC_API_KEY in lock file")
+ }
+
+ if !strings.Contains(lockStr, "OPENCLAW_STATE_DIR") {
+ t.Error("Expected OPENCLAW_STATE_DIR in lock file")
+ }
+
+ // Verify log parser reference
+ if !strings.Contains(lockStr, "parse_openclaw_log") {
+ t.Error("Expected 'parse_openclaw_log' log parser reference in lock file")
+ }
+}
+
+func TestCompileOpenClawEngineObject(t *testing.T) {
+ tmpDir := testutil.TempDir(t, "openclaw-engine-obj-test")
+
+ testContent := `---
+on: push
+timeout-minutes: 15
+permissions:
+ contents: read
+ issues: write
+ pull-requests: read
+engine:
+ id: openclaw
+ model: custom-agent-v2
+ args:
+ - "--verbose"
+ - "--timeout"
+ - "1800"
+strict: false
+features:
+ dangerous-permissions-write: true
+ experimental-engines: true
+tools:
+ bash: ["*"]
+---
+
+# OpenClaw Custom Agent Workflow
+
+Test workflow with engine object configuration including model and args.
+`
+
+ testFile := filepath.Join(tmpDir, "openclaw-engine-obj.md")
+ if err := os.WriteFile(testFile, []byte(testContent), 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ compiler := NewCompiler()
+ err := compiler.CompileWorkflow(testFile)
+ if err != nil {
+ t.Fatalf("Expected OpenClaw workflow with engine object to compile successfully, got error: %v", err)
+ }
+
+ lockFile := stringutil.MarkdownToLockFile(testFile)
+ lockContent, err := os.ReadFile(lockFile)
+ if err != nil {
+ t.Fatalf("Failed to read lock file: %v", err)
+ }
+
+ lockStr := string(lockContent)
+
+ // Verify custom model/agent is present
+ if !strings.Contains(lockStr, "--agent") {
+ t.Error("Expected '--agent' flag in lock file for custom model")
+ }
+
+ if !strings.Contains(lockStr, "custom-agent-v2") {
+ t.Error("Expected 'custom-agent-v2' agent name in lock file")
+ }
+
+ // Verify custom args are passed through
+ if !strings.Contains(lockStr, "--verbose") {
+ t.Error("Expected '--verbose' custom arg in lock file")
+ }
+}
+
+func TestCompileOpenClawWithFirewall(t *testing.T) {
+ tmpDir := testutil.TempDir(t, "openclaw-firewall-test")
+
+ testContent := `---
+on: push
+timeout-minutes: 10
+permissions:
+ contents: read
+ issues: read
+ pull-requests: read
+engine: openclaw
+strict: false
+features:
+ experimental-engines: true
+network:
+ allowed:
+ - defaults
+ - github
+tools:
+ github:
+ toolsets: [repos]
+ bash: ["echo"]
+---
+
+# OpenClaw Firewall Workflow
+
+Test workflow with firewall enabled.
+`
+
+ testFile := filepath.Join(tmpDir, "openclaw-firewall.md")
+ if err := os.WriteFile(testFile, []byte(testContent), 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ compiler := NewCompiler()
+ err := compiler.CompileWorkflow(testFile)
+ if err != nil {
+ t.Fatalf("Expected OpenClaw workflow with firewall to compile successfully, got error: %v", err)
+ }
+
+ lockFile := stringutil.MarkdownToLockFile(testFile)
+ lockContent, err := os.ReadFile(lockFile)
+ if err != nil {
+ t.Fatalf("Failed to read lock file: %v", err)
+ }
+
+ lockStr := string(lockContent)
+
+ // Verify AWF (firewall) command is present
+ if !strings.Contains(lockStr, "awf") {
+ t.Error("Expected 'awf' command in lock file when firewall is enabled")
+ }
+
+ // Verify allowed domains flag
+ if !strings.Contains(lockStr, "--allow-domains") {
+ t.Error("Expected '--allow-domains' flag in lock file when firewall is enabled")
+ }
+
+ // Verify the openclaw command is still in the wrapped command
+ if !strings.Contains(lockStr, "openclaw") {
+ t.Error("Expected 'openclaw' command inside AWF wrapper")
+ }
+}
+
+func TestCompileOpenClawWithSafeOutputs(t *testing.T) {
+ tmpDir := testutil.TempDir(t, "openclaw-safe-outputs-test")
+
+ testContent := `---
+on: push
+timeout-minutes: 10
+permissions:
+ contents: read
+ issues: write
+ pull-requests: write
+engine: openclaw
+strict: false
+features:
+ dangerous-permissions-write: true
+ experimental-engines: true
+tools:
+ github:
+ toolsets: [repos]
+ bash: ["echo"]
+safe-outputs:
+ add-comment:
+ max: 2
+ create-issue:
+ expires: 2h
+---
+
+# OpenClaw Safe Outputs Workflow
+
+Test workflow with safe outputs configured.
+`
+
+ testFile := filepath.Join(tmpDir, "openclaw-safe-outputs.md")
+ if err := os.WriteFile(testFile, []byte(testContent), 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ compiler := NewCompiler()
+ err := compiler.CompileWorkflow(testFile)
+ if err != nil {
+ t.Fatalf("Expected OpenClaw workflow with safe outputs to compile successfully, got error: %v", err)
+ }
+
+ lockFile := stringutil.MarkdownToLockFile(testFile)
+ lockContent, err := os.ReadFile(lockFile)
+ if err != nil {
+ t.Fatalf("Failed to read lock file: %v", err)
+ }
+
+ lockStr := string(lockContent)
+
+ // Verify safe outputs environment variable
+ if !strings.Contains(lockStr, "GH_AW_SAFE_OUTPUTS") {
+ t.Error("Expected GH_AW_SAFE_OUTPUTS in lock file when safe-outputs is configured")
+ }
+}
+
+func TestCompileOpenClawWithCustomCommand(t *testing.T) {
+ tmpDir := testutil.TempDir(t, "openclaw-custom-cmd-test")
+
+ testContent := `---
+on: push
+timeout-minutes: 10
+permissions:
+ contents: read
+ issues: read
+ pull-requests: read
+engine:
+ id: openclaw
+ command: /usr/local/bin/my-openclaw
+strict: false
+features:
+ experimental-engines: true
+tools:
+ bash: ["echo"]
+---
+
+# OpenClaw Custom Command Workflow
+
+Test workflow with a custom openclaw command path.
+`
+
+ testFile := filepath.Join(tmpDir, "openclaw-custom-cmd.md")
+ if err := os.WriteFile(testFile, []byte(testContent), 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ compiler := NewCompiler()
+ err := compiler.CompileWorkflow(testFile)
+ if err != nil {
+ t.Fatalf("Expected OpenClaw workflow with custom command to compile successfully, got error: %v", err)
+ }
+
+ lockFile := stringutil.MarkdownToLockFile(testFile)
+ lockContent, err := os.ReadFile(lockFile)
+ if err != nil {
+ t.Fatalf("Failed to read lock file: %v", err)
+ }
+
+ lockStr := string(lockContent)
+
+ // Verify custom command is used instead of default 'openclaw'
+ if !strings.Contains(lockStr, "/usr/local/bin/my-openclaw") {
+ t.Error("Expected custom command '/usr/local/bin/my-openclaw' in lock file")
+ }
+
+ // With custom command, installation steps should be skipped
+ if strings.Contains(lockStr, "Install OpenClaw") {
+ t.Error("Expected 'Install OpenClaw' step to be absent when custom command is specified")
+ }
+}
diff --git a/pkg/workflow/compiler_orchestrator_engine.go b/pkg/workflow/compiler_orchestrator_engine.go
index 7b7a76591b2..83eaf6fef5c 100644
--- a/pkg/workflow/compiler_orchestrator_engine.go
+++ b/pkg/workflow/compiler_orchestrator_engine.go
@@ -224,6 +224,9 @@ func (c *Compiler) setupEngineAndImports(result *parser.FrontmatterResult, clean
// Enable firewall by default for claude engine when network restrictions are present
enableFirewallByDefaultForClaude(engineSetting, networkPermissions, sandboxConfig)
+ // Enable firewall by default for openclaw engine when network restrictions are present
+ enableFirewallByDefaultForOpenClaw(engineSetting, networkPermissions, sandboxConfig)
+
// Re-evaluate strict mode for firewall and network validation
// (it was restored after validateStrictMode but we need it again)
initialStrictModeForFirewall := c.strictMode
diff --git a/pkg/workflow/domains.go b/pkg/workflow/domains.go
index 49581bc877a..e19099692c0 100644
--- a/pkg/workflow/domains.go
+++ b/pkg/workflow/domains.go
@@ -41,6 +41,14 @@ var CodexDefaultDomains = []string{
"openai.com",
}
+// OpenClawDefaultDomains are the default domains required for OpenClaw CLI operation
+var OpenClawDefaultDomains = []string{
+ "api.anthropic.com", // Default model provider (Anthropic)
+ "api.openai.com", // Alternative model provider (OpenAI)
+ "host.docker.internal", // AWF gateway
+ "registry.npmjs.org", // npm package installation
+}
+
// ClaudeDefaultDomains are the default domains required for Claude Code CLI authentication and operation
var ClaudeDefaultDomains = []string{
"*.githubusercontent.com",
@@ -477,6 +485,24 @@ func GetClaudeAllowedDomainsWithToolsAndRuntimes(network *NetworkPermissions, to
return mergeDomainsWithNetworkToolsAndRuntimes(ClaudeDefaultDomains, network, tools, runtimes)
}
+// GetOpenClawAllowedDomains merges OpenClaw default domains with NetworkPermissions allowed domains
+// Returns a deduplicated, sorted, comma-separated string suitable for AWF's --allow-domains flag
+func GetOpenClawAllowedDomains(network *NetworkPermissions) string {
+ return mergeDomainsWithNetwork(OpenClawDefaultDomains, network)
+}
+
+// GetOpenClawAllowedDomainsWithTools merges OpenClaw default domains with NetworkPermissions allowed domains and HTTP MCP server domains
+// Returns a deduplicated, sorted, comma-separated string suitable for AWF's --allow-domains flag
+func GetOpenClawAllowedDomainsWithTools(network *NetworkPermissions, tools map[string]any) string {
+ return mergeDomainsWithNetworkAndTools(OpenClawDefaultDomains, network, tools)
+}
+
+// GetOpenClawAllowedDomainsWithToolsAndRuntimes merges OpenClaw default domains with NetworkPermissions, HTTP MCP server domains, and runtime ecosystem domains
+// Returns a deduplicated, sorted, comma-separated string suitable for AWF's --allow-domains flag
+func GetOpenClawAllowedDomainsWithToolsAndRuntimes(network *NetworkPermissions, tools map[string]any, runtimes map[string]any) string {
+ return mergeDomainsWithNetworkToolsAndRuntimes(OpenClawDefaultDomains, network, tools, runtimes)
+}
+
// GetBlockedDomains returns the blocked domains from network permissions
// Returns empty slice if no network permissions configured or no domains blocked
// The returned list is sorted and deduplicated
@@ -564,6 +590,8 @@ func (c *Compiler) computeAllowedDomainsForSanitization(data *WorkflowData) stri
return GetCodexAllowedDomains(data.NetworkPermissions)
case "claude":
return GetClaudeAllowedDomains(data.NetworkPermissions)
+ case "openclaw":
+ return GetOpenClawAllowedDomains(data.NetworkPermissions)
default:
// For other engines, use network permissions only
domains := GetAllowedDomains(data.NetworkPermissions)
diff --git a/pkg/workflow/firewall.go b/pkg/workflow/firewall.go
index 0420e79c5da..34b63fb91f5 100644
--- a/pkg/workflow/firewall.go
+++ b/pkg/workflow/firewall.go
@@ -121,6 +121,17 @@ func enableFirewallByDefaultForClaude(engineID string, networkPermissions *Netwo
enableFirewallByDefaultForEngine(engineID, networkPermissions, sandboxConfig)
}
+// enableFirewallByDefaultForOpenClaw enables firewall by default for OpenClaw engine
+// when network restrictions are present but no explicit firewall configuration exists
+// and sandbox.agent is not explicitly set to false
+func enableFirewallByDefaultForOpenClaw(engineID string, networkPermissions *NetworkPermissions, sandboxConfig *SandboxConfig) {
+ if engineID != "openclaw" {
+ return
+ }
+
+ enableFirewallByDefaultForEngine(engineID, networkPermissions, sandboxConfig)
+}
+
// enableFirewallByDefaultForEngine enables firewall by default for a given engine
// when network restrictions are present but no explicit firewall configuration exists
// and no SRT sandbox is configured (SRT and AWF are mutually exclusive)
diff --git a/pkg/workflow/imported_steps_validation_test.go b/pkg/workflow/imported_steps_validation_test.go
index c9ee754c0f6..d3210631071 100644
--- a/pkg/workflow/imported_steps_validation_test.go
+++ b/pkg/workflow/imported_steps_validation_test.go
@@ -139,7 +139,11 @@ imports:
require.Error(t, err, "Expected error in strict mode")
if err != nil {
assert.Contains(t, err.Error(), "ANTHROPIC_API_KEY")
- assert.Contains(t, err.Error(), "Claude Code")
+ // ANTHROPIC_API_KEY is used by both Claude Code and OpenClaw engines
+ // The error may mention either engine depending on map iteration order
+ errMsg := err.Error()
+ assert.True(t, strings.Contains(errMsg, "Claude Code") || strings.Contains(errMsg, "OpenClaw"),
+ "Expected error to mention 'Claude Code' or 'OpenClaw', got: %s", errMsg)
}
}
diff --git a/pkg/workflow/openclaw_engine.go b/pkg/workflow/openclaw_engine.go
new file mode 100644
index 00000000000..e0cf0f8c09c
--- /dev/null
+++ b/pkg/workflow/openclaw_engine.go
@@ -0,0 +1,404 @@
+package workflow
+
+import (
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/github/gh-aw/pkg/constants"
+ "github.com/github/gh-aw/pkg/logger"
+)
+
+var openclawLog = logger.New("workflow:openclaw_engine")
+
+// OpenClawEngine represents the OpenClaw agentic engine
+type OpenClawEngine struct {
+ BaseEngine
+}
+
+func NewOpenClawEngine() *OpenClawEngine {
+ return &OpenClawEngine{
+ BaseEngine: BaseEngine{
+ id: "openclaw",
+ displayName: "OpenClaw",
+ description: "Uses OpenClaw agent platform with ACP tool support",
+ experimental: true,
+ supportsToolsAllowlist: false, // OpenClaw uses its own skill/tool system
+ supportsMaxTurns: false, // Timeout-based, not turn-based
+ supportsWebFetch: false, // Not built-in
+ supportsWebSearch: false, // Not built-in
+ supportsFirewall: true, // Supports AWF sandboxing
+ supportsLLMGateway: false, // Manages its own API connections
+ },
+ }
+}
+
+// SupportsLLMGateway returns -1 as OpenClaw does not support LLM gateway
+func (e *OpenClawEngine) SupportsLLMGateway() int {
+ return -1
+}
+
+// GetRequiredSecretNames returns the list of secrets required by the OpenClaw engine
+func (e *OpenClawEngine) GetRequiredSecretNames(workflowData *WorkflowData) []string {
+ secrets := []string{"OPENCLAW_API_KEY", "ANTHROPIC_API_KEY"}
+
+ // Add MCP gateway API key if MCP servers are present
+ if HasMCPServers(workflowData) {
+ secrets = append(secrets, "MCP_GATEWAY_API_KEY")
+ }
+
+ // Add safe-inputs secret names
+ if IsSafeInputsEnabled(workflowData.SafeInputs, workflowData) {
+ safeInputsSecrets := collectSafeInputsSecrets(workflowData.SafeInputs)
+ for varName := range safeInputsSecrets {
+ secrets = append(secrets, varName)
+ }
+ }
+
+ return secrets
+}
+
+func (e *OpenClawEngine) GetInstallationSteps(workflowData *WorkflowData) []GitHubActionStep {
+ openclawLog.Printf("Generating installation steps for OpenClaw engine: workflow=%s", workflowData.Name)
+
+ // Skip installation if custom command is specified
+ if workflowData.EngineConfig != nil && workflowData.EngineConfig.Command != "" {
+ openclawLog.Printf("Skipping installation steps: custom command specified (%s)", workflowData.EngineConfig.Command)
+ return []GitHubActionStep{}
+ }
+
+ // Use base installation steps (secret validation + npm install)
+ steps := GetBaseInstallationSteps(EngineInstallConfig{
+ Secrets: []string{"OPENCLAW_API_KEY", "ANTHROPIC_API_KEY"},
+ DocsURL: "https://github.github.com/gh-aw/reference/engines/#openclaw",
+ NpmPackage: "openclaw",
+ Version: string(constants.DefaultOpenClawVersion),
+ Name: "OpenClaw",
+ CliName: "openclaw",
+ }, workflowData)
+
+ // Add AWF installation step if firewall is enabled
+ if isFirewallEnabled(workflowData) {
+ firewallConfig := getFirewallConfig(workflowData)
+ agentConfig := getAgentConfig(workflowData)
+ var awfVersion string
+ if firewallConfig != nil {
+ awfVersion = firewallConfig.Version
+ }
+
+ awfInstall := generateAWFInstallationStep(awfVersion, agentConfig)
+ if len(awfInstall) > 0 {
+ steps = append(steps, awfInstall)
+ }
+ }
+
+ return steps
+}
+
+// GetDeclaredOutputFiles returns the output files that OpenClaw may produce
+func (e *OpenClawEngine) GetDeclaredOutputFiles() []string {
+ return []string{}
+}
+
+// GetExecutionSteps returns the GitHub Actions steps for executing OpenClaw
+func (e *OpenClawEngine) GetExecutionSteps(workflowData *WorkflowData, logFile string) []GitHubActionStep {
+ modelConfigured := workflowData.EngineConfig != nil && workflowData.EngineConfig.Model != ""
+ firewallEnabled := isFirewallEnabled(workflowData)
+ openclawLog.Printf("Building OpenClaw execution steps: workflow=%s, model_configured=%v, has_agent_file=%v, firewall=%v",
+ workflowData.Name, modelConfigured, workflowData.AgentFile != "", firewallEnabled)
+
+ // Handle custom steps if they exist in engine config
+ steps := InjectCustomEngineSteps(workflowData, e.convertStepToYAML)
+
+ // Build OpenClaw CLI arguments
+ var openclawArgs []string
+ openclawArgs = append(openclawArgs, "agent")
+ openclawArgs = append(openclawArgs, "--local")
+ openclawArgs = append(openclawArgs, "--json")
+ openclawArgs = append(openclawArgs, "--no-color")
+ openclawArgs = append(openclawArgs, "--timeout", "1200")
+
+ // Add model/agent if specified
+ if modelConfigured {
+ openclawLog.Printf("Using custom agent: %s", workflowData.EngineConfig.Model)
+ openclawArgs = append(openclawArgs, "--agent", workflowData.EngineConfig.Model)
+ }
+
+ // Add MCP configuration if MCP servers are present
+ if HasMCPServers(workflowData) {
+ openclawLog.Print("Adding MCP configuration")
+ openclawArgs = append(openclawArgs, "--mcp-config", "/tmp/gh-aw/mcp-config/mcp-servers.json")
+ }
+
+ // Add custom args from engine configuration
+ if workflowData.EngineConfig != nil && len(workflowData.EngineConfig.Args) > 0 {
+ openclawArgs = append(openclawArgs, workflowData.EngineConfig.Args...)
+ }
+
+ // Add the message argument last
+ openclawArgs = append(openclawArgs, "--message")
+
+ // Build the agent command - prepend custom agent file content if specified
+ var promptSetup string
+ var promptCommand string
+ if workflowData.AgentFile != "" {
+ agentPath := ResolveAgentFilePath(workflowData.AgentFile)
+ openclawLog.Printf("Using custom agent file: %s", workflowData.AgentFile)
+ promptSetup = fmt.Sprintf(`# Extract markdown body from custom agent file (skip frontmatter)
+ AGENT_CONTENT="$(awk 'BEGIN{skip=1} /^---$/{if(skip){skip=0;next}else{skip=1;next}} !skip' %s)"
+ # Combine agent content with prompt
+ INSTRUCTION="$(printf '%%s\n\n%%s' "$AGENT_CONTENT" "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)")"`, agentPath)
+ promptCommand = "\"$INSTRUCTION\""
+ } else {
+ promptCommand = "\"$(cat /tmp/gh-aw/aw-prompts/prompt.txt)\""
+ }
+
+ // Determine which command to use
+ var commandName string
+ if workflowData.EngineConfig != nil && workflowData.EngineConfig.Command != "" {
+ commandName = workflowData.EngineConfig.Command
+ openclawLog.Printf("Using custom command: %s", commandName)
+ } else {
+ commandName = "openclaw"
+ }
+
+ commandParts := []string{commandName}
+ commandParts = append(commandParts, openclawArgs...)
+ commandParts = append(commandParts, promptCommand)
+
+ openclawCommand := shellJoinArgs(commandParts)
+
+ // Add conditional model flag if not explicitly configured
+ isDetectionJob := workflowData.SafeOutputs == nil
+ var modelEnvVar string
+ if isDetectionJob {
+ modelEnvVar = constants.EnvVarModelDetectionOpenClaw
+ } else {
+ modelEnvVar = constants.EnvVarModelAgentOpenClaw
+ }
+ if !modelConfigured {
+ openclawCommand = fmt.Sprintf(`%s${%s:+ --agent "$%s"}`, openclawCommand, modelEnvVar, modelEnvVar)
+ }
+
+ // Build the full command based on whether firewall is enabled
+ var command string
+ if firewallEnabled {
+ // Get allowed domains
+ allowedDomains := GetOpenClawAllowedDomainsWithToolsAndRuntimes(workflowData.NetworkPermissions, workflowData.Tools, workflowData.Runtimes)
+
+ // OpenClaw does not use LLM gateway
+ usesAPIProxy := false
+
+ // Build the command with npm PATH setup for AWF container
+ npmPathSetup := GetNpmBinPathSetup()
+
+ var openclawCommandWithSetup string
+ if workflowData.AgentFile != "" {
+ agentPath := ResolveAgentFilePath(workflowData.AgentFile)
+ openclawCommandWithSetup = fmt.Sprintf(`%s && AGENT_CONTENT="$(awk 'BEGIN{skip=1} /^---$/{if(skip){skip=0;next}else{skip=1;next}} !skip' %s)" && INSTRUCTION="$(printf "%%s\n\n%%s" "$AGENT_CONTENT" "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)")" && %s`,
+ npmPathSetup, agentPath, openclawCommand)
+ } else {
+ openclawCommandWithSetup = fmt.Sprintf(`%s && INSTRUCTION="$(cat /tmp/gh-aw/aw-prompts/prompt.txt)" && %s`,
+ npmPathSetup, openclawCommand)
+ }
+
+ command = BuildAWFCommand(AWFCommandConfig{
+ EngineName: "openclaw",
+ EngineCommand: openclawCommandWithSetup,
+ LogFile: logFile,
+ WorkflowData: workflowData,
+ UsesTTY: false, // OpenClaw uses --json output, not a TUI
+ UsesAPIProxy: usesAPIProxy,
+ AllowedDomains: allowedDomains,
+ PathSetup: "mkdir -p \"$OPENCLAW_STATE_DIR\"",
+ })
+ } else {
+ // Run OpenClaw command without AWF wrapper
+ if promptSetup != "" {
+ command = fmt.Sprintf(`set -o pipefail
+ %s
+ mkdir -p "$OPENCLAW_STATE_DIR"
+ %s 2>&1 | tee %s`, promptSetup, openclawCommand, logFile)
+ } else {
+ command = fmt.Sprintf(`set -o pipefail
+ INSTRUCTION="$(cat "$GH_AW_PROMPT")"
+ mkdir -p "$OPENCLAW_STATE_DIR"
+ %s 2>&1 | tee %s`, openclawCommand, logFile)
+ }
+ }
+
+ // Build environment variables map
+ env := map[string]string{
+ "OPENCLAW_API_KEY": "${{ secrets.OPENCLAW_API_KEY }}",
+ "ANTHROPIC_API_KEY": "${{ secrets.ANTHROPIC_API_KEY }}",
+ "OPENCLAW_STATE_DIR": "/tmp/gh-aw/openclaw-state",
+ "GH_AW_PROMPT": "/tmp/gh-aw/aw-prompts/prompt.txt",
+ "DISABLE_TELEMETRY": "1",
+ "GITHUB_WORKSPACE": "${{ github.workspace }}",
+ }
+
+ // Add GH_AW_MCP_CONFIG for MCP server configuration only if there are MCP servers
+ if HasMCPServers(workflowData) {
+ env["GH_AW_MCP_CONFIG"] = "/tmp/gh-aw/mcp-config/mcp-servers.json"
+ }
+
+ // Add GH_AW_SAFE_OUTPUTS if output is needed
+ applySafeOutputEnvToMap(env, workflowData)
+
+ // Add GH_AW_STARTUP_TIMEOUT environment variable (in seconds) if startup-timeout is specified
+ if workflowData.ToolsStartupTimeout > 0 {
+ env["GH_AW_STARTUP_TIMEOUT"] = fmt.Sprintf("%d", workflowData.ToolsStartupTimeout)
+ }
+
+ // Add GH_AW_TOOL_TIMEOUT environment variable (in seconds) if timeout is specified
+ if workflowData.ToolsTimeout > 0 {
+ env["GH_AW_TOOL_TIMEOUT"] = fmt.Sprintf("%d", workflowData.ToolsTimeout)
+ }
+
+ // Add model environment variable if model is not explicitly configured
+ if !modelConfigured {
+ if isDetectionJob {
+ env[constants.EnvVarModelDetectionOpenClaw] = fmt.Sprintf("${{ vars.%s || '' }}", constants.EnvVarModelDetectionOpenClaw)
+ } else {
+ env[constants.EnvVarModelAgentOpenClaw] = fmt.Sprintf("${{ vars.%s || '' }}", constants.EnvVarModelAgentOpenClaw)
+ }
+ }
+
+ // Add custom environment variables from engine config
+ if workflowData.EngineConfig != nil && len(workflowData.EngineConfig.Env) > 0 {
+ for key, value := range workflowData.EngineConfig.Env {
+ env[key] = value
+ }
+ }
+
+ // Add custom environment variables from agent config
+ agentConfig := getAgentConfig(workflowData)
+ if agentConfig != nil && len(agentConfig.Env) > 0 {
+ for key, value := range agentConfig.Env {
+ env[key] = value
+ }
+ openclawLog.Printf("Added %d custom env vars from agent config", len(agentConfig.Env))
+ }
+
+ // Add safe-inputs secrets to env for passthrough to MCP servers
+ if IsSafeInputsEnabled(workflowData.SafeInputs, workflowData) {
+ safeInputsSecrets := collectSafeInputsSecrets(workflowData.SafeInputs)
+ for varName, secretExpr := range safeInputsSecrets {
+ if _, exists := env[varName]; !exists {
+ env[varName] = secretExpr
+ }
+ }
+ }
+
+ // Generate the step for OpenClaw execution
+ stepName := "Run OpenClaw"
+ var stepLines []string
+
+ stepLines = append(stepLines, fmt.Sprintf(" - name: %s", stepName))
+
+ // Add timeout at step level
+ if workflowData.TimeoutMinutes != "" {
+ timeoutValue := strings.TrimPrefix(workflowData.TimeoutMinutes, "timeout-minutes: ")
+ stepLines = append(stepLines, fmt.Sprintf(" timeout-minutes: %s", timeoutValue))
+ } else {
+ stepLines = append(stepLines, fmt.Sprintf(" timeout-minutes: %d", int(constants.DefaultAgenticWorkflowTimeout/time.Minute)))
+ }
+
+ // Filter environment variables to only include allowed secrets
+ allowedSecrets := e.GetRequiredSecretNames(workflowData)
+ filteredEnv := FilterEnvForSecrets(env, allowedSecrets)
+
+ // Format step with command and filtered environment variables
+ stepLines = FormatStepWithCommandAndEnv(stepLines, command, filteredEnv)
+
+ steps = append(steps, GitHubActionStep(stepLines))
+
+ return steps
+}
+
+// GetLogParserScriptId returns the JavaScript script name for parsing OpenClaw logs
+func (e *OpenClawEngine) GetLogParserScriptId() string {
+ return "parse_openclaw_log"
+}
+
+// GetFirewallLogsCollectionStep returns the step for collecting firewall logs
+func (e *OpenClawEngine) GetFirewallLogsCollectionStep(workflowData *WorkflowData) []GitHubActionStep {
+ return []GitHubActionStep{}
+}
+
+// GetSquidLogsSteps returns the steps for uploading and parsing Squid logs
+func (e *OpenClawEngine) GetSquidLogsSteps(workflowData *WorkflowData) []GitHubActionStep {
+ var steps []GitHubActionStep
+
+ if isFirewallEnabled(workflowData) {
+ openclawLog.Printf("Adding Squid logs upload and parsing steps for workflow: %s", workflowData.Name)
+
+ squidLogsUpload := generateSquidLogsUploadStep(workflowData.Name)
+ steps = append(steps, squidLogsUpload)
+
+ firewallLogParsing := generateFirewallLogParsingStep(workflowData.Name)
+ steps = append(steps, firewallLogParsing)
+ } else {
+ openclawLog.Print("Firewall disabled, skipping Squid logs upload")
+ }
+
+ return steps
+}
+
+// ParseLogMetrics implements engine-specific log parsing for OpenClaw
+func (e *OpenClawEngine) ParseLogMetrics(logContent string, verbose bool) LogMetrics {
+ openclawLog.Printf("Parsing OpenClaw log metrics: log_size=%d bytes", len(logContent))
+
+ var metrics LogMetrics
+
+ lines := strings.Split(logContent, "\n")
+ toolCallMap := make(map[string]*ToolCallInfo)
+ var currentSequence []string
+
+ for _, line := range lines {
+ // Count tool calls from JSON output
+ if strings.Contains(line, "\"type\":\"tool_call\"") || strings.Contains(line, "\"type\": \"tool_call\"") {
+ // Try to extract tool name
+ toolName := "unknown"
+ if idx := strings.Index(line, "\"name\":\""); idx != -1 {
+ nameStart := idx + len("\"name\":\"")
+ nameEnd := strings.Index(line[nameStart:], "\"")
+ if nameEnd > 0 {
+ toolName = line[nameStart : nameStart+nameEnd]
+ }
+ } else if idx := strings.Index(line, "\"name\": \""); idx != -1 {
+ nameStart := idx + len("\"name\": \"")
+ nameEnd := strings.Index(line[nameStart:], "\"")
+ if nameEnd > 0 {
+ toolName = line[nameStart : nameStart+nameEnd]
+ }
+ }
+
+ if existing, ok := toolCallMap[toolName]; ok {
+ existing.CallCount++
+ } else {
+ toolCallMap[toolName] = &ToolCallInfo{
+ Name: toolName,
+ CallCount: 1,
+ }
+ }
+ currentSequence = append(currentSequence, toolName)
+ }
+ }
+
+ // Convert tool call map to slice
+ for _, info := range toolCallMap {
+ metrics.ToolCalls = append(metrics.ToolCalls, *info)
+ }
+
+ if len(currentSequence) > 0 {
+ metrics.ToolSequences = append(metrics.ToolSequences, currentSequence)
+ }
+
+ return metrics
+}
+
+// GetDefaultDetectionModel returns the default model for OpenClaw detection jobs
+func (e *OpenClawEngine) GetDefaultDetectionModel() string {
+ return ""
+}
diff --git a/pkg/workflow/openclaw_engine_test.go b/pkg/workflow/openclaw_engine_test.go
new file mode 100644
index 00000000000..9106a7888ac
--- /dev/null
+++ b/pkg/workflow/openclaw_engine_test.go
@@ -0,0 +1,400 @@
+//go:build !integration
+
+package workflow
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/github/gh-aw/pkg/constants"
+)
+
+func TestOpenClawEngine(t *testing.T) {
+ engine := NewOpenClawEngine()
+
+ // Test basic properties
+ if engine.GetID() != "openclaw" {
+ t.Errorf("Expected ID 'openclaw', got '%s'", engine.GetID())
+ }
+
+ if engine.GetDisplayName() != "OpenClaw" {
+ t.Errorf("Expected display name 'OpenClaw', got '%s'", engine.GetDisplayName())
+ }
+
+ if engine.GetDescription() != "Uses OpenClaw agent platform with ACP tool support" {
+ t.Errorf("Expected correct description, got '%s'", engine.GetDescription())
+ }
+
+ if !engine.IsExperimental() {
+ t.Error("OpenClaw engine should be experimental")
+ }
+
+ if engine.SupportsToolsAllowlist() {
+ t.Error("OpenClaw engine should not support tools allowlist")
+ }
+
+ if engine.SupportsMaxTurns() {
+ t.Error("OpenClaw engine should not support max turns")
+ }
+
+ if engine.SupportsWebFetch() {
+ t.Error("OpenClaw engine should not support web fetch")
+ }
+
+ if engine.SupportsWebSearch() {
+ t.Error("OpenClaw engine should not support web search")
+ }
+
+ if !engine.SupportsFirewall() {
+ t.Error("OpenClaw engine should support firewall")
+ }
+
+ if engine.SupportsLLMGateway() != -1 {
+ t.Errorf("Expected LLM gateway port -1, got %d", engine.SupportsLLMGateway())
+ }
+}
+
+func TestOpenClawEngine_GetRequiredSecretNames(t *testing.T) {
+ engine := NewOpenClawEngine()
+
+ // Test basic secrets (no MCP servers)
+ workflowData := &WorkflowData{}
+ secrets := engine.GetRequiredSecretNames(workflowData)
+
+ if len(secrets) != 2 {
+ t.Errorf("Expected 2 secrets, got %d", len(secrets))
+ }
+
+ hasOpenClawKey := false
+ hasAnthropicKey := false
+ for _, s := range secrets {
+ if s == "OPENCLAW_API_KEY" {
+ hasOpenClawKey = true
+ }
+ if s == "ANTHROPIC_API_KEY" {
+ hasAnthropicKey = true
+ }
+ }
+ if !hasOpenClawKey {
+ t.Error("Expected OPENCLAW_API_KEY in secrets")
+ }
+ if !hasAnthropicKey {
+ t.Error("Expected ANTHROPIC_API_KEY in secrets")
+ }
+
+ // Test with MCP servers
+ workflowData = &WorkflowData{
+ Tools: map[string]any{
+ "github": nil,
+ },
+ ParsedTools: &ToolsConfig{
+ GitHub: &GitHubToolConfig{},
+ },
+ }
+ secrets = engine.GetRequiredSecretNames(workflowData)
+
+ hasMCPKey := false
+ for _, s := range secrets {
+ if s == "MCP_GATEWAY_API_KEY" {
+ hasMCPKey = true
+ }
+ }
+ if !hasMCPKey {
+ t.Error("Expected MCP_GATEWAY_API_KEY in secrets when MCP servers are present")
+ }
+}
+
+func TestOpenClawEngine_GetInstallationSteps(t *testing.T) {
+ engine := NewOpenClawEngine()
+
+ steps := engine.GetInstallationSteps(&WorkflowData{})
+ expectedStepCount := 3 // Secret validation + Node.js setup + Install OpenClaw
+ if len(steps) != expectedStepCount {
+ t.Errorf("Expected %d installation steps, got %d", expectedStepCount, len(steps))
+ }
+
+ // Verify first step is secret validation
+ if len(steps) > 0 && len(steps[0]) > 0 {
+ if !strings.Contains(steps[0][0], "Validate OPENCLAW_API_KEY or ANTHROPIC_API_KEY secret") {
+ t.Errorf("Expected first step to contain 'Validate OPENCLAW_API_KEY or ANTHROPIC_API_KEY secret', got '%s'", steps[0][0])
+ }
+ }
+
+ // Verify second step is Node.js setup
+ if len(steps) > 1 && len(steps[1]) > 0 {
+ if !strings.Contains(steps[1][0], "Setup Node.js") {
+ t.Errorf("Expected second step to contain 'Setup Node.js', got '%s'", steps[1][0])
+ }
+ }
+
+ // Verify third step installs OpenClaw
+ if len(steps) > 2 && len(steps[2]) > 0 {
+ if !strings.Contains(steps[2][0], "Install OpenClaw") {
+ t.Errorf("Expected third step to contain 'Install OpenClaw', got '%s'", steps[2][0])
+ }
+ }
+}
+
+func TestOpenClawEngine_GetInstallationSteps_CustomCommand(t *testing.T) {
+ engine := NewOpenClawEngine()
+
+ // Custom command should skip installation
+ steps := engine.GetInstallationSteps(&WorkflowData{
+ EngineConfig: &EngineConfig{
+ Command: "/usr/bin/custom-openclaw",
+ },
+ })
+
+ if len(steps) != 0 {
+ t.Errorf("Expected 0 installation steps with custom command, got %d", len(steps))
+ }
+}
+
+func TestOpenClawEngine_GetExecutionSteps(t *testing.T) {
+ engine := NewOpenClawEngine()
+
+ workflowData := &WorkflowData{
+ Name: "test-workflow",
+ }
+ execSteps := engine.GetExecutionSteps(workflowData, "/tmp/gh-aw/agent-stdio.log")
+ if len(execSteps) != 1 {
+ t.Fatalf("Expected 1 step for OpenClaw execution, got %d", len(execSteps))
+ }
+
+ stepContent := strings.Join([]string(execSteps[0]), "\n")
+
+ if !strings.Contains(stepContent, "name: Run OpenClaw") {
+ t.Errorf("Expected step name 'Run OpenClaw' in step content")
+ }
+
+ if !strings.Contains(stepContent, "openclaw") {
+ t.Errorf("Expected command to contain 'openclaw' in step content")
+ }
+
+ if !strings.Contains(stepContent, "agent") {
+ t.Errorf("Expected command to contain 'agent' subcommand in step content")
+ }
+
+ if !strings.Contains(stepContent, "--local") {
+ t.Errorf("Expected command to contain '--local' flag")
+ }
+
+ if !strings.Contains(stepContent, "--json") {
+ t.Errorf("Expected command to contain '--json' flag")
+ }
+
+ if !strings.Contains(stepContent, "--no-color") {
+ t.Errorf("Expected command to contain '--no-color' flag")
+ }
+
+ if !strings.Contains(stepContent, "--timeout") {
+ t.Errorf("Expected command to contain '--timeout' flag")
+ }
+
+ if !strings.Contains(stepContent, "set -o pipefail") {
+ t.Errorf("Expected command to contain 'set -o pipefail'")
+ }
+
+ // Check environment variables
+ if !strings.Contains(stepContent, "OPENCLAW_API_KEY") {
+ t.Errorf("Expected OPENCLAW_API_KEY environment variable")
+ }
+
+ if !strings.Contains(stepContent, "ANTHROPIC_API_KEY") {
+ t.Errorf("Expected ANTHROPIC_API_KEY environment variable")
+ }
+
+ if !strings.Contains(stepContent, "OPENCLAW_STATE_DIR") {
+ t.Errorf("Expected OPENCLAW_STATE_DIR environment variable")
+ }
+
+ if !strings.Contains(stepContent, "DISABLE_TELEMETRY") {
+ t.Errorf("Expected DISABLE_TELEMETRY environment variable")
+ }
+}
+
+func TestOpenClawEngine_GetExecutionSteps_WithFirewall(t *testing.T) {
+ engine := NewOpenClawEngine()
+
+ workflowData := &WorkflowData{
+ Name: "test-workflow-firewall",
+ NetworkPermissions: &NetworkPermissions{
+ Allowed: []string{"defaults"},
+ Firewall: &FirewallConfig{
+ Enabled: true,
+ },
+ },
+ }
+ execSteps := engine.GetExecutionSteps(workflowData, "/tmp/gh-aw/agent-stdio.log")
+ if len(execSteps) != 1 {
+ t.Fatalf("Expected 1 step for OpenClaw execution with firewall, got %d", len(execSteps))
+ }
+
+ stepContent := strings.Join([]string(execSteps[0]), "\n")
+
+ // Verify AWF command is present
+ if !strings.Contains(stepContent, "awf") {
+ t.Errorf("Expected AWF command in step content with firewall enabled")
+ }
+
+ if !strings.Contains(stepContent, "--allow-domains") {
+ t.Errorf("Expected --allow-domains in step content with firewall enabled")
+ }
+}
+
+func TestOpenClawEngine_GetExecutionSteps_WithAgentFile(t *testing.T) {
+ engine := NewOpenClawEngine()
+
+ workflowData := &WorkflowData{
+ Name: "test-workflow-agent-file",
+ AgentFile: ".github/agents/my-agent.md",
+ }
+ execSteps := engine.GetExecutionSteps(workflowData, "/tmp/gh-aw/agent-stdio.log")
+ if len(execSteps) != 1 {
+ t.Fatalf("Expected 1 step for OpenClaw execution with agent file, got %d", len(execSteps))
+ }
+
+ stepContent := strings.Join([]string(execSteps[0]), "\n")
+
+ // Verify agent file handling
+ if !strings.Contains(stepContent, "AGENT_CONTENT") {
+ t.Errorf("Expected AGENT_CONTENT variable in step content with agent file")
+ }
+
+ if !strings.Contains(stepContent, "awk") {
+ t.Errorf("Expected awk command for frontmatter stripping in step content")
+ }
+}
+
+func TestOpenClawEngine_GetExecutionSteps_WithModel(t *testing.T) {
+ engine := NewOpenClawEngine()
+
+ workflowData := &WorkflowData{
+ Name: "test-workflow-model",
+ EngineConfig: &EngineConfig{
+ Model: "custom-agent",
+ },
+ }
+ execSteps := engine.GetExecutionSteps(workflowData, "/tmp/gh-aw/agent-stdio.log")
+ if len(execSteps) != 1 {
+ t.Fatalf("Expected 1 step for OpenClaw execution with model, got %d", len(execSteps))
+ }
+
+ stepContent := strings.Join([]string(execSteps[0]), "\n")
+
+ if !strings.Contains(stepContent, "--agent") {
+ t.Errorf("Expected '--agent' flag when model is specified")
+ }
+
+ if !strings.Contains(stepContent, "custom-agent") {
+ t.Errorf("Expected 'custom-agent' value in step content")
+ }
+}
+
+func TestOpenClawEngine_GetLogParserScriptId(t *testing.T) {
+ engine := NewOpenClawEngine()
+
+ script := engine.GetLogParserScriptId()
+ if script != "parse_openclaw_log" {
+ t.Errorf("Expected log parser script 'parse_openclaw_log', got '%s'", script)
+ }
+}
+
+func TestOpenClawEngine_GetDeclaredOutputFiles(t *testing.T) {
+ engine := NewOpenClawEngine()
+
+ files := engine.GetDeclaredOutputFiles()
+ if len(files) != 0 {
+ t.Errorf("Expected 0 declared output files, got %d", len(files))
+ }
+}
+
+func TestOpenClawEngine_EngineRegistered(t *testing.T) {
+ registry := NewEngineRegistry()
+
+ engine, err := registry.GetEngine("openclaw")
+ if err != nil {
+ t.Fatalf("Expected openclaw engine to be registered, got error: %v", err)
+ }
+
+ if engine.GetID() != "openclaw" {
+ t.Errorf("Expected engine ID 'openclaw', got '%s'", engine.GetID())
+ }
+}
+
+func TestOpenClawEngine_Constants(t *testing.T) {
+ // Verify constants are defined
+ if string(constants.OpenClawEngine) != "openclaw" {
+ t.Errorf("Expected OpenClawEngine constant to be 'openclaw', got '%s'", string(constants.OpenClawEngine))
+ }
+
+ if string(constants.DefaultOpenClawVersion) == "" {
+ t.Error("Expected DefaultOpenClawVersion to be non-empty")
+ }
+
+ // Verify openclaw is in AgenticEngines
+ found := false
+ for _, engine := range constants.AgenticEngines {
+ if engine == "openclaw" {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Error("Expected 'openclaw' to be in AgenticEngines")
+ }
+
+ // Verify EngineOptions contains openclaw
+ option := constants.GetEngineOption("openclaw")
+ if option == nil {
+ t.Error("Expected openclaw to be in EngineOptions")
+ }
+ if option != nil && option.Label != "OpenClaw" {
+ t.Errorf("Expected EngineOption label 'OpenClaw', got '%s'", option.Label)
+ }
+}
+
+func TestOpenClawEngine_ParseLogMetrics(t *testing.T) {
+ engine := NewOpenClawEngine()
+
+ // Test with empty log
+ metrics := engine.ParseLogMetrics("", false)
+ if len(metrics.ToolCalls) != 0 {
+ t.Errorf("Expected 0 tool calls for empty log, got %d", len(metrics.ToolCalls))
+ }
+
+ // Test with tool calls in JSON
+ logContent := `{"type":"tool_call","name":"test_tool"}
+{"type":"message","content":"thinking..."}
+{"type":"tool_call","name":"another_tool"}`
+ metrics = engine.ParseLogMetrics(logContent, false)
+ if len(metrics.ToolCalls) != 2 {
+ t.Errorf("Expected 2 tool call types, got %d", len(metrics.ToolCalls))
+ }
+
+ // Test with repeated tool calls
+ logContent2 := `{"type":"tool_call","name":"test_tool"}
+{"type":"tool_call","name":"test_tool"}
+{"type":"tool_call","name":"other_tool"}`
+ metrics2 := engine.ParseLogMetrics(logContent2, false)
+ if len(metrics2.ToolCalls) != 2 {
+ t.Errorf("Expected 2 unique tool types, got %d", len(metrics2.ToolCalls))
+ }
+ // Check total call count
+ totalCalls := 0
+ for _, tc := range metrics2.ToolCalls {
+ totalCalls += tc.CallCount
+ }
+ if totalCalls != 3 {
+ t.Errorf("Expected 3 total tool calls, got %d", totalCalls)
+ }
+}
+
+func TestOpenClawEngine_GetDefaultDetectionModel(t *testing.T) {
+ engine := NewOpenClawEngine()
+
+ model := engine.GetDefaultDetectionModel()
+ if model != "" {
+ t.Errorf("Expected empty default detection model, got '%s'", model)
+ }
+}
diff --git a/pkg/workflow/openclaw_mcp.go b/pkg/workflow/openclaw_mcp.go
new file mode 100644
index 00000000000..70ba3e4024a
--- /dev/null
+++ b/pkg/workflow/openclaw_mcp.go
@@ -0,0 +1,70 @@
+package workflow
+
+import (
+ "strings"
+
+ "github.com/github/gh-aw/pkg/logger"
+)
+
+var openclawMCPLog = logger.New("workflow:openclaw_mcp")
+
+// RenderMCPConfig renders the MCP configuration for OpenClaw engine
+func (e *OpenClawEngine) RenderMCPConfig(yaml *strings.Builder, tools map[string]any, mcpTools []string, workflowData *WorkflowData) {
+ openclawMCPLog.Printf("Rendering MCP config for OpenClaw: tool_count=%d, mcp_tool_count=%d", len(tools), len(mcpTools))
+
+ // Create unified renderer with OpenClaw-specific options
+ // OpenClaw uses JSON format (same as Claude) via ACP bridge
+ createRenderer := func(isLast bool) *MCPConfigRendererUnified {
+ return NewMCPConfigRenderer(MCPRendererOptions{
+ IncludeCopilotFields: false, // OpenClaw doesn't use Copilot-specific fields
+ InlineArgs: false, // Use multi-line args format
+ Format: "json",
+ IsLast: isLast,
+ ActionMode: GetActionModeFromWorkflowData(workflowData),
+ })
+ }
+
+ // Build gateway configuration for MCP config
+ gatewayConfig := buildMCPGatewayConfig(workflowData)
+
+ // Use shared JSON MCP config renderer
+ _ = RenderJSONMCPConfig(yaml, tools, mcpTools, workflowData, JSONMCPConfigOptions{
+ ConfigPath: "/tmp/gh-aw/mcp-config/mcp-servers.json",
+ GatewayConfig: gatewayConfig,
+ Renderers: MCPToolRenderers{
+ RenderGitHub: func(yaml *strings.Builder, githubTool any, isLast bool, workflowData *WorkflowData) {
+ renderer := createRenderer(isLast)
+ renderer.RenderGitHubMCP(yaml, githubTool, workflowData)
+ },
+ RenderPlaywright: func(yaml *strings.Builder, playwrightTool any, isLast bool) {
+ renderer := createRenderer(isLast)
+ renderer.RenderPlaywrightMCP(yaml, playwrightTool)
+ },
+ RenderSerena: func(yaml *strings.Builder, serenaTool any, isLast bool) {
+ renderer := createRenderer(isLast)
+ renderer.RenderSerenaMCP(yaml, serenaTool)
+ },
+ RenderCacheMemory: func(yaml *strings.Builder, isLast bool, workflowData *WorkflowData) {
+ // Cache-memory is not used as MCP server
+ },
+ RenderAgenticWorkflows: func(yaml *strings.Builder, isLast bool) {
+ renderer := createRenderer(isLast)
+ renderer.RenderAgenticWorkflowsMCP(yaml)
+ },
+ RenderSafeOutputs: func(yaml *strings.Builder, isLast bool, workflowData *WorkflowData) {
+ renderer := createRenderer(isLast)
+ renderer.RenderSafeOutputsMCP(yaml, workflowData)
+ },
+ RenderSafeInputs: func(yaml *strings.Builder, safeInputs *SafeInputsConfig, isLast bool) {
+ renderer := createRenderer(isLast)
+ renderer.RenderSafeInputsMCP(yaml, safeInputs, workflowData)
+ },
+ RenderWebFetch: func(yaml *strings.Builder, isLast bool) {
+ renderMCPFetchServerConfig(yaml, "json", " ", isLast, false)
+ },
+ RenderCustomMCPConfig: func(yaml *strings.Builder, toolName string, toolConfig map[string]any, isLast bool) error {
+ return renderCustomMCPConfigWrapperWithContext(yaml, toolName, toolConfig, isLast, workflowData)
+ },
+ },
+ })
+}