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
32 changes: 18 additions & 14 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.5.2"
version = "5.5.3"
agent_id = coder_agent.main.id
anthropic_api_key = "xxxx-xxxxx-xxxx"
}
Expand Down Expand Up @@ -52,7 +52,7 @@ locals {

module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "5.5.2"
version = "5.5.3"
agent_id = coder_agent.main.id
workdir = local.claude_workdir
anthropic_api_key = "xxxx-xxxxx-xxxx"
Expand Down Expand Up @@ -83,7 +83,7 @@ resource "coder_app" "claude" {
```tf
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "5.5.2"
version = "5.5.3"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
enable_ai_gateway = true
Expand All @@ -97,6 +97,10 @@ When `enable_ai_gateway = true`, the module sets:

Claude Code then routes API requests through Coder's AI Gateway instead of directly to Anthropic.

The module also sets `apiKeyHelper` in `~/.claude/settings.json` unless you already configured a helper. The command tries `coder login token` and falls back to `ANTHROPIC_AUTH_TOKEN` if the CLI fails or returns an empty token. No credential value is stored in the settings file. A saved CLI login must target the same deployment as AI Gateway.

The helper is a user setting, so you can override it. Turning off AI Gateway removes the module's default helper while preserving custom helpers and other settings.

> [!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.

Expand All @@ -107,7 +111,7 @@ By default the module wires `ANTHROPIC_BASE_URL` and `ANTHROPIC_AUTH_TOKEN` via
```tf
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "5.5.2"
version = "5.5.3"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
enable_ai_gateway = true
Expand All @@ -127,7 +131,7 @@ Setting `managed_settings = null` removes this module's policy file on the next
```tf
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "5.5.2"
version = "5.5.3"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
anthropic_api_key = "xxxx-xxxxx-xxxx"
Expand Down Expand Up @@ -156,7 +160,7 @@ Setting `api_key_helper = null` removes the module's registration file and `~/.c
```tf
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "5.5.2"
version = "5.5.3"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"

Expand All @@ -175,7 +179,7 @@ Or, sourcing from AWS Secrets Manager:
```tf
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "5.5.2"
version = "5.5.3"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"

Expand All @@ -200,7 +204,7 @@ This example shows version pinning, a pre-installed binary path, a custom model,
```tf
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "5.5.2"
version = "5.5.3"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"

Expand Down Expand Up @@ -264,7 +268,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.5.2"
version = "5.5.3"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
anthropic_api_key = "xxxx-xxxxx-xxxx"
Expand Down Expand Up @@ -294,7 +298,7 @@ Set `use_bedrock = true` to route Claude Code through Amazon Bedrock. The module
```tf
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "5.5.2"
version = "5.5.3"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
use_bedrock = true
Expand Down Expand Up @@ -347,7 +351,7 @@ Set `use_vertex = true` to route Claude Code through Google Vertex AI. The modul
```tf
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "5.5.2"
version = "5.5.3"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
use_vertex = true
Expand Down Expand Up @@ -382,7 +386,7 @@ This example uses the Azure default credential chain. Attach a managed identity
```tf
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "5.5.2"
version = "5.5.3"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
use_foundry = true
Expand Down Expand Up @@ -426,7 +430,7 @@ Set `anthropic_base_url` to point Claude Code at a self-hosted gateway or proxy
```tf
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "5.5.2"
version = "5.5.3"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
anthropic_base_url = "https://llm-gateway.example.com/anthropic"
Expand All @@ -445,7 +449,7 @@ The module automatically tags every span and metric with `coder.workspace_id`, `
```tf
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "5.5.2"
version = "5.5.3"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
anthropic_api_key = "xxxx-xxxxx-xxxx"
Expand Down
119 changes: 119 additions & 0 deletions registry/coder/modules/claude-code/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -529,6 +529,121 @@ describe("claude-code", async () => {
expect(parsed.autoModeAccepted).toBeUndefined();
});

test("AI Gateway helper uses the CLI token with environment fallback", async () => {
const { id, coderEnvVars, scripts } = await setup({
moduleVariables: { enable_ai_gateway: "true" },
});
const settingsPath = "/home/coder/.claude/settings.json";
await execContainer(id, ["mkdir", "-p", path.posix.dirname(settingsPath)]);
await writeFileContainer(
id,
settingsPath,
JSON.stringify({ model: "sonnet" }),
);
coderEnvVars.ANTHROPIC_AUTH_TOKEN = "env-token";
await runScripts(id, scripts, coderEnvVars);
const settings = JSON.parse(await readFileContainer(id, settingsPath));
expect(settings.model).toBe("sonnet");
expect(typeof settings.apiKeyHelper).toBe("string");
expect(JSON.stringify(settings)).not.toContain(
coderEnvVars.ANTHROPIC_AUTH_TOKEN,
);

await writeExecutable({
containerId: id,
filePath: "/usr/bin/coder",
content:
'#!/bin/sh\nprintf "%s" "$TEST_CODER_TOKEN"\nexit "$TEST_CODER_EXIT"\n',
});
for (const [token, status, expected] of [
["cli-token", "0", "cli-token"],
["", "0", "env-token"],
["", "1", "env-token"],
["failed-token", "1", "env-token"],
]) {
const result = await execContainer(
id,
["sh", "-c", settings.apiKeyHelper],
[
"-e",
`TEST_CODER_TOKEN=${token}`,
"-e",
`TEST_CODER_EXIT=${status}`,
"-e",
"ANTHROPIC_AUTH_TOKEN=env-token",
],
);
expect(result.exitCode).toBe(0);
expect(result.stdout.trimEnd()).toBe(expected);
expect(result.stderr).toBe("");
}
await execContainer(id, ["rm", "/usr/bin/coder"]);
const missingCli = await execContainer(
id,
["sh", "-c", settings.apiKeyHelper],
["-e", "ANTHROPIC_AUTH_TOKEN=env-token"],
);
expect(missingCli.exitCode).toBe(0);
expect(missingCli.stdout).toBe("env-token\n");
const noCredentials = await execContainer(id, [
"env",
"-u",
"ANTHROPIC_AUTH_TOKEN",
"sh",
"-c",
settings.apiKeyHelper,
]);
expect(noCredentials.exitCode).not.toBe(0);
expect(noCredentials.stdout).toBe("");

await writeExecutable({
containerId: id,
filePath: "/usr/bin/coder",
content: "#!/bin/sh\nexit 0\n",
});
await runScripts(id, scripts, coderEnvVars);
expect(JSON.parse(await readFileContainer(id, settingsPath))).toEqual(
settings,
);
const disabled = await setup();
await runScripts(id, disabled.scripts);
expect(JSON.parse(await readFileContainer(id, settingsPath))).toEqual({
model: "sonnet",
});
});

test("AI Gateway preserves custom user helpers", async () => {
const { id, coderEnvVars, scripts } = await setup({
moduleVariables: { enable_ai_gateway: "true" },
});
const settingsPath = "/home/coder/.claude/settings.json";
const settings = { model: "sonnet", apiKeyHelper: "custom-helper" };
await execContainer(id, ["mkdir", "-p", path.posix.dirname(settingsPath)]);
await writeFileContainer(id, settingsPath, JSON.stringify(settings));
await runScripts(id, scripts, coderEnvVars);
expect(JSON.parse(await readFileContainer(id, settingsPath))).toEqual(
settings,
);
const disabled = await setup();
await runScripts(id, disabled.scripts);
expect(JSON.parse(await readFileContainer(id, settingsPath))).toEqual(
settings,
);
});

test("AI Gateway leaves malformed user settings intact", async () => {
const { id, coderEnvVars, scripts } = await setup({
moduleVariables: { enable_ai_gateway: "true" },
});
const settingsPath = "/home/coder/.claude/settings.json";
await execContainer(id, ["mkdir", "-p", path.posix.dirname(settingsPath)]);
await writeFileContainer(id, settingsPath, "invalid-json");
await expect(runScripts(id, scripts, coderEnvVars)).rejects.toThrow(
"script exited",
);
expect(await readFileContainer(id, settingsPath)).toBe("invalid-json");
});

test("standalone-mode-with-oauth-token", async () => {
const token = "test-oauth-token-standalone";
const { id, coderEnvVars, scripts } = await setup({
Expand Down Expand Up @@ -559,7 +674,11 @@ describe("claude-code", async () => {

test("standalone-mode-no-auth", async () => {
const { id, coderEnvVars, scripts } = await setup();
const settingsPath = "/home/coder/.claude/settings.json";
await execContainer(id, ["mkdir", "-p", path.posix.dirname(settingsPath)]);
await writeFileContainer(id, settingsPath, "invalid-json");
await runScripts(id, scripts, coderEnvVars);
expect(await readFileContainer(id, settingsPath)).toBe("invalid-json");

const installLog = await readFileContainer(
id,
Expand Down
31 changes: 31 additions & 0 deletions registry/coder/modules/claude-code/scripts/install.sh.tftpl
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,36 @@ function write_managed_settings() {
echo "Wrote Claude Code managed settings to $${target}"
}

function setup_gateway_api_key_helper() (
local settings_dir="$${CLAUDE_CONFIG_DIR:-$HOME/.claude}"
local settings="$${settings_dir}/settings.json"
# Claude expands the helper command when it requests a credential.
# shellcheck disable=SC2016
local helper='token=$(coder login token 2>/dev/null) && [ -n "$token" ] && printf "%s" "$token" || printenv ANTHROPIC_AUTH_TOKEN'

if [ "$${ARG_ENABLE_AI_GATEWAY}" != "true" ]; then
if [ ! -f "$${settings}" ] || ! jq -e --arg helper "$${helper}" '.apiKeyHelper == $helper' "$${settings}" > /dev/null 2>&1; then
return
fi
fi

mkdir -p "$${settings_dir}"
local settings_tmp
settings_tmp=$(mktemp "$${settings}.XXXXXX")
trap 'rm -f "$${settings_tmp}"' EXIT

if [ -f "$${settings}" ]; then
jq --arg helper "$${helper}" --argjson enabled "$${ARG_ENABLE_AI_GATEWAY}" '
if $enabled then .apiKeyHelper //= $helper
elif .apiKeyHelper == $helper then del(.apiKeyHelper)
else . end
' "$${settings}" > "$${settings_tmp}"
else
jq -n --arg helper "$${helper}" '{apiKeyHelper: $helper}' > "$${settings_tmp}"
fi
mv "$${settings_tmp}" "$${settings}"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Could we preserve the symlink when updating settings.json and skip rewriting it when no change is needed? The mv replaces a symlink with a regular file, even when a custom helper is already configured. For users managing this file through dotfiles, subsequent updates to the original file no longer reach Claude Code.

)

function setup_api_key_helper() {
local helper_path="$HOME/.claude/coder-api-key-helper.sh"
local dropin_dir="/etc/claude-code/managed-settings.d"
Expand Down Expand Up @@ -328,4 +358,5 @@ install_claude_code_cli
setup_claude_configurations
write_managed_settings
setup_api_key_helper
setup_gateway_api_key_helper
configure_standalone_mode
Loading