Skip to content
Closed
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
7 changes: 4 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,12 +233,13 @@ The startup script is inline in each template's `template.tf` (e.g. `freeform/te
### Volume Strategy
- **Home directory**: Host path `/coder-workspaces/<owner>-<workspace>` → Container `/home/coder`
- **Docker cache**: Named volume `coder-<owner>-<workspace>-dind-cache` → `/var/lib/docker`
- **Isolation**: Each workspace gets separate host directory and Docker volume
- **Homebrew**: Named volume `coder-<owner>-<workspace>-linuxbrew` → `/home/linuxbrew` — persists the Homebrew Cellar across workspace stop/start, so `brew upgrade`/`brew install` done inside a workspace survives restarts. Unlike the `/home/coder` bind mount, a fresh named volume is auto-populated by Docker from the image's existing `/home/linuxbrew` contents on first mount, so no manual copy step is needed — only an ownership fix (`sudo chown -R coder:coder /home/linuxbrew`) in the startup script. `HOMEBREW_CACHE` is set to `/home/linuxbrew/.cache/Homebrew` (via the Dockerfile) so the cache and Cellar always stay on the same filesystem, avoiding cross-device link (`EXDEV`) errors.
- **Isolation**: Each workspace gets separate host directory and Docker volumes

### Terraform Variables
Key template variables (e.g. in `freeform/template.tf`):
- `workspace_image_registry` - Docker registry URL (default: `index.docker.io/ddev/coder-ddev`)
- `image_version` - Image tag (default: read from `VERSION` file or `v0.1`)
- `image_version` - Image tag (default: `v0.1`; the Makefile passes `--variable image_version=$(cat VERSION)` at push time, so the root `VERSION` file is the single source of truth — no per-template copies)
- `cpu` / `memory` - Resource limits (defaults: 4 cores, 8GB RAM)
- `node_version` - Node.js version (default: `24`, informational only — Node is pre-installed in image)
- `docker_gid` - Docker group GID (default: `988`)
Expand Down Expand Up @@ -352,6 +353,6 @@ Additional logs in workspace:
- `scripts/coder-delete-workspace-dir.sh` - Sudo wrapper for workspace host dir cleanup (must be installed on server)
- `scripts/cleanup-deleted-workspaces.sh` - Manual cleanup for orphaned workspace dirs/volumes
- `scripts/workspace-lifecycle-cleanup.sh` - Notifies then deletes idle workspaces; runs via systemd timer on coder.ddev.com (production only, see `scripts/workspace-lifecycle-cleanup.service`/`.timer`, must be installed on server)
- `VERSION` - Image version used by all templates (read automatically by Makefile)
- `VERSION` - Single source of truth for the image version; read by the Makefile and passed to every template as `--variable image_version=...` at push time (no per-template copies)
- `openspec/project.md` - Project conventions and constraints
- `openspec/AGENTS.md` - OpenSpec workflow instructions
6 changes: 2 additions & 4 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,8 @@ TEMPLATE_EDIT_freeform := --display-name "DDEV Freeform" \

# Shared recipe for pushing any template (call with template name as argument)
define push_template
@echo "Syncing VERSION to $(1)..."
cp VERSION $(1)/VERSION
@echo "Pushing Coder template $(1)..."
coder templates push --directory $(1) $(1) --yes --activate=$(ACTIVATE) $(TEMPLATE_VARS_$(1))
@echo "Pushing Coder template $(1) (image_version=$(VERSION))..."
coder templates push --directory $(1) $(1) --yes --activate=$(ACTIVATE) --variable image_version=$(VERSION) $(TEMPLATE_VARS_$(1))
@echo "Setting template metadata for $(1)..."
coder templates edit $(1) --yes $(TEMPLATE_EDIT_$(1))
@echo "Template $(1) push complete"
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
v0.4
v0.5
5 changes: 5 additions & 0 deletions drupal-contrib/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Vault token file (not committed to Git)
vault-token.txt

# Terraform working directory
.terraform/
1 change: 0 additions & 1 deletion drupal-contrib/VERSION

This file was deleted.

18 changes: 17 additions & 1 deletion drupal-contrib/template.tf
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,7 @@ locals {
}

locals {
image_version = try(trimspace(file("${path.module}/VERSION")), var.image_version)
image_version = var.image_version
registry_without_version = replace(var.workspace_image_registry, ":${local.image_version}", "")
workspace_image_registry_base = replace(local.registry_without_version, ":latest", "")
}
Expand Down Expand Up @@ -313,6 +313,7 @@ resource "coder_agent" "main" {
fi

sudo chown coder:coder /home/coder
sudo chown -R coder:coder /home/linuxbrew

if [ ! -f "/home/coder/.bashrc" ]; then
echo "Initializing home directory..."
Expand Down Expand Up @@ -1073,6 +1074,15 @@ resource "docker_volume" "coder_dind_cache" {
name = "coder-${data.coder_workspace_owner.me.name}-${lower(data.coder_workspace.me.name)}-dind-cache"
}

# Persists /home/linuxbrew (Homebrew Cellar) across workspace stop/start so
# `brew upgrade`/`brew install` survives restarts. Not gated by start_count,
# matching coder_dind_cache above. Docker auto-populates a newly-created
# named volume from the image's existing directory contents on first mount,
# so no manual copy-from-image seed step is needed here.
resource "docker_volume" "coder_linuxbrew" {
name = "coder-${data.coder_workspace_owner.me.name}-${lower(data.coder_workspace.me.name)}-linuxbrew"
}

module "vscode-web" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/vscode-web/coder"
Expand Down Expand Up @@ -1188,6 +1198,12 @@ resource "docker_container" "workspace" {
target = "/var/lib/docker"
}

mounts {
type = "volume"
source = docker_volume.coder_linuxbrew.name
target = "/home/linuxbrew"
}

env = [
"CODER_AGENT_TOKEN=${coder_agent.main.token}",
"CODER_WORKSPACE_NAME=${data.coder_workspace.me.name}",
Expand Down
8 changes: 8 additions & 0 deletions drupal-contrib/tests/validate.tftest.hcl
Original file line number Diff line number Diff line change
Expand Up @@ -78,3 +78,11 @@ run "memory_above_maximum" {
}
expect_failures = [var.memory]
}

run "linuxbrew_volume_created" {
command = plan
assert {
condition = docker_volume.coder_linuxbrew.name == "coder-testuser-test-workspace-linuxbrew"
error_message = "docker_volume.coder_linuxbrew must be named per the coder-<owner>-<workspace>-linuxbrew convention"
}
}
3 changes: 0 additions & 3 deletions drupal-core/.gitignore
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
# Vault token file (not committed to Git)
vault-token.txt

# VERSION is copied from root by Makefile before push
VERSION

# Terraform working directory
.terraform/
24 changes: 20 additions & 4 deletions drupal-core/template.tf
Original file line number Diff line number Diff line change
Expand Up @@ -212,8 +212,8 @@ locals {
}

locals {
# Read image version from VERSION file if it exists, otherwise use variable default
image_version = try(trimspace(file("${path.module}/VERSION")), var.image_version)
# Set via --variable image_version=$(cat VERSION) by the Makefile at push time.
image_version = var.image_version

# Remove any tag (including :latest) if present, but preserve port numbers (e.g., :5050)
# Remove common tags from the end of the registry URL
Expand Down Expand Up @@ -252,7 +252,7 @@ variable "vscode_extensions" {
variable "workspace_image_registry" {
description = "Docker registry URL for the workspace base image (without tag, version is added automatically)"
type = string
# The version tag is appended automatically using the image_version variable or VERSION file
# The version tag is appended automatically using the image_version variable.
# DO NOT include :latest or any version tag here - version comes from image_version variable
# To use a specific version, override the image_version variable when deploying
default = "index.docker.io/ddev/coder-ddev"
Expand All @@ -262,7 +262,7 @@ variable "workspace_image_registry" {
# The image is built and pushed using the Makefile (see root Makefile and VERSION file)
# This avoids prevent_destroy issues since the image is not managed by Terraform
resource "docker_image" "workspace_image" {
# Always use version tag (never :latest) from the image_version variable or VERSION file
# Always use version tag (never :latest) from the image_version variable.
# This ensures consistent image versions and prevents using stale images
name = "${local.workspace_image_registry_base}:${local.image_version}"

Expand Down Expand Up @@ -355,6 +355,7 @@ resource "coder_agent" "main" {
# Since the volume comes from the host, it might have host permissions.
# We fix this on every startup.
sudo chown coder:coder /home/coder
sudo chown -R coder:coder /home/linuxbrew

# Copy defaults if empty (first run)
if [ ! -f "/home/coder/.bashrc" ]; then
Expand Down Expand Up @@ -1362,6 +1363,15 @@ resource "docker_volume" "coder_dind_cache" {
name = "coder-${data.coder_workspace_owner.me.name}-${lower(data.coder_workspace.me.name)}-dind-cache"
}

# Persists /home/linuxbrew (Homebrew Cellar) across workspace stop/start so
# `brew upgrade`/`brew install` survives restarts. Not gated by start_count,
# matching coder_dind_cache above. Docker auto-populates a newly-created
# named volume from the image's existing directory contents on first mount,
# so no manual copy-from-image seed step is needed here.
resource "docker_volume" "coder_linuxbrew" {
name = "coder-${data.coder_workspace_owner.me.name}-${lower(data.coder_workspace.me.name)}-linuxbrew"
}

# VS Code for Web
module "vscode-web" {
count = data.coder_workspace.me.start_count
Expand Down Expand Up @@ -1523,6 +1533,12 @@ resource "docker_container" "workspace" {
target = "/var/lib/docker"
}

mounts {
type = "volume"
source = docker_volume.coder_linuxbrew.name
target = "/home/linuxbrew"
}

# Environment variables
env = [
"CODER_AGENT_TOKEN=${coder_agent.main.token}",
Expand Down
11 changes: 11 additions & 0 deletions drupal-core/tests/validate.tftest.hcl
Original file line number Diff line number Diff line change
Expand Up @@ -82,3 +82,14 @@ run "memory_above_maximum" {
}
expect_failures = [var.memory]
}

run "linuxbrew_volume_created" {
command = plan
variables {
cache_path = "/tmp/mock-cache"
}
assert {
condition = docker_volume.coder_linuxbrew.name == "coder-testuser-test-workspace-linuxbrew"
error_message = "docker_volume.coder_linuxbrew must be named per the coder-<owner>-<workspace>-linuxbrew convention"
}
}
3 changes: 0 additions & 3 deletions freeform/.gitignore
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
# Vault token file (not committed to Git)
vault-token.txt

# VERSION is copied from root by Makefile before push
VERSION

# Terraform working directory
.terraform/
18 changes: 18 additions & 0 deletions freeform/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,24 @@ ddev restart

After `ddev restart`, the post-start hook updates `coder-routes.yaml` to include the Adminer router. Click **Adminer** in the Coder dashboard.

### Claude Code (Remote Control)

`Enable Claude Code` and `Claude Code: skip permissions` are workspace parameters (per-workspace, not template-wide) — toggle them in the Coder dashboard when creating or updating a workspace, or from the CLI:

```bash
coder create --template freeform myworkspace --parameter enable_claude_code=true
# or, on an existing workspace:
coder update myworkspace --parameter enable_claude_code=true
```

At startup, Claude Code is upgraded via Homebrew and a tmux session named after the workspace is started running `claude --remote-control myworkspace`. **The intended way to use it is not the terminal** — go to [claude.ai/code](https://claude.ai/code) or open the Claude mobile app, and the session shows up by workspace name with a green "online" dot; connect from there. Requires a Claude Pro/Max/Team/Enterprise subscription (API key auth is not supported for Remote Control) and being logged into `api.anthropic.com` (not Bedrock/Vertex/a custom gateway).

The **Claude Code** app button in the Coder dashboard (and `tmux attach -t myworkspace` manually) is a local fallback — useful for the first interactive login (`/login` inside the session), or for troubleshooting, but not the normal way to interact with the session day-to-day.

The first connection triggers Claude Code's interactive login — no API key is configured by this template. To skip permission prompts entirely, also pass `--parameter claude_code_skip_permissions=true` (off by default; use with caution).

> **Known limitation — shared session state.** `enable_claude_code` starts exactly one tmux session per workspace, named after the workspace, running a single `claude` process. Every device/client that attaches (via the "Claude Code" app button or `tmux attach -t <workspace-name>`) is attaching to the *same* running session — that's what makes multi-device remote control possible in the first place. The tradeoff: there's no per-client context, so `cd`-ing or `git checkout`-ing from inside that shared session changes it for everyone attached. Do routine repo administration (branch switches, directory navigation) from a separate, ordinary terminal (the Coder web terminal or `coder ssh <workspace>`) instead — leave the Claude Code tmux session alone for that.

### Other Add-Ons

```bash
Expand Down
89 changes: 88 additions & 1 deletion freeform/template.tf
Original file line number Diff line number Diff line change
Expand Up @@ -95,10 +95,32 @@ data "coder_parameter" "vscode_extensions" {
}
}

data "coder_parameter" "enable_claude_code" {
name = "enable_claude_code"
display_name = "Enable Claude Code"
description = "Upgrade Claude Code via Homebrew at startup and run it in a tmux session named after the workspace, attachable from the dashboard. Interactive OAuth login is required on first attach (no API key is configured by this template)."
type = "bool"
form_type = "switch"
default = "false"
mutable = true
order = 2
}

data "coder_parameter" "claude_code_skip_permissions" {
name = "claude_code_skip_permissions"
display_name = "Claude Code: skip permissions"
description = "Append --dangerously-skip-permissions to the Claude Code session. No effect unless Enable Claude Code is also on."
type = "bool"
form_type = "switch"
default = "false"
mutable = true
order = 3
}

locals {
workspace_home = "/home/coder"
selected_extensions = jsondecode(data.coder_parameter.vscode_extensions.value)
image_version = try(trimspace(file("${path.module}/VERSION")), var.image_version)
image_version = var.image_version

registry_without_version = replace(var.workspace_image_registry, ":${local.image_version}", "")
workspace_image_registry_base = replace(local.registry_without_version, ":latest", "")
Expand All @@ -111,6 +133,12 @@ locals {
? [for s in split(",", local._project_names_raw) : trimspace(s) if trimspace(s) != ""]
: [data.coder_workspace.me.name]
)

# coder_parameter values are always strings; compare rather than tobool() so an
# unrelated mock default (e.g. "[]" in Terraform tests) safely evaluates false
# instead of erroring.
enable_claude_code = data.coder_parameter.enable_claude_code.value == "true"
claude_code_skip_permissions = data.coder_parameter.claude_code_skip_permissions.value == "true"
}

variable "vscode_extensions" {
Expand Down Expand Up @@ -206,6 +234,7 @@ resource "coder_agent" "main" {
fi

sudo chown coder:coder /home/coder
sudo chown -R coder:coder /home/linuxbrew

if [ ! -f "/home/coder/.bashrc" ]; then
echo "Initializing home directory..."
Expand Down Expand Up @@ -401,6 +430,35 @@ BASHCOMP
echo 'export PATH="$HOME/.npm-global/bin:$PATH"' >> ~/.bashrc
fi

%{if local.enable_claude_code~}
# --- Claude Code remote control (enable_claude_code) ---------------------
echo "Setting up Claude Code remote-control session..."

if command -v brew > /dev/null 2>&1; then
brew update > /dev/null 2>&1 || echo "Warning: brew update failed, continuing with existing formula metadata"
(brew upgrade claude-code || brew install claude-code) || \
echo "Warning: claude-code install/upgrade via brew failed; continuing with existing version (if any)"
else
echo "Warning: brew not found on PATH; skipping claude-code upgrade"
fi

CLAUDE_CMD="claude --remote-control $CODER_WORKSPACE_NAME"
%{if local.claude_code_skip_permissions~}
CLAUDE_CMD="claude --remote-control $CODER_WORKSPACE_NAME --dangerously-skip-permissions"
%{endif~}

if command -v tmux > /dev/null 2>&1 && command -v claude > /dev/null 2>&1; then
if ! tmux has-session -t "$CODER_WORKSPACE_NAME" 2>/dev/null; then
echo "Starting Claude Code tmux session '$CODER_WORKSPACE_NAME'..."
tmux new-session -d -s "$CODER_WORKSPACE_NAME" -c "$HOME" "$CLAUDE_CMD"
else
echo "Claude Code tmux session '$CODER_WORKSPACE_NAME' already running."
fi
else
echo "Warning: tmux or claude not available; skipping Claude Code session startup"
fi
%{endif~}

echo ""
echo "=== Setup Complete ==="
echo ""
Expand Down Expand Up @@ -443,6 +501,15 @@ resource "docker_volume" "coder_dind_cache" {
name = "coder-${data.coder_workspace_owner.me.name}-${lower(data.coder_workspace.me.name)}-dind-cache"
}

# Persists /home/linuxbrew (Homebrew Cellar) across workspace stop/start so
# `brew upgrade`/`brew install` (e.g. for Claude Code) survives restarts. Not
# gated by start_count, matching coder_dind_cache above. Docker auto-populates
# a newly-created named volume from the image's existing directory contents
# on first mount, so no manual copy-from-image seed step is needed here.
resource "docker_volume" "coder_linuxbrew" {
name = "coder-${data.coder_workspace_owner.me.name}-${lower(data.coder_workspace.me.name)}-linuxbrew"
}

module "vscode-web" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/vscode-web/coder"
Expand Down Expand Up @@ -526,6 +593,20 @@ resource "coder_app" "adminer" {
}
}

# Claude Code remote control (enable_claude_code): opens/attaches the
# workspace's single dedicated tmux session (named after the workspace)
# running `claude`. Shared by every client that attaches — see README for
# the cd/git-checkout caveat.
resource "coder_app" "claude_code" {
for_each = local.enable_claude_code ? toset(["claude-code"]) : toset([])
agent_id = coder_agent.main.id
slug = "claude-code"
display_name = "Claude Code"
icon = "/icon/terminal.svg"
command = "tmux attach -t $CODER_WORKSPACE_NAME || tmux new -s $CODER_WORKSPACE_NAME claude --remote-control $CODER_WORKSPACE_NAME${local.claude_code_skip_permissions ? " --dangerously-skip-permissions" : ""}"
share = "owner"
}

resource "coder_script" "ddev_shutdown" {
agent_id = coder_agent.main.id
display_name = "Stop DDEV Projects"
Expand Down Expand Up @@ -577,6 +658,12 @@ resource "docker_container" "workspace" {
target = "/var/lib/docker"
}

mounts {
type = "volume"
source = docker_volume.coder_linuxbrew.name
target = "/home/linuxbrew"
}

env = [
"CODER_AGENT_TOKEN=${coder_agent.main.token}",
"CODER_WORKSPACE_NAME=${data.coder_workspace.me.name}",
Expand Down
Loading
Loading