Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 52 additions & 7 deletions registry/coder/modules/claude-code/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand All @@ -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`.

Expand All @@ -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"
Expand Down Expand Up @@ -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
Expand All @@ -95,14 +96,58 @@ 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.

```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"

Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down
47 changes: 47 additions & 0 deletions registry/coder/modules/claude-code/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
30 changes: 29 additions & 1 deletion registry/coder/modules/claude-code/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ""
}

Expand Down Expand Up @@ -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
Comment on lines +122 to +128
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we still need as explicit if we can do the same via #863


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
Expand Down Expand Up @@ -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", {
Expand All @@ -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"
}
Expand Down
74 changes: 74 additions & 0 deletions registry/coder/modules/claude-code/main.tftest.hcl
Original file line number Diff line number Diff line change
Expand Up @@ -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,
]
}
36 changes: 34 additions & 2 deletions registry/coder/modules/claude-code/scripts/install.sh.tftpl
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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 "--------------------------------"

Expand Down Expand Up @@ -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"
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder how this ties into #863

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

Expand Down Expand Up @@ -189,4 +220,5 @@ EOF

install_claude_code_cli
setup_claude_configurations
setup_api_key_helper
configure_standalone_mode
Loading