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: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ make test-templates

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.

`modules/claude-self-hosted-runner` follows the same rules but is vendored into `freeform` only, the one template that offers the runner.

- **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.
- **Commit the vendored copies.** They are tracked files, not build artifacts — CI validates each template directory standalone with no `make` step, so the vendored copies must already be correct and committed for CI to pass.
Expand Down
9 changes: 8 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,13 @@ sync-claude-module: ## Vendor modules/claude-remote-control into each template d
done
@echo "Synced modules/claude-remote-control into: $(TEMPLATES)"

.PHONY: sync-claude-runner-module
sync-claude-runner-module: ## Vendor modules/claude-self-hosted-runner into freeform, the only template that offers it (same reason as sync-claude-module: push only bundles the given --directory)
@rm -rf freeform/modules/claude-self-hosted-runner
@mkdir -p freeform/modules
@cp -r modules/claude-self-hosted-runner freeform/modules/claude-self-hosted-runner
@echo "Synced modules/claude-self-hosted-runner into: freeform"

.PHONY: sync-vscode-extensions
sync-vscode-extensions: ## Vendor shared/vscode-extensions.tf into each template dir (same reason as sync-claude-module: push only bundles the given --directory)
@for t in $(TEMPLATES); do \
Expand Down Expand Up @@ -146,7 +153,7 @@ sync-ssh-agent-setup: ## Vendor shared/ssh-agent-setup.sh into each template dir
@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 sync-ssh-agent-setup ## Vendor all shared Terraform assets (modules + vscode-extensions.tf) into each template dir
sync-shared: sync-claude-module sync-claude-runner-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
23 changes: 23 additions & 0 deletions docs/user/using-workspaces.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,29 @@ chmod +x ~/.coder-startup.sh
- It runs non-interactively, so `~/.bashrc` is not sourced. `PATH` includes `~/.local/bin`, `~/.npm-global/bin` and Homebrew (`/home/linuxbrew/.linuxbrew/bin`). Export anything else the hook needs inside the script.
- It runs on every start, so make it safe to run more than once, e.g. check whether a process is already running before starting it.

### Running a Claude Code Self-Hosted Runner

In a `freeform` workspace, turn on **Run a Claude Code self-hosted runner** when creating or editing it to have it run a Claude Code self-hosted runner. Sessions you start on claude.ai/code in that runner's environment then run in this workspace, with its Docker and DDEV.

One-time setup:

1. On claude.ai, create a self-hosted environment and copy its environment secret.
2. In the workspace, save it:

```bash
mkdir -p ~/.claude-runner
cat > ~/.claude-runner/environment-secret # paste, then Ctrl-D
chmod 600 ~/.claude-runner/environment-secret
```

3. Restart the workspace.

On every start the runners run in the tmux session `claude-runner` (`tmux attach -t claude-runner`), one window per runner, logging to `/tmp/claude-runner-<n>.log`. Runner `<n>` clones session repositories under `~/workspace/runner-<n>`.

- **Use a workspace only for the runner.** The runner deletes and rewrites `~/.gitconfig` and `~/.config/git` before every session, so the template puts the sessions' git identity (`Claude <noreply@anthropic.com>`) and ignore list in `/etc/gitconfig` instead. The option refuses to start the runner when Claude Code remote control is also on.
- **Finding a session's work.** Run `claude-sessions` in the workspace to list live sessions with their checkout, branch and the number of commits not on any remote.
- **Concurrent sessions.** "Claude Code runner: concurrent sessions" (1 to 4) sets how many sessions run at once. The template starts one runner per session, because a runner using Anthropic-managed git takes only one session. The sessions share one Docker daemon and one DDEV install, so the template installs the skill `~/.claude/skills/coder-shared-workspace`, which tells sessions how to stay out of each other's way: separate checkouts, a DDEV global directory per session (`DDEV_XDG_CONFIG_HOME`), no `ddev poweroff`, and a lock around test suites that take over Docker. For full isolation, use several workspaces with one session each instead; the same environment secret works in all of them.

### Ending Your Day

```bash
Expand Down
96 changes: 96 additions & 0 deletions freeform/modules/claude-self-hosted-runner/files/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
---
name: coder-shared-workspace
description: Rules for sharing this Coder workspace, its Docker daemon and its DDEV install with other Claude sessions that the self-hosted runner runs at the same time. Read before running ddev, docker, or a repository's integration tests, before cleaning up disk, and before working in a checkout another session may be using.
---

# Sharing this workspace with other sessions

This workspace runs a Claude Code self-hosted runner that can host several
sessions at once. They all run as the same Unix user, with the same home
directory, one Docker daemon and one DDEV global configuration. Nothing
isolates them except the rules below.

## See who else is here

```bash
pgrep -af -- '--sdk-url' | grep -o 'sessions/cse_[A-Za-z0-9]*' | sort -u
pgrep -af -- '--sdk-url' | grep -o -- '--add-dir [^ ]*' | sort | uniq -c
```

The first lists active sessions; the second shows which directories they
work in. If you are the only session, the rest of this file still applies,
since another session can start at any time.

## Repositories and checkouts

- Each runner clones into its own directory, `~/workspace/runner-<n>/`, so
your checkout is yours. Never change files, branches or git state under
another runner's directory.
- If you need a second checkout of your own repository, use a worktree
next to it, not a new clone:
`git worktree add ../<repo>-<your-branch> -b <your-branch> <base>`.
Remove it with `git worktree remove` when you are done.
- Put scratch files in `~/tmp/<your session id>/`, not directly in `~/tmp`.

## Docker and DDEV

There is one Docker daemon. A command that affects every container affects
every session.

- **Never** run these unless the user asks for them:
`ddev poweroff`, `ddev stop --all`, `ddev delete images`,
`ddev clean`, `docker system prune`, `docker volume prune`,
`docker rm -f $(docker ps -aq)`, or anything that restarts dockerd.
Stop, restart or delete only projects you started.
- Give your session its own DDEV global directory with
[`DDEV_XDG_CONFIG_HOME`](https://docs.ddev.com/en/stable/users/usage/architecture/#global-files),
seeded from the workspace's, so your project list and global settings
are yours:

```bash
export DDEV_XDG_CONFIG_HOME=~/tmp/<your session id>/config
mkdir -p "$DDEV_XDG_CONFIG_HOME/ddev"
cp -r ~/.ddev/global_config.yaml ~/.ddev/commands "$DDEV_XDG_CONFIG_HOME/ddev/"
```

Shell state does not carry over between your commands, so export the
variable in every command that runs `ddev`, including test runs. Keep the
router settings copied from `~/.ddev` unchanged: the router and
`ddev-ssh-agent` containers are shared by every session. Never edit
`~/.ddev/global_config.yaml` itself.
- Project names are still global, because Docker container names are.
Give throwaway projects names no other session will pick, such as
`<purpose>-<first 8 characters of your session id>`, and delete them with
`ddev delete -Oy <name>` when done. The names registered in the workspace
(`$CODER_PROJECT_NAMES`) are the only ones reachable from a browser. Each
session's `ddev list` shows only its own projects, so list every running
one with `docker ps --format '{{.Label "com.ddev.site-name"}}' | grep . | sort -u`,
and ask before taking a name another session is using.
- Do not set fixed host ports (`host_https_port`, `host_webserver_port`,
`host_db_port`) in a project. Let DDEV choose, so two projects never
collide. Pick free ports for any other server you run.
- A project you started may be stopped by another session's tests (see
below). If it is, `ddev start` it again.

## Tests that take over Docker

Some test suites start and power off DDEV projects on their own, for
example the integration tests of `ddev/ddev` (`make testpkg`, `make testcmd`,
`go test ./pkg/ddevapp/...`), which run `ddev poweroff`. Run those only
while holding the workspace-wide Docker lock, so two of them never run at
the same time:

```bash
flock --exclusive ~/.cache/coder-docker.lock make testpkg TESTARGS="-run TestName"
```

`flock` waits until the lock is free. Tell the user when you have been
waiting more than a few minutes, rather than waiting silently. Code that
only compiles or runs unit tests that do not touch Docker needs no lock.

## Disk

The disk is shared. When it fills up, stop what you started and delete
only what you created (build output, your worktrees, your `~/tmp`
directory, your test projects). Never delete another session's files, and
never prune Docker images or volumes another session may use.
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
#!/usr/bin/env bash
# Keep Claude Code self-hosted runner number $1 running; it exits on its own
# when --release-idle-session-min leaves it with no sessions.
# --use-anthropic-git-proxy requires --capacity 1, so concurrent sessions come
# from several runners, each with its own clones and its own global git config.
set -u
n="$1"
base="${base_dir}/runner-$n"
mkdir -p "$base"
export PATH="$HOME/.local/bin:$HOME/.npm-global/bin:/home/linuxbrew/.linuxbrew/bin:$PATH"
export GIT_CONFIG_GLOBAL="$base/.gitconfig"
while true; do
started=$(date +%s)
/home/linuxbrew/.linuxbrew/bin/claude self-hosted-runner \
--environment-secret-file "${secret_file}" \
--base-dir "$base" \
--capacity 1 \
--client-label "$CODER_WORKSPACE_NAME-$n" \
--use-anthropic-git-proxy \
--release-idle-session-min 30 \
--kill-session-after-min 480 \
--health-port 0 \
--log-file "/tmp/claude-runner-$n.log"
rc=$?
# A runner that fails at once (a revoked secret, no network) would otherwise retry every 5s.
delay=5
[ $(( $(date +%s) - started )) -lt 60 ] && delay=60
echo "$(date): runner $n exited ($rc); restarting in $${delay}s"
sleep "$delay"
done
13 changes: 13 additions & 0 deletions freeform/modules/claude-self-hosted-runner/files/claude-sessions
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
#!/usr/bin/env bash
# List the runner's live sessions: id, checkout, branch, and commits on no remote
# branch, so a person can find a session's work without asking it.
set -u
pgrep -af -- '--sdk-url' | while read -r _ cmd; do
id=$(grep -o 'sessions/cse_[A-Za-z0-9]*' <<<"$cmd" | head -1 | cut -d/ -f2)
for dir in $(grep -o -- '--add-dir [^ ]*' <<<"$cmd" | cut -d' ' -f2 | grep -v '/_sessions/'); do
git -C "$dir" rev-parse --git-dir >/dev/null 2>&1 || continue
branch=$(git -C "$dir" branch --show-current)
ahead=$(git -C "$dir" rev-list --count HEAD --not --remotes)
printf '%s %s %s unpushed: %s\n' "$id" "$dir" "$branch" "$ahead"
done
done | sort -u
8 changes: 8 additions & 0 deletions freeform/modules/claude-self-hosted-runner/files/gitconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# Installed by the claude-self-hosted-runner module. The runner deletes
# ~/.gitconfig and ~/.config/git before every session, so session git
# settings live here instead.
[user]
name = Claude
email = noreply@anthropic.com
[core]
excludesFile = /etc/gitignore-claude-runner
4 changes: 4 additions & 0 deletions freeform/modules/claude-self-hosted-runner/files/gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Installed by the claude-self-hosted-runner module.
config.coder.yaml
docker-compose.coder-describe.yaml
.claude/settings.local.json
43 changes: 43 additions & 0 deletions freeform/modules/claude-self-hosted-runner/main.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
data "coder_parameter" "enable_claude_runner" {
name = "enable_claude_runner"
display_name = "Run a Claude Code self-hosted runner"
description = <<-EOT
Start a Claude Code self-hosted runner on every workspace start, so sessions you start on claude.ai/code in that environment run in this workspace.

**One-time setup:** create a self-hosted environment on claude.ai, then save its environment secret in the workspace as `~/.claude-runner/environment-secret` (mode 600). Until that file exists, the runner doesn't start and the startup log says why.

**Use a workspace dedicated to the runner.** The runner deletes and rewrites `~/.gitconfig` and `~/.config/git` before every session. It can't be enabled together with Claude Code remote control.
EOT

type = "bool"
form_type = "switch"
default = "false"
mutable = true
order = var.parameter_order
}

data "coder_parameter" "claude_runner_capacity" {
name = "claude_runner_capacity"
display_name = "Claude Code runner: concurrent sessions"
description = "How many sessions this workspace hosts at once, one runner each. They share this workspace's Docker daemon and DDEV; a skill installed for them says how to avoid conflicts. Only takes effect when the runner is enabled."
type = "number"
default = "1"
mutable = true
order = var.parameter_order + 1
validation {
min = 1
max = 4
}
}

locals {
# Compared as strings, not tobool(): see modules/claude-remote-control.
enabled = data.coder_parameter.enable_claude_runner.value == "true"
capacity = try(tonumber(data.coder_parameter.claude_runner_capacity.value), 1)
secret_file = "/home/coder/.claude-runner/environment-secret"

loop_script = templatefile("${path.module}/files/claude-runner-loop.sh.tftpl", {
secret_file = local.secret_file
base_dir = var.base_dir
})
}
46 changes: 46 additions & 0 deletions freeform/modules/claude-self-hosted-runner/outputs.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
output "enabled" {
description = "Whether the Claude Code self-hosted runner is enabled"
value = local.enabled
}

output "startup_script" {
description = "Bash snippet for the caller to append to its coder_agent startup_script, after the claude-remote-control snippet and before the ~/.coder-startup.sh user hook."
value = <<-EOT

# Claude Code self-hosted runner, in tmux session "claude-runner".
CLAUDE_RUNNER_ENABLED="${local.enabled}"
CLAUDE_REMOTE_CONTROL_ENABLED="${var.remote_control_enabled}"
if [ "$CLAUDE_RUNNER_ENABLED" = "true" ]; then
if [ "$CLAUDE_REMOTE_CONTROL_ENABLED" = "true" ]; then
CLAUDE_RUNNER_MSG="✗ Claude Code self-hosted runner NOT started: Claude Code remote control is also enabled, and the runner rewrites ~/.gitconfig before every session. Turn one of them off."
elif [ ! -s "${local.secret_file}" ]; then
CLAUDE_RUNNER_MSG="✗ Claude Code self-hosted runner NOT started: save your environment secret as ${local.secret_file} (chmod 600), then restart the workspace."
else
# /etc is not persistent, so reinstall the session git config on every start.
echo '${base64encode(file("${path.module}/files/gitconfig"))}' | base64 -d | sudo tee /etc/gitconfig >/dev/null
echo '${base64encode(file("${path.module}/files/gitignore"))}' | base64 -d | sudo tee /etc/gitignore-claude-runner >/dev/null
# The runner copies ~/.claude into each session when it starts.
mkdir -p ~/.claude/skills/coder-shared-workspace ~/.cache "${var.base_dir}"
echo '${base64encode(file("${path.module}/files/SKILL.md"))}' | base64 -d > ~/.claude/skills/coder-shared-workspace/SKILL.md
mkdir -p ~/.local/bin
echo '${base64encode(local.loop_script)}' | base64 -d > ~/.local/bin/claude-runner-loop
chmod +x ~/.local/bin/claude-runner-loop
echo '${base64encode(file("${path.module}/files/claude-sessions"))}' | base64 -d > ~/.local/bin/claude-sessions
chmod +x ~/.local/bin/claude-sessions
mkcert -install >/dev/null 2>&1 || true
# Anchored on the program name: this script's own bash command line contains the phrase.
if pgrep -f '^[^ ]*claude self-hosted-runner' >/dev/null || tmux has-session -t claude-runner 2>/dev/null; then
CLAUDE_RUNNER_MSG="✓ Claude Code self-hosted runners already running (tmux attach -t claude-runner)"
else
tmux new-session -d -s claude-runner -n runner-1 "$HOME/.local/bin/claude-runner-loop 1"
for i in $(seq 2 ${local.capacity}); do
tmux new-window -t claude-runner -n "runner-$i" "$HOME/.local/bin/claude-runner-loop $i"
done
CLAUDE_RUNNER_MSG="✓ ${local.capacity} Claude Code self-hosted runner(s) started (tmux attach -t claude-runner; logs /tmp/claude-runner-*.log)"
fi
fi
echo "$CLAUDE_RUNNER_MSG"
echo "$CLAUDE_RUNNER_MSG" >> ~/WELCOME.txt
fi
EOT
}
17 changes: 17 additions & 0 deletions freeform/modules/claude-self-hosted-runner/variables.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
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 = 14
}

variable "base_dir" {
description = "Directory the runner clones session repositories into"
type = string
default = "/home/coder/workspace"
}

variable "remote_control_enabled" {
description = "Whether Claude Code remote control is enabled in the same workspace; the runner refuses to start alongside it"
type = bool
default = false
}
7 changes: 7 additions & 0 deletions freeform/modules/claude-self-hosted-runner/versions.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
terraform {
required_providers {
coder = {
source = "coder/coder"
}
}
}
7 changes: 7 additions & 0 deletions freeform/template.tf
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,8 @@ BASHCOMP

${module.claude_remote_control.startup_script}

${module.claude_self_hosted_runner.startup_script}

# User startup hook: run ~/.coder-startup.sh (persistent home) on every
# workspace start, e.g. to start services or long-running processes without
# waiting for someone to open a terminal. It is detached from the agent so a
Expand Down Expand Up @@ -446,6 +448,11 @@ module "claude_remote_control" {
agent_id = coder_agent.main.id
}

module "claude_self_hosted_runner" {
source = "./modules/claude-self-hosted-runner"
remote_control_enabled = module.claude_remote_control.enabled
}

# 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.
Expand Down
Loading
Loading