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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ make test-templates

## Shared Terraform Assets

Some Terraform config (currently `modules/claude-remote-control` and `shared/vscode-extensions.tf`) is shared across all three templates but must exist as a **physical copy inside each template directory** — `coder templates push` and CI's `terraform validate`/`test` only ever operate on a single template directory, so a relative `../modules` or `../shared` reference would not survive the push and would not be visible to CI.
Some Terraform config (currently `modules/claude-remote-control`, `modules/docker-daemon`, `shared/vscode-extensions.tf`, `shared/bashrc-symlink-guard.sh` and `shared/ssh-agent-setup.sh`) is shared across all three templates but must exist as a **physical copy inside each template directory** — `coder templates push` and CI's `terraform validate`/`test` only ever operate on a single template directory, so a relative `../modules` or `../shared` reference would not survive the push and would not be visible to CI.

- **Edit only the canonical source**: `modules/claude-remote-control/` or `shared/vscode-extensions.tf` at the repo root. Never hand-edit the vendored copies (`<template>/modules/claude-remote-control/`, `<template>/vscode-extensions.tf`) — they get overwritten the next time anyone runs the sync.
- **Always resync through `make`**, never by hand-copying: `make sync-shared` (or `make sync-claude-module` / `make sync-vscode-extensions` individually). `make validate`, `make test-templates`, and every `make push-template-*` / `make push-all-templates` target already depend on `sync-shared`, so day-to-day you rarely need to invoke it directly.
Expand Down
9 changes: 8 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -138,8 +138,15 @@ sync-bashrc-guard: ## Vendor shared/bashrc-symlink-guard.sh into each template d
done
@echo "Synced shared/bashrc-symlink-guard.sh into: $(TEMPLATES)"

.PHONY: sync-ssh-agent-setup
sync-ssh-agent-setup: ## Vendor shared/ssh-agent-setup.sh into each template dir (same reason as sync-vscode-extensions: push only bundles the given --directory)
@for t in $(TEMPLATES); do \
cp shared/ssh-agent-setup.sh $$t/ssh-agent-setup.sh; \
done
@echo "Synced shared/ssh-agent-setup.sh into: $(TEMPLATES)"

.PHONY: sync-shared
sync-shared: sync-claude-module sync-vscode-extensions sync-docker-daemon-module sync-bashrc-guard ## Vendor all shared Terraform assets (modules + vscode-extensions.tf) into each template dir
sync-shared: sync-claude-module sync-vscode-extensions sync-docker-daemon-module sync-bashrc-guard sync-ssh-agent-setup ## Vendor all shared Terraform assets (modules + vscode-extensions.tf) into each template dir

.PHONY: validate
validate: sync-shared ## Validate all Terraform templates (requires terraform in PATH)
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
v0.5
v0.6
22 changes: 22 additions & 0 deletions docs/user/using-workspaces.md
Original file line number Diff line number Diff line change
Expand Up @@ -541,6 +541,28 @@ ssh -T git@gitlab.com

**How it works:** Coder's GitSSH wrapper uses your Coder-managed SSH key automatically. No need to manage keys inside the workspace.

### SSH Keys in DDEV Containers

DDEV's containers can use the same keys, for example for `composer install` with private repositories, `drush` aliases, or `git` in a post-start hook. There's nothing to set up, and `ddev auth ssh` isn't needed:

```bash
ddev auth ssh # lists the keys the containers can use
ddev exec ssh -T git@github.com # tests them
```

The workspace runs an SSH agent relay at `~/.ssh-agent-relay/agent.sock`, and DDEV's `ssh_agent_upstream` points at it. Each time something uses the relay, it connects to the first of these that is available:

1. **Your own agent, if it's forwarded.** VS Code Desktop forwards your local agent (for example 1Password), and so does `coder ssh -A`. Each new terminal or `coder ssh -A` session makes its agent the one the relay uses.
2. **Your Coder-managed key**, loaded into an agent when the workspace starts.

The relay picks again on every use, so after VS Code reconnects, DDEV containers that are already running keep working, and they fall back to the Coder key when you disconnect. Terminals use the relay too, so plain `ssh` in the workspace works the same way.

Notes:

- The most recent terminal or session with a forwarded agent wins for the whole workspace. If you [share a workspace](#sharing-your-workspace) and a collaborator connects with `coder ssh -A`, your DDEV containers use their keys until you open a new terminal.
- While an agent is forwarded, any container on the DDEV network can ask it to sign. Agents that confirm each use, like 1Password, limit that exposure.
- To use only key files instead, run `ddev config global --ssh-agent-upstream=""` and `ddev auth ssh`. The workspace sets the relay again on its next start.

### Common Workflows

**Clone and start working:**
Expand Down
49 changes: 49 additions & 0 deletions drupal-contrib/ssh-agent-setup.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# SSH agent for the workspace and DDEV's containers (#210, #220). Runs after
# Docker is up, because `ddev config global` needs it.
#
# - ~/.coder-ssh-agent/agent.sock: an ssh-agent holding the Coder workspace
# key, so plain ssh and DDEV's containers have a key even with nothing
# forwarded (git alone already gets it from GIT_SSH_COMMAND).
# - ~/.ssh-agent-relay/agent.sock: one stable socket that relays each
# connection to the best live agent (see /home/coder-files/ssh-agent-relay/
# connect): the user's forwarded agent from VS Code or `coder ssh -A` when
# there is one, otherwise the Coder-key agent. DDEV's ssh_agent_upstream
# points here, never at the `current` symlink, since DDEV resolves symlinks
# once when the project starts.
#
# Each socket gets its own directory because DDEV bind-mounts the socket's
# whole directory into ddev-ssh-agent.
SSH_AGENT_DIR="$HOME/.coder-ssh-agent"
SSH_RELAY_DIR="$HOME/.ssh-agent-relay"
SSH_RELAY_FILES=/home/coder-files/ssh-agent-relay
mkdir -p "$SSH_AGENT_DIR" "$SSH_RELAY_DIR"
chmod 700 "$SSH_AGENT_DIR" "$SSH_RELAY_DIR"

# $HOME persists across restarts and ssh-agent -a refuses an existing socket.
pkill -u "$(id -u)" -f "ssh-agent -a $SSH_AGENT_DIR/agent.sock" 2> /dev/null || true
rm -f "$SSH_AGENT_DIR/agent.sock"
if setsid ssh-agent -a "$SSH_AGENT_DIR/agent.sock" > /dev/null 2>&1 < /dev/null; then
# The key goes straight from the API into the agent, never to disk.
if curl -fsS --max-time 10 -H "Coder-Session-Token: ${CODER_AGENT_TOKEN:-}" \
"${CODER_AGENT_URL%/}/api/v2/workspaceagents/me/gitsshkey" \
| jq -r .private_key | SSH_AUTH_SOCK="$SSH_AGENT_DIR/agent.sock" ssh-add -q - 2> /dev/null; then
echo "✓ Coder SSH key loaded into $SSH_AGENT_DIR/agent.sock"
else
echo "⚠ Could not load the Coder SSH key into $SSH_AGENT_DIR/agent.sock"
fi
else
echo "⚠ Could not start ssh-agent at $SSH_AGENT_DIR/agent.sock"
fi

if command -v socat > /dev/null 2>&1 && [ -x "$SSH_RELAY_FILES/connect" ]; then
pkill -u "$(id -u)" -f "UNIX-LISTEN:$SSH_RELAY_DIR/agent.sock" 2> /dev/null || true
nohup setsid socat UNIX-LISTEN:"$SSH_RELAY_DIR/agent.sock",fork,unlink-early,mode=600 \
EXEC:"$SSH_RELAY_FILES/connect" > /dev/null 2>&1 < /dev/null &
ddev config global --ssh-agent-upstream="$SSH_RELAY_DIR/agent.sock" > /dev/null 2>&1 || true
if ! grep -qF "$SSH_RELAY_FILES/bashrc.sh" ~/.bashrc 2> /dev/null; then
echo "[ -r $SSH_RELAY_FILES/bashrc.sh ] && . $SSH_RELAY_FILES/bashrc.sh" >> ~/.bashrc
fi
echo "✓ SSH agent relay at $SSH_RELAY_DIR/agent.sock (ddev ssh_agent_upstream)"
else
echo "⚠ socat or $SSH_RELAY_FILES/connect missing; SSH agent relay not started"
fi
20 changes: 2 additions & 18 deletions drupal-contrib/template.tf
Original file line number Diff line number Diff line change
Expand Up @@ -335,13 +335,6 @@ resource "coder_agent" "main" {
grep -qxF 'config.coder.yaml' "$HOME/.gitignore_global" || \
echo 'config.coder.yaml' >> "$HOME/.gitignore_global"
fi
# PHP2Shell workaround (written into the project's .ddev/web-build below).
# Outside the if/elif so a fresh copy from an older image also gets it.
# Remove together with the Dockerfile.php2shell step after DDEV v1.25.5.
if [ -f "$HOME/.gitignore_global" ]; then
grep -qxF 'Dockerfile.php2shell' "$HOME/.gitignore_global" || \
echo 'Dockerfile.php2shell' >> "$HOME/.gitignore_global"
fi
if [ -n "$CODER_WORKSPACE_OWNER_NAME" ]; then
git config --global user.name "$CODER_WORKSPACE_OWNER_NAME"
fi
Expand Down Expand Up @@ -374,6 +367,8 @@ resource "coder_agent" "main" {
mkdir -p ~/.ddev
echo "Configuring DDEV to omit ddev-router..."
ddev config global --omit-containers=ddev-router --instrumentation-opt-in=false > /dev/null 2>&1 || true

${file("${path.module}/ssh-agent-setup.sh")}
mkcert -install 2>/dev/null || true

# Pre-pull DDEV images (uses registry mirror if configured). Redirect --
Expand Down Expand Up @@ -629,17 +624,6 @@ hooks:
- exec-host: 'echo "" && echo " Site: https://drupal-site--$${CODER_WORKSPACE_NAME}--$${CODER_WORKSPACE_OWNER_NAME}.$CODER_DOMAIN" && echo " Mailpit: https://mailpit--$${CODER_WORKSPACE_NAME}--$${CODER_WORKSPACE_OWNER_NAME}.$CODER_DOMAIN" && echo ""'
CODER_YAML_EOF

# PHP2Shell (CVE-2026-109618/CVE-2026-109619): upgrade PHP in the web image
# until DDEV v1.25.5 ships a fixed ddev-webserver. Remove after that upgrade.
# https://ddev.com/blog/php2shell-what-ddev-users-need-to-know/
mkdir -p .ddev/web-build
cat > .ddev/web-build/Dockerfile.php2shell << 'PHP2SHELL_EOF'
# Auto-generated by coder-ddev workspace startup -- do not edit.
# PHP2Shell fix; safe to delete after upgrading to DDEV v1.25.5.
RUN (apt-get update || true) && apt-get install -y --only-upgrade 'php*'
PHP2SHELL_EOF
log_setup "✓ .ddev/web-build/Dockerfile.php2shell written (PHP2Shell fix)"

# For themes, set symlink target path via config.local.yaml
if [ "$PROJECT_TYPE" = "theme" ]; then
cat > .ddev/config.local.yaml << 'THEME_YAML_EOF'
Expand Down
12 changes: 12 additions & 0 deletions drupal-contrib/tests/validate.tftest.hcl
Original file line number Diff line number Diff line change
Expand Up @@ -174,3 +174,15 @@ run "user_startup_hook" {
error_message = "startup script must run the optional ~/.coder-startup.sh user hook, detached"
}
}

run "ssh_agent_relay" {
command = plan
assert {
condition = strcontains(coder_agent.main.startup_script, "api/v2/workspaceagents/me/gitsshkey")
error_message = "startup script must load the Coder SSH key into an ssh-agent"
}
assert {
condition = strcontains(coder_agent.main.startup_script, "--ssh-agent-upstream=\"$SSH_RELAY_DIR/agent.sock\"")
error_message = "startup script must point DDEV's ssh_agent_upstream at the relay socket"
}
}
49 changes: 49 additions & 0 deletions drupal-core/ssh-agent-setup.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# SSH agent for the workspace and DDEV's containers (#210, #220). Runs after
# Docker is up, because `ddev config global` needs it.
#
# - ~/.coder-ssh-agent/agent.sock: an ssh-agent holding the Coder workspace
# key, so plain ssh and DDEV's containers have a key even with nothing
# forwarded (git alone already gets it from GIT_SSH_COMMAND).
# - ~/.ssh-agent-relay/agent.sock: one stable socket that relays each
# connection to the best live agent (see /home/coder-files/ssh-agent-relay/
# connect): the user's forwarded agent from VS Code or `coder ssh -A` when
# there is one, otherwise the Coder-key agent. DDEV's ssh_agent_upstream
# points here, never at the `current` symlink, since DDEV resolves symlinks
# once when the project starts.
#
# Each socket gets its own directory because DDEV bind-mounts the socket's
# whole directory into ddev-ssh-agent.
SSH_AGENT_DIR="$HOME/.coder-ssh-agent"
SSH_RELAY_DIR="$HOME/.ssh-agent-relay"
SSH_RELAY_FILES=/home/coder-files/ssh-agent-relay
mkdir -p "$SSH_AGENT_DIR" "$SSH_RELAY_DIR"
chmod 700 "$SSH_AGENT_DIR" "$SSH_RELAY_DIR"

# $HOME persists across restarts and ssh-agent -a refuses an existing socket.
pkill -u "$(id -u)" -f "ssh-agent -a $SSH_AGENT_DIR/agent.sock" 2> /dev/null || true
rm -f "$SSH_AGENT_DIR/agent.sock"
if setsid ssh-agent -a "$SSH_AGENT_DIR/agent.sock" > /dev/null 2>&1 < /dev/null; then
# The key goes straight from the API into the agent, never to disk.
if curl -fsS --max-time 10 -H "Coder-Session-Token: ${CODER_AGENT_TOKEN:-}" \
"${CODER_AGENT_URL%/}/api/v2/workspaceagents/me/gitsshkey" \
| jq -r .private_key | SSH_AUTH_SOCK="$SSH_AGENT_DIR/agent.sock" ssh-add -q - 2> /dev/null; then
echo "✓ Coder SSH key loaded into $SSH_AGENT_DIR/agent.sock"
else
echo "⚠ Could not load the Coder SSH key into $SSH_AGENT_DIR/agent.sock"
fi
else
echo "⚠ Could not start ssh-agent at $SSH_AGENT_DIR/agent.sock"
fi

if command -v socat > /dev/null 2>&1 && [ -x "$SSH_RELAY_FILES/connect" ]; then
pkill -u "$(id -u)" -f "UNIX-LISTEN:$SSH_RELAY_DIR/agent.sock" 2> /dev/null || true
nohup setsid socat UNIX-LISTEN:"$SSH_RELAY_DIR/agent.sock",fork,unlink-early,mode=600 \
EXEC:"$SSH_RELAY_FILES/connect" > /dev/null 2>&1 < /dev/null &
ddev config global --ssh-agent-upstream="$SSH_RELAY_DIR/agent.sock" > /dev/null 2>&1 || true
if ! grep -qF "$SSH_RELAY_FILES/bashrc.sh" ~/.bashrc 2> /dev/null; then
echo "[ -r $SSH_RELAY_FILES/bashrc.sh ] && . $SSH_RELAY_FILES/bashrc.sh" >> ~/.bashrc
fi
echo "✓ SSH agent relay at $SSH_RELAY_DIR/agent.sock (ddev ssh_agent_upstream)"
else
echo "⚠ socat or $SSH_RELAY_FILES/connect missing; SSH agent relay not started"
fi
20 changes: 2 additions & 18 deletions drupal-core/template.tf
Original file line number Diff line number Diff line change
Expand Up @@ -396,13 +396,6 @@ resource "coder_agent" "main" {
grep -qxF 'config.zzz-coder-test-env.yaml' "$HOME/.gitignore_global" || \
echo 'config.zzz-coder-test-env.yaml' >> "$HOME/.gitignore_global"
fi
# PHP2Shell workaround (written into the project's .ddev/web-build below).
# Outside the if/elif so a fresh copy from an older image also gets it.
# Remove together with the Dockerfile.php2shell step after DDEV v1.25.5.
if [ -f "$HOME/.gitignore_global" ]; then
grep -qxF 'Dockerfile.php2shell' "$HOME/.gitignore_global" || \
echo 'Dockerfile.php2shell' >> "$HOME/.gitignore_global"
fi
if [ -n "$CODER_WORKSPACE_OWNER_NAME" ]; then
git config --global user.name "$CODER_WORKSPACE_OWNER_NAME"
fi
Expand Down Expand Up @@ -456,6 +449,8 @@ resource "coder_agent" "main" {
echo "Configuring DDEV to omit ddev-router..."
ddev config global --omit-containers=ddev-router --instrumentation-opt-in=false > /dev/null 2>&1 || true

${file("${path.module}/ssh-agent-setup.sh")}

# Install mkcert CA to suppress DDEV's "mkcert may not be properly installed" warning
# DDEV ships its own mkcert binary; this sets up the local CA trust
mkcert -install 2>/dev/null || true
Expand Down Expand Up @@ -718,17 +713,6 @@ hooks:
CODER_YAML_EOF
log_setup "✓ .ddev/config.coder.yaml written"

# PHP2Shell (CVE-2026-109618/CVE-2026-109619): upgrade PHP in the web image
# until DDEV v1.25.5 ships a fixed ddev-webserver. Remove after that upgrade.
# https://ddev.com/blog/php2shell-what-ddev-users-need-to-know/
mkdir -p .ddev/web-build
cat > .ddev/web-build/Dockerfile.php2shell << 'PHP2SHELL_EOF'
# Auto-generated by coder-ddev workspace startup -- do not edit.
# PHP2Shell fix; safe to delete after upgrading to DDEV v1.25.5.
RUN (apt-get update || true) && apt-get install -y --only-upgrade 'php*'
PHP2SHELL_EOF
log_setup "✓ .ddev/web-build/Dockerfile.php2shell written (PHP2Shell fix)"

if ddev config --project-type="$DDEV_PROJECT_TYPE" $DDEV_DOCROOT_ARG --project-name="$${CODER_WORKSPACE_NAME}--$${CODER_WORKSPACE_OWNER_NAME}" >> "$SETUP_LOG" 2>&1; then
log_setup "✓ DDEV configured (project-type=$DDEV_PROJECT_TYPE)"
update_status "✓ DDEV config: Success"
Expand Down
15 changes: 15 additions & 0 deletions drupal-core/tests/validate.tftest.hcl
Original file line number Diff line number Diff line change
Expand Up @@ -199,3 +199,18 @@ run "user_startup_hook" {
error_message = "startup script must run the optional ~/.coder-startup.sh user hook, detached"
}
}

run "ssh_agent_relay" {
command = plan
variables {
cache_path = "/tmp/mock-cache"
}
assert {
condition = strcontains(coder_agent.main.startup_script, "api/v2/workspaceagents/me/gitsshkey")
error_message = "startup script must load the Coder SSH key into an ssh-agent"
}
assert {
condition = strcontains(coder_agent.main.startup_script, "--ssh-agent-upstream=\"$SSH_RELAY_DIR/agent.sock\"")
error_message = "startup script must point DDEV's ssh_agent_upstream at the relay socket"
}
}
49 changes: 49 additions & 0 deletions freeform/ssh-agent-setup.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# SSH agent for the workspace and DDEV's containers (#210, #220). Runs after
# Docker is up, because `ddev config global` needs it.
#
# - ~/.coder-ssh-agent/agent.sock: an ssh-agent holding the Coder workspace
# key, so plain ssh and DDEV's containers have a key even with nothing
# forwarded (git alone already gets it from GIT_SSH_COMMAND).
# - ~/.ssh-agent-relay/agent.sock: one stable socket that relays each
# connection to the best live agent (see /home/coder-files/ssh-agent-relay/
# connect): the user's forwarded agent from VS Code or `coder ssh -A` when
# there is one, otherwise the Coder-key agent. DDEV's ssh_agent_upstream
# points here, never at the `current` symlink, since DDEV resolves symlinks
# once when the project starts.
#
# Each socket gets its own directory because DDEV bind-mounts the socket's
# whole directory into ddev-ssh-agent.
SSH_AGENT_DIR="$HOME/.coder-ssh-agent"
SSH_RELAY_DIR="$HOME/.ssh-agent-relay"
SSH_RELAY_FILES=/home/coder-files/ssh-agent-relay
mkdir -p "$SSH_AGENT_DIR" "$SSH_RELAY_DIR"
chmod 700 "$SSH_AGENT_DIR" "$SSH_RELAY_DIR"

# $HOME persists across restarts and ssh-agent -a refuses an existing socket.
pkill -u "$(id -u)" -f "ssh-agent -a $SSH_AGENT_DIR/agent.sock" 2> /dev/null || true
rm -f "$SSH_AGENT_DIR/agent.sock"
if setsid ssh-agent -a "$SSH_AGENT_DIR/agent.sock" > /dev/null 2>&1 < /dev/null; then
# The key goes straight from the API into the agent, never to disk.
if curl -fsS --max-time 10 -H "Coder-Session-Token: ${CODER_AGENT_TOKEN:-}" \
"${CODER_AGENT_URL%/}/api/v2/workspaceagents/me/gitsshkey" \
| jq -r .private_key | SSH_AUTH_SOCK="$SSH_AGENT_DIR/agent.sock" ssh-add -q - 2> /dev/null; then
echo "✓ Coder SSH key loaded into $SSH_AGENT_DIR/agent.sock"
else
echo "⚠ Could not load the Coder SSH key into $SSH_AGENT_DIR/agent.sock"
fi
else
echo "⚠ Could not start ssh-agent at $SSH_AGENT_DIR/agent.sock"
fi

if command -v socat > /dev/null 2>&1 && [ -x "$SSH_RELAY_FILES/connect" ]; then
pkill -u "$(id -u)" -f "UNIX-LISTEN:$SSH_RELAY_DIR/agent.sock" 2> /dev/null || true
nohup setsid socat UNIX-LISTEN:"$SSH_RELAY_DIR/agent.sock",fork,unlink-early,mode=600 \
EXEC:"$SSH_RELAY_FILES/connect" > /dev/null 2>&1 < /dev/null &
ddev config global --ssh-agent-upstream="$SSH_RELAY_DIR/agent.sock" > /dev/null 2>&1 || true
if ! grep -qF "$SSH_RELAY_FILES/bashrc.sh" ~/.bashrc 2> /dev/null; then
echo "[ -r $SSH_RELAY_FILES/bashrc.sh ] && . $SSH_RELAY_FILES/bashrc.sh" >> ~/.bashrc
fi
echo "✓ SSH agent relay at $SSH_RELAY_DIR/agent.sock (ddev ssh_agent_upstream)"
else
echo "⚠ socat or $SSH_RELAY_FILES/connect missing; SSH agent relay not started"
fi
Loading
Loading