Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/drupal-contrib-integration-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/drupal-integration-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ jobs:
--parameter issue_branch= \
--parameter install_profile=minimal \
--parameter share_drupal_site=owner \
--use-parameter-defaults \
--yes

- &verify-agent
Expand Down Expand Up @@ -279,6 +280,7 @@ jobs:
--parameter issue_branch= \
--parameter install_profile=minimal \
--parameter share_drupal_site=owner \
--use-parameter-defaults \
--yes

- *verify-agent
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/integration-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
19 changes: 14 additions & 5 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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; \
Expand All @@ -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; \
Expand Down Expand Up @@ -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
Expand Down
61 changes: 61 additions & 0 deletions drupal-contrib/modules/claude-remote-control/main.tf
Original file line number Diff line number Diff line change
@@ -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
}
125 changes: 125 additions & 0 deletions drupal-contrib/modules/claude-remote-control/outputs.tf
Original file line number Diff line number Diff line change
@@ -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
}
16 changes: 16 additions & 0 deletions drupal-contrib/modules/claude-remote-control/variables.tf
Original file line number Diff line number Diff line change
@@ -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
}
7 changes: 7 additions & 0 deletions drupal-contrib/modules/claude-remote-control/versions.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
terraform {
required_providers {
coder = {
source = "coder/coder"
}
}
}
28 changes: 28 additions & 0 deletions drupal-contrib/template.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down Expand Up @@ -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
# ==========================================
Expand Down Expand Up @@ -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"
Expand Down
Loading
Loading