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