From 65bdca437252301bb512305e21651c831aaa59dd Mon Sep 17 00:00:00 2001 From: Morgan Westlee Lunt Date: Wed, 22 Apr 2026 20:22:26 +0000 Subject: [PATCH 1/2] feat(claude-code): add api_key_helper input; mark claude_api_key sensitive; stop writing primaryApiKey to ~/.claude.json --- registry/coder/modules/claude-code/README.md | 46 +++++++++++ .../coder/modules/claude-code/main.test.ts | 54 +++++++++++++ registry/coder/modules/claude-code/main.tf | 30 ++++++- .../coder/modules/claude-code/main.tftest.hcl | 79 +++++++++++++++++++ .../modules/claude-code/scripts/install.sh | 39 +++++++-- 5 files changed, 240 insertions(+), 8 deletions(-) diff --git a/registry/coder/modules/claude-code/README.md b/registry/coder/modules/claude-code/README.md index 48b291bb0..b75c1c4c0 100644 --- a/registry/coder/modules/claude-code/README.md +++ b/registry/coder/modules/claude-code/README.md @@ -218,6 +218,52 @@ module "claude-code" { } ``` +### Short-lived credentials via api_key_helper + +For production deployments we recommend `api_key_helper` over a static `claude_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). 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`. + +#### HashiCorp Vault + +```tf +module "claude-code" { + source = "registry.coder.com/coder/claude-code/coder" + version = "4.9.2" + 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 + } +} +``` + +#### AWS Secrets Manager + +```tf +module "claude-code" { + source = "registry.coder.com/coder/claude-code/coder" + version = "4.9.2" + 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 `claude_api_key`, `claude_code_oauth_token`, and `enable_aibridge`. 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 or a `pre_install_script`. + ### Usage with AWS Bedrock #### Prerequisites diff --git a/registry/coder/modules/claude-code/main.test.ts b/registry/coder/modules/claude-code/main.test.ts index b01e88327..859dbe824 100644 --- a/registry/coder/modules/claude-code/main.test.ts +++ b/registry/coder/modules/claude-code/main.test.ts @@ -126,6 +126,60 @@ describe("claude-code", async () => { expect(envCheck.stdout).toContain("CLAUDE_API_KEY"); }); + test("claude-api-key-not-written-to-claude-json", async () => { + const apiKey = "sk-ant-test-do-not-persist"; + const { id, coderEnvVars } = await setup({ + moduleVariables: { + claude_api_key: apiKey, + report_tasks: "false", + }, + }); + await execModuleScript(id, coderEnvVars); + + const claudeConfig = await readFileContainer( + id, + "/home/coder/.claude.json", + ); + expect(claudeConfig).toContain("hasCompletedOnboarding"); + expect(claudeConfig).not.toContain("primaryApiKey"); + expect(claudeConfig).not.toContain(apiKey); + }); + + test("api-key-helper", async () => { + const helperBody = "#!/bin/sh\nvault kv get -field=key secret/anthropic\n"; + const { id, coderEnvVars } = await setup({ + moduleVariables: { + api_key_helper: JSON.stringify({ script: helperBody, ttl_ms: 60000 }), + report_tasks: "false", + }, + }); + await execModuleScript(id); + + expect(coderEnvVars["CLAUDE_CODE_API_KEY_HELPER_TTL_MS"]).toBe("60000"); + + const helper = await execContainer(id, [ + "bash", + "-c", + "stat -c '%a' /home/coder/.claude/coder-api-key-helper.sh && cat /home/coder/.claude/coder-api-key-helper.sh", + ]); + expect(helper.stdout).toContain("700"); + expect(helper.stdout).toContain("vault kv get -field=key secret/anthropic"); + + 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", + ); + expect(claudeConfig).toContain("hasCompletedOnboarding"); + expect(claudeConfig).not.toContain("primaryApiKey"); + }); + test("claude-mcp-config", async () => { const mcpConfig = JSON.stringify({ mcpServers: { diff --git a/registry/coder/modules/claude-code/main.tf b/registry/coder/modules/claude-code/main.tf index db234c052..ce7773d51 100644 --- a/registry/coder/modules/claude-code/main.tf +++ b/registry/coder/modules/claude-code/main.tf @@ -128,8 +128,9 @@ variable "disable_autoupdater" { variable "claude_api_key" { type = string - description = "The API key to use for the Claude Code server." + description = "Anthropic API key. Sets ANTHROPIC_API_KEY in the workspace environment. Prefer api_key_helper for short-lived credentials." default = "" + sensitive = true } variable "model" { @@ -198,6 +199,25 @@ variable "claude_code_oauth_token" { default = "" } +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 caches each key (default 5 minutes)." + default = null + + validation { + condition = var.api_key_helper == null || (var.claude_api_key == "" && var.claude_code_oauth_token == "") + error_message = "api_key_helper cannot be combined with claude_api_key or claude_code_oauth_token. Use exactly one authentication method." + } + + validation { + condition = var.api_key_helper == null || !var.enable_aibridge + error_message = "api_key_helper cannot be combined with enable_aibridge. AI Bridge handles authentication via the workspace owner's session token." + } +} + variable "system_prompt" { type = string description = "The system prompt to use for the Claude Code server." @@ -307,6 +327,13 @@ resource "coder_env" "disable_autoupdater" { value = "1" } +resource "coder_env" "api_key_helper_ttl_ms" { + 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) +} + resource "coder_env" "anthropic_model" { count = var.model != "" ? 1 : 0 @@ -431,6 +458,7 @@ module "agentapi" { ARG_MCP_CONFIG_REMOTE_PATH='${base64encode(jsonencode(var.mcp_config_remote_path))}' \ ARG_ENABLE_AIBRIDGE='${var.enable_aibridge}' \ ARG_PERMISSION_MODE='${var.permission_mode}' \ + ARG_API_KEY_HELPER_SCRIPT='${var.api_key_helper != null ? base64encode(var.api_key_helper.script) : ""}' \ /tmp/install.sh EOT } diff --git a/registry/coder/modules/claude-code/main.tftest.hcl b/registry/coder/modules/claude-code/main.tftest.hcl index 9c9df50f4..658707bea 100644 --- a/registry/coder/modules/claude-code/main.tftest.hcl +++ b/registry/coder/modules/claude-code/main.tftest.hcl @@ -459,4 +459,83 @@ run "test_api_key_count_with_aibridge_no_override" { condition = length(coder_env.claude_api_key) == 1 error_message = "CLAUDE_API_KEY env should be created when aibridge is enabled, regardless of session_token value" } +} + +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 = length(coder_env.api_key_helper_ttl_ms) == 1 + error_message = "CLAUDE_CODE_API_KEY_HELPER_TTL_MS env should be created when api_key_helper is set" + } + + assert { + condition = coder_env.api_key_helper_ttl_ms[0].value == "60000" + error_message = "CLAUDE_CODE_API_KEY_HELPER_TTL_MS should match api_key_helper.ttl_ms" + } + + assert { + condition = length(coder_env.claude_api_key) == 0 + error_message = "CLAUDE_API_KEY env should not be created when api_key_helper is the auth source" + } +} + +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_ms[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-helper-validation" + workdir = "/home/coder/test" + claude_api_key = "test-key" + api_key_helper = { + script = "#!/bin/sh\necho key\n" + } + } + + expect_failures = [ + var.api_key_helper, + ] +} + +run "test_api_key_helper_validation_with_aibridge" { + command = plan + + variables { + agent_id = "test-agent-helper-validation-aibridge" + workdir = "/home/coder/test" + enable_aibridge = true + api_key_helper = { + script = "#!/bin/sh\necho key\n" + } + } + + expect_failures = [ + var.api_key_helper, + ] } \ No newline at end of file diff --git a/registry/coder/modules/claude-code/scripts/install.sh b/registry/coder/modules/claude-code/scripts/install.sh index c00773b5e..9d994b037 100644 --- a/registry/coder/modules/claude-code/scripts/install.sh +++ b/registry/coder/modules/claude-code/scripts/install.sh @@ -23,6 +23,7 @@ ARG_ALLOWED_TOOLS=${ARG_ALLOWED_TOOLS:-} ARG_DISALLOWED_TOOLS=${ARG_DISALLOWED_TOOLS:-} ARG_ENABLE_AIBRIDGE=${ARG_ENABLE_AIBRIDGE:-false} ARG_PERMISSION_MODE=${ARG_PERMISSION_MODE:-} +ARG_API_KEY_HELPER_SCRIPT=${ARG_API_KEY_HELPER_SCRIPT:-} export PATH="$ARG_CLAUDE_BINARY_PATH:$PATH" @@ -180,8 +181,8 @@ function setup_claude_configurations() { function configure_standalone_mode() { echo "Configuring Claude Code for standalone mode..." - if [ -z "${CLAUDE_API_KEY:-}" ] && [ "$ARG_ENABLE_AIBRIDGE" = "false" ]; then - echo "Note: Neither claude_api_key nor enable_aibridge is set, skipping authentication setup" + if [ -z "${CLAUDE_API_KEY:-}" ] && [ "$ARG_ENABLE_AIBRIDGE" = "false" ] && [ -z "$ARG_API_KEY_HELPER_SCRIPT" ]; then + echo "Note: No authentication configured (claude_api_key, enable_aibridge, or api_key_helper), skipping authentication setup" return fi @@ -189,18 +190,18 @@ function configure_standalone_mode() { local workdir_normalized workdir_normalized=$(echo "$ARG_WORKDIR" | tr '/' '-') - # Create or update .claude.json with minimal configuration for API key auth - # This skips the interactive login prompt and onboarding screens + # Pre-accept onboarding and trust prompts so the CLI starts non-interactively. + # The API key itself is supplied via env (ANTHROPIC_API_KEY / CLAUDE_API_KEY) + # or apiKeyHelper, never written to this file. if [ -f "$claude_config" ]; then echo "Updating existing Claude configuration at $claude_config" - jq --arg workdir "$ARG_WORKDIR" --arg apikey "${CLAUDE_API_KEY:-}" \ + jq --arg workdir "$ARG_WORKDIR" \ '.autoUpdaterStatus = "disabled" | .autoModeAccepted = true | .bypassPermissionsModeAccepted = true | .hasAcknowledgedCostThreshold = true | .hasCompletedOnboarding = true | - .primaryApiKey = $apikey | .projects[$workdir].hasCompletedProjectOnboarding = true | .projects[$workdir].hasTrustDialogAccepted = true' \ "$claude_config" > "${claude_config}.tmp" && mv "${claude_config}.tmp" "$claude_config" @@ -213,7 +214,6 @@ function configure_standalone_mode() { "bypassPermissionsModeAccepted": true, "hasAcknowledgedCostThreshold": true, "hasCompletedOnboarding": true, - "primaryApiKey": "${CLAUDE_API_KEY:-}", "projects": { "$ARG_WORKDIR": { "hasCompletedProjectOnboarding": true, @@ -227,6 +227,30 @@ EOF echo "Standalone mode configured successfully" } +function setup_api_key_helper() { + if [ -z "$ARG_API_KEY_HELPER_SCRIPT" ]; then + return + fi + + echo "Configuring apiKeyHelper for short-lived credentials..." + + mkdir -p "$HOME/.claude" + local helper_path="$HOME/.claude/coder-api-key-helper.sh" + echo -n "$ARG_API_KEY_HELPER_SCRIPT" | base64 -d > "$helper_path" + chmod 0700 "$helper_path" + + local managed_dir="/etc/claude-code/managed-settings.d" + if command_exists sudo; then + sudo mkdir -p "$managed_dir" + printf '{\n "apiKeyHelper": "%s"\n}\n' "$helper_path" | sudo tee "$managed_dir/20-coder-apikeyhelper.json" > /dev/null + else + mkdir -p "$managed_dir" + printf '{\n "apiKeyHelper": "%s"\n}\n' "$helper_path" > "$managed_dir/20-coder-apikeyhelper.json" + fi + + echo "apiKeyHelper registered at $helper_path (settings: $managed_dir/20-coder-apikeyhelper.json)" +} + function report_tasks() { if [ "$ARG_REPORT_TASKS" = "true" ]; then echo "Configuring Claude Code to report tasks via Coder MCP..." @@ -258,6 +282,7 @@ function accept_auto_mode() { install_claude_code_cli setup_claude_configurations +setup_api_key_helper report_tasks if [ "$ARG_PERMISSION_MODE" = "auto" ]; then From fb39c019d03f0c5e5de9937ba476eb92e7952fcd Mon Sep 17 00:00:00 2001 From: Morgan Westlee Lunt Date: Fri, 24 Apr 2026 00:01:27 +0000 Subject: [PATCH 2/2] chore: bump README example version 4.9.2 -> 4.9.3 --- registry/coder/modules/claude-code/README.md | 22 ++++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/registry/coder/modules/claude-code/README.md b/registry/coder/modules/claude-code/README.md index b75c1c4c0..9eb17c905 100644 --- a/registry/coder/modules/claude-code/README.md +++ b/registry/coder/modules/claude-code/README.md @@ -13,7 +13,7 @@ Run the [Claude Code](https://docs.anthropic.com/en/docs/agents-and-tools/claude ```tf module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "4.9.2" + version = "4.9.3" agent_id = coder_agent.main.id workdir = "/home/coder/project" claude_api_key = "xxxx-xxxxx-xxxx" @@ -60,7 +60,7 @@ By default, when `enable_boundary = true`, the module uses `coder boundary` subc ```tf module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "4.9.2" + version = "4.9.3" agent_id = coder_agent.main.id workdir = "/home/coder/project" enable_boundary = true @@ -81,7 +81,7 @@ For tasks integration with AI Bridge, add `enable_aibridge = true` to the [Usage ```tf module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "4.9.2" + version = "4.9.3" agent_id = coder_agent.main.id workdir = "/home/coder/project" enable_aibridge = true @@ -110,7 +110,7 @@ data "coder_task" "me" {} module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "4.9.2" + version = "4.9.3" agent_id = coder_agent.main.id workdir = "/home/coder/project" ai_prompt = data.coder_task.me.prompt @@ -133,7 +133,7 @@ This example shows additional configuration options for version pinning, custom ```tf module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "4.9.2" + version = "4.9.3" agent_id = coder_agent.main.id workdir = "/home/coder/project" @@ -189,7 +189,7 @@ Run and configure Claude Code as a standalone CLI in your workspace. ```tf module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "4.9.2" + version = "4.9.3" agent_id = coder_agent.main.id workdir = "/home/coder/project" install_claude_code = true @@ -211,7 +211,7 @@ variable "claude_code_oauth_token" { module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "4.9.2" + version = "4.9.3" agent_id = coder_agent.main.id workdir = "/home/coder/project" claude_code_oauth_token = var.claude_code_oauth_token @@ -227,7 +227,7 @@ For production deployments we recommend `api_key_helper` over a static `claude_a ```tf module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "4.9.2" + version = "4.9.3" agent_id = coder_agent.main.id workdir = "/home/coder/project" @@ -246,7 +246,7 @@ module "claude-code" { ```tf module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "4.9.2" + version = "4.9.3" agent_id = coder_agent.main.id workdir = "/home/coder/project" @@ -330,7 +330,7 @@ resource "coder_env" "bedrock_api_key" { module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "4.9.2" + version = "4.9.3" agent_id = coder_agent.main.id workdir = "/home/coder/project" model = "global.anthropic.claude-sonnet-4-5-20250929-v1:0" @@ -387,7 +387,7 @@ resource "coder_env" "google_application_credentials" { module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "4.9.2" + version = "4.9.3" agent_id = coder_agent.main.id workdir = "/home/coder/project" model = "claude-sonnet-4@20250514"