From 3467dea5b412bce9bb944e2e08098e8fd42d84a5 Mon Sep 17 00:00:00 2001 From: Atif Ali Date: Wed, 22 Apr 2026 05:38:02 +0000 Subject: [PATCH 01/23] refactor(claude-code): slim to install-and-configure only Strip the claude-code module down to what its name says. Removes the AgentAPI child module, Coder Tasks orchestration, process-level network boundary, web and CLI coder_apps, the task_app_id output, and every variable that only existed to support those paths. The module now does three things: install Claude Code via the official installer, wire up authentication env vars, and optionally apply user-scope MCP server configuration. Script orchestration is delegated to coder-utils (v1.1.0) so pre_install/install/post_install hooks have a single, consistent layout. Breaking changes: - Rename claude_api_key -> anthropic_api_key. Now emits ANTHROPIC_API_KEY (the variable Claude Code actually reads), not CLAUDE_API_KEY. - AI Bridge now sets ANTHROPIC_AUTH_TOKEN + ANTHROPIC_BASE_URL, matching the dogfood template. - Remove workdir; MCP applies at user scope. - Remove install_via_npm; official installer only. - Remove allowed_tools, disallowed_tools; write ~/.claude/settings.json permission rules via pre_install_script instead. - Remove task_app_id output and every Tasks/AgentAPI/Boundary variable. coder-utils is pinned to the PR #842 branch via a git source until the v1.1.0 tag ships on the registry. --- registry/coder/modules/claude-code/README.md | 398 ++++--------- .../coder/modules/claude-code/main.test.ts | 536 ++++++------------ registry/coder/modules/claude-code/main.tf | 401 +++---------- .../coder/modules/claude-code/main.tftest.hcl | 407 ++++--------- .../modules/claude-code/scripts/install.sh | 225 ++------ .../modules/claude-code/scripts/start.sh | 256 --------- .../claude-code/testdata/claude-mock.sh | 34 +- .../claude-code/testdata/coder-mock.sh | 15 + 8 files changed, 549 insertions(+), 1723 deletions(-) delete mode 100644 registry/coder/modules/claude-code/scripts/start.sh create mode 100644 registry/coder/modules/claude-code/testdata/coder-mock.sh diff --git a/registry/coder/modules/claude-code/README.md b/registry/coder/modules/claude-code/README.md index 48b291bb0..00e56dfe4 100644 --- a/registry/coder/modules/claude-code/README.md +++ b/registry/coder/modules/claude-code/README.md @@ -1,230 +1,152 @@ --- display_name: Claude Code -description: Run the Claude Code agent in your workspace. +description: Install and configure the Claude Code CLI in your workspace. icon: ../../../../.icons/claude.svg verified: true -tags: [agent, claude-code, ai, tasks, anthropic, aibridge] +tags: [agent, claude-code, ai, anthropic, aibridge] --- # Claude Code -Run the [Claude Code](https://docs.anthropic.com/en/docs/agents-and-tools/claude-code/overview) agent in your workspace to generate code and perform tasks. This module integrates with [AgentAPI](https://github.com/coder/agentapi) for task reporting in the Coder UI. +Install and configure the [Claude Code](https://docs.anthropic.com/en/docs/claude-code/overview) CLI in your workspace. -```tf -module "claude-code" { - source = "registry.coder.com/coder/claude-code/coder" - version = "4.9.2" - agent_id = coder_agent.main.id - workdir = "/home/coder/project" - claude_api_key = "xxxx-xxxxx-xxxx" -} -``` - -> [!WARNING] -> **Security Notice**: This module uses the `--dangerously-skip-permissions` flag when running Claude Code tasks. This flag bypasses standard permission checks and allows Claude Code broader access to your system than normally permitted. While this enables more functionality, it also means Claude Code can potentially execute commands with the same privileges as the user running it. Use this module _only_ in trusted environments and be aware of the security implications. - -> [!NOTE] -> By default, this module is configured to run the embedded chat interface as a path-based application. In production, we recommend that you configure a [wildcard access URL](https://coder.com/docs/admin/setup#wildcard-access-url) and set `subdomain = true`. See [here](https://coder.com/docs/tutorials/best-practices/security-best-practices#disable-path-based-apps) for more details. - -## Prerequisites - -- An **Anthropic API key** or a _Claude Session Token_ is required for tasks. - - You can get the API key from the [Anthropic Console](https://console.anthropic.com/dashboard). - - You can get the Session Token using the `claude setup-token` command. This is a long-lived authentication token (requires Claude subscription) +This module does three things: -### Session Resumption Behavior +1. Installs Claude Code via the [official installer](https://claude.ai/install.sh). +2. Wires up authentication through environment variables. +3. Optionally applies user-scope MCP server configuration. -By default, Claude Code automatically resumes existing conversations when your workspace restarts. Sessions are tracked per workspace directory, so conversations continue where you left off. If no session exists (first start), your `ai_prompt` will run normally. To disable this behavior and always start fresh, set `continue = false` - -## State Persistence - -AgentAPI can save and restore its conversation state to disk across workspace restarts. This complements `continue` (which resumes the Claude CLI session) by also preserving the AgentAPI-level context. Enabled by default, requires agentapi >= v0.12.0 (older versions skip it with a warning). - -To disable: +It does not start Claude, create a web app, or orchestrate Tasks. For those, see the dedicated `claude-code-tasks`, `agentapi`, and `boundary` modules. ```tf module "claude-code" { - # ... other config - enable_state_persistence = false + source = "registry.coder.com/coder/claude-code/coder" + version = "5.0.0" + agent_id = coder_agent.main.id + anthropic_api_key = var.anthropic_api_key } ``` -## Examples - -### Usage with Agent Boundaries +## Authentication -This example shows how to configure the Claude Code module to run the agent behind a process-level boundary that restricts its network access. +Choose one of: -By default, when `enable_boundary = true`, the module uses `coder boundary` subcommand (provided by Coder) without requiring any installation. +- `anthropic_api_key`: Anthropic API key. Sets `ANTHROPIC_API_KEY`. +- `claude_code_oauth_token`: Long-lived Claude.ai subscription token (generate with `claude setup-token`). Sets `CLAUDE_CODE_OAUTH_TOKEN`. +- `enable_aibridge = true`: Routes through Coder [AI Bridge](https://coder.com/docs/ai-coder/ai-bridge). Sets `ANTHROPIC_AUTH_TOKEN` (workspace owner session token) and `ANTHROPIC_BASE_URL`. Cannot combine with an API key or OAuth token. ```tf +# Claude.ai subscription module "claude-code" { - source = "registry.coder.com/coder/claude-code/coder" - version = "4.9.2" - agent_id = coder_agent.main.id - workdir = "/home/coder/project" - enable_boundary = true + source = "registry.coder.com/coder/claude-code/coder" + version = "5.0.0" + agent_id = coder_agent.main.id + claude_code_oauth_token = var.claude_code_oauth_token } -``` - -> [!NOTE] -> For developers: The module also supports installing boundary from a release version (`use_boundary_directly = true`) or compiling from source (`compile_boundary_from_source = true`). These are escape hatches for development and testing purposes. - -### Usage with AI Bridge - -[AI Bridge](https://coder.com/docs/ai-coder/ai-bridge) is a Premium Coder feature that provides centralized LLM proxy management. To use AI Bridge, set `enable_aibridge = true`. Requires Coder version >= 2.29.0. - -For tasks integration with AI Bridge, add `enable_aibridge = true` to the [Usage with Tasks](#usage-with-tasks) example below. -#### Standalone usage with AI Bridge - -```tf +# AI Bridge (Premium, requires Coder >= 2.29.0) module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "4.9.2" + version = "5.0.0" agent_id = coder_agent.main.id - workdir = "/home/coder/project" enable_aibridge = true } ``` -When `enable_aibridge = true`, the module automatically sets: - -- `ANTHROPIC_BASE_URL` to `${data.coder_workspace.me.access_url}/api/v2/aibridge/anthropic` -- `CLAUDE_API_KEY` to the workspace owner's session token - -This allows Claude Code to route API requests through Coder's AI Bridge instead of directly to Anthropic's API. -Template build will fail if either `claude_api_key` or `claude_code_oauth_token` is provided alongside `enable_aibridge = true`. +## MCP configuration -### Usage with Tasks +MCP servers are applied at **user scope** via `claude mcp add-json --scope user`. They end up in `~/.claude.json` and apply across every project the user opens. -This example shows how to configure Claude Code with Coder tasks. +### Inline ```tf -resource "coder_ai_task" "task" { - count = data.coder_workspace.me.start_count - app_id = module.claude-code.task_app_id -} - -data "coder_task" "me" {} - module "claude-code" { - source = "registry.coder.com/coder/claude-code/coder" - version = "4.9.2" - agent_id = coder_agent.main.id - workdir = "/home/coder/project" - ai_prompt = data.coder_task.me.prompt - - # Optional: route through AI Bridge (Premium feature) - # enable_aibridge = true + source = "registry.coder.com/coder/claude-code/coder" + version = "5.0.0" + agent_id = coder_agent.main.id + anthropic_api_key = var.anthropic_api_key + + mcp = jsonencode({ + mcpServers = { + github = { + command = "npx" + args = ["-y", "@modelcontextprotocol/server-github"] + } + } + }) } ``` -### Advanced Configuration - -This example shows additional configuration options for version pinning, custom models, and MCP servers. - -> [!NOTE] -> The `claude_binary_path` variable can be used to specify where a pre-installed Claude binary is located. +### From remote URLs -> [!WARNING] -> **Deprecation Notice**: The npm installation method (`install_via_npm = true`) will be deprecated and removed in the next major release. Please use the default binary installation method instead. +Each URL must return JSON in the shape `{"mcpServers": {...}}`. `Content-Type` is not enforced; `text/plain` and `application/json` both work. ```tf module "claude-code" { - source = "registry.coder.com/coder/claude-code/coder" - version = "4.9.2" - agent_id = coder_agent.main.id - workdir = "/home/coder/project" - - claude_api_key = "xxxx-xxxxx-xxxx" - # OR - claude_code_oauth_token = "xxxxx-xxxx-xxxx" - - claude_code_version = "2.0.62" # Pin to a specific version - claude_binary_path = "/opt/claude/bin" # Path to pre-installed Claude binary - agentapi_version = "0.11.4" - - model = "sonnet" - permission_mode = "plan" - - mcp = <<-EOF - { - "mcpServers": { - "my-custom-tool": { - "command": "my-tool-server", - "args": ["--port", "8080"] - } - } - } - EOF + source = "registry.coder.com/coder/claude-code/coder" + version = "5.0.0" + agent_id = coder_agent.main.id + anthropic_api_key = var.anthropic_api_key mcp_config_remote_path = [ - "https://gist.githubusercontent.com/35C4n0r/cd8dce70360e5d22a070ae21893caed4/raw/", - "https://raw.githubusercontent.com/coder/coder/main/.mcp.json" + "https://raw.githubusercontent.com/coder/coder/main/.mcp.json", ] } ``` -> [!NOTE] -> Remote URLs should return a JSON body in the following format: -> -> ```json -> { -> "mcpServers": { -> "server-name": { -> "command": "some-command", -> "args": ["arg1", "arg2"] -> } -> } -> } -> ``` -> -> The `Content-Type` header doesn't matter—both `text/plain` and `application/json` work fine. - -### Standalone Mode - -Run and configure Claude Code as a standalone CLI in your workspace. +## Pinning a version ```tf module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "4.9.2" + version = "5.0.0" agent_id = coder_agent.main.id - workdir = "/home/coder/project" - install_claude_code = true claude_code_version = "2.0.62" - report_tasks = false } ``` -### Usage with Claude Code Subscription +## Using a pre-installed binary -```tf - -variable "claude_code_oauth_token" { - type = string - description = "Generate one using `claude setup-token` command" - sensitive = true - value = "xxxx-xxx-xxxx" -} +Set `install_claude_code = false` and point `claude_binary_path` at the directory containing the binary. +```tf module "claude-code" { - source = "registry.coder.com/coder/claude-code/coder" - version = "4.9.2" - agent_id = coder_agent.main.id - workdir = "/home/coder/project" - claude_code_oauth_token = var.claude_code_oauth_token + source = "registry.coder.com/coder/claude-code/coder" + version = "5.0.0" + agent_id = coder_agent.main.id + install_claude_code = false + claude_binary_path = "/opt/claude/bin" } ``` -### Usage with AWS Bedrock +## Extending with pre/post install scripts + +Use `pre_install_script` and `post_install_script` for custom setup (e.g. writing `~/.claude/settings.json` permission rules, installing cloud SDKs, pulling secrets). -#### Prerequisites +```tf +module "claude-code" { + source = "registry.coder.com/coder/claude-code/coder" + version = "5.0.0" + agent_id = coder_agent.main.id + anthropic_api_key = var.anthropic_api_key + + pre_install_script = <<-EOT + #!/bin/bash + mkdir -p "$HOME/.claude" + cat > "$HOME/.claude/settings.json" <<'JSON' + { + "permissions": { + "deny": ["Read(./.env)", "Read(./secrets/**)"] + } + } + JSON + EOT +} +``` -AWS account with Bedrock access, Claude models enabled in Bedrock console, and appropriate IAM permissions. +## Using AWS Bedrock or Google Vertex -Configure Claude Code to use AWS Bedrock for accessing Claude models through your AWS infrastructure. +The module does not own Bedrock/Vertex env vars; set them yourself with `coder_env` resources. ```tf resource "coder_env" "bedrock_use" { @@ -236,47 +158,10 @@ resource "coder_env" "bedrock_use" { resource "coder_env" "aws_region" { agent_id = coder_agent.main.id name = "AWS_REGION" - value = "us-east-1" # Choose your preferred region -} - -# Option 1: Using AWS credentials - -variable "aws_access_key_id" { - type = string - description = "Your AWS access key ID. Create this in the AWS IAM console under 'Security credentials'." - sensitive = true - value = "xxxx-xxx-xxxx" -} - -variable "aws_secret_access_key" { - type = string - description = "Your AWS secret access key. This is shown once when you create an access key in the AWS IAM console." - sensitive = true - value = "xxxx-xxx-xxxx" + value = "us-east-1" } -resource "coder_env" "aws_access_key_id" { - agent_id = coder_agent.main.id - name = "AWS_ACCESS_KEY_ID" - value = var.aws_access_key_id -} - -resource "coder_env" "aws_secret_access_key" { - agent_id = coder_agent.main.id - name = "AWS_SECRET_ACCESS_KEY" - value = var.aws_secret_access_key -} - -# Option 2: Using Bedrock API key (simpler) - -variable "aws_bearer_token_bedrock" { - type = string - description = "Your AWS Bedrock bearer token. This provides access to Bedrock without needing separate access key and secret key." - sensitive = true - value = "xxxx-xxx-xxxx" -} - -resource "coder_env" "bedrock_api_key" { +resource "coder_env" "aws_bearer_token_bedrock" { agent_id = coder_agent.main.id name = "AWS_BEARER_TOKEN_BEDROCK" value = var.aws_bearer_token_bedrock @@ -284,117 +169,32 @@ resource "coder_env" "bedrock_api_key" { module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "4.9.2" + version = "5.0.0" agent_id = coder_agent.main.id - workdir = "/home/coder/project" model = "global.anthropic.claude-sonnet-4-5-20250929-v1:0" } ``` -> [!NOTE] -> For additional Bedrock configuration options (model selection, token limits, region overrides, etc.), see the [Claude Code Bedrock documentation](https://docs.claude.com/en/docs/claude-code/amazon-bedrock). - -### Usage with Google Vertex AI - -#### Prerequisites - -GCP project with Vertex AI API enabled, Claude models enabled through Model Garden, service account with Vertex AI permissions, and appropriate IAM permissions (Vertex AI User role). - -Configure Claude Code to use Google Vertex AI for accessing Claude models through Google Cloud Platform. - -```tf -variable "vertex_sa_json" { - type = string - description = "The complete JSON content of your Google Cloud service account key file. Create a service account in the GCP Console under 'IAM & Admin > Service Accounts', then create and download a JSON key. Copy the entire JSON content into this variable." - sensitive = true -} - -resource "coder_env" "vertex_use" { - agent_id = coder_agent.main.id - name = "CLAUDE_CODE_USE_VERTEX" - value = "1" -} - -resource "coder_env" "vertex_project_id" { - agent_id = coder_agent.main.id - name = "ANTHROPIC_VERTEX_PROJECT_ID" - value = "your-gcp-project-id" -} - -resource "coder_env" "cloud_ml_region" { - agent_id = coder_agent.main.id - name = "CLOUD_ML_REGION" - value = "global" -} - -resource "coder_env" "vertex_sa_json" { - agent_id = coder_agent.main.id - name = "VERTEX_SA_JSON" - value = var.vertex_sa_json -} - -resource "coder_env" "google_application_credentials" { - agent_id = coder_agent.main.id - name = "GOOGLE_APPLICATION_CREDENTIALS" - value = "/tmp/gcp-sa.json" -} - -module "claude-code" { - source = "registry.coder.com/coder/claude-code/coder" - version = "4.9.2" - agent_id = coder_agent.main.id - workdir = "/home/coder/project" - model = "claude-sonnet-4@20250514" - - pre_install_script = <<-EOT - #!/bin/bash - # Write the service account JSON to a file - echo "$VERTEX_SA_JSON" > /tmp/gcp-sa.json - - # Install prerequisite packages - sudo apt-get update - sudo apt-get install -y apt-transport-https ca-certificates gnupg curl - - # Add Google Cloud public key - curl https://packages.cloud.google.com/apt/doc/apt-key.gpg | sudo gpg --dearmor -o /usr/share/keyrings/cloud.google.gpg - - # Add Google Cloud SDK repo to apt sources - echo "deb [signed-by=/usr/share/keyrings/cloud.google.gpg] https://packages.cloud.google.com/apt cloud-sdk main" | sudo tee /etc/apt/sources.list.d/google-cloud-sdk.list - - # Update and install the Google Cloud SDK - sudo apt-get update && sudo apt-get install -y google-cloud-cli - - # Authenticate gcloud with the service account - gcloud auth activate-service-account --key-file=/tmp/gcp-sa.json - EOT -} -``` - -> [!NOTE] -> For additional Vertex AI configuration options (model selection, token limits, region overrides, etc.), see the [Claude Code Vertex AI documentation](https://docs.claude.com/en/docs/claude-code/google-vertex-ai). +See the [Bedrock](https://docs.claude.com/en/docs/claude-code/amazon-bedrock) and [Vertex AI](https://docs.claude.com/en/docs/claude-code/google-vertex-ai) pages for additional env var options. ## Troubleshooting -If you encounter any issues, check the log files in the `~/.claude-module` directory within your workspace for detailed information. +Module logs live at `$HOME/.claude-module/`: ```bash -# Installation logs -cat ~/.claude-module/install.log - -# Startup logs -cat ~/.claude-module/agentapi-start.log - -# Pre/post install script logs -cat ~/.claude-module/pre_install.log -cat ~/.claude-module/post_install.log +cat $HOME/.claude-module/install.log +cat $HOME/.claude-module/pre_install.log +cat $HOME/.claude-module/post_install.log ``` -> [!NOTE] -> To use tasks with Claude Code, you must provide an `anthropic_api_key` or `claude_code_oauth_token`. -> The `workdir` variable is required and specifies the directory where Claude Code will run. +## Upgrading from v4.x -## References +Breaking changes in v5.0.0: -- [Claude Code Documentation](https://docs.anthropic.com/en/docs/agents-and-tools/claude-code/overview) -- [AgentAPI Documentation](https://github.com/coder/agentapi) -- [Coder AI Agents Guide](https://coder.com/docs/tutorials/ai-agents) +- `claude_api_key` renamed to `anthropic_api_key` and emits `ANTHROPIC_API_KEY` (not `CLAUDE_API_KEY`). This matches Claude Code's documented variable. +- All Tasks, AgentAPI, Boundary, and web-app variables removed. See the dedicated modules. +- `workdir` removed. MCP applies at user scope. Project-specific config belongs in the repo. +- `install_via_npm` removed. Official installer only. +- `allowed_tools` / `disallowed_tools` removed. Write `~/.claude/settings.json` via `pre_install_script` with `permissions.allow` / `permissions.deny` arrays. +- `task_app_id` output removed. Read it from the Tasks module. +- AI Bridge now uses `ANTHROPIC_AUTH_TOKEN` instead of `CLAUDE_API_KEY`. diff --git a/registry/coder/modules/claude-code/main.test.ts b/registry/coder/modules/claude-code/main.test.ts index b01e88327..383bfb110 100644 --- a/registry/coder/modules/claude-code/main.test.ts +++ b/registry/coder/modules/claude-code/main.test.ts @@ -6,15 +6,20 @@ import { beforeAll, expect, } from "bun:test"; -import { execContainer, readFileContainer, runTerraformInit } from "~test"; +import { + execContainer, + readFileContainer, + runTerraformApply, + runTerraformInit, + runContainer, + removeContainer, + type TerraformState, +} from "~test"; import { loadTestFile, writeExecutable, - setup as setupUtil, - execModuleScript, - expectAgentAPIStarted, + extractCoderEnvVars, } from "../agentapi/test-util"; -import dedent from "dedent"; let cleanupFunctions: (() => Promise)[] = []; const registerCleanup = (cleanup: () => Promise) => { @@ -33,29 +38,71 @@ afterEach(async () => { }); interface SetupProps { - skipAgentAPIMock?: boolean; skipClaudeMock?: boolean; moduleVariables?: Record; - agentapiMockScript?: string; } +// Order scripts in the same sequence coder-utils enforces at runtime via +// `coder exp sync`: pre_install -> install -> post_install. +const SCRIPT_ORDER = [ + "Pre-Install Script", + "Install Script", + "Post-Install Script", +]; + +const collectScripts = (state: TerraformState): string[] => { + const scripts: { displayName: string; script: string }[] = []; + for (const resource of state.resources) { + if (resource.type !== "coder_script") continue; + for (const instance of resource.instances) { + const attrs = instance.attributes as Record; + scripts.push({ + displayName: String(attrs.display_name ?? ""), + script: String(attrs.script ?? ""), + }); + } + } + scripts.sort( + (a, b) => + SCRIPT_ORDER.indexOf(a.displayName) - SCRIPT_ORDER.indexOf(b.displayName), + ); + return scripts.map((s) => s.script); +}; + const setup = async ( props?: SetupProps, ): Promise<{ id: string; coderEnvVars: Record }> => { - const projectDir = "/home/coder/project"; - const { id, coderEnvVars } = await setupUtil({ - moduleDir: import.meta.dir, - moduleVariables: { - install_claude_code: props?.skipClaudeMock ? "true" : "false", - install_agentapi: props?.skipAgentAPIMock ? "true" : "false", - workdir: projectDir, - ...props?.moduleVariables, - }, - registerCleanup, - projectDir, - skipAgentAPIMock: props?.skipAgentAPIMock, - agentapiMockScript: props?.agentapiMockScript, + const moduleVariables: Record = { + agent_id: "foo", + install_claude_code: props?.skipClaudeMock ? "true" : "false", + ...props?.moduleVariables, + }; + const state = await runTerraformApply(import.meta.dir, moduleVariables); + const scripts = collectScripts(state); + const coderEnvVars = extractCoderEnvVars(state); + + const id = await runContainer("codercom/enterprise-node:latest"); + registerCleanup(async () => { + if ( + process.env["DEBUG"] === "true" || + process.env["DEBUG"] === "1" || + process.env["DEBUG"] === "yes" + ) { + console.log(`Not removing container ${id} in debug mode`); + console.log(`Run "docker rm -f ${id}" to remove it manually.`); + } else { + await removeContainer(id); + } + }); + + // `coder-utils` wraps each script with `coder exp sync` calls. Install a + // no-op mock so the script runs in the minimal test container. + await writeExecutable({ + containerId: id, + filePath: "/usr/bin/coder", + content: await loadTestFile(import.meta.dir, "coder-mock.sh"), }); + if (!props?.skipClaudeMock) { await writeExecutable({ containerId: id, @@ -63,9 +110,39 @@ const setup = async ( content: await loadTestFile(import.meta.dir, "claude-mock.sh"), }); } + + // Concatenate scripts in dependency order into a single driver. Each script + // runs in its own subshell so that `set -e` and `exit` stay contained. + const driver = scripts + .map((s, i) => `(\n# --- script ${i} ---\n${s}\n)`) + .join("\n"); + await writeExecutable({ + containerId: id, + filePath: "/home/coder/script.sh", + content: driver, + }); + return { id, coderEnvVars }; }; +const runModuleScripts = async (id: string, env?: Record) => { + const envArgs = env + ? Object.entries(env) + .map(([key, value]) => `export ${key}="${value.replace(/"/g, '\\"')}"`) + .join(" && ") + " && " + : ""; + const resp = await execContainer(id, [ + "bash", + "-c", + `${envArgs}set -o errexit; set -o pipefail; cd /home/coder && ./script.sh 2>&1 | tee /home/coder/script.log`, + ]); + if (resp.exitCode !== 0) { + console.log(resp.stdout); + console.log(resp.stderr); + } + return resp; +}; + setDefaultTimeout(60 * 1000); describe("claude-code", async () => { @@ -75,8 +152,12 @@ describe("claude-code", async () => { test("happy-path", async () => { const { id } = await setup(); - await execModuleScript(id); - await expectAgentAPIStarted(id); + await runModuleScripts(id); + const installLog = await readFileContainer( + id, + "/home/coder/.claude-module/install.log", + ); + expect(installLog).toContain("ARG_INSTALL_CLAUDE_CODE"); }); test("install-claude-code-version", async () => { @@ -88,7 +169,7 @@ describe("claude-code", async () => { claude_code_version: version_to_install, }, }); - await execModuleScript(id, coderEnvVars); + await runModuleScripts(id, coderEnvVars); const resp = await execContainer(id, [ "bash", "-c", @@ -97,107 +178,59 @@ describe("claude-code", async () => { expect(resp.stdout).toContain(version_to_install); }); - test("check-latest-claude-code-version-works", async () => { + test("install-claude-code-latest", async () => { const { id, coderEnvVars } = await setup({ skipClaudeMock: true, - skipAgentAPIMock: true, moduleVariables: { install_claude_code: "true", }, }); - await execModuleScript(id, coderEnvVars); - await expectAgentAPIStarted(id); - }); - - test("claude-api-key", async () => { - const apiKey = "test-api-key-123"; - const { id } = await setup({ - moduleVariables: { - claude_api_key: apiKey, - }, - }); - await execModuleScript(id); - - const envCheck = await execContainer(id, [ + await runModuleScripts(id, coderEnvVars); + const resp = await execContainer(id, [ "bash", "-c", - 'env | grep CLAUDE_API_KEY || echo "CLAUDE_API_KEY not found"', + 'export PATH="$HOME/.local/bin:$PATH" && claude --version', ]); - expect(envCheck.stdout).toContain("CLAUDE_API_KEY"); + expect(resp.exitCode).toBe(0); + expect(resp.stdout).toMatch(/\d+\.\d+\.\d+/); }); - test("claude-mcp-config", async () => { - const mcpConfig = JSON.stringify({ - mcpServers: { - test: { - command: "test-cmd", - type: "stdio", - }, - }, - }); + test("anthropic-api-key", async () => { + const apiKey = "sk-test-api-key-123"; const { id, coderEnvVars } = await setup({ - skipClaudeMock: true, moduleVariables: { - mcp: mcpConfig, + anthropic_api_key: apiKey, }, }); - await execModuleScript(id, coderEnvVars); - - const resp = await readFileContainer(id, "/home/coder/.claude.json"); - expect(resp).toContain("test-cmd"); + expect(coderEnvVars["ANTHROPIC_API_KEY"]).toBe(apiKey); + expect(coderEnvVars["CLAUDE_API_KEY"]).toBeUndefined(); + await runModuleScripts(id); }); - test("claude-task-prompt", async () => { - const prompt = "This is a task prompt for Claude."; - const { id } = await setup({ - moduleVariables: { - ai_prompt: prompt, - }, - }); - await execModuleScript(id); - - const resp = await execContainer(id, [ - "bash", - "-c", - "cat /home/coder/.claude-module/agentapi-start.log", - ]); - expect(resp.stdout).toContain(prompt); - }); - - test("claude-permission-mode", async () => { - const mode = "plan"; - const { id } = await setup({ + test("claude-oauth-token", async () => { + const token = "oauth-live-token"; + const { coderEnvVars } = await setup({ moduleVariables: { - permission_mode: mode, - ai_prompt: "test prompt", + claude_code_oauth_token: token, }, }); - await execModuleScript(id); - - const startLog = await execContainer(id, [ - "bash", - "-c", - "cat /home/coder/.claude-module/agentapi-start.log", - ]); - expect(startLog.stdout).toContain(`--permission-mode ${mode}`); + expect(coderEnvVars["CLAUDE_CODE_OAUTH_TOKEN"]).toBe(token); }); - test("claude-auto-permission-mode", async () => { - const mode = "auto"; - const { id } = await setup({ + test("aibridge-env-vars", async () => { + // In the test env data.coder_workspace_owner.me.session_token is empty, + // so ANTHROPIC_AUTH_TOKEN is emitted with an empty value (filtered out by + // extractCoderEnvVars). Verify ANTHROPIC_BASE_URL and confirm + // ANTHROPIC_API_KEY is absent. + const { coderEnvVars } = await setup({ moduleVariables: { - permission_mode: mode, - ai_prompt: "test prompt", + enable_aibridge: "true", }, }); - await execModuleScript(id); - - const startLog = await execContainer(id, [ - "bash", - "-c", - "cat /home/coder/.claude-module/agentapi-start.log", - ]); - expect(startLog.stdout).toContain(`--permission-mode ${mode}`); + expect(coderEnvVars["ANTHROPIC_BASE_URL"]).toContain( + "/api/v2/aibridge/anthropic", + ); + expect(coderEnvVars["ANTHROPIC_API_KEY"]).toBeUndefined(); }); test("claude-model", async () => { @@ -205,282 +238,36 @@ describe("claude-code", async () => { const { coderEnvVars } = await setup({ moduleVariables: { model: model, - ai_prompt: "test prompt", }, }); - - // Verify ANTHROPIC_MODEL env var is set via coder_env expect(coderEnvVars["ANTHROPIC_MODEL"]).toBe(model); }); - test("claude-continue-resume-task-session", async () => { - const { id } = await setup({ - moduleVariables: { - continue: "true", - report_tasks: "true", - ai_prompt: "test prompt", - }, - }); - - // Create a mock task session file with the hardcoded task session ID - // Note: Claude CLI creates files without "session-" prefix when using --session-id - const taskSessionId = "cd32e253-ca16-4fd3-9825-d837e74ae3c2"; - const sessionDir = `/home/coder/.claude/projects/-home-coder-project`; - await execContainer(id, ["mkdir", "-p", sessionDir]); - await execContainer(id, [ - "bash", - "-c", - `cat > ${sessionDir}/${taskSessionId}.jsonl << 'SESSIONEOF' -{"sessionId":"${taskSessionId}","message":{"content":"Task"},"timestamp":"2020-01-01T10:00:00.000Z"} -{"type":"assistant","message":{"content":"Response"},"timestamp":"2020-01-01T10:00:05.000Z"} -SESSIONEOF`, - ]); - - await execModuleScript(id); - - const startLog = await execContainer(id, [ - "bash", - "-c", - "cat /home/coder/.claude-module/agentapi-start.log", - ]); - expect(startLog.stdout).toContain("--resume"); - expect(startLog.stdout).toContain(taskSessionId); - expect(startLog.stdout).toContain("Resuming task session"); - expect(startLog.stdout).toContain("--dangerously-skip-permissions"); - }); - - test("pre-post-install-scripts", async () => { - const { id } = await setup({ - moduleVariables: { - pre_install_script: "#!/bin/bash\necho 'claude-pre-install-script'", - post_install_script: "#!/bin/bash\necho 'claude-post-install-script'", - }, - }); - await execModuleScript(id); - - const preInstallLog = await readFileContainer( - id, - "/home/coder/.claude-module/pre_install.log", - ); - expect(preInstallLog).toContain("claude-pre-install-script"); - - const postInstallLog = await readFileContainer( - id, - "/home/coder/.claude-module/post_install.log", - ); - expect(postInstallLog).toContain("claude-post-install-script"); - }); - - test("workdir-variable", async () => { - const workdir = "/home/coder/claude-test-folder"; - const { id } = await setup({ - skipClaudeMock: false, - moduleVariables: { - workdir, + test("claude-mcp-inline-user-scope", async () => { + const mcpConfig = JSON.stringify({ + mcpServers: { + "test-server": { + command: "test-cmd", + args: ["--config", "test.json"], + }, }, }); - await execModuleScript(id); - - const resp = await readFileContainer( - id, - "/home/coder/.claude-module/agentapi-start.log", - ); - expect(resp).toContain(workdir); - }); - - test("coder-mcp-config-created", async () => { const { id } = await setup({ moduleVariables: { - install_claude_code: "false", + mcp: mcpConfig, }, }); - await execModuleScript(id); + await runModuleScripts(id); const installLog = await readFileContainer( id, "/home/coder/.claude-module/install.log", ); - expect(installLog).toContain( - "Configuring Claude Code to report tasks via Coder MCP", - ); - }); - - test("dangerously-skip-permissions", async () => { - const { id } = await setup({ - moduleVariables: { - dangerously_skip_permissions: "true", - }, - }); - await execModuleScript(id); - - const startLog = await execContainer(id, [ - "bash", - "-c", - "cat /home/coder/.claude-module/agentapi-start.log", - ]); - expect(startLog.stdout).toContain(`--dangerously-skip-permissions`); - }); - - test("subdomain-false", async () => { - const { id } = await setup({ - skipAgentAPIMock: true, - moduleVariables: { - subdomain: "false", - post_install_script: dedent` - #!/bin/bash - env | grep AGENTAPI_CHAT_BASE_PATH || echo "AGENTAPI_CHAT_BASE_PATH not found" - `, - }, - }); - - await execModuleScript(id); - const startLog = await execContainer(id, [ - "bash", - "-c", - "cat /home/coder/.claude-module/post_install.log", - ]); - expect(startLog.stdout).toContain( - "ARG_AGENTAPI_CHAT_BASE_PATH=/@default/default.foo/apps/ccw/chat", - ); + expect(installLog).toContain("claude mcp add-json --scope user"); + expect(installLog).toContain("test-server"); }); - test("partial-initialization-detection", async () => { - const { id } = await setup({ - moduleVariables: { - continue: "true", - report_tasks: "true", - ai_prompt: "test prompt", - }, - }); - - const taskSessionId = "cd32e253-ca16-4fd3-9825-d837e74ae3c2"; - const sessionDir = `/home/coder/.claude/projects/-home-coder-project`; - await execContainer(id, ["mkdir", "-p", sessionDir]); - - await execContainer(id, [ - "bash", - "-c", - `echo '{"sessionId":"${taskSessionId}"}' > ${sessionDir}/${taskSessionId}.jsonl`, - ]); - - await execModuleScript(id); - - const startLog = await execContainer(id, [ - "bash", - "-c", - "cat /home/coder/.claude-module/agentapi-start.log", - ]); - - // Should start new session, not try to resume invalid one - expect(startLog.stdout).toContain("Starting new task session"); - expect(startLog.stdout).toContain("--session-id"); - }); - - test("standalone-first-build-no-sessions", async () => { - const { id } = await setup({ - moduleVariables: { - continue: "true", - report_tasks: "false", - }, - }); - - await execModuleScript(id); - - const startLog = await execContainer(id, [ - "bash", - "-c", - "cat /home/coder/.claude-module/agentapi-start.log", - ]); - - // Should start fresh, not try to continue - expect(startLog.stdout).toContain("No sessions found"); - expect(startLog.stdout).toContain("starting fresh standalone session"); - expect(startLog.stdout).not.toContain("--continue"); - }); - - test("standalone-with-sessions-continues", async () => { - const { id } = await setup({ - moduleVariables: { - continue: "true", - report_tasks: "false", - }, - }); - - const sessionDir = `/home/coder/.claude/projects/-home-coder-project`; - await execContainer(id, ["mkdir", "-p", sessionDir]); - await execContainer(id, [ - "bash", - "-c", - `cat > ${sessionDir}/generic-123.jsonl << 'EOF' -{"sessionId":"generic-123","message":{"content":"User session"},"timestamp":"2020-01-01T10:00:00.000Z"} -{"type":"assistant","message":{"content":"Response"},"timestamp":"2020-01-01T10:00:05.000Z"} -EOF`, - ]); - - await execModuleScript(id); - - const startLog = await execContainer(id, [ - "bash", - "-c", - "cat /home/coder/.claude-module/agentapi-start.log", - ]); - - // Should continue existing session - expect(startLog.stdout).toContain("Sessions found"); - expect(startLog.stdout).toContain( - "Continuing most recent standalone session", - ); - expect(startLog.stdout).toContain("--continue"); - }); - - test("task-mode-ignores-manual-sessions", async () => { - const { id } = await setup({ - moduleVariables: { - continue: "true", - report_tasks: "true", - ai_prompt: "test prompt", - }, - }); - - const taskSessionId = "cd32e253-ca16-4fd3-9825-d837e74ae3c2"; - const sessionDir = `/home/coder/.claude/projects/-home-coder-project`; - await execContainer(id, ["mkdir", "-p", sessionDir]); - - // Create task session (without "session-" prefix, as CLI does) - await execContainer(id, [ - "bash", - "-c", - `cat > ${sessionDir}/${taskSessionId}.jsonl << 'EOF' -{"sessionId":"${taskSessionId}","message":{"content":"Task"},"timestamp":"2020-01-01T10:00:00.000Z"} -{"type":"assistant","message":{"content":"Response"},"timestamp":"2020-01-01T10:00:05.000Z"} -EOF`, - ]); - - // Create manual session (newer) - await execContainer(id, [ - "bash", - "-c", - `cat > ${sessionDir}/manual-456.jsonl << 'EOF' -{"sessionId":"manual-456","message":{"content":"Manual"},"timestamp":"2020-01-02T10:00:00.000Z"} -{"type":"assistant","message":{"content":"Response"},"timestamp":"2020-01-02T10:00:05.000Z"} -EOF`, - ]); - - await execModuleScript(id); - - const startLog = await execContainer(id, [ - "bash", - "-c", - "cat /home/coder/.claude-module/agentapi-start.log", - ]); - - // Should resume task session, not manual session - expect(startLog.stdout).toContain("Resuming task session"); - expect(startLog.stdout).toContain(taskSessionId); - expect(startLog.stdout).not.toContain("manual-456"); - }); - - test("mcp-config-remote-path", async () => { + test("claude-mcp-remote-user-scope", async () => { const failingUrl = "http://localhost:19999/mcp.json"; const successUrl = "https://raw.githubusercontent.com/coder/coder/main/.mcp.json"; @@ -491,42 +278,43 @@ EOF`, mcp_config_remote_path: JSON.stringify([failingUrl, successUrl]), }, }); - await execModuleScript(id, coderEnvVars); + await runModuleScripts(id, coderEnvVars); const installLog = await readFileContainer( id, "/home/coder/.claude-module/install.log", ); - // Verify both URLs are attempted expect(installLog).toContain(failingUrl); expect(installLog).toContain(successUrl); - - // First URL should fail gracefully expect(installLog).toContain( `Warning: Failed to fetch MCP configuration from '${failingUrl}'`, ); - - // Second URL should succeed - no failure warning for it expect(installLog).not.toContain( `Warning: Failed to fetch MCP configuration from '${successUrl}'`, ); + expect(installLog).toContain("claude mcp add-json --scope user"); + }); - // Should contain the MCP server add command from successful fetch - expect(installLog).toContain( - "Added stdio MCP server go-language-server to local config", - ); + test("pre-post-install-scripts", async () => { + const { id } = await setup({ + moduleVariables: { + pre_install_script: "#!/bin/bash\necho 'claude-pre-install-script'", + post_install_script: "#!/bin/bash\necho 'claude-post-install-script'", + }, + }); + await runModuleScripts(id); - expect(installLog).toContain( - "Added stdio MCP server typescript-language-server to local config", + const preInstallLog = await readFileContainer( + id, + "/home/coder/.claude-module/pre_install.log", ); + expect(preInstallLog).toContain("claude-pre-install-script"); - // Verify the MCP config was added to claude.json - const claudeConfig = await readFileContainer( + const postInstallLog = await readFileContainer( id, - "/home/coder/.claude.json", + "/home/coder/.claude-module/post_install.log", ); - expect(claudeConfig).toContain("typescript-language-server"); - expect(claudeConfig).toContain("go-language-server"); + expect(postInstallLog).toContain("claude-post-install-script"); }); }); diff --git a/registry/coder/modules/claude-code/main.tf b/registry/coder/modules/claude-code/main.tf index db234c052..a335fdb85 100644 --- a/registry/coder/modules/claude-code/main.tf +++ b/registry/coder/modules/claude-code/main.tf @@ -18,286 +18,134 @@ data "coder_workspace" "me" {} data "coder_workspace_owner" "me" {} -variable "order" { - type = number - description = "The order determines the position of app in the UI presentation. The lowest order is shown first and apps with equal order are sorted by name (ascending order)." - default = null -} - -variable "group" { - type = string - description = "The name of a group that this app belongs to." - default = null -} - -variable "icon" { - type = string - description = "The icon to use for the app." - default = "/icon/claude.svg" -} - -variable "workdir" { - type = string - description = "The folder to run Claude Code in." -} - -variable "report_tasks" { - type = bool - description = "Whether to enable task reporting to Coder UI via AgentAPI" - default = true -} - -variable "web_app" { - type = bool - description = "Whether to create the web app for Claude Code. When false, AgentAPI still runs but no web UI app icon is shown in the Coder dashboard. This is automatically enabled when using Coder Tasks, regardless of this setting." - default = true -} - -variable "cli_app" { - type = bool - description = "Whether to create a CLI app for Claude Code" - default = false -} - -variable "web_app_display_name" { - type = string - description = "Display name for the web app" - default = "Claude Code" -} - -variable "cli_app_display_name" { - type = string - description = "Display name for the CLI app" - default = "Claude Code CLI" -} - -variable "pre_install_script" { - type = string - description = "Custom script to run before installing Claude Code. Can be used for dependency ordering between modules (e.g., waiting for git-clone to complete before Claude Code initialization)." - default = null -} - -variable "post_install_script" { - type = string - description = "Custom script to run after installing Claude Code." - default = null -} - -variable "install_agentapi" { - type = bool - description = "Whether to install AgentAPI." - default = true -} - -variable "agentapi_version" { +variable "anthropic_api_key" { type = string - description = "The version of AgentAPI to install." - default = "v0.11.8" + description = "Anthropic API key. Exported as ANTHROPIC_API_KEY." + default = "" + sensitive = true } -variable "ai_prompt" { +variable "claude_code_oauth_token" { type = string - description = "Initial task prompt for Claude Code." + description = "Long-lived Claude.ai subscription token. Generate with `claude setup-token`. Exported as CLAUDE_CODE_OAUTH_TOKEN." default = "" + sensitive = true } -variable "subdomain" { - type = bool - description = "Whether to use a subdomain for AgentAPI." - default = false +variable "claude_code_version" { + type = string + description = "The version of Claude Code to install. Forwarded to the official installer." + default = "latest" } - variable "install_claude_code" { type = bool - description = "Whether to install Claude Code." + description = "Whether to install Claude Code via the official installer." default = true } -variable "claude_code_version" { +variable "claude_binary_path" { type = string - description = "The version of Claude Code to install." - default = "latest" + description = "Directory where the Claude Code binary is located. Use this when Claude is pre-installed outside the module." + default = "$HOME/.local/bin" + + validation { + condition = var.claude_binary_path == "$HOME/.local/bin" || !var.install_claude_code + error_message = "Custom claude_binary_path can only be used when install_claude_code is false. The official installer always installs to $HOME/.local/bin and does not support custom paths." + } } variable "disable_autoupdater" { type = bool - description = "Disable Claude Code automatic updates. When true, Claude Code will stay on the installed version." + description = "Disable Claude Code automatic updates. Sets DISABLE_AUTOUPDATER=1." default = false } -variable "claude_api_key" { - type = string - description = "The API key to use for the Claude Code server." - default = "" -} - variable "model" { type = string - description = "Sets the default model for Claude Code via ANTHROPIC_MODEL env var. If empty, Claude Code uses its default. Supports aliases (sonnet, opus) or full model names." - default = "" -} - -variable "resume_session_id" { - type = string - description = "Resume a specific session by ID." + description = "Default model for Claude Code. Exported as ANTHROPIC_MODEL. Supports aliases (sonnet, opus) or full model names." default = "" } -variable "continue" { - type = bool - description = "Automatically continue existing sessions on workspace restart. When true, resumes existing conversation if found, otherwise runs prompt or starts new session. When false, always starts fresh (ignores existing sessions)." - default = true -} - -variable "dangerously_skip_permissions" { - type = bool - description = "Skip the permission prompts. Use with caution. This will be set to true if using Coder Tasks" - default = false -} - -variable "permission_mode" { +variable "claude_md_path" { type = string - description = "Permission mode for the cli, check https://docs.anthropic.com/en/docs/claude-code/iam#permission-modes" - default = "" - validation { - condition = contains(["", "default", "acceptEdits", "plan", "auto", "bypassPermissions"], var.permission_mode) - error_message = "interaction_mode must be one of: default, acceptEdits, plan, auto, bypassPermissions." - } + description = "Path to a global CLAUDE.md. Exported as CODER_MCP_CLAUDE_MD_PATH." + default = "$HOME/.claude/CLAUDE.md" } variable "mcp" { type = string - description = "MCP JSON to be added to the claude code local scope" + description = "Inline MCP JSON (format: {\"mcpServers\": {\"name\": {...}}}). Applied at user scope with `claude mcp add-json --scope user`." default = "" } variable "mcp_config_remote_path" { type = list(string) - description = "List of URLs that return JSON MCP server configurations (text/plain with valid JSON)" + description = "List of URLs that return MCP JSON (same shape as `mcp`). Each is fetched and applied at user scope." default = [] } -variable "allowed_tools" { - type = string - description = "A list of tools that should be allowed without prompting the user for permission, in addition to settings.json files." - default = "" -} - -variable "disallowed_tools" { - type = string - description = "A list of tools that should be disallowed without prompting the user for permission, in addition to settings.json files." - default = "" - -} - -variable "claude_code_oauth_token" { - type = string - description = "Set up a long-lived authentication token (requires Claude subscription). Generated using `claude setup-token` command" - sensitive = true - default = "" -} - -variable "system_prompt" { - type = string - description = "The system prompt to use for the Claude Code server." - default = "" -} - -variable "claude_md_path" { - type = string - description = "The path to CLAUDE.md." - default = "$HOME/.claude/CLAUDE.md" -} - -variable "claude_binary_path" { - type = string - description = "Directory where the Claude Code binary is located. Use this if Claude is pre-installed or installed outside the module to a non-default location." - default = "$HOME/.local/bin" - - validation { - condition = var.claude_binary_path == "$HOME/.local/bin" || !var.install_claude_code - error_message = "Custom claude_binary_path can only be used when install_claude_code is false. The official installer always installs to $HOME/.local/bin and does not support custom paths." - } -} - -variable "install_via_npm" { - type = bool - description = "Install Claude Code via npm instead of the official installer. Useful if npm is preferred or the official installer fails." - default = false -} - -variable "enable_boundary" { - type = bool - description = "Whether to enable coder boundary for network filtering" - default = false -} - -variable "boundary_version" { - type = string - description = "Boundary version. When use_boundary_directly is true, a release version should be provided or 'latest' for the latest release. When compile_boundary_from_source is true, a valid git reference should be provided (tag, commit, branch)." - default = "latest" -} - -variable "compile_boundary_from_source" { - type = bool - description = "Whether to compile boundary from source instead of using the official install script" - default = false -} - -variable "use_boundary_directly" { - type = bool - description = "Whether to use boundary binary directly instead of coder boundary subcommand. When false (default), uses coder boundary subcommand. When true, installs and uses boundary binary from release." - default = false -} - variable "enable_aibridge" { type = bool - description = "Use AI Bridge for Claude Code. https://coder.com/docs/ai-coder/ai-bridge" + description = "Route Claude Code through Coder AI Bridge. Sets ANTHROPIC_AUTH_TOKEN and ANTHROPIC_BASE_URL. See https://coder.com/docs/ai-coder/ai-bridge." default = false validation { - condition = !(var.enable_aibridge && length(var.claude_api_key) > 0) - error_message = "claude_api_key cannot be provided when enable_aibridge is true. AI Bridge automatically authenticates the client using Coder credentials." + condition = !(var.enable_aibridge && length(var.anthropic_api_key) > 0) + error_message = "anthropic_api_key cannot be provided when enable_aibridge is true. AI Bridge authenticates using Coder credentials." } validation { condition = !(var.enable_aibridge && length(var.claude_code_oauth_token) > 0) - error_message = "claude_code_oauth_token cannot be provided when enable_aibridge is true. AI Bridge automatically authenticates the client using Coder credentials." + error_message = "claude_code_oauth_token cannot be provided when enable_aibridge is true. AI Bridge authenticates using Coder credentials." } } -variable "enable_state_persistence" { - type = bool - description = "Enable AgentAPI conversation state persistence across restarts." - default = true +variable "pre_install_script" { + type = string + description = "Custom script to run before installing Claude Code." + default = null } -resource "coder_env" "claude_code_md_path" { - count = var.claude_md_path == "" ? 0 : 1 +variable "post_install_script" { + type = string + description = "Custom script to run after installing Claude Code." + default = null +} + +resource "coder_env" "anthropic_api_key" { + count = var.anthropic_api_key != "" ? 1 : 0 agent_id = var.agent_id - name = "CODER_MCP_CLAUDE_MD_PATH" - value = var.claude_md_path + name = "ANTHROPIC_API_KEY" + value = var.anthropic_api_key +} + +resource "coder_env" "anthropic_auth_token" { + count = var.enable_aibridge ? 1 : 0 + agent_id = var.agent_id + name = "ANTHROPIC_AUTH_TOKEN" + value = data.coder_workspace_owner.me.session_token } -resource "coder_env" "claude_code_system_prompt" { +resource "coder_env" "anthropic_base_url" { + count = var.enable_aibridge ? 1 : 0 agent_id = var.agent_id - name = "CODER_MCP_CLAUDE_SYSTEM_PROMPT" - value = local.final_system_prompt + name = "ANTHROPIC_BASE_URL" + value = "${data.coder_workspace.me.access_url}/api/v2/aibridge/anthropic" } resource "coder_env" "claude_code_oauth_token" { + count = var.claude_code_oauth_token != "" ? 1 : 0 agent_id = var.agent_id name = "CLAUDE_CODE_OAUTH_TOKEN" value = var.claude_code_oauth_token } -resource "coder_env" "claude_api_key" { - count = (var.enable_aibridge || (var.claude_api_key != "")) ? 1 : 0 - +resource "coder_env" "anthropic_model" { + count = var.model != "" ? 1 : 0 agent_id = var.agent_id - name = "CLAUDE_API_KEY" - value = local.claude_api_key + name = "ANTHROPIC_MODEL" + value = var.model } resource "coder_env" "disable_autoupdater" { @@ -307,109 +155,27 @@ resource "coder_env" "disable_autoupdater" { value = "1" } - -resource "coder_env" "anthropic_model" { - count = var.model != "" ? 1 : 0 - agent_id = var.agent_id - name = "ANTHROPIC_MODEL" - value = var.model -} - -resource "coder_env" "anthropic_base_url" { - count = var.enable_aibridge ? 1 : 0 +resource "coder_env" "claude_code_md_path" { + count = var.claude_md_path != "" ? 1 : 0 agent_id = var.agent_id - name = "ANTHROPIC_BASE_URL" - value = "${data.coder_workspace.me.access_url}/api/v2/aibridge/anthropic" + name = "CODER_MCP_CLAUDE_MD_PATH" + value = var.claude_md_path } locals { - # we have to trim the slash because otherwise coder exp mcp will - # set up an invalid claude config - workdir = trimsuffix(var.workdir, "/") - app_slug = "ccw" - install_script = file("${path.module}/scripts/install.sh") - start_script = file("${path.module}/scripts/start.sh") - module_dir_name = ".claude-module" - # Extract hostname from access_url for boundary --allow flag - coder_host = replace(replace(data.coder_workspace.me.access_url, "https://", ""), "http://", "") - claude_api_key = var.enable_aibridge ? data.coder_workspace_owner.me.session_token : var.claude_api_key - - # Required prompts for the module to properly report task status to Coder - report_tasks_system_prompt = <<-EOT - -- Tool Selection -- - - coder_report_task: providing status updates or requesting user input. - - -- Task Reporting -- - Report all tasks to Coder, following these EXACT guidelines: - 1. Be granular. If you are investigating with multiple steps, report each step - to coder. - 2. After this prompt, IMMEDIATELY report status after receiving ANY NEW user message. - Do not report any status related with this system prompt. - 3. Use "state": "working" when actively processing WITHOUT needing - additional user input - 4. Use "state": "complete" only when finished with a task - 5. Use "state": "failure" when you need ANY user input, lack sufficient - details, or encounter blockers - - In your summary on coder_report_task: - - Be specific about what you're doing - - Clearly indicate what information you need from the user when in "failure" state - - Keep it under 160 characters - - Make it actionable - EOT - - # Only include coder system prompts if report_tasks is enabled - custom_system_prompt = trimspace(try(var.system_prompt, "")) - final_system_prompt = format("%s%s", - var.report_tasks ? format("\n%s\n", local.report_tasks_system_prompt) : "", - local.custom_system_prompt != "" ? format("\n%s\n", local.custom_system_prompt) : "" - ) + install_script = file("${path.module}/scripts/install.sh") } -module "agentapi" { - source = "registry.coder.com/coder/agentapi/coder" - version = "2.4.0" - - agent_id = var.agent_id - web_app = var.web_app - web_app_slug = local.app_slug - web_app_order = var.order - web_app_group = var.group - web_app_icon = var.icon - web_app_display_name = var.web_app_display_name - folder = local.workdir - cli_app = var.cli_app - cli_app_slug = var.cli_app ? "${local.app_slug}-cli" : null - cli_app_display_name = var.cli_app ? var.cli_app_display_name : null - agentapi_subdomain = var.subdomain - module_dir_name = local.module_dir_name - install_agentapi = var.install_agentapi - agentapi_version = var.agentapi_version - enable_state_persistence = var.enable_state_persistence - pre_install_script = var.pre_install_script - post_install_script = var.post_install_script - start_script = <<-EOT - #!/bin/bash - set -o errexit - set -o pipefail - echo -n '${base64encode(local.start_script)}' | base64 -d > /tmp/start.sh - chmod +x /tmp/start.sh - - ARG_RESUME_SESSION_ID='${var.resume_session_id}' \ - ARG_CONTINUE='${var.continue}' \ - ARG_DANGEROUSLY_SKIP_PERMISSIONS='${var.dangerously_skip_permissions}' \ - ARG_PERMISSION_MODE='${var.permission_mode}' \ - ARG_WORKDIR='${local.workdir}' \ - ARG_AI_PROMPT='${base64encode(var.ai_prompt)}' \ - ARG_REPORT_TASKS='${var.report_tasks}' \ - ARG_ENABLE_BOUNDARY='${var.enable_boundary}' \ - ARG_BOUNDARY_VERSION='${var.boundary_version}' \ - ARG_COMPILE_FROM_SOURCE='${var.compile_boundary_from_source}' \ - ARG_USE_BOUNDARY_DIRECTLY='${var.use_boundary_directly}' \ - ARG_CODER_HOST='${local.coder_host}' \ - ARG_CLAUDE_BINARY_PATH='${var.claude_binary_path}' \ - /tmp/start.sh - EOT +module "coder-utils" { + # Pinned to PR #842 branch on coder/registry until coder-utils@1.1.0 is published. + source = "git::https://github.com/coder/registry.git//registry/coder/modules/coder-utils?ref=feat/coder-utils-optional-install-start" + + agent_id = var.agent_id + agent_name = "claude-code" + module_directory = "$HOME/.claude-module" + + pre_install_script = var.pre_install_script + post_install_script = var.post_install_script install_script = <<-EOT #!/bin/bash @@ -418,23 +184,12 @@ module "agentapi" { echo -n '${base64encode(local.install_script)}' | base64 -d > /tmp/install.sh chmod +x /tmp/install.sh + ARG_CLAUDE_CODE_VERSION='${var.claude_code_version}' \ - ARG_MCP_APP_STATUS_SLUG='${local.app_slug}' \ ARG_INSTALL_CLAUDE_CODE='${var.install_claude_code}' \ ARG_CLAUDE_BINARY_PATH='${var.claude_binary_path}' \ - ARG_INSTALL_VIA_NPM='${var.install_via_npm}' \ - ARG_REPORT_TASKS='${var.report_tasks}' \ - ARG_WORKDIR='${local.workdir}' \ - ARG_ALLOWED_TOOLS='${var.allowed_tools}' \ - ARG_DISALLOWED_TOOLS='${var.disallowed_tools}' \ - ARG_MCP='${var.mcp != null ? base64encode(replace(var.mcp, "'", "'\\''")) : ""}' \ + ARG_MCP='${var.mcp != "" ? base64encode(var.mcp) : ""}' \ ARG_MCP_CONFIG_REMOTE_PATH='${base64encode(jsonencode(var.mcp_config_remote_path))}' \ - ARG_ENABLE_AIBRIDGE='${var.enable_aibridge}' \ - ARG_PERMISSION_MODE='${var.permission_mode}' \ /tmp/install.sh EOT } - -output "task_app_id" { - value = module.agentapi.task_app_id -} diff --git a/registry/coder/modules/claude-code/main.tftest.hcl b/registry/coder/modules/claude-code/main.tftest.hcl index 9c9df50f4..c4729df4c 100644 --- a/registry/coder/modules/claude-code/main.tftest.hcl +++ b/registry/coder/modules/claude-code/main.tftest.hcl @@ -1,462 +1,291 @@ -run "test_claude_code_basic" { +mock_provider "coder" {} + +run "test_defaults" { command = plan variables { - agent_id = "test-agent-123" - workdir = "/home/coder/projects" - } - - assert { - condition = var.workdir == "/home/coder/projects" - error_message = "Workdir variable should be set correctly" - } - - assert { - condition = var.agent_id == "test-agent-123" - error_message = "Agent ID variable should be set correctly" + agent_id = "test-agent" } assert { condition = var.install_claude_code == true - error_message = "Install claude_code should default to true" + error_message = "install_claude_code should default to true" } assert { - condition = var.install_agentapi == true - error_message = "Install agentapi should default to true" + condition = var.disable_autoupdater == false + error_message = "disable_autoupdater should default to false" } assert { - condition = var.report_tasks == true - error_message = "report_tasks should default to true" - } -} - -run "test_claude_code_with_api_key" { - command = plan - - variables { - agent_id = "test-agent-456" - workdir = "/home/coder/workspace" - claude_api_key = "test-api-key-123" + condition = var.enable_aibridge == false + error_message = "enable_aibridge should default to false" } assert { - condition = coder_env.claude_api_key[0].value == "test-api-key-123" - error_message = "Claude API key value should match the input" - } -} - -run "test_claude_code_with_custom_options" { - command = plan - - variables { - agent_id = "test-agent-789" - workdir = "/home/coder/custom" - order = 5 - group = "development" - icon = "/icon/custom.svg" - model = "opus" - ai_prompt = "Help me write better code" - permission_mode = "plan" - continue = true - install_claude_code = false - install_agentapi = false - claude_code_version = "1.0.0" - agentapi_version = "v0.6.0" - dangerously_skip_permissions = true + condition = length(coder_env.anthropic_api_key) == 0 + error_message = "ANTHROPIC_API_KEY should not be set by default" } assert { - condition = var.order == 5 - error_message = "Order variable should be set to 5" + condition = length(coder_env.anthropic_auth_token) == 0 + error_message = "ANTHROPIC_AUTH_TOKEN should not be set by default" } assert { - condition = var.group == "development" - error_message = "Group variable should be set to 'development'" + condition = length(coder_env.anthropic_base_url) == 0 + error_message = "ANTHROPIC_BASE_URL should not be set by default" } assert { - condition = var.icon == "/icon/custom.svg" - error_message = "Icon variable should be set to custom icon" + condition = length(coder_env.anthropic_model) == 0 + error_message = "ANTHROPIC_MODEL should not be set by default" } assert { - condition = var.model == "opus" - error_message = "Claude model variable should be set to 'opus'" + condition = length(coder_env.disable_autoupdater) == 0 + error_message = "DISABLE_AUTOUPDATER should not be set by default" } assert { - condition = var.ai_prompt == "Help me write better code" - error_message = "AI prompt variable should be set correctly" + condition = length(coder_env.claude_code_oauth_token) == 0 + error_message = "CLAUDE_CODE_OAUTH_TOKEN should not be set by default" } +} - assert { - condition = var.permission_mode == "plan" - error_message = "Permission mode should be set to 'plan'" +run "test_with_anthropic_api_key" { + command = plan + + variables { + agent_id = "test-agent" + anthropic_api_key = "sk-live-test" } assert { - condition = var.continue == true - error_message = "Continue should be set to true" + condition = coder_env.anthropic_api_key[0].name == "ANTHROPIC_API_KEY" + error_message = "Env var name must be ANTHROPIC_API_KEY" } assert { - condition = var.claude_code_version == "1.0.0" - error_message = "Claude Code version should be set to '1.0.0'" + condition = coder_env.anthropic_api_key[0].value == "sk-live-test" + error_message = "ANTHROPIC_API_KEY value should match input" } assert { - condition = var.agentapi_version == "v0.6.0" - error_message = "AgentAPI version should be set to 'v0.6.0'" + condition = length(coder_env.anthropic_auth_token) == 0 + error_message = "ANTHROPIC_AUTH_TOKEN should not be set when only anthropic_api_key is provided" } assert { - condition = var.dangerously_skip_permissions == true - error_message = "dangerously_skip_permissions should be set to true" + condition = length(coder_env.anthropic_base_url) == 0 + error_message = "ANTHROPIC_BASE_URL should not be set when AI Bridge is disabled" } } -run "test_claude_code_with_mcp_and_tools" { +run "test_with_oauth_token" { command = plan variables { - agent_id = "test-agent-mcp" - workdir = "/home/coder/mcp-test" - mcp = jsonencode({ - mcpServers = { - test = { - command = "test-server" - args = ["--config", "test.json"] - } - } - }) - allowed_tools = "bash,python" - disallowed_tools = "rm" - } - - assert { - condition = var.mcp != "" - error_message = "MCP configuration should be provided" + agent_id = "test-agent" + claude_code_oauth_token = "oauth-test-token" } assert { - condition = var.allowed_tools == "bash,python" - error_message = "Allowed tools should be set" + condition = coder_env.claude_code_oauth_token[0].name == "CLAUDE_CODE_OAUTH_TOKEN" + error_message = "Env var name must be CLAUDE_CODE_OAUTH_TOKEN" } assert { - condition = var.disallowed_tools == "rm" - error_message = "Disallowed tools should be set" + condition = coder_env.claude_code_oauth_token[0].value == "oauth-test-token" + error_message = "CLAUDE_CODE_OAUTH_TOKEN value should match input" } } -run "test_claude_code_with_scripts" { +run "test_with_model" { command = plan variables { - agent_id = "test-agent-scripts" - workdir = "/home/coder/scripts" - pre_install_script = "echo 'Pre-install script'" - post_install_script = "echo 'Post-install script'" - } - - assert { - condition = var.pre_install_script == "echo 'Pre-install script'" - error_message = "Pre-install script should be set correctly" + agent_id = "test-agent" + model = "opus" } assert { - condition = var.post_install_script == "echo 'Post-install script'" - error_message = "Post-install script should be set correctly" + condition = coder_env.anthropic_model[0].value == "opus" + error_message = "ANTHROPIC_MODEL should be set to 'opus'" } } -run "test_claude_code_permission_mode_validation" { +run "test_with_disable_autoupdater" { command = plan variables { - agent_id = "test-agent-validation" - workdir = "/home/coder/test" - permission_mode = "acceptEdits" + agent_id = "test-agent" + disable_autoupdater = true } assert { - condition = contains(["", "default", "acceptEdits", "plan", "auto", "bypassPermissions"], var.permission_mode) - error_message = "Permission mode should be one of the valid options" + condition = coder_env.disable_autoupdater[0].value == "1" + error_message = "DISABLE_AUTOUPDATER should be '1' when disable_autoupdater is true" } } -run "test_claude_code_auto_permission_mode" { +run "test_with_claude_md_path_default" { command = plan variables { - agent_id = "test-agent-auto" - workdir = "/home/coder/test" - permission_mode = "auto" + agent_id = "test-agent" } assert { - condition = var.permission_mode == "auto" - error_message = "Permission mode should be set to auto" + condition = coder_env.claude_code_md_path[0].value == "$HOME/.claude/CLAUDE.md" + error_message = "CODER_MCP_CLAUDE_MD_PATH should default to $HOME/.claude/CLAUDE.md" } } -run "test_claude_code_with_boundary" { +run "test_with_claude_md_path_empty" { command = plan variables { - agent_id = "test-agent-boundary" - workdir = "/home/coder/boundary-test" - enable_boundary = true + agent_id = "test-agent" + claude_md_path = "" } assert { - condition = var.enable_boundary == true - error_message = "Boundary should be enabled" - } - - assert { - condition = local.coder_host != "" - error_message = "Coder host should be extracted from access URL" + condition = length(coder_env.claude_code_md_path) == 0 + error_message = "CODER_MCP_CLAUDE_MD_PATH should not be set when claude_md_path is empty" } } -run "test_claude_code_system_prompt" { +run "test_with_mcp_inline" { command = plan variables { - agent_id = "test-agent-system-prompt" - workdir = "/home/coder/test" - system_prompt = "Custom addition" - } - - assert { - condition = trimspace(coder_env.claude_code_system_prompt.value) != "" - error_message = "System prompt should not be empty" + agent_id = "test-agent" + mcp = jsonencode({ + mcpServers = { + test-server = { + command = "test-cmd" + args = [] + } + } + }) } assert { - condition = length(regexall("Custom addition", coder_env.claude_code_system_prompt.value)) > 0 - error_message = "System prompt should have system_prompt variable value" + condition = var.mcp != "" + error_message = "mcp should be passed through" } } -run "test_claude_report_tasks_default" { +run "test_with_mcp_remote" { command = plan variables { - agent_id = "test-agent-report-tasks" - workdir = "/home/coder/test" - # report_tasks: default is true - } - - assert { - condition = trimspace(coder_env.claude_code_system_prompt.value) != "" - error_message = "System prompt should not be empty" - } - - # Ensure system prompt is wrapped by - assert { - condition = startswith(trimspace(coder_env.claude_code_system_prompt.value), "") - error_message = "System prompt should start with " - } - assert { - condition = endswith(trimspace(coder_env.claude_code_system_prompt.value), "") - error_message = "System prompt should end with " - } - - # Ensure Coder sections are injected when report_tasks=true (default) - assert { - condition = length(regexall("-- Tool Selection --", coder_env.claude_code_system_prompt.value)) > 0 - error_message = "System prompt should have Tool Selection section" + agent_id = "test-agent" + mcp_config_remote_path = ["https://example.com/mcp.json"] } assert { - condition = length(regexall("-- Task Reporting --", coder_env.claude_code_system_prompt.value)) > 0 - error_message = "System prompt should have Task Reporting section" + condition = length(var.mcp_config_remote_path) == 1 + error_message = "mcp_config_remote_path should carry one URL" } } -run "test_claude_report_tasks_disabled" { +run "test_with_pre_post_install" { command = plan variables { - agent_id = "test-agent-report-tasks" - workdir = "/home/coder/test" - report_tasks = false + agent_id = "test-agent" + pre_install_script = "echo pre" + post_install_script = "echo post" } assert { - condition = trimspace(coder_env.claude_code_system_prompt.value) != "" - error_message = "System prompt should not be empty" + condition = var.pre_install_script == "echo pre" + error_message = "pre_install_script should be forwarded" } - # Ensure system prompt is wrapped by - assert { - condition = startswith(trimspace(coder_env.claude_code_system_prompt.value), "") - error_message = "System prompt should start with " - } assert { - condition = endswith(trimspace(coder_env.claude_code_system_prompt.value), "") - error_message = "System prompt should end with " + condition = var.post_install_script == "echo post" + error_message = "post_install_script should be forwarded" } } -run "test_aibridge_enabled" { +run "test_with_aibridge" { command = plan variables { - agent_id = "test-agent-aibridge" - workdir = "/home/coder/aibridge" + agent_id = "test-agent" enable_aibridge = true } override_data { - target = data.coder_workspace_owner.me + target = data.coder_workspace.me values = { - session_token = "mock-session-token" + access_url = "https://coder.test" } } - assert { - condition = var.enable_aibridge == true - error_message = "AI Bridge should be enabled" + override_data { + target = data.coder_workspace_owner.me + values = { + session_token = "session-token-mock" + } } assert { - condition = coder_env.anthropic_base_url[0].name == "ANTHROPIC_BASE_URL" - error_message = "ANTHROPIC_BASE_URL environment variable should be set" + condition = coder_env.anthropic_auth_token[0].name == "ANTHROPIC_AUTH_TOKEN" + error_message = "AI Bridge should set ANTHROPIC_AUTH_TOKEN" } assert { - condition = length(regexall("/api/v2/aibridge/anthropic", coder_env.anthropic_base_url[0].value)) > 0 - error_message = "ANTHROPIC_BASE_URL should point to AI Bridge endpoint" + condition = coder_env.anthropic_auth_token[0].value == "session-token-mock" + error_message = "ANTHROPIC_AUTH_TOKEN should use the workspace owner session token" } assert { - condition = coder_env.claude_api_key[0].name == "CLAUDE_API_KEY" - error_message = "CLAUDE_API_KEY environment variable should be set" + condition = coder_env.anthropic_base_url[0].value == "https://coder.test/api/v2/aibridge/anthropic" + error_message = "ANTHROPIC_BASE_URL should be built from the workspace access URL" } assert { - condition = coder_env.claude_api_key[0].value == data.coder_workspace_owner.me.session_token - error_message = "CLAUDE_API_KEY should use workspace owner's session token when aibridge is enabled" + condition = length(coder_env.anthropic_api_key) == 0 + error_message = "ANTHROPIC_API_KEY must not be set when AI Bridge is enabled" } } -run "test_aibridge_validation_with_api_key" { +run "test_aibridge_api_key_conflict" { command = plan variables { - agent_id = "test-agent-validation" - workdir = "/home/coder/test" - enable_aibridge = true - claude_api_key = "test-api-key" + agent_id = "test-agent" + enable_aibridge = true + anthropic_api_key = "sk-live" } - expect_failures = [ - var.enable_aibridge, - ] + expect_failures = [var.enable_aibridge] } -run "test_aibridge_validation_with_oauth_token" { +run "test_aibridge_oauth_conflict" { command = plan variables { - agent_id = "test-agent-validation" - workdir = "/home/coder/test" + agent_id = "test-agent" enable_aibridge = true - claude_code_oauth_token = "test-oauth-token" + claude_code_oauth_token = "oauth-live" } - expect_failures = [ - var.enable_aibridge, - ] + expect_failures = [var.enable_aibridge] } -run "test_aibridge_disabled_with_api_key" { +run "test_claude_binary_path_validation" { command = plan variables { - agent_id = "test-agent-no-aibridge" - workdir = "/home/coder/test" - enable_aibridge = false - claude_api_key = "test-api-key-xyz" + agent_id = "test-agent" + install_claude_code = true + claude_binary_path = "/opt/custom" } - assert { - condition = var.enable_aibridge == false - error_message = "AI Bridge should be disabled" - } - - assert { - condition = coder_env.claude_api_key[0].value == "test-api-key-xyz" - error_message = "CLAUDE_API_KEY should use the provided API key when aibridge is disabled" - } - - assert { - condition = length(coder_env.anthropic_base_url) == 0 - error_message = "ANTHROPIC_BASE_URL should not be set when aibridge is disabled" - } + expect_failures = [var.claude_binary_path] } - -run "test_enable_state_persistence_default" { - command = plan - - variables { - agent_id = "test-agent" - workdir = "/home/coder" - } - - assert { - condition = var.enable_state_persistence == true - error_message = "enable_state_persistence should default to true" - } -} - -run "test_disable_state_persistence" { - command = plan - - variables { - agent_id = "test-agent" - workdir = "/home/coder" - enable_state_persistence = false - } - - assert { - condition = var.enable_state_persistence == false - error_message = "enable_state_persistence should be false when explicitly disabled" - } -} - -run "test_no_api_key_no_env" { - command = plan - - variables { - agent_id = "test-agent-no-key" - workdir = "/home/coder/test" - enable_aibridge = false - } - - assert { - condition = length(coder_env.claude_api_key) == 0 - error_message = "CLAUDE_API_KEY should not be created when no API key is provided and aibridge is disabled" - } -} - -run "test_api_key_count_with_aibridge_no_override" { - command = plan - - variables { - agent_id = "test-agent-count" - workdir = "/home/coder/test" - enable_aibridge = true - } - - assert { - condition = length(coder_env.claude_api_key) == 1 - error_message = "CLAUDE_API_KEY env should be created when aibridge is enabled, regardless of session_token value" - } -} \ No newline at end of file diff --git a/registry/coder/modules/claude-code/scripts/install.sh b/registry/coder/modules/claude-code/scripts/install.sh index c00773b5e..c9ee7c41a 100644 --- a/registry/coder/modules/claude-code/scripts/install.sh +++ b/registry/coder/modules/claude-code/scripts/install.sh @@ -2,61 +2,31 @@ set -euo pipefail -BOLD='\033[0;1m' - command_exists() { command -v "$1" > /dev/null 2>&1 } ARG_CLAUDE_CODE_VERSION=${ARG_CLAUDE_CODE_VERSION:-} -ARG_WORKDIR=${ARG_WORKDIR:-"$HOME"} -ARG_INSTALL_CLAUDE_CODE=${ARG_INSTALL_CLAUDE_CODE:-} +ARG_INSTALL_CLAUDE_CODE=${ARG_INSTALL_CLAUDE_CODE:-true} ARG_CLAUDE_BINARY_PATH=${ARG_CLAUDE_BINARY_PATH:-"$HOME/.local/bin"} ARG_CLAUDE_BINARY_PATH="${ARG_CLAUDE_BINARY_PATH/#\~/$HOME}" ARG_CLAUDE_BINARY_PATH="${ARG_CLAUDE_BINARY_PATH//\$HOME/$HOME}" -ARG_INSTALL_VIA_NPM=${ARG_INSTALL_VIA_NPM:-false} -ARG_REPORT_TASKS=${ARG_REPORT_TASKS:-true} -ARG_MCP_APP_STATUS_SLUG=${ARG_MCP_APP_STATUS_SLUG:-} ARG_MCP=$(echo -n "${ARG_MCP:-}" | base64 -d) ARG_MCP_CONFIG_REMOTE_PATH=$(echo -n "${ARG_MCP_CONFIG_REMOTE_PATH:-}" | base64 -d) -ARG_ALLOWED_TOOLS=${ARG_ALLOWED_TOOLS:-} -ARG_DISALLOWED_TOOLS=${ARG_DISALLOWED_TOOLS:-} -ARG_ENABLE_AIBRIDGE=${ARG_ENABLE_AIBRIDGE:-false} -ARG_PERMISSION_MODE=${ARG_PERMISSION_MODE:-} export PATH="$ARG_CLAUDE_BINARY_PATH:$PATH" echo "--------------------------------" - printf "ARG_CLAUDE_CODE_VERSION: %s\n" "$ARG_CLAUDE_CODE_VERSION" -printf "ARG_WORKDIR: %s\n" "$ARG_WORKDIR" printf "ARG_INSTALL_CLAUDE_CODE: %s\n" "$ARG_INSTALL_CLAUDE_CODE" printf "ARG_CLAUDE_BINARY_PATH: %s\n" "$ARG_CLAUDE_BINARY_PATH" -printf "ARG_INSTALL_VIA_NPM: %s\n" "$ARG_INSTALL_VIA_NPM" -printf "ARG_REPORT_TASKS: %s\n" "$ARG_REPORT_TASKS" -printf "ARG_MCP_APP_STATUS_SLUG: %s\n" "$ARG_MCP_APP_STATUS_SLUG" printf "ARG_MCP: %s\n" "$ARG_MCP" printf "ARG_MCP_CONFIG_REMOTE_PATH: %s\n" "$ARG_MCP_CONFIG_REMOTE_PATH" -printf "ARG_ALLOWED_TOOLS: %s\n" "$ARG_ALLOWED_TOOLS" -printf "ARG_DISALLOWED_TOOLS: %s\n" "$ARG_DISALLOWED_TOOLS" -printf "ARG_ENABLE_AIBRIDGE: %s\n" "$ARG_ENABLE_AIBRIDGE" - echo "--------------------------------" -function add_mcp_servers() { - local mcp_json="$1" - local source_desc="$2" - - while IFS= read -r server_name && IFS= read -r server_json; do - echo "------------------------" - echo "Executing: claude mcp add-json \"$server_name\" '$server_json' ($source_desc)" - claude mcp add-json "$server_name" "$server_json" || echo "Warning: Failed to add MCP server '$server_name', continuing..." - echo "------------------------" - echo "" - done < <(echo "$mcp_json" | jq -r '.mcpServers | to_entries[] | .key, (.value | @json)') -} - -function add_path_to_shell_profiles() { +# Ensures $ARG_CLAUDE_BINARY_PATH is on PATH across the common shell profiles so +# interactive shells started by the user can find the installed claude binary. +add_path_to_shell_profiles() { local path_dir="$1" for profile in "$HOME/.profile" "$HOME/.bash_profile" "$HOME/.bashrc" "$HOME/.zprofile" "$HOME/.zshrc"; do @@ -77,7 +47,9 @@ function add_path_to_shell_profiles() { fi } -function ensure_claude_in_path() { +# Resolves the claude binary, symlinks it into CODER_SCRIPT_BIN_DIR so the +# agent's coder_script context can call it, and updates shell profiles. +ensure_claude_in_path() { local CLAUDE_BIN="" if command -v claude > /dev/null 2>&1; then CLAUDE_BIN=$(command -v claude) @@ -103,163 +75,62 @@ function ensure_claude_in_path() { add_path_to_shell_profiles "$CLAUDE_DIR" } -function install_claude_code_cli() { +# Adds each MCP server from the provided JSON at user scope. The claude CLI +# writes to ~/.claude.json; this module does not touch that file directly. +add_mcp_servers() { + local mcp_json="$1" + local source_desc="$2" + + while IFS= read -r server_name && IFS= read -r server_json; do + echo "------------------------" + echo "Executing: claude mcp add-json --scope user \"$server_name\" ($source_desc)" + claude mcp add-json --scope user "$server_name" "$server_json" \ + || echo "Warning: Failed to add MCP server '$server_name', continuing..." + echo "------------------------" + done < <(echo "$mcp_json" | jq -r '.mcpServers | to_entries[] | .key, (.value | @json)') +} + +install_claude_code_cli() { if [ "$ARG_INSTALL_CLAUDE_CODE" != "true" ]; then echo "Skipping Claude Code installation as per configuration." ensure_claude_in_path return fi - # Use npm when install_via_npm is true - if [ "$ARG_INSTALL_VIA_NPM" = "true" ]; then - echo "WARNING: npm installation method will be deprecated and removed in the next major release." - echo "Installing Claude Code via npm (version: $ARG_CLAUDE_CODE_VERSION)" - npm install -g "@anthropic-ai/claude-code@$ARG_CLAUDE_CODE_VERSION" - echo "Installed Claude Code via npm. Version: $(claude --version || echo 'unknown')" - else - echo "Installing Claude Code via official installer" - set +e - curl -fsSL claude.ai/install.sh | bash -s -- "$ARG_CLAUDE_CODE_VERSION" 2>&1 - CURL_EXIT=${PIPESTATUS[0]} - set -e - if [ $CURL_EXIT -ne 0 ]; then - echo "Claude Code installer failed with exit code $CURL_EXIT" - fi - echo "Installed Claude Code successfully. Version: $(claude --version || echo 'unknown')" + echo "Installing Claude Code via official installer (version: $ARG_CLAUDE_CODE_VERSION)" + set +e + curl -fsSL https://claude.ai/install.sh | bash -s -- "$ARG_CLAUDE_CODE_VERSION" 2>&1 + CURL_EXIT=${PIPESTATUS[0]} + set -e + if [ "$CURL_EXIT" -ne 0 ]; then + echo "Claude Code installer failed with exit code $CURL_EXIT" + exit "$CURL_EXIT" fi + echo "Installed Claude Code successfully. Version: $(claude --version || echo 'unknown')" ensure_claude_in_path } -function setup_claude_configurations() { - if [ ! -d "$ARG_WORKDIR" ]; then - echo "Warning: The specified folder '$ARG_WORKDIR' does not exist." - echo "Creating the folder..." - mkdir -p "$ARG_WORKDIR" - echo "Folder created successfully." - fi - - module_path="$HOME/.claude-module" - mkdir -p "$module_path" - - if [ "$ARG_MCP" != "" ]; then - ( - cd "$ARG_WORKDIR" - add_mcp_servers "$ARG_MCP" "in $ARG_WORKDIR" - ) +apply_mcp() { + if [ -n "$ARG_MCP" ]; then + add_mcp_servers "$ARG_MCP" "inline" fi if [ -n "$ARG_MCP_CONFIG_REMOTE_PATH" ] && [ "$ARG_MCP_CONFIG_REMOTE_PATH" != "[]" ]; then - ( - cd "$ARG_WORKDIR" - for url in $(echo "$ARG_MCP_CONFIG_REMOTE_PATH" | jq -r '.[]'); do - echo "Fetching MCP configuration from: $url" - mcp_json=$(curl -fsSL "$url") || { - echo "Warning: Failed to fetch MCP configuration from '$url', continuing..." - continue - } - if ! echo "$mcp_json" | jq -e '.mcpServers' > /dev/null 2>&1; then - echo "Warning: Invalid MCP configuration from '$url' (missing mcpServers), continuing..." - continue - fi - add_mcp_servers "$mcp_json" "from $url" - done - ) - fi - - if [ -n "$ARG_ALLOWED_TOOLS" ]; then - coder --allowedTools "$ARG_ALLOWED_TOOLS" - fi - - if [ -n "$ARG_DISALLOWED_TOOLS" ]; then - coder --disallowedTools "$ARG_DISALLOWED_TOOLS" - fi - -} - -function configure_standalone_mode() { - echo "Configuring Claude Code for standalone mode..." - - if [ -z "${CLAUDE_API_KEY:-}" ] && [ "$ARG_ENABLE_AIBRIDGE" = "false" ]; then - echo "Note: Neither claude_api_key nor enable_aibridge is set, skipping authentication setup" - return - fi - - local claude_config="$HOME/.claude.json" - local workdir_normalized - workdir_normalized=$(echo "$ARG_WORKDIR" | tr '/' '-') - - # Create or update .claude.json with minimal configuration for API key auth - # This skips the interactive login prompt and onboarding screens - if [ -f "$claude_config" ]; then - echo "Updating existing Claude configuration at $claude_config" - - jq --arg workdir "$ARG_WORKDIR" --arg apikey "${CLAUDE_API_KEY:-}" \ - '.autoUpdaterStatus = "disabled" | - .autoModeAccepted = true | - .bypassPermissionsModeAccepted = true | - .hasAcknowledgedCostThreshold = true | - .hasCompletedOnboarding = true | - .primaryApiKey = $apikey | - .projects[$workdir].hasCompletedProjectOnboarding = true | - .projects[$workdir].hasTrustDialogAccepted = true' \ - "$claude_config" > "${claude_config}.tmp" && mv "${claude_config}.tmp" "$claude_config" - else - echo "Creating new Claude configuration at $claude_config" - cat > "$claude_config" << EOF -{ - "autoUpdaterStatus": "disabled", - "autoModeAccepted": true, - "bypassPermissionsModeAccepted": true, - "hasAcknowledgedCostThreshold": true, - "hasCompletedOnboarding": true, - "primaryApiKey": "${CLAUDE_API_KEY:-}", - "projects": { - "$ARG_WORKDIR": { - "hasCompletedProjectOnboarding": true, - "hasTrustDialogAccepted": true - } - } -} -EOF - fi - - echo "Standalone mode configured successfully" -} - -function report_tasks() { - if [ "$ARG_REPORT_TASKS" = "true" ]; then - echo "Configuring Claude Code to report tasks via Coder MCP..." - export CODER_MCP_APP_STATUS_SLUG="$ARG_MCP_APP_STATUS_SLUG" - export CODER_MCP_AI_AGENTAPI_URL="http://localhost:3284" - coder exp mcp configure claude-code "$ARG_WORKDIR" - else - configure_standalone_mode - fi -} - -function accept_auto_mode() { - # Pre-accept the auto mode TOS prompt so it doesn't appear interactively. - # Claude Code shows a confirmation dialog for auto mode that blocks - # non-interactive/headless usage. - # Note: bypassPermissions acceptance is already handled by - # coder exp mcp configure (task mode) and configure_standalone_mode. - local claude_config="$HOME/.claude.json" - - if [ -f "$claude_config" ]; then - jq '.autoModeAccepted = true' \ - "$claude_config" > "${claude_config}.tmp" && mv "${claude_config}.tmp" "$claude_config" - else - echo '{"autoModeAccepted": true}' > "$claude_config" + for url in $(echo "$ARG_MCP_CONFIG_REMOTE_PATH" | jq -r '.[]'); do + echo "Fetching MCP configuration from: $url" + mcp_json=$(curl -fsSL "$url") || { + echo "Warning: Failed to fetch MCP configuration from '$url', continuing..." + continue + } + if ! echo "$mcp_json" | jq -e '.mcpServers' > /dev/null 2>&1; then + echo "Warning: Invalid MCP configuration from '$url' (missing mcpServers), continuing..." + continue + fi + add_mcp_servers "$mcp_json" "from $url" + done fi - - echo "Pre-accepted auto mode prompt" } install_claude_code_cli -setup_claude_configurations -report_tasks - -if [ "$ARG_PERMISSION_MODE" = "auto" ]; then - accept_auto_mode -fi +apply_mcp diff --git a/registry/coder/modules/claude-code/scripts/start.sh b/registry/coder/modules/claude-code/scripts/start.sh deleted file mode 100644 index 5ccbc8fa1..000000000 --- a/registry/coder/modules/claude-code/scripts/start.sh +++ /dev/null @@ -1,256 +0,0 @@ -#!/bin/bash - -set -euo pipefail - -ARG_CLAUDE_BINARY_PATH=${ARG_CLAUDE_BINARY_PATH:-"$HOME/.local/bin"} -ARG_CLAUDE_BINARY_PATH="${ARG_CLAUDE_BINARY_PATH/#\~/$HOME}" -ARG_CLAUDE_BINARY_PATH="${ARG_CLAUDE_BINARY_PATH//\$HOME/$HOME}" - -export PATH="$ARG_CLAUDE_BINARY_PATH:$PATH" - -command_exists() { - command -v "$1" > /dev/null 2>&1 -} - -ARG_RESUME_SESSION_ID=${ARG_RESUME_SESSION_ID:-} -ARG_CONTINUE=${ARG_CONTINUE:-false} -ARG_DANGEROUSLY_SKIP_PERMISSIONS=${ARG_DANGEROUSLY_SKIP_PERMISSIONS:-} -ARG_PERMISSION_MODE=${ARG_PERMISSION_MODE:-} -ARG_WORKDIR=${ARG_WORKDIR:-"$HOME"} -ARG_AI_PROMPT=$(echo -n "${ARG_AI_PROMPT:-}" | base64 -d) -ARG_REPORT_TASKS=${ARG_REPORT_TASKS:-true} -ARG_ENABLE_BOUNDARY=${ARG_ENABLE_BOUNDARY:-false} -ARG_BOUNDARY_VERSION=${ARG_BOUNDARY_VERSION:-"latest"} -ARG_COMPILE_FROM_SOURCE=${ARG_COMPILE_FROM_SOURCE:-false} -ARG_USE_BOUNDARY_DIRECTLY=${ARG_USE_BOUNDARY_DIRECTLY:-false} -ARG_CODER_HOST=${ARG_CODER_HOST:-} - -echo "--------------------------------" - -printf "ARG_RESUME: %s\n" "$ARG_RESUME_SESSION_ID" -printf "ARG_CONTINUE: %s\n" "$ARG_CONTINUE" -printf "ARG_DANGEROUSLY_SKIP_PERMISSIONS: %s\n" "$ARG_DANGEROUSLY_SKIP_PERMISSIONS" -printf "ARG_PERMISSION_MODE: %s\n" "$ARG_PERMISSION_MODE" -printf "ARG_AI_PROMPT: %s\n" "$ARG_AI_PROMPT" -printf "ARG_WORKDIR: %s\n" "$ARG_WORKDIR" -printf "ARG_REPORT_TASKS: %s\n" "$ARG_REPORT_TASKS" -printf "ARG_ENABLE_BOUNDARY: %s\n" "$ARG_ENABLE_BOUNDARY" -printf "ARG_BOUNDARY_VERSION: %s\n" "$ARG_BOUNDARY_VERSION" -printf "ARG_COMPILE_FROM_SOURCE: %s\n" "$ARG_COMPILE_FROM_SOURCE" -printf "ARG_USE_BOUNDARY_DIRECTLY: %s\n" "$ARG_USE_BOUNDARY_DIRECTLY" -printf "ARG_CODER_HOST: %s\n" "$ARG_CODER_HOST" - -echo "--------------------------------" - -function install_boundary() { - if [ "$ARG_COMPILE_FROM_SOURCE" = "true" ]; then - # Install boundary by compiling from source - echo "Compiling boundary from source (version: $ARG_BOUNDARY_VERSION)" - - echo "Removing existing boundary directory to allow re-running the script safely" - if [ -d boundary ]; then - rm -rf boundary - fi - - echo "Clone boundary repository" - git clone https://github.com/coder/boundary.git - cd boundary - git checkout "$ARG_BOUNDARY_VERSION" - - # Build the binary - make build - - # Install binary - sudo cp boundary /usr/local/bin/ - sudo chmod +x /usr/local/bin/boundary - elif [ "$ARG_USE_BOUNDARY_DIRECTLY" = "true" ]; then - # Install boundary using official install script - echo "Installing boundary using official install script (version: $ARG_BOUNDARY_VERSION)" - curl -fsSL https://raw.githubusercontent.com/coder/boundary/main/install.sh | bash -s -- --version "$ARG_BOUNDARY_VERSION" - else - # Use coder boundary subcommand (default) - no installation needed - echo "Using coder boundary subcommand (provided by Coder)" - fi -} - -function validate_claude_installation() { - if command_exists claude; then - printf "Claude Code is installed\n" - else - printf "Error: Claude Code is not installed. Please enable install_claude_code or install it manually\n" - exit 1 - fi -} - -# Hardcoded task session ID for Coder task reporting -# This ensures all task sessions use a consistent, predictable ID -TASK_SESSION_ID="cd32e253-ca16-4fd3-9825-d837e74ae3c2" - -get_project_dir() { - local workdir_normalized - workdir_normalized=$(echo "$ARG_WORKDIR" | tr '/._' '-') - echo "$HOME/.claude/projects/${workdir_normalized}" -} - -get_task_session_file() { - echo "$(get_project_dir)/${TASK_SESSION_ID}.jsonl" -} - -task_session_exists() { - local session_file - session_file=$(get_task_session_file) - - if [ -f "$session_file" ]; then - printf "Task session file found: %s\n" "$session_file" - return 0 - else - printf "Task session file not found: %s\n" "$session_file" - return 1 - fi -} - -is_valid_session() { - local session_file="$1" - - # Check if file exists and is not empty - # Empty files indicate the session was created but never used so they need to be removed - if [ ! -f "$session_file" ]; then - printf "Session validation failed: file does not exist\n" - return 1 - fi - - if [ ! -s "$session_file" ]; then - printf "Session validation failed: file is empty, removing stale file\n" - rm -f "$session_file" - return 1 - fi - - # Check for minimum session content - # Valid sessions need at least 2 lines: initial message and first response - local line_count - line_count=$(wc -l < "$session_file") - if [ "$line_count" -lt 2 ]; then - printf "Session validation failed: incomplete (only %s lines), removing incomplete file\n" "$line_count" - rm -f "$session_file" - return 1 - fi - - # Validate JSONL format by checking first 3 lines - # Claude session files use JSONL (JSON Lines) format where each line is valid JSON - if ! head -3 "$session_file" | jq empty 2> /dev/null; then - printf "Session validation failed: invalid JSONL format, removing corrupt file\n" - rm -f "$session_file" - return 1 - fi - - # Verify the session has a valid sessionId field - # This ensures the file structure matches Claude's session format - if ! grep -q '"sessionId"' "$session_file" \ - || ! grep -m 1 '"sessionId"' "$session_file" | jq -e '.sessionId' > /dev/null 2>&1; then - printf "Session validation failed: no valid sessionId found, removing malformed file\n" - rm -f "$session_file" - return 1 - fi - - printf "Session validation passed: %s\n" "$session_file" - return 0 -} - -has_any_sessions() { - local project_dir - project_dir=$(get_project_dir) - - if [ -d "$project_dir" ] && find "$project_dir" -maxdepth 1 -name "*.jsonl" -size +0c 2> /dev/null | grep -q .; then - printf "Sessions found in: %s\n" "$project_dir" - return 0 - else - printf "No sessions found in: %s\n" "$project_dir" - return 1 - fi -} - -ARGS=() - -function start_agentapi() { - # For Task reporting - export CODER_MCP_ALLOWED_TOOLS="coder_report_task" - - mkdir -p "$ARG_WORKDIR" - cd "$ARG_WORKDIR" - - if [ -n "$ARG_PERMISSION_MODE" ]; then - ARGS+=(--permission-mode "$ARG_PERMISSION_MODE") - fi - - if [ -n "$ARG_RESUME_SESSION_ID" ]; then - echo "Resuming specified session: $ARG_RESUME_SESSION_ID" - ARGS+=(--resume "$ARG_RESUME_SESSION_ID") - [ "$ARG_DANGEROUSLY_SKIP_PERMISSIONS" = "true" ] && ARGS+=(--dangerously-skip-permissions) - - elif [ "$ARG_CONTINUE" = "true" ]; then - - if [ "$ARG_REPORT_TASKS" = "true" ]; then - local session_file - session_file=$(get_task_session_file) - - if task_session_exists && is_valid_session "$session_file"; then - echo "Resuming task session: $TASK_SESSION_ID" - ARGS+=(--resume "$TASK_SESSION_ID" --dangerously-skip-permissions) - else - echo "Starting new task session: $TASK_SESSION_ID" - ARGS+=(--session-id "$TASK_SESSION_ID" --dangerously-skip-permissions) - [ -n "$ARG_AI_PROMPT" ] && ARGS+=(-- "$ARG_AI_PROMPT") - fi - - else - if has_any_sessions; then - echo "Continuing most recent standalone session" - ARGS+=(--continue) - [ "$ARG_DANGEROUSLY_SKIP_PERMISSIONS" = "true" ] && ARGS+=(--dangerously-skip-permissions) - else - echo "No sessions found, starting fresh standalone session" - [ "$ARG_DANGEROUSLY_SKIP_PERMISSIONS" = "true" ] && ARGS+=(--dangerously-skip-permissions) - [ -n "$ARG_AI_PROMPT" ] && ARGS+=(-- "$ARG_AI_PROMPT") - fi - fi - - else - echo "Continue disabled, starting fresh session" - [ "$ARG_DANGEROUSLY_SKIP_PERMISSIONS" = "true" ] && ARGS+=(--dangerously-skip-permissions) - [ -n "$ARG_AI_PROMPT" ] && ARGS+=(-- "$ARG_AI_PROMPT") - fi - - printf "Running claude code with args: %s\n" "$(printf '%q ' "${ARGS[@]}")" - - if [ "$ARG_ENABLE_BOUNDARY" = "true" ]; then - install_boundary - - printf "Starting with coder boundary enabled\n" - - BOUNDARY_ARGS+=() - - # Determine which boundary command to use - if [ "$ARG_COMPILE_FROM_SOURCE" = "true" ] || [ "$ARG_USE_BOUNDARY_DIRECTLY" = "true" ]; then - # Use boundary binary directly (from compilation or release installation) - BOUNDARY_CMD=("boundary") - else - # Use coder boundary subcommand (default) - # Copy coder binary to coder-no-caps. Copying strips CAP_NET_ADMIN capabilities - # from the binary, which is necessary because boundary doesn't work with - # privileged binaries (you can't launch privileged binaries inside network - # namespaces unless you have sys_admin). - CODER_NO_CAPS="$(dirname "$(which coder)")/coder-no-caps" - cp "$(which coder)" "$CODER_NO_CAPS" - BOUNDARY_CMD=("$CODER_NO_CAPS" "boundary") - fi - - agentapi server --type claude --term-width 67 --term-height 1190 -- \ - "${BOUNDARY_CMD[@]}" "${BOUNDARY_ARGS[@]}" -- \ - claude "${ARGS[@]}" - else - agentapi server --type claude --term-width 67 --term-height 1190 -- claude "${ARGS[@]}" - fi -} - -validate_claude_installation -start_agentapi diff --git a/registry/coder/modules/claude-code/testdata/claude-mock.sh b/registry/coder/modules/claude-code/testdata/claude-mock.sh index b437b4d30..96969dcc3 100644 --- a/registry/coder/modules/claude-code/testdata/claude-mock.sh +++ b/registry/coder/modules/claude-code/testdata/claude-mock.sh @@ -1,13 +1,37 @@ #!/bin/bash +# Mock of the claude CLI used in bun tests. Needs to cover: +# claude --version +# claude mcp add-json --scope user +# Other invocations are ignored (no-op, exit 0). + if [[ "$1" == "--version" ]]; then echo "claude version v1.0.0" exit 0 fi -set -e +if [[ "$1" == "mcp" && "$2" == "add-json" ]]; then + # Expected argv: mcp add-json --scope user + # Echo the server name so tests can grep for it in install.log. + name="" + for ((i = 3; i <= $#; i++)); do + arg="${!i}" + if [[ "$arg" == --* ]]; then + continue + fi + if [[ "$arg" == "user" || "$arg" == "project" || "$arg" == "local" ]]; then + continue + fi + name="$arg" + break + done + if [[ -n "$name" ]]; then + echo "mock: added MCP server '$name' at user scope" + fi + exit 0 +fi -while true; do - echo "$(date) - claude-mock" - sleep 15 -done +# Fallback: stay alive so any orchestration that spawns claude doesn't exit +# the test container prematurely. Tests that need a quick claude return use +# --version above. +exit 0 diff --git a/registry/coder/modules/claude-code/testdata/coder-mock.sh b/registry/coder/modules/claude-code/testdata/coder-mock.sh new file mode 100644 index 000000000..74b380911 --- /dev/null +++ b/registry/coder/modules/claude-code/testdata/coder-mock.sh @@ -0,0 +1,15 @@ +#!/bin/bash + +# Mock of the coder workspace CLI used in bun tests. +# +# The coder-utils module wraps scripts in `coder exp sync` calls for +# dependency ordering. Tests run inside a minimal container that does +# not ship a real coder binary, so this mock acknowledges the sync calls +# and exits 0. + +if [[ "$1" == "exp" && "$2" == "sync" ]]; then + exit 0 +fi + +# Fallback: unknown invocation, no-op. +exit 0 From 2c69ab902e3fce67e734c687dd724d24e6dd6c13 Mon Sep 17 00:00:00 2001 From: Atif Ali Date: Wed, 22 Apr 2026 05:50:35 +0000 Subject: [PATCH 02/23] refactor(claude-code): drop ai bridge, claude_md_path, and opinionated wiring Follow-up to the v5 cleanup. The module now only touches variables and env vars that Claude Code reads natively. Everything Coder-specific is gone. Removed: - enable_aibridge variable and the ANTHROPIC_AUTH_TOKEN / ANTHROPIC_BASE_URL coder_env resources. Template authors who want AI Bridge, Bedrock, Vertex, or any other custom endpoint set ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN themselves via coder_env, exactly as the Claude Code docs describe. - claude_md_path variable and the CODER_MCP_CLAUDE_MD_PATH coder_env. That env var was only consumed by 'coder exp mcp configure claude-code' in Tasks mode. Claude Code itself discovers ~/.claude/CLAUDE.md from user scope automatically. - Associated tftest.hcl cases and the aibridge-env-vars bun test. Also fix a bug in the bun test runModuleScripts helper that produced a malformed bash command when the env map was an empty object. --- registry/coder/modules/claude-code/README.md | 77 +++++------- .../coder/modules/claude-code/main.test.ts | 21 +--- registry/coder/modules/claude-code/main.tf | 47 ------- .../coder/modules/claude-code/main.tftest.hcl | 119 ------------------ 4 files changed, 33 insertions(+), 231 deletions(-) diff --git a/registry/coder/modules/claude-code/README.md b/registry/coder/modules/claude-code/README.md index 00e56dfe4..6f4de8fe6 100644 --- a/registry/coder/modules/claude-code/README.md +++ b/registry/coder/modules/claude-code/README.md @@ -3,7 +3,7 @@ display_name: Claude Code description: Install and configure the Claude Code CLI in your workspace. icon: ../../../../.icons/claude.svg verified: true -tags: [agent, claude-code, ai, anthropic, aibridge] +tags: [agent, claude-code, ai, anthropic] --- # Claude Code @@ -13,10 +13,10 @@ Install and configure the [Claude Code](https://docs.anthropic.com/en/docs/claud This module does three things: 1. Installs Claude Code via the [official installer](https://claude.ai/install.sh). -2. Wires up authentication through environment variables. +2. Wires up authentication through the environment variables Claude Code reads natively. 3. Optionally applies user-scope MCP server configuration. -It does not start Claude, create a web app, or orchestrate Tasks. For those, see the dedicated `claude-code-tasks`, `agentapi`, and `boundary` modules. +It does not start Claude, create a web app, or orchestrate Tasks. Compose with dedicated modules for those concerns. ```tf module "claude-code" { @@ -33,7 +33,6 @@ Choose one of: - `anthropic_api_key`: Anthropic API key. Sets `ANTHROPIC_API_KEY`. - `claude_code_oauth_token`: Long-lived Claude.ai subscription token (generate with `claude setup-token`). Sets `CLAUDE_CODE_OAUTH_TOKEN`. -- `enable_aibridge = true`: Routes through Coder [AI Bridge](https://coder.com/docs/ai-coder/ai-bridge). Sets `ANTHROPIC_AUTH_TOKEN` (workspace owner session token) and `ANTHROPIC_BASE_URL`. Cannot combine with an API key or OAuth token. ```tf # Claude.ai subscription @@ -43,16 +42,33 @@ module "claude-code" { agent_id = coder_agent.main.id claude_code_oauth_token = var.claude_code_oauth_token } +``` + +For custom endpoints (AI Bridge, Bedrock, Vertex AI, LiteLLM, a private gateway), set the env vars Claude Code reads directly via `coder_env`: + +```tf +# Example: route through a custom Anthropic-compatible proxy. +resource "coder_env" "anthropic_base_url" { + agent_id = coder_agent.main.id + name = "ANTHROPIC_BASE_URL" + value = "https://proxy.example.com/anthropic" +} + +resource "coder_env" "anthropic_auth_token" { + agent_id = coder_agent.main.id + name = "ANTHROPIC_AUTH_TOKEN" + value = var.proxy_token +} -# AI Bridge (Premium, requires Coder >= 2.29.0) module "claude-code" { - source = "registry.coder.com/coder/claude-code/coder" - version = "5.0.0" - agent_id = coder_agent.main.id - enable_aibridge = true + source = "registry.coder.com/coder/claude-code/coder" + version = "5.0.0" + agent_id = coder_agent.main.id } ``` +See Claude Code's [environment variables reference](https://docs.claude.com/en/docs/claude-code/env-vars) for the full list (`ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN`, `CLAUDE_CODE_USE_BEDROCK`, `CLAUDE_CODE_USE_VERTEX`, `ANTHROPIC_VERTEX_PROJECT_ID`, etc.). + ## MCP configuration MCP servers are applied at **user scope** via `claude mcp add-json --scope user`. They end up in `~/.claude.json` and apply across every project the user opens. @@ -144,39 +160,6 @@ module "claude-code" { } ``` -## Using AWS Bedrock or Google Vertex - -The module does not own Bedrock/Vertex env vars; set them yourself with `coder_env` resources. - -```tf -resource "coder_env" "bedrock_use" { - agent_id = coder_agent.main.id - name = "CLAUDE_CODE_USE_BEDROCK" - value = "1" -} - -resource "coder_env" "aws_region" { - agent_id = coder_agent.main.id - name = "AWS_REGION" - value = "us-east-1" -} - -resource "coder_env" "aws_bearer_token_bedrock" { - agent_id = coder_agent.main.id - name = "AWS_BEARER_TOKEN_BEDROCK" - value = var.aws_bearer_token_bedrock -} - -module "claude-code" { - source = "registry.coder.com/coder/claude-code/coder" - version = "5.0.0" - agent_id = coder_agent.main.id - model = "global.anthropic.claude-sonnet-4-5-20250929-v1:0" -} -``` - -See the [Bedrock](https://docs.claude.com/en/docs/claude-code/amazon-bedrock) and [Vertex AI](https://docs.claude.com/en/docs/claude-code/google-vertex-ai) pages for additional env var options. - ## Troubleshooting Module logs live at `$HOME/.claude-module/`: @@ -191,10 +174,10 @@ cat $HOME/.claude-module/post_install.log Breaking changes in v5.0.0: -- `claude_api_key` renamed to `anthropic_api_key` and emits `ANTHROPIC_API_KEY` (not `CLAUDE_API_KEY`). This matches Claude Code's documented variable. -- All Tasks, AgentAPI, Boundary, and web-app variables removed. See the dedicated modules. -- `workdir` removed. MCP applies at user scope. Project-specific config belongs in the repo. +- `claude_api_key` renamed to `anthropic_api_key`. Now sets `ANTHROPIC_API_KEY` (the variable Claude Code actually reads), not `CLAUDE_API_KEY`. +- All Tasks, AgentAPI, Boundary, AI Bridge, and web-app variables removed. Compose dedicated modules or set env vars via `coder_env`. +- `workdir` removed. MCP applies at user scope. +- `claude_md_path` removed. Claude Code discovers `~/.claude/CLAUDE.md` automatically. - `install_via_npm` removed. Official installer only. - `allowed_tools` / `disallowed_tools` removed. Write `~/.claude/settings.json` via `pre_install_script` with `permissions.allow` / `permissions.deny` arrays. -- `task_app_id` output removed. Read it from the Tasks module. -- AI Bridge now uses `ANTHROPIC_AUTH_TOKEN` instead of `CLAUDE_API_KEY`. +- `task_app_id` output removed. diff --git a/registry/coder/modules/claude-code/main.test.ts b/registry/coder/modules/claude-code/main.test.ts index 383bfb110..44b9d4562 100644 --- a/registry/coder/modules/claude-code/main.test.ts +++ b/registry/coder/modules/claude-code/main.test.ts @@ -126,8 +126,9 @@ const setup = async ( }; const runModuleScripts = async (id: string, env?: Record) => { - const envArgs = env - ? Object.entries(env) + const entries = env ? Object.entries(env) : []; + const envArgs = entries.length + ? entries .map(([key, value]) => `export ${key}="${value.replace(/"/g, '\\"')}"`) .join(" && ") + " && " : ""; @@ -217,22 +218,6 @@ describe("claude-code", async () => { expect(coderEnvVars["CLAUDE_CODE_OAUTH_TOKEN"]).toBe(token); }); - test("aibridge-env-vars", async () => { - // In the test env data.coder_workspace_owner.me.session_token is empty, - // so ANTHROPIC_AUTH_TOKEN is emitted with an empty value (filtered out by - // extractCoderEnvVars). Verify ANTHROPIC_BASE_URL and confirm - // ANTHROPIC_API_KEY is absent. - const { coderEnvVars } = await setup({ - moduleVariables: { - enable_aibridge: "true", - }, - }); - expect(coderEnvVars["ANTHROPIC_BASE_URL"]).toContain( - "/api/v2/aibridge/anthropic", - ); - expect(coderEnvVars["ANTHROPIC_API_KEY"]).toBeUndefined(); - }); - test("claude-model", async () => { const model = "opus"; const { coderEnvVars } = await setup({ diff --git a/registry/coder/modules/claude-code/main.tf b/registry/coder/modules/claude-code/main.tf index a335fdb85..ce3b3885d 100644 --- a/registry/coder/modules/claude-code/main.tf +++ b/registry/coder/modules/claude-code/main.tf @@ -14,10 +14,6 @@ variable "agent_id" { description = "The ID of a Coder agent." } -data "coder_workspace" "me" {} - -data "coder_workspace_owner" "me" {} - variable "anthropic_api_key" { type = string description = "Anthropic API key. Exported as ANTHROPIC_API_KEY." @@ -67,12 +63,6 @@ variable "model" { default = "" } -variable "claude_md_path" { - type = string - description = "Path to a global CLAUDE.md. Exported as CODER_MCP_CLAUDE_MD_PATH." - default = "$HOME/.claude/CLAUDE.md" -} - variable "mcp" { type = string description = "Inline MCP JSON (format: {\"mcpServers\": {\"name\": {...}}}). Applied at user scope with `claude mcp add-json --scope user`." @@ -85,22 +75,6 @@ variable "mcp_config_remote_path" { default = [] } -variable "enable_aibridge" { - type = bool - description = "Route Claude Code through Coder AI Bridge. Sets ANTHROPIC_AUTH_TOKEN and ANTHROPIC_BASE_URL. See https://coder.com/docs/ai-coder/ai-bridge." - default = false - - validation { - condition = !(var.enable_aibridge && length(var.anthropic_api_key) > 0) - error_message = "anthropic_api_key cannot be provided when enable_aibridge is true. AI Bridge authenticates using Coder credentials." - } - - validation { - condition = !(var.enable_aibridge && length(var.claude_code_oauth_token) > 0) - error_message = "claude_code_oauth_token cannot be provided when enable_aibridge is true. AI Bridge authenticates using Coder credentials." - } -} - variable "pre_install_script" { type = string description = "Custom script to run before installing Claude Code." @@ -120,20 +94,6 @@ resource "coder_env" "anthropic_api_key" { value = var.anthropic_api_key } -resource "coder_env" "anthropic_auth_token" { - count = var.enable_aibridge ? 1 : 0 - agent_id = var.agent_id - name = "ANTHROPIC_AUTH_TOKEN" - value = data.coder_workspace_owner.me.session_token -} - -resource "coder_env" "anthropic_base_url" { - count = var.enable_aibridge ? 1 : 0 - agent_id = var.agent_id - name = "ANTHROPIC_BASE_URL" - value = "${data.coder_workspace.me.access_url}/api/v2/aibridge/anthropic" -} - resource "coder_env" "claude_code_oauth_token" { count = var.claude_code_oauth_token != "" ? 1 : 0 agent_id = var.agent_id @@ -155,13 +115,6 @@ resource "coder_env" "disable_autoupdater" { value = "1" } -resource "coder_env" "claude_code_md_path" { - count = var.claude_md_path != "" ? 1 : 0 - agent_id = var.agent_id - name = "CODER_MCP_CLAUDE_MD_PATH" - value = var.claude_md_path -} - locals { install_script = file("${path.module}/scripts/install.sh") } diff --git a/registry/coder/modules/claude-code/main.tftest.hcl b/registry/coder/modules/claude-code/main.tftest.hcl index c4729df4c..67a72589a 100644 --- a/registry/coder/modules/claude-code/main.tftest.hcl +++ b/registry/coder/modules/claude-code/main.tftest.hcl @@ -17,26 +17,11 @@ run "test_defaults" { error_message = "disable_autoupdater should default to false" } - assert { - condition = var.enable_aibridge == false - error_message = "enable_aibridge should default to false" - } - assert { condition = length(coder_env.anthropic_api_key) == 0 error_message = "ANTHROPIC_API_KEY should not be set by default" } - assert { - condition = length(coder_env.anthropic_auth_token) == 0 - error_message = "ANTHROPIC_AUTH_TOKEN should not be set by default" - } - - assert { - condition = length(coder_env.anthropic_base_url) == 0 - error_message = "ANTHROPIC_BASE_URL should not be set by default" - } - assert { condition = length(coder_env.anthropic_model) == 0 error_message = "ANTHROPIC_MODEL should not be set by default" @@ -70,16 +55,6 @@ run "test_with_anthropic_api_key" { condition = coder_env.anthropic_api_key[0].value == "sk-live-test" error_message = "ANTHROPIC_API_KEY value should match input" } - - assert { - condition = length(coder_env.anthropic_auth_token) == 0 - error_message = "ANTHROPIC_AUTH_TOKEN should not be set when only anthropic_api_key is provided" - } - - assert { - condition = length(coder_env.anthropic_base_url) == 0 - error_message = "ANTHROPIC_BASE_URL should not be set when AI Bridge is disabled" - } } run "test_with_oauth_token" { @@ -129,33 +104,6 @@ run "test_with_disable_autoupdater" { } } -run "test_with_claude_md_path_default" { - command = plan - - variables { - agent_id = "test-agent" - } - - assert { - condition = coder_env.claude_code_md_path[0].value == "$HOME/.claude/CLAUDE.md" - error_message = "CODER_MCP_CLAUDE_MD_PATH should default to $HOME/.claude/CLAUDE.md" - } -} - -run "test_with_claude_md_path_empty" { - command = plan - - variables { - agent_id = "test-agent" - claude_md_path = "" - } - - assert { - condition = length(coder_env.claude_code_md_path) == 0 - error_message = "CODER_MCP_CLAUDE_MD_PATH should not be set when claude_md_path is empty" - } -} - run "test_with_mcp_inline" { command = plan @@ -211,73 +159,6 @@ run "test_with_pre_post_install" { } } -run "test_with_aibridge" { - command = plan - - variables { - agent_id = "test-agent" - enable_aibridge = true - } - - override_data { - target = data.coder_workspace.me - values = { - access_url = "https://coder.test" - } - } - - override_data { - target = data.coder_workspace_owner.me - values = { - session_token = "session-token-mock" - } - } - - assert { - condition = coder_env.anthropic_auth_token[0].name == "ANTHROPIC_AUTH_TOKEN" - error_message = "AI Bridge should set ANTHROPIC_AUTH_TOKEN" - } - - assert { - condition = coder_env.anthropic_auth_token[0].value == "session-token-mock" - error_message = "ANTHROPIC_AUTH_TOKEN should use the workspace owner session token" - } - - assert { - condition = coder_env.anthropic_base_url[0].value == "https://coder.test/api/v2/aibridge/anthropic" - error_message = "ANTHROPIC_BASE_URL should be built from the workspace access URL" - } - - assert { - condition = length(coder_env.anthropic_api_key) == 0 - error_message = "ANTHROPIC_API_KEY must not be set when AI Bridge is enabled" - } -} - -run "test_aibridge_api_key_conflict" { - command = plan - - variables { - agent_id = "test-agent" - enable_aibridge = true - anthropic_api_key = "sk-live" - } - - expect_failures = [var.enable_aibridge] -} - -run "test_aibridge_oauth_conflict" { - command = plan - - variables { - agent_id = "test-agent" - enable_aibridge = true - claude_code_oauth_token = "oauth-live" - } - - expect_failures = [var.enable_aibridge] -} - run "test_claude_binary_path_validation" { command = plan From 430fbdcc4c0ed820378e031335de8bce43daa8f5 Mon Sep 17 00:00:00 2001 From: Atif Ali Date: Wed, 22 Apr 2026 05:58:21 +0000 Subject: [PATCH 03/23] feat(claude-code): add `env` map for arbitrary env vars Expose a generic `env = { KEY = VALUE }` map that fans out via `for_each` into one `coder_env` resource per entry. Template authors can set any Claude Code env var (ANTHROPIC_BASE_URL, ANTHROPIC_MODEL, DISABLE_AUTOUPDATER, CLAUDE_CODE_USE_BEDROCK, etc.) or any custom var their pre/post scripts consume, without the module needing to know about each one. Remove the now-redundant `model`, `disable_autoupdater`, and `claude_md_path` variables. `claude_md_path` was a Coder-specific holdover; the other two have canonical env vars users can set through `env` directly. The module no longer invents names it doesn't need to. `anthropic_api_key` and `claude_code_oauth_token` stay as dedicated sensitive variables (sensitive values can't be used as `for_each` keys), and `env` rejects those two keys via validation to avoid double-resource collisions. Add a local extractCoderEnvVars helper in the bun tests that iterates every instance of every coder_env resource. The upstream helper in agentapi/test-util only reads instances[0], which misses every for_each entry past the first. --- registry/coder/modules/claude-code/README.md | 50 +++++--- .../coder/modules/claude-code/main.test.ts | 39 +++++-- registry/coder/modules/claude-code/main.tf | 65 ++++++----- .../coder/modules/claude-code/main.tftest.hcl | 109 ++++++++++++++---- 4 files changed, 179 insertions(+), 84 deletions(-) diff --git a/registry/coder/modules/claude-code/README.md b/registry/coder/modules/claude-code/README.md index 6f4de8fe6..105807cd8 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/claud This module does three things: 1. Installs Claude Code via the [official installer](https://claude.ai/install.sh). -2. Wires up authentication through the environment variables Claude Code reads natively. +2. Exports environment variables to the Coder agent. 3. Optionally applies user-scope MCP server configuration. It does not start Claude, create a web app, or orchestrate Tasks. Compose with dedicated modules for those concerns. @@ -29,10 +29,10 @@ module "claude-code" { ## Authentication -Choose one of: +Two sensitive shortcuts are provided as dedicated variables. Every other env var goes through the `env` map. -- `anthropic_api_key`: Anthropic API key. Sets `ANTHROPIC_API_KEY`. -- `claude_code_oauth_token`: Long-lived Claude.ai subscription token (generate with `claude setup-token`). Sets `CLAUDE_CODE_OAUTH_TOKEN`. +- `anthropic_api_key`: sets `ANTHROPIC_API_KEY`. Marked sensitive. +- `claude_code_oauth_token`: sets `CLAUDE_CODE_OAUTH_TOKEN` (generate with `claude setup-token`). Marked sensitive. ```tf # Claude.ai subscription @@ -44,30 +44,44 @@ module "claude-code" { } ``` -For custom endpoints (AI Bridge, Bedrock, Vertex AI, LiteLLM, a private gateway), set the env vars Claude Code reads directly via `coder_env`: +## Arbitrary environment variables (`env`) + +Pass any Claude Code env var (or any custom var your pre/post scripts consume) through the `env` map. Each key/value pair becomes one `coder_env` resource on the agent. ```tf -# Example: route through a custom Anthropic-compatible proxy. -resource "coder_env" "anthropic_base_url" { - agent_id = coder_agent.main.id - name = "ANTHROPIC_BASE_URL" - value = "https://proxy.example.com/anthropic" -} +module "claude-code" { + source = "registry.coder.com/coder/claude-code/coder" + version = "5.0.0" + agent_id = coder_agent.main.id + anthropic_api_key = var.anthropic_api_key -resource "coder_env" "anthropic_auth_token" { - agent_id = coder_agent.main.id - name = "ANTHROPIC_AUTH_TOKEN" - value = var.proxy_token + env = { + ANTHROPIC_MODEL = "opus" + DISABLE_AUTOUPDATER = "1" + MY_CUSTOM_VAR = "hello" + } } +``` + +### Using a custom endpoint (AI Bridge, Bedrock, Vertex, LiteLLM, a private proxy) +Set the endpoint and token through `env`. Nothing is baked in; the [Claude Code env-vars reference](https://docs.claude.com/en/docs/claude-code/env-vars) lists every supported name. + +```tf module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" version = "5.0.0" agent_id = coder_agent.main.id + + env = { + ANTHROPIC_BASE_URL = "https://proxy.example.com/anthropic" + ANTHROPIC_AUTH_TOKEN = var.proxy_token + } } ``` -See Claude Code's [environment variables reference](https://docs.claude.com/en/docs/claude-code/env-vars) for the full list (`ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN`, `CLAUDE_CODE_USE_BEDROCK`, `CLAUDE_CODE_USE_VERTEX`, `ANTHROPIC_VERTEX_PROJECT_ID`, etc.). +> [!NOTE] +> `ANTHROPIC_API_KEY` and `CLAUDE_CODE_OAUTH_TOKEN` are rejected in `env` because they have dedicated sensitive variables (`anthropic_api_key`, `claude_code_oauth_token`). Every other env var is allowed. ## MCP configuration @@ -175,9 +189,9 @@ cat $HOME/.claude-module/post_install.log Breaking changes in v5.0.0: - `claude_api_key` renamed to `anthropic_api_key`. Now sets `ANTHROPIC_API_KEY` (the variable Claude Code actually reads), not `CLAUDE_API_KEY`. -- All Tasks, AgentAPI, Boundary, AI Bridge, and web-app variables removed. Compose dedicated modules or set env vars via `coder_env`. +- All Tasks, AgentAPI, Boundary, AI Bridge, and web-app variables removed. Compose dedicated modules or set env vars via the `env` map. +- `model`, `disable_autoupdater`, and `claude_md_path` variables removed. Set `ANTHROPIC_MODEL` and `DISABLE_AUTOUPDATER` via `env`. Claude Code discovers `~/.claude/CLAUDE.md` automatically. - `workdir` removed. MCP applies at user scope. -- `claude_md_path` removed. Claude Code discovers `~/.claude/CLAUDE.md` automatically. - `install_via_npm` removed. Official installer only. - `allowed_tools` / `disallowed_tools` removed. Write `~/.claude/settings.json` via `pre_install_script` with `permissions.allow` / `permissions.deny` arrays. - `task_app_id` output removed. diff --git a/registry/coder/modules/claude-code/main.test.ts b/registry/coder/modules/claude-code/main.test.ts index 44b9d4562..9607ef905 100644 --- a/registry/coder/modules/claude-code/main.test.ts +++ b/registry/coder/modules/claude-code/main.test.ts @@ -15,11 +15,27 @@ import { removeContainer, type TerraformState, } from "~test"; -import { - loadTestFile, - writeExecutable, - extractCoderEnvVars, -} from "../agentapi/test-util"; +import { loadTestFile, writeExecutable } from "../agentapi/test-util"; +import type { TerraformState } from "~test"; + +/** + * Walk every instance of every coder_env resource and return a flat map of + * env var names to values. The upstream extractCoderEnvVars helper only + * reads `instances[0]`, which misses every for_each entry past the first. + */ +const extractCoderEnvVars = (state: TerraformState): Record => { + const envVars: Record = {}; + for (const resource of state.resources) { + if (resource.type !== "coder_env") continue; + for (const instance of resource.instances) { + const attrs = instance.attributes as Record; + const name = attrs.name as string; + const value = attrs.value as string; + if (name && value) envVars[name] = value; + } + } + return envVars; +}; let cleanupFunctions: (() => Promise)[] = []; const registerCleanup = (cleanup: () => Promise) => { @@ -218,14 +234,19 @@ describe("claude-code", async () => { expect(coderEnvVars["CLAUDE_CODE_OAUTH_TOKEN"]).toBe(token); }); - test("claude-model", async () => { - const model = "opus"; + test("env-map-passthrough", async () => { const { coderEnvVars } = await setup({ moduleVariables: { - model: model, + env: JSON.stringify({ + ANTHROPIC_MODEL: "opus", + DISABLE_AUTOUPDATER: "1", + CUSTOM_VAR: "hello", + }), }, }); - expect(coderEnvVars["ANTHROPIC_MODEL"]).toBe(model); + expect(coderEnvVars["ANTHROPIC_MODEL"]).toBe("opus"); + expect(coderEnvVars["DISABLE_AUTOUPDATER"]).toBe("1"); + expect(coderEnvVars["CUSTOM_VAR"]).toBe("hello"); }); test("claude-mcp-inline-user-scope", async () => { diff --git a/registry/coder/modules/claude-code/main.tf b/registry/coder/modules/claude-code/main.tf index ce3b3885d..5d753ffdc 100644 --- a/registry/coder/modules/claude-code/main.tf +++ b/registry/coder/modules/claude-code/main.tf @@ -16,18 +16,34 @@ variable "agent_id" { variable "anthropic_api_key" { type = string - description = "Anthropic API key. Exported as ANTHROPIC_API_KEY." + description = "Convenience shortcut for setting ANTHROPIC_API_KEY. Equivalent to adding ANTHROPIC_API_KEY to `env`." default = "" sensitive = true } variable "claude_code_oauth_token" { type = string - description = "Long-lived Claude.ai subscription token. Generate with `claude setup-token`. Exported as CLAUDE_CODE_OAUTH_TOKEN." + description = "Convenience shortcut for setting CLAUDE_CODE_OAUTH_TOKEN. Generate with `claude setup-token`. Equivalent to adding CLAUDE_CODE_OAUTH_TOKEN to `env`." default = "" sensitive = true } +variable "env" { + type = map(string) + description = "Arbitrary environment variables to export to the Coder agent. Each key/value pair becomes a `coder_env` resource. Use this for any Claude Code env var (ANTHROPIC_BASE_URL, ANTHROPIC_AUTH_TOKEN, ANTHROPIC_MODEL, CLAUDE_CODE_USE_BEDROCK, etc.) or for custom vars your pre/post scripts consume." + default = {} + + validation { + condition = !contains(keys(var.env), "ANTHROPIC_API_KEY") + error_message = "Use the `anthropic_api_key` variable instead of setting ANTHROPIC_API_KEY via `env`. It is marked sensitive and handled as a dedicated resource." + } + + validation { + condition = !contains(keys(var.env), "CLAUDE_CODE_OAUTH_TOKEN") + error_message = "Use the `claude_code_oauth_token` variable instead of setting CLAUDE_CODE_OAUTH_TOKEN via `env`. It is marked sensitive and handled as a dedicated resource." + } +} + variable "claude_code_version" { type = string description = "The version of Claude Code to install. Forwarded to the official installer." @@ -51,18 +67,6 @@ variable "claude_binary_path" { } } -variable "disable_autoupdater" { - type = bool - description = "Disable Claude Code automatic updates. Sets DISABLE_AUTOUPDATER=1." - default = false -} - -variable "model" { - type = string - description = "Default model for Claude Code. Exported as ANTHROPIC_MODEL. Supports aliases (sonnet, opus) or full model names." - default = "" -} - variable "mcp" { type = string description = "Inline MCP JSON (format: {\"mcpServers\": {\"name\": {...}}}). Applied at user scope with `claude mcp add-json --scope user`." @@ -87,6 +91,21 @@ variable "post_install_script" { default = null } +locals { + # `env` fans out to one `coder_env` per entry via for_each. Sensitive + # shortcuts (anthropic_api_key, claude_code_oauth_token) can't be merged in + # because Terraform forbids sensitive values as for_each keys. They're + # emitted as dedicated resources below instead. + install_script = file("${path.module}/scripts/install.sh") +} + +resource "coder_env" "env" { + for_each = var.env + agent_id = var.agent_id + name = each.key + value = each.value +} + resource "coder_env" "anthropic_api_key" { count = var.anthropic_api_key != "" ? 1 : 0 agent_id = var.agent_id @@ -101,24 +120,6 @@ resource "coder_env" "claude_code_oauth_token" { value = var.claude_code_oauth_token } -resource "coder_env" "anthropic_model" { - count = var.model != "" ? 1 : 0 - agent_id = var.agent_id - name = "ANTHROPIC_MODEL" - value = var.model -} - -resource "coder_env" "disable_autoupdater" { - count = var.disable_autoupdater ? 1 : 0 - agent_id = var.agent_id - name = "DISABLE_AUTOUPDATER" - value = "1" -} - -locals { - install_script = file("${path.module}/scripts/install.sh") -} - module "coder-utils" { # Pinned to PR #842 branch on coder/registry until coder-utils@1.1.0 is published. source = "git::https://github.com/coder/registry.git//registry/coder/modules/coder-utils?ref=feat/coder-utils-optional-install-start" diff --git a/registry/coder/modules/claude-code/main.tftest.hcl b/registry/coder/modules/claude-code/main.tftest.hcl index 67a72589a..a36ae6c92 100644 --- a/registry/coder/modules/claude-code/main.tftest.hcl +++ b/registry/coder/modules/claude-code/main.tftest.hcl @@ -13,8 +13,8 @@ run "test_defaults" { } assert { - condition = var.disable_autoupdater == false - error_message = "disable_autoupdater should default to false" + condition = length(coder_env.env) == 0 + error_message = "No env vars should be set by default" } assert { @@ -22,16 +22,6 @@ run "test_defaults" { error_message = "ANTHROPIC_API_KEY should not be set by default" } - assert { - condition = length(coder_env.anthropic_model) == 0 - error_message = "ANTHROPIC_MODEL should not be set by default" - } - - assert { - condition = length(coder_env.disable_autoupdater) == 0 - error_message = "DISABLE_AUTOUPDATER should not be set by default" - } - assert { condition = length(coder_env.claude_code_oauth_token) == 0 error_message = "CLAUDE_CODE_OAUTH_TOKEN should not be set by default" @@ -48,12 +38,12 @@ run "test_with_anthropic_api_key" { assert { condition = coder_env.anthropic_api_key[0].name == "ANTHROPIC_API_KEY" - error_message = "Env var name must be ANTHROPIC_API_KEY" + error_message = "Shortcut must create a coder_env named ANTHROPIC_API_KEY" } assert { condition = coder_env.anthropic_api_key[0].value == "sk-live-test" - error_message = "ANTHROPIC_API_KEY value should match input" + error_message = "anthropic_api_key value must round-trip" } } @@ -67,41 +57,110 @@ run "test_with_oauth_token" { assert { condition = coder_env.claude_code_oauth_token[0].name == "CLAUDE_CODE_OAUTH_TOKEN" - error_message = "Env var name must be CLAUDE_CODE_OAUTH_TOKEN" + error_message = "Shortcut must create a coder_env named CLAUDE_CODE_OAUTH_TOKEN" } assert { condition = coder_env.claude_code_oauth_token[0].value == "oauth-test-token" - error_message = "CLAUDE_CODE_OAUTH_TOKEN value should match input" + error_message = "claude_code_oauth_token value must round-trip" } } -run "test_with_model" { +run "test_with_env_map" { command = plan variables { agent_id = "test-agent" - model = "opus" + env = { + ANTHROPIC_MODEL = "opus" + ANTHROPIC_BASE_URL = "https://proxy.example.com" + DISABLE_AUTOUPDATER = "1" + CUSTOM_VAR = "hello" + } + } + + assert { + condition = coder_env.env["ANTHROPIC_MODEL"].value == "opus" + error_message = "env[ANTHROPIC_MODEL] should be set" + } + + assert { + condition = coder_env.env["ANTHROPIC_BASE_URL"].value == "https://proxy.example.com" + error_message = "env[ANTHROPIC_BASE_URL] should be set" } assert { - condition = coder_env.anthropic_model[0].value == "opus" - error_message = "ANTHROPIC_MODEL should be set to 'opus'" + condition = coder_env.env["DISABLE_AUTOUPDATER"].value == "1" + error_message = "env[DISABLE_AUTOUPDATER] should be set" + } + + assert { + condition = coder_env.env["CUSTOM_VAR"].value == "hello" + error_message = "arbitrary env keys should pass through" + } + + assert { + condition = length(coder_env.env) == 4 + error_message = "should create exactly 4 coder_env resources from env" } } -run "test_with_disable_autoupdater" { +run "test_env_and_shortcut_coexist" { command = plan variables { - agent_id = "test-agent" - disable_autoupdater = true + agent_id = "test-agent" + anthropic_api_key = "sk-live" + env = { + ANTHROPIC_MODEL = "sonnet" + } + } + + assert { + condition = coder_env.anthropic_api_key[0].value == "sk-live" + error_message = "shortcut should set ANTHROPIC_API_KEY" } assert { - condition = coder_env.disable_autoupdater[0].value == "1" - error_message = "DISABLE_AUTOUPDATER should be '1' when disable_autoupdater is true" + condition = coder_env.env["ANTHROPIC_MODEL"].value == "sonnet" + error_message = "env map should set ANTHROPIC_MODEL" } + + assert { + condition = length(coder_env.env) == 1 + error_message = "env resource should have one entry" + } + + assert { + condition = length(coder_env.anthropic_api_key) == 1 + error_message = "anthropic_api_key resource should have one entry" + } +} + +run "test_env_map_api_key_conflict" { + command = plan + + variables { + agent_id = "test-agent" + env = { + ANTHROPIC_API_KEY = "sk-wrong-channel" + } + } + + expect_failures = [var.env] +} + +run "test_env_map_oauth_token_conflict" { + command = plan + + variables { + agent_id = "test-agent" + env = { + CLAUDE_CODE_OAUTH_TOKEN = "oauth-wrong-channel" + } + } + + expect_failures = [var.env] } run "test_with_mcp_inline" { From 6a86d74b6f1524cb4a53d5bcded31495206a57b5 Mon Sep 17 00:00:00 2001 From: Atif Ali Date: Wed, 22 Apr 2026 06:03:04 +0000 Subject: [PATCH 04/23] refactor(claude-code): drop auth shortcuts, use only `env` map Remove `anthropic_api_key` and `claude_code_oauth_token` as dedicated sensitive variables. Template authors set them through `env` like every other Claude Code env var. The module's job is to export what the caller asks for. It has no opinion about which env vars matter. `env` uses `nonsensitive(toset(keys(var.env)))` so sensitive values can pass through without tainting the for_each keys. Callers declare their own variables with `sensitive = true` and pipe them in. Down to 9 variables from 11 (1 required, 8 optional). --- registry/coder/modules/claude-code/README.md | 96 +++++++------ .../coder/modules/claude-code/main.test.ts | 30 +---- registry/coder/modules/claude-code/main.tf | 52 +------- .../coder/modules/claude-code/main.tftest.hcl | 126 +++--------------- 4 files changed, 84 insertions(+), 220 deletions(-) diff --git a/registry/coder/modules/claude-code/README.md b/registry/coder/modules/claude-code/README.md index 105807cd8..536c58582 100644 --- a/registry/coder/modules/claude-code/README.md +++ b/registry/coder/modules/claude-code/README.md @@ -20,50 +20,57 @@ It does not start Claude, create a web app, or orchestrate Tasks. Compose with d ```tf module "claude-code" { - source = "registry.coder.com/coder/claude-code/coder" - version = "5.0.0" - agent_id = coder_agent.main.id - anthropic_api_key = var.anthropic_api_key + source = "registry.coder.com/coder/claude-code/coder" + version = "5.0.0" + agent_id = coder_agent.main.id + + env = { + ANTHROPIC_API_KEY = var.anthropic_api_key + } } ``` -## Authentication +## Environment variables (`env`) -Two sensitive shortcuts are provided as dedicated variables. Every other env var goes through the `env` map. +Pass any Claude Code env var (or any custom var your pre/post scripts consume) through the `env` map. Each key/value pair becomes one `coder_env` resource on the agent. -- `anthropic_api_key`: sets `ANTHROPIC_API_KEY`. Marked sensitive. -- `claude_code_oauth_token`: sets `CLAUDE_CODE_OAUTH_TOKEN` (generate with `claude setup-token`). Marked sensitive. +Declare your Terraform variable with `sensitive = true` to keep secrets out of plan output. Values retain their sensitivity when passed through the module. ```tf -# Claude.ai subscription +variable "anthropic_api_key" { + type = string + sensitive = true +} + module "claude-code" { - source = "registry.coder.com/coder/claude-code/coder" - version = "5.0.0" - agent_id = coder_agent.main.id - claude_code_oauth_token = var.claude_code_oauth_token + source = "registry.coder.com/coder/claude-code/coder" + version = "5.0.0" + agent_id = coder_agent.main.id + + env = { + ANTHROPIC_API_KEY = var.anthropic_api_key + ANTHROPIC_MODEL = "opus" + DISABLE_AUTOUPDATER = "1" + MY_CUSTOM_VAR = "hello" + } } ``` -## Arbitrary environment variables (`env`) - -Pass any Claude Code env var (or any custom var your pre/post scripts consume) through the `env` map. Each key/value pair becomes one `coder_env` resource on the agent. +### Claude.ai subscription ```tf module "claude-code" { - source = "registry.coder.com/coder/claude-code/coder" - version = "5.0.0" - agent_id = coder_agent.main.id - anthropic_api_key = var.anthropic_api_key + source = "registry.coder.com/coder/claude-code/coder" + version = "5.0.0" + agent_id = coder_agent.main.id env = { - ANTHROPIC_MODEL = "opus" - DISABLE_AUTOUPDATER = "1" - MY_CUSTOM_VAR = "hello" + CLAUDE_CODE_OAUTH_TOKEN = var.claude_code_oauth_token } } ``` -### Using a custom endpoint (AI Bridge, Bedrock, Vertex, LiteLLM, a private proxy) +### Custom endpoint (AI Bridge, Bedrock, Vertex, LiteLLM, a private proxy) Set the endpoint and token through `env`. Nothing is baked in; the [Claude Code env-vars reference](https://docs.claude.com/en/docs/claude-code/env-vars) lists every supported name. @@ -80,9 +87,6 @@ module "claude-code" { } ``` -> [!NOTE] -> `ANTHROPIC_API_KEY` and `CLAUDE_CODE_OAUTH_TOKEN` are rejected in `env` because they have dedicated sensitive variables (`anthropic_api_key`, `claude_code_oauth_token`). Every other env var is allowed. - ## MCP configuration MCP servers are applied at **user scope** via `claude mcp add-json --scope user`. They end up in `~/.claude.json` and apply across every project the user opens. @@ -91,10 +95,13 @@ MCP servers are applied at **user scope** via `claude mcp add-json --scope user` ```tf module "claude-code" { - source = "registry.coder.com/coder/claude-code/coder" - version = "5.0.0" - agent_id = coder_agent.main.id - anthropic_api_key = var.anthropic_api_key + source = "registry.coder.com/coder/claude-code/coder" + version = "5.0.0" + agent_id = coder_agent.main.id + + env = { + ANTHROPIC_API_KEY = var.anthropic_api_key + } mcp = jsonencode({ mcpServers = { @@ -113,10 +120,13 @@ Each URL must return JSON in the shape `{"mcpServers": {...}}`. `Content-Type` i ```tf module "claude-code" { - source = "registry.coder.com/coder/claude-code/coder" - version = "5.0.0" - agent_id = coder_agent.main.id - anthropic_api_key = var.anthropic_api_key + source = "registry.coder.com/coder/claude-code/coder" + version = "5.0.0" + agent_id = coder_agent.main.id + + env = { + ANTHROPIC_API_KEY = var.anthropic_api_key + } mcp_config_remote_path = [ "https://raw.githubusercontent.com/coder/coder/main/.mcp.json", @@ -155,10 +165,13 @@ Use `pre_install_script` and `post_install_script` for custom setup (e.g. writin ```tf module "claude-code" { - source = "registry.coder.com/coder/claude-code/coder" - version = "5.0.0" - agent_id = coder_agent.main.id - anthropic_api_key = var.anthropic_api_key + source = "registry.coder.com/coder/claude-code/coder" + version = "5.0.0" + agent_id = coder_agent.main.id + + env = { + ANTHROPIC_API_KEY = var.anthropic_api_key + } pre_install_script = <<-EOT #!/bin/bash @@ -188,9 +201,8 @@ cat $HOME/.claude-module/post_install.log Breaking changes in v5.0.0: -- `claude_api_key` renamed to `anthropic_api_key`. Now sets `ANTHROPIC_API_KEY` (the variable Claude Code actually reads), not `CLAUDE_API_KEY`. -- All Tasks, AgentAPI, Boundary, AI Bridge, and web-app variables removed. Compose dedicated modules or set env vars via the `env` map. -- `model`, `disable_autoupdater`, and `claude_md_path` variables removed. Set `ANTHROPIC_MODEL` and `DISABLE_AUTOUPDATER` via `env`. Claude Code discovers `~/.claude/CLAUDE.md` automatically. +- `claude_api_key`, `claude_code_oauth_token`, `model`, `disable_autoupdater`, `claude_md_path` removed as dedicated variables. Set them through `env` instead. The module now emits `ANTHROPIC_API_KEY` (the variable Claude Code actually reads), not `CLAUDE_API_KEY`. +- All Tasks, AgentAPI, Boundary, AI Bridge, and web-app variables removed. Compose dedicated modules or set env vars via `env`. - `workdir` removed. MCP applies at user scope. - `install_via_npm` removed. Official installer only. - `allowed_tools` / `disallowed_tools` removed. Write `~/.claude/settings.json` via `pre_install_script` with `permissions.allow` / `permissions.deny` arrays. diff --git a/registry/coder/modules/claude-code/main.test.ts b/registry/coder/modules/claude-code/main.test.ts index 9607ef905..54041b20c 100644 --- a/registry/coder/modules/claude-code/main.test.ts +++ b/registry/coder/modules/claude-code/main.test.ts @@ -212,41 +212,25 @@ describe("claude-code", async () => { expect(resp.stdout).toMatch(/\d+\.\d+\.\d+/); }); - test("anthropic-api-key", async () => { - const apiKey = "sk-test-api-key-123"; - const { id, coderEnvVars } = await setup({ - moduleVariables: { - anthropic_api_key: apiKey, - }, - }); - expect(coderEnvVars["ANTHROPIC_API_KEY"]).toBe(apiKey); - expect(coderEnvVars["CLAUDE_API_KEY"]).toBeUndefined(); - await runModuleScripts(id); - }); - - test("claude-oauth-token", async () => { - const token = "oauth-live-token"; - const { coderEnvVars } = await setup({ - moduleVariables: { - claude_code_oauth_token: token, - }, - }); - expect(coderEnvVars["CLAUDE_CODE_OAUTH_TOKEN"]).toBe(token); - }); - test("env-map-passthrough", async () => { - const { coderEnvVars } = await setup({ + const { id, coderEnvVars } = await setup({ moduleVariables: { env: JSON.stringify({ + ANTHROPIC_API_KEY: "sk-test-api-key-123", + CLAUDE_CODE_OAUTH_TOKEN: "oauth-live-token", ANTHROPIC_MODEL: "opus", DISABLE_AUTOUPDATER: "1", CUSTOM_VAR: "hello", }), }, }); + expect(coderEnvVars["ANTHROPIC_API_KEY"]).toBe("sk-test-api-key-123"); + expect(coderEnvVars["CLAUDE_CODE_OAUTH_TOKEN"]).toBe("oauth-live-token"); expect(coderEnvVars["ANTHROPIC_MODEL"]).toBe("opus"); expect(coderEnvVars["DISABLE_AUTOUPDATER"]).toBe("1"); expect(coderEnvVars["CUSTOM_VAR"]).toBe("hello"); + expect(coderEnvVars["CLAUDE_API_KEY"]).toBeUndefined(); + await runModuleScripts(id); }); test("claude-mcp-inline-user-scope", async () => { diff --git a/registry/coder/modules/claude-code/main.tf b/registry/coder/modules/claude-code/main.tf index 5d753ffdc..7e78c1c3c 100644 --- a/registry/coder/modules/claude-code/main.tf +++ b/registry/coder/modules/claude-code/main.tf @@ -14,34 +14,10 @@ variable "agent_id" { description = "The ID of a Coder agent." } -variable "anthropic_api_key" { - type = string - description = "Convenience shortcut for setting ANTHROPIC_API_KEY. Equivalent to adding ANTHROPIC_API_KEY to `env`." - default = "" - sensitive = true -} - -variable "claude_code_oauth_token" { - type = string - description = "Convenience shortcut for setting CLAUDE_CODE_OAUTH_TOKEN. Generate with `claude setup-token`. Equivalent to adding CLAUDE_CODE_OAUTH_TOKEN to `env`." - default = "" - sensitive = true -} - variable "env" { type = map(string) - description = "Arbitrary environment variables to export to the Coder agent. Each key/value pair becomes a `coder_env` resource. Use this for any Claude Code env var (ANTHROPIC_BASE_URL, ANTHROPIC_AUTH_TOKEN, ANTHROPIC_MODEL, CLAUDE_CODE_USE_BEDROCK, etc.) or for custom vars your pre/post scripts consume." + description = "Environment variables to export to the Coder agent. Each key/value pair becomes one coder_env resource. Use this for any Claude Code env var (ANTHROPIC_API_KEY, CLAUDE_CODE_OAUTH_TOKEN, ANTHROPIC_BASE_URL, ANTHROPIC_MODEL, CLAUDE_CODE_USE_BEDROCK, etc.) or for custom vars your pre/post scripts consume. Declare your Terraform variable with `sensitive = true` to keep secrets out of plan output." default = {} - - validation { - condition = !contains(keys(var.env), "ANTHROPIC_API_KEY") - error_message = "Use the `anthropic_api_key` variable instead of setting ANTHROPIC_API_KEY via `env`. It is marked sensitive and handled as a dedicated resource." - } - - validation { - condition = !contains(keys(var.env), "CLAUDE_CODE_OAUTH_TOKEN") - error_message = "Use the `claude_code_oauth_token` variable instead of setting CLAUDE_CODE_OAUTH_TOKEN via `env`. It is marked sensitive and handled as a dedicated resource." - } } variable "claude_code_version" { @@ -92,32 +68,18 @@ variable "post_install_script" { } locals { - # `env` fans out to one `coder_env` per entry via for_each. Sensitive - # shortcuts (anthropic_api_key, claude_code_oauth_token) can't be merged in - # because Terraform forbids sensitive values as for_each keys. They're - # emitted as dedicated resources below instead. install_script = file("${path.module}/scripts/install.sh") } +# Fan var.env out into one coder_env per entry. Keys are lifted out of +# their sensitivity taint with `nonsensitive` so Terraform can use them as +# for_each instance addresses; values retain any sensitivity attached by +# the caller's variable declaration. resource "coder_env" "env" { - for_each = var.env + for_each = nonsensitive(toset(keys(var.env))) agent_id = var.agent_id name = each.key - value = each.value -} - -resource "coder_env" "anthropic_api_key" { - count = var.anthropic_api_key != "" ? 1 : 0 - agent_id = var.agent_id - name = "ANTHROPIC_API_KEY" - value = var.anthropic_api_key -} - -resource "coder_env" "claude_code_oauth_token" { - count = var.claude_code_oauth_token != "" ? 1 : 0 - agent_id = var.agent_id - name = "CLAUDE_CODE_OAUTH_TOKEN" - value = var.claude_code_oauth_token + value = var.env[each.key] } module "coder-utils" { diff --git a/registry/coder/modules/claude-code/main.tftest.hcl b/registry/coder/modules/claude-code/main.tftest.hcl index a36ae6c92..a1977bd9c 100644 --- a/registry/coder/modules/claude-code/main.tftest.hcl +++ b/registry/coder/modules/claude-code/main.tftest.hcl @@ -16,67 +16,31 @@ run "test_defaults" { condition = length(coder_env.env) == 0 error_message = "No env vars should be set by default" } - - assert { - condition = length(coder_env.anthropic_api_key) == 0 - error_message = "ANTHROPIC_API_KEY should not be set by default" - } - - assert { - condition = length(coder_env.claude_code_oauth_token) == 0 - error_message = "CLAUDE_CODE_OAUTH_TOKEN should not be set by default" - } -} - -run "test_with_anthropic_api_key" { - command = plan - - variables { - agent_id = "test-agent" - anthropic_api_key = "sk-live-test" - } - - assert { - condition = coder_env.anthropic_api_key[0].name == "ANTHROPIC_API_KEY" - error_message = "Shortcut must create a coder_env named ANTHROPIC_API_KEY" - } - - assert { - condition = coder_env.anthropic_api_key[0].value == "sk-live-test" - error_message = "anthropic_api_key value must round-trip" - } } -run "test_with_oauth_token" { +run "test_with_env_map" { command = plan variables { - agent_id = "test-agent" - claude_code_oauth_token = "oauth-test-token" + agent_id = "test-agent" + env = { + ANTHROPIC_API_KEY = "sk-live" + CLAUDE_CODE_OAUTH_TOKEN = "oauth-live" + ANTHROPIC_MODEL = "opus" + ANTHROPIC_BASE_URL = "https://proxy.example.com" + DISABLE_AUTOUPDATER = "1" + CUSTOM_VAR = "hello" + } } assert { - condition = coder_env.claude_code_oauth_token[0].name == "CLAUDE_CODE_OAUTH_TOKEN" - error_message = "Shortcut must create a coder_env named CLAUDE_CODE_OAUTH_TOKEN" + condition = coder_env.env["ANTHROPIC_API_KEY"].value == "sk-live" + error_message = "env[ANTHROPIC_API_KEY] should be set" } assert { - condition = coder_env.claude_code_oauth_token[0].value == "oauth-test-token" - error_message = "claude_code_oauth_token value must round-trip" - } -} - -run "test_with_env_map" { - command = plan - - variables { - agent_id = "test-agent" - env = { - ANTHROPIC_MODEL = "opus" - ANTHROPIC_BASE_URL = "https://proxy.example.com" - DISABLE_AUTOUPDATER = "1" - CUSTOM_VAR = "hello" - } + condition = coder_env.env["CLAUDE_CODE_OAUTH_TOKEN"].value == "oauth-live" + error_message = "env[CLAUDE_CODE_OAUTH_TOKEN] should be set" } assert { @@ -100,67 +64,9 @@ run "test_with_env_map" { } assert { - condition = length(coder_env.env) == 4 - error_message = "should create exactly 4 coder_env resources from env" - } -} - -run "test_env_and_shortcut_coexist" { - command = plan - - variables { - agent_id = "test-agent" - anthropic_api_key = "sk-live" - env = { - ANTHROPIC_MODEL = "sonnet" - } - } - - assert { - condition = coder_env.anthropic_api_key[0].value == "sk-live" - error_message = "shortcut should set ANTHROPIC_API_KEY" - } - - assert { - condition = coder_env.env["ANTHROPIC_MODEL"].value == "sonnet" - error_message = "env map should set ANTHROPIC_MODEL" - } - - assert { - condition = length(coder_env.env) == 1 - error_message = "env resource should have one entry" - } - - assert { - condition = length(coder_env.anthropic_api_key) == 1 - error_message = "anthropic_api_key resource should have one entry" - } -} - -run "test_env_map_api_key_conflict" { - command = plan - - variables { - agent_id = "test-agent" - env = { - ANTHROPIC_API_KEY = "sk-wrong-channel" - } - } - - expect_failures = [var.env] -} - -run "test_env_map_oauth_token_conflict" { - command = plan - - variables { - agent_id = "test-agent" - env = { - CLAUDE_CODE_OAUTH_TOKEN = "oauth-wrong-channel" - } + condition = length(coder_env.env) == 6 + error_message = "should create exactly 6 coder_env resources" } - - expect_failures = [var.env] } run "test_with_mcp_inline" { From 802950d6184808577f03311ffd897566d4a276bb Mon Sep 17 00:00:00 2001 From: Atif Ali Date: Wed, 22 Apr 2026 06:04:18 +0000 Subject: [PATCH 05/23] docs(claude-code): add explicit AI Bridge example Promote AI Bridge from a footnote in the custom-endpoints section to its own example with the exact env block, including the data source references for access_url and session_token. Keep the generic custom-endpoints example below for Bedrock/Vertex/LiteLLM/proxies. --- registry/coder/modules/claude-code/README.md | 25 ++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/registry/coder/modules/claude-code/README.md b/registry/coder/modules/claude-code/README.md index 536c58582..42b58d19d 100644 --- a/registry/coder/modules/claude-code/README.md +++ b/registry/coder/modules/claude-code/README.md @@ -70,9 +70,30 @@ module "claude-code" { } ``` -### Custom endpoint (AI Bridge, Bedrock, Vertex, LiteLLM, a private proxy) +### Coder AI Bridge -Set the endpoint and token through `env`. Nothing is baked in; the [Claude Code env-vars reference](https://docs.claude.com/en/docs/claude-code/env-vars) lists every supported name. +Route Claude Code through [Coder AI Bridge](https://coder.com/docs/ai-coder/ai-bridge) (Premium, requires Coder >= 2.29.0). AI Bridge authenticates with the workspace owner's session token, so no API key is needed. + +```tf +data "coder_workspace" "me" {} + +data "coder_workspace_owner" "me" {} + +module "claude-code" { + source = "registry.coder.com/coder/claude-code/coder" + version = "5.0.0" + agent_id = coder_agent.main.id + + env = { + ANTHROPIC_BASE_URL = "${data.coder_workspace.me.access_url}/api/v2/aibridge/anthropic" + ANTHROPIC_AUTH_TOKEN = data.coder_workspace_owner.me.session_token + } +} +``` + +### Other custom endpoints (Bedrock, Vertex, LiteLLM, a private proxy) + +Same pattern with your own endpoint and token. The [Claude Code env-vars reference](https://docs.claude.com/en/docs/claude-code/env-vars) lists every supported name. ```tf module "claude-code" { From 2b1d58edc87ea499142d8581c4dcdc424c22eb26 Mon Sep 17 00:00:00 2001 From: Atif Ali Date: Wed, 22 Apr 2026 06:16:47 +0000 Subject: [PATCH 06/23] fix(claude-code): handle pre-installed Claude binary cases The module already covered the happy paths (install_claude_code=true with or without Claude already present, install_claude_code=false with Claude on PATH). It silently no-oped MCP configuration when install_claude_code=false and Claude was nowhere to be found, which is a confusing failure mode for template authors who forget to either install Claude or set install_claude_code=true. Add an explicit guard after install_claude_code_cli: - If Claude is absent and MCP was requested: fail loudly with a clear message pointing at the three ways to fix it (install_claude_code=true, install via pre_install_script, or point claude_binary_path at a pre-installed binary). - If Claude is absent and no MCP was requested: log a note and exit 0. The script has nothing else to do. Two new bun tests cover these cases. --- .../coder/modules/claude-code/main.test.ts | 61 +++++++++++++++++++ .../modules/claude-code/scripts/install.sh | 14 +++++ 2 files changed, 75 insertions(+) diff --git a/registry/coder/modules/claude-code/main.test.ts b/registry/coder/modules/claude-code/main.test.ts index 54041b20c..e11dfe748 100644 --- a/registry/coder/modules/claude-code/main.test.ts +++ b/registry/coder/modules/claude-code/main.test.ts @@ -233,6 +233,67 @@ describe("claude-code", async () => { await runModuleScripts(id); }); + test("no-claude-no-mcp-is-fine", async () => { + // install_claude_code=false and no MCP requested: install log records + // the note and no resource tries to call the claude binary. The + // overall coder-utils pipeline succeeds. + const { id, coderEnvVars } = await setup({ + moduleVariables: { + install_claude_code: "false", + }, + }); + // Remove the claude mock so command -v claude fails in the container. + // /usr/bin requires root, so exec as root. + await execContainer( + id, + [ + "bash", + "-c", + "rm -f /usr/bin/claude /home/coder/.local/bin/claude 2>/dev/null; hash -r", + ], + ["--user", "root"], + ); + const resp = await runModuleScripts(id, coderEnvVars); + expect(resp.exitCode).toBe(0); + const installLog = await readFileContainer( + id, + "/home/coder/.claude-module/install.log", + ); + expect(installLog).toContain("claude binary not found on PATH"); + }); + + test("mcp-without-claude-fails-loudly", async () => { + // install_claude_code=false + mcp requested + no claude binary: the + // install script must exit non-zero with a clear error in the log + // instead of silently no-oping every claude mcp add-json call. + const { id, coderEnvVars } = await setup({ + moduleVariables: { + install_claude_code: "false", + mcp: JSON.stringify({ + mcpServers: { test: { command: "test-cmd" } }, + }), + }, + }); + await execContainer( + id, + [ + "bash", + "-c", + "rm -f /usr/bin/claude /home/coder/.local/bin/claude 2>/dev/null; hash -r", + ], + ["--user", "root"], + ); + const resp = await runModuleScripts(id, coderEnvVars); + expect(resp.exitCode).not.toBe(0); + const installLog = await readFileContainer( + id, + "/home/coder/.claude-module/install.log", + ); + expect(installLog).toContain( + "MCP configuration was provided but the claude binary is not on PATH", + ); + }); + test("claude-mcp-inline-user-scope", async () => { const mcpConfig = JSON.stringify({ mcpServers: { diff --git a/registry/coder/modules/claude-code/scripts/install.sh b/registry/coder/modules/claude-code/scripts/install.sh index c9ee7c41a..69217752b 100644 --- a/registry/coder/modules/claude-code/scripts/install.sh +++ b/registry/coder/modules/claude-code/scripts/install.sh @@ -133,4 +133,18 @@ apply_mcp() { } install_claude_code_cli + +# Guard: MCP add commands require the claude binary. If Claude is absent +# (install_claude_code=false and no pre_install_script installed it), fail +# loudly instead of silently no-oping every `claude mcp add-json` call. +if ! command -v claude > /dev/null 2>&1; then + if [ -n "$ARG_MCP" ] || { [ -n "$ARG_MCP_CONFIG_REMOTE_PATH" ] && [ "$ARG_MCP_CONFIG_REMOTE_PATH" != "[]" ]; }; then + echo "Error: MCP configuration was provided but the claude binary is not on PATH." >&2 + echo "Either set install_claude_code = true, install Claude via a pre_install_script, or point claude_binary_path at a pre-installed binary." >&2 + exit 1 + fi + echo "Note: claude binary not found on PATH. Skipping MCP configuration." + exit 0 +fi + apply_mcp From 79a9a17bcb655155f872b8d664cd63941ffdef26 Mon Sep 17 00:00:00 2001 From: Atif Ali Date: Wed, 22 Apr 2026 06:34:13 +0000 Subject: [PATCH 07/23] refactor(claude-code): feed install script directly to coder-utils Drop the /tmp/install.sh wrapper. Previously main.tf rendered a small bash script that base64-decoded the real install.sh into /tmp, then ran it with ARG_* exports. coder-utils was then wrapping that wrapper with its own base64 round-trip and mkdir+sync plumbing. Now main.tf prepends the ARG_* exports directly in front of the install.sh body with a local join(), and passes the combined string straight to coder-utils. coder-utils writes it once to $HOME/.claude-module/install.sh, runs it, logs to install.log. One file on disk instead of two, easier to debug by cat-ing the install.sh that actually ran. --- registry/coder/modules/claude-code/main.tf | 33 +++++++++++----------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/registry/coder/modules/claude-code/main.tf b/registry/coder/modules/claude-code/main.tf index 7e78c1c3c..16fc0d289 100644 --- a/registry/coder/modules/claude-code/main.tf +++ b/registry/coder/modules/claude-code/main.tf @@ -68,7 +68,22 @@ variable "post_install_script" { } locals { - install_script = file("${path.module}/scripts/install.sh") + # Prepend ARG_* exports pulled from Terraform variables directly in front of + # the install script body. coder-utils then takes the combined string, + # writes it to $HOME/.claude-module/install.sh, and runs it. One file on + # disk, one base64 round-trip, no /tmp wrapper. + install_script = join("\n", [ + "#!/bin/bash", + "set -euo pipefail", + "", + "export ARG_CLAUDE_CODE_VERSION='${var.claude_code_version}'", + "export ARG_INSTALL_CLAUDE_CODE='${var.install_claude_code}'", + "export ARG_CLAUDE_BINARY_PATH='${var.claude_binary_path}'", + "export ARG_MCP='${var.mcp != "" ? base64encode(var.mcp) : ""}'", + "export ARG_MCP_CONFIG_REMOTE_PATH='${base64encode(jsonencode(var.mcp_config_remote_path))}'", + "", + file("${path.module}/scripts/install.sh"), + ]) } # Fan var.env out into one coder_env per entry. Keys are lifted out of @@ -93,19 +108,5 @@ module "coder-utils" { pre_install_script = var.pre_install_script post_install_script = var.post_install_script - install_script = <<-EOT - #!/bin/bash - set -o errexit - set -o pipefail - - echo -n '${base64encode(local.install_script)}' | base64 -d > /tmp/install.sh - chmod +x /tmp/install.sh - - ARG_CLAUDE_CODE_VERSION='${var.claude_code_version}' \ - ARG_INSTALL_CLAUDE_CODE='${var.install_claude_code}' \ - ARG_CLAUDE_BINARY_PATH='${var.claude_binary_path}' \ - ARG_MCP='${var.mcp != "" ? base64encode(var.mcp) : ""}' \ - ARG_MCP_CONFIG_REMOTE_PATH='${base64encode(jsonencode(var.mcp_config_remote_path))}' \ - /tmp/install.sh - EOT + install_script = local.install_script } From d8d597770e589e89a338fce08d092dd7a77b9cf8 Mon Sep 17 00:00:00 2001 From: Atif Ali Date: Wed, 22 Apr 2026 07:08:26 +0000 Subject: [PATCH 08/23] feat(claude-code): brand coder-utils scripts with display_name and icon Pass display_name_prefix = "Claude Code" and icon = "/icon/claude.svg" through to the coder-utils module so the workspace shows "Claude Code: Install Script" with the Claude icon instead of the generic defaults. Relies on the two new variables added to coder-utils on PR #842's branch. --- registry/coder/modules/claude-code/main.tf | 3 +++ 1 file changed, 3 insertions(+) diff --git a/registry/coder/modules/claude-code/main.tf b/registry/coder/modules/claude-code/main.tf index 16fc0d289..164c8b839 100644 --- a/registry/coder/modules/claude-code/main.tf +++ b/registry/coder/modules/claude-code/main.tf @@ -105,6 +105,9 @@ module "coder-utils" { agent_name = "claude-code" module_directory = "$HOME/.claude-module" + display_name_prefix = "Claude Code" + icon = "/icon/claude.svg" + pre_install_script = var.pre_install_script post_install_script = var.post_install_script From 9f0b0b0a95eeb7896b56eb20d8ca4e17f6ffa383 Mon Sep 17 00:00:00 2001 From: Atif Ali Date: Wed, 22 Apr 2026 08:39:57 +0000 Subject: [PATCH 09/23] docs(claude-code): rename AI Bridge to AI Gateway and document script defaults The Premium feature formerly known as AI Bridge is now called AI Gateway. Server-side endpoints and env vars still use the 'aibridge' prefix; only the product name changed. Rework the README section accordingly: - Section heading: 'Coder AI Bridge' -> 'Coder AI Gateway'. - Link: docs/ai-coder/ai-bridge -> docs/ai-coder/ai-gateway. - Add a note calling out the rename and that the endpoint prefix is unchanged. - Upgrading section mentions the rename so readers coming from v4.x find the new example. Also document the script surface: this module creates exactly one coder_script by default (Claude Code: Install Script). Pre- and post-install scripts appear only when the caller opts in. No start script is produced in any configuration. --- registry/coder/modules/claude-code/README.md | 23 +++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/registry/coder/modules/claude-code/README.md b/registry/coder/modules/claude-code/README.md index 42b58d19d..791e1c7d2 100644 --- a/registry/coder/modules/claude-code/README.md +++ b/registry/coder/modules/claude-code/README.md @@ -70,9 +70,11 @@ module "claude-code" { } ``` -### Coder AI Bridge +### Coder AI Gateway -Route Claude Code through [Coder AI Bridge](https://coder.com/docs/ai-coder/ai-bridge) (Premium, requires Coder >= 2.29.0). AI Bridge authenticates with the workspace owner's session token, so no API key is needed. +Route Claude Code through [Coder AI Gateway](https://coder.com/docs/ai-coder/ai-gateway) for centralized auditing, token usage tracking, and MCP policy enforcement. Requires Coder Premium with the AI Governance add-on and `CODER_AIBRIDGE_ENABLED=true` on the server. + +Point `ANTHROPIC_BASE_URL` at your deployment's `/api/v2/aibridge/anthropic` endpoint and authenticate with the workspace owner's session token via `ANTHROPIC_AUTH_TOKEN`. Claude Code reads both variables natively; no API key is required. ```tf data "coder_workspace" "me" {} @@ -91,6 +93,9 @@ module "claude-code" { } ``` +> [!NOTE] +> AI Gateway was previously named AI Bridge. The server-side endpoints and environment variables still use the `aibridge` prefix; only the product name changed. + ### Other custom endpoints (Bedrock, Vertex, LiteLLM, a private proxy) Same pattern with your own endpoint and token. The [Claude Code env-vars reference](https://docs.claude.com/en/docs/claude-code/env-vars) lists every supported name. @@ -180,6 +185,18 @@ module "claude-code" { } ``` +## Scripts produced + +By default this module creates exactly one `coder_script` on the agent: `Claude Code: Install Script`. Additional scripts appear only when you opt in: + +| Script | Created when | +| ---------------------------------- | ----------------------------- | +| `Claude Code: Install Script` | Always. | +| `Claude Code: Pre-Install Script` | `pre_install_script` is set. | +| `Claude Code: Post-Install Script` | `post_install_script` is set. | + +No start script is produced in any configuration. Compose with a dedicated module (e.g. a future Tasks module) if you need one. + ## Extending with pre/post install scripts Use `pre_install_script` and `post_install_script` for custom setup (e.g. writing `~/.claude/settings.json` permission rules, installing cloud SDKs, pulling secrets). @@ -223,7 +240,7 @@ cat $HOME/.claude-module/post_install.log Breaking changes in v5.0.0: - `claude_api_key`, `claude_code_oauth_token`, `model`, `disable_autoupdater`, `claude_md_path` removed as dedicated variables. Set them through `env` instead. The module now emits `ANTHROPIC_API_KEY` (the variable Claude Code actually reads), not `CLAUDE_API_KEY`. -- All Tasks, AgentAPI, Boundary, AI Bridge, and web-app variables removed. Compose dedicated modules or set env vars via `env`. +- All Tasks, AgentAPI, Boundary, AI Bridge (now **AI Gateway**), and web-app variables removed. Compose dedicated modules or set env vars via `env`. See the AI Gateway example above for the replacement pattern. - `workdir` removed. MCP applies at user scope. - `install_via_npm` removed. Official installer only. - `allowed_tools` / `disallowed_tools` removed. Write `~/.claude/settings.json` via `pre_install_script` with `permissions.allow` / `permissions.deny` arrays. From 6bf210c1f0020ffc3dcd8cbbc736dae769af2e58 Mon Sep 17 00:00:00 2001 From: Atif Ali Date: Wed, 22 Apr 2026 08:40:05 +0000 Subject: [PATCH 10/23] test(claude-code): assert only install script is created when pre/post unset Locks in the invariant that coder-utils does not create optional coder_script resources when the caller does not pass pre_install_script or post_install_script. A regression here would leak empty scripts into the agent's script list in the Coder UI. --- .../coder/modules/claude-code/main.test.ts | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/registry/coder/modules/claude-code/main.test.ts b/registry/coder/modules/claude-code/main.test.ts index e11dfe748..5139c7944 100644 --- a/registry/coder/modules/claude-code/main.test.ts +++ b/registry/coder/modules/claude-code/main.test.ts @@ -347,6 +347,29 @@ describe("claude-code", async () => { expect(installLog).toContain("claude mcp add-json --scope user"); }); + test("no-extra-scripts-when-pre-post-unset", async () => { + // When pre_install_script / post_install_script are not provided, + // coder-utils must skip creating their coder_script resources. This + // keeps the agent's scripts list clean in the Coder UI. + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + install_claude_code: "false", + }); + + const scriptCount = state.resources + .filter((r) => r.type === "coder_script") + .reduce((n, r) => n + r.instances.length, 0); + expect(scriptCount).toBe(1); + + const scripts = state.resources.filter((r) => r.type === "coder_script"); + const displayNames = scripts.flatMap((r) => + r.instances.map( + (i) => (i.attributes as Record).display_name, + ), + ); + expect(displayNames).toEqual(["Claude Code: Install Script"]); + }); + test("pre-post-install-scripts", async () => { const { id } = await setup({ moduleVariables: { From 49d1d303973343152316cbdc41463f1b35a3ee26 Mon Sep 17 00:00:00 2001 From: Atif Ali Date: Wed, 22 Apr 2026 08:40:57 +0000 Subject: [PATCH 11/23] test(claude-code): add example MCP JSON fixture for e2e tests Enables pointing mcp_config_remote_path at an in-repo raw URL during end-to-end verification. Kept minimal: one filesystem server. --- .../coder/modules/claude-code/testdata/example-mcp.json | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 registry/coder/modules/claude-code/testdata/example-mcp.json diff --git a/registry/coder/modules/claude-code/testdata/example-mcp.json b/registry/coder/modules/claude-code/testdata/example-mcp.json new file mode 100644 index 000000000..438376e04 --- /dev/null +++ b/registry/coder/modules/claude-code/testdata/example-mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "filesystem": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"] + } + } +} From b118b1d4da373c451cb45105cb044f43f5cc20aa Mon Sep 17 00:00:00 2001 From: Atif Ali Date: Wed, 22 Apr 2026 09:20:23 +0000 Subject: [PATCH 12/23] fix(claude-code): harden install script against shell injection and log leaks Address deep review findings on the install-script assembly layer and the shell script itself. - Base64-encode every ARG_* value in local.install_script instead of only ARG_MCP and ARG_MCP_CONFIG_REMOTE_PATH. Previously, claude_code_version and claude_binary_path flowed directly into a single-quoted shell literal; a value containing a closing quote would break out and inject arbitrary shell. Decoding happens inside install.sh; the encoded wire form is [A-Za-z0-9+/=] only. - Reject non-https URLs in mcp_config_remote_path at plan time. Plain http allowed MITM on credentialed MCP configs and made SSRF to plaintext internal services easier. - Stop logging ARG_MCP and ARG_MCP_CONFIG_REMOTE_PATH contents in the install log. Inline MCP JSON can embed credentials for MCP servers; log only presence and size. - Track add-json successes and failures. If every MCP server fails to register, exit non-zero instead of silently passing. - Replace 'for url in $(jq -r ...)' with a while-read loop so URLs with whitespace don't word-split into multiple loop iterations. - Switch grep -q to grep -qF so paths with regex metacharacters don't cause false negatives in shell profile detection. --- registry/coder/modules/claude-code/main.tf | 22 ++++-- .../modules/claude-code/scripts/install.sh | 78 +++++++++++++++---- 2 files changed, 80 insertions(+), 20 deletions(-) diff --git a/registry/coder/modules/claude-code/main.tf b/registry/coder/modules/claude-code/main.tf index 164c8b839..4dce373b7 100644 --- a/registry/coder/modules/claude-code/main.tf +++ b/registry/coder/modules/claude-code/main.tf @@ -51,8 +51,13 @@ variable "mcp" { variable "mcp_config_remote_path" { type = list(string) - description = "List of URLs that return MCP JSON (same shape as `mcp`). Each is fetched and applied at user scope." + description = "List of HTTPS URLs that return MCP JSON (same shape as `mcp`). Each is fetched and applied at user scope." default = [] + + validation { + condition = alltrue([for url in var.mcp_config_remote_path : can(regex("^https://", url))]) + error_message = "mcp_config_remote_path entries must use https:// to avoid MITM attacks and SSRF to plaintext-only internal services." + } } variable "pre_install_script" { @@ -68,18 +73,21 @@ variable "post_install_script" { } locals { - # Prepend ARG_* exports pulled from Terraform variables directly in front of - # the install script body. coder-utils then takes the combined string, + # All ARG_* values are base64-encoded in Terraform and decoded inside + # install.sh. Base64 is the safe channel: the encoded form contains only + # [A-Za-z0-9+/=], so an attacker-controlled string value (e.g. a template + # parameter forwarded into `claude_code_version`) cannot break out of the + # single-quoted shell literal. coder-utils takes the combined string, # writes it to $HOME/.claude-module/install.sh, and runs it. One file on # disk, one base64 round-trip, no /tmp wrapper. install_script = join("\n", [ "#!/bin/bash", "set -euo pipefail", "", - "export ARG_CLAUDE_CODE_VERSION='${var.claude_code_version}'", - "export ARG_INSTALL_CLAUDE_CODE='${var.install_claude_code}'", - "export ARG_CLAUDE_BINARY_PATH='${var.claude_binary_path}'", - "export ARG_MCP='${var.mcp != "" ? base64encode(var.mcp) : ""}'", + "export ARG_CLAUDE_CODE_VERSION='${base64encode(var.claude_code_version)}'", + "export ARG_INSTALL_CLAUDE_CODE='${base64encode(tostring(var.install_claude_code))}'", + "export ARG_CLAUDE_BINARY_PATH='${base64encode(var.claude_binary_path)}'", + "export ARG_MCP='${base64encode(var.mcp)}'", "export ARG_MCP_CONFIG_REMOTE_PATH='${base64encode(jsonencode(var.mcp_config_remote_path))}'", "", file("${path.module}/scripts/install.sh"), diff --git a/registry/coder/modules/claude-code/scripts/install.sh b/registry/coder/modules/claude-code/scripts/install.sh index 69217752b..b9464a593 100644 --- a/registry/coder/modules/claude-code/scripts/install.sh +++ b/registry/coder/modules/claude-code/scripts/install.sh @@ -6,32 +6,63 @@ command_exists() { command -v "$1" > /dev/null 2>&1 } -ARG_CLAUDE_CODE_VERSION=${ARG_CLAUDE_CODE_VERSION:-} +# Decode every ARG_* from base64. Terraform base64-encodes all values so that +# attacker-controlled input (e.g. a workspace parameter forwarded into +# `claude_code_version`) cannot break out of the shell literal and inject +# commands. An empty input decodes to an empty string. +decode_arg() { + local raw="${1:-}" + if [ -z "$raw" ]; then + printf '' + return + fi + printf '%s' "$raw" | base64 -d +} + +ARG_CLAUDE_CODE_VERSION=$(decode_arg "${ARG_CLAUDE_CODE_VERSION:-}") +ARG_CLAUDE_CODE_VERSION=${ARG_CLAUDE_CODE_VERSION:-latest} +ARG_INSTALL_CLAUDE_CODE=$(decode_arg "${ARG_INSTALL_CLAUDE_CODE:-}") ARG_INSTALL_CLAUDE_CODE=${ARG_INSTALL_CLAUDE_CODE:-true} +ARG_CLAUDE_BINARY_PATH=$(decode_arg "${ARG_CLAUDE_BINARY_PATH:-}") ARG_CLAUDE_BINARY_PATH=${ARG_CLAUDE_BINARY_PATH:-"$HOME/.local/bin"} ARG_CLAUDE_BINARY_PATH="${ARG_CLAUDE_BINARY_PATH/#\~/$HOME}" ARG_CLAUDE_BINARY_PATH="${ARG_CLAUDE_BINARY_PATH//\$HOME/$HOME}" -ARG_MCP=$(echo -n "${ARG_MCP:-}" | base64 -d) -ARG_MCP_CONFIG_REMOTE_PATH=$(echo -n "${ARG_MCP_CONFIG_REMOTE_PATH:-}" | base64 -d) +ARG_MCP=$(decode_arg "${ARG_MCP:-}") +ARG_MCP_CONFIG_REMOTE_PATH=$(decode_arg "${ARG_MCP_CONFIG_REMOTE_PATH:-}") export PATH="$ARG_CLAUDE_BINARY_PATH:$PATH" +# Log only non-sensitive ARG_* values. ARG_MCP (inline JSON) and +# ARG_MCP_CONFIG_REMOTE_PATH (URL list) may contain credentials embedded in +# MCP server configs or internal URLs, so we log only presence, not content. echo "--------------------------------" printf "ARG_CLAUDE_CODE_VERSION: %s\n" "$ARG_CLAUDE_CODE_VERSION" printf "ARG_INSTALL_CLAUDE_CODE: %s\n" "$ARG_INSTALL_CLAUDE_CODE" printf "ARG_CLAUDE_BINARY_PATH: %s\n" "$ARG_CLAUDE_BINARY_PATH" -printf "ARG_MCP: %s\n" "$ARG_MCP" -printf "ARG_MCP_CONFIG_REMOTE_PATH: %s\n" "$ARG_MCP_CONFIG_REMOTE_PATH" +if [ -n "$ARG_MCP" ]; then + printf "ARG_MCP: [set, %d bytes]\n" "${#ARG_MCP}" +else + printf "ARG_MCP: [unset]\n" +fi +if [ -n "$ARG_MCP_CONFIG_REMOTE_PATH" ] && [ "$ARG_MCP_CONFIG_REMOTE_PATH" != "[]" ]; then + local_url_count=$(echo "$ARG_MCP_CONFIG_REMOTE_PATH" | jq -r '. | length' 2> /dev/null || echo "?") + printf "ARG_MCP_CONFIG_REMOTE_PATH: [%s URL(s)]\n" "$local_url_count" +else + printf "ARG_MCP_CONFIG_REMOTE_PATH: [unset]\n" +fi echo "--------------------------------" -# Ensures $ARG_CLAUDE_BINARY_PATH is on PATH across the common shell profiles so -# interactive shells started by the user can find the installed claude binary. +# Ensures $ARG_CLAUDE_BINARY_PATH is on PATH across the common shell profiles +# so interactive shells started by the user can find the installed claude +# binary. add_path_to_shell_profiles() { local path_dir="$1" for profile in "$HOME/.profile" "$HOME/.bash_profile" "$HOME/.bashrc" "$HOME/.zprofile" "$HOME/.zshrc"; do if [ -f "$profile" ]; then - if ! grep -q "$path_dir" "$profile" 2> /dev/null; then + # grep -F treats the path as a literal string so regex metacharacters + # (uncommon but valid in paths) don't cause false negatives. + if ! grep -qF "$path_dir" "$profile" 2> /dev/null; then echo "export PATH=\"\$PATH:$path_dir\"" >> "$profile" echo "Added $path_dir to $profile" fi @@ -40,7 +71,7 @@ add_path_to_shell_profiles() { local fish_config="$HOME/.config/fish/config.fish" if [ -f "$fish_config" ]; then - if ! grep -q "$path_dir" "$fish_config" 2> /dev/null; then + if ! grep -qF "$path_dir" "$fish_config" 2> /dev/null; then echo "fish_add_path $path_dir" >> "$fish_config" echo "Added $path_dir to $fish_config" fi @@ -75,6 +106,11 @@ ensure_claude_in_path() { add_path_to_shell_profiles "$CLAUDE_DIR" } +# Totals across all MCP sources. Populated by add_mcp_servers, inspected at +# the end of apply_mcp so the user sees whether any server actually landed. +MCP_ADDED=0 +MCP_FAILED=0 + # Adds each MCP server from the provided JSON at user scope. The claude CLI # writes to ~/.claude.json; this module does not touch that file directly. add_mcp_servers() { @@ -84,8 +120,12 @@ add_mcp_servers() { while IFS= read -r server_name && IFS= read -r server_json; do echo "------------------------" echo "Executing: claude mcp add-json --scope user \"$server_name\" ($source_desc)" - claude mcp add-json --scope user "$server_name" "$server_json" \ - || echo "Warning: Failed to add MCP server '$server_name', continuing..." + if claude mcp add-json --scope user "$server_name" "$server_json"; then + MCP_ADDED=$((MCP_ADDED + 1)) + else + MCP_FAILED=$((MCP_FAILED + 1)) + echo "Warning: Failed to add MCP server '$server_name', continuing..." + fi echo "------------------------" done < <(echo "$mcp_json" | jq -r '.mcpServers | to_entries[] | .key, (.value | @json)') } @@ -117,7 +157,10 @@ apply_mcp() { fi if [ -n "$ARG_MCP_CONFIG_REMOTE_PATH" ] && [ "$ARG_MCP_CONFIG_REMOTE_PATH" != "[]" ]; then - for url in $(echo "$ARG_MCP_CONFIG_REMOTE_PATH" | jq -r '.[]'); do + # Read one URL per line so URLs with whitespace stay intact. A plain + # `for url in $(...)` would word-split and break URLs silently. + while IFS= read -r url; do + [ -z "$url" ] && continue echo "Fetching MCP configuration from: $url" mcp_json=$(curl -fsSL "$url") || { echo "Warning: Failed to fetch MCP configuration from '$url', continuing..." @@ -128,7 +171,16 @@ apply_mcp() { continue fi add_mcp_servers "$mcp_json" "from $url" - done + done < <(echo "$ARG_MCP_CONFIG_REMOTE_PATH" | jq -r '.[]') + fi + + local attempted=$((MCP_ADDED + MCP_FAILED)) + if [ "$attempted" -gt 0 ]; then + echo "MCP configuration complete: $MCP_ADDED added, $MCP_FAILED failed." + if [ "$MCP_FAILED" -gt 0 ] && [ "$MCP_ADDED" -eq 0 ]; then + echo "Error: all $MCP_FAILED MCP server(s) failed to register." >&2 + exit 1 + fi fi } From 1616ec4bc5274ff507c2149f1145c94e50e7e8a0 Mon Sep 17 00:00:00 2001 From: Atif Ali Date: Wed, 22 Apr 2026 09:20:39 +0000 Subject: [PATCH 13/23] test(claude-code): rename happy-path, assert script counts, dedupe casts Tighten the test surface based on deep review findings. - Rename 'happy-path' to 'install-script-runs-with-mock' and assert the 'Skipping Claude Code installation' log line. The previous name implied a full install run, but setup() defaults to install_claude_code=false with a mock binary. Test name now matches what it actually checks. - Pass coderEnvVars through runModuleScripts in env-map-passthrough so the script execution context sees the Terraform-declared values, not just the Terraform state. - Add tftest cases asserting coder-utils script_names output: default surface creates only install, pre/post appear only when set, start never exists. Covers the claim documented in the README. - Add tftest case asserting http:// in mcp_config_remote_path is rejected by the new validation. - Extract a ResourceAttributes type alias and getStringAttr helper to replace three identical Record casts. - Drop the duplicate TerraformState import (already imported on line 16). - Switch the failing MCP remote URL to an https 127.0.0.1:19999 variant so it still fails the fetch but passes the https-only validation. --- .../coder/modules/claude-code/main.test.ts | 48 ++++++++++------ .../coder/modules/claude-code/main.tftest.hcl | 56 +++++++++++++++++++ 2 files changed, 88 insertions(+), 16 deletions(-) diff --git a/registry/coder/modules/claude-code/main.test.ts b/registry/coder/modules/claude-code/main.test.ts index 5139c7944..3294ee970 100644 --- a/registry/coder/modules/claude-code/main.test.ts +++ b/registry/coder/modules/claude-code/main.test.ts @@ -16,21 +16,28 @@ import { type TerraformState, } from "~test"; import { loadTestFile, writeExecutable } from "../agentapi/test-util"; -import type { TerraformState } from "~test"; + +// Terraform state resource attributes are untyped JSON; this alias makes the +// shape explicit everywhere we unpack it. +type ResourceAttributes = Record; + +const getStringAttr = (attrs: ResourceAttributes, key: string): string => + String(attrs[key] ?? ""); /** - * Walk every instance of every coder_env resource and return a flat map of - * env var names to values. The upstream extractCoderEnvVars helper only - * reads `instances[0]`, which misses every for_each entry past the first. + * Walk every instance of every `coder_env` resource and return a flat map of + * env var names to values. The `extractCoderEnvVars` helper in + * `agentapi/test-util.ts` only reads `instances[0]`, which misses every + * `for_each` entry past the first. This local version covers all instances. */ const extractCoderEnvVars = (state: TerraformState): Record => { const envVars: Record = {}; for (const resource of state.resources) { if (resource.type !== "coder_env") continue; for (const instance of resource.instances) { - const attrs = instance.attributes as Record; - const name = attrs.name as string; - const value = attrs.value as string; + const attrs = instance.attributes as ResourceAttributes; + const name = getStringAttr(attrs, "name"); + const value = getStringAttr(attrs, "value"); if (name && value) envVars[name] = value; } } @@ -59,7 +66,7 @@ interface SetupProps { } // Order scripts in the same sequence coder-utils enforces at runtime via -// `coder exp sync`: pre_install -> install -> post_install. +// `coder exp sync`: first pre_install, then install, then post_install. const SCRIPT_ORDER = [ "Pre-Install Script", "Install Script", @@ -71,10 +78,10 @@ const collectScripts = (state: TerraformState): string[] => { for (const resource of state.resources) { if (resource.type !== "coder_script") continue; for (const instance of resource.instances) { - const attrs = instance.attributes as Record; + const attrs = instance.attributes as ResourceAttributes; scripts.push({ - displayName: String(attrs.display_name ?? ""), - script: String(attrs.script ?? ""), + displayName: getStringAttr(attrs, "display_name"), + script: getStringAttr(attrs, "script"), }); } } @@ -167,7 +174,10 @@ describe("claude-code", async () => { await runTerraformInit(import.meta.dir); }); - test("happy-path", async () => { + test("install-script-runs-with-mock", async () => { + // Default setup: install_claude_code=false with a mocked claude binary. + // Verifies that install.sh is assembled, written by coder-utils, executed + // on the agent, and leaves a readable install.log. const { id } = await setup(); await runModuleScripts(id); const installLog = await readFileContainer( @@ -175,6 +185,7 @@ describe("claude-code", async () => { "/home/coder/.claude-module/install.log", ); expect(installLog).toContain("ARG_INSTALL_CLAUDE_CODE"); + expect(installLog).toContain("Skipping Claude Code installation"); }); test("install-claude-code-version", async () => { @@ -230,7 +241,9 @@ describe("claude-code", async () => { expect(coderEnvVars["DISABLE_AUTOUPDATER"]).toBe("1"); expect(coderEnvVars["CUSTOM_VAR"]).toBe("hello"); expect(coderEnvVars["CLAUDE_API_KEY"]).toBeUndefined(); - await runModuleScripts(id); + // Export the Terraform-declared env vars into the script execution + // context so the install script sees the values the module produced. + await runModuleScripts(id, coderEnvVars); }); test("no-claude-no-mcp-is-fine", async () => { @@ -319,7 +332,10 @@ describe("claude-code", async () => { }); test("claude-mcp-remote-user-scope", async () => { - const failingUrl = "http://localhost:19999/mcp.json"; + // HTTPS URL on an unreachable port so the fetch fails with the expected + // "Warning: Failed to fetch" message (the module validation enforces + // https:// so plain http URLs are rejected at plan time). + const failingUrl = "https://127.0.0.1:19999/mcp.json"; const successUrl = "https://raw.githubusercontent.com/coder/coder/main/.mcp.json"; @@ -363,8 +379,8 @@ describe("claude-code", async () => { const scripts = state.resources.filter((r) => r.type === "coder_script"); const displayNames = scripts.flatMap((r) => - r.instances.map( - (i) => (i.attributes as Record).display_name, + r.instances.map((i) => + getStringAttr(i.attributes as ResourceAttributes, "display_name"), ), ); expect(displayNames).toEqual(["Claude Code: Install Script"]); diff --git a/registry/coder/modules/claude-code/main.tftest.hcl b/registry/coder/modules/claude-code/main.tftest.hcl index a1977bd9c..9cba19d61 100644 --- a/registry/coder/modules/claude-code/main.tftest.hcl +++ b/registry/coder/modules/claude-code/main.tftest.hcl @@ -122,6 +122,62 @@ run "test_with_pre_post_install" { condition = var.post_install_script == "echo post" error_message = "post_install_script should be forwarded" } + + # coder-utils exposes `script_names` with empty strings for scripts it did + # not create; a non-empty name confirms the downstream resource was emitted. + assert { + condition = module.coder-utils.script_names.pre_install != "" + error_message = "Pre-install script name should be populated when pre_install_script is set" + } + + assert { + condition = module.coder-utils.script_names.post_install != "" + error_message = "Post-install script name should be populated when post_install_script is set" + } + + assert { + condition = module.coder-utils.script_names.install != "" + error_message = "Install script name should always be populated" + } +} + +run "test_defaults_produce_only_install_script" { + command = plan + + variables { + agent_id = "test-agent" + } + + assert { + condition = module.coder-utils.script_names.pre_install == "" + error_message = "Pre-install script should be absent by default" + } + + assert { + condition = module.coder-utils.script_names.post_install == "" + error_message = "Post-install script should be absent by default" + } + + assert { + condition = module.coder-utils.script_names.start == "" + error_message = "Start script should never be created by claude-code" + } + + assert { + condition = module.coder-utils.script_names.install != "" + error_message = "Install script must always be created" + } +} + +run "test_mcp_remote_rejects_http" { + command = plan + + variables { + agent_id = "test-agent" + mcp_config_remote_path = ["http://example.com/mcp.json"] + } + + expect_failures = [var.mcp_config_remote_path] } run "test_claude_binary_path_validation" { From c03be50b4a948dbf0ffb750fd901b2bd42b7b1d3 Mon Sep 17 00:00:00 2001 From: Atif Ali Date: Wed, 22 Apr 2026 09:20:47 +0000 Subject: [PATCH 14/23] docs(claude-code): warn Tasks and AgentAPI users to stay on v4 Add a CAUTION admonition to the Upgrade section. Users who depend on report_tasks, ai_prompt, continue, resume_session_id, web_app, cli_app, or install_agentapi lose that surface entirely in v5.0.0. The previous wording ('switch to the upcoming dedicated modules') was too soft for users whose templates are live today. --- registry/coder/modules/claude-code/README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/registry/coder/modules/claude-code/README.md b/registry/coder/modules/claude-code/README.md index 791e1c7d2..53072f584 100644 --- a/registry/coder/modules/claude-code/README.md +++ b/registry/coder/modules/claude-code/README.md @@ -237,6 +237,9 @@ cat $HOME/.claude-module/post_install.log ## Upgrading from v4.x +> [!CAUTION] +> If your template depends on Coder Tasks (`report_tasks`, `ai_prompt`, `continue`, `resume_session_id`, `enable_state_persistence`, `dangerously_skip_permissions`) or AgentAPI web-app integration (`web_app`, `cli_app`, `install_agentapi`, `agentapi_version`), stay on `v4.x` until the dedicated `claude-code-tasks` and `agentapi` modules ship. v5.0.0 removes all of that surface. + Breaking changes in v5.0.0: - `claude_api_key`, `claude_code_oauth_token`, `model`, `disable_autoupdater`, `claude_md_path` removed as dedicated variables. Set them through `env` instead. The module now emits `ANTHROPIC_API_KEY` (the variable Claude Code actually reads), not `CLAUDE_API_KEY`. From 615d41a9fb44b6612c977538da8fbd1507853988 Mon Sep 17 00:00:00 2001 From: Atif Ali Date: Wed, 22 Apr 2026 09:36:40 +0000 Subject: [PATCH 15/23] feat(claude-code): emit scripts output for downstream composition Add a `scripts` output that lists the `coder exp sync` names for every `coder_script` this module actually creates, in run order. Absent scripts (pre/post when their inputs are unset; start is never created by claude-code) are filtered out, so downstream consumers can use `${join(" ", module.claude-code.scripts)}` with `coder exp sync want` to serialize their own scripts behind Claude Code's install without having to know which optional scripts are present. The README's Outputs section shows the composition pattern. Future `claude-code-tasks` or `boundary` modules can declare a dependency on this list to run after Claude is installed. No app_id output: claude-code v5 does not create any `coder_app` resource. That surface belongs on the dedicated Tasks module when it ships. --- registry/coder/modules/claude-code/README.md | 29 ++++++++++ registry/coder/modules/claude-code/main.tf | 17 ++++++ .../coder/modules/claude-code/main.tftest.hcl | 57 +++++++++++++++++++ 3 files changed, 103 insertions(+) diff --git a/registry/coder/modules/claude-code/README.md b/registry/coder/modules/claude-code/README.md index 53072f584..8557bce1f 100644 --- a/registry/coder/modules/claude-code/README.md +++ b/registry/coder/modules/claude-code/README.md @@ -225,6 +225,35 @@ module "claude-code" { } ``` +## Outputs + +| Output | Type | Description | +| --------- | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `scripts` | `list(string)` | `coder exp sync` names for every `coder_script` this module actually creates, in the run order `coder-utils` enforces (pre-install, install, post-install). Absent scripts are not in the list. | + +Use `scripts` to gate a downstream module behind Claude Code's install: + +```tf +module "claude-code" { + source = "registry.coder.com/coder/claude-code/coder" + version = "5.0.0" + agent_id = coder_agent.main.id +} + +resource "coder_script" "wait_for_claude" { + agent_id = coder_agent.main.id + display_name = "Wait for Claude Code" + run_on_start = true + script = <<-EOT + #!/bin/bash + coder exp sync want my-downstream-script ${join(" ", module.claude-code.scripts)} + coder exp sync start my-downstream-script + # your logic here + coder exp sync complete my-downstream-script + EOT +} +``` + ## Troubleshooting Module logs live at `$HOME/.claude-module/`: diff --git a/registry/coder/modules/claude-code/main.tf b/registry/coder/modules/claude-code/main.tf index 4dce373b7..735fbed72 100644 --- a/registry/coder/modules/claude-code/main.tf +++ b/registry/coder/modules/claude-code/main.tf @@ -121,3 +121,20 @@ module "coder-utils" { install_script = local.install_script } + +# Sync names for the coder_scripts this module actually creates, in the order +# coder-utils enforces at runtime (pre-install, then install, then +# post-install). Downstream modules can `coder exp sync want ` to +# serialize behind the install. claude-code never emits a start script so it +# is never in the list. If pre- or post-install scripts are not configured, +# they are absent from the list entirely, not included as empty strings. +output "scripts" { + description = "Ordered list of `coder exp sync` names for every coder_script this module creates. Use these to gate downstream scripts behind Claude Code's install with `coder exp sync want`." + value = [ + for name in [ + module.coder-utils.script_names.pre_install, + module.coder-utils.script_names.install, + module.coder-utils.script_names.post_install, + ] : name if name != "" + ] +} diff --git a/registry/coder/modules/claude-code/main.tftest.hcl b/registry/coder/modules/claude-code/main.tftest.hcl index 9cba19d61..c6d1fad3c 100644 --- a/registry/coder/modules/claude-code/main.tftest.hcl +++ b/registry/coder/modules/claude-code/main.tftest.hcl @@ -139,6 +139,27 @@ run "test_with_pre_post_install" { condition = module.coder-utils.script_names.install != "" error_message = "Install script name should always be populated" } + + # `scripts` output is a filtered, run-order list. All three expected. + assert { + condition = length(output.scripts) == 3 + error_message = "scripts output should have exactly 3 entries when pre/post are set" + } + + assert { + condition = output.scripts[0] == module.coder-utils.script_names.pre_install + error_message = "scripts[0] must be the pre-install name (run-order)" + } + + assert { + condition = output.scripts[1] == module.coder-utils.script_names.install + error_message = "scripts[1] must be the install name (run-order)" + } + + assert { + condition = output.scripts[2] == module.coder-utils.script_names.post_install + error_message = "scripts[2] must be the post-install name (run-order)" + } } run "test_defaults_produce_only_install_script" { @@ -167,6 +188,42 @@ run "test_defaults_produce_only_install_script" { condition = module.coder-utils.script_names.install != "" error_message = "Install script must always be created" } + + # Defaults: `scripts` output holds exactly one entry (install). + assert { + condition = length(output.scripts) == 1 + error_message = "scripts output should contain exactly 1 entry by default" + } + + assert { + condition = output.scripts[0] == module.coder-utils.script_names.install + error_message = "scripts[0] must be the install script name" + } +} + +run "test_scripts_output_excludes_post_when_only_pre_set" { + command = plan + + variables { + agent_id = "test-agent" + pre_install_script = "echo only-pre" + } + + # With only pre_install set, `scripts` holds 2 entries: pre, install. + assert { + condition = length(output.scripts) == 2 + error_message = "scripts output should contain exactly 2 entries when only pre is set" + } + + assert { + condition = output.scripts[0] == module.coder-utils.script_names.pre_install + error_message = "scripts[0] must be pre-install when it is set" + } + + assert { + condition = output.scripts[1] == module.coder-utils.script_names.install + error_message = "scripts[1] must be install when post is unset" + } } run "test_mcp_remote_rejects_http" { From 2669f305d0c673509f2a4cc1bdbf4611b8e0ade8 Mon Sep 17 00:00:00 2001 From: Atif Ali Date: Wed, 22 Apr 2026 11:05:25 +0000 Subject: [PATCH 16/23] docs(claude-code): add unattended-mode example for template admins The most common template-admin use case is 'make Claude just work for an agent or headless workspace without human interaction.' The existing README shows envs, MCP, and a one-liner pre_install_script, but never walks through how to skip the first-run wizard and the bypass-mode consent banner. Add a dedicated section documenting: - settings.json with permissions.defaultMode = bypassPermissions, permissions.deny allowlist, and skipDangerousModePermissionPrompt (verified live against Claude Code CLI v2.1.117 on a workspace). - ~/.claude.json hasCompletedOnboarding merged via jq so installer keys (userID, firstStartTime, installMethod, autoUpdates, migrationVersion) are preserved. - A runtime-flag alternative for one-off 'claude -p' runs. Verified the complete example end-to-end on dev.coder.com: fresh workspace lands with the expected settings.json keys, onboarding skipped, and installer-managed state intact. --- registry/coder/modules/claude-code/README.md | 65 ++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/registry/coder/modules/claude-code/README.md b/registry/coder/modules/claude-code/README.md index 8557bce1f..e14bcd849 100644 --- a/registry/coder/modules/claude-code/README.md +++ b/registry/coder/modules/claude-code/README.md @@ -225,6 +225,71 @@ module "claude-code" { } ``` +## Unattended mode (skip setup wizard and permission prompts) + +For template-admin setups where Claude Code should just work — CI agents, headless workspaces, AI coding agents that do not have a human to click through the first-run wizard or confirm bypass-permissions mode — pre-write `settings.json` and `~/.claude.json` via `pre_install_script`. + +```tf +module "claude-code" { + source = "registry.coder.com/coder/claude-code/coder" + version = "5.0.0" + agent_id = coder_agent.main.id + + env = { + ANTHROPIC_API_KEY = var.anthropic_api_key + } + + pre_install_script = <<-EOT + #!/bin/bash + set -euo pipefail + + # Settings: default to bypassPermissions so tool calls do not prompt, + # silence the "dangerous mode" consent banner, and keep a deny list for + # anything the agent must never read. + mkdir -p "$HOME/.claude" + cat > "$HOME/.claude/settings.json" <<'JSON' + { + "permissions": { + "defaultMode": "bypassPermissions", + "deny": ["Read(./.env)", "Read(./secrets/**)", "Read(**/*.pem)"] + }, + "skipDangerousModePermissionPrompt": true + } + JSON + + # User config: skip the theme and first-run onboarding flow. The official + # installer creates ~/.claude.json before this pre_install_script runs, + # so merge rather than overwrite to preserve installer-managed keys + # (userID, autoUpdates, migrationVersion, firstStartTime). + if [ -f "$HOME/.claude.json" ]; then + tmp=$(mktemp) + jq '. + {hasCompletedOnboarding: true}' "$HOME/.claude.json" > "$tmp" \ + && mv "$tmp" "$HOME/.claude.json" + else + printf '%s\n' '{"hasCompletedOnboarding": true}' > "$HOME/.claude.json" + fi + EOT +} +``` + +Keys verified live against Claude Code CLI v2.1.117: + +| File | Key | Effect | +| ------------------------- | ----------------------------------- | ------------------------------------------------------------------------------------- | +| `~/.claude/settings.json` | `permissions.defaultMode` | `"bypassPermissions"`, `"acceptEdits"`, `"plan"`, `"auto"`, `"default"`, `"dontAsk"`. | +| `~/.claude/settings.json` | `permissions.allow` / `deny` | Per-tool allowlist / denylist (e.g. `"Bash(git *)"`, `"Read(./secrets/**)"`). | +| `~/.claude/settings.json` | `skipDangerousModePermissionPrompt` | Silences the one-time "enable bypassPermissions mode" consent banner. | +| `~/.claude.json` | `hasCompletedOnboarding` | Skips the first-run theme picker and welcome screens. | + +> [!NOTE] +> Pre-writing these files makes sense for automation and agents. Human users who expect the usual onboarding and per-project trust dialog should not use this pattern. + +For one-off non-interactive runs, prefer the runtime flag instead of pre-writing config: + +```bash +claude -p "$PROMPT" --dangerously-skip-permissions --permission-mode bypassPermissions +``` + ## Outputs | Output | Type | Description | From 512f3b01b0c26aff043fb7e730edaab73b7bf383 Mon Sep 17 00:00:00 2001 From: Atif Ali Date: Wed, 22 Apr 2026 11:56:59 +0000 Subject: [PATCH 17/23] docs(claude-code): add Bedrock and Vertex examples, tighten README Review feedback on the README: - AI Gateway description drops 'MCP policy enforcement' because it is not shipping yet; keeps the auditing and token usage claims that are live. - Add a first-class AWS Bedrock example using the env map with either a bearer token (AWS_BEARER_TOKEN_BEDROCK) or access key pair. Mirrors what v4 had but composed via env, not dedicated variables. - Add a first-class Google Vertex AI example. Requires a pre_install_script to drop the SA JSON and point GOOGLE_APPLICATION_CREDENTIALS at it; keep gcloud installation as the template author's choice. - Clarify 'Using a pre-installed binary': claude_binary_path is only consulted when install_claude_code = false; the official installer drops the binary at $HOME/.local/bin and does not accept a destination override. - Drop the 'Scripts produced' section. It restated an implementation detail that duplicates the Outputs section and the pre/post-install extension docs. - Simplify the Unattended mode section: keep the example and runtime-flag alternative, drop the keys-verified table and the human-user note. Point at upstream Claude Code docs for canonical key definitions. - Drop the Outputs table; keep the composition example. The type and description already live in the module's output block. --- registry/coder/modules/claude-code/README.md | 108 +++++++++++++------ 1 file changed, 76 insertions(+), 32 deletions(-) diff --git a/registry/coder/modules/claude-code/README.md b/registry/coder/modules/claude-code/README.md index e14bcd849..8474f2293 100644 --- a/registry/coder/modules/claude-code/README.md +++ b/registry/coder/modules/claude-code/README.md @@ -72,7 +72,7 @@ module "claude-code" { ### Coder AI Gateway -Route Claude Code through [Coder AI Gateway](https://coder.com/docs/ai-coder/ai-gateway) for centralized auditing, token usage tracking, and MCP policy enforcement. Requires Coder Premium with the AI Governance add-on and `CODER_AIBRIDGE_ENABLED=true` on the server. +Route Claude Code through [Coder AI Gateway](https://coder.com/docs/ai-coder/ai-gateway) for centralized auditing and token usage tracking. Requires Coder Premium with the AI Governance add-on and `CODER_AIBRIDGE_ENABLED=true` on the server. Point `ANTHROPIC_BASE_URL` at your deployment's `/api/v2/aibridge/anthropic` endpoint and authenticate with the workspace owner's session token via `ANTHROPIC_AUTH_TOKEN`. Claude Code reads both variables natively; no API key is required. @@ -96,7 +96,75 @@ module "claude-code" { > [!NOTE] > AI Gateway was previously named AI Bridge. The server-side endpoints and environment variables still use the `aibridge` prefix; only the product name changed. -### Other custom endpoints (Bedrock, Vertex, LiteLLM, a private proxy) +### AWS Bedrock + +Route Claude Code through [AWS Bedrock](https://docs.claude.com/en/docs/claude-code/amazon-bedrock) to access Claude models via your AWS account. Requires an AWS account with Bedrock access, the target Claude models enabled in the Bedrock console, and IAM permissions that allow `bedrock:InvokeModel` and `bedrock:InvokeModelWithResponseStream`. + +Pick either an access key pair or a Bedrock bearer token for auth; do not set both. + +```tf +variable "aws_bearer_token_bedrock" { + type = string + sensitive = true +} + +module "claude-code" { + source = "registry.coder.com/coder/claude-code/coder" + version = "5.0.0" + agent_id = coder_agent.main.id + + env = { + CLAUDE_CODE_USE_BEDROCK = "1" + AWS_REGION = "us-east-1" + ANTHROPIC_MODEL = "global.anthropic.claude-sonnet-4-5-20250929-v1:0" + AWS_BEARER_TOKEN_BEDROCK = var.aws_bearer_token_bedrock + # Or, with access keys instead of the bearer token: + # AWS_ACCESS_KEY_ID = var.aws_access_key_id + # AWS_SECRET_ACCESS_KEY = var.aws_secret_access_key + } +} +``` + +### Google Vertex AI + +Route Claude Code through [Google Vertex AI](https://docs.claude.com/en/docs/claude-code/google-vertex-ai). Requires a GCP project with Vertex AI enabled, Claude models enabled via Model Garden, and a service account with the Vertex AI User role. + +The service account JSON has to land on the workspace filesystem where Claude can read it, so authenticating gcloud happens in `pre_install_script`: + +```tf +variable "vertex_sa_json" { + type = string + description = "Full JSON body of a GCP service account key with Vertex AI User." + sensitive = true +} + +module "claude-code" { + source = "registry.coder.com/coder/claude-code/coder" + version = "5.0.0" + agent_id = coder_agent.main.id + + env = { + CLAUDE_CODE_USE_VERTEX = "1" + ANTHROPIC_VERTEX_PROJECT_ID = "your-gcp-project-id" + CLOUD_ML_REGION = "global" + ANTHROPIC_MODEL = "claude-sonnet-4@20250514" + GOOGLE_APPLICATION_CREDENTIALS = "$HOME/.config/gcloud/sa.json" + VERTEX_SA_JSON = var.vertex_sa_json + } + + pre_install_script = <<-EOT + #!/bin/bash + set -euo pipefail + mkdir -p "$HOME/.config/gcloud" + printf '%s' "$VERTEX_SA_JSON" > "$HOME/.config/gcloud/sa.json" + chmod 600 "$HOME/.config/gcloud/sa.json" + EOT +} +``` + +Install `gcloud` itself in the workspace image, in `pre_install_script`, or via a separate Coder module; this example leaves that choice to the template author. + +### Other custom endpoints (LiteLLM, a private proxy) Same pattern with your own endpoint and token. The [Claude Code env-vars reference](https://docs.claude.com/en/docs/claude-code/env-vars) lists every supported name. @@ -173,7 +241,9 @@ module "claude-code" { ## Using a pre-installed binary -Set `install_claude_code = false` and point `claude_binary_path` at the directory containing the binary. +`claude_binary_path` is only consulted when `install_claude_code = false`. The official installer always drops the binary at `$HOME/.local/bin/claude` and does not accept a custom destination, so combining `install_claude_code = true` with a custom `claude_binary_path` is rejected at plan time. + +To use a binary you bake into the image (or install via a separate module), set `install_claude_code = false` and point `claude_binary_path` at the directory containing it: ```tf module "claude-code" { @@ -185,18 +255,6 @@ module "claude-code" { } ``` -## Scripts produced - -By default this module creates exactly one `coder_script` on the agent: `Claude Code: Install Script`. Additional scripts appear only when you opt in: - -| Script | Created when | -| ---------------------------------- | ----------------------------- | -| `Claude Code: Install Script` | Always. | -| `Claude Code: Pre-Install Script` | `pre_install_script` is set. | -| `Claude Code: Post-Install Script` | `post_install_script` is set. | - -No start script is produced in any configuration. Compose with a dedicated module (e.g. a future Tasks module) if you need one. - ## Extending with pre/post install scripts Use `pre_install_script` and `post_install_script` for custom setup (e.g. writing `~/.claude/settings.json` permission rules, installing cloud SDKs, pulling secrets). @@ -272,19 +330,9 @@ module "claude-code" { } ``` -Keys verified live against Claude Code CLI v2.1.117: - -| File | Key | Effect | -| ------------------------- | ----------------------------------- | ------------------------------------------------------------------------------------- | -| `~/.claude/settings.json` | `permissions.defaultMode` | `"bypassPermissions"`, `"acceptEdits"`, `"plan"`, `"auto"`, `"default"`, `"dontAsk"`. | -| `~/.claude/settings.json` | `permissions.allow` / `deny` | Per-tool allowlist / denylist (e.g. `"Bash(git *)"`, `"Read(./secrets/**)"`). | -| `~/.claude/settings.json` | `skipDangerousModePermissionPrompt` | Silences the one-time "enable bypassPermissions mode" consent banner. | -| `~/.claude.json` | `hasCompletedOnboarding` | Skips the first-run theme picker and welcome screens. | +Key reference: [`permissions`](https://docs.claude.com/en/docs/claude-code/settings) in `~/.claude/settings.json`, [`hasCompletedOnboarding`](https://docs.claude.com/en/docs/claude-code/settings) in `~/.claude.json`. -> [!NOTE] -> Pre-writing these files makes sense for automation and agents. Human users who expect the usual onboarding and per-project trust dialog should not use this pattern. - -For one-off non-interactive runs, prefer the runtime flag instead of pre-writing config: +For one-off non-interactive runs, prefer a runtime flag over pre-writing config: ```bash claude -p "$PROMPT" --dangerously-skip-permissions --permission-mode bypassPermissions @@ -292,11 +340,7 @@ claude -p "$PROMPT" --dangerously-skip-permissions --permission-mode bypassPermi ## Outputs -| Output | Type | Description | -| --------- | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `scripts` | `list(string)` | `coder exp sync` names for every `coder_script` this module actually creates, in the run order `coder-utils` enforces (pre-install, install, post-install). Absent scripts are not in the list. | - -Use `scripts` to gate a downstream module behind Claude Code's install: +`scripts` is a list of `coder exp sync` names for every `coder_script` this module creates, in the order `coder-utils` runs them. Use it to gate a downstream `coder_script` behind Claude Code's install: ```tf module "claude-code" { From 6534587592a202649030dd977c1c28630b0a75a8 Mon Sep 17 00:00:00 2001 From: Atif Ali Date: Wed, 22 Apr 2026 12:40:05 +0000 Subject: [PATCH 18/23] refactor(claude-code): delegate scripts output filtering to coder-utils Replace the local `for name in [...] : name if name != ""` filter with a one-line passthrough of `module.coder-utils.scripts`, the new upstream output that returns the run-ordered, filtered list of `coder exp sync` names. The filtering logic is generic to any module that wraps coder-utils, so it lives better there than in every consumer. claude-code just forwards it under its own `output "scripts"` so downstream templates keep the same surface. Requires PR #842 on coder/registry (feat/coder-utils-optional-install-start). --- registry/coder/modules/claude-code/main.tf | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/registry/coder/modules/claude-code/main.tf b/registry/coder/modules/claude-code/main.tf index 735fbed72..9b62d959e 100644 --- a/registry/coder/modules/claude-code/main.tf +++ b/registry/coder/modules/claude-code/main.tf @@ -122,19 +122,11 @@ module "coder-utils" { install_script = local.install_script } -# Sync names for the coder_scripts this module actually creates, in the order -# coder-utils enforces at runtime (pre-install, then install, then -# post-install). Downstream modules can `coder exp sync want ` to -# serialize behind the install. claude-code never emits a start script so it -# is never in the list. If pre- or post-install scripts are not configured, -# they are absent from the list entirely, not included as empty strings. +# Passthrough of coder-utils' run-ordered, filtered sync-name list. +# claude-code adds no scripts of its own beyond what coder-utils creates for +# pre_install, install, and post_install, so downstream modules can gate +# behind this with `coder exp sync want`. output "scripts" { description = "Ordered list of `coder exp sync` names for every coder_script this module creates. Use these to gate downstream scripts behind Claude Code's install with `coder exp sync want`." - value = [ - for name in [ - module.coder-utils.script_names.pre_install, - module.coder-utils.script_names.install, - module.coder-utils.script_names.post_install, - ] : name if name != "" - ] + value = module.coder-utils.scripts } From 1384fd65db7364d2f34fc6c432c31860694d5b71 Mon Sep 17 00:00:00 2001 From: Atif Ali Date: Wed, 22 Apr 2026 12:47:49 +0000 Subject: [PATCH 19/23] refactor(claude-code): move module_directory to $HOME/.coder-modules/claude-code Follow the shared convention proposed in coder/registry#782 for stable per-module persistent storage. coder-utils threads this value through into the generated pre_install, install, and post_install wrapper scripts, so scripts and logs now live at $HOME/.coder-modules/claude-code/ instead of $HOME/.claude-module/. Namespacing under $HOME/.coder-modules/ keeps $HOME from getting polluted with one dotdir per module when a workspace uses several, and matches what boundary, tasks, and agentapi are standardizing on. Updates the README troubleshooting block and the 8 log-path expectations in the bun test suite to match. Related to #782. --- registry/coder/modules/claude-code/README.md | 8 ++++---- registry/coder/modules/claude-code/main.test.ts | 16 ++++++++-------- registry/coder/modules/claude-code/main.tf | 4 ++-- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/registry/coder/modules/claude-code/README.md b/registry/coder/modules/claude-code/README.md index 8474f2293..77da1a84b 100644 --- a/registry/coder/modules/claude-code/README.md +++ b/registry/coder/modules/claude-code/README.md @@ -365,12 +365,12 @@ resource "coder_script" "wait_for_claude" { ## Troubleshooting -Module logs live at `$HOME/.claude-module/`: +Module logs live at `$HOME/.coder-modules/claude-code/`: ```bash -cat $HOME/.claude-module/install.log -cat $HOME/.claude-module/pre_install.log -cat $HOME/.claude-module/post_install.log +cat $HOME/.coder-modules/claude-code/install.log +cat $HOME/.coder-modules/claude-code/pre_install.log +cat $HOME/.coder-modules/claude-code/post_install.log ``` ## Upgrading from v4.x diff --git a/registry/coder/modules/claude-code/main.test.ts b/registry/coder/modules/claude-code/main.test.ts index 3294ee970..3524a4437 100644 --- a/registry/coder/modules/claude-code/main.test.ts +++ b/registry/coder/modules/claude-code/main.test.ts @@ -182,7 +182,7 @@ describe("claude-code", async () => { await runModuleScripts(id); const installLog = await readFileContainer( id, - "/home/coder/.claude-module/install.log", + "/home/coder/.coder-modules/claude-code/install.log", ); expect(installLog).toContain("ARG_INSTALL_CLAUDE_CODE"); expect(installLog).toContain("Skipping Claude Code installation"); @@ -201,7 +201,7 @@ describe("claude-code", async () => { const resp = await execContainer(id, [ "bash", "-c", - "cat /home/coder/.claude-module/install.log", + "cat /home/coder/.coder-modules/claude-code/install.log", ]); expect(resp.stdout).toContain(version_to_install); }); @@ -270,7 +270,7 @@ describe("claude-code", async () => { expect(resp.exitCode).toBe(0); const installLog = await readFileContainer( id, - "/home/coder/.claude-module/install.log", + "/home/coder/.coder-modules/claude-code/install.log", ); expect(installLog).toContain("claude binary not found on PATH"); }); @@ -300,7 +300,7 @@ describe("claude-code", async () => { expect(resp.exitCode).not.toBe(0); const installLog = await readFileContainer( id, - "/home/coder/.claude-module/install.log", + "/home/coder/.coder-modules/claude-code/install.log", ); expect(installLog).toContain( "MCP configuration was provided but the claude binary is not on PATH", @@ -325,7 +325,7 @@ describe("claude-code", async () => { const installLog = await readFileContainer( id, - "/home/coder/.claude-module/install.log", + "/home/coder/.coder-modules/claude-code/install.log", ); expect(installLog).toContain("claude mcp add-json --scope user"); expect(installLog).toContain("test-server"); @@ -349,7 +349,7 @@ describe("claude-code", async () => { const installLog = await readFileContainer( id, - "/home/coder/.claude-module/install.log", + "/home/coder/.coder-modules/claude-code/install.log", ); expect(installLog).toContain(failingUrl); @@ -397,13 +397,13 @@ describe("claude-code", async () => { const preInstallLog = await readFileContainer( id, - "/home/coder/.claude-module/pre_install.log", + "/home/coder/.coder-modules/claude-code/pre_install.log", ); expect(preInstallLog).toContain("claude-pre-install-script"); const postInstallLog = await readFileContainer( id, - "/home/coder/.claude-module/post_install.log", + "/home/coder/.coder-modules/claude-code/post_install.log", ); expect(postInstallLog).toContain("claude-post-install-script"); }); diff --git a/registry/coder/modules/claude-code/main.tf b/registry/coder/modules/claude-code/main.tf index 9b62d959e..7dded0fd3 100644 --- a/registry/coder/modules/claude-code/main.tf +++ b/registry/coder/modules/claude-code/main.tf @@ -78,7 +78,7 @@ locals { # [A-Za-z0-9+/=], so an attacker-controlled string value (e.g. a template # parameter forwarded into `claude_code_version`) cannot break out of the # single-quoted shell literal. coder-utils takes the combined string, - # writes it to $HOME/.claude-module/install.sh, and runs it. One file on + # writes it to $HOME/.coder-modules/claude-code/install.sh, and runs it. One file on # disk, one base64 round-trip, no /tmp wrapper. install_script = join("\n", [ "#!/bin/bash", @@ -111,7 +111,7 @@ module "coder-utils" { agent_id = var.agent_id agent_name = "claude-code" - module_directory = "$HOME/.claude-module" + module_directory = "$HOME/.coder-modules/claude-code" display_name_prefix = "Claude Code" icon = "/icon/claude.svg" From dbb2c211e3059df3f749dd2f0494e4858fad4430 Mon Sep 17 00:00:00 2001 From: Atif Ali Date: Wed, 22 Apr 2026 13:06:59 +0000 Subject: [PATCH 20/23] docs(claude-code): tighten README intro Drop three items from the intro that were noise rather than signal: - "Exports environment variables to the Coder agent." The `env` map has its own dedicated section a few lines down; the bullet restated the obvious in agent-speak that users don't care about. - "It does not start Claude, create a web app, or orchestrate Tasks..." The "Upgrading from v4.x" section already explains the v5 scope change with concrete variable names, which is what returning v4 users need. - "Declare your Terraform variable with `sensitive = true`..." Generic Terraform hygiene, not claude-code specific. --- registry/coder/modules/claude-code/README.md | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/registry/coder/modules/claude-code/README.md b/registry/coder/modules/claude-code/README.md index 77da1a84b..e87302bfd 100644 --- a/registry/coder/modules/claude-code/README.md +++ b/registry/coder/modules/claude-code/README.md @@ -10,13 +10,10 @@ tags: [agent, claude-code, ai, anthropic] Install and configure the [Claude Code](https://docs.anthropic.com/en/docs/claude-code/overview) CLI in your workspace. -This module does three things: +This module does two things: 1. Installs Claude Code via the [official installer](https://claude.ai/install.sh). -2. Exports environment variables to the Coder agent. -3. Optionally applies user-scope MCP server configuration. - -It does not start Claude, create a web app, or orchestrate Tasks. Compose with dedicated modules for those concerns. +2. Optionally applies user-scope MCP server configuration. ```tf module "claude-code" { @@ -34,8 +31,6 @@ module "claude-code" { Pass any Claude Code env var (or any custom var your pre/post scripts consume) through the `env` map. Each key/value pair becomes one `coder_env` resource on the agent. -Declare your Terraform variable with `sensitive = true` to keep secrets out of plan output. Values retain their sensitivity when passed through the module. - ```tf variable "anthropic_api_key" { type = string From 78ecee6e05d2d6115064a5af6e7772d2a4708aa4 Mon Sep 17 00:00:00 2001 From: Atif Ali Date: Wed, 22 Apr 2026 13:14:01 +0000 Subject: [PATCH 21/23] docs(claude-code): strip Terraform and Coder-internal jargon from README Rewrite phrases that leaked Terraform lifecycle or Coder internals into plain English. The README is read by template authors who don't need to know that each env map pair becomes a `coder_env` resource or that validation happens "at plan time" rather than before the workspace deploys. Also replaces two em dashes around the "Unattended mode" intro with parentheses, matching the no-emdash convention used in coder/coder. --- registry/coder/modules/claude-code/README.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/registry/coder/modules/claude-code/README.md b/registry/coder/modules/claude-code/README.md index e87302bfd..916f0f03e 100644 --- a/registry/coder/modules/claude-code/README.md +++ b/registry/coder/modules/claude-code/README.md @@ -29,7 +29,7 @@ module "claude-code" { ## Environment variables (`env`) -Pass any Claude Code env var (or any custom var your pre/post scripts consume) through the `env` map. Each key/value pair becomes one `coder_env` resource on the agent. +Pass any Claude Code env var (or any custom var your pre/post scripts consume) through the `env` map. Each pair is exposed as an environment variable to the workspace. ```tf variable "anthropic_api_key" { @@ -69,7 +69,7 @@ module "claude-code" { Route Claude Code through [Coder AI Gateway](https://coder.com/docs/ai-coder/ai-gateway) for centralized auditing and token usage tracking. Requires Coder Premium with the AI Governance add-on and `CODER_AIBRIDGE_ENABLED=true` on the server. -Point `ANTHROPIC_BASE_URL` at your deployment's `/api/v2/aibridge/anthropic` endpoint and authenticate with the workspace owner's session token via `ANTHROPIC_AUTH_TOKEN`. Claude Code reads both variables natively; no API key is required. +Point `ANTHROPIC_BASE_URL` at your deployment's `/api/v2/aibridge/anthropic` endpoint and authenticate with the workspace owner's session token via `ANTHROPIC_AUTH_TOKEN`. Claude Code reads both variables directly, so no API key is required. ```tf data "coder_workspace" "me" {} @@ -124,7 +124,7 @@ module "claude-code" { Route Claude Code through [Google Vertex AI](https://docs.claude.com/en/docs/claude-code/google-vertex-ai). Requires a GCP project with Vertex AI enabled, Claude models enabled via Model Garden, and a service account with the Vertex AI User role. -The service account JSON has to land on the workspace filesystem where Claude can read it, so authenticating gcloud happens in `pre_install_script`: +The service account JSON must be written to disk where Claude can read it, so gcloud authentication happens in `pre_install_script`: ```tf variable "vertex_sa_json" { @@ -236,7 +236,7 @@ module "claude-code" { ## Using a pre-installed binary -`claude_binary_path` is only consulted when `install_claude_code = false`. The official installer always drops the binary at `$HOME/.local/bin/claude` and does not accept a custom destination, so combining `install_claude_code = true` with a custom `claude_binary_path` is rejected at plan time. +`claude_binary_path` is only consulted when `install_claude_code = false`. The official installer always drops the binary at `$HOME/.local/bin/claude` and does not accept a custom destination, so combining `install_claude_code = true` with a custom `claude_binary_path` is rejected before the workspace deploys. To use a binary you bake into the image (or install via a separate module), set `install_claude_code = false` and point `claude_binary_path` at the directory containing it: @@ -280,7 +280,7 @@ module "claude-code" { ## Unattended mode (skip setup wizard and permission prompts) -For template-admin setups where Claude Code should just work — CI agents, headless workspaces, AI coding agents that do not have a human to click through the first-run wizard or confirm bypass-permissions mode — pre-write `settings.json` and `~/.claude.json` via `pre_install_script`. +For templates that need Claude Code to run without human interaction (CI agents, headless workspaces, AI coding agents that cannot click through the first-run wizard or confirm bypass-permissions mode), pre-write `settings.json` and `~/.claude.json` via `pre_install_script`. ```tf module "claude-code" { @@ -335,7 +335,7 @@ claude -p "$PROMPT" --dangerously-skip-permissions --permission-mode bypassPermi ## Outputs -`scripts` is a list of `coder exp sync` names for every `coder_script` this module creates, in the order `coder-utils` runs them. Use it to gate a downstream `coder_script` behind Claude Code's install: +`scripts` is the list of script names this module creates, in run order. Use it with `coder exp sync` to make another `coder_script` wait until Claude Code finishes installing: ```tf module "claude-code" { @@ -360,7 +360,7 @@ resource "coder_script" "wait_for_claude" { ## Troubleshooting -Module logs live at `$HOME/.coder-modules/claude-code/`: +Module logs are written to `$HOME/.coder-modules/claude-code/`: ```bash cat $HOME/.coder-modules/claude-code/install.log @@ -375,8 +375,8 @@ cat $HOME/.coder-modules/claude-code/post_install.log Breaking changes in v5.0.0: -- `claude_api_key`, `claude_code_oauth_token`, `model`, `disable_autoupdater`, `claude_md_path` removed as dedicated variables. Set them through `env` instead. The module now emits `ANTHROPIC_API_KEY` (the variable Claude Code actually reads), not `CLAUDE_API_KEY`. -- All Tasks, AgentAPI, Boundary, AI Bridge (now **AI Gateway**), and web-app variables removed. Compose dedicated modules or set env vars via `env`. See the AI Gateway example above for the replacement pattern. +- `claude_api_key`, `claude_code_oauth_token`, `model`, `disable_autoupdater`, `claude_md_path` removed as dedicated variables. Set them through `env` instead. The module now sets `ANTHROPIC_API_KEY` (the variable Claude Code actually reads), not `CLAUDE_API_KEY`. +- All Tasks, AgentAPI, Boundary, AI Bridge (now **AI Gateway**), and web-app variables removed. Use dedicated modules instead, or set env vars through the `env` map. See the AI Gateway example above for the replacement pattern. - `workdir` removed. MCP applies at user scope. - `install_via_npm` removed. Official installer only. - `allowed_tools` / `disallowed_tools` removed. Write `~/.claude/settings.json` via `pre_install_script` with `permissions.allow` / `permissions.deny` arrays. From ee22f4019e8c927fca96b3725f721b354cabd04f Mon Sep 17 00:00:00 2001 From: Atif Ali Date: Wed, 22 Apr 2026 14:47:18 +0000 Subject: [PATCH 22/23] feat(claude-code): restore model, oauth_token, ai_gateway, auto_updater inputs Add four dedicated inputs that cover the most common Claude Code configurations: - model: sets ANTHROPIC_MODEL. Replaces writing it manually in env. - claude_code_oauth_token: sets CLAUDE_CODE_OAUTH_TOKEN for Claude.ai subscription users. Marked sensitive. - enable_ai_gateway: wires ANTHROPIC_BASE_URL to the workspace access URL + /api/v2/aibridge/anthropic and ANTHROPIC_AUTH_TOKEN to the workspace owner's session token, collapsing the 10-line manual setup to a single flag. - disable_auto_updater: sets DISABLE_AUTOUPDATER=1. The env map stays as the escape hatch for any Claude Code env var (ANTHROPIC_API_KEY, ANTHROPIC_BASE_URL for custom proxies, CLAUDE_CODE_USE_BEDROCK, CLAUDE_CODE_USE_VERTEX, and any user-defined vars pre/post scripts consume). Collisions between a convenience input and the env map fail at plan time via cross-variable validation blocks on var.env. Silent precedence was tempting but would hide misconfigurations; a hard-fail surfaces the duplicate so the template author decides which route wins. Implementation notes: - Unconditional data.coder_workspace.me and data.coder_workspace_owner.me so enable_ai_gateway can read access_url + session_token without count-indexed access. Both are cheap metadata reads. - locals.merged_env deterministically merges each convenience input's derived map with var.env. Merge order puts var.env last so a future change that relaxes validation falls back to user-wins rather than silent-convenience-wins. - 10 new tftest runs: 4 convenience happy paths, 1 merge-with-env, 5 collision expect_failures (one per validation block). - README rewritten: convenience inputs lead the env-map section, the Claude.ai / AI Gateway / Bedrock / Vertex examples switch to the new inputs, and the "Upgrading from v4.x" section reflects which v4 variables came back and which stayed out. --- registry/coder/modules/claude-code/README.md | 62 +++--- registry/coder/modules/claude-code/main.tf | 102 +++++++++- .../coder/modules/claude-code/main.tftest.hcl | 177 ++++++++++++++++++ 3 files changed, 303 insertions(+), 38 deletions(-) diff --git a/registry/coder/modules/claude-code/README.md b/registry/coder/modules/claude-code/README.md index 916f0f03e..4f8bbf6ef 100644 --- a/registry/coder/modules/claude-code/README.md +++ b/registry/coder/modules/claude-code/README.md @@ -27,9 +27,9 @@ module "claude-code" { } ``` -## Environment variables (`env`) +## Environment variables (`env`) and convenience inputs -Pass any Claude Code env var (or any custom var your pre/post scripts consume) through the `env` map. Each pair is exposed as an environment variable to the workspace. +The convenience inputs `model`, `claude_code_oauth_token`, `enable_ai_gateway`, and `disable_auto_updater` cover the most common Claude Code configuration. For anything else, pass raw env vars through the `env` map. The convenience inputs and the `env` map merge into one set. Setting the same env key through both routes fails before the workspace deploys. ```tf variable "anthropic_api_key" { @@ -42,11 +42,12 @@ module "claude-code" { version = "5.0.0" agent_id = coder_agent.main.id + model = "opus" + disable_auto_updater = true + env = { - ANTHROPIC_API_KEY = var.anthropic_api_key - ANTHROPIC_MODEL = "opus" - DISABLE_AUTOUPDATER = "1" - MY_CUSTOM_VAR = "hello" + ANTHROPIC_API_KEY = var.anthropic_api_key + MY_CUSTOM_VAR = "hello" } } ``` @@ -54,14 +55,16 @@ module "claude-code" { ### Claude.ai subscription ```tf -module "claude-code" { - source = "registry.coder.com/coder/claude-code/coder" - version = "5.0.0" - agent_id = coder_agent.main.id +variable "claude_code_oauth_token" { + type = string + sensitive = true +} - env = { - CLAUDE_CODE_OAUTH_TOKEN = var.claude_code_oauth_token - } +module "claude-code" { + source = "registry.coder.com/coder/claude-code/coder" + version = "5.0.0" + agent_id = coder_agent.main.id + claude_code_oauth_token = var.claude_code_oauth_token } ``` @@ -69,25 +72,17 @@ module "claude-code" { Route Claude Code through [Coder AI Gateway](https://coder.com/docs/ai-coder/ai-gateway) for centralized auditing and token usage tracking. Requires Coder Premium with the AI Governance add-on and `CODER_AIBRIDGE_ENABLED=true` on the server. -Point `ANTHROPIC_BASE_URL` at your deployment's `/api/v2/aibridge/anthropic` endpoint and authenticate with the workspace owner's session token via `ANTHROPIC_AUTH_TOKEN`. Claude Code reads both variables directly, so no API key is required. - ```tf -data "coder_workspace" "me" {} - -data "coder_workspace_owner" "me" {} - module "claude-code" { - source = "registry.coder.com/coder/claude-code/coder" - version = "5.0.0" - agent_id = coder_agent.main.id - - env = { - ANTHROPIC_BASE_URL = "${data.coder_workspace.me.access_url}/api/v2/aibridge/anthropic" - ANTHROPIC_AUTH_TOKEN = data.coder_workspace_owner.me.session_token - } + source = "registry.coder.com/coder/claude-code/coder" + version = "5.0.0" + agent_id = coder_agent.main.id + enable_ai_gateway = true } ``` +`enable_ai_gateway = true` wires `ANTHROPIC_BASE_URL` to your deployment's `/api/v2/aibridge/anthropic` endpoint and `ANTHROPIC_AUTH_TOKEN` to the workspace owner's session token. Claude Code reads both directly, so no API key is required. + > [!NOTE] > AI Gateway was previously named AI Bridge. The server-side endpoints and environment variables still use the `aibridge` prefix; only the product name changed. @@ -108,10 +103,11 @@ module "claude-code" { version = "5.0.0" agent_id = coder_agent.main.id + model = "global.anthropic.claude-sonnet-4-5-20250929-v1:0" + env = { CLAUDE_CODE_USE_BEDROCK = "1" AWS_REGION = "us-east-1" - ANTHROPIC_MODEL = "global.anthropic.claude-sonnet-4-5-20250929-v1:0" AWS_BEARER_TOKEN_BEDROCK = var.aws_bearer_token_bedrock # Or, with access keys instead of the bearer token: # AWS_ACCESS_KEY_ID = var.aws_access_key_id @@ -138,11 +134,12 @@ module "claude-code" { version = "5.0.0" agent_id = coder_agent.main.id + model = "claude-sonnet-4@20250514" + env = { CLAUDE_CODE_USE_VERTEX = "1" ANTHROPIC_VERTEX_PROJECT_ID = "your-gcp-project-id" CLOUD_ML_REGION = "global" - ANTHROPIC_MODEL = "claude-sonnet-4@20250514" GOOGLE_APPLICATION_CREDENTIALS = "$HOME/.config/gcloud/sa.json" VERTEX_SA_JSON = var.vertex_sa_json } @@ -375,8 +372,11 @@ cat $HOME/.coder-modules/claude-code/post_install.log Breaking changes in v5.0.0: -- `claude_api_key`, `claude_code_oauth_token`, `model`, `disable_autoupdater`, `claude_md_path` removed as dedicated variables. Set them through `env` instead. The module now sets `ANTHROPIC_API_KEY` (the variable Claude Code actually reads), not `CLAUDE_API_KEY`. -- All Tasks, AgentAPI, Boundary, AI Bridge (now **AI Gateway**), and web-app variables removed. Use dedicated modules instead, or set env vars through the `env` map. See the AI Gateway example above for the replacement pattern. +- `claude_api_key` removed. Set `ANTHROPIC_API_KEY` through the `env` map (the variable Claude Code actually reads, not `CLAUDE_API_KEY`). +- `claude_md_path` removed. Write the file in `pre_install_script`. +- `disable_autoupdater` renamed to `disable_auto_updater`. +- `model`, `claude_code_oauth_token`, and the AI Gateway wiring stay as dedicated inputs (`model`, `claude_code_oauth_token`, `enable_ai_gateway`); see the examples above. +- All Tasks, AgentAPI, Boundary, and web-app variables removed. Use dedicated modules instead, or set env vars through the `env` map. - `workdir` removed. MCP applies at user scope. - `install_via_npm` removed. Official installer only. - `allowed_tools` / `disallowed_tools` removed. Write `~/.claude/settings.json` via `pre_install_script` with `permissions.allow` / `permissions.deny` arrays. diff --git a/registry/coder/modules/claude-code/main.tf b/registry/coder/modules/claude-code/main.tf index 7dded0fd3..d03395eb4 100644 --- a/registry/coder/modules/claude-code/main.tf +++ b/registry/coder/modules/claude-code/main.tf @@ -16,8 +16,58 @@ variable "agent_id" { variable "env" { type = map(string) - description = "Environment variables to export to the Coder agent. Each key/value pair becomes one coder_env resource. Use this for any Claude Code env var (ANTHROPIC_API_KEY, CLAUDE_CODE_OAUTH_TOKEN, ANTHROPIC_BASE_URL, ANTHROPIC_MODEL, CLAUDE_CODE_USE_BEDROCK, etc.) or for custom vars your pre/post scripts consume. Declare your Terraform variable with `sensitive = true` to keep secrets out of plan output." + description = "Environment variables to export to the workspace. Use this for any Claude Code env var (ANTHROPIC_API_KEY, ANTHROPIC_BASE_URL, CLAUDE_CODE_USE_BEDROCK, etc.) or for custom vars your pre/post scripts consume. Keys that are also wired by a convenience input (model, claude_code_oauth_token, enable_ai_gateway, disable_auto_updater) fail at plan time; use one or the other." default = {} + + validation { + condition = var.model == "" || !contains(keys(var.env), "ANTHROPIC_MODEL") + error_message = "Set ANTHROPIC_MODEL via the `model` input or the `env` map, not both." + } + + validation { + condition = var.claude_code_oauth_token == "" || !contains(keys(var.env), "CLAUDE_CODE_OAUTH_TOKEN") + error_message = "Set CLAUDE_CODE_OAUTH_TOKEN via the `claude_code_oauth_token` input or the `env` map, not both." + } + + validation { + condition = !var.enable_ai_gateway || !contains(keys(var.env), "ANTHROPIC_BASE_URL") + error_message = "enable_ai_gateway wires ANTHROPIC_BASE_URL automatically; remove it from the `env` map." + } + + validation { + condition = !var.enable_ai_gateway || !contains(keys(var.env), "ANTHROPIC_AUTH_TOKEN") + error_message = "enable_ai_gateway wires ANTHROPIC_AUTH_TOKEN automatically; remove it from the `env` map." + } + + validation { + condition = !var.disable_auto_updater || !contains(keys(var.env), "DISABLE_AUTOUPDATER") + error_message = "Set DISABLE_AUTOUPDATER via the `disable_auto_updater` input or the `env` map, not both." + } +} + +variable "model" { + type = string + description = "Claude model identifier. Sets ANTHROPIC_MODEL when non-empty. Examples: \"opus\", \"sonnet\", \"claude-sonnet-4-5-20250929\"." + default = "" +} + +variable "claude_code_oauth_token" { + type = string + description = "Claude.ai subscription OAuth token. Sets CLAUDE_CODE_OAUTH_TOKEN when non-empty. Use a sensitive Terraform variable to keep this out of plan output." + default = "" + sensitive = true +} + +variable "enable_ai_gateway" { + type = bool + description = "Route Claude Code through Coder AI Gateway. Wires ANTHROPIC_BASE_URL (to /api/v2/aibridge/anthropic) and ANTHROPIC_AUTH_TOKEN (to the workspace owner's session token). Requires Coder Premium with the AI Governance add-on and CODER_AIBRIDGE_ENABLED=true on the server." + default = false +} + +variable "disable_auto_updater" { + type = bool + description = "Turn off Claude Code's built-in auto-updater by setting DISABLE_AUTOUPDATER=1. Useful for air-gapped workspaces or when the image pins a specific version." + default = false } variable "claude_code_version" { @@ -72,7 +122,44 @@ variable "post_install_script" { default = null } +# Workspace and owner metadata powers the convenience inputs. Unconditionally +# declared so enable_ai_gateway can read them without count-indexed access. +data "coder_workspace" "me" {} + +data "coder_workspace_owner" "me" {} + locals { + # Convenience inputs expand into env keys that Claude Code reads at runtime. + # Each entry is included only when the corresponding input is set; the + # validation blocks on var.env guarantee no key collision. + model_env = var.model == "" ? {} : { + ANTHROPIC_MODEL = var.model + } + + oauth_token_env = var.claude_code_oauth_token == "" ? {} : { + CLAUDE_CODE_OAUTH_TOKEN = var.claude_code_oauth_token + } + + ai_gateway_env = var.enable_ai_gateway ? { + ANTHROPIC_BASE_URL = "${data.coder_workspace.me.access_url}/api/v2/aibridge/anthropic" + ANTHROPIC_AUTH_TOKEN = data.coder_workspace_owner.me.session_token + } : {} + + auto_updater_env = var.disable_auto_updater ? { + DISABLE_AUTOUPDATER = "1" + } : {} + + # Merge order is unimportant because validation rules out collisions, but + # we put var.env last so a future change that relaxes validation falls + # back to user-wins, not silent-convenience-wins. + merged_env = merge( + local.model_env, + local.oauth_token_env, + local.ai_gateway_env, + local.auto_updater_env, + var.env, + ) + # All ARG_* values are base64-encoded in Terraform and decoded inside # install.sh. Base64 is the safe channel: the encoded form contains only # [A-Za-z0-9+/=], so an attacker-controlled string value (e.g. a template @@ -94,15 +181,16 @@ locals { ]) } -# Fan var.env out into one coder_env per entry. Keys are lifted out of -# their sensitivity taint with `nonsensitive` so Terraform can use them as -# for_each instance addresses; values retain any sensitivity attached by -# the caller's variable declaration. +# Fan the merged env map (convenience inputs + var.env) out into one +# coder_env per entry. Keys are lifted out of their sensitivity taint with +# `nonsensitive` so Terraform can use them as for_each instance addresses; +# values retain any sensitivity attached by the caller's variable declaration +# (and by `claude_code_oauth_token` / the workspace owner's session token). resource "coder_env" "env" { - for_each = nonsensitive(toset(keys(var.env))) + for_each = nonsensitive(toset(keys(local.merged_env))) agent_id = var.agent_id name = each.key - value = var.env[each.key] + value = local.merged_env[each.key] } module "coder-utils" { diff --git a/registry/coder/modules/claude-code/main.tftest.hcl b/registry/coder/modules/claude-code/main.tftest.hcl index c6d1fad3c..2a257ea3c 100644 --- a/registry/coder/modules/claude-code/main.tftest.hcl +++ b/registry/coder/modules/claude-code/main.tftest.hcl @@ -248,3 +248,180 @@ run "test_claude_binary_path_validation" { expect_failures = [var.claude_binary_path] } + +run "test_model_convenience" { + command = plan + + variables { + agent_id = "test-agent" + model = "opus" + } + + assert { + condition = coder_env.env["ANTHROPIC_MODEL"].value == "opus" + error_message = "model input must set ANTHROPIC_MODEL" + } + + assert { + condition = length(coder_env.env) == 1 + error_message = "only ANTHROPIC_MODEL should be set" + } +} + +run "test_claude_code_oauth_token_convenience" { + command = plan + + variables { + agent_id = "test-agent" + claude_code_oauth_token = "oauth-live" + } + + assert { + condition = coder_env.env["CLAUDE_CODE_OAUTH_TOKEN"].value == "oauth-live" + error_message = "claude_code_oauth_token must set CLAUDE_CODE_OAUTH_TOKEN" + } +} + +run "test_disable_auto_updater_convenience" { + command = plan + + variables { + agent_id = "test-agent" + disable_auto_updater = true + } + + assert { + condition = coder_env.env["DISABLE_AUTOUPDATER"].value == "1" + error_message = "disable_auto_updater must set DISABLE_AUTOUPDATER=1" + } +} + +run "test_enable_ai_gateway_convenience" { + command = plan + + variables { + agent_id = "test-agent" + enable_ai_gateway = true + } + + override_data { + target = data.coder_workspace.me + values = { + access_url = "https://coder.example.com" + } + } + + override_data { + target = data.coder_workspace_owner.me + values = { + session_token = "mock-session-token" + } + } + + assert { + condition = coder_env.env["ANTHROPIC_BASE_URL"].value == "https://coder.example.com/api/v2/aibridge/anthropic" + error_message = "enable_ai_gateway must wire ANTHROPIC_BASE_URL to the aibridge endpoint" + } + + assert { + condition = coder_env.env["ANTHROPIC_AUTH_TOKEN"].value == "mock-session-token" + error_message = "enable_ai_gateway must wire ANTHROPIC_AUTH_TOKEN to the workspace owner session token" + } +} + +run "test_convenience_and_env_merge" { + command = plan + + variables { + agent_id = "test-agent" + model = "opus" + env = { + ANTHROPIC_API_KEY = "sk-live" + } + } + + assert { + condition = coder_env.env["ANTHROPIC_MODEL"].value == "opus" + error_message = "convenience input must still apply when env is set" + } + + assert { + condition = coder_env.env["ANTHROPIC_API_KEY"].value == "sk-live" + error_message = "env entries must still apply when a convenience input is set" + } + + assert { + condition = length(coder_env.env) == 2 + error_message = "merged env must have exactly 2 entries" + } +} + +run "test_model_conflicts_with_env" { + command = plan + + variables { + agent_id = "test-agent" + model = "opus" + env = { + ANTHROPIC_MODEL = "sonnet" + } + } + + expect_failures = [var.env] +} + +run "test_oauth_token_conflicts_with_env" { + command = plan + + variables { + agent_id = "test-agent" + claude_code_oauth_token = "oauth-live" + env = { + CLAUDE_CODE_OAUTH_TOKEN = "oauth-from-env" + } + } + + expect_failures = [var.env] +} + +run "test_ai_gateway_conflicts_with_env_base_url" { + command = plan + + variables { + agent_id = "test-agent" + enable_ai_gateway = true + env = { + ANTHROPIC_BASE_URL = "https://custom.example.com" + } + } + + expect_failures = [var.env] +} + +run "test_ai_gateway_conflicts_with_env_auth_token" { + command = plan + + variables { + agent_id = "test-agent" + enable_ai_gateway = true + env = { + ANTHROPIC_AUTH_TOKEN = "custom-token" + } + } + + expect_failures = [var.env] +} + +run "test_auto_updater_conflicts_with_env" { + command = plan + + variables { + agent_id = "test-agent" + disable_auto_updater = true + env = { + DISABLE_AUTOUPDATER = "0" + } + } + + expect_failures = [var.env] +} From b0a1612824e0e461d3fabec672001ce4a22b8952 Mon Sep 17 00:00:00 2001 From: Atif Ali Date: Wed, 22 Apr 2026 14:51:23 +0000 Subject: [PATCH 23/23] refactor(claude-code): rename disable_auto_updater to disable_autoupdater Match the shape of the DISABLE_AUTOUPDATER env var and keep parity with the v4 input name. Mechanical rename across main.tf, tftest, and README, plus the internal locals.autoupdater_env identifier. Drops the v4 rename bullet from the upgrade notes since it is now a no-op. --- registry/coder/modules/claude-code/README.md | 7 +++---- registry/coder/modules/claude-code/main.tf | 12 ++++++------ registry/coder/modules/claude-code/main.tftest.hcl | 14 +++++++------- 3 files changed, 16 insertions(+), 17 deletions(-) diff --git a/registry/coder/modules/claude-code/README.md b/registry/coder/modules/claude-code/README.md index 4f8bbf6ef..484d1b664 100644 --- a/registry/coder/modules/claude-code/README.md +++ b/registry/coder/modules/claude-code/README.md @@ -29,7 +29,7 @@ module "claude-code" { ## Environment variables (`env`) and convenience inputs -The convenience inputs `model`, `claude_code_oauth_token`, `enable_ai_gateway`, and `disable_auto_updater` cover the most common Claude Code configuration. For anything else, pass raw env vars through the `env` map. The convenience inputs and the `env` map merge into one set. Setting the same env key through both routes fails before the workspace deploys. +The convenience inputs `model`, `claude_code_oauth_token`, `enable_ai_gateway`, and `disable_autoupdater` cover the most common Claude Code configuration. For anything else, pass raw env vars through the `env` map. The convenience inputs and the `env` map merge into one set. Setting the same env key through both routes fails before the workspace deploys. ```tf variable "anthropic_api_key" { @@ -42,8 +42,8 @@ module "claude-code" { version = "5.0.0" agent_id = coder_agent.main.id - model = "opus" - disable_auto_updater = true + model = "opus" + disable_autoupdater = true env = { ANTHROPIC_API_KEY = var.anthropic_api_key @@ -374,7 +374,6 @@ Breaking changes in v5.0.0: - `claude_api_key` removed. Set `ANTHROPIC_API_KEY` through the `env` map (the variable Claude Code actually reads, not `CLAUDE_API_KEY`). - `claude_md_path` removed. Write the file in `pre_install_script`. -- `disable_autoupdater` renamed to `disable_auto_updater`. - `model`, `claude_code_oauth_token`, and the AI Gateway wiring stay as dedicated inputs (`model`, `claude_code_oauth_token`, `enable_ai_gateway`); see the examples above. - All Tasks, AgentAPI, Boundary, and web-app variables removed. Use dedicated modules instead, or set env vars through the `env` map. - `workdir` removed. MCP applies at user scope. diff --git a/registry/coder/modules/claude-code/main.tf b/registry/coder/modules/claude-code/main.tf index d03395eb4..bf7a0095f 100644 --- a/registry/coder/modules/claude-code/main.tf +++ b/registry/coder/modules/claude-code/main.tf @@ -16,7 +16,7 @@ variable "agent_id" { variable "env" { type = map(string) - description = "Environment variables to export to the workspace. Use this for any Claude Code env var (ANTHROPIC_API_KEY, ANTHROPIC_BASE_URL, CLAUDE_CODE_USE_BEDROCK, etc.) or for custom vars your pre/post scripts consume. Keys that are also wired by a convenience input (model, claude_code_oauth_token, enable_ai_gateway, disable_auto_updater) fail at plan time; use one or the other." + description = "Environment variables to export to the workspace. Use this for any Claude Code env var (ANTHROPIC_API_KEY, ANTHROPIC_BASE_URL, CLAUDE_CODE_USE_BEDROCK, etc.) or for custom vars your pre/post scripts consume. Keys that are also wired by a convenience input (model, claude_code_oauth_token, enable_ai_gateway, disable_autoupdater) fail at plan time; use one or the other." default = {} validation { @@ -40,8 +40,8 @@ variable "env" { } validation { - condition = !var.disable_auto_updater || !contains(keys(var.env), "DISABLE_AUTOUPDATER") - error_message = "Set DISABLE_AUTOUPDATER via the `disable_auto_updater` input or the `env` map, not both." + condition = !var.disable_autoupdater || !contains(keys(var.env), "DISABLE_AUTOUPDATER") + error_message = "Set DISABLE_AUTOUPDATER via the `disable_autoupdater` input or the `env` map, not both." } } @@ -64,7 +64,7 @@ variable "enable_ai_gateway" { default = false } -variable "disable_auto_updater" { +variable "disable_autoupdater" { type = bool description = "Turn off Claude Code's built-in auto-updater by setting DISABLE_AUTOUPDATER=1. Useful for air-gapped workspaces or when the image pins a specific version." default = false @@ -145,7 +145,7 @@ locals { ANTHROPIC_AUTH_TOKEN = data.coder_workspace_owner.me.session_token } : {} - auto_updater_env = var.disable_auto_updater ? { + autoupdater_env = var.disable_autoupdater ? { DISABLE_AUTOUPDATER = "1" } : {} @@ -156,7 +156,7 @@ locals { local.model_env, local.oauth_token_env, local.ai_gateway_env, - local.auto_updater_env, + local.autoupdater_env, var.env, ) diff --git a/registry/coder/modules/claude-code/main.tftest.hcl b/registry/coder/modules/claude-code/main.tftest.hcl index 2a257ea3c..7492bd484 100644 --- a/registry/coder/modules/claude-code/main.tftest.hcl +++ b/registry/coder/modules/claude-code/main.tftest.hcl @@ -282,17 +282,17 @@ run "test_claude_code_oauth_token_convenience" { } } -run "test_disable_auto_updater_convenience" { +run "test_disable_autoupdater_convenience" { command = plan variables { - agent_id = "test-agent" - disable_auto_updater = true + agent_id = "test-agent" + disable_autoupdater = true } assert { condition = coder_env.env["DISABLE_AUTOUPDATER"].value == "1" - error_message = "disable_auto_updater must set DISABLE_AUTOUPDATER=1" + error_message = "disable_autoupdater must set DISABLE_AUTOUPDATER=1" } } @@ -412,12 +412,12 @@ run "test_ai_gateway_conflicts_with_env_auth_token" { expect_failures = [var.env] } -run "test_auto_updater_conflicts_with_env" { +run "test_autoupdater_conflicts_with_env" { command = plan variables { - agent_id = "test-agent" - disable_auto_updater = true + agent_id = "test-agent" + disable_autoupdater = true env = { DISABLE_AUTOUPDATER = "0" }