Files
coder/q5vault/operations/workspaces.md
Antigravity 3478e7b913 coder: let the omp harness drive workspaces over Coders MCP server
Coder ships `coder exp mcp server` and omp is an MCP client, so the two compose
with no custom code. The agent stays on the host where its credentials already
live and reaches into the workspace, rather than shipping secrets into a
container.

- scripts/coder-mcp.sh wraps the server, passing a dedicated `omp-mcp` token via
  `docker exec --env-file` (mode 600 temp file, shredded by an EXIT trap) so the
  token never appears in argv or ps
- allow-list exposes 26 of 32 tools; template mutation is excluded because
  pushing a template is the one real path from a Coder principal to the host
  docker socket
- scripts/coder-token-rotate.sh handles the 168h token lifetime cap: mint,
  verify against the API, then revoke the predecessors
- .omp/mcp.json registers it project-scoped so 26 tools do not load into every
  unrelated omp session

The docker exec must stay in the foreground: backgrounding it makes bash
redirect stdin from /dev/null with job control disabled, and the MCP transport
dies on EOF with "MCP tool load failed ... Transport closed".

Verified: fresh omp session in this directory answered from the tools -
coder_get_authenticated_user returned loca, workspace ws1 running.
2026-08-20 17:46:51 +02:00

185 lines
14 KiB
Markdown

---
title: "📦 Templates and workspaces"
description: "Template strategy for the docker starter, the pushed docker template, the two non-interactive CLI traps that had to be solved, the first workspace build, how to reach workspace apps without a wildcard access URL, and the docker socket risk."
source: "verified deployment on the loca operator host, 2026-08-19; template push and first workspace verified 2026-08-20"
reviewed: 2026-08-20
---
# 📦 Templates and workspaces
## 🧭 Template strategy
- start from the upstream docker starter template rather than hand-rolling Terraform:
- `coder templates init --id docker` writes the starter into a directory, then edit it in place
- the starter is Terraform using the `coder/coder` provider plus the `kreuzwerker/docker` provider
- what the upstream docker starter provisions:
- `coder_agent.main` with a `startup_script` that seeds `/etc/skel` on first start, plus CPU, RAM, disk and host load metadata blocks
- `docker_volume` for the workspace home, labelled with the owner, owner id, workspace id and creation-time name
- `docker_container` from image `codercom/example-base:ubuntu`, named `coder-<owner>-<workspace>`, with `CODER_AGENT_TOKEN` in its environment and a `host.docker.internal` host entry mapped to `host-gateway`
- registry modules for `code-server` and `jetbrains` as optional IDE entry points
- local rules for this deployment:
- pin module versions rather than floating; the starter's `~> 1.0` style constraint is fine for a trial but a production template should name the version it was tested with
- never set `privileged`, never add extra host bind mounts, and never mount `/var/run/docker.sock` into a workspace container
- keep the workspace image explicit; do not use `latest`
## 🔗 coder_app must stay path-based
- every `coder_app` in a template here keeps `subdomain = false`, which is also the provider default
- upstream is explicit: if wildcards have not been set up by the administrator, apps with `subdomain = true` are not accessible
- no wildcard access URL is configured on this deployment, and the blocker is TLS issuance rather than DNS; the detail lives in [[reference/limits]]
- example shape for a path-based app:
```tf
resource "coder_app" "web" {
agent_id = coder_agent.main.id
slug = "web"
display_name = "Web app"
url = "http://localhost:3000"
subdomain = false
share = "owner"
healthcheck {
url = "http://localhost:3000/healthz"
interval = 5
threshold = 10
}
}
```
- `share = "owner"` is the right default here because this is a single-admin deployment; `authenticated` and `public` widen exposure through the same public vhost
- frameworks that insist on serving from an absolute root path (Vite, React dev server, Next.js dev server, JupyterLab, RStudio per upstream) are the ones that suffer under path-based access; run them behind their own base-path setting, or reach them with `coder port-forward` instead
## 📤 Pushing a template
The `docker` template is pushed and its version is active. Verified 2026-08-20: the push ran against the deployed provisioner, which planned 4 resources and created the template version.
A pre-validated docker template ships with this deployment at
`/home/loca/dev/coder/templates/docker`, so a re-push from an operator shell is one command once a CLI token exists:
```bash
coder login https://code.loca.zone # browser session flow, stores a CLI token
coder templates push docker -d /home/loca/dev/coder/templates/docker \
-m "initial docker template"
```
The first push was done with the CLI that already lives in the server container, staging the directory at `/tmp/tmpl` inside it, which is where the first trap below comes from:
```bash
/opt/coder templates push docker -d /tmp/tmpl -y
```
- what ships in that directory:
- `main.tf` — one `coder_app` (code-server) pinned to `subdomain = false`, a persistent `/home/coder` volume keyed to the workspace id, and no docker socket in the workspace container
- `.terraform.lock.hcl` — provider hashes generated by the provisioner's own terraform (linux_amd64), so the push is warning-free and reproducible
- `README.md` — the operator runbook, including the upstream sources each construct came from
- providers are pinned: `coder/coder ~> 2.18` and `kreuzwerker/docker ~> 4.5`, resolved to 2.18.0 and 4.5.0
- validation performed against the deployed provisioner before the push: `terraform fmt -check`, `init`, `validate` and `plan` all clean (`Plan: 6 to add`); the first real `apply` came with workspace `ws1` below, which was also the first exercise of the code-server download path
- scaffolding a fresh template instead is `coder templates init --id docker <dir>`, then edit and push the same way
- `coder templates push` creates or updates a template from the chosen directory, marking the new version active by default (`--activate` defaults to true)
- `-d`/`--directory` selects the source directory, `-m`/`--message` records the change description, `--name` names the version
- the Template Admin role and above can create templates; in this deployment that means the single Owner
- `coder templates pull`, edit, `coder templates push` is the round-trip for later edits; the dashboard can also edit template files directly
- never put credentials in a template: upstream warns that template versions are persisted indefinitely in postgres and pushing a new version does not expunge secrets from earlier ones
## 🪤 Two non-interactive CLI traps, both hit for real
| Trap | Symptom | Recipe |
| --- | --- | --- |
| `docker cp` lands files in the container owned by root, while the container process runs as `coder` | the in-container CLI dies with `open /home/coder/.config/coderv2/session: permission denied` | `chown` the staged session file and the copied template directory to `coder` before invoking the CLI, then delete both afterwards; the staged session file is a credential and must not survive the operation |
| `coder create ... -y` does not auto-answer `coder_parameter` prompts | the CLI dies with `error creating cancelreader` instead of prompting, because there is no TTY to prompt on | give every parameter explicitly on the command line: `--parameter cpu=2 --parameter memory=2` |
## 🧱 First workspace, as built
```bash
/opt/coder create ws1 --template docker --parameter cpu=2 --parameter memory=2 -y
```
- resource gate re-measured immediately before the build: swap had drained to 0 of 16383 MB and roughly 31 GB of RAM was available, so the eight-gigabyte floor in [[reference/limits]] passed rather than being assumed
- build result: `Apply complete` with 6 resources added; image `codercom/enterprise-base:ubuntu` pulled in about 1m30s; total start about 95s
- runtime state after the build: workspace `ws1` running with health true, agent `main` connected with lifecycle `ready`, app `code-server` health `healthy`, `subdomain` false
- ceilings actually applied: `cpu=2` and `memory=2` GB, with container swap pinned equal to memory, so a workspace cannot push the host back into swap
- live proof that path-based apps work with no wildcard access URL and no extra certificate: https://code.loca.zone/@loca/ws1.main/apps/code-server/ loads a full code-server workbench in a real browser and redirects to `?folder=/home/coder`
## 🚪 Reaching a workspace
- workspace app, path-based:
- `https://code.loca.zone/@<username>/<workspace>.<agent>/apps/<slug>/`
- the agent segment is optional when the workspace has a single agent, giving `/@<username>/<workspace>/apps/<slug>/`
- the trailing slash matters; the server issues a redirect when it is missing, so the dashboard always links with it
- web terminal:
- `https://code.loca.zone/@<username>/<workspace>.<agent>/terminal`
- it is a websocket-backed xterm.js session, which is exactly why `proxy_read_timeout 86400` and `proxy_buffering off` are set in the vhost
- arbitrary ports, replacing the dashboard port-forward UI that a wildcard URL would provide:
- `coder port-forward <workspace> --tcp 8000:8080` forwards remote 8080 to local 8000
- condensed syntax works too: `coder port-forward <workspace> --tcp 8080,9000:3000,9990-9999`
- `--udp` exists for UDP services, and `coder port-forward <workspace>.<agent> --tcp ...` targets a specific agent
- SSH:
- `coder ssh <workspace>` for an interactive shell, `coder ssh <workspace> -- <command>` for one-shot commands
- `coder config-ssh` writes host entries so `ssh coder.<workspace>` and any SSH-based tooling works, including `-L`/`-R` forwarding
- `coder ssh -R` provides reverse forwarding
- IDE remotes (VS Code extension, JetBrains Gateway, Coder Desktop) all ride the same CLI-established connections and need no wildcard URL
## ⚠️ Docker socket risk and containment
The risk, stated plainly: `/var/run/docker.sock` is bind-mounted into the `coder` container, so the Coder server is root-equivalent on this host. Docker documents that access to the socket grants root-level privileges, and a template author can schedule a privileged container, mount host paths, or run arbitrary `local-exec` code during a build.
- why it is accepted: this is a single-admin OSS deployment where only the Owner authors templates, and the built-in provisioner is the only way to build docker workspaces without standing up an external provisioner
- containment actually in place:
- the server image is pinned to `ghcr.io/coder/coder:v2.35.3`, not `latest`
- no open signup, so no untrusted account can reach the template APIs; see [[operations/auth-model]]
- the socket is not passed into workspace containers, so a workspace shell cannot reach the daemon
- the socket group id is read at write time with `getent group docker | cut -d: -f3` and pinned as `group_add: "105"` rather than guessed
- `mem_limit` bounds both containers, so a runaway build cannot swallow the host; see [[reference/limits]]
- treat Template Admin as equivalent to host root and keep it to the single operator account
## 🤖 Driving workspaces from the omp harness (MCP)
Coder ships its own MCP server, and the `omp` harness is an MCP client, so the two compose natively. This is the deliberate direction of the integration: the agent runs on the host where its credentials already live and reaches into the workspace, rather than installing an agent inside the container and shipping secrets to it.
Wiring:
- server: `coder exp mcp server`, run inside the `coder` container, speaking MCP over stdio. Confirmed `serverInfo {name: "Coder Agent", version: "v2.35.3+65e2bfb"}`
- wrapper: `scripts/coder-mcp.sh`, which supplies credentials and the tool allow-list
- registration: `/home/loca/dev/coder/.omp/mcp.json`, project-scoped on purpose. The server exposes 26 tools; loading those into every unrelated omp session would waste context, so it only mounts when the working directory is this project
```json
{
"mcpServers": {
"coder": { "command": "/home/loca/dev/coder/scripts/coder-mcp.sh", "args": [], "disabled": false }
}
}
```
Credentials:
- a dedicated Coder token named `omp-mcp`, not the operator session token, so it can be revoked alone. `.mcp-token`, mode 600, gitignored
- this deployment caps token lifetime at 168h (7d); `--lifetime 30d` is rejected outright. Rotate weekly with `scripts/coder-token-rotate.sh`, which mints the replacement, verifies it with a real API call, and only then revokes the previous ones
- the token is handed to the container through `docker exec --env-file` with a mode-600 temp file that an EXIT trap shreds. It is never an argument, so it never appears in `ps` or in an agent transcript
Tool allow-list, and why:
- 26 of 32 tools are exposed via `--allowed-tools`
- excluded: `coder_create_template`, `coder_create_template_version`, `coder_update_template_active_version`, `coder_delete_template`, `coder_delete_task`, `coder_upload_tar_file`
- rationale: pushing a template is the one real path from a Coder principal to the host docker socket, as described in the section above. Template mutation stays a human action; the agent gets read-only template inspection plus full workspace access. Verified: a `tools/list` through the wrapper returns 26 names and none of the excluded ones
- kept: `coder_workspace_bash`, `coder_workspace_read_file` / `write_file` / `edit_file` / `edit_files` / `ls`, `coder_workspace_list_apps`, `coder_workspace_port_forward`, agent and build logs, workspace create plus `coder_create_workspace_build` for start/stop/delete, the task tools, and `search` / `fetch`
Wrapper trap worth knowing: the `docker exec` must run in the foreground. Backgrounding it with `&` plus `wait` looks harmless but breaks the server, because with job control disabled bash redirects an async command's stdin from `/dev/null`; the MCP stdio transport then sees EOF immediately and the client logs `MCP tool load failed ... Transport closed` with no tools registered.
Verified end to end 2026-08-20: a fresh `omp` session started in `/home/loca/dev/coder` mounted the tools and answered from them — `coder_get_authenticated_user` returned `loca`, `coder_list_workspaces` plus `coder_get_workspace` returned `ws1` `running`.
## 📚 Sources
- https://github.com/coder/coder/blob/main/examples/templates/docker/main.tf
- https://coder.com/docs/admin/templates/managing-templates
- https://coder.com/docs/reference/cli/templates_init
- https://coder.com/docs/reference/cli/templates_push
- https://coder.com/docs/reference/cli/port-forward
- https://coder.com/docs/reference/cli/ssh
- https://coder.com/docs/user-guides/workspace-access
- https://coder.com/docs/admin/networking/wildcard-access-url
- https://coder.com/docs/tutorials/best-practices/security-best-practices
- https://registry.terraform.io/providers/coder/coder/latest/docs/resources/app
- https://github.com/coder/coder/blob/main/coderd/workspaceapps/appurl/appurl.go
- https://github.com/coder/coder/blob/main/site/src/modules/apps/apps.ts
- https://docs.docker.com/engine/install/linux-postinstall/