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.1"
version = "5.5.2"
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.1"
version = "5.5.2"
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.1"
version = "5.5.2"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
enable_ai_gateway = true
Expand All @@ -107,7 +107,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.1"
version = "5.5.2"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
enable_ai_gateway = true
Expand All @@ -122,10 +122,12 @@ module "claude-code" {

The `managed_settings` input writes a policy file to `/etc/claude-code/managed-settings.d/10-coder.json` inside the workspace. Claude Code reads this directory at startup with the highest configuration precedence, so users cannot override these values in their own `~/.claude/settings.json`. This is a local file mechanism and works with any inference backend (Anthropic API, AWS Bedrock, Google Vertex AI, or AI Gateway).

Setting `managed_settings = null` removes this module's policy file on the next workspace start, unless `authentication_config = "managed_settings"` still requires it for gateway authentication. Other Claude Code policy and user configuration files are preserved. Removing a system policy file requires write access to its directory or passwordless sudo; a cleanup failure stops the install script and is reported in `~/.coder-modules/coder/claude-code/logs/install.log`.

```tf
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "5.5.1"
version = "5.5.2"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
anthropic_api_key = "xxxx-xxxxx-xxxx"
Expand All @@ -149,10 +151,12 @@ See the [Claude Code settings reference](https://docs.anthropic.com/en/docs/clau

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`.

Setting `api_key_helper = null` removes the module's registration file and `~/.claude/coder-api-key-helper.sh` on the next workspace start. Other managed settings and user configuration are preserved. Cleanup failures stop the install script and are reported in the install log.

```tf
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "5.5.1"
version = "5.5.2"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"

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

Expand All @@ -196,7 +200,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.1"
version = "5.5.2"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"

Expand Down Expand Up @@ -260,7 +264,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.1"
version = "5.5.2"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
anthropic_api_key = "xxxx-xxxxx-xxxx"
Expand Down Expand Up @@ -290,7 +294,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.1"
version = "5.5.2"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
use_bedrock = true
Expand Down Expand Up @@ -343,7 +347,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.1"
version = "5.5.2"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
use_vertex = true
Expand Down Expand Up @@ -378,7 +382,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.1"
version = "5.5.2"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
use_foundry = true
Expand Down Expand Up @@ -422,7 +426,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.1"
version = "5.5.2"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
anthropic_base_url = "https://llm-gateway.example.com/anthropic"
Expand All @@ -441,7 +445,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.1"
version = "5.5.2"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
anthropic_api_key = "xxxx-xxxxx-xxxx"
Expand Down
188 changes: 188 additions & 0 deletions registry/coder/modules/claude-code/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
runTerraformApply,
runTerraformInit,
TerraformState,
writeFileContainer,
} from "~test";
import { extractCoderEnvVars, writeExecutable } from "../agentapi/test-util";
import path from "path";
Expand Down Expand Up @@ -609,6 +610,11 @@ describe("claude-code", async () => {

test("claude-managed-settings-not-set", async () => {
const { id, scripts } = await setup();
await writeExecutable({
containerId: id,
filePath: "/usr/bin/sudo",
content: "#!/bin/sh\necho 'unexpected sudo invocation' >&2\nexit 1\n",
});
await runScripts(id, scripts);

const resp = await execContainer(id, [
Expand All @@ -617,6 +623,188 @@ describe("claude-code", async () => {
"test -e /etc/claude-code/managed-settings.d/10-coder.json && echo EXISTS || echo ABSENT",
]);
expect(resp.stdout.trim()).toBe("ABSENT");
const helper = await execContainer(id, [
"bash",
"-c",
"test ! -e /etc/claude-code/managed-settings.d/20-coder-apikeyhelper.json && test ! -e /home/coder/.claude/coder-api-key-helper.sh",
]);
expect(helper.exitCode).toBe(0);
});

for (const sudo of [true, false]) {
test.each(["managed_settings", "api_key_helper", "both"])(
`removes disabled %s configuration (sudo=${sudo})`,
async (disabled) => {
const managedSettings = JSON.stringify({ model: "sonnet" });
const helperBody = "#!/bin/sh\necho test-key\n";
const apiKeyHelper = JSON.stringify({ script: helperBody });
const { id, scripts } = await setup({
moduleVariables: {
managed_settings: managedSettings,
api_key_helper: apiKeyHelper,
},
});
await runScripts(id, scripts);
const helperBefore = await readFileContainer(
id,
"/home/coder/.claude/coder-api-key-helper.sh",
);
const dropin = "/etc/claude-code/managed-settings.d";
const unrelated = [
`${dropin}/90-admin.json`,
"/etc/claude-code/managed-settings.json",
"/home/coder/.claude/settings.json",
];
for (const file of unrelated) {
await writeFileContainer(id, file, '{"model":"opus"}\n', {
user: "root",
});
}
if (!sudo) {
const result = await execContainer(
id,
[
"bash",
"-c",
`chown -R coder:coder /etc/claude-code && mv /usr/bin/sudo /usr/bin/sudo.disabled`,
],
["--user", "root"],
);
expect(result.exitCode).toBe(0);
}
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
install_claude_code: "false",
managed_settings:
disabled === "api_key_helper" ? managedSettings : "null",
api_key_helper:
disabled === "managed_settings" ? apiKeyHelper : "null",
});
const updatedScripts = collectScripts(state);
for (let run = 0; run < 2; run++) {
await runScripts(id, updatedScripts);
for (const [file, keep] of [
[`${dropin}/10-coder.json`, disabled === "api_key_helper"],
[
`${dropin}/20-coder-apikeyhelper.json`,
disabled === "managed_settings",
],
[
"/home/coder/.claude/coder-api-key-helper.sh",
disabled === "managed_settings",
],
] as const) {
const result = await execContainer(id, ["test", "-e", file]);
expect(result.exitCode).toBe(keep ? 0 : 1);
}
for (const file of unrelated) {
expect(await readFileContainer(id, file)).toBe(
'{"model":"opus"}\n',
);
}
}
if (disabled === "api_key_helper") {
expect(
JSON.parse(await readFileContainer(id, `${dropin}/10-coder.json`)),
).toEqual({ model: "sonnet" });
} else if (disabled === "managed_settings") {
expect(
await readFileContainer(
id,
"/home/coder/.claude/coder-api-key-helper.sh",
),
).toBe(helperBefore);
}
},
);
}

test.each([
"/etc/claude-code/managed-settings.d/10-coder.json",
"/etc/claude-code/managed-settings.d/20-coder-apikeyhelper.json",
"/home/coder/.claude/coder-api-key-helper.sh",
])("reports cleanup permission failure for %s", async (file) => {
const { id, scripts } = await setup();
const parent = path.posix.dirname(file);
const prepare = await execContainer(
id,
[
"bash",
"-c",
`mkdir -p '${parent}' && printf stale > '${file}' && chmod 0555 '${parent}' && mv /usr/bin/sudo /usr/bin/sudo.disabled`,
],
["--user", "root"],
);
expect(prepare.exitCode).toBe(0);
await expect(runScripts(id, scripts)).rejects.toThrow("script exited");
const log = await readFileContainer(
id,
"/home/coder/.coder-modules/coder/claude-code/logs/install.log",
);
expect(log).toContain("Error: could not remove stale Claude Code");
expect(log).toContain(file);
expect(await readFileContainer(id, file)).toBe("stale");
});

test("reports denied sudo when removing stale managed settings", async () => {
const { id, scripts } = await setup();
const file = "/etc/claude-code/managed-settings.d/10-coder.json";
const prepare = await execContainer(
id,
["mkdir", "-p", path.posix.dirname(file)],
["--user", "root"],
);
expect(prepare.exitCode).toBe(0);
await writeFileContainer(id, file, "stale", { user: "root" });
await writeExecutable({
containerId: id,
filePath: "/usr/bin/sudo",
content: "#!/bin/sh\necho 'sudo: permission denied' >&2\nexit 1\n",
});
await expect(runScripts(id, scripts)).rejects.toThrow("script exited");
const log = await readFileContainer(
id,
"/home/coder/.coder-modules/coder/claude-code/logs/install.log",
);
expect(log).toContain("sudo: permission denied");
expect(log).toContain(
`Error: could not remove stale Claude Code configuration at ${file}`,
);
expect(await readFileContainer(id, file)).toBe("stale");
});

test("removes stale symlinks without following their targets", async () => {
const { id, scripts } = await setup();
const prepare = await execContainer(id, [
"bash",
"-c",
"mkdir -p /home/coder/.claude",
]);
expect(prepare.exitCode).toBe(0);
const dropin = "/etc/claude-code/managed-settings.d";
const links = [
`${dropin}/10-coder.json`,
`${dropin}/20-coder-apikeyhelper.json`,
"/home/coder/.claude/coder-api-key-helper.sh",
];
const seed = await execContainer(
id,
[
"bash",
"-c",
`mkdir -p '${dropin}' && printf admin > '${dropin}/90-admin.json' && ln -s '${dropin}/90-admin.json' '${links[0]}' && ln -s '${dropin}/missing.json' '${links[1]}' && ln -s '${dropin}/90-admin.json' '${links[2]}'`,
],
["--user", "root"],
);
expect(seed.exitCode).toBe(0);
await runScripts(id, scripts);
for (const file of links) {
const result = await execContainer(id, ["test", "-L", file]);
expect(result.exitCode).toBe(1);
}
expect(await readFileContainer(id, `${dropin}/90-admin.json`)).toBe(
"admin",
);
});

test("telemetry-otel", async () => {
Expand Down
Loading
Loading