Skip to content

Commit 65bdca4

Browse files
committed
feat(claude-code): add api_key_helper input; mark claude_api_key sensitive; stop writing primaryApiKey to ~/.claude.json
1 parent 39f332f commit 65bdca4

5 files changed

Lines changed: 240 additions & 8 deletions

File tree

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

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,52 @@ module "claude-code" {
218218
}
219219
```
220220

221+
### Short-lived credentials via api_key_helper
222+
223+
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`.
224+
225+
#### HashiCorp Vault
226+
227+
```tf
228+
module "claude-code" {
229+
source = "registry.coder.com/coder/claude-code/coder"
230+
version = "4.9.2"
231+
agent_id = coder_agent.main.id
232+
workdir = "/home/coder/project"
233+
234+
api_key_helper = {
235+
script = <<-EOT
236+
#!/bin/sh
237+
exec vault kv get -field=key secret/anthropic
238+
EOT
239+
ttl_ms = 300000
240+
}
241+
}
242+
```
243+
244+
#### AWS Secrets Manager
245+
246+
```tf
247+
module "claude-code" {
248+
source = "registry.coder.com/coder/claude-code/coder"
249+
version = "4.9.2"
250+
agent_id = coder_agent.main.id
251+
workdir = "/home/coder/project"
252+
253+
api_key_helper = {
254+
script = <<-EOT
255+
#!/bin/sh
256+
exec aws secretsmanager get-secret-value \
257+
--secret-id anthropic/api-key \
258+
--query SecretString --output text
259+
EOT
260+
}
261+
}
262+
```
263+
264+
> [!NOTE]
265+
> `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`.
266+
221267
### Usage with AWS Bedrock
222268

223269
#### Prerequisites

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

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,60 @@ describe("claude-code", async () => {
126126
expect(envCheck.stdout).toContain("CLAUDE_API_KEY");
127127
});
128128

129+
test("claude-api-key-not-written-to-claude-json", async () => {
130+
const apiKey = "sk-ant-test-do-not-persist";
131+
const { id, coderEnvVars } = await setup({
132+
moduleVariables: {
133+
claude_api_key: apiKey,
134+
report_tasks: "false",
135+
},
136+
});
137+
await execModuleScript(id, coderEnvVars);
138+
139+
const claudeConfig = await readFileContainer(
140+
id,
141+
"/home/coder/.claude.json",
142+
);
143+
expect(claudeConfig).toContain("hasCompletedOnboarding");
144+
expect(claudeConfig).not.toContain("primaryApiKey");
145+
expect(claudeConfig).not.toContain(apiKey);
146+
});
147+
148+
test("api-key-helper", async () => {
149+
const helperBody = "#!/bin/sh\nvault kv get -field=key secret/anthropic\n";
150+
const { id, coderEnvVars } = await setup({
151+
moduleVariables: {
152+
api_key_helper: JSON.stringify({ script: helperBody, ttl_ms: 60000 }),
153+
report_tasks: "false",
154+
},
155+
});
156+
await execModuleScript(id);
157+
158+
expect(coderEnvVars["CLAUDE_CODE_API_KEY_HELPER_TTL_MS"]).toBe("60000");
159+
160+
const helper = await execContainer(id, [
161+
"bash",
162+
"-c",
163+
"stat -c '%a' /home/coder/.claude/coder-api-key-helper.sh && cat /home/coder/.claude/coder-api-key-helper.sh",
164+
]);
165+
expect(helper.stdout).toContain("700");
166+
expect(helper.stdout).toContain("vault kv get -field=key secret/anthropic");
167+
168+
const managed = await readFileContainer(
169+
id,
170+
"/etc/claude-code/managed-settings.d/20-coder-apikeyhelper.json",
171+
);
172+
expect(managed).toContain('"apiKeyHelper"');
173+
expect(managed).toContain("/home/coder/.claude/coder-api-key-helper.sh");
174+
175+
const claudeConfig = await readFileContainer(
176+
id,
177+
"/home/coder/.claude.json",
178+
);
179+
expect(claudeConfig).toContain("hasCompletedOnboarding");
180+
expect(claudeConfig).not.toContain("primaryApiKey");
181+
});
182+
129183
test("claude-mcp-config", async () => {
130184
const mcpConfig = JSON.stringify({
131185
mcpServers: {

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

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,8 +128,9 @@ variable "disable_autoupdater" {
128128

129129
variable "claude_api_key" {
130130
type = string
131-
description = "The API key to use for the Claude Code server."
131+
description = "Anthropic API key. Sets ANTHROPIC_API_KEY in the workspace environment. Prefer api_key_helper for short-lived credentials."
132132
default = ""
133+
sensitive = true
133134
}
134135

135136
variable "model" {
@@ -198,6 +199,25 @@ variable "claude_code_oauth_token" {
198199
default = ""
199200
}
200201

202+
variable "api_key_helper" {
203+
type = object({
204+
script = string
205+
ttl_ms = optional(number, 300000)
206+
})
207+
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)."
208+
default = null
209+
210+
validation {
211+
condition = var.api_key_helper == null || (var.claude_api_key == "" && var.claude_code_oauth_token == "")
212+
error_message = "api_key_helper cannot be combined with claude_api_key or claude_code_oauth_token. Use exactly one authentication method."
213+
}
214+
215+
validation {
216+
condition = var.api_key_helper == null || !var.enable_aibridge
217+
error_message = "api_key_helper cannot be combined with enable_aibridge. AI Bridge handles authentication via the workspace owner's session token."
218+
}
219+
}
220+
201221
variable "system_prompt" {
202222
type = string
203223
description = "The system prompt to use for the Claude Code server."
@@ -307,6 +327,13 @@ resource "coder_env" "disable_autoupdater" {
307327
value = "1"
308328
}
309329

330+
resource "coder_env" "api_key_helper_ttl_ms" {
331+
count = var.api_key_helper != null ? 1 : 0
332+
agent_id = var.agent_id
333+
name = "CLAUDE_CODE_API_KEY_HELPER_TTL_MS"
334+
value = tostring(var.api_key_helper.ttl_ms)
335+
}
336+
310337

311338
resource "coder_env" "anthropic_model" {
312339
count = var.model != "" ? 1 : 0
@@ -431,6 +458,7 @@ module "agentapi" {
431458
ARG_MCP_CONFIG_REMOTE_PATH='${base64encode(jsonencode(var.mcp_config_remote_path))}' \
432459
ARG_ENABLE_AIBRIDGE='${var.enable_aibridge}' \
433460
ARG_PERMISSION_MODE='${var.permission_mode}' \
461+
ARG_API_KEY_HELPER_SCRIPT='${var.api_key_helper != null ? base64encode(var.api_key_helper.script) : ""}' \
434462
/tmp/install.sh
435463
EOT
436464
}

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

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -459,4 +459,83 @@ run "test_api_key_count_with_aibridge_no_override" {
459459
condition = length(coder_env.claude_api_key) == 1
460460
error_message = "CLAUDE_API_KEY env should be created when aibridge is enabled, regardless of session_token value"
461461
}
462+
}
463+
464+
run "test_api_key_helper" {
465+
command = plan
466+
467+
variables {
468+
agent_id = "test-agent-helper"
469+
workdir = "/home/coder/test"
470+
api_key_helper = {
471+
script = "#!/bin/sh\nvault kv get -field=key secret/anthropic\n"
472+
ttl_ms = 60000
473+
}
474+
}
475+
476+
assert {
477+
condition = length(coder_env.api_key_helper_ttl_ms) == 1
478+
error_message = "CLAUDE_CODE_API_KEY_HELPER_TTL_MS env should be created when api_key_helper is set"
479+
}
480+
481+
assert {
482+
condition = coder_env.api_key_helper_ttl_ms[0].value == "60000"
483+
error_message = "CLAUDE_CODE_API_KEY_HELPER_TTL_MS should match api_key_helper.ttl_ms"
484+
}
485+
486+
assert {
487+
condition = length(coder_env.claude_api_key) == 0
488+
error_message = "CLAUDE_API_KEY env should not be created when api_key_helper is the auth source"
489+
}
490+
}
491+
492+
run "test_api_key_helper_default_ttl" {
493+
command = plan
494+
495+
variables {
496+
agent_id = "test-agent-helper-default"
497+
workdir = "/home/coder/test"
498+
api_key_helper = {
499+
script = "#!/bin/sh\necho key\n"
500+
}
501+
}
502+
503+
assert {
504+
condition = coder_env.api_key_helper_ttl_ms[0].value == "300000"
505+
error_message = "ttl_ms should default to 300000 (5 minutes)"
506+
}
507+
}
508+
509+
run "test_api_key_helper_validation_with_api_key" {
510+
command = plan
511+
512+
variables {
513+
agent_id = "test-agent-helper-validation"
514+
workdir = "/home/coder/test"
515+
claude_api_key = "test-key"
516+
api_key_helper = {
517+
script = "#!/bin/sh\necho key\n"
518+
}
519+
}
520+
521+
expect_failures = [
522+
var.api_key_helper,
523+
]
524+
}
525+
526+
run "test_api_key_helper_validation_with_aibridge" {
527+
command = plan
528+
529+
variables {
530+
agent_id = "test-agent-helper-validation-aibridge"
531+
workdir = "/home/coder/test"
532+
enable_aibridge = true
533+
api_key_helper = {
534+
script = "#!/bin/sh\necho key\n"
535+
}
536+
}
537+
538+
expect_failures = [
539+
var.api_key_helper,
540+
]
462541
}

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

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ ARG_ALLOWED_TOOLS=${ARG_ALLOWED_TOOLS:-}
2323
ARG_DISALLOWED_TOOLS=${ARG_DISALLOWED_TOOLS:-}
2424
ARG_ENABLE_AIBRIDGE=${ARG_ENABLE_AIBRIDGE:-false}
2525
ARG_PERMISSION_MODE=${ARG_PERMISSION_MODE:-}
26+
ARG_API_KEY_HELPER_SCRIPT=${ARG_API_KEY_HELPER_SCRIPT:-}
2627

2728
export PATH="$ARG_CLAUDE_BINARY_PATH:$PATH"
2829

@@ -180,27 +181,27 @@ function setup_claude_configurations() {
180181
function configure_standalone_mode() {
181182
echo "Configuring Claude Code for standalone mode..."
182183

183-
if [ -z "${CLAUDE_API_KEY:-}" ] && [ "$ARG_ENABLE_AIBRIDGE" = "false" ]; then
184-
echo "Note: Neither claude_api_key nor enable_aibridge is set, skipping authentication setup"
184+
if [ -z "${CLAUDE_API_KEY:-}" ] && [ "$ARG_ENABLE_AIBRIDGE" = "false" ] && [ -z "$ARG_API_KEY_HELPER_SCRIPT" ]; then
185+
echo "Note: No authentication configured (claude_api_key, enable_aibridge, or api_key_helper), skipping authentication setup"
185186
return
186187
fi
187188

188189
local claude_config="$HOME/.claude.json"
189190
local workdir_normalized
190191
workdir_normalized=$(echo "$ARG_WORKDIR" | tr '/' '-')
191192

192-
# Create or update .claude.json with minimal configuration for API key auth
193-
# This skips the interactive login prompt and onboarding screens
193+
# Pre-accept onboarding and trust prompts so the CLI starts non-interactively.
194+
# The API key itself is supplied via env (ANTHROPIC_API_KEY / CLAUDE_API_KEY)
195+
# or apiKeyHelper, never written to this file.
194196
if [ -f "$claude_config" ]; then
195197
echo "Updating existing Claude configuration at $claude_config"
196198

197-
jq --arg workdir "$ARG_WORKDIR" --arg apikey "${CLAUDE_API_KEY:-}" \
199+
jq --arg workdir "$ARG_WORKDIR" \
198200
'.autoUpdaterStatus = "disabled" |
199201
.autoModeAccepted = true |
200202
.bypassPermissionsModeAccepted = true |
201203
.hasAcknowledgedCostThreshold = true |
202204
.hasCompletedOnboarding = true |
203-
.primaryApiKey = $apikey |
204205
.projects[$workdir].hasCompletedProjectOnboarding = true |
205206
.projects[$workdir].hasTrustDialogAccepted = true' \
206207
"$claude_config" > "${claude_config}.tmp" && mv "${claude_config}.tmp" "$claude_config"
@@ -213,7 +214,6 @@ function configure_standalone_mode() {
213214
"bypassPermissionsModeAccepted": true,
214215
"hasAcknowledgedCostThreshold": true,
215216
"hasCompletedOnboarding": true,
216-
"primaryApiKey": "${CLAUDE_API_KEY:-}",
217217
"projects": {
218218
"$ARG_WORKDIR": {
219219
"hasCompletedProjectOnboarding": true,
@@ -227,6 +227,30 @@ EOF
227227
echo "Standalone mode configured successfully"
228228
}
229229

230+
function setup_api_key_helper() {
231+
if [ -z "$ARG_API_KEY_HELPER_SCRIPT" ]; then
232+
return
233+
fi
234+
235+
echo "Configuring apiKeyHelper for short-lived credentials..."
236+
237+
mkdir -p "$HOME/.claude"
238+
local helper_path="$HOME/.claude/coder-api-key-helper.sh"
239+
echo -n "$ARG_API_KEY_HELPER_SCRIPT" | base64 -d > "$helper_path"
240+
chmod 0700 "$helper_path"
241+
242+
local managed_dir="/etc/claude-code/managed-settings.d"
243+
if command_exists sudo; then
244+
sudo mkdir -p "$managed_dir"
245+
printf '{\n "apiKeyHelper": "%s"\n}\n' "$helper_path" | sudo tee "$managed_dir/20-coder-apikeyhelper.json" > /dev/null
246+
else
247+
mkdir -p "$managed_dir"
248+
printf '{\n "apiKeyHelper": "%s"\n}\n' "$helper_path" > "$managed_dir/20-coder-apikeyhelper.json"
249+
fi
250+
251+
echo "apiKeyHelper registered at $helper_path (settings: $managed_dir/20-coder-apikeyhelper.json)"
252+
}
253+
230254
function report_tasks() {
231255
if [ "$ARG_REPORT_TASKS" = "true" ]; then
232256
echo "Configuring Claude Code to report tasks via Coder MCP..."
@@ -258,6 +282,7 @@ function accept_auto_mode() {
258282

259283
install_claude_code_cli
260284
setup_claude_configurations
285+
setup_api_key_helper
261286
report_tasks
262287

263288
if [ "$ARG_PERMISSION_MODE" = "auto" ]; then

0 commit comments

Comments
 (0)