From 33a399e6939d44af1bac11f411a355f06105bd33 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 27 Oct 2026 00:59:46 +0000 Subject: [PATCH 1/4] Initial plan From b0bc79ab5eaabef48c8630340626d166735b4a8c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 27 Oct 2026 01:32:52 +0000 Subject: [PATCH 2/4] Support Claude Vertex AI authentication with GCP WIF Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/claude_harness.test.cjs | 67 ++++++ docs/src/content/docs/engines/claude.md | 2 + docs/src/content/docs/reference/auth.mdx | 70 +++++- pkg/constants/constants.go | 11 + pkg/workflow/claude_engine.go | 48 ++++- pkg/workflow/claude_vertex_test.go | 262 +++++++++++++++++++++++ pkg/workflow/sandbox_validation.go | 8 + 7 files changed, 462 insertions(+), 6 deletions(-) create mode 100644 pkg/workflow/claude_vertex_test.go diff --git a/actions/setup/js/claude_harness.test.cjs b/actions/setup/js/claude_harness.test.cjs index 9230db32e95..1f009a2cfaf 100644 --- a/actions/setup/js/claude_harness.test.cjs +++ b/actions/setup/js/claude_harness.test.cjs @@ -380,6 +380,19 @@ describe("claude_harness.cjs", () => { expect(await buildClaudeChildEnv(undefined, env, () => {})).toMatchObject(env); }); + it("resolves Vertex models without an Anthropic endpoint", async () => { + const env = { + GH_AW_LLM_PROVIDER: "vertex", + ANTHROPIC_MODEL: "claude-sonnet-4-6", + CLAUDE_CODE_USE_VERTEX: "1", + CLAUDE_CODE_SKIP_VERTEX_AUTH: "1", + ANTHROPIC_VERTEX_BASE_URL: "http://api-proxy:10004/v1", + ANTHROPIC_API_KEY: "", + }; + const reflect = { endpoints: [{ provider: "vertex", configured: true, port: 10004, models: ["claude-sonnet-4-6"] }] }; + expect(await buildClaudeChildEnv(reflect, env, () => {})).toMatchObject(env); + }); + it("normalizes a dynamically selected fallback model on the provisioned route", async () => { const child = await buildClaudeChildEnv(reflectData, { GH_AW_LLM_PROVIDER: "github", GH_AW_MODEL_FALLBACK: "copilot/claude-sonnet-4.6" }, () => {}); expect(child.ANTHROPIC_MODEL).toBe("claude-sonnet-4.6"); @@ -1199,6 +1212,40 @@ process.exit(0); expect(result.stderr).toContain("output produced but no Claude session progress — retrying startup as fresh run"); }); + it("retries a pending Vertex token exchange before the first assistant response", () => { + const { result, calls } = runHarnessWithStub({ + extraEnv: { + GH_AW_LLM_PROVIDER: "vertex", + ANTHROPIC_MODEL: "claude-sonnet-4-6", + CLAUDE_CODE_USE_VERTEX: "1", + CLAUDE_CODE_SKIP_VERTEX_AUTH: "1", + ANTHROPIC_VERTEX_BASE_URL: "http://api-proxy:10004/v1", + CLAUDE_CODE_MAX_RETRIES: "8", + }, + stubScript: ` +const fs = require("fs"); +const callsPath = process.env.CLAUDE_HARNESS_STUB_CALLS; +const pending = !fs.existsSync(callsPath); +fs.appendFileSync(callsPath, JSON.stringify({ + args: process.argv.slice(2), stdin: fs.readFileSync(0, "utf8"), + vertex: process.env.CLAUDE_CODE_USE_VERTEX, retries: process.env.CLAUDE_CODE_MAX_RETRIES, + baseUrl: process.env.ANTHROPIC_VERTEX_BASE_URL +}) + "\\n"); +console.log(JSON.stringify({ type: "system", subtype: "init", session_id: "vertex-session" })); +if (pending) { + console.log(JSON.stringify({ type: "result", subtype: "error_during_execution", is_error: true, result: "503 service_unavailable: token exchange pending" })); + process.exit(1); +} +console.log(JSON.stringify({ type: "assistant", session_id: "vertex-session", message: { content: [{ type: "text", text: "Vertex ready" }] } })); +`, + }); + expect(result.status, result.stderr).toBe(0); + expect(calls).toHaveLength(2); + expect(calls.map(call => call.args.includes("--resume"))).toEqual([false, false]); + expect(calls[1]).toMatchObject({ vertex: "1", retries: "8", baseUrl: "http://api-proxy:10004/v1" }); + expect(result.stdout).toContain("Vertex ready"); + }); + it("does not fall through to --continue after no-progress output exhausts startup retries", () => { const stubScript = ` const fs = require("fs"); @@ -1741,6 +1788,26 @@ describe("Claude sub-agent models before spawn (github/gh-aw#70667)", () => { expect(records).toContainEqual(resolved); }); + it("checks declared sub-agent models against the Vertex catalog", () => { + const reflectData = runs.typistReflect(); + reflectData.endpoints = reflectData.endpoints.filter(endpoint => endpoint.provider === "anthropic"); + reflectData.endpoints[0].provider = "vertex"; + reflectData.endpoints[0].port = 10004; + const { result, calls, records } = runWithStagedAgent({ + agentContent: typistAgent().replace("model: small", "model: claude-haiku-5-5"), + reflectData, + env: { + GH_AW_LLM_PROVIDER: "vertex", + CLAUDE_CODE_USE_VERTEX: "1", + CLAUDE_CODE_SKIP_VERTEX_AUTH: "1", + ANTHROPIC_VERTEX_BASE_URL: "http://api-proxy:10004/v1", + }, + }); + expect(result.status, result.stderr).toBe(0); + expect(calls).toHaveLength(1); + expect(records).toContainEqual(expect.objectContaining({ role: "duplicate-type-finder", provider: "vertex", resolved_model: "claude-haiku-5-5" })); + }); + it("claude_declared_dead_or_unknown_model_fails_before_spawn", () => { // Fixture mutation: the recorded catalogue without claude-haiku-5-5, requested explicitly. const reflectData = runs.typistReflect(); diff --git a/docs/src/content/docs/engines/claude.md b/docs/src/content/docs/engines/claude.md index b7512473543..9d02c7dcd49 100644 --- a/docs/src/content/docs/engines/claude.md +++ b/docs/src/content/docs/engines/claude.md @@ -9,6 +9,8 @@ Run Claude Code on GitHub events using an Anthropic API key or GitHub Copilot in Set `engine: claude` and add an [`ANTHROPIC_API_KEY`](/gh-aw/reference/auth/#anthropic_api_key) repository secret. For keyless authentication, use [Anthropic WIF](/gh-aw/reference/auth/#anthropic-workload-identity-federation-wif). +To use Google Cloud billing and GCP WIF instead, configure [Claude on Vertex AI](/gh-aw/reference/auth/#claude-on-vertex-ai). This requires Model Garden access and the AWF Vertex adapter; no Anthropic key is needed. + To bill inference through Copilot instead, add `model: copilot/auto` and `permissions: { copilot-requests: write }`. No Anthropic key is needed. ## Run a daily report diff --git a/docs/src/content/docs/reference/auth.mdx b/docs/src/content/docs/reference/auth.mdx index 5b36a022bbd..e6ca112958c 100644 --- a/docs/src/content/docs/reference/auth.mdx +++ b/docs/src/content/docs/reference/auth.mdx @@ -414,9 +414,9 @@ When WIF is active, the compiler suppresses the static-key requirement, switches Vertex AI backend (`GOOGLE_GENAI_USE_VERTEXAI=true`), and emits `AWF_AUTH_GCP_*` environment variables consumed by the AWF firewall api-proxy sidecar. -This supported WIF path remains specific to `engine: gemini`. Agy ADC/WIF and -Vertex AI support require a separate verified profile; do not migrate a Gemini -WIF workflow to Agy. +Claude also supports GCP WIF through the Vertex adapter, as described below. +Agy ADC/WIF and Vertex AI support require a separate verified profile; do not +migrate a WIF workflow to Agy. **Prerequisites:** - A Google Cloud Workload Identity Pool and Provider configured for GitHub Actions @@ -468,6 +468,70 @@ Vertex AI backend env vars for the Gemini CLI: Additionally, `GOOGLE_GENAI_USE_VERTEXAI=true` is always set when GCP WIF is active. +#### Claude on Vertex AI + +For `engine: claude`, use the same GCP WIF prerequisites and fields, keep the agent +sandbox enabled, and enable the selected Claude model in the project's +[Vertex AI Model Garden](https://cloud.google.com/gemini-enterprise-agent-platform/models/partner-models/claude/use-claude). +Enable the Vertex AI API and ensure that the project has billing and model quota. +Use a Claude model ID available in your chosen location; do not assume that a +Gemini region or an Anthropic-hosted model ID is available on Vertex. +Check the model card and [Claude Code's Vertex documentation](https://code.claude.com/docs/en/google-vertex-ai) +for current model IDs and regional availability before running. + +```yaml wrap +permissions: + contents: read + id-token: write + +engine: + id: claude + auth: + type: github-oidc + provider: gcp + workload-identity-provider: projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/POOL/providers/PROVIDER + service-account: claude@my-project.iam.gserviceaccount.com + project: my-project + location: global +model: claude-sonnet-4-6 +``` + +Claude defaults `auth.location` to `global`, not Gemini's `us-central1`. Global +availability is model-specific and does not provide regional data residency. +Set an explicitly supported regional location when residency is required. + +The compiler applies these values after `engine.env` and sandbox environment merges: + +| Environment variable | Value | +|---|---| +| `CLAUDE_CODE_USE_VERTEX` | `1` | +| `CLAUDE_CODE_SKIP_VERTEX_AUTH` | `1` (the sidecar authenticates requests) | +| `ANTHROPIC_VERTEX_BASE_URL` | Internal Vertex adapter URL on port `10004`, ending in `/v1` | +| `ANTHROPIC_VERTEX_PROJECT_ID` | `auth.project` | +| `CLOUD_ML_REGION` | `auth.location`, defaulting to `global` | +| `CLAUDE_CODE_MAX_RETRIES` | `8`, allowing retryable startup 503s during token exchange | + +No `ANTHROPIC_API_KEY` secret is required. The execution step clears Anthropic +API-key, bearer-token, subscription-token, and direct-API URL values so unrelated +runner credentials cannot select Anthropic authentication. GCP token exchange stays +in the AWF api-proxy sidecar; do not provide ADC files or access tokens to the agent. +The adapter URL uses `api-proxy` in isolated Docker/microVM topologies and +`host.docker.internal` in host-access or standalone detection/eval topologies. +Both proxy-bypass environment variables include the adapter endpoint. + +For projects protected by VPC Service Controls, configure +[perimeter ingress](https://cloud.google.com/vpc-service-controls/docs/ingress-egress-rules) +for the runner's origin and impersonated service account to access Vertex AI +(`aiplatform.googleapis.com`), including Claude prediction methods. Ensure that +the WIF token exchange can reach `sts.googleapis.com` and +`iamcredentials.googleapis.com`, with any required perimeter rules for those +services. WIF configuration alone does not grant perimeter ingress. + +> [!NOTE] +> This configures the compiler's Claude Vertex path, not the firewall adapter itself. +> The separate [api-proxy issue #71987](https://github.com/github/gh-aw/issues/71987) +> can still prevent inference. End-to-end inference through gh-aw has not been verified. + --- ## Troubleshooting auth errors diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go index ac04bab1038..6508d208df1 100644 --- a/pkg/constants/constants.go +++ b/pkg/constants/constants.go @@ -199,6 +199,9 @@ const ( // GeminiLLMGatewayPort is the port for the Gemini LLM gateway GeminiLLMGatewayPort = 10003 + + // VertexLLMGatewayPort is the port for AWF's Vertex AI adapter. + VertexLLMGatewayPort = 10004 ) // AWFNoProxyHosts is the value for the NO_PROXY and no_proxy environment variables @@ -222,6 +225,14 @@ const AWFNoProxyHosts = "localhost,127.0.0.1," + AWFAPIProxyContainerIP + ":10002," + AWFAPIProxyContainerIP + ":10003" +// AWFVertexNoProxyHosts adds the Vertex adapter endpoint without changing +// proxy bypass configuration for workflows that do not use Vertex. +const AWFVertexNoProxyHosts = AWFNoProxyHosts + "," + + "host.docker.internal:10004," + + AWFAPIProxyHostname + "," + + AWFAPIProxyHostname + ":10004," + + AWFAPIProxyContainerIP + ":10004" + // DefaultGitHubLockdown is the default value for the GitHub MCP server lockdown setting. // Lockdown mode restricts the GitHub MCP server to the triggering repository only. // Defaults to false (lockdown disabled). diff --git a/pkg/workflow/claude_engine.go b/pkg/workflow/claude_engine.go index 814f8d404aa..ded6855f562 100644 --- a/pkg/workflow/claude_engine.go +++ b/pkg/workflow/claude_engine.go @@ -72,7 +72,7 @@ func (e *ClaudeEngine) GetAPMTarget() string { } // GetRequiredSecretNames returns the list of secrets required by the Claude engine. -// When Anthropic WIF (github-oidc + provider=anthropic) is configured, no static API key +// When Anthropic or Vertex WIF is configured, no static API key // is needed and only common MCP secrets are returned. func (e *ClaudeEngine) GetRequiredSecretNames(workflowData *WorkflowData) []string { provider := e.ResolveLLMProvider(workflowData) @@ -91,7 +91,7 @@ func (e *ClaudeEngine) GetSupportedEnvVarKeys() []string { } // GetSecretValidationStep returns the secret validation step for the Claude engine. -// Returns an empty step if custom command is specified or if Anthropic WIF is configured. +// Returns an empty step if custom command is specified or if Anthropic or Vertex WIF is configured. func (e *ClaudeEngine) GetSecretValidationStep(workflowData *WorkflowData) GitHubActionStep { provider := e.ResolveLLMProvider(workflowData) return BuildEngineSecretValidationStep(workflowData, EngineSecretValidationConfig{ @@ -105,7 +105,7 @@ func (e *ClaudeEngine) GetSecretValidationStep(workflowData *WorkflowData) GitHu } func claudeSkipsStaticSecret(provider LLMProvider, workflowData *WorkflowData) bool { - return provider == LLMProviderAnthropic && isAnthropicWIF(workflowData) || + return provider == LLMProviderAnthropic && (isAnthropicWIF(workflowData) || isClaudeVertexWIF(workflowData)) || provider == LLMProviderGitHub && hasCopilotRequestsWritePermission(workflowData) } @@ -119,6 +119,18 @@ func isAnthropicWIF(workflowData *WorkflowData) bool { return auth.Type == "github-oidc" && auth.Provider == "anthropic" } +// isClaudeVertexWIF requires the same GCP WIF fields as the Gemini Vertex path. +func isClaudeVertexWIF(workflowData *WorkflowData) bool { + if workflowData == nil || workflowData.EngineConfig == nil || workflowData.EngineConfig.Auth == nil { + return false + } + auth := workflowData.EngineConfig.Auth + return auth.Type == "github-oidc" && auth.Provider == "gcp" && + auth.GoogleWorkloadIdentityProvider != "" && + auth.GoogleServiceAccount != "" && + auth.GoogleProject != "" +} + func (e *ClaudeEngine) GetInstallationSteps(workflowData *WorkflowData) []GitHubActionStep { claudeLog.Printf("Generating installation steps for Claude engine: workflow=%s", workflowData.Name) @@ -527,9 +539,39 @@ func (e *ClaudeEngine) buildClaudeCommandEnv(workflowData *WorkflowData) map[str applyMCPScriptsSecretEnv(env, workflowData) applyModelRoutingEnv(env, workflowData) applyModelAliasesEnv(env, workflowData) + if isClaudeVertexWIF(workflowData) { + applyClaudeVertexEnv(env, workflowData) + } return env } +// applyClaudeVertexEnv runs after environment merges so authentication and routing +// remain sidecar-owned, even when the runner has unrelated Anthropic credentials. +func applyClaudeVertexEnv(env map[string]string, workflowData *WorkflowData) { + auth := workflowData.EngineConfig.Auth + env["GH_AW_LLM_PROVIDER"] = "vertex" + env["CLAUDE_CODE_USE_VERTEX"] = "1" + env["CLAUDE_CODE_SKIP_VERTEX_AUTH"] = "1" + env["ANTHROPIC_VERTEX_BASE_URL"] = llmGatewayBaseURL(constants.VertexLLMGatewayPort, workflowData) + "/v1" //nolint:manualpathconcat // This is a URL, not a filesystem path. + env["ANTHROPIC_VERTEX_PROJECT_ID"] = auth.GoogleProject + location := auth.GoogleLocation + if location == "" { + location = "global" + } + env["CLOUD_ML_REGION"] = location + // The adapter returns retryable 503s while its first WIF exchange is pending. + env["CLAUDE_CODE_MAX_RETRIES"] = "8" + env["NO_PROXY"] = constants.AWFVertexNoProxyHosts + env["no_proxy"] = constants.AWFVertexNoProxyHosts + // Empty expressions shadow inherited credentials without emitting YAML nulls. + env["ANTHROPIC_API_KEY"] = "${{ '' }}" + env["ANTHROPIC_AUTH_TOKEN"] = "${{ '' }}" + env["CLAUDE_CODE_OAUTH_TOKEN"] = "${{ '' }}" + env["ANTHROPIC_BASE_URL"] = "${{ '' }}" + env["CLAUDE_CODE_USE_BEDROCK"] = "0" + env["CLAUDE_CODE_USE_FOUNDRY"] = "0" +} + // buildClaudeBaseEnvMap returns the initial Claude execution environment with static flags // and well-known GitHub Actions context values. func buildClaudeBaseEnvMap(provider LLMProvider, workflowData *WorkflowData) map[string]string { diff --git a/pkg/workflow/claude_vertex_test.go b/pkg/workflow/claude_vertex_test.go new file mode 100644 index 00000000000..9bc34272ae7 --- /dev/null +++ b/pkg/workflow/claude_vertex_test.go @@ -0,0 +1,262 @@ +//go:build !integration + +package workflow + +import ( + "fmt" + "maps" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + + "github.com/github/gh-aw/pkg/constants" + "github.com/github/gh-aw/pkg/stringutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func claudeVertexTestData() *WorkflowData { + return &WorkflowData{ + Name: "claude-vertex", + Model: "claude-sonnet-4-6", + ParsedTools: &ToolsConfig{}, + Tools: map[string]any{}, + EngineConfig: &EngineConfig{ + ID: "claude", + Auth: &EngineAuthConfig{ + Type: "github-oidc", + Provider: "gcp", + GoogleWorkloadIdentityProvider: "projects/123/locations/global/workloadIdentityPools/pool/providers/github", + GoogleServiceAccount: "claude@my-project.iam.gserviceaccount.com", + GoogleProject: "my-project", + }, + }, + SandboxConfig: &SandboxConfig{Agent: &AgentSandboxConfig{ID: "awf"}}, + } +} + +func TestIsClaudeVertexWIF(t *testing.T) { + assert.False(t, isClaudeVertexWIF(nil)) + assert.False(t, isClaudeVertexWIF(&WorkflowData{})) + assert.False(t, isClaudeVertexWIF(&WorkflowData{EngineConfig: &EngineConfig{}})) + for _, field := range []string{"complete", "type", "provider", "workload-identity-provider", "service-account", "project"} { + t.Run(field, func(t *testing.T) { + data := claudeVertexTestData() + auth := data.EngineConfig.Auth + switch field { + case "type": + auth.Type = "other" + case "provider": + auth.Provider = "anthropic" + case "workload-identity-provider": + auth.GoogleWorkloadIdentityProvider = "" + case "service-account": + auth.GoogleServiceAccount = "" + case "project": + auth.GoogleProject = "" + } + assert.Equal(t, field == "complete", isClaudeVertexWIF(data)) + assert.Equal(t, isGeminiVertexWIF(data), isClaudeVertexWIF(data)) + }) + } +} + +func TestClaudeVertexEnvironment(t *testing.T) { + engine := NewClaudeEngine() + for _, runtime := range supportedAgentRuntimes { + for _, phase := range []string{"agent", "detection", "evals"} { + t.Run(string(runtime)+"/"+phase, func(t *testing.T) { + data := claudeVertexTestData() + data.SandboxConfig.Agent.Runtime = runtime + data.IsDetectionRun = phase == "detection" + data.IsEvalsRun = phase == "evals" + env := engine.buildClaudeCommandEnv(data) + host := constants.AWFAPIProxyHostname + if runtime == AgentRuntimeDockerSudoIptables || + (phase != "agent" && runtime != AgentRuntimeNVX) { + host = "host.docker.internal" + } + assert.Equal(t, "http://"+host+":10004/v1", env["ANTHROPIC_VERTEX_BASE_URL"]) + assert.Equal(t, "my-project", env["ANTHROPIC_VERTEX_PROJECT_ID"]) + assert.Equal(t, "vertex", env["GH_AW_LLM_PROVIDER"]) + assert.Equal(t, "global", env["CLOUD_ML_REGION"]) + assert.Equal(t, "1", env["CLAUDE_CODE_USE_VERTEX"]) + assert.Equal(t, "1", env["CLAUDE_CODE_SKIP_VERTEX_AUTH"]) + assert.Equal(t, "8", env["CLAUDE_CODE_MAX_RETRIES"]) + assert.Equal(t, data.Model, env["ANTHROPIC_MODEL"]) + for _, key := range []string{"ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_BASE_URL"} { + assert.Equal(t, "${{ '' }}", env[key], key) + } + for _, key := range []string{"NO_PROXY", "no_proxy"} { + assert.Contains(t, strings.Split(env[key], ","), host+":10004") + } + proxyURL, err := url.Parse(env["ANTHROPIC_VERTEX_BASE_URL"]) + require.NoError(t, err) + assert.Equal(t, "10004", proxyURL.Port()) + }) + } + } +} + +func TestClaudeVertexEnvironmentOverrides(t *testing.T) { + engine := NewClaudeEngine() + for _, source := range []string{"engine", "agent"} { + t.Run(source, func(t *testing.T) { + data := claudeVertexTestData() + data.EngineConfig.Auth.GoogleLocation = "europe-west1" + overrides := map[string]string{ + "CLAUDE_CODE_USE_VERTEX": "0", + "CLAUDE_CODE_SKIP_VERTEX_AUTH": "0", + "ANTHROPIC_VERTEX_BASE_URL": "https://example.com", + "ANTHROPIC_VERTEX_PROJECT_ID": "other-project", + "CLOUD_ML_REGION": "us-central1", + "CLAUDE_CODE_MAX_RETRIES": "0", + "ANTHROPIC_API_KEY": "${{ secrets.UNRELATED_KEY }}", + "ANTHROPIC_AUTH_TOKEN": "unrelated-token", + "CLAUDE_CODE_OAUTH_TOKEN": "unrelated-token", + "ANTHROPIC_BASE_URL": "https://api.anthropic.com", + "NO_PROXY": "example.com", + "no_proxy": "example.com", + } + if source == "engine" { + data.EngineConfig.Env = maps.Clone(overrides) + } else { + data.SandboxConfig.Agent.Env = maps.Clone(overrides) + } + env := engine.buildClaudeCommandEnv(data) + assert.Equal(t, "europe-west1", env["CLOUD_ML_REGION"]) + assert.Equal(t, "my-project", env["ANTHROPIC_VERTEX_PROJECT_ID"]) + assert.Equal(t, "http://api-proxy:10004/v1", env["ANTHROPIC_VERTEX_BASE_URL"]) + assert.Equal(t, "1", env["CLAUDE_CODE_USE_VERTEX"]) + assert.Equal(t, "1", env["CLAUDE_CODE_SKIP_VERTEX_AUTH"]) + assert.Equal(t, "8", env["CLAUDE_CODE_MAX_RETRIES"]) + assert.Equal(t, constants.AWFVertexNoProxyHosts, env["NO_PROXY"]) + assert.Equal(t, constants.AWFVertexNoProxyHosts, env["no_proxy"]) + for _, key := range []string{"ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_BASE_URL"} { + assert.Equal(t, "${{ '' }}", env[key], key) + } + assert.NotContains(t, engine.GetRequiredSecretNames(data), "ANTHROPIC_API_KEY") + assert.Empty(t, engine.GetSecretValidationStep(data)) + filtered := FilterEnvForSecrets(env, engine.GetRequiredSecretNames(data)) + assert.Equal(t, "${{ '' }}", filtered["ANTHROPIC_API_KEY"]) + }) + } +} + +func TestClaudeVertexPreservesAnthropicModes(t *testing.T) { + engine := NewClaudeEngine() + for _, wif := range []bool{false, true} { + t.Run(strconv.FormatBool(wif), func(t *testing.T) { + data := claudeVertexTestData() + data.EngineConfig.Auth = nil + if wif { + data.EngineConfig.Auth = &EngineAuthConfig{Type: "github-oidc", Provider: "anthropic"} + } + assert.Equal(t, !wif, len(engine.GetSecretValidationStep(data)) > 0) + assert.Equal(t, !wif, strings.Contains(strings.Join(engine.GetRequiredSecretNames(data), ","), "ANTHROPIC_API_KEY")) + env := engine.buildClaudeCommandEnv(data) + assert.Equal(t, "${{ secrets.ANTHROPIC_API_KEY }}", env["ANTHROPIC_API_KEY"]) + assert.Equal(t, "0", env["CLAUDE_CODE_MAX_RETRIES"]) + assert.NotContains(t, env, "CLAUDE_CODE_USE_VERTEX") + assert.NotContains(t, env, "ANTHROPIC_VERTEX_BASE_URL") + }) + } +} + +func TestClaudeVertexRequiresSandbox(t *testing.T) { + data := claudeVertexTestData() + data.SandboxConfig.Agent.Disabled = true + data.Features = map[string]any{"dangerously-disable-sandbox-agent": true} + require.ErrorContains(t, validateSandboxConfig(data), "Claude Vertex WIF requires the agent sandbox") + data.EngineConfig.Auth = nil + require.NoError(t, validateSandboxConfig(data)) +} + +func TestClaudeVertexCompilation(t *testing.T) { + t.Setenv("ANTHROPIC_API_KEY", "unrelated-runner-key") + for _, runtime := range []AgentRuntime{AgentRuntimeDocker, AgentRuntimeDockerSudoIptables} { + t.Run(string(runtime), func(t *testing.T) { + source := fmt.Sprintf(`--- +on: workflow_dispatch +strict: false +engine: + id: claude + auth: + type: github-oidc + provider: gcp + workload-identity-provider: projects/123/locations/global/workloadIdentityPools/pool/providers/github + service-account: claude@my-project.iam.gserviceaccount.com + project: my-project + location: global + env: + ANTHROPIC_API_KEY: ${{ secrets.UNRELATED_KEY }} + ANTHROPIC_VERTEX_BASE_URL: https://api.anthropic.com + ANTHROPIC_VERTEX_PROJECT_ID: wrong-project + CLOUD_ML_REGION: us-central1 + CLAUDE_CODE_MAX_RETRIES: "0" +model: claude-sonnet-4-6 +permissions: + contents: read + id-token: write +sandbox: + agent: + runtime: %s + env: + CLOUD_ML_REGION: wrong-region +checkout: false +tools: + github: false +safe-outputs: + noop: + threat-detection: false +--- +Call noop once. +`, runtime) + workflowPath := filepath.Join(t.TempDir(), "claude-vertex.md") + require.NoError(t, os.WriteFile(workflowPath, []byte(source), 0600)) + compiler := NewCompiler() + require.NoError(t, compiler.CompileWorkflow(workflowPath)) + lock, err := os.ReadFile(stringutil.MarkdownToLockFile(workflowPath)) + require.NoError(t, err) + agent := extractJobSection(string(lock), "agent") + host := "api-proxy" + if runtime == AgentRuntimeDockerSudoIptables { + host = "host.docker.internal" + } + for _, value := range []string{ + "AWF_AUTH_TYPE: github-oidc", + "AWF_AUTH_PROVIDER: gcp", + "GH_AW_LLM_PROVIDER: vertex", + "AWF_AUTH_GCP_WORKLOAD_IDENTITY_PROVIDER: projects/123/locations/global/workloadIdentityPools/pool/providers/github", + "AWF_AUTH_GCP_SERVICE_ACCOUNT: claude@my-project.iam.gserviceaccount.com", + "ANTHROPIC_VERTEX_BASE_URL: http://" + host + ":10004/v1", + "ANTHROPIC_VERTEX_PROJECT_ID: my-project", + "CLOUD_ML_REGION: global", + "CLAUDE_CODE_USE_VERTEX: 1", + "CLAUDE_CODE_SKIP_VERTEX_AUTH: 1", + "CLAUDE_CODE_MAX_RETRIES: 8", + "ANTHROPIC_API_KEY: ${{ '' }}", + "ANTHROPIC_AUTH_TOKEN: ${{ '' }}", + "CLAUDE_CODE_OAUTH_TOKEN: ${{ '' }}", + "ANTHROPIC_BASE_URL: ${{ '' }}", + "--exclude-env ANTHROPIC_API_KEY", + "--exclude-env ACTIONS_ID_TOKEN_REQUEST_TOKEN", + } { + assert.Contains(t, agent, value) + } + for _, value := range []string{ + "Validate ANTHROPIC_API_KEY", + "secrets.ANTHROPIC_API_KEY", + "secrets.UNRELATED_KEY", + "unrelated-runner-key", + "ANTHROPIC_BASE_URL: https://api.anthropic.com", + "GOOGLE_APPLICATION_CREDENTIALS:", + } { + assert.NotContains(t, agent, value) + } + }) + } +} diff --git a/pkg/workflow/sandbox_validation.go b/pkg/workflow/sandbox_validation.go index c0602f660c9..076e994eb9b 100644 --- a/pkg/workflow/sandbox_validation.go +++ b/pkg/workflow/sandbox_validation.go @@ -119,6 +119,14 @@ func validateSandboxConfig(workflowData *WorkflowData) error { //nolint:largefun "Enable the agent sandbox or select an Anthropic-hosted model.", ) } + if ResolveEngineID(workflowData) == string(constants.ClaudeEngine) && isClaudeVertexWIF(workflowData) { + return NewValidationError( + "sandbox.agent", + "false", + "Claude Vertex WIF requires the agent sandbox for sidecar authentication", + "Enable the agent sandbox. Example:\nsandbox:\n agent: awf", + ) + } } // Validate mounts syntax if specified in agent config From d6fec07f4d5dadf74463b7ce4a7877f7ca08af5f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 27 Oct 2026 10:39:31 +0000 Subject: [PATCH 3/4] Fix Claude Vertex review feedback Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- actions/setup/js/claude_harness.cjs | 2 +- actions/setup/js/claude_harness.test.cjs | 39 +++++++++++++++++---- actions/setup/js/engine_subagent_models.cjs | 29 +++++++++++---- actions/setup/js/resolve_model_alias.cjs | 20 ++++++++++- pkg/workflow/claude_engine.go | 6 +++- pkg/workflow/claude_vertex_test.go | 24 ++++++++++--- pkg/workflow/sandbox_validation.go | 19 ++++++++++ 7 files changed, 118 insertions(+), 21 deletions(-) diff --git a/actions/setup/js/claude_harness.cjs b/actions/setup/js/claude_harness.cjs index 324c324f373..48811c9f905 100644 --- a/actions/setup/js/claude_harness.cjs +++ b/actions/setup/js/claude_harness.cjs @@ -453,7 +453,7 @@ async function buildClaudeChildEnv(reflectData, env = process.env, logger = log, if (resolution.reflectData) reflectData = resolution.reflectData; childEnv.ANTHROPIC_MODEL = resolution.engineModel; } - const resolved = reflectData ? resolveProviderEndpointFromReflect({ provider: copilotProvider ? "github" : provider, reflectData, logger }) : null; + const resolved = provider === "vertex" ? null : reflectData ? resolveProviderEndpointFromReflect({ provider: copilotProvider ? "github" : provider, reflectData, logger }) : null; if (copilotProvider && (!resolved || !REFLECT_PROVIDER_ALIASES.github.has(normalizeReflectProviderName(resolved.endpointProvider)))) { throw new Error("Claude GitHub Copilot inference requires a configured Copilot endpoint from AWF /reflect; keep the agent sandbox enabled and configure permissions.copilot-requests: write or COPILOT_GITHUB_TOKEN"); } diff --git a/actions/setup/js/claude_harness.test.cjs b/actions/setup/js/claude_harness.test.cjs index 1f009a2cfaf..fdb1c71a69b 100644 --- a/actions/setup/js/claude_harness.test.cjs +++ b/actions/setup/js/claude_harness.test.cjs @@ -1788,13 +1788,15 @@ describe("Claude sub-agent models before spawn (github/gh-aw#70667)", () => { expect(records).toContainEqual(resolved); }); - it("checks declared sub-agent models against the Vertex catalog", () => { - const reflectData = runs.typistReflect(); - reflectData.endpoints = reflectData.endpoints.filter(endpoint => endpoint.provider === "anthropic"); - reflectData.endpoints[0].provider = "vertex"; - reflectData.endpoints[0].port = 10004; + it("resolves Claude aliases against a Vertex model catalog", async () => { + const reflectData = { + endpoints: [{ provider: "vertex", configured: true, port: 10004, models: ["claude-sonnet-5", "claude-haiku-5-5"] }], + }; + const child = await buildClaudeChildEnv(reflectData, { GH_AW_LLM_PROVIDER: "vertex", ANTHROPIC_MODEL: "sonnet" }, () => {}); + expect(child.ANTHROPIC_MODEL).toBe("claude-sonnet-5"); + const { result, calls, records } = runWithStagedAgent({ - agentContent: typistAgent().replace("model: small", "model: claude-haiku-5-5"), + agentContent: typistAgent(), reflectData, env: { GH_AW_LLM_PROVIDER: "vertex", @@ -1805,7 +1807,30 @@ describe("Claude sub-agent models before spawn (github/gh-aw#70667)", () => { }); expect(result.status, result.stderr).toBe(0); expect(calls).toHaveLength(1); - expect(records).toContainEqual(expect.objectContaining({ role: "duplicate-type-finder", provider: "vertex", resolved_model: "claude-haiku-5-5" })); + expect(records).toContainEqual(expect.objectContaining({ role: "duplicate-type-finder", configured_model: "small", provider: "vertex", resolved_model: "claude-haiku-5-5", status: "resolved" })); + }); + + it("passes through concrete Vertex sub-agent ids when AWF has no Vertex model catalogue", () => { + const reflectData = { + endpoints: [{ provider: "anthropic", configured: true, port: 10001, models: null, models_url: null }], + models_fetch_complete: false, + }; + const concreteModel = "claude-haiku-5-5"; + const { result, calls, records, workspaceAgent } = runWithStagedAgent({ + agentContent: typistAgent().replace("model: small", `model: ${concreteModel}`), + reflectData, + env: { + GH_AW_LLM_PROVIDER: "vertex", + CLAUDE_CODE_USE_VERTEX: "1", + CLAUDE_CODE_SKIP_VERTEX_AUTH: "1", + ANTHROPIC_VERTEX_BASE_URL: "http://api-proxy:10004/v1", + }, + }); + + expect(result.status, result.stderr).toBe(0); + expect(calls).toHaveLength(1); + expect(records).toContainEqual(expect.objectContaining({ role: "duplicate-type-finder", configured_model: concreteModel, provider: "vertex", resolved_model: concreteModel, status: "exact" })); + expect(workspaceAgent).toContain(`model: ${concreteModel}`); }); it("claude_declared_dead_or_unknown_model_fails_before_spawn", () => { diff --git a/actions/setup/js/engine_subagent_models.cjs b/actions/setup/js/engine_subagent_models.cjs index 872c04f7dc5..f92d93c325c 100644 --- a/actions/setup/js/engine_subagent_models.cjs +++ b/actions/setup/js/engine_subagent_models.cjs @@ -176,6 +176,17 @@ function classifyUnlistedModel(reflectData, providers) { return incomplete ? "catalog_unavailable" : "model_unavailable"; } +/** + * Whether the Vertex provider has no model catalogue endpoint in this AWF response. + * @param {any} reflectData + * @returns {boolean} + */ +function hasNoVertexModelCatalog(reflectData) { + if (!reflectData || !Array.isArray(reflectData.endpoints)) return false; + const endpoints = reflectData.endpoints.filter(endpoint => endpoint?.configured === true && normalizeReflectProviderName(endpoint.provider) === "vertex"); + return endpoints.length === 0 || endpoints.every(endpoint => !Array.isArray(endpoint.models) && endpoint.models_url == null); +} + /** * The per-model endpoint metadata (`routing_models`) published by the configured `/reflect` * endpoints of the engine's providers, or null when none of them publishes it. @@ -420,13 +431,17 @@ async function resolveEngineSubagentModels(options) { const resolvedNote = model !== configuredModel ? ` (resolved to '${model}')` : ""; if (liveCheck && !isModelAvailableInReflectData(model, reflectData, providers)) { const initialReason = classifyUnlistedModel(reflectData, providers); - const refreshed = await refreshOnce(); - if (!refreshed || !isModelAvailableInReflectData(model, refreshed, providers)) { - // A complete catalogue without the model already proves model_unavailable; only an - // incomplete one is reclassified, and only when a refreshed catalogue exists. - const reason = initialReason === "catalog_unavailable" && refreshed ? classifyUnlistedModel(refreshed, providers) : initialReason; - fail(new ModelAliasResolutionError(configuredModel, reason, { provider: result.provider || options.provider }), `Sub-agent model '${configuredModel}'${resolvedNote} is not listed on the configured awf-reflect endpoints.`); - continue; + if (options.provider === "vertex" && result.status === "exact" && initialReason === "catalog_unavailable" && hasNoVertexModelCatalog(reflectData)) { + logger(`claude sub-agent '${agent.name}': Vertex does not publish a model catalogue through awf-reflect; passing through concrete model id '${model}'`); + } else { + const refreshed = await refreshOnce(); + if (!refreshed || !isModelAvailableInReflectData(model, refreshed, providers)) { + // A complete catalogue without the model already proves model_unavailable; only an + // incomplete one is reclassified, and only when a refreshed catalogue exists. + const reason = initialReason === "catalog_unavailable" && refreshed ? classifyUnlistedModel(refreshed, providers) : initialReason; + fail(new ModelAliasResolutionError(configuredModel, reason, { provider: result.provider || options.provider }), `Sub-agent model '${configuredModel}'${resolvedNote} is not listed on the configured awf-reflect endpoints.`); + continue; + } } } if (wireApi) { diff --git a/actions/setup/js/resolve_model_alias.cjs b/actions/setup/js/resolve_model_alias.cjs index 178e34c1cee..cfa5bf14659 100644 --- a/actions/setup/js/resolve_model_alias.cjs +++ b/actions/setup/js/resolve_model_alias.cjs @@ -17,9 +17,19 @@ const MODEL_PROVIDER_PREFIXES = Object.freeze({ copilot: ["copilot", "github-copilot", "github"], openai: ["openai", "codex"], anthropic: ["anthropic"], + vertex: ["vertex"], gemini: ["gemini", "google"], }); +const CLAUDE_VERTEX_ALIAS_PATTERNS = Object.freeze({ + sonnet: ["vertex/*sonnet*"], + "sonnet-6x": ["vertex/*sonnet-4-5-*", "vertex/*sonnet-4-6*", "vertex/*sonnet-5*"], + haiku: ["vertex/*haiku*"], + opus: ["vertex/*opus*"], + fable: ["vertex/*fable*"], + any: ["vertex/*"], +}); + /** * A known alias could not resolve because its catalog is unavailable or no * catalog entry matches. Keep those causes distinct; neither permits forwarding @@ -725,7 +735,15 @@ async function resolveEngineModel(options) { const env = options.env || process.env; const role = options.role || "main"; const configuredModel = String(options.configuredModel || "").trim(); - const aliasMap = options.aliasMap !== undefined ? options.aliasMap : loadModelAliasMap({ reflectData: options.reflectData, env }); + let aliasMap = options.aliasMap !== undefined ? options.aliasMap : loadModelAliasMap({ reflectData: options.reflectData, env }); + if (options.engine === "claude" && canonicalModelProvider(options.provider || "") === "vertex") { + const vertexAliasMap = { ...(aliasMap && typeof aliasMap === "object" && !Array.isArray(aliasMap) ? aliasMap : {}) }; + for (const [alias, patterns] of Object.entries(CLAUDE_VERTEX_ALIAS_PATTERNS)) { + const existingPatterns = Array.isArray(vertexAliasMap[alias]) ? vertexAliasMap[alias] : []; + vertexAliasMap[alias] = [...existingPatterns, ...patterns.filter(pattern => !existingPatterns.includes(pattern))]; + } + aliasMap = vertexAliasMap; + } // Without AWF no /reflect catalogue can exist; an explicit catalogue or /reflect data overrides. const noCatalog = options.noCatalog ?? (env.AWF_REFLECT_ENABLED !== "1" && !options.reflectData && !Array.isArray(options.catalog)); const record = (/** @type {any} */ fields) => { diff --git a/pkg/workflow/claude_engine.go b/pkg/workflow/claude_engine.go index ded6855f562..4b655e7b648 100644 --- a/pkg/workflow/claude_engine.go +++ b/pkg/workflow/claude_engine.go @@ -405,6 +405,10 @@ func (e *ClaudeEngine) buildClaudeCommandString(workflowData *WorkflowData, clau claudeCommand = fmt.Sprintf(`%s${%s:+ --model "$%s"}`, claudeCommand, modelEnvVar, modelEnvVar) } + if isClaudeVertexWIF(workflowData) { + claudeCommand = `export no_proxy="${NO_PROXY:-}" && ` + claudeCommand + } + return claudeCommand } @@ -562,7 +566,7 @@ func applyClaudeVertexEnv(env map[string]string, workflowData *WorkflowData) { // The adapter returns retryable 503s while its first WIF exchange is pending. env["CLAUDE_CODE_MAX_RETRIES"] = "8" env["NO_PROXY"] = constants.AWFVertexNoProxyHosts - env["no_proxy"] = constants.AWFVertexNoProxyHosts + delete(env, "no_proxy") // Empty expressions shadow inherited credentials without emitting YAML nulls. env["ANTHROPIC_API_KEY"] = "${{ '' }}" env["ANTHROPIC_AUTH_TOKEN"] = "${{ '' }}" diff --git a/pkg/workflow/claude_vertex_test.go b/pkg/workflow/claude_vertex_test.go index 9bc34272ae7..c140e1d81de 100644 --- a/pkg/workflow/claude_vertex_test.go +++ b/pkg/workflow/claude_vertex_test.go @@ -90,9 +90,8 @@ func TestClaudeVertexEnvironment(t *testing.T) { for _, key := range []string{"ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_BASE_URL"} { assert.Equal(t, "${{ '' }}", env[key], key) } - for _, key := range []string{"NO_PROXY", "no_proxy"} { - assert.Contains(t, strings.Split(env[key], ","), host+":10004") - } + assert.Contains(t, strings.Split(env["NO_PROXY"], ","), host+":10004") + assert.NotContains(t, env, "no_proxy") proxyURL, err := url.Parse(env["ANTHROPIC_VERTEX_BASE_URL"]) require.NoError(t, err) assert.Equal(t, "10004", proxyURL.Port()) @@ -134,7 +133,7 @@ func TestClaudeVertexEnvironmentOverrides(t *testing.T) { assert.Equal(t, "1", env["CLAUDE_CODE_SKIP_VERTEX_AUTH"]) assert.Equal(t, "8", env["CLAUDE_CODE_MAX_RETRIES"]) assert.Equal(t, constants.AWFVertexNoProxyHosts, env["NO_PROXY"]) - assert.Equal(t, constants.AWFVertexNoProxyHosts, env["no_proxy"]) + assert.NotContains(t, env, "no_proxy") for _, key := range []string{"ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_BASE_URL"} { assert.Equal(t, "${{ '' }}", env[key], key) } @@ -146,6 +145,20 @@ func TestClaudeVertexEnvironmentOverrides(t *testing.T) { } } +func TestClaudeVertexRejectsConflictingModelProviders(t *testing.T) { + t.Run("GitHub provider", func(t *testing.T) { + data := claudeVertexTestData() + data.EngineConfig.LLMProvider = "github" + require.ErrorContains(t, validateSandboxConfig(data), "Claude Vertex WIF cannot be combined with a non-Anthropic engine.model-provider") + }) + t.Run("Copilot model", func(t *testing.T) { + data := claudeVertexTestData() + data.EngineConfig.LLMProvider = "anthropic" + data.Model = "copilot/auto" + require.ErrorContains(t, validateSandboxConfig(data), "Claude Vertex WIF cannot be combined with a copilot/ model") + }) +} + func TestClaudeVertexPreservesAnthropicModes(t *testing.T) { engine := NewClaudeEngine() for _, wif := range []bool{false, true} { @@ -238,6 +251,8 @@ Call noop once. "CLAUDE_CODE_USE_VERTEX: 1", "CLAUDE_CODE_SKIP_VERTEX_AUTH: 1", "CLAUDE_CODE_MAX_RETRIES: 8", + "NO_PROXY: " + constants.AWFVertexNoProxyHosts, + `export no_proxy="${NO_PROXY:-}"`, "ANTHROPIC_API_KEY: ${{ '' }}", "ANTHROPIC_AUTH_TOKEN: ${{ '' }}", "CLAUDE_CODE_OAUTH_TOKEN: ${{ '' }}", @@ -253,6 +268,7 @@ Call noop once. "secrets.UNRELATED_KEY", "unrelated-runner-key", "ANTHROPIC_BASE_URL: https://api.anthropic.com", + "no_proxy:", "GOOGLE_APPLICATION_CREDENTIALS:", } { assert.NotContains(t, agent, value) diff --git a/pkg/workflow/sandbox_validation.go b/pkg/workflow/sandbox_validation.go index 076e994eb9b..055842db7c7 100644 --- a/pkg/workflow/sandbox_validation.go +++ b/pkg/workflow/sandbox_validation.go @@ -79,6 +79,25 @@ func validateSandboxConfig(workflowData *WorkflowData) error { //nolint:largefun return nil } + if ResolveEngineID(workflowData) == string(constants.ClaudeEngine) && isClaudeVertexWIF(workflowData) { + if provider := NewClaudeEngine().ResolveLLMProvider(workflowData); provider != LLMProviderAnthropic { + return NewValidationError( + "engine.model-provider", + string(provider), + "Claude Vertex WIF cannot be combined with a non-Anthropic engine.model-provider", + "Remove engine.model-provider and use a Claude model id or alias supported by Vertex AI.", + ) + } + if strings.HasPrefix(strings.ToLower(strings.TrimSpace(workflowData.Model)), "copilot/") { + return NewValidationError( + "model", + workflowData.Model, + "Claude Vertex WIF cannot be combined with a copilot/ model", + "Use a Claude model id or alias supported by Vertex AI.", + ) + } + } + if workflowData.SandboxConfig == nil { return nil // No sandbox config is valid } From a102809436e3fd5b46e0976c5a7c2451195cc4bc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 30 Oct 2026 20:42:50 +0000 Subject: [PATCH 4/4] Align Claude Vertex topology regression with latest main Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/workflow/claude_vertex_test.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pkg/workflow/claude_vertex_test.go b/pkg/workflow/claude_vertex_test.go index c140e1d81de..ff51acee425 100644 --- a/pkg/workflow/claude_vertex_test.go +++ b/pkg/workflow/claude_vertex_test.go @@ -75,8 +75,7 @@ func TestClaudeVertexEnvironment(t *testing.T) { data.IsEvalsRun = phase == "evals" env := engine.buildClaudeCommandEnv(data) host := constants.AWFAPIProxyHostname - if runtime == AgentRuntimeDockerSudoIptables || - (phase != "agent" && runtime != AgentRuntimeNVX) { + if runtime == AgentRuntimeDockerSudoIptables { host = "host.docker.internal" } assert.Equal(t, "http://"+host+":10004/v1", env["ANTHROPIC_VERTEX_BASE_URL"])