From 3478e7b913cfcc79370806b938497f4341c84609 Mon Sep 17 00:00:00 2001 From: Antigravity Date: Thu, 20 Aug 2026 17:46:51 +0200 Subject: [PATCH] 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. --- .gitignore | 1 + .omp/mcp.json | 10 +++++ q5vault/operations/index.md | 2 +- q5vault/operations/verification.md | 16 ++++++++ q5vault/operations/workspaces.md | 35 ++++++++++++++++ q5vault/reference/config-paths.md | 7 +++- scripts/coder-mcp.sh | 66 ++++++++++++++++++++++++++++++ scripts/coder-token-rotate.sh | 50 ++++++++++++++++++++++ 8 files changed, 185 insertions(+), 2 deletions(-) create mode 100644 .omp/mcp.json create mode 100755 scripts/coder-mcp.sh create mode 100755 scripts/coder-token-rotate.sh diff --git a/.gitignore b/.gitignore index 83fb8d1..3b7be98 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .env .admin-credentials +.mcp-token q5vault/.obsidian/ # terraform working dirs; the committed .terraform.lock.hcl is intentional diff --git a/.omp/mcp.json b/.omp/mcp.json new file mode 100644 index 0000000..d99526b --- /dev/null +++ b/.omp/mcp.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://raw.githubusercontent.com/can1357/oh-my-pi/main/packages/coding-agent/src/config/mcp-schema.json", + "mcpServers": { + "coder": { + "command": "/home/loca/dev/coder/scripts/coder-mcp.sh", + "args": [], + "disabled": false + } + } +} diff --git a/q5vault/operations/index.md b/q5vault/operations/index.md index d49edd4..ca1bed4 100644 --- a/q5vault/operations/index.md +++ b/q5vault/operations/index.md @@ -15,7 +15,7 @@ Every command on these pages runs from `/home/loca/dev/coder` and every docker i | --- | --- | | Stack is down, needs starting, or a new image tag is out | [[operations/install-upgrade]] | | Someone asks why this host is not behind Authelia, or how the Owner account was bootstrapped | [[operations/auth-model]] | -| A template needs writing or pushing, or a workspace app must be reached | [[operations/workspaces]] | +| A template needs writing or pushing, a workspace app must be reached, or the omp harness should drive workspaces over MCP | [[operations/workspaces]] | | Proving the deployment is healthy after any change | [[operations/verification]] | | Looking up a path, container, volume, port or env var | [[reference/config-paths]] | | Explaining a missing feature | [[reference/limits]] | diff --git a/q5vault/operations/verification.md b/q5vault/operations/verification.md index a488177..62f6cb9 100644 --- a/q5vault/operations/verification.md +++ b/q5vault/operations/verification.md @@ -164,6 +164,22 @@ sudo -n gunzip -t $D/coder-db.sql.gz && sudo -n ls -l $D - artifacts are root-owned; retention keeps 14 dated directories - `.env`, `.admin-credentials` and `.ai-credentials` are deliberately excluded — they are keys, not data; verify with `sudo -n tar tzf $D/stack-configs.tgz | grep -c credentials` returning `0` +## 1️⃣5️⃣ omp harness reaches Coder over MCP + +```bash +printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"probe","version":"1"}}}' \ + | /home/loca/dev/coder/scripts/coder-mcp.sh | head -1 +ls /tmp/tmp.* 2>/dev/null | wc -l +cd /home/loca/dev/coder && omp -p 'Call coder_get_authenticated_user and coder_list_workspaces. Report the username and each workspace name plus status.' +``` + +- expect the first line to contain `"serverInfo":{"name":"Coder Agent","version":"v2.35.3+..."}` +- expect the temp-file count to be unchanged, proving the wrapper's EXIT trap shredded the mode-600 env file holding the token +- expect the harness to answer from tools, not guesses: username `loca`, workspace `ws1` `running` +- if the harness reports no tools, check `~/.omp/logs/omp...log` for `MCP tool load failed ... Transport closed`, which means the wrapper backgrounded the server and its stdin was redirected from `/dev/null` +- token expiry is the most likely 401 cause: the `omp-mcp` token lasts 168h maximum. Rotate with `scripts/coder-token-rotate.sh` and confirm with `sudo -n docker exec coder /opt/coder tokens list` +- 26 of 32 tools are exposed by design; template mutation is excluded. Confirm with a `tools/list` request and check that no `coder_create_template*`, `coder_update_template_active_version`, `coder_delete_template`, `coder_delete_task` or `coder_upload_tar_file` appears + ## 🧹 Pre-publish wiki lint gate Run this before every `build.sh` invocation, so before item 7 above. It is a gate, not a smoke test: it exits non-zero and publishes nothing. diff --git a/q5vault/operations/workspaces.md b/q5vault/operations/workspaces.md index daf30a4..e08969b 100644 --- a/q5vault/operations/workspaces.md +++ b/q5vault/operations/workspaces.md @@ -132,6 +132,41 @@ The risk, stated plainly: `/var/run/docker.sock` is bind-mounted into the `coder - `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 diff --git a/q5vault/reference/config-paths.md b/q5vault/reference/config-paths.md index f149408..2a0632b 100644 --- a/q5vault/reference/config-paths.md +++ b/q5vault/reference/config-paths.md @@ -14,7 +14,12 @@ reviewed: 2026-08-19 | `/home/loca/dev/coder/` | stack root; compose project name `coder` derives from this directory name | `loca`; run every compose command from here | | `/home/loca/dev/coder/docker-compose.yml` | the whole deployment definition: images, pins, ports, env, volumes, memory ceilings | `loca`; edit here, never with `docker run` flags | | `/home/loca/dev/coder/.env` | supplies `CODER_PG_PASSWORD` to compose interpolation | `loca`, mode 600, git-ignored; contents are never read into or quoted by any document | -| `/home/loca/dev/coder/.gitignore` | ignores `.env`, `q5vault/.obsidian/`, and terraform working state under `templates/` | `loca`; keeps the secret, editor state and tfstate out of version control while the provider lockfile stays committed | +| `/home/loca/dev/coder/.mcp-token` | dedicated Coder token named `omp-mcp`, consumed by `scripts/coder-mcp.sh` so the omp harness can call Coder's MCP tools | `loca`, mode 600, git-ignored; 168h maximum lifetime, rotate with `scripts/coder-token-rotate.sh` | +| `/home/loca/dev/coder/.omp/mcp.json` | project-scoped MCP registration; mounts the `coder` server only when omp runs in this directory | `loca`; holds no secret, the token is read by the wrapper | +| `/home/loca/dev/coder/scripts/coder-mcp.sh` | wraps `coder exp mcp server` with `docker exec --env-file` credential passing and a 26-tool allow-list that excludes template mutation | `loca`, mode 755; must run the docker exec in the foreground or the MCP transport dies on EOF | +| `/home/loca/dev/coder/scripts/coder-token-rotate.sh` | mints a replacement `omp-mcp` token, verifies it against the API, then revokes the previous ones | `loca`, mode 755; run weekly | +| `/home/loca/dev/coder/scripts/backup-stacks.sh` | nightly backup driver for both stacks, invoked by `stacks-backup.service` | `loca`, mode 755; runs as root from the timer, so no `sudo` inside | +| `/home/loca/dev/coder/.gitignore` | ignores `.env`, `.admin-credentials`, `.mcp-token`, `q5vault/.obsidian/`, and terraform working state under `templates/` | `loca`; keeps secrets, editor state and tfstate out of version control while the provider lockfile stays committed | | `/home/loca/dev/coder/config/runtime-version.txt` | plain-text pin, currently `v2.35.3`; the human-readable twin of the image tag | `loca`; changes in the same commit as the tag | | `/home/loca/dev/coder/deploy/code.nginx.conf` | source of truth for the four nginx server blocks | `loca`; the installed site file is a copy, so edit here first | | `/home/loca/dev/coder/templates/docker/main.tf` | pre-validated docker workspace template; one `coder_app` at `subdomain = false`, persistent `/home/coder` volume, no docker socket in the workspace | `loca`; pushed by the operator with `coder templates push` | diff --git a/scripts/coder-mcp.sh b/scripts/coder-mcp.sh new file mode 100755 index 0000000..3cbe760 --- /dev/null +++ b/scripts/coder-mcp.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# Coder MCP server, wrapped for the omp harness. +# +# Speaks MCP over stdio, so it is spawned as a child process by an MCP client +# (omp registers it in .omp/mcp.json). It gives the agent Coder's own tool set: +# workspace lifecycle, in-workspace bash/file operations, task management and +# read-only template inspection. +# +# Credential handling: the token is written to a mode-600 temp file and handed +# to `docker exec --env-file`, never passed as an argument. That keeps it out of +# argv, out of `ps`, and out of any agent transcript. The file is removed when +# this script exits. +# +# The token is a dedicated, revocable Coder token named `omp-mcp`, not the +# operator session token. Rotate with: +# scripts/coder-token-rotate.sh +# List/revoke with: +# sudo -n docker exec coder /opt/coder tokens list +# sudo -n docker exec coder /opt/coder tokens remove +set -euo pipefail + +TOKEN_FILE="/home/loca/dev/coder/.mcp-token" +CRED="/home/loca/dev/coder/.admin-credentials" +CONTAINER="coder" + +[[ -r "$TOKEN_FILE" ]] || { echo "missing token file: $TOKEN_FILE" >&2; exit 1; } +[[ -r "$CRED" ]] || { echo "missing credentials file: $CRED" >&2; exit 1; } + +url="$(sed -n 's/^url=//p' "$CRED")" +[[ -n "$url" ]] || { echo "no url= line in $CRED" >&2; exit 1; } + +ENVF="$(mktemp)" +chmod 600 "$ENVF" +trap 'rm -f "$ENVF"' EXIT + +{ + printf 'CODER_URL=%s\n' "$url" + printf 'CODER_SESSION_TOKEN=%s\n' "$(tr -d '[:space:]' <"$TOKEN_FILE")" +} >"$ENVF" + +# Tool allow-list. Template mutation is deliberately excluded: pushing a +# template is the one identified path from a Coder principal to the host docker +# socket, so the agent gets read-only template access and full workspace access. +# Excluded on purpose: coder_create_template, coder_create_template_version, +# coder_update_template_active_version, coder_delete_template, +# coder_delete_task, coder_upload_tar_file. +ALLOWED="coder_get_authenticated_user,\ +coder_list_workspaces,coder_get_workspace,coder_create_workspace,coder_create_workspace_build,\ +coder_workspace_bash,coder_workspace_ls,coder_workspace_read_file,coder_workspace_write_file,\ +coder_workspace_edit_file,coder_workspace_edit_files,coder_workspace_list_apps,\ +coder_workspace_port_forward,coder_get_workspace_agent_logs,coder_get_workspace_build_logs,\ +coder_list_templates,coder_get_template,coder_template_version_parameters,\ +coder_get_template_version_logs,\ +coder_create_task,coder_list_tasks,coder_get_task_status,coder_get_task_logs,\ +coder_send_task_input,\ +search,fetch" + +# Foreground on purpose. Backgrounding this (`&` + `wait`) breaks the server: +# with job control disabled, bash redirects an async command's stdin from +# /dev/null, so the MCP stdio transport sees EOF immediately and the client +# reports "MCP tool load failed: Transport closed". Running in the foreground +# keeps the client's stdin pipe attached; the child shares this process group, +# so group-directed signals reach it directly, and the EXIT trap still shreds +# the env file once it exits (including on stdin close). +sudo -n docker exec -i --env-file "$ENVF" "$CONTAINER" \ + /opt/coder exp mcp server --allowed-tools "$ALLOWED" diff --git a/scripts/coder-token-rotate.sh b/scripts/coder-token-rotate.sh new file mode 100755 index 0000000..864476b --- /dev/null +++ b/scripts/coder-token-rotate.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# Rotate the dedicated Coder token used by the omp MCP integration. +# +# Coder caps token lifetime at 168h (7d) on this deployment, so this must be run +# weekly or the agent's Coder tools start returning 401. +# +# Mints a fresh `omp-mcp` token, writes it to .mcp-token (mode 600), then +# revokes every older token of the same name. The value is never printed. +set -euo pipefail + +ROOT="/home/loca/dev/coder" +CRED="$ROOT/.admin-credentials" +TOKEN_FILE="$ROOT/.mcp-token" +CONTAINER="coder" +NAME="omp-mcp" +LIFETIME="168h" + +[[ -r "$CRED" ]] || { echo "missing credentials file: $CRED" >&2; exit 1; } + +url="$(sed -n 's/^url=//p' "$CRED")" +session="$(sed -n 's/^session_token=//p' "$CRED")" +[[ -n "$url" && -n "$session" ]] || { echo "url= or session_token= missing in $CRED" >&2; exit 1; } + +ENVF="$(mktemp)" +chmod 600 "$ENVF" +trap 'rm -f "$ENVF"' EXIT +printf 'CODER_URL=%s\nCODER_SESSION_TOKEN=%s\n' "$url" "$session" >"$ENVF" + +coder() { sudo -n docker exec -i --env-file "$ENVF" "$CONTAINER" /opt/coder "$@"; } + +# Existing token ids for this name, captured before minting the replacement. +old_ids="$(coder tokens list | awk -v n="$NAME" 'NR>1 && $2==n {print $1}')" + +new="$(coder tokens create --name "$NAME" --lifetime "$LIFETIME" | tail -n1 | tr -d '[:space:]')" +[[ -n "$new" ]] || { echo "token creation produced no value" >&2; exit 1; } + +umask 077 +printf '%s\n' "$new" >"$TOKEN_FILE" +chmod 600 "$TOKEN_FILE" + +# Verify before revoking the old ones, so a bad mint cannot lock us out. +code="$(curl -s -o /dev/null -w '%{http_code}' -H "Coder-Session-Token: $new" "$url/api/v2/users/me")" +[[ "$code" == "200" ]] || { echo "new token failed verification (http $code); old tokens left intact" >&2; exit 1; } + +for id in $old_ids; do + coder tokens remove "$id" >/dev/null && echo "revoked old token $id" +done + +echo "rotated $NAME; expires in $LIFETIME" +coder tokens list