diff --git a/docs/src/content/docs/troubleshooting/common-issues.md b/docs/src/content/docs/troubleshooting/common-issues.md index d12e1616ef7..55096432a10 100644 --- a/docs/src/content/docs/troubleshooting/common-issues.md +++ b/docs/src/content/docs/troubleshooting/common-issues.md @@ -157,6 +157,68 @@ mcp-servers: API_KEY: "${{ secrets.MCP_API_KEY }}" ``` +### OpenCode/Crush MCP Tools Not Being Called + +When integrating OpenCode-compatible engines (such as `crush`) in AWF workflows (including smoke tests), runs can complete while never calling MCP tools or file tools. + +Use this `.crush.json` structure. Port `10004` is the default local AWF API proxy port (used with `--enable-api-proxy` for OpenCode/Crush-compatible routing), while `MCP_GATEWAY_PORT` is a placeholder for the MCP gateway port. + +```json +{ + "provider": { + "copilot-proxy": { + "name": "Copilot Proxy", + "type": "openai-compatible", + "baseURL": "http://host.docker.internal:10004", + "models": ["gpt-4.1", "claude-sonnet-4-6"] + } + }, + "model": "copilot-proxy/claude-sonnet-4-6", + "mcp": { + "safeoutputs": { + "type": "http", + "url": "http://host.docker.internal:${MCP_GATEWAY_PORT}/mcp/safeoutputs", + "headers": { "Authorization": "${MCP_GATEWAY_API_KEY}" }, + "disabled": false, + "timeout": 30000 + } + }, + "agent": { + "build": { + "permission": { + "bash": "allow", + "edit": "allow", + "read": "allow", + "glob": "allow", + "grep": "allow", + "write": "allow", + "external_directory": "allow" + } + } + } +} +``` + +`MCP_GATEWAY_PORT` and `MCP_GATEWAY_API_KEY` are placeholders that are expanded from workflow environment variables when AWF renders the config at runtime. When running outside workflow context (such as local development), replace them with concrete values before writing `.crush.json`. + +Key gotchas: + +- Crush/OpenCode do not auto-discover MCP servers. Add an explicit top-level `mcp` section. +- Use routed gateway URLs: `http://host.docker.internal:${MCP_GATEWAY_PORT}/mcp/`. +- ⚠️ Use `agent.build.permission` (singular). Using `permissions` (plural) is silently ignored by OpenCode-compatible config loaders, which leaves tools unavailable even though the run continues. +- In non-interactive mode (such as when running `crush run` in CI or AWF workflows), `external_directory` defaults to `ask`, which becomes an implicit deny without terminal prompts. Set it to `allow` only when the agent must access paths outside its primary workspace, such as `/tmp` or mounted external directories. +- For direct Copilot-compatible endpoints (`api.githubcopilot.com`), do not append `/v1` to the base URL. For other OpenAI-compatible providers, use the provider's expected base path (for example `https://models.inference.ai.azure.com`) so the client can append `/chat/completions` correctly. +- If you route through the local proxy (`http://host.docker.internal:10004`), keep the proxy URL as-is. +- When running through AWF `--enable-api-proxy`, provide `COPILOT_GITHUB_TOKEN` in the same execute step `env:` so the proxy can authenticate. + +```yaml wrap +- name: Execute + env: + COPILOT_GITHUB_TOKEN: ${{ steps.copilot-token.outputs.token }} + run: | + awf --enable-api-proxy -- crush run "" +``` + ### Playwright Network Access Denied Add domains to `network.allowed`: diff --git a/pkg/workflow/crush_engine.go b/pkg/workflow/crush_engine.go index 4243308cb31..106d718cb93 100644 --- a/pkg/workflow/crush_engine.go +++ b/pkg/workflow/crush_engine.go @@ -278,7 +278,9 @@ func (e *CrushEngine) GetExecutionSteps(workflowData *WorkflowData, logFile stri // to prevent CI hanging on permission prompts. func (e *CrushEngine) generateCrushConfigStep(_ *WorkflowData) GitHubActionStep { // Build the config JSON with all permissions set to allow - configJSON := `{"agent":{"build":{"permissions":{"bash":"allow","edit":"allow","read":"allow","glob":"allow","grep":"allow","write":"allow","webfetch":"allow","websearch":"allow"}}}}` + // OpenCode/Crush uses "permission" (singular) — "permissions" (plural) is silently ignored. + // "external_directory" must be "allow" in non-interactive CI mode (defaults to "ask" → implicit deny). + configJSON := `{"agent":{"build":{"permission":{"bash":"allow","edit":"allow","read":"allow","glob":"allow","grep":"allow","write":"allow","webfetch":"allow","websearch":"allow","external_directory":"allow"}}}}` // Shell command to write or merge the config with restrictive permissions command := fmt.Sprintf(`umask 077 diff --git a/pkg/workflow/crush_engine_test.go b/pkg/workflow/crush_engine_test.go index b7004818f85..977e7d4d4bb 100644 --- a/pkg/workflow/crush_engine_test.go +++ b/pkg/workflow/crush_engine_test.go @@ -332,7 +332,9 @@ func TestCrushEngineExecution(t *testing.T) { assert.Contains(t, configContent, "Write Crush Config", "First step should be Write Crush Config") assert.Contains(t, configContent, ".crush.json", "Config step should reference .crush.json") - assert.Contains(t, configContent, "permissions", "Config step should set permissions") + assert.Contains(t, configContent, `"permission"`, "Config step should use 'permission' (singular, not 'permissions')") + assert.Contains(t, configContent, `"external_directory":"allow"`, "Config step should allow external_directory for non-interactive CI") + assert.NotContains(t, configContent, `"permissions"`, "Config step must NOT use 'permissions' (plural) — silently ignored by OpenCode)") assert.Contains(t, execContent, "Execute Crush CLI", "Second step should be Execute Crush CLI") }) }