Skip to content

Commit 9a6b770

Browse files
committed
feat(claude-code): add api_key_helper for short-lived credentials; mark anthropic_api_key sensitive
Rebased onto post-#861 main. Changes from the original PR: - Targets the renamed anthropic_api_key variable (was claude_api_key). - Targets the renamed enable_ai_gateway variable (was enable_aibridge). - The primaryApiKey removal from the original PR is no longer needed since #861's install template does not write that key. - install.sh.tftpl uses templatefile substitution; helper script is passed base64-encoded as ARG_API_KEY_HELPER_SCRIPT. - README bumped to 5.1.0.
1 parent 4ca251f commit 9a6b770

5 files changed

Lines changed: 238 additions & 12 deletions

File tree

registry/coder/modules/claude-code/README.md

Lines changed: 54 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ Install and configure the [Claude Code](https://docs.anthropic.com/en/docs/agent
1313
```tf
1414
module "claude-code" {
1515
source = "registry.coder.com/coder/claude-code/coder"
16-
version = "5.2.0"
16+
version = "5.3.0"
1717
agent_id = coder_agent.main.id
1818
anthropic_api_key = "xxxx-xxxxx-xxxx"
1919
}
@@ -27,6 +27,7 @@ module "claude-code" {
2727
Provide exactly one authentication method:
2828

2929
- **Anthropic API key**: get one from the [Anthropic Console](https://console.anthropic.com/dashboard) and pass it as `anthropic_api_key`.
30+
- **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).
3031
- **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`.
3132
- **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`.
3233

@@ -47,7 +48,7 @@ locals {
4748
4849
module "claude-code" {
4950
source = "registry.coder.com/coder/claude-code/coder"
50-
version = "5.2.0"
51+
version = "5.3.0"
5152
agent_id = coder_agent.main.id
5253
workdir = local.claude_workdir
5354
anthropic_api_key = "xxxx-xxxxx-xxxx"
@@ -78,7 +79,7 @@ resource "coder_app" "claude" {
7879
```tf
7980
module "claude-code" {
8081
source = "registry.coder.com/coder/claude-code/coder"
81-
version = "5.2.0"
82+
version = "5.3.0"
8283
agent_id = coder_agent.main.id
8384
workdir = "/home/coder/project"
8485
enable_ai_gateway = true
@@ -102,7 +103,7 @@ The `managed_settings` input writes a policy file to `/etc/claude-code/managed-s
102103
```tf
103104
module "claude-code" {
104105
source = "registry.coder.com/coder/claude-code/coder"
105-
version = "5.2.0"
106+
version = "5.3.0"
106107
agent_id = coder_agent.main.id
107108
workdir = "/home/coder/project"
108109
anthropic_api_key = "xxxx-xxxxx-xxxx"
@@ -122,14 +123,58 @@ module "claude-code" {
122123

123124
See the [Claude Code settings reference](https://docs.anthropic.com/en/docs/claude-code/settings) for the full schema. Common keys: `permissions` (`defaultMode`, `allow`, `deny`, `disableBypassPermissionsMode`, `additionalDirectories`), `env`, `model`, `apiKeyHelper`, `hooks`, `cleanupPeriodDays`.
124125

126+
### Short-lived credentials via api_key_helper
127+
128+
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`.
129+
130+
```tf
131+
module "claude-code" {
132+
source = "registry.coder.com/coder/claude-code/coder"
133+
version = "5.3.0"
134+
agent_id = coder_agent.main.id
135+
workdir = "/home/coder/project"
136+
137+
api_key_helper = {
138+
script = <<-EOT
139+
#!/bin/sh
140+
exec vault kv get -field=key secret/anthropic
141+
EOT
142+
ttl_ms = 300000
143+
}
144+
}
145+
```
146+
147+
Or, sourcing from AWS Secrets Manager:
148+
149+
```tf
150+
module "claude-code" {
151+
source = "registry.coder.com/coder/claude-code/coder"
152+
version = "5.3.0"
153+
agent_id = coder_agent.main.id
154+
workdir = "/home/coder/project"
155+
156+
api_key_helper = {
157+
script = <<-EOT
158+
#!/bin/sh
159+
exec aws secretsmanager get-secret-value \
160+
--secret-id anthropic/api-key \
161+
--query SecretString --output text
162+
EOT
163+
}
164+
}
165+
```
166+
167+
> [!NOTE]
168+
> `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`.
169+
125170
### Advanced Configuration
126171

127172
This example shows version pinning, a pre-installed binary path, a custom model, and MCP servers.
128173

129174
```tf
130175
module "claude-code" {
131176
source = "registry.coder.com/coder/claude-code/coder"
132-
version = "5.2.0"
177+
version = "5.3.0"
133178
agent_id = coder_agent.main.id
134179
workdir = "/home/coder/project"
135180
@@ -193,7 +238,7 @@ Downstream `coder_script` resources can wait for this module's install pipeline
193238
```tf
194239
module "claude-code" {
195240
source = "registry.coder.com/coder/claude-code/coder"
196-
version = "5.2.0"
241+
version = "5.3.0"
197242
agent_id = coder_agent.main.id
198243
workdir = "/home/coder/project"
199244
anthropic_api_key = "xxxx-xxxxx-xxxx"
@@ -279,7 +324,7 @@ resource "coder_env" "bedrock_api_key" {
279324
280325
module "claude-code" {
281326
source = "registry.coder.com/coder/claude-code/coder"
282-
version = "5.2.0"
327+
version = "5.3.0"
283328
agent_id = coder_agent.main.id
284329
workdir = "/home/coder/project"
285330
model = "global.anthropic.claude-sonnet-4-5-20250929-v1:0"
@@ -336,7 +381,7 @@ resource "coder_env" "google_application_credentials" {
336381
337382
module "claude-code" {
338383
source = "registry.coder.com/coder/claude-code/coder"
339-
version = "5.2.0"
384+
version = "5.3.0"
340385
agent_id = coder_agent.main.id
341386
workdir = "/home/coder/project"
342387
model = "claude-sonnet-4@20250514"
@@ -377,7 +422,7 @@ The module automatically tags every span and metric with `coder.workspace_id`, `
377422
```tf
378423
module "claude-code" {
379424
source = "registry.coder.com/coder/claude-code/coder"
380-
version = "5.2.0"
425+
version = "5.3.0"
381426
agent_id = coder_agent.main.id
382427
workdir = "/home/coder/project"
383428
anthropic_api_key = "xxxx-xxxxx-xxxx"

registry/coder/modules/claude-code/main.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -518,4 +518,51 @@ describe("claude-code", async () => {
518518
expect(coderEnvVars["OTEL_EXPORTER_OTLP_HEADERS"]).toBeUndefined();
519519
expect(coderEnvVars["OTEL_RESOURCE_ATTRIBUTES"]).toBeUndefined();
520520
});
521+
522+
test("api-key-helper", async () => {
523+
const helperBody = "#!/bin/sh\nvault kv get -field=key secret/anthropic\n";
524+
const { id, coderEnvVars, scripts } = await setup({
525+
moduleVariables: {
526+
api_key_helper: JSON.stringify({ script: helperBody, ttl_ms: 60000 }),
527+
},
528+
});
529+
expect(coderEnvVars["CLAUDE_CODE_API_KEY_HELPER_TTL_MS"]).toBe("60000");
530+
531+
await runScripts(id, scripts, coderEnvVars);
532+
533+
const installLog = await readFileContainer(
534+
id,
535+
"/home/coder/.coder-modules/coder/claude-code/logs/install.log",
536+
);
537+
expect(installLog).toContain("Configuring api_key_helper");
538+
expect(installLog).toContain(
539+
"Wrote api_key_helper script to /home/coder/.claude/coder-api-key-helper.sh",
540+
);
541+
// api_key_helper counts as authentication, so onboarding bypass runs.
542+
expect(installLog).not.toContain("skipping onboarding bypass");
543+
expect(installLog).toContain("Standalone mode configured successfully");
544+
545+
const helper = await execContainer(id, [
546+
"bash",
547+
"-c",
548+
"cat /home/coder/.claude/coder-api-key-helper.sh && stat -c '%a' /home/coder/.claude/coder-api-key-helper.sh",
549+
]);
550+
expect(helper.exitCode).toBe(0);
551+
expect(helper.stdout).toContain("vault kv get -field=key secret/anthropic");
552+
expect(helper.stdout).toContain("700");
553+
554+
const managed = await readFileContainer(
555+
id,
556+
"/etc/claude-code/managed-settings.d/20-coder-apikeyhelper.json",
557+
);
558+
expect(managed).toContain('"apiKeyHelper"');
559+
expect(managed).toContain("/home/coder/.claude/coder-api-key-helper.sh");
560+
561+
const claudeConfig = await readFileContainer(
562+
id,
563+
"/home/coder/.claude.json",
564+
);
565+
const parsed = JSON.parse(claudeConfig);
566+
expect(parsed.hasCompletedOnboarding).toBe(true);
567+
});
521568
});

registry/coder/modules/claude-code/main.tf

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,8 @@ variable "disable_autoupdater" {
6262

6363
variable "anthropic_api_key" {
6464
type = string
65-
description = "API key passed to Claude Code via the ANTHROPIC_API_KEY env var."
65+
description = "API key passed to Claude Code via the ANTHROPIC_API_KEY env var. Prefer api_key_helper for short-lived credentials."
66+
sensitive = true
6667
default = ""
6768
}
6869

@@ -136,6 +137,25 @@ variable "telemetry" {
136137
description = "Configure Claude Code OpenTelemetry export. When enabled, sets CLAUDE_CODE_ENABLE_TELEMETRY and the standard OTEL_EXPORTER_OTLP_* environment variables. Coder workspace identifiers (coder.workspace_id, coder.workspace_name, coder.workspace_owner, coder.template_name) are automatically appended to OTEL_RESOURCE_ATTRIBUTES so Claude Code telemetry can be joined with Coder audit and exectrace logs."
137138
}
138139

140+
variable "api_key_helper" {
141+
type = object({
142+
script = string
143+
ttl_ms = optional(number, 300000)
144+
})
145+
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)."
146+
default = null
147+
148+
validation {
149+
condition = var.api_key_helper == null || (var.anthropic_api_key == "" && var.claude_code_oauth_token == "")
150+
error_message = "api_key_helper cannot be combined with anthropic_api_key or claude_code_oauth_token. Use exactly one authentication method."
151+
}
152+
153+
validation {
154+
condition = var.api_key_helper == null || !var.enable_ai_gateway
155+
error_message = "api_key_helper cannot be combined with enable_ai_gateway. AI Gateway authenticates using the workspace owner's session token."
156+
}
157+
}
158+
139159
resource "coder_env" "claude_code_oauth_token" {
140160
count = var.claude_code_oauth_token != "" ? 1 : 0
141161
agent_id = var.agent_id
@@ -181,6 +201,13 @@ resource "coder_env" "anthropic_base_url" {
181201
value = "${data.coder_workspace.me.access_url}/api/v2/aibridge/anthropic"
182202
}
183203

204+
resource "coder_env" "api_key_helper_ttl" {
205+
count = var.api_key_helper != null ? 1 : 0
206+
agent_id = var.agent_id
207+
name = "CLAUDE_CODE_API_KEY_HELPER_TTL_MS"
208+
value = tostring(var.api_key_helper.ttl_ms)
209+
}
210+
184211
locals {
185212
# Always inject Coder workspace identifiers so OTEL data can be joined with
186213
# Coder's audit log / exectrace on workspace_id without per-template wiring.
@@ -244,6 +271,7 @@ locals {
244271
ARG_MCP_CONFIG_REMOTE_PATH = base64encode(jsonencode(var.mcp_config_remote_path))
245272
ARG_ENABLE_AI_GATEWAY = tostring(var.enable_ai_gateway)
246273
ARG_MANAGED_SETTINGS_JSON = var.managed_settings != null ? base64encode(jsonencode(var.managed_settings)) : ""
274+
ARG_API_KEY_HELPER_SCRIPT = var.api_key_helper != null ? base64encode(var.api_key_helper.script) : ""
247275
})
248276
module_dir_name = ".coder-modules/coder/claude-code"
249277
}

registry/coder/modules/claude-code/main.tftest.hcl

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,3 +327,77 @@ run "test_managed_settings_default_null" {
327327
error_message = "managed_settings should default to null when omitted"
328328
}
329329
}
330+
331+
run "test_api_key_helper" {
332+
command = plan
333+
334+
variables {
335+
agent_id = "test-agent-helper"
336+
workdir = "/home/coder/test"
337+
api_key_helper = {
338+
script = "#!/bin/sh\nvault kv get -field=key secret/anthropic\n"
339+
ttl_ms = 60000
340+
}
341+
}
342+
343+
assert {
344+
condition = coder_env.api_key_helper_ttl[0].name == "CLAUDE_CODE_API_KEY_HELPER_TTL_MS"
345+
error_message = "api_key_helper_ttl env var name should be CLAUDE_CODE_API_KEY_HELPER_TTL_MS"
346+
}
347+
348+
assert {
349+
condition = coder_env.api_key_helper_ttl[0].value == "60000"
350+
error_message = "api_key_helper_ttl env var value should match ttl_ms"
351+
}
352+
}
353+
354+
run "test_api_key_helper_default_ttl" {
355+
command = plan
356+
357+
variables {
358+
agent_id = "test-agent-helper-default"
359+
workdir = "/home/coder/test"
360+
api_key_helper = {
361+
script = "#!/bin/sh\necho key\n"
362+
}
363+
}
364+
365+
assert {
366+
condition = coder_env.api_key_helper_ttl[0].value == "300000"
367+
error_message = "ttl_ms should default to 300000 (5 minutes)"
368+
}
369+
}
370+
371+
run "test_api_key_helper_validation_with_api_key" {
372+
command = plan
373+
374+
variables {
375+
agent_id = "test-agent-validation"
376+
workdir = "/home/coder/test"
377+
anthropic_api_key = "sk-test"
378+
api_key_helper = {
379+
script = "echo key"
380+
}
381+
}
382+
383+
expect_failures = [
384+
var.api_key_helper,
385+
]
386+
}
387+
388+
run "test_api_key_helper_validation_with_ai_gateway" {
389+
command = plan
390+
391+
variables {
392+
agent_id = "test-agent-validation"
393+
workdir = "/home/coder/test"
394+
enable_ai_gateway = true
395+
api_key_helper = {
396+
script = "echo key"
397+
}
398+
}
399+
400+
expect_failures = [
401+
var.api_key_helper,
402+
]
403+
}

registry/coder/modules/claude-code/scripts/install.sh.tftpl

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ ARG_MCP=$(echo -n '${ARG_MCP}' | base64 -d)
1818
ARG_MCP_CONFIG_REMOTE_PATH=$(echo -n '${ARG_MCP_CONFIG_REMOTE_PATH}' | base64 -d)
1919
ARG_ENABLE_AI_GATEWAY='${ARG_ENABLE_AI_GATEWAY}'
2020
ARG_MANAGED_SETTINGS_JSON=$(echo -n '${ARG_MANAGED_SETTINGS_JSON}' | base64 -d)
21+
ARG_API_KEY_HELPER_SCRIPT=$(echo -n '${ARG_API_KEY_HELPER_SCRIPT}' | base64 -d)
2122

2223
export PATH="$${ARG_CLAUDE_BINARY_PATH}:$PATH"
2324

@@ -31,6 +32,7 @@ printf "ARG_MCP: %s\n" "$${ARG_MCP}"
3132
printf "ARG_MCP_CONFIG_REMOTE_PATH: %s\n" "$${ARG_MCP_CONFIG_REMOTE_PATH}"
3233
printf "ARG_ENABLE_AI_GATEWAY: %s\n" "$${ARG_ENABLE_AI_GATEWAY}"
3334
printf "ARG_MANAGED_SETTINGS_JSON: %s\n" "$${ARG_MANAGED_SETTINGS_JSON}"
35+
printf "ARG_API_KEY_HELPER_SCRIPT: %s\n" "$${ARG_API_KEY_HELPER_SCRIPT:+(set)}"
3436

3537
echo "--------------------------------"
3638

@@ -172,11 +174,40 @@ function write_managed_settings() {
172174
echo "Wrote Claude Code managed settings to $${target}"
173175
}
174176

177+
function setup_api_key_helper() {
178+
if [ -z "$${ARG_API_KEY_HELPER_SCRIPT}" ]; then
179+
return
180+
fi
181+
182+
echo "Configuring api_key_helper for short-lived credentials..."
183+
184+
mkdir -p "$HOME/.claude"
185+
local helper_path="$HOME/.claude/coder-api-key-helper.sh"
186+
printf '%s' "$${ARG_API_KEY_HELPER_SCRIPT}" > "$${helper_path}"
187+
chmod 0700 "$${helper_path}"
188+
189+
local dropin_dir="/etc/claude-code/managed-settings.d"
190+
local target="$${dropin_dir}/20-coder-apikeyhelper.json"
191+
if command_exists sudo; then
192+
sudo mkdir -p "$${dropin_dir}"
193+
printf '{"apiKeyHelper":"%s"}\n' "$${helper_path}" | sudo tee "$${target}" > /dev/null
194+
sudo chmod 0644 "$${target}"
195+
elif mkdir -p "$${dropin_dir}" 2> /dev/null; then
196+
printf '{"apiKeyHelper":"%s"}\n' "$${helper_path}" > "$${target}"
197+
chmod 0644 "$${target}"
198+
else
199+
echo "Warning: cannot write to $${dropin_dir} (no sudo and not writable); api_key_helper will not be registered"
200+
return
201+
fi
202+
203+
echo "Wrote api_key_helper script to $${helper_path} and registered via $${target}"
204+
}
205+
175206
function configure_standalone_mode() {
176207
echo "Configuring Claude Code for standalone mode..."
177208

178-
if [ -z "$${ANTHROPIC_API_KEY:-}" ] && [ -z "$${CLAUDE_CODE_OAUTH_TOKEN:-}" ] && [ "$${ARG_ENABLE_AI_GATEWAY}" = "false" ]; then
179-
echo "Note: No authentication configured (anthropic_api_key, claude_code_oauth_token, enable_ai_gateway), skipping onboarding bypass"
209+
if [ -z "$${ANTHROPIC_API_KEY:-}" ] && [ -z "$${CLAUDE_CODE_OAUTH_TOKEN:-}" ] && [ "$${ARG_ENABLE_AI_GATEWAY}" = "false" ] && [ -z "$${ARG_API_KEY_HELPER_SCRIPT}" ]; then
210+
echo "Note: No authentication configured (anthropic_api_key, claude_code_oauth_token, enable_ai_gateway, api_key_helper), skipping onboarding bypass"
180211
return
181212
fi
182213

@@ -214,4 +245,5 @@ EOF
214245
install_claude_code_cli
215246
setup_claude_configurations
216247
write_managed_settings
248+
setup_api_key_helper
217249
configure_standalone_mode

0 commit comments

Comments
 (0)