From 244a56c5893a1f4ecdbb31ad835748cc1f5e5bb4 Mon Sep 17 00:00:00 2001 From: Eddy Marc <132223353+Edd88-pixel@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:36:03 +0100 Subject: [PATCH] fix(claude-code): remove disabled managed configuration --- registry/coder/modules/claude-code/README.md | 32 +-- .../coder/modules/claude-code/main.test.ts | 188 ++++++++++++++++++ .../claude-code/scripts/install.sh.tftpl | 37 +++- 3 files changed, 238 insertions(+), 19 deletions(-) diff --git a/registry/coder/modules/claude-code/README.md b/registry/coder/modules/claude-code/README.md index b0bfe8987..fc8cddf33 100644 --- a/registry/coder/modules/claude-code/README.md +++ b/registry/coder/modules/claude-code/README.md @@ -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" } @@ -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" @@ -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 @@ -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 @@ -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" @@ -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" @@ -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" @@ -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" @@ -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" @@ -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 @@ -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 @@ -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 @@ -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" @@ -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" diff --git a/registry/coder/modules/claude-code/main.test.ts b/registry/coder/modules/claude-code/main.test.ts index 91f05e936..c548b22b9 100644 --- a/registry/coder/modules/claude-code/main.test.ts +++ b/registry/coder/modules/claude-code/main.test.ts @@ -14,6 +14,7 @@ import { runTerraformApply, runTerraformInit, TerraformState, + writeFileContainer, } from "~test"; import { extractCoderEnvVars, writeExecutable } from "../agentapi/test-util"; import path from "path"; @@ -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, [ @@ -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 () => { diff --git a/registry/coder/modules/claude-code/scripts/install.sh.tftpl b/registry/coder/modules/claude-code/scripts/install.sh.tftpl index 5e535e82d..0a16b9a97 100644 --- a/registry/coder/modules/claude-code/scripts/install.sh.tftpl +++ b/registry/coder/modules/claude-code/scripts/install.sh.tftpl @@ -183,14 +183,35 @@ function setup_claude_configurations() { } -function write_managed_settings() { - if [ -z "$${ARG_MANAGED_SETTINGS_JSON}" ]; then +function remove_managed_file() { + local target="$1" + + if [ ! -e "$${target}" ] && [ ! -L "$${target}" ]; then return fi + if command_exists sudo; then + sudo -n rm -f -- "$${target}" || { + echo "Error: could not remove stale Claude Code configuration at $${target}" >&2 + return 1 + } + else + rm -f -- "$${target}" || { + echo "Error: could not remove stale Claude Code configuration at $${target}" >&2 + return 1 + } + fi +} + +function write_managed_settings() { local dropin_dir="/etc/claude-code/managed-settings.d" local target="$${dropin_dir}/10-coder.json" + if [ -z "$${ARG_MANAGED_SETTINGS_JSON}" ]; then + remove_managed_file "$${target}" + return + fi + if ! echo "$${ARG_MANAGED_SETTINGS_JSON}" | jq empty 2> /dev/null; then echo "Warning: managed_settings is not valid JSON, skipping policy write" return @@ -210,19 +231,25 @@ function write_managed_settings() { } function setup_api_key_helper() { + local helper_path="$HOME/.claude/coder-api-key-helper.sh" + local dropin_dir="/etc/claude-code/managed-settings.d" + local target="$${dropin_dir}/20-coder-apikeyhelper.json" + if [ -z "$${ARG_API_KEY_HELPER_SCRIPT}" ]; then + remove_managed_file "$${target}" + if ! rm -f -- "$${helper_path}"; then + echo "Error: could not remove stale Claude Code API key helper at $${helper_path}" >&2 + return 1 + fi return fi echo "Configuring api_key_helper for short-lived credentials..." mkdir -p "$HOME/.claude" - local helper_path="$HOME/.claude/coder-api-key-helper.sh" printf '%s' "$${ARG_API_KEY_HELPER_SCRIPT}" > "$${helper_path}" chmod 0700 "$${helper_path}" - local dropin_dir="/etc/claude-code/managed-settings.d" - local target="$${dropin_dir}/20-coder-apikeyhelper.json" if command_exists sudo; then sudo mkdir -p "$${dropin_dir}" printf '{"apiKeyHelper":"%s"}\n' "$${helper_path}" | sudo tee "$${target}" > /dev/null