diff --git a/registry/coder/modules/claude-code/README.md b/registry/coder/modules/claude-code/README.md index 48b291bb0..484d1b664 100644 --- a/registry/coder/modules/claude-code/README.md +++ b/registry/coder/modules/claude-code/README.md @@ -1,400 +1,382 @@ --- 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] --- # 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. + +This module does two things: + +1. Installs Claude Code via the [official installer](https://claude.ai/install.sh). +2. Optionally applies user-scope MCP server configuration. ```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" + 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 + } } ``` -> [!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. +## Environment variables (`env`) and convenience inputs -> [!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) +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. -### Session Resumption Behavior +```tf +variable "anthropic_api_key" { + type = string + sensitive = true +} -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` +module "claude-code" { + source = "registry.coder.com/coder/claude-code/coder" + version = "5.0.0" + agent_id = coder_agent.main.id -## State Persistence + model = "opus" + disable_autoupdater = true -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). + env = { + ANTHROPIC_API_KEY = var.anthropic_api_key + MY_CUSTOM_VAR = "hello" + } +} +``` -To disable: +### Claude.ai subscription ```tf +variable "claude_code_oauth_token" { + type = string + sensitive = true +} + 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 + claude_code_oauth_token = var.claude_code_oauth_token } ``` -## Examples - -### Usage with Agent Boundaries +### Coder AI Gateway -This example shows how to configure the Claude Code module to run the agent behind a process-level boundary that restricts its network access. - -By default, when `enable_boundary = true`, the module uses `coder boundary` subcommand (provided by Coder) without requiring any installation. +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. ```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" - enable_boundary = true + source = "registry.coder.com/coder/claude-code/coder" + version = "5.0.0" + agent_id = coder_agent.main.id + enable_ai_gateway = true } ``` -> [!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. +`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. -### Usage with AI Bridge +> [!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. -[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. +### AWS Bedrock -For tasks integration with AI Bridge, add `enable_aibridge = true` to the [Usage with Tasks](#usage-with-tasks) example below. +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`. -#### Standalone usage with AI Bridge +Pick either an access key pair or a Bedrock bearer token for auth; do not set both. ```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" - enable_aibridge = true +variable "aws_bearer_token_bedrock" { + type = string + sensitive = true } -``` -When `enable_aibridge = true`, the module automatically sets: +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" -- `ANTHROPIC_BASE_URL` to `${data.coder_workspace.me.access_url}/api/v2/aibridge/anthropic` -- `CLAUDE_API_KEY` to the workspace owner's session token + env = { + CLAUDE_CODE_USE_BEDROCK = "1" + AWS_REGION = "us-east-1" + 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 + } +} +``` -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`. +### Google Vertex AI -### Usage with Tasks +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. -This example shows how to configure Claude Code with Coder tasks. +The service account JSON must be written to disk where Claude can read it, so gcloud authentication happens in `pre_install_script`: ```tf -resource "coder_ai_task" "task" { - count = data.coder_workspace.me.start_count - app_id = module.claude-code.task_app_id +variable "vertex_sa_json" { + type = string + description = "Full JSON body of a GCP service account key with Vertex AI User." + sensitive = true } -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 + + model = "claude-sonnet-4@20250514" + + env = { + CLAUDE_CODE_USE_VERTEX = "1" + ANTHROPIC_VERTEX_PROJECT_ID = "your-gcp-project-id" + CLOUD_ML_REGION = "global" + 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 } ``` -### Advanced Configuration +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. -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. +### Other custom endpoints (LiteLLM, a private proxy) -> [!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. +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" { 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" - claude_api_key = "xxxx-xxxxx-xxxx" - # OR - claude_code_oauth_token = "xxxxx-xxxx-xxxx" + env = { + ANTHROPIC_BASE_URL = "https://proxy.example.com/anthropic" + ANTHROPIC_AUTH_TOKEN = var.proxy_token + } +} +``` + +## 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. - 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" +### Inline - model = "sonnet" - permission_mode = "plan" +```tf +module "claude-code" { + source = "registry.coder.com/coder/claude-code/coder" + version = "5.0.0" + agent_id = coder_agent.main.id - mcp = <<-EOF - { - "mcpServers": { - "my-custom-tool": { - "command": "my-tool-server", - "args": ["--port", "8080"] + env = { + ANTHROPIC_API_KEY = var.anthropic_api_key + } + + mcp = jsonencode({ + mcpServers = { + github = { + command = "npx" + args = ["-y", "@modelcontextprotocol/server-github"] } } + }) +} +``` + +### From remote URLs + +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 = "5.0.0" + agent_id = coder_agent.main.id + + env = { + ANTHROPIC_API_KEY = var.anthropic_api_key } - EOF 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 +`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. -variable "claude_code_oauth_token" { - type = string - description = "Generate one using `claude setup-token` command" - sensitive = true - value = "xxxx-xxx-xxxx" -} +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" { - 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 -#### Prerequisites - -AWS account with Bedrock access, Claude models enabled in Bedrock console, and appropriate IAM permissions. - -Configure Claude Code to use AWS Bedrock for accessing Claude models through your AWS infrastructure. +Use `pre_install_script` and `post_install_script` for custom setup (e.g. writing `~/.claude/settings.json` permission rules, installing cloud SDKs, pulling secrets). ```tf -resource "coder_env" "bedrock_use" { - agent_id = coder_agent.main.id - name = "CLAUDE_CODE_USE_BEDROCK" - value = "1" -} - -resource "coder_env" "aws_region" { +module "claude-code" { + source = "registry.coder.com/coder/claude-code/coder" + version = "5.0.0" 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" -} - -resource "coder_env" "aws_access_key_id" { - agent_id = coder_agent.main.id - name = "AWS_ACCESS_KEY_ID" - value = var.aws_access_key_id -} + env = { + ANTHROPIC_API_KEY = var.anthropic_api_key + } -resource "coder_env" "aws_secret_access_key" { - agent_id = coder_agent.main.id - name = "AWS_SECRET_ACCESS_KEY" - value = var.aws_secret_access_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 } +``` -# 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" -} +## Unattended mode (skip setup wizard and permission prompts) -resource "coder_env" "bedrock_api_key" { - agent_id = coder_agent.main.id - name = "AWS_BEARER_TOKEN_BEDROCK" - value = var.aws_bearer_token_bedrock -} +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" { 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. + env = { + ANTHROPIC_API_KEY = var.anthropic_api_key + } -```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 + 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 } +``` -resource "coder_env" "vertex_use" { - agent_id = coder_agent.main.id - name = "CLAUDE_CODE_USE_VERTEX" - value = "1" -} +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`. -resource "coder_env" "vertex_project_id" { - agent_id = coder_agent.main.id - name = "ANTHROPIC_VERTEX_PROJECT_ID" - value = "your-gcp-project-id" -} +For one-off non-interactive runs, prefer a runtime flag over pre-writing config: -resource "coder_env" "cloud_ml_region" { - agent_id = coder_agent.main.id - name = "CLOUD_ML_REGION" - value = "global" -} +```bash +claude -p "$PROMPT" --dangerously-skip-permissions --permission-mode bypassPermissions +``` -resource "coder_env" "vertex_sa_json" { - agent_id = coder_agent.main.id - name = "VERTEX_SA_JSON" - value = var.vertex_sa_json -} +## Outputs -resource "coder_env" "google_application_credentials" { - agent_id = coder_agent.main.id - name = "GOOGLE_APPLICATION_CREDENTIALS" - value = "/tmp/gcp-sa.json" -} +`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" { 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 = "claude-sonnet-4@20250514" +} - pre_install_script = <<-EOT +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 - # 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 + 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 } ``` -> [!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). - ## Troubleshooting -If you encounter any issues, check the log files in the `~/.claude-module` directory within your workspace for detailed information. +Module logs are written to `$HOME/.coder-modules/claude-code/`: ```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/.coder-modules/claude-code/install.log +cat $HOME/.coder-modules/claude-code/pre_install.log +cat $HOME/.coder-modules/claude-code/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 + +> [!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. -## 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` 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`. +- `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. +- `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 b01e88327..3524a4437 100644 --- a/registry/coder/modules/claude-code/main.test.ts +++ b/registry/coder/modules/claude-code/main.test.ts @@ -6,15 +6,43 @@ import { beforeAll, expect, } from "bun:test"; -import { execContainer, readFileContainer, runTerraformInit } from "~test"; import { - loadTestFile, - writeExecutable, - setup as setupUtil, - execModuleScript, - expectAgentAPIStarted, -} from "../agentapi/test-util"; -import dedent from "dedent"; + execContainer, + readFileContainer, + runTerraformApply, + runTerraformInit, + runContainer, + removeContainer, + type TerraformState, +} from "~test"; +import { loadTestFile, writeExecutable } from "../agentapi/test-util"; + +// 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 `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 ResourceAttributes; + const name = getStringAttr(attrs, "name"); + const value = getStringAttr(attrs, "value"); + if (name && value) envVars[name] = value; + } + } + return envVars; +}; let cleanupFunctions: (() => Promise)[] = []; const registerCleanup = (cleanup: () => Promise) => { @@ -33,29 +61,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`: first pre_install, then install, then 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 ResourceAttributes; + scripts.push({ + displayName: getStringAttr(attrs, "display_name"), + script: getStringAttr(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 +133,40 @@ 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 entries = env ? Object.entries(env) : []; + const envArgs = entries.length + ? entries + .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 () => { @@ -73,10 +174,18 @@ 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 execModuleScript(id); - await expectAgentAPIStarted(id); + await runModuleScripts(id); + const installLog = await readFileContainer( + id, + "/home/coder/.coder-modules/claude-code/install.log", + ); + expect(installLog).toContain("ARG_INSTALL_CLAUDE_CODE"); + expect(installLog).toContain("Skipping Claude Code installation"); }); test("install-claude-code-version", async () => { @@ -88,400 +197,145 @@ 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", - "cat /home/coder/.claude-module/install.log", + "cat /home/coder/.coder-modules/claude-code/install.log", ]); 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, [ - "bash", - "-c", - 'env | grep CLAUDE_API_KEY || echo "CLAUDE_API_KEY not found"', - ]); - expect(envCheck.stdout).toContain("CLAUDE_API_KEY"); - }); - - test("claude-mcp-config", async () => { - const mcpConfig = JSON.stringify({ - mcpServers: { - test: { - command: "test-cmd", - type: "stdio", - }, - }, - }); - const { id, coderEnvVars } = await setup({ - skipClaudeMock: true, - moduleVariables: { - mcp: mcpConfig, - }, - }); - await execModuleScript(id, coderEnvVars); - - const resp = await readFileContainer(id, "/home/coder/.claude.json"); - expect(resp).toContain("test-cmd"); - }); - - 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); - + await runModuleScripts(id, coderEnvVars); const resp = await execContainer(id, [ "bash", "-c", - "cat /home/coder/.claude-module/agentapi-start.log", + 'export PATH="$HOME/.local/bin:$PATH" && claude --version', ]); - expect(resp.stdout).toContain(prompt); + expect(resp.exitCode).toBe(0); + expect(resp.stdout).toMatch(/\d+\.\d+\.\d+/); }); - test("claude-permission-mode", async () => { - const mode = "plan"; - const { id } = await setup({ - moduleVariables: { - permission_mode: mode, - ai_prompt: "test prompt", - }, - }); - 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}`); - }); - - test("claude-auto-permission-mode", async () => { - const mode = "auto"; - const { id } = await setup({ - moduleVariables: { - permission_mode: mode, - ai_prompt: "test prompt", - }, - }); - 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}`); - }); - - test("claude-model", async () => { - const model = "opus"; - const { coderEnvVars } = await setup({ + test("env-map-passthrough", async () => { + const { id, coderEnvVars } = await setup({ moduleVariables: { - model: model, - ai_prompt: "test prompt", + 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", + }), }, }); - - // Verify ANTHROPIC_MODEL env var is set via coder_env - expect(coderEnvVars["ANTHROPIC_MODEL"]).toBe(model); + 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(); + // 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("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({ + 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: { - pre_install_script: "#!/bin/bash\necho 'claude-pre-install-script'", - post_install_script: "#!/bin/bash\necho 'claude-post-install-script'", + install_claude_code: "false", }, }); - await execModuleScript(id); - - const preInstallLog = await readFileContainer( + // Remove the claude mock so command -v claude fails in the container. + // /usr/bin requires root, so exec as root. + await execContainer( id, - "/home/coder/.claude-module/pre_install.log", + [ + "bash", + "-c", + "rm -f /usr/bin/claude /home/coder/.local/bin/claude 2>/dev/null; hash -r", + ], + ["--user", "root"], ); - expect(preInstallLog).toContain("claude-pre-install-script"); - - const postInstallLog = await readFileContainer( + const resp = await runModuleScripts(id, coderEnvVars); + expect(resp.exitCode).toBe(0); + const installLog = await readFileContainer( id, - "/home/coder/.claude-module/post_install.log", + "/home/coder/.coder-modules/claude-code/install.log", ); - expect(postInstallLog).toContain("claude-post-install-script"); + expect(installLog).toContain("claude binary not found on PATH"); }); - test("workdir-variable", async () => { - const workdir = "/home/coder/claude-test-folder"; - const { id } = await setup({ - skipClaudeMock: false, + 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: { - workdir, + install_claude_code: "false", + mcp: JSON.stringify({ + mcpServers: { test: { command: "test-cmd" } }, + }), }, }); - await execModuleScript(id); - - const resp = await readFileContainer( + await execContainer( id, - "/home/coder/.claude-module/agentapi-start.log", + [ + "bash", + "-c", + "rm -f /usr/bin/claude /home/coder/.local/bin/claude 2>/dev/null; hash -r", + ], + ["--user", "root"], ); - expect(resp).toContain(workdir); - }); - - test("coder-mcp-config-created", async () => { - const { id } = await setup({ - moduleVariables: { - install_claude_code: "false", - }, - }); - await execModuleScript(id); - + const resp = await runModuleScripts(id, coderEnvVars); + 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( - "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", + "MCP configuration was provided but the claude binary is not on PATH", ); }); - 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", + 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 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", + mcp: mcpConfig, }, }); + await runModuleScripts(id); - 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", + const installLog = await readFileContainer( + id, + "/home/coder/.coder-modules/claude-code/install.log", ); - expect(startLog.stdout).toContain("--continue"); + expect(installLog).toContain("claude mcp add-json --scope user"); + expect(installLog).toContain("test-server"); }); - 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 () => { - const failingUrl = "http://localhost:19999/mcp.json"; + test("claude-mcp-remote-user-scope", async () => { + // 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"; @@ -491,42 +345,66 @@ 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", + "/home/coder/.coder-modules/claude-code/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("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) => + getStringAttr(i.attributes as ResourceAttributes, "display_name"), + ), ); + expect(displayNames).toEqual(["Claude Code: Install Script"]); + }); - expect(installLog).toContain( - "Added stdio MCP server typescript-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); + + const preInstallLog = await readFileContainer( + id, + "/home/coder/.coder-modules/claude-code/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/.coder-modules/claude-code/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..bf7a0095f 100644 --- a/registry/coder/modules/claude-code/main.tf +++ b/registry/coder/modules/claude-code/main.tf @@ -14,427 +14,207 @@ variable "agent_id" { description = "The ID of a Coder agent." } -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 "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_autoupdater) fail at plan time; use one or the other." + default = {} -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" -} + 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." + } -variable "cli_app_display_name" { - type = string - description = "Display name for the CLI app" - default = "Claude Code CLI" -} + 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." + } -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 -} + 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." + } -variable "post_install_script" { - type = string - description = "Custom script to run after installing Claude Code." - default = null -} + 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." + } -variable "install_agentapi" { - type = bool - description = "Whether to install AgentAPI." - default = true + validation { + 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." + } } -variable "agentapi_version" { +variable "model" { type = string - description = "The version of AgentAPI to install." - default = "v0.11.8" + description = "Claude model identifier. Sets ANTHROPIC_MODEL when non-empty. Examples: \"opus\", \"sonnet\", \"claude-sonnet-4-5-20250929\"." + default = "" } -variable "ai_prompt" { +variable "claude_code_oauth_token" { type = string - description = "Initial task prompt for Claude Code." + 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 "subdomain" { +variable "enable_ai_gateway" { type = bool - description = "Whether to use a subdomain for AgentAPI." + 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 "install_claude_code" { - type = bool - description = "Whether to install Claude Code." - default = true -} - -variable "claude_code_version" { - type = string - description = "The version of Claude Code to install." - default = "latest" -} - variable "disable_autoupdater" { type = bool - description = "Disable Claude Code automatic updates. When true, Claude Code will stay on the installed version." + 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_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" { +variable "claude_code_version" { type = string - description = "Resume a specific session by ID." - default = "" + description = "The version of Claude Code to install. Forwarded to the official installer." + default = "latest" } -variable "continue" { +variable "install_claude_code" { 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)." + description = "Whether to install Claude Code via the official installer." 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_binary_path" { type = string - description = "Permission mode for the cli, check https://docs.anthropic.com/en/docs/claude-code/iam#permission-modes" - default = "" + 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 = contains(["", "default", "acceptEdits", "plan", "auto", "bypassPermissions"], var.permission_mode) - error_message = "interaction_mode must be one of: default, acceptEdits, plan, auto, bypassPermissions." + 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 "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 HTTPS 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." + 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 "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" { +variable "pre_install_script" { 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" + description = "Custom script to run before installing Claude Code." + default = null } -variable "compile_boundary_from_source" { - type = bool - description = "Whether to compile boundary from source instead of using the official install script" - default = false +variable "post_install_script" { + type = string + description = "Custom script to run after installing Claude Code." + default = null } -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 -} +# 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" {} -variable "enable_aibridge" { - type = bool - description = "Use AI Bridge for Claude Code. https://coder.com/docs/ai-coder/ai-bridge" - default = false +data "coder_workspace_owner" "me" {} - 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." +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 } - 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." + oauth_token_env = var.claude_code_oauth_token == "" ? {} : { + CLAUDE_CODE_OAUTH_TOKEN = var.claude_code_oauth_token } -} -variable "enable_state_persistence" { - type = bool - description = "Enable AgentAPI conversation state persistence across restarts." - default = true -} - -resource "coder_env" "claude_code_md_path" { - count = var.claude_md_path == "" ? 0 : 1 - agent_id = var.agent_id - name = "CODER_MCP_CLAUDE_MD_PATH" - value = var.claude_md_path -} - -resource "coder_env" "claude_code_system_prompt" { - agent_id = var.agent_id - name = "CODER_MCP_CLAUDE_SYSTEM_PROMPT" - value = local.final_system_prompt -} - -resource "coder_env" "claude_code_oauth_token" { - 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 - - agent_id = var.agent_id - name = "CLAUDE_API_KEY" - value = local.claude_api_key -} + 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 + } : {} + + autoupdater_env = var.disable_autoupdater ? { + 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.autoupdater_env, + var.env, + ) -resource "coder_env" "disable_autoupdater" { - count = var.disable_autoupdater ? 1 : 0 + # 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/.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", + "set -euo pipefail", + "", + "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"), + ]) +} + +# 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(local.merged_env))) agent_id = var.agent_id - name = "DISABLE_AUTOUPDATER" - value = "1" + name = each.key + value = local.merged_env[each.key] } +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" -resource "coder_env" "anthropic_model" { - count = var.model != "" ? 1 : 0 - agent_id = var.agent_id - name = "ANTHROPIC_MODEL" - value = var.model -} + agent_id = var.agent_id + agent_name = "claude-code" + module_directory = "$HOME/.coder-modules/claude-code" -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" -} + display_name_prefix = "Claude Code" + icon = "/icon/claude.svg" -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) : "" - ) -} + pre_install_script = var.pre_install_script + post_install_script = var.post_install_script -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 - - 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_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_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 + install_script = local.install_script } -output "task_app_id" { - value = module.agentapi.task_app_id +# 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 = module.coder-utils.scripts } diff --git a/registry/coder/modules/claude-code/main.tftest.hcl b/registry/coder/modules/claude-code/main.tftest.hcl index 9c9df50f4..7492bd484 100644 --- a/registry/coder/modules/claude-code/main.tftest.hcl +++ b/registry/coder/modules/claude-code/main.tftest.hcl @@ -1,462 +1,427 @@ -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" - } - - assert { - condition = var.report_tasks == true - error_message = "report_tasks should default to true" + condition = length(coder_env.env) == 0 + error_message = "No env vars should be set by default" } } -run "test_claude_code_with_api_key" { +run "test_with_env_map" { command = plan variables { - agent_id = "test-agent-456" - workdir = "/home/coder/workspace" - claude_api_key = "test-api-key-123" - } - - 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 - } - - assert { - condition = var.order == 5 - error_message = "Order variable should be set to 5" - } - - assert { - condition = var.group == "development" - error_message = "Group variable should be set to 'development'" - } - - assert { - condition = var.icon == "/icon/custom.svg" - error_message = "Icon variable should be set to custom icon" + 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 = var.model == "opus" - error_message = "Claude model variable should be set to 'opus'" + condition = coder_env.env["ANTHROPIC_API_KEY"].value == "sk-live" + error_message = "env[ANTHROPIC_API_KEY] should be set" } assert { - condition = var.ai_prompt == "Help me write better code" - error_message = "AI prompt variable should be set correctly" + condition = coder_env.env["CLAUDE_CODE_OAUTH_TOKEN"].value == "oauth-live" + error_message = "env[CLAUDE_CODE_OAUTH_TOKEN] should be set" } assert { - condition = var.permission_mode == "plan" - error_message = "Permission mode should be set to 'plan'" + condition = coder_env.env["ANTHROPIC_MODEL"].value == "opus" + error_message = "env[ANTHROPIC_MODEL] should be set" } assert { - condition = var.continue == true - error_message = "Continue should be set to true" + condition = coder_env.env["ANTHROPIC_BASE_URL"].value == "https://proxy.example.com" + error_message = "env[ANTHROPIC_BASE_URL] should be set" } assert { - condition = var.claude_code_version == "1.0.0" - error_message = "Claude Code version should be set to '1.0.0'" + condition = coder_env.env["DISABLE_AUTOUPDATER"].value == "1" + error_message = "env[DISABLE_AUTOUPDATER] should be set" } assert { - condition = var.agentapi_version == "v0.6.0" - error_message = "AgentAPI version should be set to 'v0.6.0'" + condition = coder_env.env["CUSTOM_VAR"].value == "hello" + error_message = "arbitrary env keys should pass through" } assert { - condition = var.dangerously_skip_permissions == true - error_message = "dangerously_skip_permissions should be set to true" + condition = length(coder_env.env) == 6 + error_message = "should create exactly 6 coder_env resources" } } -run "test_claude_code_with_mcp_and_tools" { +run "test_with_mcp_inline" { command = plan variables { - agent_id = "test-agent-mcp" - workdir = "/home/coder/mcp-test" + agent_id = "test-agent" mcp = jsonencode({ mcpServers = { - test = { - command = "test-server" - args = ["--config", "test.json"] + test-server = { + command = "test-cmd" + args = [] } } }) - allowed_tools = "bash,python" - disallowed_tools = "rm" } assert { condition = var.mcp != "" - error_message = "MCP configuration should be provided" - } - - assert { - condition = var.allowed_tools == "bash,python" - error_message = "Allowed tools should be set" - } - - assert { - condition = var.disallowed_tools == "rm" - error_message = "Disallowed tools should be set" + error_message = "mcp should be passed through" } } -run "test_claude_code_with_scripts" { +run "test_with_mcp_remote" { 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" + mcp_config_remote_path = ["https://example.com/mcp.json"] } assert { - condition = var.post_install_script == "echo 'Post-install script'" - error_message = "Post-install script should be set correctly" + condition = length(var.mcp_config_remote_path) == 1 + error_message = "mcp_config_remote_path should carry one URL" } } -run "test_claude_code_permission_mode_validation" { +run "test_with_pre_post_install" { command = plan variables { - agent_id = "test-agent-validation" - workdir = "/home/coder/test" - permission_mode = "acceptEdits" + agent_id = "test-agent" + pre_install_script = "echo pre" + post_install_script = "echo post" } assert { - condition = contains(["", "default", "acceptEdits", "plan", "auto", "bypassPermissions"], var.permission_mode) - error_message = "Permission mode should be one of the valid options" + condition = var.pre_install_script == "echo pre" + error_message = "pre_install_script should be forwarded" } -} - -run "test_claude_code_auto_permission_mode" { - command = plan - variables { - agent_id = "test-agent-auto" - workdir = "/home/coder/test" - permission_mode = "auto" + assert { + 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 = var.permission_mode == "auto" - error_message = "Permission mode should be set to auto" + condition = module.coder-utils.script_names.pre_install != "" + error_message = "Pre-install script name should be populated when pre_install_script is set" } -} -run "test_claude_code_with_boundary" { - command = plan - - variables { - agent_id = "test-agent-boundary" - workdir = "/home/coder/boundary-test" - enable_boundary = true + 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 = var.enable_boundary == true - error_message = "Boundary should be enabled" + 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 = local.coder_host != "" - error_message = "Coder host should be extracted from access URL" + condition = length(output.scripts) == 3 + error_message = "scripts output should have exactly 3 entries when pre/post are set" } -} - -run "test_claude_code_system_prompt" { - command = plan - variables { - agent_id = "test-agent-system-prompt" - workdir = "/home/coder/test" - system_prompt = "Custom addition" + 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 = trimspace(coder_env.claude_code_system_prompt.value) != "" - error_message = "System prompt should not be empty" + condition = output.scripts[1] == module.coder-utils.script_names.install + error_message = "scripts[1] must be the install name (run-order)" } 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 = output.scripts[2] == module.coder-utils.script_names.post_install + error_message = "scripts[2] must be the post-install name (run-order)" } } -run "test_claude_report_tasks_default" { +run "test_defaults_produce_only_install_script" { command = plan variables { - agent_id = "test-agent-report-tasks" - workdir = "/home/coder/test" - # report_tasks: default is true + agent_id = "test-agent" } assert { - condition = trimspace(coder_env.claude_code_system_prompt.value) != "" - error_message = "System prompt should not be empty" + condition = module.coder-utils.script_names.pre_install == "" + error_message = "Pre-install script should be absent by default" } - # Ensure system prompt is wrapped by assert { - condition = startswith(trimspace(coder_env.claude_code_system_prompt.value), "") - error_message = "System prompt should start with " + condition = module.coder-utils.script_names.post_install == "" + error_message = "Post-install script should be absent by default" } + assert { - condition = endswith(trimspace(coder_env.claude_code_system_prompt.value), "") - error_message = "System prompt should end with " + condition = module.coder-utils.script_names.start == "" + error_message = "Start script should never be created by claude-code" } - # 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" + 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(regexall("-- Task Reporting --", coder_env.claude_code_system_prompt.value)) > 0 - error_message = "System prompt should have Task Reporting section" + 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_claude_report_tasks_disabled" { +run "test_scripts_output_excludes_post_when_only_pre_set" { command = plan variables { - agent_id = "test-agent-report-tasks" - workdir = "/home/coder/test" - report_tasks = false + agent_id = "test-agent" + pre_install_script = "echo only-pre" } + # With only pre_install set, `scripts` holds 2 entries: pre, install. assert { - condition = trimspace(coder_env.claude_code_system_prompt.value) != "" - error_message = "System prompt should not be empty" + condition = length(output.scripts) == 2 + error_message = "scripts output should contain exactly 2 entries when only pre is set" } - # Ensure system prompt is wrapped by assert { - condition = startswith(trimspace(coder_env.claude_code_system_prompt.value), "") - error_message = "System prompt should start with " + 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 = endswith(trimspace(coder_env.claude_code_system_prompt.value), "") - error_message = "System prompt should end with " + condition = output.scripts[1] == module.coder-utils.script_names.install + error_message = "scripts[1] must be install when post is unset" } } -run "test_aibridge_enabled" { +run "test_mcp_remote_rejects_http" { command = plan variables { - agent_id = "test-agent-aibridge" - workdir = "/home/coder/aibridge" - enable_aibridge = true + agent_id = "test-agent" + mcp_config_remote_path = ["http://example.com/mcp.json"] } - override_data { - target = data.coder_workspace_owner.me - values = { - session_token = "mock-session-token" - } + expect_failures = [var.mcp_config_remote_path] +} + +run "test_claude_binary_path_validation" { + command = plan + + variables { + agent_id = "test-agent" + install_claude_code = true + claude_binary_path = "/opt/custom" } - assert { - condition = var.enable_aibridge == true - error_message = "AI Bridge should be enabled" + expect_failures = [var.claude_binary_path] +} + +run "test_model_convenience" { + command = plan + + variables { + agent_id = "test-agent" + model = "opus" } 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.env["ANTHROPIC_MODEL"].value == "opus" + error_message = "model input must set ANTHROPIC_MODEL" } 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 = length(coder_env.env) == 1 + error_message = "only ANTHROPIC_MODEL should be set" } +} - assert { - condition = coder_env.claude_api_key[0].name == "CLAUDE_API_KEY" - error_message = "CLAUDE_API_KEY environment variable 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.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 = coder_env.env["CLAUDE_CODE_OAUTH_TOKEN"].value == "oauth-live" + error_message = "claude_code_oauth_token must set CLAUDE_CODE_OAUTH_TOKEN" } } -run "test_aibridge_validation_with_api_key" { +run "test_disable_autoupdater_convenience" { 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" + disable_autoupdater = true } - expect_failures = [ - var.enable_aibridge, - ] + assert { + condition = coder_env.env["DISABLE_AUTOUPDATER"].value == "1" + error_message = "disable_autoupdater must set DISABLE_AUTOUPDATER=1" + } } -run "test_aibridge_validation_with_oauth_token" { +run "test_enable_ai_gateway_convenience" { command = plan variables { - agent_id = "test-agent-validation" - workdir = "/home/coder/test" - enable_aibridge = true - claude_code_oauth_token = "test-oauth-token" + 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" } - expect_failures = [ - var.enable_aibridge, - ] + 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_aibridge_disabled_with_api_key" { +run "test_convenience_and_env_merge" { 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" + model = "opus" + env = { + ANTHROPIC_API_KEY = "sk-live" + } } assert { - condition = var.enable_aibridge == false - error_message = "AI Bridge should be disabled" + condition = coder_env.env["ANTHROPIC_MODEL"].value == "opus" + error_message = "convenience input must still apply when env is set" } 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" + 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.anthropic_base_url) == 0 - error_message = "ANTHROPIC_BASE_URL should not be set when aibridge is disabled" + condition = length(coder_env.env) == 2 + error_message = "merged env must have exactly 2 entries" } } -run "test_enable_state_persistence_default" { +run "test_model_conflicts_with_env" { command = plan variables { agent_id = "test-agent" - workdir = "/home/coder" + model = "opus" + env = { + ANTHROPIC_MODEL = "sonnet" + } } - assert { - condition = var.enable_state_persistence == true - error_message = "enable_state_persistence should default to true" - } + expect_failures = [var.env] } -run "test_disable_state_persistence" { +run "test_oauth_token_conflicts_with_env" { command = plan variables { - agent_id = "test-agent" - workdir = "/home/coder" - enable_state_persistence = false + agent_id = "test-agent" + claude_code_oauth_token = "oauth-live" + env = { + CLAUDE_CODE_OAUTH_TOKEN = "oauth-from-env" + } } - assert { - condition = var.enable_state_persistence == false - error_message = "enable_state_persistence should be false when explicitly disabled" - } + expect_failures = [var.env] } -run "test_no_api_key_no_env" { +run "test_ai_gateway_conflicts_with_env_base_url" { command = plan variables { - agent_id = "test-agent-no-key" - workdir = "/home/coder/test" - enable_aibridge = false + agent_id = "test-agent" + enable_ai_gateway = true + env = { + ANTHROPIC_BASE_URL = "https://custom.example.com" + } } - 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" - } + expect_failures = [var.env] } -run "test_api_key_count_with_aibridge_no_override" { +run "test_ai_gateway_conflicts_with_env_auth_token" { command = plan variables { - agent_id = "test-agent-count" - workdir = "/home/coder/test" - enable_aibridge = true + agent_id = "test-agent" + enable_ai_gateway = true + env = { + ANTHROPIC_AUTH_TOKEN = "custom-token" + } } - 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" + expect_failures = [var.env] +} + +run "test_autoupdater_conflicts_with_env" { + command = plan + + variables { + agent_id = "test-agent" + disable_autoupdater = true + env = { + DISABLE_AUTOUPDATER = "0" + } } -} \ No newline at end of file + + expect_failures = [var.env] +} diff --git a/registry/coder/modules/claude-code/scripts/install.sh b/registry/coder/modules/claude-code/scripts/install.sh index c00773b5e..b9464a593 100644 --- a/registry/coder/modules/claude-code/scripts/install.sh +++ b/registry/coder/modules/claude-code/scripts/install.sh @@ -2,66 +2,67 @@ 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:-} +# 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_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:-} +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_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" - +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 "--------------------------------" -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 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 @@ -70,14 +71,16 @@ function 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 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 +106,97 @@ function ensure_claude_in_path() { add_path_to_shell_profiles "$CLAUDE_DIR" } -function install_claude_code_cli() { +# 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() { + 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)" + 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)') +} + +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 + # 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..." + 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 < <(echo "$ARG_MCP_CONFIG_REMOTE_PATH" | jq -r '.[]') 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" + 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 - - echo "Pre-accepted auto mode prompt" } install_claude_code_cli -setup_claude_configurations -report_tasks -if [ "$ARG_PERMISSION_MODE" = "auto" ]; then - accept_auto_mode +# 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 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 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"] + } + } +}