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
64 changes: 55 additions & 9 deletions registry/coder/modules/claude-code/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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"

Expand Down Expand Up @@ -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
Expand All @@ -211,13 +211,59 @@ 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
}
```

### 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.3"
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.3"
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
Expand Down Expand Up @@ -284,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"
Expand Down Expand Up @@ -341,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"
Expand Down
54 changes: 54 additions & 0 deletions registry/coder/modules/claude-code/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
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 @@ -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" {
Expand Down Expand Up @@ -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."
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down
79 changes: 79 additions & 0 deletions registry/coder/modules/claude-code/main.tftest.hcl
Original file line number Diff line number Diff line change
Expand Up @@ -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,
]
}
Loading