diff --git a/registry/coder/modules/claude-code/README.md b/registry/coder/modules/claude-code/README.md index fc8cddf33..256b830d5 100644 --- a/registry/coder/modules/claude-code/README.md +++ b/registry/coder/modules/claude-code/README.md @@ -13,7 +13,7 @@ Install and configure the [Claude Code](https://docs.anthropic.com/en/docs/agent ```tf module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "5.5.2" + version = "5.5.3" agent_id = coder_agent.main.id anthropic_api_key = "xxxx-xxxxx-xxxx" } @@ -52,7 +52,7 @@ locals { module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "5.5.2" + version = "5.5.3" agent_id = coder_agent.main.id workdir = local.claude_workdir anthropic_api_key = "xxxx-xxxxx-xxxx" @@ -83,7 +83,7 @@ resource "coder_app" "claude" { ```tf module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "5.5.2" + version = "5.5.3" agent_id = coder_agent.main.id workdir = "/home/coder/project" enable_ai_gateway = true @@ -97,6 +97,10 @@ When `enable_ai_gateway = true`, the module sets: Claude Code then routes API requests through Coder's AI Gateway instead of directly to Anthropic. +The module also sets `apiKeyHelper` in `~/.claude/settings.json` unless you already configured a helper. The command tries `coder login token` and falls back to `ANTHROPIC_AUTH_TOKEN` if the CLI fails or returns an empty token. No credential value is stored in the settings file. A saved CLI login must target the same deployment as AI Gateway. + +The helper is a user setting, so you can override it. Turning off AI Gateway removes the module's default helper while preserving custom helpers and other settings. + > [!CAUTION] > `enable_ai_gateway = true` is mutually exclusive with `anthropic_api_key` and `claude_code_oauth_token`. Setting any of them together fails at plan time. @@ -107,7 +111,7 @@ By default the module wires `ANTHROPIC_BASE_URL` and `ANTHROPIC_AUTH_TOKEN` via ```tf module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "5.5.2" + version = "5.5.3" agent_id = coder_agent.main.id workdir = "/home/coder/project" enable_ai_gateway = true @@ -127,7 +131,7 @@ Setting `managed_settings = null` removes this module's policy file on the next ```tf module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "5.5.2" + version = "5.5.3" agent_id = coder_agent.main.id workdir = "/home/coder/project" anthropic_api_key = "xxxx-xxxxx-xxxx" @@ -156,7 +160,7 @@ Setting `api_key_helper = null` removes the module's registration file and `~/.c ```tf module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "5.5.2" + version = "5.5.3" agent_id = coder_agent.main.id workdir = "/home/coder/project" @@ -175,7 +179,7 @@ Or, sourcing from AWS Secrets Manager: ```tf module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "5.5.2" + version = "5.5.3" agent_id = coder_agent.main.id workdir = "/home/coder/project" @@ -200,7 +204,7 @@ This example shows version pinning, a pre-installed binary path, a custom model, ```tf module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "5.5.2" + version = "5.5.3" agent_id = coder_agent.main.id workdir = "/home/coder/project" @@ -264,7 +268,7 @@ Downstream `coder_script` resources can wait for this module's install pipeline ```tf module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "5.5.2" + version = "5.5.3" agent_id = coder_agent.main.id workdir = "/home/coder/project" anthropic_api_key = "xxxx-xxxxx-xxxx" @@ -294,7 +298,7 @@ Set `use_bedrock = true` to route Claude Code through Amazon Bedrock. The module ```tf module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "5.5.2" + version = "5.5.3" agent_id = coder_agent.main.id workdir = "/home/coder/project" use_bedrock = true @@ -347,7 +351,7 @@ Set `use_vertex = true` to route Claude Code through Google Vertex AI. The modul ```tf module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "5.5.2" + version = "5.5.3" agent_id = coder_agent.main.id workdir = "/home/coder/project" use_vertex = true @@ -382,7 +386,7 @@ This example uses the Azure default credential chain. Attach a managed identity ```tf module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "5.5.2" + version = "5.5.3" agent_id = coder_agent.main.id workdir = "/home/coder/project" use_foundry = true @@ -426,7 +430,7 @@ Set `anthropic_base_url` to point Claude Code at a self-hosted gateway or proxy ```tf module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "5.5.2" + version = "5.5.3" agent_id = coder_agent.main.id workdir = "/home/coder/project" anthropic_base_url = "https://llm-gateway.example.com/anthropic" @@ -445,7 +449,7 @@ The module automatically tags every span and metric with `coder.workspace_id`, ` ```tf module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "5.5.2" + version = "5.5.3" agent_id = coder_agent.main.id workdir = "/home/coder/project" anthropic_api_key = "xxxx-xxxxx-xxxx" diff --git a/registry/coder/modules/claude-code/main.test.ts b/registry/coder/modules/claude-code/main.test.ts index c548b22b9..734678be9 100644 --- a/registry/coder/modules/claude-code/main.test.ts +++ b/registry/coder/modules/claude-code/main.test.ts @@ -529,6 +529,121 @@ describe("claude-code", async () => { expect(parsed.autoModeAccepted).toBeUndefined(); }); + test("AI Gateway helper uses the CLI token with environment fallback", async () => { + const { id, coderEnvVars, scripts } = await setup({ + moduleVariables: { enable_ai_gateway: "true" }, + }); + const settingsPath = "/home/coder/.claude/settings.json"; + await execContainer(id, ["mkdir", "-p", path.posix.dirname(settingsPath)]); + await writeFileContainer( + id, + settingsPath, + JSON.stringify({ model: "sonnet" }), + ); + coderEnvVars.ANTHROPIC_AUTH_TOKEN = "env-token"; + await runScripts(id, scripts, coderEnvVars); + const settings = JSON.parse(await readFileContainer(id, settingsPath)); + expect(settings.model).toBe("sonnet"); + expect(typeof settings.apiKeyHelper).toBe("string"); + expect(JSON.stringify(settings)).not.toContain( + coderEnvVars.ANTHROPIC_AUTH_TOKEN, + ); + + await writeExecutable({ + containerId: id, + filePath: "/usr/bin/coder", + content: + '#!/bin/sh\nprintf "%s" "$TEST_CODER_TOKEN"\nexit "$TEST_CODER_EXIT"\n', + }); + for (const [token, status, expected] of [ + ["cli-token", "0", "cli-token"], + ["", "0", "env-token"], + ["", "1", "env-token"], + ["failed-token", "1", "env-token"], + ]) { + const result = await execContainer( + id, + ["sh", "-c", settings.apiKeyHelper], + [ + "-e", + `TEST_CODER_TOKEN=${token}`, + "-e", + `TEST_CODER_EXIT=${status}`, + "-e", + "ANTHROPIC_AUTH_TOKEN=env-token", + ], + ); + expect(result.exitCode).toBe(0); + expect(result.stdout.trimEnd()).toBe(expected); + expect(result.stderr).toBe(""); + } + await execContainer(id, ["rm", "/usr/bin/coder"]); + const missingCli = await execContainer( + id, + ["sh", "-c", settings.apiKeyHelper], + ["-e", "ANTHROPIC_AUTH_TOKEN=env-token"], + ); + expect(missingCli.exitCode).toBe(0); + expect(missingCli.stdout).toBe("env-token\n"); + const noCredentials = await execContainer(id, [ + "env", + "-u", + "ANTHROPIC_AUTH_TOKEN", + "sh", + "-c", + settings.apiKeyHelper, + ]); + expect(noCredentials.exitCode).not.toBe(0); + expect(noCredentials.stdout).toBe(""); + + await writeExecutable({ + containerId: id, + filePath: "/usr/bin/coder", + content: "#!/bin/sh\nexit 0\n", + }); + await runScripts(id, scripts, coderEnvVars); + expect(JSON.parse(await readFileContainer(id, settingsPath))).toEqual( + settings, + ); + const disabled = await setup(); + await runScripts(id, disabled.scripts); + expect(JSON.parse(await readFileContainer(id, settingsPath))).toEqual({ + model: "sonnet", + }); + }); + + test("AI Gateway preserves custom user helpers", async () => { + const { id, coderEnvVars, scripts } = await setup({ + moduleVariables: { enable_ai_gateway: "true" }, + }); + const settingsPath = "/home/coder/.claude/settings.json"; + const settings = { model: "sonnet", apiKeyHelper: "custom-helper" }; + await execContainer(id, ["mkdir", "-p", path.posix.dirname(settingsPath)]); + await writeFileContainer(id, settingsPath, JSON.stringify(settings)); + await runScripts(id, scripts, coderEnvVars); + expect(JSON.parse(await readFileContainer(id, settingsPath))).toEqual( + settings, + ); + const disabled = await setup(); + await runScripts(id, disabled.scripts); + expect(JSON.parse(await readFileContainer(id, settingsPath))).toEqual( + settings, + ); + }); + + test("AI Gateway leaves malformed user settings intact", async () => { + const { id, coderEnvVars, scripts } = await setup({ + moduleVariables: { enable_ai_gateway: "true" }, + }); + const settingsPath = "/home/coder/.claude/settings.json"; + await execContainer(id, ["mkdir", "-p", path.posix.dirname(settingsPath)]); + await writeFileContainer(id, settingsPath, "invalid-json"); + await expect(runScripts(id, scripts, coderEnvVars)).rejects.toThrow( + "script exited", + ); + expect(await readFileContainer(id, settingsPath)).toBe("invalid-json"); + }); + test("standalone-mode-with-oauth-token", async () => { const token = "test-oauth-token-standalone"; const { id, coderEnvVars, scripts } = await setup({ @@ -559,7 +674,11 @@ describe("claude-code", async () => { test("standalone-mode-no-auth", async () => { const { id, coderEnvVars, scripts } = await setup(); + const settingsPath = "/home/coder/.claude/settings.json"; + await execContainer(id, ["mkdir", "-p", path.posix.dirname(settingsPath)]); + await writeFileContainer(id, settingsPath, "invalid-json"); await runScripts(id, scripts, coderEnvVars); + expect(await readFileContainer(id, settingsPath)).toBe("invalid-json"); const installLog = await readFileContainer( id, diff --git a/registry/coder/modules/claude-code/scripts/install.sh.tftpl b/registry/coder/modules/claude-code/scripts/install.sh.tftpl index 0a16b9a97..7dd2ad518 100644 --- a/registry/coder/modules/claude-code/scripts/install.sh.tftpl +++ b/registry/coder/modules/claude-code/scripts/install.sh.tftpl @@ -230,6 +230,36 @@ function write_managed_settings() { echo "Wrote Claude Code managed settings to $${target}" } +function setup_gateway_api_key_helper() ( + local settings_dir="$${CLAUDE_CONFIG_DIR:-$HOME/.claude}" + local settings="$${settings_dir}/settings.json" + # Claude expands the helper command when it requests a credential. + # shellcheck disable=SC2016 + local helper='token=$(coder login token 2>/dev/null) && [ -n "$token" ] && printf "%s" "$token" || printenv ANTHROPIC_AUTH_TOKEN' + + if [ "$${ARG_ENABLE_AI_GATEWAY}" != "true" ]; then + if [ ! -f "$${settings}" ] || ! jq -e --arg helper "$${helper}" '.apiKeyHelper == $helper' "$${settings}" > /dev/null 2>&1; then + return + fi + fi + + mkdir -p "$${settings_dir}" + local settings_tmp + settings_tmp=$(mktemp "$${settings}.XXXXXX") + trap 'rm -f "$${settings_tmp}"' EXIT + + if [ -f "$${settings}" ]; then + jq --arg helper "$${helper}" --argjson enabled "$${ARG_ENABLE_AI_GATEWAY}" ' + if $enabled then .apiKeyHelper //= $helper + elif .apiKeyHelper == $helper then del(.apiKeyHelper) + else . end + ' "$${settings}" > "$${settings_tmp}" + else + jq -n --arg helper "$${helper}" '{apiKeyHelper: $helper}' > "$${settings_tmp}" + fi + mv "$${settings_tmp}" "$${settings}" +) + function setup_api_key_helper() { local helper_path="$HOME/.claude/coder-api-key-helper.sh" local dropin_dir="/etc/claude-code/managed-settings.d" @@ -328,4 +358,5 @@ install_claude_code_cli setup_claude_configurations write_managed_settings setup_api_key_helper +setup_gateway_api_key_helper configure_standalone_mode