diff --git a/registry/coder/modules/claude-code/README.md b/registry/coder/modules/claude-code/README.md index d3264256f..d68cfa402 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.0.0" + version = "5.1.0" agent_id = coder_agent.main.id anthropic_api_key = "xxxx-xxxxx-xxxx" } @@ -27,6 +27,7 @@ module "claude-code" { Provide exactly one authentication method: - **Anthropic API key**: get one from the [Anthropic Console](https://console.anthropic.com/dashboard) and pass it as `anthropic_api_key`. +- **API key helper script** (`api_key_helper`): a script that prints a short-lived Anthropic API key to stdout. Recommended for production deployments where keys come from Vault, AWS Secrets Manager, or cloud IAM. See [Short-lived credentials via api_key_helper](#short-lived-credentials-via-api_key_helper). - **Claude.ai OAuth token** (Pro, Max, or Enterprise accounts): generate one by running `claude setup-token` locally and pass it as `claude_code_oauth_token`. - **Coder AI Gateway** (Coder Premium, Coder >= 2.30.0): set `enable_ai_gateway = true`. The module authenticates against the gateway using the workspace owner's session token. Do not combine with `anthropic_api_key` or `claude_code_oauth_token`. @@ -47,7 +48,7 @@ locals { module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "5.0.0" + version = "5.1.0" agent_id = coder_agent.main.id workdir = local.claude_workdir anthropic_api_key = "xxxx-xxxxx-xxxx" @@ -78,7 +79,7 @@ resource "coder_app" "claude" { ```tf module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "5.0.0" + version = "5.1.0" agent_id = coder_agent.main.id workdir = "/home/coder/project" enable_ai_gateway = true @@ -95,6 +96,50 @@ Claude Code then routes API requests through Coder's AI Gateway instead of direc > [!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. +### Short-lived credentials via api_key_helper + +For production deployments we recommend `api_key_helper` over a static `anthropic_api_key`. The module writes the helper script into the workspace and registers it via Claude Code's [`apiKeyHelper` setting](https://docs.anthropic.com/en/docs/claude-code/settings#available-settings) at `/etc/claude-code/managed-settings.d/20-coder-apikeyhelper.json`. Claude invokes the script whenever it needs a key and caches the result for `ttl_ms` milliseconds (default 5 minutes), so the credential never lands in Terraform state, the agent environment, or `~/.claude.json`. + +```tf +module "claude-code" { + source = "registry.coder.com/coder/claude-code/coder" + version = "5.1.0" + agent_id = coder_agent.main.id + workdir = "/home/coder/project" + + api_key_helper = { + script = <<-EOT + #!/bin/sh + exec vault kv get -field=key secret/anthropic + EOT + ttl_ms = 300000 + } +} +``` + +Or, sourcing from AWS Secrets Manager: + +```tf +module "claude-code" { + source = "registry.coder.com/coder/claude-code/coder" + version = "5.1.0" + agent_id = coder_agent.main.id + workdir = "/home/coder/project" + + api_key_helper = { + script = <<-EOT + #!/bin/sh + exec aws secretsmanager get-secret-value \ + --secret-id anthropic/api-key \ + --query SecretString --output text + EOT + } +} +``` + +> [!NOTE] +> `api_key_helper` is mutually exclusive with `anthropic_api_key`, `claude_code_oauth_token`, and `enable_ai_gateway`. The script runs as the workspace user, so any CLI it calls (`vault`, `aws`, `gcloud`) must already be installed and authenticated in the workspace, for example via Workload Identity, IRSA, or a `pre_install_script`. + ### Advanced Configuration This example shows version pinning, a pre-installed binary path, a custom model, and MCP servers. @@ -102,7 +147,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.0.0" + version = "5.1.0" agent_id = coder_agent.main.id workdir = "/home/coder/project" @@ -166,7 +211,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.0.0" + version = "5.1.0" agent_id = coder_agent.main.id workdir = "/home/coder/project" anthropic_api_key = "xxxx-xxxxx-xxxx" @@ -252,7 +297,7 @@ resource "coder_env" "bedrock_api_key" { module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "5.0.0" + version = "5.1.0" agent_id = coder_agent.main.id workdir = "/home/coder/project" model = "global.anthropic.claude-sonnet-4-5-20250929-v1:0" @@ -309,7 +354,7 @@ resource "coder_env" "google_application_credentials" { module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "5.0.0" + version = "5.1.0" agent_id = coder_agent.main.id workdir = "/home/coder/project" model = "claude-sonnet-4@20250514" diff --git a/registry/coder/modules/claude-code/main.test.ts b/registry/coder/modules/claude-code/main.test.ts index 6b1367551..b60841175 100644 --- a/registry/coder/modules/claude-code/main.test.ts +++ b/registry/coder/modules/claude-code/main.test.ts @@ -435,4 +435,51 @@ describe("claude-code", async () => { ]); expect(resp.stdout.trim()).toBe("ABSENT"); }); + + test("api-key-helper", async () => { + const helperBody = "#!/bin/sh\nvault kv get -field=key secret/anthropic\n"; + const { id, coderEnvVars, scripts } = await setup({ + moduleVariables: { + api_key_helper: JSON.stringify({ script: helperBody, ttl_ms: 60000 }), + }, + }); + expect(coderEnvVars["CLAUDE_CODE_API_KEY_HELPER_TTL_MS"]).toBe("60000"); + + await runScripts(id, scripts, coderEnvVars); + + const installLog = await readFileContainer( + id, + "/home/coder/.coder-modules/coder/claude-code/logs/install.log", + ); + expect(installLog).toContain("Configuring api_key_helper"); + expect(installLog).toContain( + "Wrote api_key_helper script to /home/coder/.claude/coder-api-key-helper.sh", + ); + // api_key_helper counts as authentication, so onboarding bypass runs. + expect(installLog).not.toContain("skipping onboarding bypass"); + expect(installLog).toContain("Standalone mode configured successfully"); + + const helper = await execContainer(id, [ + "bash", + "-c", + "cat /home/coder/.claude/coder-api-key-helper.sh && stat -c '%a' /home/coder/.claude/coder-api-key-helper.sh", + ]); + expect(helper.exitCode).toBe(0); + expect(helper.stdout).toContain("vault kv get -field=key secret/anthropic"); + expect(helper.stdout).toContain("700"); + + const managed = await readFileContainer( + id, + "/etc/claude-code/managed-settings.d/20-coder-apikeyhelper.json", + ); + expect(managed).toContain('"apiKeyHelper"'); + expect(managed).toContain("/home/coder/.claude/coder-api-key-helper.sh"); + + const claudeConfig = await readFileContainer( + id, + "/home/coder/.claude.json", + ); + const parsed = JSON.parse(claudeConfig); + expect(parsed.hasCompletedOnboarding).toBe(true); + }); }); diff --git a/registry/coder/modules/claude-code/main.tf b/registry/coder/modules/claude-code/main.tf index 346930db9..f4fb6cce6 100644 --- a/registry/coder/modules/claude-code/main.tf +++ b/registry/coder/modules/claude-code/main.tf @@ -62,7 +62,8 @@ variable "disable_autoupdater" { variable "anthropic_api_key" { type = string - description = "API key passed to Claude Code via the ANTHROPIC_API_KEY env var." + description = "API key passed to Claude Code via the ANTHROPIC_API_KEY env var. Prefer api_key_helper for short-lived credentials." + sensitive = true default = "" } @@ -118,6 +119,25 @@ variable "enable_ai_gateway" { } } +variable "api_key_helper" { + type = object({ + script = string + ttl_ms = optional(number, 300000) + }) + description = "Script that prints an Anthropic API key to stdout. Written to ~/.claude/coder-api-key-helper.sh and registered via the apiKeyHelper setting in /etc/claude-code/managed-settings.d/. Use for short-lived credentials from Vault, AWS Secrets Manager, cloud IAM, etc. ttl_ms is how long Claude Code caches each key (default 5 minutes)." + default = null + + validation { + condition = var.api_key_helper == null || (var.anthropic_api_key == "" && var.claude_code_oauth_token == "") + error_message = "api_key_helper cannot be combined with anthropic_api_key or claude_code_oauth_token. Use exactly one authentication method." + } + + validation { + condition = var.api_key_helper == null || !var.enable_ai_gateway + error_message = "api_key_helper cannot be combined with enable_ai_gateway. AI Gateway authenticates using the workspace owner's session token." + } +} + resource "coder_env" "claude_code_oauth_token" { count = var.claude_code_oauth_token != "" ? 1 : 0 agent_id = var.agent_id @@ -163,6 +183,13 @@ resource "coder_env" "anthropic_base_url" { value = "${data.coder_workspace.me.access_url}/api/v2/aibridge/anthropic" } +resource "coder_env" "api_key_helper_ttl" { + count = var.api_key_helper != null ? 1 : 0 + agent_id = var.agent_id + name = "CLAUDE_CODE_API_KEY_HELPER_TTL_MS" + value = tostring(var.api_key_helper.ttl_ms) +} + locals { workdir = var.workdir != null ? trimsuffix(var.workdir, "/") : "" install_script = templatefile("${path.module}/scripts/install.sh.tftpl", { @@ -173,6 +200,7 @@ locals { ARG_MCP = var.mcp != "" ? base64encode(var.mcp) : "" ARG_MCP_CONFIG_REMOTE_PATH = base64encode(jsonencode(var.mcp_config_remote_path)) ARG_ENABLE_AI_GATEWAY = tostring(var.enable_ai_gateway) + ARG_API_KEY_HELPER_SCRIPT = var.api_key_helper != null ? base64encode(var.api_key_helper.script) : "" }) module_dir_name = ".coder-modules/coder/claude-code" } diff --git a/registry/coder/modules/claude-code/main.tftest.hcl b/registry/coder/modules/claude-code/main.tftest.hcl index 08e0c005b..b9116930a 100644 --- a/registry/coder/modules/claude-code/main.tftest.hcl +++ b/registry/coder/modules/claude-code/main.tftest.hcl @@ -283,3 +283,77 @@ run "test_workdir_optional" { error_message = "workdir should default to null when omitted" } } + +run "test_api_key_helper" { + command = plan + + variables { + agent_id = "test-agent-helper" + workdir = "/home/coder/test" + api_key_helper = { + script = "#!/bin/sh\nvault kv get -field=key secret/anthropic\n" + ttl_ms = 60000 + } + } + + assert { + condition = coder_env.api_key_helper_ttl[0].name == "CLAUDE_CODE_API_KEY_HELPER_TTL_MS" + error_message = "api_key_helper_ttl env var name should be CLAUDE_CODE_API_KEY_HELPER_TTL_MS" + } + + assert { + condition = coder_env.api_key_helper_ttl[0].value == "60000" + error_message = "api_key_helper_ttl env var value should match ttl_ms" + } +} + +run "test_api_key_helper_default_ttl" { + command = plan + + variables { + agent_id = "test-agent-helper-default" + workdir = "/home/coder/test" + api_key_helper = { + script = "#!/bin/sh\necho key\n" + } + } + + assert { + condition = coder_env.api_key_helper_ttl[0].value == "300000" + error_message = "ttl_ms should default to 300000 (5 minutes)" + } +} + +run "test_api_key_helper_validation_with_api_key" { + command = plan + + variables { + agent_id = "test-agent-validation" + workdir = "/home/coder/test" + anthropic_api_key = "sk-test" + api_key_helper = { + script = "echo key" + } + } + + expect_failures = [ + var.api_key_helper, + ] +} + +run "test_api_key_helper_validation_with_ai_gateway" { + command = plan + + variables { + agent_id = "test-agent-validation" + workdir = "/home/coder/test" + enable_ai_gateway = true + api_key_helper = { + script = "echo key" + } + } + + expect_failures = [ + var.api_key_helper, + ] +} diff --git a/registry/coder/modules/claude-code/scripts/install.sh.tftpl b/registry/coder/modules/claude-code/scripts/install.sh.tftpl index bd142c5d3..9bebc43f6 100644 --- a/registry/coder/modules/claude-code/scripts/install.sh.tftpl +++ b/registry/coder/modules/claude-code/scripts/install.sh.tftpl @@ -17,6 +17,7 @@ ARG_CLAUDE_BINARY_PATH="$${ARG_CLAUDE_BINARY_PATH//\$HOME/$HOME}" ARG_MCP=$(echo -n '${ARG_MCP}' | base64 -d) ARG_MCP_CONFIG_REMOTE_PATH=$(echo -n '${ARG_MCP_CONFIG_REMOTE_PATH}' | base64 -d) ARG_ENABLE_AI_GATEWAY='${ARG_ENABLE_AI_GATEWAY}' +ARG_API_KEY_HELPER_SCRIPT=$(echo -n '${ARG_API_KEY_HELPER_SCRIPT}' | base64 -d) export PATH="$${ARG_CLAUDE_BINARY_PATH}:$PATH" @@ -29,6 +30,7 @@ printf "ARG_CLAUDE_BINARY_PATH: %s\n" "$${ARG_CLAUDE_BINARY_PATH}" printf "ARG_MCP: %s\n" "$${ARG_MCP}" printf "ARG_MCP_CONFIG_REMOTE_PATH: %s\n" "$${ARG_MCP_CONFIG_REMOTE_PATH}" printf "ARG_ENABLE_AI_GATEWAY: %s\n" "$${ARG_ENABLE_AI_GATEWAY}" +printf "ARG_API_KEY_HELPER_SCRIPT: %s\n" "$${ARG_API_KEY_HELPER_SCRIPT:+(set)}" echo "--------------------------------" @@ -144,11 +146,40 @@ function setup_claude_configurations() { } +function setup_api_key_helper() { + if [ -z "$${ARG_API_KEY_HELPER_SCRIPT}" ]; then + return + fi + + echo "Configuring api_key_helper for short-lived credentials..." + + mkdir -p "$HOME/.claude" + local helper_path="$HOME/.claude/coder-api-key-helper.sh" + printf '%s' "$${ARG_API_KEY_HELPER_SCRIPT}" > "$${helper_path}" + chmod 0700 "$${helper_path}" + + local dropin_dir="/etc/claude-code/managed-settings.d" + local target="$${dropin_dir}/20-coder-apikeyhelper.json" + if command_exists sudo; then + sudo mkdir -p "$${dropin_dir}" + printf '{"apiKeyHelper":"%s"}\n' "$${helper_path}" | sudo tee "$${target}" > /dev/null + sudo chmod 0644 "$${target}" + elif mkdir -p "$${dropin_dir}" 2> /dev/null; then + printf '{"apiKeyHelper":"%s"}\n' "$${helper_path}" > "$${target}" + chmod 0644 "$${target}" + else + echo "Warning: cannot write to $${dropin_dir} (no sudo and not writable); api_key_helper will not be registered" + return + fi + + echo "Wrote api_key_helper script to $${helper_path} and registered via $${target}" +} + function configure_standalone_mode() { echo "Configuring Claude Code for standalone mode..." - if [ -z "$${ANTHROPIC_API_KEY:-}" ] && [ -z "$${CLAUDE_CODE_OAUTH_TOKEN:-}" ] && [ "$${ARG_ENABLE_AI_GATEWAY}" = "false" ]; then - echo "Note: No authentication configured (anthropic_api_key, claude_code_oauth_token, enable_ai_gateway), skipping onboarding bypass" + if [ -z "$${ANTHROPIC_API_KEY:-}" ] && [ -z "$${CLAUDE_CODE_OAUTH_TOKEN:-}" ] && [ "$${ARG_ENABLE_AI_GATEWAY}" = "false" ] && [ -z "$${ARG_API_KEY_HELPER_SCRIPT}" ]; then + echo "Note: No authentication configured (anthropic_api_key, claude_code_oauth_token, enable_ai_gateway, api_key_helper), skipping onboarding bypass" return fi @@ -189,4 +220,5 @@ EOF install_claude_code_cli setup_claude_configurations +setup_api_key_helper configure_standalone_mode