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

14 KiB

title, description, source, reviewed
title description source reviewed
📦 Templates and workspaces 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. verified deployment on the loca operator host, 2026-08-19; template push and first workspace verified 2026-08-20 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:
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:

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:

/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

/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
{
  "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