Skip to content

Commit 0fe1053

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 3494da4 commit 0fe1053

5 files changed

Lines changed: 236 additions & 10 deletions

File tree

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

Lines changed: 52 additions & 7 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.0.0"
16+
version = "5.1.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.0.0"
51+
version = "5.1.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.0.0"
82+
version = "5.1.0"
8283
agent_id = coder_agent.main.id
8384
workdir = "/home/coder/project"
8485
enable_ai_gateway = true
@@ -95,14 +96,58 @@ Claude Code then routes API requests through Coder's AI Gateway instead of direc
9596
> [!CAUTION]
9697
> `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.
9798
99+
### Short-lived credentials via api_key_helper
100+
101+
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`.
102+
103+
```tf
104+
module "claude-code" {
105+
source = "registry.coder.com/coder/claude-code/coder"
106+
version = "5.1.0"
107+
agent_id = coder_agent.main.id
108+
workdir = "/home/coder/project"
109+
110+
api_key_helper = {
111+
script = <<-EOT
112+
#!/bin/sh
113+
exec vault kv get -field=key secret/anthropic
114+
EOT
115+
ttl_ms = 300000
116+
}
117+
}
118+
```
119+
120+
Or, sourcing from AWS Secrets Manager:
121+
122+
```tf
123+
module "claude-code" {
124+
source = "registry.coder.com/coder/claude-code/coder"
125+
version = "5.1.0"
126+
agent_id = coder_agent.main.id
127+
workdir = "/home/coder/project"
128+
129+
api_key_helper = {
130+
script = <<-EOT
131+
#!/bin/sh
132+
exec aws secretsmanager get-secret-value \
133+
--secret-id anthropic/api-key \
134+
--query SecretString --output text
135+
EOT
136+
}
137+
}
138+
```
139+
140+
> [!NOTE]
141+
> `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`.
142+
98143
### Advanced Configuration
99144

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

102147
```tf
103148
module "claude-code" {
104149
source = "registry.coder.com/coder/claude-code/coder"
105-
version = "5.0.0"
150+
version = "5.1.0"
106151
agent_id = coder_agent.main.id
107152
workdir = "/home/coder/project"
108153
@@ -166,7 +211,7 @@ Downstream `coder_script` resources can wait for this module's install pipeline
166211
```tf
167212
module "claude-code" {
168213
source = "registry.coder.com/coder/claude-code/coder"
169-
version = "5.0.0"
214+
version = "5.1.0"
170215
agent_id = coder_agent.main.id
171216
workdir = "/home/coder/project"
172217
anthropic_api_key = "xxxx-xxxxx-xxxx"
@@ -252,7 +297,7 @@ resource "coder_env" "bedrock_api_key" {
252297
253298
module "claude-code" {
254299
source = "registry.coder.com/coder/claude-code/coder"
255-
version = "5.0.0"
300+
version = "5.1.0"
256301
agent_id = coder_agent.main.id
257302
workdir = "/home/coder/project"
258303
model = "global.anthropic.claude-sonnet-4-5-20250929-v1:0"
@@ -309,7 +354,7 @@ resource "coder_env" "google_application_credentials" {
309354
310355
module "claude-code" {
311356
source = "registry.coder.com/coder/claude-code/coder"
312-
version = "5.0.0"
357+
version = "5.1.0"
313358
agent_id = coder_agent.main.id
314359
workdir = "/home/coder/project"
315360
model = "claude-sonnet-4@20250514"

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

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -435,4 +435,51 @@ describe("claude-code", async () => {
435435
]);
436436
expect(resp.stdout.trim()).toBe("ABSENT");
437437
});
438+
439+
test("api-key-helper", async () => {
440+
const helperBody = "#!/bin/sh\nvault kv get -field=key secret/anthropic\n";
441+
const { id, coderEnvVars, scripts } = await setup({
442+
moduleVariables: {
443+
api_key_helper: JSON.stringify({ script: helperBody, ttl_ms: 60000 }),
444+
},
445+
});
446+
expect(coderEnvVars["CLAUDE_CODE_API_KEY_HELPER_TTL_MS"]).toBe("60000");
447+
448+
await runScripts(id, scripts, coderEnvVars);
449+
450+
const installLog = await readFileContainer(
451+
id,
452+
"/home/coder/.coder-modules/coder/claude-code/logs/install.log",
453+
);
454+
expect(installLog).toContain("Configuring api_key_helper");
455+
expect(installLog).toContain(
456+
"Wrote api_key_helper script to /home/coder/.claude/coder-api-key-helper.sh",
457+
);
458+
// api_key_helper counts as authentication, so onboarding bypass runs.
459+
expect(installLog).not.toContain("skipping onboarding bypass");
460+
expect(installLog).toContain("Standalone mode configured successfully");
461+
462+
const helper = await execContainer(id, [
463+
"bash",
464+
"-c",
465+
"cat /home/coder/.claude/coder-api-key-helper.sh && stat -c '%a' /home/coder/.claude/coder-api-key-helper.sh",
466+
]);
467+
expect(helper.exitCode).toBe(0);
468+
expect(helper.stdout).toContain("vault kv get -field=key secret/anthropic");
469+
expect(helper.stdout).toContain("700");
470+
471+
const managed = await readFileContainer(
472+
id,
473+
"/etc/claude-code/managed-settings.d/20-coder-apikeyhelper.json",
474+
);
475+
expect(managed).toContain('"apiKeyHelper"');
476+
expect(managed).toContain("/home/coder/.claude/coder-api-key-helper.sh");
477+
478+
const claudeConfig = await readFileContainer(
479+
id,
480+
"/home/coder/.claude.json",
481+
);
482+
const parsed = JSON.parse(claudeConfig);
483+
expect(parsed.hasCompletedOnboarding).toBe(true);
484+
});
438485
});

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

@@ -118,6 +119,25 @@ variable "enable_ai_gateway" {
118119
}
119120
}
120121

122+
variable "api_key_helper" {
123+
type = object({
124+
script = string
125+
ttl_ms = optional(number, 300000)
126+
})
127+
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)."
128+
default = null
129+
130+
validation {
131+
condition = var.api_key_helper == null || (var.anthropic_api_key == "" && var.claude_code_oauth_token == "")
132+
error_message = "api_key_helper cannot be combined with anthropic_api_key or claude_code_oauth_token. Use exactly one authentication method."
133+
}
134+
135+
validation {
136+
condition = var.api_key_helper == null || !var.enable_ai_gateway
137+
error_message = "api_key_helper cannot be combined with enable_ai_gateway. AI Gateway authenticates using the workspace owner's session token."
138+
}
139+
}
140+
121141
resource "coder_env" "claude_code_oauth_token" {
122142
count = var.claude_code_oauth_token != "" ? 1 : 0
123143
agent_id = var.agent_id
@@ -163,6 +183,13 @@ resource "coder_env" "anthropic_base_url" {
163183
value = "${data.coder_workspace.me.access_url}/api/v2/aibridge/anthropic"
164184
}
165185

186+
resource "coder_env" "api_key_helper_ttl" {
187+
count = var.api_key_helper != null ? 1 : 0
188+
agent_id = var.agent_id
189+
name = "CLAUDE_CODE_API_KEY_HELPER_TTL_MS"
190+
value = tostring(var.api_key_helper.ttl_ms)
191+
}
192+
166193
locals {
167194
workdir = var.workdir != null ? trimsuffix(var.workdir, "/") : ""
168195
install_script = templatefile("${path.module}/scripts/install.sh.tftpl", {
@@ -173,6 +200,7 @@ locals {
173200
ARG_MCP = var.mcp != "" ? base64encode(var.mcp) : ""
174201
ARG_MCP_CONFIG_REMOTE_PATH = base64encode(jsonencode(var.mcp_config_remote_path))
175202
ARG_ENABLE_AI_GATEWAY = tostring(var.enable_ai_gateway)
203+
ARG_API_KEY_HELPER_SCRIPT = var.api_key_helper != null ? base64encode(var.api_key_helper.script) : ""
176204
})
177205
module_dir_name = ".coder-modules/coder/claude-code"
178206
}

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

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,3 +283,77 @@ run "test_workdir_optional" {
283283
error_message = "workdir should default to null when omitted"
284284
}
285285
}
286+
287+
run "test_api_key_helper" {
288+
command = plan
289+
290+
variables {
291+
agent_id = "test-agent-helper"
292+
workdir = "/home/coder/test"
293+
api_key_helper = {
294+
script = "#!/bin/sh\nvault kv get -field=key secret/anthropic\n"
295+
ttl_ms = 60000
296+
}
297+
}
298+
299+
assert {
300+
condition = coder_env.api_key_helper_ttl[0].name == "CLAUDE_CODE_API_KEY_HELPER_TTL_MS"
301+
error_message = "api_key_helper_ttl env var name should be CLAUDE_CODE_API_KEY_HELPER_TTL_MS"
302+
}
303+
304+
assert {
305+
condition = coder_env.api_key_helper_ttl[0].value == "60000"
306+
error_message = "api_key_helper_ttl env var value should match ttl_ms"
307+
}
308+
}
309+
310+
run "test_api_key_helper_default_ttl" {
311+
command = plan
312+
313+
variables {
314+
agent_id = "test-agent-helper-default"
315+
workdir = "/home/coder/test"
316+
api_key_helper = {
317+
script = "#!/bin/sh\necho key\n"
318+
}
319+
}
320+
321+
assert {
322+
condition = coder_env.api_key_helper_ttl[0].value == "300000"
323+
error_message = "ttl_ms should default to 300000 (5 minutes)"
324+
}
325+
}
326+
327+
run "test_api_key_helper_validation_with_api_key" {
328+
command = plan
329+
330+
variables {
331+
agent_id = "test-agent-validation"
332+
workdir = "/home/coder/test"
333+
anthropic_api_key = "sk-test"
334+
api_key_helper = {
335+
script = "echo key"
336+
}
337+
}
338+
339+
expect_failures = [
340+
var.api_key_helper,
341+
]
342+
}
343+
344+
run "test_api_key_helper_validation_with_ai_gateway" {
345+
command = plan
346+
347+
variables {
348+
agent_id = "test-agent-validation"
349+
workdir = "/home/coder/test"
350+
enable_ai_gateway = true
351+
api_key_helper = {
352+
script = "echo key"
353+
}
354+
}
355+
356+
expect_failures = [
357+
var.api_key_helper,
358+
]
359+
}

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

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ ARG_CLAUDE_BINARY_PATH="$${ARG_CLAUDE_BINARY_PATH//\$HOME/$HOME}"
1717
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}'
20+
ARG_API_KEY_HELPER_SCRIPT=$(echo -n '${ARG_API_KEY_HELPER_SCRIPT}' | base64 -d)
2021

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

@@ -29,6 +30,7 @@ printf "ARG_CLAUDE_BINARY_PATH: %s\n" "$${ARG_CLAUDE_BINARY_PATH}"
2930
printf "ARG_MCP: %s\n" "$${ARG_MCP}"
3031
printf "ARG_MCP_CONFIG_REMOTE_PATH: %s\n" "$${ARG_MCP_CONFIG_REMOTE_PATH}"
3132
printf "ARG_ENABLE_AI_GATEWAY: %s\n" "$${ARG_ENABLE_AI_GATEWAY}"
33+
printf "ARG_API_KEY_HELPER_SCRIPT: %s\n" "$${ARG_API_KEY_HELPER_SCRIPT:+(set)}"
3234

3335
echo "--------------------------------"
3436

@@ -144,11 +146,40 @@ function setup_claude_configurations() {
144146

145147
}
146148

149+
function setup_api_key_helper() {
150+
if [ -z "$${ARG_API_KEY_HELPER_SCRIPT}" ]; then
151+
return
152+
fi
153+
154+
echo "Configuring api_key_helper for short-lived credentials..."
155+
156+
mkdir -p "$HOME/.claude"
157+
local helper_path="$HOME/.claude/coder-api-key-helper.sh"
158+
printf '%s' "$${ARG_API_KEY_HELPER_SCRIPT}" > "$${helper_path}"
159+
chmod 0700 "$${helper_path}"
160+
161+
local dropin_dir="/etc/claude-code/managed-settings.d"
162+
local target="$${dropin_dir}/20-coder-apikeyhelper.json"
163+
if command_exists sudo; then
164+
sudo mkdir -p "$${dropin_dir}"
165+
printf '{"apiKeyHelper":"%s"}\n' "$${helper_path}" | sudo tee "$${target}" > /dev/null
166+
sudo chmod 0644 "$${target}"
167+
elif mkdir -p "$${dropin_dir}" 2> /dev/null; then
168+
printf '{"apiKeyHelper":"%s"}\n' "$${helper_path}" > "$${target}"
169+
chmod 0644 "$${target}"
170+
else
171+
echo "Warning: cannot write to $${dropin_dir} (no sudo and not writable); api_key_helper will not be registered"
172+
return
173+
fi
174+
175+
echo "Wrote api_key_helper script to $${helper_path} and registered via $${target}"
176+
}
177+
147178
function configure_standalone_mode() {
148179
echo "Configuring Claude Code for standalone mode..."
149180

150-
if [ -z "$${ANTHROPIC_API_KEY:-}" ] && [ -z "$${CLAUDE_CODE_OAUTH_TOKEN:-}" ] && [ "$${ARG_ENABLE_AI_GATEWAY}" = "false" ]; then
151-
echo "Note: No authentication configured (anthropic_api_key, claude_code_oauth_token, enable_ai_gateway), skipping onboarding bypass"
181+
if [ -z "$${ANTHROPIC_API_KEY:-}" ] && [ -z "$${CLAUDE_CODE_OAUTH_TOKEN:-}" ] && [ "$${ARG_ENABLE_AI_GATEWAY}" = "false" ] && [ -z "$${ARG_API_KEY_HELPER_SCRIPT}" ]; then
182+
echo "Note: No authentication configured (anthropic_api_key, claude_code_oauth_token, enable_ai_gateway, api_key_helper), skipping onboarding bypass"
152183
return
153184
fi
154185

@@ -189,4 +220,5 @@ EOF
189220

190221
install_claude_code_cli
191222
setup_claude_configurations
223+
setup_api_key_helper
192224
configure_standalone_mode

0 commit comments

Comments
 (0)