diff --git a/.github/workflows/drupal-contrib-integration-test.yml b/.github/workflows/drupal-contrib-integration-test.yml index 039266d..00af395 100644 --- a/.github/workflows/drupal-contrib-integration-test.yml +++ b/.github/workflows/drupal-contrib-integration-test.yml @@ -133,6 +133,7 @@ jobs: --parameter issue_branch= \ --parameter install_profile=minimal \ --parameter share_drupal_site=owner \ + --use-parameter-defaults \ --yes - name: Verify workspace — agent connected @@ -316,6 +317,7 @@ jobs: --parameter issue_branch=${{ env.ISSUE_BRANCH }} \ --parameter install_profile=minimal \ --parameter share_drupal_site=owner \ + --use-parameter-defaults \ --yes - name: Verify workspace — agent connected @@ -474,6 +476,7 @@ jobs: --parameter issue_branch= \ --parameter install_profile=minimal \ --parameter share_drupal_site=owner \ + --use-parameter-defaults \ --yes - name: Verify workspace — agent connected @@ -633,6 +636,7 @@ jobs: --parameter issue_branch=${{ env.ISSUE_BRANCH }} \ --parameter install_profile=minimal \ --parameter share_drupal_site=owner \ + --use-parameter-defaults \ --yes - name: Verify workspace — agent connected diff --git a/.github/workflows/drupal-integration-test.yml b/.github/workflows/drupal-integration-test.yml index c12ee0e..b9f1840 100644 --- a/.github/workflows/drupal-integration-test.yml +++ b/.github/workflows/drupal-integration-test.yml @@ -132,6 +132,7 @@ jobs: --parameter issue_branch= \ --parameter install_profile=minimal \ --parameter share_drupal_site=owner \ + --use-parameter-defaults \ --yes - &verify-agent @@ -279,6 +280,7 @@ jobs: --parameter issue_branch= \ --parameter install_profile=minimal \ --parameter share_drupal_site=owner \ + --use-parameter-defaults \ --yes - *verify-agent @@ -383,6 +385,7 @@ jobs: --parameter issue_branch=${{ env.ISSUE_BRANCH }} \ --parameter install_profile=minimal \ --parameter share_drupal_site=owner \ + --use-parameter-defaults \ --yes - name: Verify workspace — agent connected @@ -539,6 +542,7 @@ jobs: --parameter issue_branch=${{ env.ISSUE_BRANCH }} \ --parameter install_profile=minimal \ --parameter share_drupal_site=owner \ + --use-parameter-defaults \ --yes - name: Verify workspace — agent connected diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index 2a19a20..7ac7c6c 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -149,6 +149,7 @@ jobs: --template-version ci-${{ env.CI_TAG }} \ --parameter "vscode_extensions=[]" \ ${{ matrix.extra_params }} \ + --use-parameter-defaults \ --yes - name: Create freeform workspace (with project names) @@ -371,6 +372,7 @@ jobs: --template-version ci-gh-${{ env.CI_TAG }} \ --parameter "vscode_extensions=[]" \ ${{ matrix.extra_params }} \ + --use-parameter-defaults \ --yes - name: Create freeform workspace (with project names) diff --git a/Makefile b/Makefile index d544682..6f1e46f 100644 --- a/Makefile +++ b/Makefile @@ -103,8 +103,17 @@ test: ## Test the built image by running it docker run --rm $(IMAGE_TAG) node --version @echo "Test complete" +.PHONY: sync-claude-module +sync-claude-module: ## Vendor modules/claude-remote-control into each template dir (coder templates push only bundles the single --directory given to it, so a shared "../modules" reference doesn't survive the push; each template needs its own copy) + @for t in $(TEMPLATES); do \ + rm -rf $$t/modules/claude-remote-control; \ + mkdir -p $$t/modules; \ + cp -r modules/claude-remote-control $$t/modules/claude-remote-control; \ + done + @echo "Synced modules/claude-remote-control into: $(TEMPLATES)" + .PHONY: validate -validate: ## Validate all Terraform templates (requires terraform in PATH) +validate: sync-claude-module ## Validate all Terraform templates (requires terraform in PATH) @for t in $(TEMPLATES); do \ echo "--- Validating $$t ---"; \ (cd $$t && terraform init -backend=false -input=false -no-color && terraform validate -no-color) || exit 1; \ @@ -116,7 +125,7 @@ fmt-check: ## Check Terraform formatting across all templates terraform fmt -check -recursive .PHONY: test-templates -test-templates: ## Run Terraform mock unit tests for all templates (requires terraform in PATH) +test-templates: sync-claude-module ## Run Terraform mock unit tests for all templates (requires terraform in PATH) @for t in $(TEMPLATES); do \ echo "--- Testing $$t ---"; \ (cd $$t && terraform test) || exit 1; \ @@ -161,15 +170,15 @@ info: ## Show image and template information # --- Template push targets --- .PHONY: push-template-drupal-core -push-template-drupal-core: ## Push drupal-core template to Coder +push-template-drupal-core: sync-claude-module ## Push drupal-core template to Coder $(call push_template,drupal-core) .PHONY: push-template-drupal-contrib -push-template-drupal-contrib: ## Push drupal-contrib template to Coder +push-template-drupal-contrib: sync-claude-module ## Push drupal-contrib template to Coder $(call push_template,drupal-contrib) .PHONY: push-template-freeform -push-template-freeform: ## Push freeform template to Coder +push-template-freeform: sync-claude-module ## Push freeform template to Coder $(call push_template,freeform) .PHONY: push-all-templates diff --git a/drupal-contrib/modules/claude-remote-control/main.tf b/drupal-contrib/modules/claude-remote-control/main.tf new file mode 100644 index 0000000..3e9e3f1 --- /dev/null +++ b/drupal-contrib/modules/claude-remote-control/main.tf @@ -0,0 +1,61 @@ +data "coder_parameter" "enable_claude_code" { + name = "enable_claude_code" + display_name = "Enable Claude Code remote control" + description = <<-EOT + Control Claude Code in this workspace from claude.ai/code or the Claude mobile app. + + **First use:** open the "Claude Code" app button (or SSH into the workspace) and complete the one-time Claude login plus the workspace-trust prompt. + + **Work in a project directory:** Claude starts in `$HOME`. From any terminal, `cd` into your project and run `claude-here` to bring Claude there — no re-login needed, and directories under `$HOME` are auto-trusted (same trust boundary as `$HOME` itself), so no trust prompt either. This starts a new conversation in a new session (named after the directory) — the old session stops appearing in claude.ai/code once you do this. + + Requires a Claude Pro/Max/Team/Enterprise subscription (API-key auth is not supported); does not work with Bedrock/Vertex/a custom gateway. Remote sessions disconnect after ~10 minutes offline. + EOT + + type = "bool" + form_type = "switch" + default = "false" + mutable = true + order = var.parameter_order +} + +data "coder_parameter" "claude_code_skip_permissions" { + name = "claude_code_skip_permissions" + display_name = "Claude Code: skip permission prompts" + description = "Pass --dangerously-skip-permissions. Only takes effect when remote control is enabled." + type = "bool" + form_type = "switch" + default = "false" + mutable = true + order = var.parameter_order + 1 +} + +locals { + # Deliberately compared as strings, not tobool(): the blanket coder_parameter + # mock in callers' tests defaults every parameter's value to "[]", which + # tobool() would reject outright. + enabled = data.coder_parameter.enable_claude_code.value == "true" + skip_permissions = data.coder_parameter.claude_code_skip_permissions.value == "true" + extra_flags = local.skip_permissions ? "--dangerously-skip-permissions" : "" + + # `claude` is installed via Homebrew (linuxbrew), which is only on PATH in + # interactive shells (~/.bashrc). tmux new-session/respawn-pane run the + # command directly, not through a login/interactive shell, so a bare + # "claude" is not found the first time a session is created at boot - + # invoke it by absolute path instead of depending on PATH inheritance. + claude_bin = "/home/linuxbrew/.linuxbrew/bin/claude" + remote_cmd = join(" ", compact([ + local.claude_bin, "--remote-control", "$CODER_WORKSPACE_NAME", local.extra_flags, + ])) +} + +# Terminal-tab fallback for first login/troubleshooting. The primary intended +# usage is connecting from claude.ai/code or the mobile app to the named tmux +# session started by the caller's startup script, not this button. +resource "coder_app" "claude_code" { + count = local.enabled ? 1 : 0 + agent_id = var.agent_id + slug = "claude-code" + display_name = "Claude Code" + command = "~/.local/bin/claude-ensure 2>/dev/null; tmux attach -t \"$CODER_WORKSPACE_NAME:0\"" + order = var.app_order +} diff --git a/drupal-contrib/modules/claude-remote-control/outputs.tf b/drupal-contrib/modules/claude-remote-control/outputs.tf new file mode 100644 index 0000000..474aaf8 --- /dev/null +++ b/drupal-contrib/modules/claude-remote-control/outputs.tf @@ -0,0 +1,125 @@ +output "enabled" { + description = "Whether Claude Code remote control is enabled" + value = local.enabled +} + +output "app_count" { + description = "Number of coder_app.claude_code instances created (0 or 1) - exposed for tests" + value = length(coder_app.claude_code) +} + +output "startup_script" { + description = "Bash snippet for the caller to append to its coder_agent startup_script, after other ~/.bashrc customizations." + value = <<-EOT + + # Claude Code remote control: this launches `claude --remote-control` in a named + # tmux session so it's connectable from claude.ai/code or the mobile app. + CLAUDE_CODE_ENABLED="${local.enabled}" + if [ "$CLAUDE_CODE_ENABLED" = "true" ]; then + sed -i '/^export CLAUDE_REMOTE_CMD=/d;/^export CLAUDE_EXTRA_FLAGS=/d' ~/.bashrc || true + echo 'export CLAUDE_REMOTE_CMD="${local.remote_cmd}"' >> ~/.bashrc + echo 'export CLAUDE_EXTRA_FLAGS="${local.extra_flags}"' >> ~/.bashrc + export CLAUDE_REMOTE_CMD="${local.remote_cmd}" + export CLAUDE_EXTRA_FLAGS="${local.extra_flags}" + + # Target window 0 explicitly, not just the session: an untargeted + # `-t "$CODER_WORKSPACE_NAME"` resolves to the session's current + # *active* window, which silently becomes some other window if one + # was ever created (e.g. by the user running tmux themselves) and + # left focused - respawning the wrong pane instead of the intended + # Claude Code session. + # + # The remote-control name includes the current directory's basename + # so each retarget shows up as a visibly different session in + # claude.ai/code - reusing the exact same name every time makes it + # impossible to tell the new (live) session apart from the old + # (now-dead, since a respawn never resumes the prior one) ones in + # that list. + mkdir -p ~/.local/bin + cat > ~/.local/bin/claude-here <<-'CLAUDEHERE' + #!/usr/bin/env bash + set -euo pipefail + TARGET_DIR="$(pwd)" + DIR_NAME=$(basename "$TARGET_DIR") + + case "$TARGET_DIR" in + "$HOME"/*) + # $HOME itself is already a trusted workspace boundary (accepted once + # at first login) - claude's trust store is keyed per exact path, not + # inherited by subdirectories, so without this every new project + # directory under $HOME would re-prompt for a dialog nobody's there + # to answer (this just launches a background tmux pane) - remote + # control won't come online until someone manually accepts it. + if command -v jq >/dev/null 2>&1 && [ -f "$HOME/.claude.json" ]; then + CLAUDE_JSON_TMP=$(mktemp) + if jq --arg dir "$TARGET_DIR" '.projects[$dir].hasTrustDialogAccepted = true' "$HOME/.claude.json" > "$CLAUDE_JSON_TMP" 2>/dev/null; then + mv "$CLAUDE_JSON_TMP" "$HOME/.claude.json" + else + rm -f "$CLAUDE_JSON_TMP" + fi + fi + ;; + *) + echo "Note: $TARGET_DIR is outside \$HOME, so it isn't auto-trusted." + echo "Go accept the workspace-trust prompt in the Claude Code session before it starts serving." + ;; + esac + + tmux respawn-pane -k -t "$CODER_WORKSPACE_NAME:0" -c "$TARGET_DIR" "${local.claude_bin} --remote-control $CODER_WORKSPACE_NAME-$DIR_NAME $CLAUDE_EXTRA_FLAGS" +CLAUDEHERE + # ^ must stay at column 0: bash's <<- only strips leading TABS from the + # terminator line, not spaces, and this file is space-indented - any + # indentation here makes the heredoc unterminated (silently swallows the + # rest of the script as its body, a syntax error only bash notices). + chmod +x ~/.local/bin/claude-here + + # claude-ensure: makes sure window 0 of the named tmux session is + # actually running claude, (re)launching it if not. Needed because + # tmux has-session alone isn't a sufficient guard: the session could + # already exist but be a bare shell instead of claude - e.g. the + # "Claude Code" app button raced ahead of this script and created it + # first, or the claude process itself already exited (it will, if + # nobody completes login/connects within its idle window) leaving the + # tmux server dead entirely. Shared by both this startup script and + # the app button's command so either one can revive a dead/bare + # session, not just create it once at boot. + cat > ~/.local/bin/claude-ensure <<-'CLAUDEENSURE' + #!/usr/bin/env bash + set -euo pipefail + if tmux has-session -t "$CODER_WORKSPACE_NAME" 2>/dev/null; then + # remain-on-exit keeps the pane (and so the session/server) around even + # after claude exits - e.g. if nobody logs in before its idle timeout. + # Without it, an unattended claude exiting takes the whole tmux server + # down with it, with no session left for claude-ensure to even detect. + tmux set-option -t "$CODER_WORKSPACE_NAME" remain-on-exit on 2>/dev/null || true + read -r PANE_DEAD CURRENT_CMD < <(tmux list-panes -t "$CODER_WORKSPACE_NAME:0" -F '#{pane_dead} #{pane_current_command}' 2>/dev/null | head -1) + if [ "$PANE_DEAD" = "1" ] || [ "$CURRENT_CMD" != "claude" ]; then + tmux respawn-pane -k -t "$CODER_WORKSPACE_NAME:0" -c "$HOME" "$CLAUDE_REMOTE_CMD" + fi + else + tmux new-session -d -s "$CODER_WORKSPACE_NAME" -c "$HOME" "$CLAUDE_REMOTE_CMD" + tmux set-option -t "$CODER_WORKSPACE_NAME" remain-on-exit on + fi +CLAUDEENSURE + # ^ column 0 for the same reason as CLAUDEHERE above. + chmod +x ~/.local/bin/claude-ensure + + ~/.local/bin/claude-ensure + + # Printed to the live build log so it's seen during the build, not + # just buried in a one-line status - and appended to WELCOME.txt so + # it's seen again on every later login, since the build log usually + # isn't scrolled back to. + CLAUDE_CODE_BANNER=" +✓ Claude Code remote control session ready: $CODER_WORKSPACE_NAME + + → First time? Open the \"Claude Code\" app button (or SSH in) to finish + logging in and accept the workspace-trust prompt - the session won't + show up on claude.ai/code or the mobile app until you do. + → Working in a project directory? cd there and run: claude-here +" + echo "$CLAUDE_CODE_BANNER" + echo "$CLAUDE_CODE_BANNER" >> ~/WELCOME.txt + fi + EOT +} diff --git a/drupal-contrib/modules/claude-remote-control/variables.tf b/drupal-contrib/modules/claude-remote-control/variables.tf new file mode 100644 index 0000000..ffc56ad --- /dev/null +++ b/drupal-contrib/modules/claude-remote-control/variables.tf @@ -0,0 +1,16 @@ +variable "agent_id" { + description = "ID of the coder_agent to attach the Claude Code fallback terminal app to" + type = string +} + +variable "parameter_order" { + description = "Base UI order for the two coder_parameters this module declares (uses this value and this value + 1)" + type = number + default = 10 +} + +variable "app_order" { + description = "UI order for the fallback terminal coder_app" + type = number + default = 12 +} diff --git a/drupal-contrib/modules/claude-remote-control/versions.tf b/drupal-contrib/modules/claude-remote-control/versions.tf new file mode 100644 index 0000000..394c7cb --- /dev/null +++ b/drupal-contrib/modules/claude-remote-control/versions.tf @@ -0,0 +1,7 @@ +terraform { + required_providers { + coder = { + source = "coder/coder" + } + } +} diff --git a/drupal-contrib/template.tf b/drupal-contrib/template.tf index 8891590..3d78900 100644 --- a/drupal-contrib/template.tf +++ b/drupal-contrib/template.tf @@ -315,6 +315,27 @@ resource "coder_agent" "main" { sudo chown coder:coder /home/coder sudo chown -R coder:coder /home/linuxbrew + # If ~/.bashrc is already a symlink resolving outside $HOME (e.g. a + # dotfiles repo checked out under $HOME), replace it with a real file + # that sources the original target before the exports below get + # appended. Every append/sed step further down writes straight through + # symlinks, so a live symlink here would silently edit tracked files in + # a developer's dotfiles repo instead of this workspace's local shell + # config -- that happened once already. + if [ -L "/home/coder/.bashrc" ]; then + _bashrc_target=$(readlink -f "/home/coder/.bashrc" 2>/dev/null || true) + case "$_bashrc_target" in + /home/coder/*) ;; # resolves inside $HOME, safe to write through + *) + echo "~/.bashrc is a symlink to $_bashrc_target (outside \$HOME); replacing with a local file that sources it" + rm -f "/home/coder/.bashrc" + if [ -n "$_bashrc_target" ]; then + echo "[ -r \"$_bashrc_target\" ] && source \"$_bashrc_target\"" > "/home/coder/.bashrc" + fi + ;; + esac + fi + if [ ! -f "/home/coder/.bashrc" ]; then echo "Initializing home directory..." cp -rT /etc/skel/. /home/coder/ @@ -998,6 +1019,8 @@ BASHPROFILE_WELCOME printf '\n# Source system-wide settings (bash_completion etc.) for login shells\nif [ -f /etc/bash.bashrc ]; then\n . /etc/bash.bashrc\nfi\n' >> ~/.bash_profile fi + ${module.claude_remote_control.startup_script} + # ========================================== # Timing summary and final status # ========================================== @@ -1094,6 +1117,11 @@ module "vscode-web" { extensions = local.selected_extensions } +module "claude_remote_control" { + source = "./modules/claude-remote-control" + agent_id = coder_agent.main.id +} + resource "coder_app" "ddev-web" { agent_id = coder_agent.main.id slug = "ddev-web" diff --git a/drupal-contrib/tests/validate.tftest.hcl b/drupal-contrib/tests/validate.tftest.hcl index 5950625..9bc20b6 100644 --- a/drupal-contrib/tests/validate.tftest.hcl +++ b/drupal-contrib/tests/validate.tftest.hcl @@ -86,3 +86,53 @@ run "linuxbrew_volume_created" { error_message = "docker_volume.coder_linuxbrew must be named per the coder---linuxbrew convention" } } + +run "claude_code_disabled_by_default" { + command = plan + assert { + condition = module.claude_remote_control.app_count == 0 + error_message = "module.claude_remote_control's coder_app.claude_code must not be created when enable_claude_code=false" + } +} + +run "claude_code_enabled" { + command = plan + override_data { + target = module.claude_remote_control.data.coder_parameter.enable_claude_code + values = { + value = "true" + } + } + assert { + condition = module.claude_remote_control.app_count == 1 + error_message = "module.claude_remote_control's coder_app.claude_code must be created when enable_claude_code=true" + } + assert { + condition = strcontains(coder_agent.main.startup_script, "claude --remote-control") + error_message = "startup script must actually launch claude --remote-control" + } + assert { + condition = !strcontains(coder_agent.main.startup_script, "--dangerously-skip-permissions") + error_message = "skip-permissions flag must not appear unless claude_code_skip_permissions is also set" + } +} + +run "claude_code_enabled_skip_permissions" { + command = plan + override_data { + target = module.claude_remote_control.data.coder_parameter.enable_claude_code + values = { + value = "true" + } + } + override_data { + target = module.claude_remote_control.data.coder_parameter.claude_code_skip_permissions + values = { + value = "true" + } + } + assert { + condition = strcontains(coder_agent.main.startup_script, "--dangerously-skip-permissions") + error_message = "skip-permissions flag should appear in the generated command when claude_code_skip_permissions=true" + } +} diff --git a/drupal-core/modules/claude-remote-control/main.tf b/drupal-core/modules/claude-remote-control/main.tf new file mode 100644 index 0000000..3e9e3f1 --- /dev/null +++ b/drupal-core/modules/claude-remote-control/main.tf @@ -0,0 +1,61 @@ +data "coder_parameter" "enable_claude_code" { + name = "enable_claude_code" + display_name = "Enable Claude Code remote control" + description = <<-EOT + Control Claude Code in this workspace from claude.ai/code or the Claude mobile app. + + **First use:** open the "Claude Code" app button (or SSH into the workspace) and complete the one-time Claude login plus the workspace-trust prompt. + + **Work in a project directory:** Claude starts in `$HOME`. From any terminal, `cd` into your project and run `claude-here` to bring Claude there — no re-login needed, and directories under `$HOME` are auto-trusted (same trust boundary as `$HOME` itself), so no trust prompt either. This starts a new conversation in a new session (named after the directory) — the old session stops appearing in claude.ai/code once you do this. + + Requires a Claude Pro/Max/Team/Enterprise subscription (API-key auth is not supported); does not work with Bedrock/Vertex/a custom gateway. Remote sessions disconnect after ~10 minutes offline. + EOT + + type = "bool" + form_type = "switch" + default = "false" + mutable = true + order = var.parameter_order +} + +data "coder_parameter" "claude_code_skip_permissions" { + name = "claude_code_skip_permissions" + display_name = "Claude Code: skip permission prompts" + description = "Pass --dangerously-skip-permissions. Only takes effect when remote control is enabled." + type = "bool" + form_type = "switch" + default = "false" + mutable = true + order = var.parameter_order + 1 +} + +locals { + # Deliberately compared as strings, not tobool(): the blanket coder_parameter + # mock in callers' tests defaults every parameter's value to "[]", which + # tobool() would reject outright. + enabled = data.coder_parameter.enable_claude_code.value == "true" + skip_permissions = data.coder_parameter.claude_code_skip_permissions.value == "true" + extra_flags = local.skip_permissions ? "--dangerously-skip-permissions" : "" + + # `claude` is installed via Homebrew (linuxbrew), which is only on PATH in + # interactive shells (~/.bashrc). tmux new-session/respawn-pane run the + # command directly, not through a login/interactive shell, so a bare + # "claude" is not found the first time a session is created at boot - + # invoke it by absolute path instead of depending on PATH inheritance. + claude_bin = "/home/linuxbrew/.linuxbrew/bin/claude" + remote_cmd = join(" ", compact([ + local.claude_bin, "--remote-control", "$CODER_WORKSPACE_NAME", local.extra_flags, + ])) +} + +# Terminal-tab fallback for first login/troubleshooting. The primary intended +# usage is connecting from claude.ai/code or the mobile app to the named tmux +# session started by the caller's startup script, not this button. +resource "coder_app" "claude_code" { + count = local.enabled ? 1 : 0 + agent_id = var.agent_id + slug = "claude-code" + display_name = "Claude Code" + command = "~/.local/bin/claude-ensure 2>/dev/null; tmux attach -t \"$CODER_WORKSPACE_NAME:0\"" + order = var.app_order +} diff --git a/drupal-core/modules/claude-remote-control/outputs.tf b/drupal-core/modules/claude-remote-control/outputs.tf new file mode 100644 index 0000000..474aaf8 --- /dev/null +++ b/drupal-core/modules/claude-remote-control/outputs.tf @@ -0,0 +1,125 @@ +output "enabled" { + description = "Whether Claude Code remote control is enabled" + value = local.enabled +} + +output "app_count" { + description = "Number of coder_app.claude_code instances created (0 or 1) - exposed for tests" + value = length(coder_app.claude_code) +} + +output "startup_script" { + description = "Bash snippet for the caller to append to its coder_agent startup_script, after other ~/.bashrc customizations." + value = <<-EOT + + # Claude Code remote control: this launches `claude --remote-control` in a named + # tmux session so it's connectable from claude.ai/code or the mobile app. + CLAUDE_CODE_ENABLED="${local.enabled}" + if [ "$CLAUDE_CODE_ENABLED" = "true" ]; then + sed -i '/^export CLAUDE_REMOTE_CMD=/d;/^export CLAUDE_EXTRA_FLAGS=/d' ~/.bashrc || true + echo 'export CLAUDE_REMOTE_CMD="${local.remote_cmd}"' >> ~/.bashrc + echo 'export CLAUDE_EXTRA_FLAGS="${local.extra_flags}"' >> ~/.bashrc + export CLAUDE_REMOTE_CMD="${local.remote_cmd}" + export CLAUDE_EXTRA_FLAGS="${local.extra_flags}" + + # Target window 0 explicitly, not just the session: an untargeted + # `-t "$CODER_WORKSPACE_NAME"` resolves to the session's current + # *active* window, which silently becomes some other window if one + # was ever created (e.g. by the user running tmux themselves) and + # left focused - respawning the wrong pane instead of the intended + # Claude Code session. + # + # The remote-control name includes the current directory's basename + # so each retarget shows up as a visibly different session in + # claude.ai/code - reusing the exact same name every time makes it + # impossible to tell the new (live) session apart from the old + # (now-dead, since a respawn never resumes the prior one) ones in + # that list. + mkdir -p ~/.local/bin + cat > ~/.local/bin/claude-here <<-'CLAUDEHERE' + #!/usr/bin/env bash + set -euo pipefail + TARGET_DIR="$(pwd)" + DIR_NAME=$(basename "$TARGET_DIR") + + case "$TARGET_DIR" in + "$HOME"/*) + # $HOME itself is already a trusted workspace boundary (accepted once + # at first login) - claude's trust store is keyed per exact path, not + # inherited by subdirectories, so without this every new project + # directory under $HOME would re-prompt for a dialog nobody's there + # to answer (this just launches a background tmux pane) - remote + # control won't come online until someone manually accepts it. + if command -v jq >/dev/null 2>&1 && [ -f "$HOME/.claude.json" ]; then + CLAUDE_JSON_TMP=$(mktemp) + if jq --arg dir "$TARGET_DIR" '.projects[$dir].hasTrustDialogAccepted = true' "$HOME/.claude.json" > "$CLAUDE_JSON_TMP" 2>/dev/null; then + mv "$CLAUDE_JSON_TMP" "$HOME/.claude.json" + else + rm -f "$CLAUDE_JSON_TMP" + fi + fi + ;; + *) + echo "Note: $TARGET_DIR is outside \$HOME, so it isn't auto-trusted." + echo "Go accept the workspace-trust prompt in the Claude Code session before it starts serving." + ;; + esac + + tmux respawn-pane -k -t "$CODER_WORKSPACE_NAME:0" -c "$TARGET_DIR" "${local.claude_bin} --remote-control $CODER_WORKSPACE_NAME-$DIR_NAME $CLAUDE_EXTRA_FLAGS" +CLAUDEHERE + # ^ must stay at column 0: bash's <<- only strips leading TABS from the + # terminator line, not spaces, and this file is space-indented - any + # indentation here makes the heredoc unterminated (silently swallows the + # rest of the script as its body, a syntax error only bash notices). + chmod +x ~/.local/bin/claude-here + + # claude-ensure: makes sure window 0 of the named tmux session is + # actually running claude, (re)launching it if not. Needed because + # tmux has-session alone isn't a sufficient guard: the session could + # already exist but be a bare shell instead of claude - e.g. the + # "Claude Code" app button raced ahead of this script and created it + # first, or the claude process itself already exited (it will, if + # nobody completes login/connects within its idle window) leaving the + # tmux server dead entirely. Shared by both this startup script and + # the app button's command so either one can revive a dead/bare + # session, not just create it once at boot. + cat > ~/.local/bin/claude-ensure <<-'CLAUDEENSURE' + #!/usr/bin/env bash + set -euo pipefail + if tmux has-session -t "$CODER_WORKSPACE_NAME" 2>/dev/null; then + # remain-on-exit keeps the pane (and so the session/server) around even + # after claude exits - e.g. if nobody logs in before its idle timeout. + # Without it, an unattended claude exiting takes the whole tmux server + # down with it, with no session left for claude-ensure to even detect. + tmux set-option -t "$CODER_WORKSPACE_NAME" remain-on-exit on 2>/dev/null || true + read -r PANE_DEAD CURRENT_CMD < <(tmux list-panes -t "$CODER_WORKSPACE_NAME:0" -F '#{pane_dead} #{pane_current_command}' 2>/dev/null | head -1) + if [ "$PANE_DEAD" = "1" ] || [ "$CURRENT_CMD" != "claude" ]; then + tmux respawn-pane -k -t "$CODER_WORKSPACE_NAME:0" -c "$HOME" "$CLAUDE_REMOTE_CMD" + fi + else + tmux new-session -d -s "$CODER_WORKSPACE_NAME" -c "$HOME" "$CLAUDE_REMOTE_CMD" + tmux set-option -t "$CODER_WORKSPACE_NAME" remain-on-exit on + fi +CLAUDEENSURE + # ^ column 0 for the same reason as CLAUDEHERE above. + chmod +x ~/.local/bin/claude-ensure + + ~/.local/bin/claude-ensure + + # Printed to the live build log so it's seen during the build, not + # just buried in a one-line status - and appended to WELCOME.txt so + # it's seen again on every later login, since the build log usually + # isn't scrolled back to. + CLAUDE_CODE_BANNER=" +✓ Claude Code remote control session ready: $CODER_WORKSPACE_NAME + + → First time? Open the \"Claude Code\" app button (or SSH in) to finish + logging in and accept the workspace-trust prompt - the session won't + show up on claude.ai/code or the mobile app until you do. + → Working in a project directory? cd there and run: claude-here +" + echo "$CLAUDE_CODE_BANNER" + echo "$CLAUDE_CODE_BANNER" >> ~/WELCOME.txt + fi + EOT +} diff --git a/drupal-core/modules/claude-remote-control/variables.tf b/drupal-core/modules/claude-remote-control/variables.tf new file mode 100644 index 0000000..ffc56ad --- /dev/null +++ b/drupal-core/modules/claude-remote-control/variables.tf @@ -0,0 +1,16 @@ +variable "agent_id" { + description = "ID of the coder_agent to attach the Claude Code fallback terminal app to" + type = string +} + +variable "parameter_order" { + description = "Base UI order for the two coder_parameters this module declares (uses this value and this value + 1)" + type = number + default = 10 +} + +variable "app_order" { + description = "UI order for the fallback terminal coder_app" + type = number + default = 12 +} diff --git a/drupal-core/modules/claude-remote-control/versions.tf b/drupal-core/modules/claude-remote-control/versions.tf new file mode 100644 index 0000000..394c7cb --- /dev/null +++ b/drupal-core/modules/claude-remote-control/versions.tf @@ -0,0 +1,7 @@ +terraform { + required_providers { + coder = { + source = "coder/coder" + } + } +} diff --git a/drupal-core/template.tf b/drupal-core/template.tf index 0dbf358..003e893 100644 --- a/drupal-core/template.tf +++ b/drupal-core/template.tf @@ -357,6 +357,27 @@ resource "coder_agent" "main" { sudo chown coder:coder /home/coder sudo chown -R coder:coder /home/linuxbrew + # If ~/.bashrc is already a symlink resolving outside $HOME (e.g. a + # dotfiles repo checked out under $HOME), replace it with a real file + # that sources the original target before the exports below get + # appended. Every append/sed step further down writes straight through + # symlinks, so a live symlink here would silently edit tracked files in + # a developer's dotfiles repo instead of this workspace's local shell + # config -- that happened once already. + if [ -L "/home/coder/.bashrc" ]; then + _bashrc_target=$(readlink -f "/home/coder/.bashrc" 2>/dev/null || true) + case "$_bashrc_target" in + /home/coder/*) ;; # resolves inside $HOME, safe to write through + *) + echo "~/.bashrc is a symlink to $_bashrc_target (outside \$HOME); replacing with a local file that sources it" + rm -f "/home/coder/.bashrc" + if [ -n "$_bashrc_target" ]; then + echo "[ -r \"$_bashrc_target\" ] && source \"$_bashrc_target\"" > "/home/coder/.bashrc" + fi + ;; + esac + fi + # Copy defaults if empty (first run) if [ ! -f "/home/coder/.bashrc" ]; then echo "Initializing home directory..." @@ -1288,7 +1309,8 @@ BASHCOMP sudo ln -sf ~/.npm-global/bin/task-master-ai /usr/local/bin/task-master-ai 2>/dev/null || true fi fi - + + ${module.claude_remote_control.startup_script} @@ -1384,6 +1406,11 @@ module "vscode-web" { extensions = local.selected_extensions } +module "claude_remote_control" { + source = "./modules/claude-remote-control" + agent_id = coder_agent.main.id +} + # DDEV Web Server (HTTP) - appears when DDEV project is running # Uses subdomain routing for unique URLs per workspace resource "coder_app" "ddev-web" { diff --git a/drupal-core/tests/validate.tftest.hcl b/drupal-core/tests/validate.tftest.hcl index 27c38aa..c2b4316 100644 --- a/drupal-core/tests/validate.tftest.hcl +++ b/drupal-core/tests/validate.tftest.hcl @@ -93,3 +93,62 @@ run "linuxbrew_volume_created" { error_message = "docker_volume.coder_linuxbrew must be named per the coder---linuxbrew convention" } } + +run "claude_code_disabled_by_default" { + command = plan + variables { + cache_path = "/tmp/mock-cache" + } + assert { + condition = module.claude_remote_control.app_count == 0 + error_message = "module.claude_remote_control's coder_app.claude_code must not be created when enable_claude_code=false" + } +} + +run "claude_code_enabled" { + command = plan + variables { + cache_path = "/tmp/mock-cache" + } + override_data { + target = module.claude_remote_control.data.coder_parameter.enable_claude_code + values = { + value = "true" + } + } + assert { + condition = module.claude_remote_control.app_count == 1 + error_message = "module.claude_remote_control's coder_app.claude_code must be created when enable_claude_code=true" + } + assert { + condition = strcontains(coder_agent.main.startup_script, "claude --remote-control") + error_message = "startup script must actually launch claude --remote-control" + } + assert { + condition = !strcontains(coder_agent.main.startup_script, "--dangerously-skip-permissions") + error_message = "skip-permissions flag must not appear unless claude_code_skip_permissions is also set" + } +} + +run "claude_code_enabled_skip_permissions" { + command = plan + variables { + cache_path = "/tmp/mock-cache" + } + override_data { + target = module.claude_remote_control.data.coder_parameter.enable_claude_code + values = { + value = "true" + } + } + override_data { + target = module.claude_remote_control.data.coder_parameter.claude_code_skip_permissions + values = { + value = "true" + } + } + assert { + condition = strcontains(coder_agent.main.startup_script, "--dangerously-skip-permissions") + error_message = "skip-permissions flag should appear in the generated command when claude_code_skip_permissions=true" + } +} diff --git a/freeform/modules/claude-remote-control/main.tf b/freeform/modules/claude-remote-control/main.tf new file mode 100644 index 0000000..3e9e3f1 --- /dev/null +++ b/freeform/modules/claude-remote-control/main.tf @@ -0,0 +1,61 @@ +data "coder_parameter" "enable_claude_code" { + name = "enable_claude_code" + display_name = "Enable Claude Code remote control" + description = <<-EOT + Control Claude Code in this workspace from claude.ai/code or the Claude mobile app. + + **First use:** open the "Claude Code" app button (or SSH into the workspace) and complete the one-time Claude login plus the workspace-trust prompt. + + **Work in a project directory:** Claude starts in `$HOME`. From any terminal, `cd` into your project and run `claude-here` to bring Claude there — no re-login needed, and directories under `$HOME` are auto-trusted (same trust boundary as `$HOME` itself), so no trust prompt either. This starts a new conversation in a new session (named after the directory) — the old session stops appearing in claude.ai/code once you do this. + + Requires a Claude Pro/Max/Team/Enterprise subscription (API-key auth is not supported); does not work with Bedrock/Vertex/a custom gateway. Remote sessions disconnect after ~10 minutes offline. + EOT + + type = "bool" + form_type = "switch" + default = "false" + mutable = true + order = var.parameter_order +} + +data "coder_parameter" "claude_code_skip_permissions" { + name = "claude_code_skip_permissions" + display_name = "Claude Code: skip permission prompts" + description = "Pass --dangerously-skip-permissions. Only takes effect when remote control is enabled." + type = "bool" + form_type = "switch" + default = "false" + mutable = true + order = var.parameter_order + 1 +} + +locals { + # Deliberately compared as strings, not tobool(): the blanket coder_parameter + # mock in callers' tests defaults every parameter's value to "[]", which + # tobool() would reject outright. + enabled = data.coder_parameter.enable_claude_code.value == "true" + skip_permissions = data.coder_parameter.claude_code_skip_permissions.value == "true" + extra_flags = local.skip_permissions ? "--dangerously-skip-permissions" : "" + + # `claude` is installed via Homebrew (linuxbrew), which is only on PATH in + # interactive shells (~/.bashrc). tmux new-session/respawn-pane run the + # command directly, not through a login/interactive shell, so a bare + # "claude" is not found the first time a session is created at boot - + # invoke it by absolute path instead of depending on PATH inheritance. + claude_bin = "/home/linuxbrew/.linuxbrew/bin/claude" + remote_cmd = join(" ", compact([ + local.claude_bin, "--remote-control", "$CODER_WORKSPACE_NAME", local.extra_flags, + ])) +} + +# Terminal-tab fallback for first login/troubleshooting. The primary intended +# usage is connecting from claude.ai/code or the mobile app to the named tmux +# session started by the caller's startup script, not this button. +resource "coder_app" "claude_code" { + count = local.enabled ? 1 : 0 + agent_id = var.agent_id + slug = "claude-code" + display_name = "Claude Code" + command = "~/.local/bin/claude-ensure 2>/dev/null; tmux attach -t \"$CODER_WORKSPACE_NAME:0\"" + order = var.app_order +} diff --git a/freeform/modules/claude-remote-control/outputs.tf b/freeform/modules/claude-remote-control/outputs.tf new file mode 100644 index 0000000..474aaf8 --- /dev/null +++ b/freeform/modules/claude-remote-control/outputs.tf @@ -0,0 +1,125 @@ +output "enabled" { + description = "Whether Claude Code remote control is enabled" + value = local.enabled +} + +output "app_count" { + description = "Number of coder_app.claude_code instances created (0 or 1) - exposed for tests" + value = length(coder_app.claude_code) +} + +output "startup_script" { + description = "Bash snippet for the caller to append to its coder_agent startup_script, after other ~/.bashrc customizations." + value = <<-EOT + + # Claude Code remote control: this launches `claude --remote-control` in a named + # tmux session so it's connectable from claude.ai/code or the mobile app. + CLAUDE_CODE_ENABLED="${local.enabled}" + if [ "$CLAUDE_CODE_ENABLED" = "true" ]; then + sed -i '/^export CLAUDE_REMOTE_CMD=/d;/^export CLAUDE_EXTRA_FLAGS=/d' ~/.bashrc || true + echo 'export CLAUDE_REMOTE_CMD="${local.remote_cmd}"' >> ~/.bashrc + echo 'export CLAUDE_EXTRA_FLAGS="${local.extra_flags}"' >> ~/.bashrc + export CLAUDE_REMOTE_CMD="${local.remote_cmd}" + export CLAUDE_EXTRA_FLAGS="${local.extra_flags}" + + # Target window 0 explicitly, not just the session: an untargeted + # `-t "$CODER_WORKSPACE_NAME"` resolves to the session's current + # *active* window, which silently becomes some other window if one + # was ever created (e.g. by the user running tmux themselves) and + # left focused - respawning the wrong pane instead of the intended + # Claude Code session. + # + # The remote-control name includes the current directory's basename + # so each retarget shows up as a visibly different session in + # claude.ai/code - reusing the exact same name every time makes it + # impossible to tell the new (live) session apart from the old + # (now-dead, since a respawn never resumes the prior one) ones in + # that list. + mkdir -p ~/.local/bin + cat > ~/.local/bin/claude-here <<-'CLAUDEHERE' + #!/usr/bin/env bash + set -euo pipefail + TARGET_DIR="$(pwd)" + DIR_NAME=$(basename "$TARGET_DIR") + + case "$TARGET_DIR" in + "$HOME"/*) + # $HOME itself is already a trusted workspace boundary (accepted once + # at first login) - claude's trust store is keyed per exact path, not + # inherited by subdirectories, so without this every new project + # directory under $HOME would re-prompt for a dialog nobody's there + # to answer (this just launches a background tmux pane) - remote + # control won't come online until someone manually accepts it. + if command -v jq >/dev/null 2>&1 && [ -f "$HOME/.claude.json" ]; then + CLAUDE_JSON_TMP=$(mktemp) + if jq --arg dir "$TARGET_DIR" '.projects[$dir].hasTrustDialogAccepted = true' "$HOME/.claude.json" > "$CLAUDE_JSON_TMP" 2>/dev/null; then + mv "$CLAUDE_JSON_TMP" "$HOME/.claude.json" + else + rm -f "$CLAUDE_JSON_TMP" + fi + fi + ;; + *) + echo "Note: $TARGET_DIR is outside \$HOME, so it isn't auto-trusted." + echo "Go accept the workspace-trust prompt in the Claude Code session before it starts serving." + ;; + esac + + tmux respawn-pane -k -t "$CODER_WORKSPACE_NAME:0" -c "$TARGET_DIR" "${local.claude_bin} --remote-control $CODER_WORKSPACE_NAME-$DIR_NAME $CLAUDE_EXTRA_FLAGS" +CLAUDEHERE + # ^ must stay at column 0: bash's <<- only strips leading TABS from the + # terminator line, not spaces, and this file is space-indented - any + # indentation here makes the heredoc unterminated (silently swallows the + # rest of the script as its body, a syntax error only bash notices). + chmod +x ~/.local/bin/claude-here + + # claude-ensure: makes sure window 0 of the named tmux session is + # actually running claude, (re)launching it if not. Needed because + # tmux has-session alone isn't a sufficient guard: the session could + # already exist but be a bare shell instead of claude - e.g. the + # "Claude Code" app button raced ahead of this script and created it + # first, or the claude process itself already exited (it will, if + # nobody completes login/connects within its idle window) leaving the + # tmux server dead entirely. Shared by both this startup script and + # the app button's command so either one can revive a dead/bare + # session, not just create it once at boot. + cat > ~/.local/bin/claude-ensure <<-'CLAUDEENSURE' + #!/usr/bin/env bash + set -euo pipefail + if tmux has-session -t "$CODER_WORKSPACE_NAME" 2>/dev/null; then + # remain-on-exit keeps the pane (and so the session/server) around even + # after claude exits - e.g. if nobody logs in before its idle timeout. + # Without it, an unattended claude exiting takes the whole tmux server + # down with it, with no session left for claude-ensure to even detect. + tmux set-option -t "$CODER_WORKSPACE_NAME" remain-on-exit on 2>/dev/null || true + read -r PANE_DEAD CURRENT_CMD < <(tmux list-panes -t "$CODER_WORKSPACE_NAME:0" -F '#{pane_dead} #{pane_current_command}' 2>/dev/null | head -1) + if [ "$PANE_DEAD" = "1" ] || [ "$CURRENT_CMD" != "claude" ]; then + tmux respawn-pane -k -t "$CODER_WORKSPACE_NAME:0" -c "$HOME" "$CLAUDE_REMOTE_CMD" + fi + else + tmux new-session -d -s "$CODER_WORKSPACE_NAME" -c "$HOME" "$CLAUDE_REMOTE_CMD" + tmux set-option -t "$CODER_WORKSPACE_NAME" remain-on-exit on + fi +CLAUDEENSURE + # ^ column 0 for the same reason as CLAUDEHERE above. + chmod +x ~/.local/bin/claude-ensure + + ~/.local/bin/claude-ensure + + # Printed to the live build log so it's seen during the build, not + # just buried in a one-line status - and appended to WELCOME.txt so + # it's seen again on every later login, since the build log usually + # isn't scrolled back to. + CLAUDE_CODE_BANNER=" +✓ Claude Code remote control session ready: $CODER_WORKSPACE_NAME + + → First time? Open the \"Claude Code\" app button (or SSH in) to finish + logging in and accept the workspace-trust prompt - the session won't + show up on claude.ai/code or the mobile app until you do. + → Working in a project directory? cd there and run: claude-here +" + echo "$CLAUDE_CODE_BANNER" + echo "$CLAUDE_CODE_BANNER" >> ~/WELCOME.txt + fi + EOT +} diff --git a/freeform/modules/claude-remote-control/variables.tf b/freeform/modules/claude-remote-control/variables.tf new file mode 100644 index 0000000..ffc56ad --- /dev/null +++ b/freeform/modules/claude-remote-control/variables.tf @@ -0,0 +1,16 @@ +variable "agent_id" { + description = "ID of the coder_agent to attach the Claude Code fallback terminal app to" + type = string +} + +variable "parameter_order" { + description = "Base UI order for the two coder_parameters this module declares (uses this value and this value + 1)" + type = number + default = 10 +} + +variable "app_order" { + description = "UI order for the fallback terminal coder_app" + type = number + default = 12 +} diff --git a/freeform/modules/claude-remote-control/versions.tf b/freeform/modules/claude-remote-control/versions.tf new file mode 100644 index 0000000..394c7cb --- /dev/null +++ b/freeform/modules/claude-remote-control/versions.tf @@ -0,0 +1,7 @@ +terraform { + required_providers { + coder = { + source = "coder/coder" + } + } +} diff --git a/freeform/template.tf b/freeform/template.tf index bba01e1..2ce77ce 100644 --- a/freeform/template.tf +++ b/freeform/template.tf @@ -208,6 +208,27 @@ resource "coder_agent" "main" { sudo chown coder:coder /home/coder sudo chown -R coder:coder /home/linuxbrew + # If ~/.bashrc is already a symlink resolving outside $HOME (e.g. a + # dotfiles repo checked out under $HOME), replace it with a real file + # that sources the original target before the exports below get + # appended. Every append/sed step further down writes straight through + # symlinks, so a live symlink here would silently edit tracked files in + # a developer's dotfiles repo instead of this workspace's local shell + # config -- that happened once already. + if [ -L "/home/coder/.bashrc" ]; then + _bashrc_target=$(readlink -f "/home/coder/.bashrc" 2>/dev/null || true) + case "$_bashrc_target" in + /home/coder/*) ;; # resolves inside $HOME, safe to write through + *) + echo "~/.bashrc is a symlink to $_bashrc_target (outside \$HOME); replacing with a local file that sources it" + rm -f "/home/coder/.bashrc" + if [ -n "$_bashrc_target" ]; then + echo "[ -r \"$_bashrc_target\" ] && source \"$_bashrc_target\"" > "/home/coder/.bashrc" + fi + ;; + esac + fi + if [ ! -f "/home/coder/.bashrc" ]; then echo "Initializing home directory..." cp -rT /etc/skel/. /home/coder/ @@ -402,6 +423,8 @@ BASHCOMP echo 'export PATH="$HOME/.npm-global/bin:$PATH"' >> ~/.bashrc fi + ${module.claude_remote_control.startup_script} + echo "" echo "=== Setup Complete ===" echo "" @@ -464,6 +487,11 @@ module "vscode-web" { extensions = local.selected_extensions } +module "claude_remote_control" { + source = "./modules/claude-remote-control" + agent_id = coder_agent.main.id +} + # One coder_app per project name. All route to ddev-router on port 8080. # ddev-router dispatches by Host header: {slug}--{workspace}--{owner}.{domain} # The DDEV project name must equal the slug for coder-routes to build the correct rule. diff --git a/freeform/tests/validate.tftest.hcl b/freeform/tests/validate.tftest.hcl index a126552..f0c84ab 100644 --- a/freeform/tests/validate.tftest.hcl +++ b/freeform/tests/validate.tftest.hcl @@ -140,3 +140,53 @@ run "linuxbrew_volume_created" { error_message = "docker_volume.coder_linuxbrew must be named per the coder---linuxbrew convention" } } + +run "claude_code_disabled_by_default" { + command = plan + assert { + condition = module.claude_remote_control.app_count == 0 + error_message = "module.claude_remote_control's coder_app.claude_code must not be created when enable_claude_code=false" + } +} + +run "claude_code_enabled" { + command = plan + override_data { + target = module.claude_remote_control.data.coder_parameter.enable_claude_code + values = { + value = "true" + } + } + assert { + condition = module.claude_remote_control.app_count == 1 + error_message = "module.claude_remote_control's coder_app.claude_code must be created when enable_claude_code=true" + } + assert { + condition = strcontains(coder_agent.main.startup_script, "claude --remote-control") + error_message = "startup script must actually launch claude --remote-control" + } + assert { + condition = !strcontains(coder_agent.main.startup_script, "--dangerously-skip-permissions") + error_message = "skip-permissions flag must not appear unless claude_code_skip_permissions is also set" + } +} + +run "claude_code_enabled_skip_permissions" { + command = plan + override_data { + target = module.claude_remote_control.data.coder_parameter.enable_claude_code + values = { + value = "true" + } + } + override_data { + target = module.claude_remote_control.data.coder_parameter.claude_code_skip_permissions + values = { + value = "true" + } + } + assert { + condition = strcontains(coder_agent.main.startup_script, "--dangerously-skip-permissions") + error_message = "skip-permissions flag should appear in the generated command when claude_code_skip_permissions=true" + } +} diff --git a/modules/claude-remote-control/main.tf b/modules/claude-remote-control/main.tf new file mode 100644 index 0000000..3e9e3f1 --- /dev/null +++ b/modules/claude-remote-control/main.tf @@ -0,0 +1,61 @@ +data "coder_parameter" "enable_claude_code" { + name = "enable_claude_code" + display_name = "Enable Claude Code remote control" + description = <<-EOT + Control Claude Code in this workspace from claude.ai/code or the Claude mobile app. + + **First use:** open the "Claude Code" app button (or SSH into the workspace) and complete the one-time Claude login plus the workspace-trust prompt. + + **Work in a project directory:** Claude starts in `$HOME`. From any terminal, `cd` into your project and run `claude-here` to bring Claude there — no re-login needed, and directories under `$HOME` are auto-trusted (same trust boundary as `$HOME` itself), so no trust prompt either. This starts a new conversation in a new session (named after the directory) — the old session stops appearing in claude.ai/code once you do this. + + Requires a Claude Pro/Max/Team/Enterprise subscription (API-key auth is not supported); does not work with Bedrock/Vertex/a custom gateway. Remote sessions disconnect after ~10 minutes offline. + EOT + + type = "bool" + form_type = "switch" + default = "false" + mutable = true + order = var.parameter_order +} + +data "coder_parameter" "claude_code_skip_permissions" { + name = "claude_code_skip_permissions" + display_name = "Claude Code: skip permission prompts" + description = "Pass --dangerously-skip-permissions. Only takes effect when remote control is enabled." + type = "bool" + form_type = "switch" + default = "false" + mutable = true + order = var.parameter_order + 1 +} + +locals { + # Deliberately compared as strings, not tobool(): the blanket coder_parameter + # mock in callers' tests defaults every parameter's value to "[]", which + # tobool() would reject outright. + enabled = data.coder_parameter.enable_claude_code.value == "true" + skip_permissions = data.coder_parameter.claude_code_skip_permissions.value == "true" + extra_flags = local.skip_permissions ? "--dangerously-skip-permissions" : "" + + # `claude` is installed via Homebrew (linuxbrew), which is only on PATH in + # interactive shells (~/.bashrc). tmux new-session/respawn-pane run the + # command directly, not through a login/interactive shell, so a bare + # "claude" is not found the first time a session is created at boot - + # invoke it by absolute path instead of depending on PATH inheritance. + claude_bin = "/home/linuxbrew/.linuxbrew/bin/claude" + remote_cmd = join(" ", compact([ + local.claude_bin, "--remote-control", "$CODER_WORKSPACE_NAME", local.extra_flags, + ])) +} + +# Terminal-tab fallback for first login/troubleshooting. The primary intended +# usage is connecting from claude.ai/code or the mobile app to the named tmux +# session started by the caller's startup script, not this button. +resource "coder_app" "claude_code" { + count = local.enabled ? 1 : 0 + agent_id = var.agent_id + slug = "claude-code" + display_name = "Claude Code" + command = "~/.local/bin/claude-ensure 2>/dev/null; tmux attach -t \"$CODER_WORKSPACE_NAME:0\"" + order = var.app_order +} diff --git a/modules/claude-remote-control/outputs.tf b/modules/claude-remote-control/outputs.tf new file mode 100644 index 0000000..474aaf8 --- /dev/null +++ b/modules/claude-remote-control/outputs.tf @@ -0,0 +1,125 @@ +output "enabled" { + description = "Whether Claude Code remote control is enabled" + value = local.enabled +} + +output "app_count" { + description = "Number of coder_app.claude_code instances created (0 or 1) - exposed for tests" + value = length(coder_app.claude_code) +} + +output "startup_script" { + description = "Bash snippet for the caller to append to its coder_agent startup_script, after other ~/.bashrc customizations." + value = <<-EOT + + # Claude Code remote control: this launches `claude --remote-control` in a named + # tmux session so it's connectable from claude.ai/code or the mobile app. + CLAUDE_CODE_ENABLED="${local.enabled}" + if [ "$CLAUDE_CODE_ENABLED" = "true" ]; then + sed -i '/^export CLAUDE_REMOTE_CMD=/d;/^export CLAUDE_EXTRA_FLAGS=/d' ~/.bashrc || true + echo 'export CLAUDE_REMOTE_CMD="${local.remote_cmd}"' >> ~/.bashrc + echo 'export CLAUDE_EXTRA_FLAGS="${local.extra_flags}"' >> ~/.bashrc + export CLAUDE_REMOTE_CMD="${local.remote_cmd}" + export CLAUDE_EXTRA_FLAGS="${local.extra_flags}" + + # Target window 0 explicitly, not just the session: an untargeted + # `-t "$CODER_WORKSPACE_NAME"` resolves to the session's current + # *active* window, which silently becomes some other window if one + # was ever created (e.g. by the user running tmux themselves) and + # left focused - respawning the wrong pane instead of the intended + # Claude Code session. + # + # The remote-control name includes the current directory's basename + # so each retarget shows up as a visibly different session in + # claude.ai/code - reusing the exact same name every time makes it + # impossible to tell the new (live) session apart from the old + # (now-dead, since a respawn never resumes the prior one) ones in + # that list. + mkdir -p ~/.local/bin + cat > ~/.local/bin/claude-here <<-'CLAUDEHERE' + #!/usr/bin/env bash + set -euo pipefail + TARGET_DIR="$(pwd)" + DIR_NAME=$(basename "$TARGET_DIR") + + case "$TARGET_DIR" in + "$HOME"/*) + # $HOME itself is already a trusted workspace boundary (accepted once + # at first login) - claude's trust store is keyed per exact path, not + # inherited by subdirectories, so without this every new project + # directory under $HOME would re-prompt for a dialog nobody's there + # to answer (this just launches a background tmux pane) - remote + # control won't come online until someone manually accepts it. + if command -v jq >/dev/null 2>&1 && [ -f "$HOME/.claude.json" ]; then + CLAUDE_JSON_TMP=$(mktemp) + if jq --arg dir "$TARGET_DIR" '.projects[$dir].hasTrustDialogAccepted = true' "$HOME/.claude.json" > "$CLAUDE_JSON_TMP" 2>/dev/null; then + mv "$CLAUDE_JSON_TMP" "$HOME/.claude.json" + else + rm -f "$CLAUDE_JSON_TMP" + fi + fi + ;; + *) + echo "Note: $TARGET_DIR is outside \$HOME, so it isn't auto-trusted." + echo "Go accept the workspace-trust prompt in the Claude Code session before it starts serving." + ;; + esac + + tmux respawn-pane -k -t "$CODER_WORKSPACE_NAME:0" -c "$TARGET_DIR" "${local.claude_bin} --remote-control $CODER_WORKSPACE_NAME-$DIR_NAME $CLAUDE_EXTRA_FLAGS" +CLAUDEHERE + # ^ must stay at column 0: bash's <<- only strips leading TABS from the + # terminator line, not spaces, and this file is space-indented - any + # indentation here makes the heredoc unterminated (silently swallows the + # rest of the script as its body, a syntax error only bash notices). + chmod +x ~/.local/bin/claude-here + + # claude-ensure: makes sure window 0 of the named tmux session is + # actually running claude, (re)launching it if not. Needed because + # tmux has-session alone isn't a sufficient guard: the session could + # already exist but be a bare shell instead of claude - e.g. the + # "Claude Code" app button raced ahead of this script and created it + # first, or the claude process itself already exited (it will, if + # nobody completes login/connects within its idle window) leaving the + # tmux server dead entirely. Shared by both this startup script and + # the app button's command so either one can revive a dead/bare + # session, not just create it once at boot. + cat > ~/.local/bin/claude-ensure <<-'CLAUDEENSURE' + #!/usr/bin/env bash + set -euo pipefail + if tmux has-session -t "$CODER_WORKSPACE_NAME" 2>/dev/null; then + # remain-on-exit keeps the pane (and so the session/server) around even + # after claude exits - e.g. if nobody logs in before its idle timeout. + # Without it, an unattended claude exiting takes the whole tmux server + # down with it, with no session left for claude-ensure to even detect. + tmux set-option -t "$CODER_WORKSPACE_NAME" remain-on-exit on 2>/dev/null || true + read -r PANE_DEAD CURRENT_CMD < <(tmux list-panes -t "$CODER_WORKSPACE_NAME:0" -F '#{pane_dead} #{pane_current_command}' 2>/dev/null | head -1) + if [ "$PANE_DEAD" = "1" ] || [ "$CURRENT_CMD" != "claude" ]; then + tmux respawn-pane -k -t "$CODER_WORKSPACE_NAME:0" -c "$HOME" "$CLAUDE_REMOTE_CMD" + fi + else + tmux new-session -d -s "$CODER_WORKSPACE_NAME" -c "$HOME" "$CLAUDE_REMOTE_CMD" + tmux set-option -t "$CODER_WORKSPACE_NAME" remain-on-exit on + fi +CLAUDEENSURE + # ^ column 0 for the same reason as CLAUDEHERE above. + chmod +x ~/.local/bin/claude-ensure + + ~/.local/bin/claude-ensure + + # Printed to the live build log so it's seen during the build, not + # just buried in a one-line status - and appended to WELCOME.txt so + # it's seen again on every later login, since the build log usually + # isn't scrolled back to. + CLAUDE_CODE_BANNER=" +✓ Claude Code remote control session ready: $CODER_WORKSPACE_NAME + + → First time? Open the \"Claude Code\" app button (or SSH in) to finish + logging in and accept the workspace-trust prompt - the session won't + show up on claude.ai/code or the mobile app until you do. + → Working in a project directory? cd there and run: claude-here +" + echo "$CLAUDE_CODE_BANNER" + echo "$CLAUDE_CODE_BANNER" >> ~/WELCOME.txt + fi + EOT +} diff --git a/modules/claude-remote-control/variables.tf b/modules/claude-remote-control/variables.tf new file mode 100644 index 0000000..ffc56ad --- /dev/null +++ b/modules/claude-remote-control/variables.tf @@ -0,0 +1,16 @@ +variable "agent_id" { + description = "ID of the coder_agent to attach the Claude Code fallback terminal app to" + type = string +} + +variable "parameter_order" { + description = "Base UI order for the two coder_parameters this module declares (uses this value and this value + 1)" + type = number + default = 10 +} + +variable "app_order" { + description = "UI order for the fallback terminal coder_app" + type = number + default = 12 +} diff --git a/modules/claude-remote-control/versions.tf b/modules/claude-remote-control/versions.tf new file mode 100644 index 0000000..394c7cb --- /dev/null +++ b/modules/claude-remote-control/versions.tf @@ -0,0 +1,7 @@ +terraform { + required_providers { + coder = { + source = "coder/coder" + } + } +}